diff --git a/README.md b/README.md
index 80eed622f..646b064e9 100644
--- a/README.md
+++ b/README.md
@@ -53,195 +53,195 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns).
diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go
index 80f9ab243..2e8822501 100644
--- a/cmd/zz_gen_cmd_dnshelp.go
+++ b/cmd/zz_gen_cmd_dnshelp.go
@@ -14,6 +14,7 @@ func allDNSCodes() string {
providers := []string{
"manual",
"acme-dns",
+ "active24",
"alidns",
"allinkl",
"arvancloud",
@@ -191,6 +192,27 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/acme-dns`)
+ case "active24":
+ // generated from: providers/dns/active24/active24.toml
+ ew.writeln(`Configuration for Active24.`)
+ ew.writeln(`Code: 'active24'`)
+ ew.writeln(`Since: 'v4.23.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "ACTIVE24_API_KEY": API key`)
+ ew.writeln(` - "ACTIVE24_SECRET": Secret`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "ACTIVE24_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "ACTIVE24_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`)
+ ew.writeln(` - "ACTIVE24_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`)
+ ew.writeln(` - "ACTIVE24_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/active24`)
+
case "alidns":
// generated from: providers/dns/alidns/alidns.toml
ew.writeln(`Configuration for Alibaba Cloud DNS.`)
diff --git a/docs/content/dns/zz_gen_active24.md b/docs/content/dns/zz_gen_active24.md
new file mode 100644
index 000000000..cadc6660c
--- /dev/null
+++ b/docs/content/dns/zz_gen_active24.md
@@ -0,0 +1,69 @@
+---
+title: "Active24"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: active24
+dnsprovider:
+ since: "v4.23.0"
+ code: "active24"
+ url: "https://www.active24.cz"
+---
+
+
+
+
+
+
+Configuration for [Active24](https://www.active24.cz).
+
+
+
+
+- Code: `active24`
+- Since: v4.23.0
+
+
+Here is an example bash command using the Active24 provider:
+
+```bash
+ACTIVE24_API_KEY="xxx" \
+ACTIVE24_SECRET="yyy" \
+lego --email you@example.com --dns active24 -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `ACTIVE24_API_KEY` | API key |
+| `ACTIVE24_SECRET` | Secret |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+## Additional Configuration
+
+| Environment Variable Name | Description |
+|--------------------------------|-------------|
+| `ACTIVE24_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `ACTIVE24_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) |
+| `ACTIVE24_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) |
+| `ACTIVE24_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+The environment variable names can be suffixed by `_FILE` to reference a file instead of a value.
+More information [here]({{% ref "dns#configuration-and-credentials" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://rest.active24.cz/v2/docs)
+
+
+
+
diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml
index 1cbfdbf4e..da083666f 100644
--- a/docs/data/zz_cli_help.toml
+++ b/docs/data/zz_cli_help.toml
@@ -149,7 +149,7 @@ To display the documentation for a specific DNS provider, run:
$ lego dnshelp -c code
Supported DNS providers:
- acme-dns, alidns, allinkl, arvancloud, auroradns, autodns, azure, azuredns, bindman, bluecat, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, conoha, constellix, corenetworks, cpanel, derak, desec, designate, digitalocean, directadmin, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dynu, easydns, edgedns, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, glesys, godaddy, googledomains, hetzner, hostingde, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ipv64, iwantmyname, joker, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, netcup, netlify, nicmanager, nifcloud, njalla, nodion, ns1, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, spaceship, stackpath, technitium, tencentcloud, timewebcloud, transip, ultradns, variomedia, vegadns, vercel, versio, vinyldns, vkcloud, volcengine, vscale, vultr, webnames, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneee, zonomi
+ acme-dns, active24, alidns, allinkl, arvancloud, auroradns, autodns, azure, azuredns, bindman, bluecat, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, conoha, constellix, corenetworks, cpanel, derak, desec, designate, digitalocean, directadmin, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dynu, easydns, edgedns, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, glesys, godaddy, googledomains, hetzner, hostingde, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ipv64, iwantmyname, joker, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, netcup, netlify, nicmanager, nifcloud, njalla, nodion, ns1, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, spaceship, stackpath, technitium, tencentcloud, timewebcloud, transip, ultradns, variomedia, vegadns, vercel, versio, vinyldns, vkcloud, volcengine, vscale, vultr, webnames, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneee, zonomi
More information: https://go-acme.github.io/lego/dns
"""
diff --git a/providers/dns/active24/active24.go b/providers/dns/active24/active24.go
new file mode 100644
index 000000000..5f146d66e
--- /dev/null
+++ b/providers/dns/active24/active24.go
@@ -0,0 +1,216 @@
+// Package active24 implements a DNS provider for solving the DNS-01 challenge using Active24.
+package active24
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/active24/internal"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "ACTIVE24_"
+
+ EnvAPIKey = envNamespace + "API_KEY"
+ EnvSecret = envNamespace + "SECRET"
+
+ EnvTTL = envNamespace + "TTL"
+ EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
+ EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
+ EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
+)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+ APIKey string
+ Secret string
+
+ PropagationTimeout time.Duration
+ PollingInterval time.Duration
+ TTL int
+ HTTPClient *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider.
+func NewDefaultConfig() *Config {
+ return &Config{
+ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Active24.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvAPIKey, EnvSecret)
+ if err != nil {
+ return nil, fmt.Errorf("active24: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.APIKey = values[EnvAPIKey]
+ config.Secret = values[EnvSecret]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for Active24.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("active24: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.APIKey, config.Secret)
+ if err != nil {
+ return nil, fmt.Errorf("active24: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ ctx := context.Background()
+
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("active24: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("active24: %w", err)
+ }
+
+ serviceID, err := d.findServiceID(ctx, dns01.UnFqdn(authZone))
+ if err != nil {
+ return fmt.Errorf("active24: find service ID: %w", err)
+ }
+
+ record := internal.Record{
+ Type: "TXT",
+ Name: subDomain,
+ Content: info.Value,
+ TTL: d.config.TTL,
+ }
+
+ err = d.client.CreateRecord(ctx, strconv.Itoa(serviceID), record)
+ if err != nil {
+ return fmt.Errorf("active24: create record: %w", err)
+ }
+
+ return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ ctx := context.Background()
+
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("active24: could not find zone for domain %q: %w", domain, err)
+ }
+
+ serviceID, err := d.findServiceID(ctx, dns01.UnFqdn(authZone))
+ if err != nil {
+ return fmt.Errorf("active24: find service ID: %w", err)
+ }
+
+ recordID, err := d.findRecordID(ctx, strconv.Itoa(serviceID), info)
+ if err != nil {
+ return fmt.Errorf("active24: find record ID: %w", err)
+ }
+
+ err = d.client.DeleteRecord(ctx, strconv.Itoa(serviceID), strconv.Itoa(recordID))
+ if err != nil {
+ return fmt.Errorf("active24: delete record %w", err)
+ }
+
+ return nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+// Adjusting here to cope with spikes in propagation times.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+ return d.config.PropagationTimeout, d.config.PollingInterval
+}
+
+func (d *DNSProvider) findServiceID(ctx context.Context, domain string) (int, error) {
+ services, err := d.client.GetServices(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("get services: %w", err)
+ }
+
+ for _, service := range services {
+ if service.ServiceName != "domain" {
+ continue
+ }
+
+ if service.Name != domain {
+ continue
+ }
+
+ return service.ID, nil
+ }
+
+ return 0, fmt.Errorf("service not found for domain: %s", domain)
+}
+
+func (d *DNSProvider) findRecordID(ctx context.Context, serviceID string, info dns01.ChallengeInfo) (int, error) {
+ // NOTE(ldez): Despite the API documentation, the filter doesn't seem to work.
+ filter := internal.RecordFilter{
+ Name: dns01.UnFqdn(info.EffectiveFQDN),
+ Type: []string{"TXT"},
+ Content: info.Value,
+ }
+
+ records, err := d.client.GetRecords(ctx, serviceID, filter)
+ if err != nil {
+ return 0, fmt.Errorf("get records: %w", err)
+ }
+
+ for _, record := range records {
+ if record.Type != "TXT" {
+ continue
+ }
+
+ if record.Name != dns01.UnFqdn(info.EffectiveFQDN) {
+ continue
+ }
+
+ if record.Content != info.Value {
+ continue
+ }
+
+ return record.ID, nil
+ }
+
+ return 0, errors.New("no record found")
+}
diff --git a/providers/dns/active24/active24.toml b/providers/dns/active24/active24.toml
new file mode 100644
index 000000000..a1bf03924
--- /dev/null
+++ b/providers/dns/active24/active24.toml
@@ -0,0 +1,24 @@
+Name = "Active24"
+Description = ''''''
+URL = "https://www.active24.cz"
+Code = "active24"
+Since = "v4.23.0"
+
+Example = '''
+ACTIVE24_API_KEY="xxx" \
+ACTIVE24_SECRET="yyy" \
+lego --email you@example.com --dns active24 -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ ACTIVE24_API_KEY = "API key"
+ ACTIVE24_SECRET = "Secret"
+ [Configuration.Additional]
+ ACTIVE24_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)"
+ ACTIVE24_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)"
+ ACTIVE24_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ ACTIVE24_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://rest.active24.cz/v2/docs"
diff --git a/providers/dns/active24/active24_test.go b/providers/dns/active24/active24_test.go
new file mode 100644
index 000000000..d7d2c5535
--- /dev/null
+++ b/providers/dns/active24/active24_test.go
@@ -0,0 +1,145 @@
+package active24
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(EnvAPIKey, EnvSecret).WithDomain(envDomain)
+
+func TestNewDNSProvider(t *testing.T) {
+ testCases := []struct {
+ desc string
+ envVars map[string]string
+ expected string
+ }{
+ {
+ desc: "success",
+ envVars: map[string]string{
+ EnvAPIKey: "user",
+ EnvSecret: "secret",
+ },
+ },
+ {
+ desc: "missing API key",
+ envVars: map[string]string{
+ EnvAPIKey: "",
+ EnvSecret: "secret",
+ },
+ expected: "active24: some credentials information are missing: ACTIVE24_API_KEY",
+ },
+ {
+ desc: "missing secret",
+ envVars: map[string]string{
+ EnvAPIKey: "user",
+ EnvSecret: "",
+ },
+ expected: "active24: some credentials information are missing: ACTIVE24_SECRET",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "active24: some credentials information are missing: ACTIVE24_API_KEY,ACTIVE24_SECRET",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ defer envTest.RestoreEnv()
+ envTest.ClearEnv()
+
+ envTest.Apply(test.envVars)
+
+ p, err := NewDNSProvider()
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ apiKey string
+ secret string
+ expected string
+ }{
+ {
+ desc: "success",
+ apiKey: "user",
+ secret: "secret",
+ },
+ {
+ desc: "missing API key",
+ apiKey: "",
+ secret: "secret",
+ expected: "active24: credentials missing",
+ },
+ {
+ desc: "missing secret",
+ apiKey: "user",
+ secret: "",
+ expected: "active24: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "active24: credentials missing",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ config.APIKey = test.apiKey
+ config.Secret = test.secret
+
+ p, err := NewDNSProviderConfig(config)
+
+ if test.expected == "" {
+ require.NoError(t, err)
+ require.NotNil(t, p)
+ require.NotNil(t, p.config)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestLivePresent(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+ provider, err := NewDNSProvider()
+ require.NoError(t, err)
+
+ err = provider.Present(envTest.GetDomain(), "", "123d==")
+ require.NoError(t, err)
+}
+
+func TestLiveCleanUp(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+ provider, err := NewDNSProvider()
+ require.NoError(t, err)
+
+ err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/active24/internal/client.go b/providers/dns/active24/internal/client.go
new file mode 100644
index 000000000..064cc61fb
--- /dev/null
+++ b/providers/dns/active24/internal/client.go
@@ -0,0 +1,214 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "crypto/hmac"
+ "crypto/sha1"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const defaultBaseURL = "https://rest.active24.cz"
+
+// Client the Active24 API client.
+type Client struct {
+ apiKey string
+ secret string
+
+ baseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(apiKey, secret string) (*Client, error) {
+ if apiKey == "" || secret == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ apiKey: apiKey,
+ secret: secret,
+ baseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+// GetServices lists of all services.
+// https://rest.active24.cz/docs/v1.service#services
+func (c *Client) GetServices(ctx context.Context) ([]Service, error) {
+ endpoint := c.baseURL.JoinPath("v1", "user", "self", "service")
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var result OldAPIResponse
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Items, err
+}
+
+// GetRecords lists of DNS records.
+// https://rest.active24.cz/v2/docs#/DNS/rest.v2.dns.record_f94908d4e0e48489468498fce87cb90b
+func (c *Client) GetRecords(ctx context.Context, service string, filter RecordFilter) ([]Record, error) {
+ endpoint := c.baseURL.JoinPath("v2", "service", service, "dns", "record")
+
+ encodedFilter, err := json.Marshal(filter)
+ if err != nil {
+ return nil, fmt.Errorf("marshal records filter: %w", err)
+ }
+
+ query := endpoint.Query()
+ query.Add("filters", string(encodedFilter))
+
+ req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var result APIResponse
+ err = c.do(req, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Data, err
+}
+
+// CreateRecord creates a new DNS record.
+// https://rest.active24.cz/v2/docs#/DNS/rest.v2.dns.create-record_6773d572235be9a72646bf6c54863573
+func (c *Client) CreateRecord(ctx context.Context, service string, record Record) error {
+ endpoint := c.baseURL.JoinPath("v2", "service", service, "dns", "record")
+
+ req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+// DeleteRecord deletes a DNS record.
+// https://rest.active24.cz/v2/docs#/DNS/rest.v2.dns.delete-record_fc6603c14848e547f8d0b967842f0a2c
+func (c *Client) DeleteRecord(ctx context.Context, service, recordID string) error {
+ endpoint := c.baseURL.JoinPath("v2", "service", service, "dns", "record", recordID)
+
+ req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
+ if err != nil {
+ return err
+ }
+
+ return c.do(req, nil)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ req.Header.Set("Accept-Language", "en_us")
+
+ err := c.sign(req, time.Now())
+ if err != nil {
+ return fmt.Errorf("sign request: %w", err)
+ }
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ return parseError(req, resp)
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ err = json.Unmarshal(raw, result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
+ buf := new(bytes.Buffer)
+
+ if payload != nil {
+ err := json.NewEncoder(buf).Encode(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request JSON body: %w", err)
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ return req, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var errAPI APIError
+ err := json.Unmarshal(raw, &errAPI)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return &errAPI
+}
+
+// sign creates and sets request signature and date.
+// https://rest.active24.cz/v2/docs/intro
+func (c *Client) sign(req *http.Request, now time.Time) error {
+ if req.URL.Path == "" {
+ req.URL.Path += "/"
+ }
+
+ canonicalRequest := fmt.Sprintf("%s %s %d", req.Method, req.URL.Path, now.Unix())
+
+ mac := hmac.New(sha1.New, []byte(c.secret))
+ _, err := mac.Write([]byte(canonicalRequest))
+ if err != nil {
+ return err
+ }
+
+ hashed := mac.Sum(nil)
+ signature := hex.EncodeToString(hashed)
+
+ req.SetBasicAuth(c.apiKey, signature)
+
+ req.Header.Set("Date", now.Format(time.RFC3339))
+
+ return nil
+}
diff --git a/providers/dns/active24/internal/client_test.go b/providers/dns/active24/internal/client_test.go
new file mode 100644
index 000000000..2e7359313
--- /dev/null
+++ b/providers/dns/active24/internal/client_test.go
@@ -0,0 +1,174 @@
+package internal
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func setupTest(t *testing.T, pattern string, status int, filename string) *Client {
+ t.Helper()
+
+ mux := http.NewServeMux()
+ server := httptest.NewServer(mux)
+ t.Cleanup(server.Close)
+
+ mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) {
+ if filename == "" {
+ rw.WriteHeader(status)
+ return
+ }
+
+ file, err := os.Open(filepath.Join("fixtures", filename))
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ defer func() { _ = file.Close() }()
+
+ rw.WriteHeader(status)
+ _, err = io.Copy(rw, file)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ })
+
+ client, err := NewClient("user", "secret")
+ require.NoError(t, err)
+
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
+
+ return client
+}
+
+func TestClient_GetServices(t *testing.T) {
+ client := setupTest(t, "GET /v1/user/self/service", http.StatusOK, "services.json")
+
+ services, err := client.GetServices(context.Background())
+ require.NoError(t, err)
+
+ expected := []Service{
+ {
+ ID: 1111,
+ ServiceName: ".sk doména",
+ Status: "active",
+ Name: "mydomain.sk",
+ CreateTime: 1374357600,
+ ExpireTime: 1405914526,
+ Price: 12.3,
+ },
+ {
+ ID: 2222,
+ ServiceName: "The Hosting",
+ Status: "active",
+ Name: "myname_1",
+ CreateTime: 1400145443,
+ ExpireTime: 1431702371,
+ Price: 55.2,
+ },
+ }
+
+ assert.Equal(t, expected, services)
+}
+
+func TestClient_GetServices_errors(t *testing.T) {
+ client := setupTest(t, "GET /v1/user/self/service", http.StatusUnauthorized, "error_v1.json")
+
+ _, err := client.GetServices(context.Background())
+ require.EqualError(t, err, "401: No username or password.")
+}
+
+func TestClient_GetRecords(t *testing.T) {
+ client := setupTest(t, "GET /v2/service/aaa/dns/record", http.StatusOK, "records.json")
+
+ filter := RecordFilter{
+ Name: "example.com",
+ Type: []string{"TXT"},
+ Content: "txt",
+ }
+
+ records, err := client.GetRecords(context.Background(), "aaa", filter)
+ require.NoError(t, err)
+
+ expected := []Record{{
+ ID: 13,
+ Name: "string",
+ Content: "string",
+ TTL: 120,
+ Priority: 1,
+ Port: 443,
+ Weight: 50,
+ }}
+
+ assert.Equal(t, expected, records)
+}
+
+func TestClient_GetRecords_errors(t *testing.T) {
+ client := setupTest(t, "GET /v2/service/aaa/dns/record", http.StatusForbidden, "error_403.json")
+
+ filter := RecordFilter{
+ Name: "example.com",
+ Type: []string{"TXT"},
+ Content: "txt",
+ }
+
+ _, err := client.GetRecords(context.Background(), "aaa", filter)
+ require.EqualError(t, err, "403: /errors/httpException: This action is unauthorized.")
+}
+
+func TestClient_CreateRecord(t *testing.T) {
+ client := setupTest(t, "POST /v2/service/aaa/dns/record", http.StatusNoContent, "")
+
+ err := client.CreateRecord(context.Background(), "aaa", Record{})
+ require.NoError(t, err)
+}
+
+func TestClient_CreateRecord_errors(t *testing.T) {
+ client := setupTest(t, "POST /v2/service/aaa/dns/record", http.StatusForbidden, "error_403.json")
+
+ err := client.CreateRecord(context.Background(), "aaa", Record{})
+ require.EqualError(t, err, "403: /errors/httpException: This action is unauthorized.")
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ client := setupTest(t, "DELETE /v2/service/aaa/dns/record/123", http.StatusNoContent, "")
+
+ err := client.DeleteRecord(context.Background(), "aaa", "123")
+ require.NoError(t, err)
+}
+
+func TestClient_DeleteRecord_error(t *testing.T) {
+ client := setupTest(t, "DELETE /v2/service/aaa/dns/record/123", http.StatusForbidden, "error_403.json")
+
+ err := client.DeleteRecord(context.Background(), "aaa", "123")
+ require.EqualError(t, err, "403: /errors/httpException: This action is unauthorized.")
+}
+
+func TestClient_sign(t *testing.T) {
+ client, err := NewClient("user", "secret")
+ require.NoError(t, err)
+
+ req, err := http.NewRequest(http.MethodGet, "/v1/user/self/service", nil)
+ require.NoError(t, err)
+
+ err = client.sign(req, time.Date(2025, 6, 28, 1, 2, 3, 4, time.UTC))
+ require.NoError(t, err)
+
+ username, password, ok := req.BasicAuth()
+ require.True(t, ok)
+
+ assert.Equal(t, "user", username)
+ assert.Equal(t, "743e2257421b260ed561f3e7af4b035414636393", password)
+}
diff --git a/providers/dns/active24/internal/fixtures/error_403.json b/providers/dns/active24/internal/fixtures/error_403.json
new file mode 100644
index 000000000..ee3ce196e
--- /dev/null
+++ b/providers/dns/active24/internal/fixtures/error_403.json
@@ -0,0 +1,5 @@
+{
+ "type": "/errors/httpException",
+ "status": 403,
+ "title": "This action is unauthorized."
+}
diff --git a/providers/dns/active24/internal/fixtures/error_422.json b/providers/dns/active24/internal/fixtures/error_422.json
new file mode 100644
index 000000000..0864a1fce
--- /dev/null
+++ b/providers/dns/active24/internal/fixtures/error_422.json
@@ -0,0 +1,16 @@
+{
+ "type": "/errors/validation",
+ "status": 422,
+ "title": "The given data was invalid.",
+ "violations": [
+ {
+ "propertyPath": "string",
+ "errors": [
+ {}
+ ]
+ }
+ ],
+ "data": {
+ "name": "Merlin"
+ }
+}
diff --git a/providers/dns/active24/internal/fixtures/error_v1.json b/providers/dns/active24/internal/fixtures/error_v1.json
new file mode 100644
index 000000000..8043412e5
--- /dev/null
+++ b/providers/dns/active24/internal/fixtures/error_v1.json
@@ -0,0 +1,4 @@
+{
+ "message": "No username or password.",
+ "code": 401
+}
diff --git a/providers/dns/active24/internal/fixtures/records.json b/providers/dns/active24/internal/fixtures/records.json
new file mode 100644
index 000000000..bf07d9ef7
--- /dev/null
+++ b/providers/dns/active24/internal/fixtures/records.json
@@ -0,0 +1,28 @@
+{
+ "currentPage": 0,
+ "rowsPerPage": 0,
+ "totalPages": 0,
+ "totalRecords": 0,
+ "actions": {
+ "additionalProp1": {
+ "additionalProp1": {}
+ },
+ "additionalProp2": {
+ "additionalProp1": {}
+ },
+ "additionalProp3": {
+ "additionalProp1": {}
+ }
+ },
+ "data": [
+ {
+ "id": 13,
+ "name": "string",
+ "content": "string",
+ "ttl": 120,
+ "priority": 1,
+ "port": 443,
+ "weight": 50
+ }
+ ]
+}
diff --git a/providers/dns/active24/internal/fixtures/services.json b/providers/dns/active24/internal/fixtures/services.json
new file mode 100644
index 000000000..ad9b28700
--- /dev/null
+++ b/providers/dns/active24/internal/fixtures/services.json
@@ -0,0 +1,31 @@
+{
+ "items":
+ [
+ {
+ "id": 1111,
+ "serviceName": ".sk doména",
+ "status": "active",
+ "name": "mydomain.sk",
+ "createTime": 1374357600,
+ "expireTime": 1405914526,
+ "price": 12.3,
+ "autoExtend": false
+ },
+ {
+ "id": 2222,
+ "serviceName": "The Hosting",
+ "status": "active",
+ "name": "myname_1",
+ "createTime": 1400145443,
+ "expireTime": 1431702371,
+ "price": 55.2,
+ "autoExtend": false
+ }
+ ],
+ "pager":
+ {
+ "page": 1,
+ "pagesize": null,
+ "items": 2
+ }
+}
diff --git a/providers/dns/active24/internal/types.go b/providers/dns/active24/internal/types.go
new file mode 100644
index 000000000..ed8dfc9d3
--- /dev/null
+++ b/providers/dns/active24/internal/types.go
@@ -0,0 +1,65 @@
+package internal
+
+import "fmt"
+
+type APIError struct {
+ // v2 error
+ Type string `json:"type,omitempty"`
+ Status int `json:"status,omitempty"`
+ Title string `json:"title,omitempty"`
+
+ // v1 error
+ Message string `json:"message,omitempty"`
+ Code int `json:"code,omitempty"`
+}
+
+func (a *APIError) Error() string {
+ if a.Message != "" {
+ return fmt.Sprintf("%d: %s", a.Code, a.Message)
+ }
+
+ return fmt.Sprintf("%d: %s: %s", a.Status, a.Type, a.Title)
+}
+
+type APIResponse struct {
+ Data []Record `json:"data"`
+}
+
+type Record struct {
+ ID int `json:"id,omitempty"`
+ Type string `json:"type,omitempty"`
+ Name string `json:"name,omitempty"`
+ Content string `json:"content,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ Priority int `json:"priority,omitempty"`
+ Port int `json:"port,omitempty"`
+ Weight int `json:"weight,omitempty"`
+}
+
+type OldAPIResponse struct {
+ Items []Service `json:"items"`
+}
+
+type Service struct {
+ ID int `json:"id,omitempty"`
+ ServiceName string `json:"serviceName,omitempty"`
+ Status string `json:"status,omitempty"`
+ Name string `json:"name,omitempty"`
+ CreateTime int `json:"createTime,omitempty"`
+ ExpireTime int `json:"expireTime,omitempty"`
+ Price float64 `json:"price,omitempty"`
+ AutoExtend bool `json:"autoExtend,omitempty"`
+}
+
+type RecordFilter struct {
+ Name string `json:"name,omitempty"`
+ Type []string `json:"type,omitempty"`
+ Content string `json:"content,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+ Note string `json:"note,omitempty"`
+ Priority int `json:"priority,omitempty"`
+ Port int `json:"port,omitempty"`
+ Weight int `json:"weight,omitempty"`
+ Flags int `json:"flags,omitempty"`
+ Tag []string `json:"tag,omitempty"`
+}
diff --git a/providers/dns/zz_gen_dns_providers.go b/providers/dns/zz_gen_dns_providers.go
index f52feaa25..9b3f70771 100644
--- a/providers/dns/zz_gen_dns_providers.go
+++ b/providers/dns/zz_gen_dns_providers.go
@@ -8,6 +8,7 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/providers/dns/acmedns"
+ "github.com/go-acme/lego/v4/providers/dns/active24"
"github.com/go-acme/lego/v4/providers/dns/alidns"
"github.com/go-acme/lego/v4/providers/dns/allinkl"
"github.com/go-acme/lego/v4/providers/dns/arvancloud"
@@ -165,6 +166,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return dns01.NewDNSProviderManual()
case "acme-dns", "acmedns":
return acmedns.NewDNSProvider()
+ case "active24":
+ return active24.NewDNSProvider()
case "alidns":
return alidns.NewDNSProvider()
case "allinkl":