From 730af10596bc4ef08bd68706950c36cc590d22ab Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Tue, 11 Mar 2025 17:54:28 +0100 Subject: [PATCH] Add DNS provider for Active24 (#2478) --- README.md | 76 +++--- cmd/zz_gen_cmd_dnshelp.go | 22 ++ docs/content/dns/zz_gen_active24.md | 69 ++++++ docs/data/zz_cli_help.toml | 2 +- providers/dns/active24/active24.go | 216 ++++++++++++++++++ providers/dns/active24/active24.toml | 24 ++ providers/dns/active24/active24_test.go | 145 ++++++++++++ providers/dns/active24/internal/client.go | 214 +++++++++++++++++ .../dns/active24/internal/client_test.go | 174 ++++++++++++++ .../active24/internal/fixtures/error_403.json | 5 + .../active24/internal/fixtures/error_422.json | 16 ++ .../active24/internal/fixtures/error_v1.json | 4 + .../active24/internal/fixtures/records.json | 28 +++ .../active24/internal/fixtures/services.json | 31 +++ providers/dns/active24/internal/types.go | 65 ++++++ providers/dns/zz_gen_dns_providers.go | 3 + 16 files changed, 1055 insertions(+), 39 deletions(-) create mode 100644 docs/content/dns/zz_gen_active24.md create mode 100644 providers/dns/active24/active24.go create mode 100644 providers/dns/active24/active24.toml create mode 100644 providers/dns/active24/active24_test.go create mode 100644 providers/dns/active24/internal/client.go create mode 100644 providers/dns/active24/internal/client_test.go create mode 100644 providers/dns/active24/internal/fixtures/error_403.json create mode 100644 providers/dns/active24/internal/fixtures/error_422.json create mode 100644 providers/dns/active24/internal/fixtures/error_v1.json create mode 100644 providers/dns/active24/internal/fixtures/records.json create mode 100644 providers/dns/active24/internal/fixtures/services.json create mode 100644 providers/dns/active24/internal/types.go 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). + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + -
Active24 Akamai EdgeDNS Alibaba Cloud DNS all-inklAmazon Lightsail
Amazon Lightsail Amazon Route 53 ArvanCloud Aurora DNSAutodns
Autodns Azure (deprecated) Azure DNS BindmanBluecat
Bluecat BookMyName Brandit (deprecated) BunnyCheckdomain
Checkdomain Civo Cloud.ru CloudDNSCloudflare
Cloudflare ClouDNS CloudXNS (Deprecated) ConoHaConstellix
Constellix Core-Networks CPanel/WHM Derak ClouddeSEC.io
deSEC.io Designate DNSaaS for Openstack Digital Ocean DirectAdminDNS Made Easy
DNS Made Easy dnsHome.de DNSimple DNSPod (deprecated)Domain Offensive (do.de)
Domain Offensive (do.de) Domeneshop DreamHost Duck DNSDyn
Dyn Dynu EasyDNS Efficient IPEpik
Epik Exoscale External program F5 XCfreemyip.com
freemyip.com G-Core Gandi Gandi Live DNS (v5)Glesys
Glesys Go Daddy Google Cloud Google DomainsHetzner
Hetzner Hosting.de Hosttech HTTP requesthttp.net
http.net Huawei Cloud Hurricane Electric DNS HyperOneIBM Cloud (SoftLayer)
IBM Cloud (SoftLayer) IIJ DNS Platform Service Infoblox InfomaniakInternet Initiative Japan
Internet Initiative Japan Internet.bs INWX IonosIPv64
IPv64 iwantmyname Joker Joohoi's ACME-DNSLiara
Liara Lima-City Linode (v4) Liquid WebLoopia
Loopia LuaDNS Mail-in-a-Box ManageEngine CloudDNSManual
Manual Metaname Metaregistrar mijn.hostMittwald
Mittwald myaddr.{tools,dev,io} MyDNS.jp MythicBeastsName.com
Name.com Namecheap Namesilo NearlyFreeSpeech.NETNetcup
Netcup Netlify Nicmanager NIFCloudNjalla
Njalla Nodion NS1 Open Telekom CloudOracle Cloud
Oracle Cloud OVH plesk.com PorkbunPowerDNS
PowerDNS Rackspace Rain Yun/雨云 RcodeZeroreg.ru
reg.ru Regfish RFC2136 RimuHostingSakura Cloud
Sakura Cloud Scaleway Selectel Selectel v2SelfHost.(de|eu)
SelfHost.(de|eu) Servercow Shellrent Simply.comSonic
Sonic Spaceship Stackpath TechnitiumTencent Cloud DNS
Tencent Cloud DNS Timeweb Cloud TransIP UKFast SafeDNSUltradns
Ultradns Variomedia VegaDNS VercelVersio.[nl|eu|uk]
Versio.[nl|eu|uk] VinylDNS VK Cloud Volcano Engine/火山引擎Vscale
Vscale Vultr Webnames WebsupportWEDOS
WEDOS West.cn/西部数码 Yandex 360 Yandex CloudYandex PDD
Yandex PDD Zone.ee Zonomi
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":