From de869c8a7ebce8beb0397b470eda7d04dc89dbe2 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Mon, 19 Jan 2026 17:31:56 +0100 Subject: [PATCH 01/21] Add DNS provider for Bluecat v2 (#2791) --- README.md | 85 ++-- cmd/zz_gen_cmd_dnshelp.go | 26 ++ docs/content/dns/zz_gen_bluecatv2.md | 76 ++++ docs/data/zz_cli_help.toml | 2 +- providers/dns/bluecatv2/bluecatv2.go | 249 +++++++++++ providers/dns/bluecatv2/bluecatv2.toml | 33 ++ providers/dns/bluecatv2/bluecatv2_test.go | 414 ++++++++++++++++++ providers/dns/bluecatv2/internal/client.go | 221 ++++++++++ .../dns/bluecatv2/internal/client_test.go | 208 +++++++++ .../fixtures/deleteResourceRecord.json | 75 ++++ .../bluecatv2/internal/fixtures/error.json | 6 + .../internal/fixtures/getZoneDeployments.json | 46 ++ .../fixtures/postSession-request.json | 4 + .../internal/fixtures/postSession.json | 50 +++ .../fixtures/postZoneDeployment-request.json | 3 + .../internal/fixtures/postZoneDeployment.json | 40 ++ .../postZoneResourceRecord-request.json | 7 + .../fixtures/postZoneResourceRecord.json | 25 ++ .../bluecatv2/internal/fixtures/zones.json | 185 ++++++++ providers/dns/bluecatv2/internal/identity.go | 60 +++ .../dns/bluecatv2/internal/identity_test.go | 82 ++++ .../dns/bluecatv2/internal/predicates.go | 64 +++ .../dns/bluecatv2/internal/predicates_test.go | 78 ++++ providers/dns/bluecatv2/internal/types.go | 122 ++++++ providers/dns/zz_gen_dns_providers.go | 3 + 25 files changed, 2123 insertions(+), 41 deletions(-) create mode 100644 docs/content/dns/zz_gen_bluecatv2.md create mode 100644 providers/dns/bluecatv2/bluecatv2.go create mode 100644 providers/dns/bluecatv2/bluecatv2.toml create mode 100644 providers/dns/bluecatv2/bluecatv2_test.go create mode 100644 providers/dns/bluecatv2/internal/client.go create mode 100644 providers/dns/bluecatv2/internal/client_test.go create mode 100644 providers/dns/bluecatv2/internal/fixtures/deleteResourceRecord.json create mode 100644 providers/dns/bluecatv2/internal/fixtures/error.json create mode 100644 providers/dns/bluecatv2/internal/fixtures/getZoneDeployments.json create mode 100644 providers/dns/bluecatv2/internal/fixtures/postSession-request.json create mode 100644 providers/dns/bluecatv2/internal/fixtures/postSession.json create mode 100644 providers/dns/bluecatv2/internal/fixtures/postZoneDeployment-request.json create mode 100644 providers/dns/bluecatv2/internal/fixtures/postZoneDeployment.json create mode 100644 providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord-request.json create mode 100644 providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord.json create mode 100644 providers/dns/bluecatv2/internal/fixtures/zones.json create mode 100644 providers/dns/bluecatv2/internal/identity.go create mode 100644 providers/dns/bluecatv2/internal/identity_test.go create mode 100644 providers/dns/bluecatv2/internal/predicates.go create mode 100644 providers/dns/bluecatv2/internal/predicates_test.go create mode 100644 providers/dns/bluecatv2/internal/types.go diff --git a/README.md b/README.md index 557542ca0..6324ece67 100644 --- a/README.md +++ b/README.md @@ -88,208 +88,213 @@ If your DNS provider is not supported, please open an [issue](https://github.com Bindman Bluecat + Bluecat v2 BookMyName - Brandit (deprecated) + Brandit (deprecated) Bunny Checkdomain Civo - Cloud.ru + Cloud.ru CloudDNS Cloudflare ClouDNS - CloudXNS (Deprecated) + CloudXNS (Deprecated) ConoHa v2 ConoHa v3 Constellix - Core-Networks + Core-Networks CPanel/WHM DDnss (DynDNS Service) Derak Cloud - deSEC.io + deSEC.io Designate DNSaaS for Openstack Digital Ocean DirectAdmin - DNS Made Easy + DNS Made Easy DNSExit dnsHome.de DNSimple - DNSPod (deprecated) + DNSPod (deprecated) Domain Offensive (do.de) Domeneshop DreamHost - Duck DNS + Duck DNS Dyn DynDnsFree.de Dynu - EasyDNS + EasyDNS EdgeCenter Efficient IP Epik - Exoscale + Exoscale External program F5 XC freemyip.com - G-Core + G-Core Gandi Gandi Live DNS (v5) Gigahost.no - Glesys + Glesys Go Daddy Google Cloud Google Domains - Gravity + Gravity Hetzner Hosting.de Hosting.nl - Hostinger + Hostinger Hosttech HTTP request http.net - Huawei Cloud + Huawei Cloud Hurricane Electric DNS HyperOne IBM Cloud (SoftLayer) - IIJ DNS Platform Service + IIJ DNS Platform Service Infoblox Infomaniak Internet Initiative Japan - Internet.bs + Internet.bs INWX Ionos Ionos Cloud - IPv64 + IPv64 ISPConfig 3 ISPConfig 3 - Dynamic DNS (DDNS) Module iwantmyname (Deprecated) - JD Cloud + JD Cloud Joker Joohoi's ACME-DNS KeyHelp - Liara + Liara Lima-City Linode (v4) Liquid Web - Loopia + Loopia LuaDNS Mail-in-a-Box ManageEngine CloudDNS - Manual + Manual Metaname Metaregistrar mijn.host - Mittwald + Mittwald myaddr.{tools,dev,io} MyDNS.jp MythicBeasts - Name.com + Name.com Namecheap Namesilo NearlyFreeSpeech.NET - Neodigit + Neodigit Netcup Netlify Nicmanager - NIFCloud + NIFCloud Njalla Nodion NS1 - Octenium + Octenium Open Telekom Cloud Oracle Cloud OVH - plesk.com + plesk.com Porkbun PowerDNS Rackspace - Rain Yun/雨云 + Rain Yun/雨云 RcodeZero reg.ru Regfish - RFC2136 + RFC2136 RimuHosting RU CENTER Sakura Cloud - Scaleway + Scaleway Selectel Selectel v2 SelfHost.(de|eu) - Servercow + Servercow Shellrent Simply.com Sonic - Spaceship + Spaceship Stackpath Syse Technitium - Tencent Cloud DNS + Tencent Cloud DNS Tencent EdgeOne Timeweb Cloud TodayNIC/时代互联 - TransIP + TransIP UKFast SafeDNS Ultradns United-Domains - Variomedia + Variomedia VegaDNS Vercel Versio.[nl|eu|uk] - VinylDNS + VinylDNS Virtualname VK Cloud Volcano Engine/火山引擎 - Vscale + Vscale Vultr webnames.ca webnames.ru - Websupport + Websupport WEDOS West.cn/西部数码 Yandex 360 - Yandex Cloud + Yandex Cloud Yandex PDD Zone.ee ZoneEdit + Zonomi + + + diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index cf2da8563..600e49753 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -31,6 +31,7 @@ func allDNSCodes() string { "binarylane", "bindman", "bluecat", + "bluecatv2", "bookmyname", "brandit", "bunny", @@ -623,6 +624,31 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/bluecat`) + case "bluecatv2": + // generated from: providers/dns/bluecatv2/bluecatv2.toml + ew.writeln(`Configuration for Bluecat v2.`) + ew.writeln(`Code: 'bluecatv2'`) + ew.writeln(`Since: 'v4.32.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "BLUECATV2_CONFIG_NAME": Configuration name`) + ew.writeln(` - "BLUECATV2_PASSWORD": API password`) + ew.writeln(` - "BLUECATV2_USERNAME": API username`) + ew.writeln(` - "BLUECATV2_VIEW_NAME": DNS View Name`) + ew.writeln(` - "BLUECAT_SERVER_URL": The server URL: it should have a scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "BLUECATV2_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "BLUECATV2_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "BLUECATV2_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "BLUECATV2_SKIP_DEPLOY": Skip quick deployements`) + ew.writeln(` - "BLUECATV2_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/bluecatv2`) + case "bookmyname": // generated from: providers/dns/bookmyname/bookmyname.toml ew.writeln(`Configuration for BookMyName.`) diff --git a/docs/content/dns/zz_gen_bluecatv2.md b/docs/content/dns/zz_gen_bluecatv2.md new file mode 100644 index 000000000..7d748df99 --- /dev/null +++ b/docs/content/dns/zz_gen_bluecatv2.md @@ -0,0 +1,76 @@ +--- +title: "Bluecat v2" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: bluecatv2 +dnsprovider: + since: "v4.32.0" + code: "bluecatv2" + url: "https://www.bluecatnetworks.com" +--- + + + + + + +Configuration for [Bluecat v2](https://www.bluecatnetworks.com). + + + + +- Code: `bluecatv2` +- Since: v4.32.0 + + +Here is an example bash command using the Bluecat v2 provider: + +```bash +BLUECATV2_SERVER_URL="https://example.com" \ +BLUECATV2_USERNAME="xxx" \ +BLUECATV2_PASSWORD="yyy" \ +BLUECATV2_CONFIG_NAME="myConfiguration" \ +BLUECATV2_VIEW_NAME="myView" \ +lego --dns bluecatv2 -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `BLUECATV2_CONFIG_NAME` | Configuration name | +| `BLUECATV2_PASSWORD` | API password | +| `BLUECATV2_USERNAME` | API username | +| `BLUECATV2_VIEW_NAME` | DNS View Name | +| `BLUECAT_SERVER_URL` | The server URL: it should have a scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve | + +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 | +|--------------------------------|-------------| +| `BLUECATV2_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `BLUECATV2_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `BLUECATV2_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `BLUECATV2_SKIP_DEPLOY` | Skip quick deployements | +| `BLUECATV2_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://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Introduction/9.6.0) + + + + diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml index 3d3043690..e31633567 100644 --- a/docs/data/zz_cli_help.toml +++ b/docs/data/zz_cli_help.toml @@ -152,7 +152,7 @@ To display the documentation for a specific DNS provider, run: $ lego dnshelp -c code Supported DNS providers: - acme-dns, active24, alidns, aliesa, allinkl, alwaysdata, anexia, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, ddnss, derak, desec, designate, digitalocean, directadmin, dnsexit, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, jdcloud, joker, keyhelp, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, 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, syse, technitium, tencentcloud, timewebcloud, todaynic, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnames, webnamesca, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi + acme-dns, active24, alidns, aliesa, allinkl, alwaysdata, anexia, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bluecatv2, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, ddnss, derak, desec, designate, digitalocean, directadmin, dnsexit, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, jdcloud, joker, keyhelp, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, 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, syse, technitium, tencentcloud, timewebcloud, todaynic, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnames, webnamesca, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi More information: https://go-acme.github.io/lego/dns """ diff --git a/providers/dns/bluecatv2/bluecatv2.go b/providers/dns/bluecatv2/bluecatv2.go new file mode 100644 index 000000000..0efe99661 --- /dev/null +++ b/providers/dns/bluecatv2/bluecatv2.go @@ -0,0 +1,249 @@ +// Package bluecatv2 implements a DNS provider for solving the DNS-01 challenge using Bluecat v2. +package bluecatv2 + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "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/bluecatv2/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "BLUECATV2_" + + EnvServerURL = envNamespace + "SERVER_URL" + EnvUsername = envNamespace + "USERNAME" + EnvPassword = envNamespace + "PASSWORD" + EnvConfigName = envNamespace + "CONFIG_NAME" + EnvViewName = envNamespace + "VIEW_NAME" + EnvSkipDeploy = envNamespace + "SKIP_DEPLOY" + + 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 { + ServerURL string + Username string + Password string + ConfigName string + ViewName string + SkipDeploy bool + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + SkipDeploy: env.GetOrDefaultBool(EnvSkipDeploy, false), + + 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 + + zoneIDs map[string]int64 + recordIDs map[string]int64 + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for Bluecat v2. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvServerURL, EnvUsername, EnvPassword, EnvConfigName, EnvViewName) + if err != nil { + return nil, fmt.Errorf("bluecatv2: %w", err) + } + + config := NewDefaultConfig() + config.ServerURL = values[EnvServerURL] + config.Username = values[EnvUsername] + config.Password = values[EnvPassword] + config.ConfigName = values[EnvConfigName] + config.ViewName = values[EnvViewName] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Bluecat v2. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("bluecatv2: the configuration of the DNS provider is nil") + } + + if config.ServerURL == "" { + return nil, errors.New("bluecatv2: missing server URL") + } + + if config.ConfigName == "" { + return nil, errors.New("bluecatv2: missing configuration name") + } + + if config.ViewName == "" { + return nil, errors.New("bluecatv2: missing view name") + } + + client, err := internal.NewClient(config.ServerURL, config.Username, config.Password) + if err != nil { + return nil, fmt.Errorf("bluecatv2: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + recordIDs: make(map[string]int64), + zoneIDs: make(map[string]int64), + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + ctx, err := d.client.CreateAuthenticatedContext(context.Background()) + if err != nil { + return fmt.Errorf("bluecatv2: %w", err) + } + + zone, err := d.findZone(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("bluecatv2: %w", err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.AbsoluteName) + if err != nil { + return fmt.Errorf("bluecatv2: %w", err) + } + + record := internal.RecordTXT{ + CommonResource: internal.CommonResource{ + Type: "TXTRecord", + Name: subDomain, + }, + Text: info.Value, + TTL: d.config.TTL, + RecordType: "TXT", + } + + newRecord, err := d.client.CreateZoneResourceRecord(ctx, zone.ID, record) + if err != nil { + return fmt.Errorf("bluecatv2: create resource record: %w", err) + } + + d.recordIDsMu.Lock() + d.zoneIDs[token] = zone.ID + d.recordIDs[token] = newRecord.ID + d.recordIDsMu.Unlock() + + if d.config.SkipDeploy { + return nil + } + + _, err = d.client.CreateZoneDeployment(ctx, zone.ID) + if err != nil { + return fmt.Errorf("bluecat: deploy zone: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + d.recordIDsMu.Lock() + recordID, recordOK := d.recordIDs[token] + zoneID, zoneOK := d.zoneIDs[token] + d.recordIDsMu.Unlock() + + if !recordOK { + return fmt.Errorf("bluecatv2: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) + } + + if !zoneOK { + return fmt.Errorf("bluecatv2: unknown zone ID for '%s' '%s'", info.EffectiveFQDN, token) + } + + ctx, err := d.client.CreateAuthenticatedContext(context.Background()) + if err != nil { + return fmt.Errorf("bluecatv2: %w", err) + } + + err = d.client.DeleteResourceRecord(ctx, recordID) + if err != nil { + return fmt.Errorf("bluecatv2: delete resource record: %w", err) + } + + if d.config.SkipDeploy { + return nil + } + + _, err = d.client.CreateZoneDeployment(ctx, zoneID) + if err != nil { + return fmt.Errorf("bluecat: deploy zone: %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) findZone(ctx context.Context, fqdn string) (*internal.ZoneResource, error) { + for name := range dns01.UnFqdnDomainsSeq(fqdn) { + opts := &internal.CollectionOptions{ + Fields: "id,absoluteName,configuration.id,configuration.name,view.id,view.name", + Filter: internal.And( + internal.Eq("absoluteName", name), + internal.Eq("configuration.name", d.config.ConfigName), + internal.Eq("view.name", d.config.ViewName), + ).String(), + } + + zones, err := d.client.RetrieveZones(ctx, opts) + if err != nil { + // TODO(ldez) maybe add a log in v5. + continue + } + + for _, zone := range zones { + if zone.AbsoluteName == name { + return &zone, nil + } + } + } + + return nil, fmt.Errorf("no zone found for fqdn: %s", fqdn) +} diff --git a/providers/dns/bluecatv2/bluecatv2.toml b/providers/dns/bluecatv2/bluecatv2.toml new file mode 100644 index 000000000..6ec3781c6 --- /dev/null +++ b/providers/dns/bluecatv2/bluecatv2.toml @@ -0,0 +1,33 @@ +Name = "Bluecat v2" +Description = '''''' +URL = "https://www.bluecatnetworks.com" +Code = "bluecatv2" +Since = "v4.32.0" + +Example = ''' +BLUECATV2_SERVER_URL="https://example.com" \ +BLUECATV2_USERNAME="xxx" \ +BLUECATV2_PASSWORD="yyy" \ +BLUECATV2_CONFIG_NAME="myConfiguration" \ +BLUECATV2_VIEW_NAME="myView" \ +lego --dns bluecatv2 -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + BLUECAT_SERVER_URL = "The server URL: it should have a scheme, hostname, and port (if required) of the authoritative Bluecat BAM serve" + BLUECATV2_USERNAME = "API username" + BLUECATV2_PASSWORD = "API password" + BLUECATV2_CONFIG_NAME = "Configuration name" + BLUECATV2_VIEW_NAME = "DNS View Name" + [Configuration.Additional] + BLUECATV2_SKIP_DEPLOY = "Skip quick deployements" + BLUECATV2_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + BLUECATV2_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + BLUECATV2_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + BLUECATV2_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Introduction/9.6.0" + Swagger = "http://{Address_Manager_IP}/api/openapi.json" + SwaggerDump = "https://github.com/go-acme/lego/discussions/2218#discussioncomment-13060545" diff --git a/providers/dns/bluecatv2/bluecatv2_test.go b/providers/dns/bluecatv2/bluecatv2_test.go new file mode 100644 index 000000000..d852f0e18 --- /dev/null +++ b/providers/dns/bluecatv2/bluecatv2_test.go @@ -0,0 +1,414 @@ +package bluecatv2 + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/go-acme/lego/v4/providers/dns/bluecatv2/internal" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvServerURL, + EnvUsername, + EnvPassword, + EnvConfigName, + EnvViewName, + EnvSkipDeploy, +).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvServerURL: "https://example.com/", + EnvUsername: "userA", + EnvPassword: "secret", + EnvConfigName: "myConfig", + EnvViewName: "myView", + }, + }, + { + desc: "missing server URL", + envVars: map[string]string{ + EnvServerURL: "", + EnvUsername: "userA", + EnvPassword: "secret", + EnvConfigName: "myConfig", + EnvViewName: "myView", + }, + expected: "bluecatv2: some credentials information are missing: BLUECATV2_SERVER_URL", + }, + { + desc: "missing username", + envVars: map[string]string{ + EnvServerURL: "https://example.com/", + EnvUsername: "", + EnvPassword: "secret", + EnvConfigName: "myConfig", + EnvViewName: "myView", + }, + expected: "bluecatv2: some credentials information are missing: BLUECATV2_USERNAME", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvServerURL: "https://example.com/", + EnvUsername: "userA", + EnvPassword: "", + EnvConfigName: "myConfig", + EnvViewName: "myView", + }, + expected: "bluecatv2: some credentials information are missing: BLUECATV2_PASSWORD", + }, + { + desc: "missing configuration name", + envVars: map[string]string{ + EnvServerURL: "https://example.com/", + EnvUsername: "userA", + EnvPassword: "secret", + EnvConfigName: "", + EnvViewName: "myView", + }, + expected: "bluecatv2: some credentials information are missing: BLUECATV2_CONFIG_NAME", + }, + { + desc: "missing view name", + envVars: map[string]string{ + EnvServerURL: "https://example.com/", + EnvUsername: "userA", + EnvPassword: "secret", + EnvConfigName: "myConfig", + EnvViewName: "", + }, + expected: "bluecatv2: some credentials information are missing: BLUECATV2_VIEW_NAME", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "bluecatv2: some credentials information are missing: BLUECATV2_SERVER_URL,BLUECATV2_USERNAME,BLUECATV2_PASSWORD,BLUECATV2_CONFIG_NAME,BLUECATV2_VIEW_NAME", + }, + } + + 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 + serverURL string + username string + password string + configName string + viewName string + expected string + }{ + { + desc: "success", + serverURL: "https://example.com/", + username: "userA", + password: "secret", + configName: "myConfig", + viewName: "myView", + }, + { + desc: "missing server URL", + username: "userA", + password: "secret", + configName: "myConfig", + viewName: "myView", + expected: "bluecatv2: missing server URL", + }, + { + desc: "missing username", + serverURL: "https://example.com/", + password: "secret", + configName: "myConfig", + viewName: "myView", + expected: "bluecatv2: credentials missing", + }, + { + desc: "missing password", + serverURL: "https://example.com/", + username: "userA", + configName: "myConfig", + viewName: "myView", + expected: "bluecatv2: credentials missing", + }, + { + desc: "missing configuration name", + serverURL: "https://example.com/", + username: "userA", + password: "secret", + viewName: "myView", + expected: "bluecatv2: missing configuration name", + }, + { + desc: "missing view name", + serverURL: "https://example.com/", + username: "userA", + password: "secret", + configName: "myConfig", + expected: "bluecatv2: missing view name", + }, + { + desc: "missing credentials", + expected: "bluecatv2: missing server URL", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.ServerURL = test.serverURL + config.Username = test.username + config.Password = test.password + config.ConfigName = test.configName + config.ViewName = test.viewName + + 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) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + + config.ServerURL = server.URL + config.Username = "userA" + config.Password = "secret" + config.ConfigName = "myConfiguration" + config.ViewName = "myView" + + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + return p, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("POST /api/v2/sessions", + servermock.ResponseFromInternal("postSession.json"), + servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"), + ). + Route("GET /api/v2/configurations", + servermock.ResponseFromInternal("configurations.json"), + servermock.CheckQueryParameter().Strict(). + With("filter", "name:eq('myConfiguration')"), + ). + Route("GET /api/v2/configurations/12345/views", + servermock.ResponseFromInternal("views.json"), + servermock.CheckQueryParameter().Strict(). + With("filter", "name:eq('myView')"), + ). + Route("GET /api/v2/zones", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + filter := req.URL.Query().Get("filter") + + if strings.Contains(filter, internal.Eq("absoluteName", "example.com").String()) { + servermock.ResponseFromInternal("zones.json").ServeHTTP(rw, req) + + return + } + + servermock.ResponseFromInternal("error.json"). + WithStatusCode(http.StatusNotFound).ServeHTTP(rw, req) + }), + ). + Route("POST /api/v2/zones/12345/resourceRecords", + servermock.ResponseFromInternal("postZoneResourceRecord.json"), + servermock.CheckRequestJSONBodyFromInternal("postZoneResourceRecord-request.json"), + ). + Route("POST /api/v2/zones/12345/deployments", + servermock.ResponseFromInternal("postZoneDeployment.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBodyFromInternal("postZoneDeployment-request.json"), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_Present_skipDeploy(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(map[string]string{ + EnvSkipDeploy: "true", + }) + + provider := mockBuilder(). + Route("POST /api/v2/sessions", + servermock.ResponseFromInternal("postSession.json"), + servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"), + ). + Route("GET /api/v2/configurations", + servermock.ResponseFromInternal("configurations.json"), + servermock.CheckQueryParameter().Strict(). + With("filter", "name:eq('myConfiguration')"), + ). + Route("GET /api/v2/configurations/12345/views", + servermock.ResponseFromInternal("views.json"), + servermock.CheckQueryParameter().Strict(). + With("filter", "name:eq('myView')"), + ). + Route("GET /api/v2/zones", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + filter := req.URL.Query().Get("filter") + + if strings.Contains(filter, internal.Eq("absoluteName", "example.com").String()) { + servermock.ResponseFromInternal("zones.json").ServeHTTP(rw, req) + + return + } + + servermock.ResponseFromInternal("error.json"). + WithStatusCode(http.StatusNotFound).ServeHTTP(rw, req) + }), + ). + Route("POST /api/v2/zones/12345/resourceRecords", + servermock.ResponseFromInternal("postZoneResourceRecord.json"), + servermock.CheckRequestJSONBodyFromInternal("postZoneResourceRecord-request.json"), + ). + Route("POST /api/v2/zones/456789/deployments", + servermock.Noop(). + WithStatusCode(http.StatusUnauthorized), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("POST /api/v2/sessions", + servermock.ResponseFromInternal("postSession.json"), + servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"), + ). + Route("DELETE /api/v2/resourceRecords/12345", + servermock.ResponseFromInternal("deleteResourceRecord.json"), + ). + Route("POST /api/v2/zones/456789/deployments", + servermock.ResponseFromInternal("postZoneDeployment.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBodyFromInternal("postZoneDeployment-request.json"), + ). + Build(t) + + provider.zoneIDs["abc"] = 456789 + provider.recordIDs["abc"] = 12345 + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp_skipDeploy(t *testing.T) { + defer envTest.RestoreEnv() + + envTest.ClearEnv() + + envTest.Apply(map[string]string{ + EnvSkipDeploy: "true", + }) + + provider := mockBuilder(). + Route("POST /api/v2/sessions", + servermock.ResponseFromInternal("postSession.json"), + servermock.CheckRequestJSONBodyFromInternal("postSession-request.json"), + ). + Route("DELETE /api/v2/resourceRecords/12345", + servermock.ResponseFromInternal("deleteResourceRecord.json"), + ). + Route("POST /api/v2/zones/456789/deployments", + servermock.Noop(). + WithStatusCode(http.StatusUnauthorized), + ). + Build(t) + + provider.zoneIDs["abc"] = 456789 + provider.recordIDs["abc"] = 12345 + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/bluecatv2/internal/client.go b/providers/dns/bluecatv2/internal/client.go new file mode 100644 index 000000000..d3c801154 --- /dev/null +++ b/providers/dns/bluecatv2/internal/client.go @@ -0,0 +1,221 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" + querystring "github.com/google/go-querystring/query" +) + +// Client the Bluecat v2 API client. +type Client struct { + username string + password string + + baseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(serverURL, username, password string) (*Client, error) { + if serverURL == "" { + return nil, errors.New("server URL missing") + } + + if username == "" || password == "" { + return nil, errors.New("credentials missing") + } + + baseURL, err := url.Parse(serverURL) + if err != nil { + return nil, err + } + + return &Client{ + username: username, + password: password, + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// RetrieveZones retrieves all zones. +func (c *Client) RetrieveZones(ctx context.Context, opts *CollectionOptions) ([]ZoneResource, error) { + endpoint := c.baseURL.JoinPath("api", "v2", "zones") + + collection, err := retrieveCollection[ZoneResource](ctx, c, endpoint, opts) + if err != nil { + return nil, err + } + + return collection.Data, nil +} + +// RetrieveZoneDeployments retrieves all deployments for a zone. +func (c *Client) RetrieveZoneDeployments(ctx context.Context, zoneID int64, opts *CollectionOptions) ([]QuickDeployment, error) { + endpoint := c.baseURL.JoinPath("api", "v2", "zones", strconv.FormatInt(zoneID, 10), "deployments") + + collection, err := retrieveCollection[QuickDeployment](ctx, c, endpoint, opts) + if err != nil { + return nil, err + } + + return collection.Data, nil +} + +// CreateZoneDeployment creates a new deployment for a zone. +func (c *Client) CreateZoneDeployment(ctx context.Context, zoneID int64) (*QuickDeployment, error) { + endpoint := c.baseURL.JoinPath("api", "v2", "zones", strconv.FormatInt(zoneID, 10), "deployments") + + payload := CommonResource{ + Type: "QuickDeployment", + } + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, payload) + if err != nil { + return nil, err + } + + result := new(QuickDeployment) + + err = c.doAuthenticated(ctx, req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +// CreateZoneResourceRecord creates a new TXT record in a zone. +func (c *Client) CreateZoneResourceRecord(ctx context.Context, zoneID int64, record RecordTXT) (*RecordTXT, error) { + endpoint := c.baseURL.JoinPath("api", "v2", "zones", strconv.FormatInt(zoneID, 10), "resourceRecords") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) + if err != nil { + return nil, err + } + + result := new(RecordTXT) + + err = c.doAuthenticated(ctx, req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +// DeleteResourceRecord deletes a resource record. +func (c *Client) DeleteResourceRecord(ctx context.Context, recordID int64) error { + endpoint := c.baseURL.JoinPath("api", "v2", "resourceRecords", strconv.FormatInt(recordID, 10)) + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + return c.doAuthenticated(ctx, req, nil) +} + +func (c *Client) do(req *http.Request, result any) error { + useragent.SetHeader(req.Header) + + 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 retrieveCollection[T any](ctx context.Context, client *Client, endpoint *url.URL, opts *CollectionOptions) (*Collection[T], error) { + if opts != nil { + values, err := querystring.Values(opts) + if err != nil { + return nil, err + } + + endpoint.RawQuery = values.Encode() + } + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + result := &Collection[T]{} + + err = client.doAuthenticated(ctx, req, result) + if err != nil { + return nil, err + } + + return result, 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 +} diff --git a/providers/dns/bluecatv2/internal/client_test.go b/providers/dns/bluecatv2/internal/client_test.go new file mode 100644 index 000000000..2559af66e --- /dev/null +++ b/providers/dns/bluecatv2/internal/client_test.go @@ -0,0 +1,208 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilderAuthenticated() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.URL, "userA", "secret") + if err != nil { + return nil, err + } + + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(), + servermock.CheckHeader(). + WithAuthorization("Basic secretToken"), + ) +} + +func TestClient_RetrieveZones(t *testing.T) { + client := mockBuilderAuthenticated(). + Route("GET /api/v2/zones", + servermock.ResponseFromFixture("zones.json"), + servermock.CheckQueryParameter().Strict(). + With( + "filter", + "absoluteName:eq('example.com') and configuration.name:eq('myConfiguration') and view.name:eq('myView')", + ), + ). + Build(t) + + opts := &CollectionOptions{ + Filter: And( + Eq("absoluteName", "example.com"), + Eq("configuration.name", "myConfiguration"), + Eq("view.name", "myView"), + ).String(), + } + + result, err := client.RetrieveZones(mockToken(t.Context()), opts) + require.NoError(t, err) + + expected := []ZoneResource{ + { + CommonResource: CommonResource{ID: 12345, Type: "ENUMZone", Name: "5678"}, + AbsoluteName: "string", + }, + { + CommonResource: CommonResource{ID: 12345, Type: "ExternalHostsZone", Name: "name"}, + }, + { + CommonResource: CommonResource{ID: 12345, Type: "InternalRootZone", Name: "name"}, + }, + { + CommonResource: CommonResource{ID: 12345, Type: "ResponsePolicyZone", Name: "name"}, + }, + { + CommonResource: CommonResource{ID: 12345, Type: "Zone", Name: "example.com"}, + AbsoluteName: "example.com", + }, + } + + assert.Equal(t, expected, result) +} + +func TestClient_RetrieveZones_error(t *testing.T) { + client := mockBuilderAuthenticated(). + Route("GET /api/v2/zones", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusUnauthorized), + ). + Build(t) + + opts := &CollectionOptions{ + Filter: And( + Eq("absoluteName", "example.com"), + Eq("configuration.name", "myConfiguration"), + Eq("view.name", "myView"), + ).String(), + } + + _, err := client.RetrieveZones(mockToken(t.Context()), opts) + require.EqualError(t, err, "401: Unauthorized: InvalidAuthorizationToken: The provided authorization token is invalid") +} + +func TestClient_RetrieveZoneDeployments(t *testing.T) { + client := mockBuilderAuthenticated(). + Route("GET /api/v2/zones/456789/deployments", + servermock.ResponseFromFixture("getZoneDeployments.json"), + servermock.CheckQueryParameter().Strict(). + With("filter", "id:eq('12345')"), + ). + Build(t) + + opts := &CollectionOptions{ + Filter: Eq("id", "12345").String(), + } + + result, err := client.RetrieveZoneDeployments(mockToken(t.Context()), 456789, opts) + require.NoError(t, err) + + expected := []QuickDeployment{ + { + CommonResource: CommonResource{ID: 12345, Type: "QuickDeployment", Name: ""}, + State: "PENDING", + Status: "CANCEL", + Message: "string", + PercentComplete: 50, + CreationDateTime: time.Date(2022, time.November, 23, 2, 53, 0, 0, time.UTC), + StartDateTime: time.Date(2022, time.November, 23, 2, 53, 3, 0, time.UTC), + CompletionDateTime: time.Date(2022, time.November, 23, 2, 54, 5, 0, time.UTC), + Method: "SCHEDULED", + }, + } + + assert.Equal(t, expected, result) +} + +func TestClient_CreateZoneDeployment(t *testing.T) { + client := mockBuilderAuthenticated(). + Route("POST /api/v2/zones/12345/deployments", + servermock.ResponseFromFixture("postZoneDeployment.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBodyFromFixture("postZoneDeployment-request.json"), + ). + Build(t) + + quickDeployment, err := client.CreateZoneDeployment(mockToken(t.Context()), 12345) + require.NoError(t, err) + + expected := &QuickDeployment{ + CommonResource: CommonResource{ID: 12345, Type: "QuickDeployment"}, + State: "PENDING", + Status: "CANCEL", + Message: "string", + PercentComplete: 50, + CreationDateTime: time.Date(2022, time.November, 23, 2, 53, 0, 0, time.UTC), + StartDateTime: time.Date(2022, time.November, 23, 2, 53, 3, 0, time.UTC), + CompletionDateTime: time.Date(2022, time.November, 23, 2, 54, 5, 0, time.UTC), + Method: "SCHEDULED", + } + + assert.Equal(t, expected, quickDeployment) +} + +func TestClient_CreateZoneResourceRecord(t *testing.T) { + client := mockBuilderAuthenticated(). + Route("POST /api/v2/zones/12345/resourceRecords", + servermock.ResponseFromFixture("postZoneResourceRecord.json"), + servermock.CheckRequestJSONBodyFromFixture("postZoneResourceRecord-request.json"), + ). + Build(t) + + record := RecordTXT{ + CommonResource: CommonResource{ + Type: "TXTRecord", + Name: "_acme-challenge", + }, + Text: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 120, + RecordType: "TXT", + } + + result, err := client.CreateZoneResourceRecord(mockToken(t.Context()), 12345, record) + require.NoError(t, err) + + expected := &RecordTXT{ + CommonResource: CommonResource{ + ID: 12345, + Type: "ResourceRecord", + Name: "name", + }, + TTL: 3600, + AbsoluteName: "host1.example.com", + Comment: "Sample comment.", + Dynamic: true, + RecordType: "CNAME", + Text: "", + } + + assert.Equal(t, expected, result) +} + +func TestClient_DeleteResourceRecord(t *testing.T) { + client := mockBuilderAuthenticated(). + Route("DELETE /api/v2/resourceRecords/12345", + servermock.ResponseFromFixture("deleteResourceRecord.json"), + ). + Build(t) + + err := client.DeleteResourceRecord(mockToken(t.Context()), 12345) + require.NoError(t, err) +} diff --git a/providers/dns/bluecatv2/internal/fixtures/deleteResourceRecord.json b/providers/dns/bluecatv2/internal/fixtures/deleteResourceRecord.json new file mode 100644 index 000000000..38ae2db6e --- /dev/null +++ b/providers/dns/bluecatv2/internal/fixtures/deleteResourceRecord.json @@ -0,0 +1,75 @@ +{ + "id": 12345, + "type": "WorkflowRequest", + "state": "APPROVED", + "operation": "ADD_ALIAS_RECORD", + "creator": { + "id": 103307, + "type": "User", + "name": "admin", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "authenticator": { + "id": 12345, + "type": "Authenticator", + "name": "LDAP authenticator" + }, + "email": "user@example.com", + "phoneNumber": "555-1234", + "securityPrivilege": "NO_ACCESS", + "historyPrivilege": "HIDE", + "accessType": "GUI", + "passwordResetRequired": true, + "accountLocked": true, + "x509Required": true, + "administrativeAccessRights": [ + { + "resourceType": "Event", + "accessLevel": "HIDE" + } + ] + }, + "resourceId": 0, + "resourceType": "ACL", + "fieldUpdates": [ + { + "name": "string", + "value": {}, + "previousValue": {} + } + ], + "dependentRequest": "string", + "modifier": { + "id": 103307, + "type": "User", + "name": "admin", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "authenticator": { + "id": 12345, + "type": "Authenticator", + "name": "LDAP authenticator" + }, + "email": "user@example.com", + "phoneNumber": "555-1234", + "securityPrivilege": "NO_ACCESS", + "historyPrivilege": "HIDE", + "accessType": "GUI", + "passwordResetRequired": true, + "accountLocked": true, + "x509Required": true, + "administrativeAccessRights": [ + { + "resourceType": "Event", + "accessLevel": "HIDE" + } + ] + }, + "creationDateTime": "2022-10-17T19:11:45Z", + "modificationDateTime": "2022-10-18T19:11:45Z", + "comment": "Sample comment." +} diff --git a/providers/dns/bluecatv2/internal/fixtures/error.json b/providers/dns/bluecatv2/internal/fixtures/error.json new file mode 100644 index 000000000..d3d2b8b5f --- /dev/null +++ b/providers/dns/bluecatv2/internal/fixtures/error.json @@ -0,0 +1,6 @@ +{ + "status": 401, + "reason": "Unauthorized", + "code": "InvalidAuthorizationToken", + "message": "The provided authorization token is invalid" +} diff --git a/providers/dns/bluecatv2/internal/fixtures/getZoneDeployments.json b/providers/dns/bluecatv2/internal/fixtures/getZoneDeployments.json new file mode 100644 index 000000000..b1a4938ad --- /dev/null +++ b/providers/dns/bluecatv2/internal/fixtures/getZoneDeployments.json @@ -0,0 +1,46 @@ +{ + "count": 0, + "totalCount": 0, + "data": [ + { + "id": 12345, + "type": "QuickDeployment", + "state": "PENDING", + "status": "CANCEL", + "message": "string", + "percentComplete": 50, + "creationDateTime": "2022-11-23T02:53:00Z", + "startDateTime": "2022-11-23T02:53:03Z", + "completionDateTime": "2022-11-23T02:54:05Z", + "user": { + "id": 103307, + "type": "User", + "name": "admin", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "authenticator": { + "id": 12345, + "type": "Authenticator", + "name": "LDAP authenticator" + }, + "email": "user@example.com", + "phoneNumber": "555-1234", + "securityPrivilege": "NO_ACCESS", + "historyPrivilege": "HIDE", + "accessType": "GUI", + "passwordResetRequired": true, + "accountLocked": true, + "x509Required": true, + "administrativeAccessRights": [ + { + "resourceType": "Event", + "accessLevel": "HIDE" + } + ] + }, + "method": "SCHEDULED" + } + ] +} diff --git a/providers/dns/bluecatv2/internal/fixtures/postSession-request.json b/providers/dns/bluecatv2/internal/fixtures/postSession-request.json new file mode 100644 index 000000000..e62048eb9 --- /dev/null +++ b/providers/dns/bluecatv2/internal/fixtures/postSession-request.json @@ -0,0 +1,4 @@ +{ + "username": "userA", + "password": "secret" +} diff --git a/providers/dns/bluecatv2/internal/fixtures/postSession.json b/providers/dns/bluecatv2/internal/fixtures/postSession.json new file mode 100644 index 000000000..4599ad0ad --- /dev/null +++ b/providers/dns/bluecatv2/internal/fixtures/postSession.json @@ -0,0 +1,50 @@ +{ + "id": 12345, + "type": "UserSession", + "apiToken": "VZoO2Z0BjBaJyvuhE4vNJRWqI9upwDHk70UNi0Ez", + "apiTokenExpirationDateTime": "2022-09-15T17:52:07Z", + "basicAuthenticationCredentials": "YXBpOlQ0OExOT3VIRGhDcnVBNEo1bGFES3JuS3hTZC9QK3pjczZXTzBJMDY=", + "remoteAddress": "192.168.1.1", + "readOnly": true, + "loginDateTime": "2022-09-14T17:45:03Z", + "logoutDateTime": "2022-09-14T19:45:03Z", + "state": "LOGGED_IN", + "response": "Authentication Error: Ensure that your username and password are correct.", + "user": { + "id": 103307, + "type": "User", + "name": "admin", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "authenticator": { + "id": 12345, + "type": "Authenticator", + "name": "LDAP authenticator" + }, + "email": "user@example.com", + "phoneNumber": "555-1234", + "securityPrivilege": "NO_ACCESS", + "historyPrivilege": "HIDE", + "accessType": "GUI", + "passwordResetRequired": true, + "accountLocked": true, + "x509Required": true, + "administrativeAccessRights": [ + { + "resourceType": "Event", + "accessLevel": "HIDE" + } + ] + }, + "authenticator": { + "id": 12345, + "type": "Authenticator", + "name": "LDAP authenticator", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + } + } +} diff --git a/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment-request.json b/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment-request.json new file mode 100644 index 000000000..099573a84 --- /dev/null +++ b/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment-request.json @@ -0,0 +1,3 @@ +{ + "type": "QuickDeployment" +} diff --git a/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment.json b/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment.json new file mode 100644 index 000000000..fd26781fb --- /dev/null +++ b/providers/dns/bluecatv2/internal/fixtures/postZoneDeployment.json @@ -0,0 +1,40 @@ +{ + "id": 12345, + "type": "QuickDeployment", + "state": "PENDING", + "status": "CANCEL", + "message": "string", + "percentComplete": 50, + "creationDateTime": "2022-11-23T02:53:00Z", + "startDateTime": "2022-11-23T02:53:03Z", + "completionDateTime": "2022-11-23T02:54:05Z", + "user": { + "id": 103307, + "type": "User", + "name": "admin", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "authenticator": { + "id": 12345, + "type": "Authenticator", + "name": "LDAP authenticator" + }, + "email": "user@example.com", + "phoneNumber": "555-1234", + "securityPrivilege": "NO_ACCESS", + "historyPrivilege": "HIDE", + "accessType": "GUI", + "passwordResetRequired": true, + "accountLocked": true, + "x509Required": true, + "administrativeAccessRights": [ + { + "resourceType": "Event", + "accessLevel": "HIDE" + } + ] + }, + "method": "SCHEDULED" +} diff --git a/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord-request.json b/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord-request.json new file mode 100644 index 000000000..2de733c71 --- /dev/null +++ b/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord-request.json @@ -0,0 +1,7 @@ +{ + "type": "TXTRecord", + "name": "_acme-challenge", + "ttl": 120, + "recordType": "TXT", + "text": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" +} diff --git a/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord.json b/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord.json new file mode 100644 index 000000000..78d028ee3 --- /dev/null +++ b/providers/dns/bluecatv2/internal/fixtures/postZoneResourceRecord.json @@ -0,0 +1,25 @@ +{ + "id": 12345, + "type": "ResourceRecord", + "name": "name", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "ttl": 3600, + "absoluteName": "host1.example.com", + "comment": "Sample comment.", + "dynamic": true, + "recordType": "CNAME", + "linkedRecord": { + "id": 12345, + "type": "ResourceRecord", + "name": "name", + "absoluteName": "host1.example.com" + } +} diff --git a/providers/dns/bluecatv2/internal/fixtures/zones.json b/providers/dns/bluecatv2/internal/fixtures/zones.json new file mode 100644 index 000000000..b9f2dfa8f --- /dev/null +++ b/providers/dns/bluecatv2/internal/fixtures/zones.json @@ -0,0 +1,185 @@ +{ + "count": 0, + "totalCount": 0, + "data": [ + { + "id": 12345, + "type": "ENUMZone", + "name": "5678", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "view": { + "id": 12345, + "type": "View", + "name": "default", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "deviceRegistrationEnabled": true, + "deviceRegistrationPortalAddress": "10.10.10.10" + }, + "deploymentEnabled": true, + "absoluteName": "string" + }, + { + "id": 12345, + "type": "ExternalHostsZone", + "name": "name", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "view": { + "id": 12345, + "type": "View", + "name": "default", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "deviceRegistrationEnabled": true, + "deviceRegistrationPortalAddress": "10.10.10.10" + } + }, + { + "id": 12345, + "type": "InternalRootZone", + "name": "name", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "view": { + "id": 12345, + "type": "View", + "name": "default", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "deviceRegistrationEnabled": true, + "deviceRegistrationPortalAddress": "10.10.10.10" + }, + "deploymentEnabled": true + }, + { + "id": 12345, + "type": "ResponsePolicyZone", + "name": "name", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "view": { + "id": 12345, + "type": "View", + "name": "default", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "deviceRegistrationEnabled": true, + "deviceRegistrationPortalAddress": "10.10.10.10" + }, + "responsePolicyZoneType": "LOCAL", + "responsePolicy": { + "id": 12345, + "type": "ResponsePolicy", + "name": "Block Response Policy" + }, + "overridePolicyType": "ALLOWLIST", + "overrideRefreshTime": "string", + "redirectTarget": "string", + "feedCategories": [ + "string" + ] + }, + { + "id": 12345, + "type": "Zone", + "name": "example.com", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "view": { + "id": 12345, + "type": "View", + "name": "default", + "userDefinedFields": { + "udf1": "value1", + "udf2": "value2" + }, + "configuration": { + "id": 12345, + "type": "Configuration", + "name": "name" + }, + "deviceRegistrationEnabled": true, + "deviceRegistrationPortalAddress": "10.10.10.10" + }, + "deploymentEnabled": true, + "dynamicUpdateEnabled": true, + "template": { + "id": 12345, + "type": "ZoneTemplate", + "name": "name" + }, + "signed": true, + "signingPolicy": { + "id": 12345, + "type": "DNSSECSigningPolicy", + "name": "name" + }, + "absoluteName": "example.com" + } + ] +} diff --git a/providers/dns/bluecatv2/internal/identity.go b/providers/dns/bluecatv2/internal/identity.go new file mode 100644 index 000000000..af9355ab2 --- /dev/null +++ b/providers/dns/bluecatv2/internal/identity.go @@ -0,0 +1,60 @@ +package internal + +import ( + "context" + "fmt" + "net/http" +) + +type token string + +const tokenKey token = "token" + +const authorizationHeader = "Authorization" + +// CreateSession creates a new session. +func (c *Client) CreateSession(ctx context.Context, info LoginInfo) (*Session, error) { + endpoint := c.baseURL.JoinPath("api", "v2", "sessions") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, info) + if err != nil { + return nil, err + } + + result := new(Session) + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +// CreateAuthenticatedContext creates a new authenticated context. +func (c *Client) CreateAuthenticatedContext(ctx context.Context) (context.Context, error) { + tok, err := c.CreateSession(ctx, LoginInfo{Username: c.username, Password: c.password}) + if err != nil { + return nil, fmt.Errorf("create session: %w", err) + } + + return context.WithValue(ctx, tokenKey, tok.BasicAuthenticationCredentials), nil +} + +func (c *Client) doAuthenticated(ctx context.Context, req *http.Request, result any) error { + tok := getToken(ctx) + if tok != "" { + req.Header.Set(authorizationHeader, "Basic "+tok) + } + + return c.do(req, result) +} + +func getToken(ctx context.Context) string { + tok, ok := ctx.Value(tokenKey).(string) + if !ok { + return "" + } + + return tok +} diff --git a/providers/dns/bluecatv2/internal/identity_test.go b/providers/dns/bluecatv2/internal/identity_test.go new file mode 100644 index 000000000..3a1c4d2a2 --- /dev/null +++ b/providers/dns/bluecatv2/internal/identity_test.go @@ -0,0 +1,82 @@ +package internal + +import ( + "context" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.URL, "userA", "secret") + if err != nil { + return nil, err + } + + client.baseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(), + ) +} + +func mockToken(ctx context.Context) context.Context { + return context.WithValue(ctx, tokenKey, "secretToken") +} + +func TestClient_CreateSession(t *testing.T) { + client := mockBuilder(). + Route("POST /api/v2/sessions", + servermock.ResponseFromFixture("postSession.json"), + servermock.CheckRequestJSONBodyFromFixture("postSession-request.json"), + ). + Build(t) + + info := LoginInfo{ + Username: "userA", + Password: "secret", + } + + result, err := client.CreateSession(mockToken(t.Context()), info) + require.NoError(t, err) + + expected := &Session{ + ID: 12345, + Type: "UserSession", + APIToken: "VZoO2Z0BjBaJyvuhE4vNJRWqI9upwDHk70UNi0Ez", + APITokenExpirationDateTime: time.Date(2022, time.September, 15, 17, 52, 7, 0, time.UTC), + BasicAuthenticationCredentials: "YXBpOlQ0OExOT3VIRGhDcnVBNEo1bGFES3JuS3hTZC9QK3pjczZXTzBJMDY=", + RemoteAddress: "192.168.1.1", + ReadOnly: true, + LoginDateTime: time.Date(2022, time.September, 14, 17, 45, 3, 0, time.UTC), + LogoutDateTime: time.Date(2022, time.September, 14, 19, 45, 3, 0, time.UTC), + State: "LOGGED_IN", + Response: "Authentication Error: Ensure that your username and password are correct.", + } + + assert.Equal(t, expected, result) +} + +func TestClient_CreateAuthenticatedContext(t *testing.T) { + client := mockBuilder(). + Route("POST /api/v2/sessions", + servermock.ResponseFromFixture("postSession.json"), + servermock.CheckRequestJSONBodyFromFixture("postSession-request.json"), + ). + Build(t) + + ctx, err := client.CreateAuthenticatedContext(t.Context()) + require.NoError(t, err) + + assert.Equal(t, "YXBpOlQ0OExOT3VIRGhDcnVBNEo1bGFES3JuS3hTZC9QK3pjczZXTzBJMDY=", getToken(ctx)) +} diff --git a/providers/dns/bluecatv2/internal/predicates.go b/providers/dns/bluecatv2/internal/predicates.go new file mode 100644 index 000000000..8ed6f714b --- /dev/null +++ b/providers/dns/bluecatv2/internal/predicates.go @@ -0,0 +1,64 @@ +package internal + +import ( + "fmt" + "strings" +) + +type Predicate struct { + field string + operator string + values []string +} + +func (p *Predicate) String() string { + var values []string + for _, v := range p.values { + values = append(values, fmt.Sprintf("'%s'", v)) + } + + return fmt.Sprintf("%s:%s(%s)", p.field, p.operator, strings.Join(values, ", ")) +} + +func Eq(field, value string) *Predicate { + return &Predicate{field: field, operator: "eq", values: []string{value}} +} + +func Contains(field, value string) *Predicate { + return &Predicate{field: field, operator: "contains", values: []string{value}} +} + +func StartsWith(field, value string) *Predicate { + return &Predicate{field: field, operator: "startsWith", values: []string{value}} +} + +func EndsWith(field, value string) *Predicate { + return &Predicate{field: field, operator: "endsWith", values: []string{value}} +} + +func In(field string, values ...string) *Predicate { + return &Predicate{field: field, operator: "in", values: values} +} + +type Combined struct { + predicates []*Predicate + operator string +} + +func (o *Combined) String() string { + var parts []string + + for _, predicate := range o.predicates { + parts = append(parts, predicate.String()) + } + + return strings.Join(parts, " "+o.operator+" ") +} + +func And(predicates ...*Predicate) *Combined { + return &Combined{predicates: predicates, operator: "and"} +} + +func Or(predicates ...*Predicate) *Combined { + return &Combined{predicates: predicates, operator: "or"} +} diff --git a/providers/dns/bluecatv2/internal/predicates_test.go b/providers/dns/bluecatv2/internal/predicates_test.go new file mode 100644 index 000000000..6913e8729 --- /dev/null +++ b/providers/dns/bluecatv2/internal/predicates_test.go @@ -0,0 +1,78 @@ +package internal + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPredicate(t *testing.T) { + testCases := []struct { + desc string + predicate fmt.Stringer + expected string + }{ + { + desc: "Equals", + predicate: Eq("foo", "bar"), + expected: "foo:eq('bar')", + }, + { + desc: "Contains", + predicate: Contains("foo", "bar"), + expected: "foo:contains('bar')", + }, + { + desc: "Starts with", + predicate: StartsWith("foo", "bar"), + expected: "foo:startsWith('bar')", + }, + { + desc: "Ends with", + predicate: EndsWith("foo", "bar"), + expected: "foo:endsWith('bar')", + }, + { + desc: "Match a list of values", + predicate: In("foo", "bar", "bir"), + expected: "foo:in('bar', 'bir')", + }, + { + desc: "Combined: and", + predicate: And(Eq("foo", "bar"), Eq("fii", "bir")), + expected: "foo:eq('bar') and fii:eq('bir')", + }, + { + desc: "Combined: multiple and", + predicate: And( + Eq("foo", "bar"), + Eq("fii", "bir"), + Eq("fuu", "bur"), + ), + expected: "foo:eq('bar') and fii:eq('bir') and fuu:eq('bur')", + }, + { + desc: "Combined: or", + predicate: Or(Eq("foo", "bar"), Eq("foo", "bir")), + expected: "foo:eq('bar') or foo:eq('bir')", + }, + { + desc: "Combined: multiple or", + predicate: Or( + Eq("foo", "bar"), + Eq("foo", "bir"), + Eq("foo", "bur"), + ), + expected: "foo:eq('bar') or foo:eq('bir') or foo:eq('bur')", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, test.expected, test.predicate.String()) + }) + } +} diff --git a/providers/dns/bluecatv2/internal/types.go b/providers/dns/bluecatv2/internal/types.go new file mode 100644 index 000000000..562fd60b0 --- /dev/null +++ b/providers/dns/bluecatv2/internal/types.go @@ -0,0 +1,122 @@ +package internal + +import ( + "fmt" + "time" +) + +// Quick deployment states. +// +//nolint:misspell // US vs UK +const ( + QDStatePending = "PENDING" + QDStateQueued = "QUEUED" + QDStateRunning = "RUNNING" + QDStateCancelled = "CANCELLED" + QDStateCancelling = "CANCELLING" + QDStateCompleted = "COMPLETED" + QDStateCompletedWithErrors = "COMPLETED_WITH_ERRORS" + QDStateCompletedWithWarnings = "COMPLETED_WITH_WARNINGS" + QDStateFailed = "FAILED" + QDStateUnknown = "UNKNOWN" +) + +// APIError represents an error. +// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Errors/9.6.0 +type APIError struct { + Status int `json:"status"` + Reason string `json:"reason"` + Code string `json:"code"` + Message string `json:"message"` +} + +func (a *APIError) Error() string { + return fmt.Sprintf("%d: %s: %s: %s", a.Status, a.Reason, a.Code, a.Message) +} + +// CommonResource represents the common resource fields. +// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Resources/9.6.0 +type CommonResource struct { + ID int64 `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` +} + +// Collection represents a collection of resources. +// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Collections/9.6.0 +type Collection[T any] struct { + Count int64 `json:"count"` + TotalCount int64 `json:"totalCount"` + Data []T `json:"data"` +} + +type CollectionOptions struct { + // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Fields/9.6.0 + Fields string `url:"fields,omitempty"` + + // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Pagination/9.6.0 + Limit int `url:"limit,omitempty"` + Offset int `url:"offset,omitempty"` + + // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Filter/9.6.0 + Filter string `url:"filter,omitempty"` + + // https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Ordering/9.6.0 + OrderBy string `url:"orderBy,omitempty"` + + // Should return or not the total number of resources matching the query. + Total bool `url:"total,omitempty"` +} + +type RecordTXT struct { + CommonResource + + TTL int `json:"ttl,omitempty"` + AbsoluteName string `json:"absoluteName,omitempty"` + Comment string `json:"comment,omitempty"` + Dynamic bool `json:"dynamic,omitempty"` + RecordType string `json:"recordType,omitempty"` + Text string `json:"text,omitempty"` +} + +type ZoneResource struct { + CommonResource + + AbsoluteName string `json:"absoluteName,omitempty"` +} + +type QuickDeployment struct { + CommonResource + + State string `json:"state,omitempty"` + Status string `json:"status,omitempty"` + Message string `json:"message,omitempty"` + PercentComplete int `json:"percentComplete,omitempty"` + CreationDateTime time.Time `json:"creationDateTime,omitzero"` + StartDateTime time.Time `json:"startDateTime,omitzero"` + CompletionDateTime time.Time `json:"completionDateTime,omitzero"` + Method string `json:"method,omitempty"` +} + +// LoginInfo represents the login information. +// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Creating-an-API-session/9.6.0 +type LoginInfo struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// Session represents the session. +// https://docs.bluecatnetworks.com/r/Address-Manager-RESTful-v2-API-Guide/Creating-an-API-session/9.6.0 +type Session struct { + ID int `json:"id"` + Type string `json:"type"` + APIToken string `json:"apiToken"` + APITokenExpirationDateTime time.Time `json:"apiTokenExpirationDateTime"` + BasicAuthenticationCredentials string `json:"basicAuthenticationCredentials"` + RemoteAddress string `json:"remoteAddress"` + ReadOnly bool `json:"readOnly"` + LoginDateTime time.Time `json:"loginDateTime"` + LogoutDateTime time.Time `json:"logoutDateTime"` + State string `json:"state"` + Response string `json:"response"` +} diff --git a/providers/dns/zz_gen_dns_providers.go b/providers/dns/zz_gen_dns_providers.go index b4b98e23f..ae41f6a20 100644 --- a/providers/dns/zz_gen_dns_providers.go +++ b/providers/dns/zz_gen_dns_providers.go @@ -25,6 +25,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/binarylane" "github.com/go-acme/lego/v4/providers/dns/bindman" "github.com/go-acme/lego/v4/providers/dns/bluecat" + "github.com/go-acme/lego/v4/providers/dns/bluecatv2" "github.com/go-acme/lego/v4/providers/dns/bookmyname" "github.com/go-acme/lego/v4/providers/dns/brandit" "github.com/go-acme/lego/v4/providers/dns/bunny" @@ -233,6 +234,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return bindman.NewDNSProvider() case "bluecat": return bluecat.NewDNSProvider() + case "bluecatv2": + return bluecatv2.NewDNSProvider() case "bookmyname": return bookmyname.NewDNSProvider() case "brandit": From 16894fb99e3aa60fe0a5f9edcbea7a5fb9d32f34 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Tue, 20 Jan 2026 17:59:42 +0100 Subject: [PATCH 02/21] allinkl: detect zone through API (#2721) --- providers/dns/allinkl/allinkl.go | 24 +++- providers/dns/allinkl/allinkl_test.go | 114 ++++++++++++++++++ providers/dns/allinkl/internal/client.go | 87 ++++++++----- providers/dns/allinkl/internal/client_test.go | 27 ++++- .../internal/fixtures/auth-request.xml | 7 ++ .../internal/fixtures/flood_protection.xml | 11 ++ .../get_dns_settings-zone_not_found.xml | 11 ++ ...get_dns_settings-zone_syntax_incorrect.xml | 11 ++ providers/dns/allinkl/internal/identity.go | 22 ++-- .../dns/allinkl/internal/identity_test.go | 12 +- providers/dns/allinkl/internal/types.go | 5 +- providers/dns/allinkl/internal/types_api.go | 12 +- 12 files changed, 277 insertions(+), 66 deletions(-) create mode 100644 providers/dns/allinkl/internal/fixtures/auth-request.xml create mode 100644 providers/dns/allinkl/internal/fixtures/flood_protection.xml create mode 100644 providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_not_found.xml create mode 100644 providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_syntax_incorrect.xml diff --git a/providers/dns/allinkl/allinkl.go b/providers/dns/allinkl/allinkl.go index 4a0aadd2b..0ccce7226 100644 --- a/providers/dns/allinkl/allinkl.go +++ b/providers/dns/allinkl/allinkl.go @@ -11,6 +11,7 @@ import ( "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/allinkl/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" @@ -121,11 +122,6 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) - authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) - if err != nil { - return fmt.Errorf("allinkl: could not find zone for domain %q: %w", domain, err) - } - ctx := context.Background() credential, err := d.identifier.Authentication(ctx, 60, true) @@ -135,6 +131,24 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx = internal.WithContext(ctx, credential) + var authZone string + + for z := range dns01.DomainsSeq(info.EffectiveFQDN) { + _, errG := d.client.GetDNSSettings(ctx, z, "") + if errG != nil { + log.Infof("allinkl: get DNS settings zone[%q] %v", z, errG) + continue + } + + authZone = z + + break + } + + if authZone == "" { + return fmt.Errorf("allinkl: unable to find auth zone for '%s'", info.EffectiveFQDN) + } + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("allinkl: %w", err) diff --git a/providers/dns/allinkl/allinkl_test.go b/providers/dns/allinkl/allinkl_test.go index b42adce5d..7da47aee4 100644 --- a/providers/dns/allinkl/allinkl_test.go +++ b/providers/dns/allinkl/allinkl_test.go @@ -1,9 +1,18 @@ package allinkl import ( + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/go-acme/lego/v4/providers/dns/allinkl/internal" "github.com/stretchr/testify/require" ) @@ -143,3 +152,108 @@ func TestLiveCleanUp(t *testing.T) { err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.Login = "user" + config.Password = "secret" + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + p.identifier.BaseURL, _ = url.Parse(server.URL) + + return p, err + }, + ).Route("POST /KasAuth.php", + servermock.ResponseFromInternal("auth.xml"), + servermock.CheckRequestBodyFromInternal("auth-request.xml"). + IgnoreWhitespace(), + ) +} + +func extractKasRequest(reader io.Reader) (*internal.KasRequest, error) { + type ReqEnvelope struct { + XMLName xml.Name `xml:"Envelope"` + Body struct { + KasAPI struct { + Params string `xml:"Params"` + } `xml:"KasApi"` + } `xml:"Body"` + } + + raw, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + + reqEnvelope := ReqEnvelope{} + + err = xml.Unmarshal(raw, &reqEnvelope) + if err != nil { + return nil, err + } + + var kReq internal.KasRequest + + err = json.Unmarshal([]byte(reqEnvelope.Body.KasAPI.Params), &kReq) + if err != nil { + return nil, err + } + + return &kReq, nil +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("POST /KasApi.php", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + kReq, err := extractKasRequest(req.Body) + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + switch kReq.Action { + case "get_dns_settings": + params := kReq.RequestParams.(map[string]any) + + if params["zone_host"] == "_acme-challenge.example.com." { + servermock.ResponseFromInternal("get_dns_settings_not_found.xml").ServeHTTP(rw, req) + } else { + servermock.ResponseFromInternal("get_dns_settings.xml").ServeHTTP(rw, req) + } + + case "add_dns_settings": + servermock.ResponseFromInternal("add_dns_settings.xml").ServeHTTP(rw, req) + + default: + http.Error(rw, fmt.Sprintf("unknown action: %v", kReq.Action), http.StatusBadRequest) + } + }), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("POST /KasApi.php", + servermock.ResponseFromInternal("delete_dns_settings.xml"), + servermock.CheckRequestBodyFromInternal("delete_dns_settings-request.xml"). + IgnoreWhitespace()). + Build(t) + + provider.recordIDs["abc"] = "57347450" + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/allinkl/internal/client.go b/providers/dns/allinkl/internal/client.go index d747e9b36..d4403cac5 100644 --- a/providers/dns/allinkl/internal/client.go +++ b/providers/dns/allinkl/internal/client.go @@ -6,16 +6,21 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "strconv" "strings" "sync" "time" + "github.com/cenkalti/backoff/v5" + "github.com/go-acme/lego/v4/platform/wait" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-viper/mapstructure/v2" ) -const apiEndpoint = "https://kasapi.kasserver.com/soap/KasApi.php" +const defaultBaseURL = "https://kasapi.kasserver.com/soap/" + +const apiPath = "KasApi.php" type Authentication interface { Authentication(ctx context.Context, sessionLifetime int, sessionUpdateLifetime bool) (string, error) @@ -28,16 +33,21 @@ type Client struct { floodTime time.Time muFloodTime sync.Mutex - baseURL string + maxElapsedTime time.Duration + + BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(login string) *Client { + baseURL, _ := url.Parse(defaultBaseURL) + return &Client{ - login: login, - baseURL: apiEndpoint, - HTTPClient: &http.Client{Timeout: 10 * time.Second}, + login: login, + BaseURL: baseURL, + maxElapsedTime: 3 * time.Minute, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } @@ -51,14 +61,9 @@ func (c *Client) GetDNSSettings(ctx context.Context, zone, recordID string) ([]R requestParams["record_id"] = recordID } - req, err := c.newRequest(ctx, "get_dns_settings", requestParams) - if err != nil { - return nil, err - } + var g APIResponse[GetDNSSettingsResponse] - var g GetDNSSettingsAPIResponse - - err = c.do(req, &g) + err := c.doRequest(ctx, "get_dns_settings", requestParams, &g) if err != nil { return nil, err } @@ -70,14 +75,9 @@ func (c *Client) GetDNSSettings(ctx context.Context, zone, recordID string) ([]R // AddDNSSettings Creation of a DNS resource record. func (c *Client) AddDNSSettings(ctx context.Context, record DNSRequest) (string, error) { - req, err := c.newRequest(ctx, "add_dns_settings", record) - if err != nil { - return "", err - } + var g APIResponse[AddDNSSettingsResponse] - var g AddDNSSettingsAPIResponse - - err = c.do(req, &g) + err := c.doRequest(ctx, "add_dns_settings", record, &g) if err != nil { return "", err } @@ -91,14 +91,9 @@ func (c *Client) AddDNSSettings(ctx context.Context, record DNSRequest) (string, func (c *Client) DeleteDNSSettings(ctx context.Context, recordID string) (string, error) { requestParams := map[string]string{"record_id": recordID} - req, err := c.newRequest(ctx, "delete_dns_settings", requestParams) - if err != nil { - return "", err - } + var g APIResponse[DeleteDNSSettingsResponse] - var g DeleteDNSSettingsAPIResponse - - err = c.do(req, &g) + err := c.doRequest(ctx, "delete_dns_settings", requestParams, &g) if err != nil { return "", err } @@ -124,7 +119,9 @@ func (c *Client) newRequest(ctx context.Context, action string, requestParams an payload := []byte(strings.TrimSpace(fmt.Sprintf(kasAPIEnvelope, body))) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewReader(payload)) + endpoint := c.BaseURL.JoinPath(apiPath) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload)) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } @@ -132,6 +129,21 @@ func (c *Client) newRequest(ctx context.Context, action string, requestParams an return req, nil } +func (c *Client) doRequest(ctx context.Context, action string, requestParams, result any) error { + return wait.Retry(ctx, + func() error { + req, err := c.newRequest(ctx, action, requestParams) + if err != nil { + return backoff.Permanent(err) + } + + return c.do(req, result) + }, + backoff.WithBackOff(&backoff.ZeroBackOff{}), + backoff.WithMaxElapsedTime(c.maxElapsedTime), + ) +} + func (c *Client) do(req *http.Request, result any) error { c.muFloodTime.Lock() time.Sleep(time.Until(c.floodTime)) @@ -139,29 +151,40 @@ func (c *Client) do(req *http.Request, result any) error { resp, err := c.HTTPClient.Do(req) if err != nil { - return errutils.NewHTTPDoError(req, err) + return backoff.Permanent(errutils.NewHTTPDoError(req, err)) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return errutils.NewUnexpectedResponseStatusCodeError(req, resp) + return backoff.Permanent(errutils.NewUnexpectedResponseStatusCodeError(req, resp)) } envlp, err := decodeXML[KasAPIResponseEnvelope](resp.Body) if err != nil { - return err + return backoff.Permanent(err) } if envlp.Body.Fault != nil { - return envlp.Body.Fault + if envlp.Body.Fault.Message == "flood_protection" { + ft, errP := strconv.ParseFloat(envlp.Body.Fault.Detail, 64) + if errP != nil { + return fmt.Errorf("parse flood protection delay: %w", envlp.Body.Fault) + } + + c.updateFloodTime(ft) + + return envlp.Body.Fault + } + + return backoff.Permanent(envlp.Body.Fault) } raw := getValue(envlp.Body.KasAPIResponse.Return) err = mapstructure.Decode(raw, result) if err != nil { - return fmt.Errorf("response struct decode: %w", err) + return backoff.Permanent(fmt.Errorf("response struct decode: %w", err)) } return nil diff --git a/providers/dns/allinkl/internal/client_test.go b/providers/dns/allinkl/internal/client_test.go index 4b111e31c..949f45bf9 100644 --- a/providers/dns/allinkl/internal/client_test.go +++ b/providers/dns/allinkl/internal/client_test.go @@ -2,7 +2,9 @@ package internal import ( "net/http/httptest" + "net/url" "testing" + "time" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" @@ -11,15 +13,17 @@ import ( func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("user") - client.baseURL = server.URL + client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() + client.maxElapsedTime = 1 * time.Second + return client, nil } func TestClient_GetDNSSettings(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). - Route("POST /", servermock.ResponseFromFixture("get_dns_settings.xml"), + Route("POST /KasApi.php", servermock.ResponseFromFixture("get_dns_settings.xml"), servermock.CheckRequestBodyFromFixture("get_dns_settings-request.xml"). IgnoreWhitespace()). Build(t) @@ -96,9 +100,24 @@ func TestClient_GetDNSSettings(t *testing.T) { assert.Equal(t, expected, records) } +func TestClient_GetDNSSettings_error_flood_protection(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /KasApi.php", + servermock.ResponseFromFixture("flood_protection.xml"), + ). + Build(t) + + assert.Zero(t, client.floodTime) + + _, err := client.GetDNSSettings(mockContext(t), "example.com", "") + require.EqualError(t, err, "KasApi: SOAP-ENV:Server: flood_protection: 0.0688529014587") + + assert.NotZero(t, client.floodTime) +} + func TestClient_AddDNSSettings(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). - Route("POST /", servermock.ResponseFromFixture("add_dns_settings.xml"), + Route("POST /KasApi.php", servermock.ResponseFromFixture("add_dns_settings.xml"), servermock.CheckRequestBodyFromFixture("add_dns_settings-request.xml"). IgnoreWhitespace()). Build(t) @@ -118,7 +137,7 @@ func TestClient_AddDNSSettings(t *testing.T) { func TestClient_DeleteDNSSettings(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). - Route("POST /", servermock.ResponseFromFixture("delete_dns_settings.xml"), + Route("POST /KasApi.php", servermock.ResponseFromFixture("delete_dns_settings.xml"), servermock.CheckRequestBodyFromFixture("delete_dns_settings-request.xml"). IgnoreWhitespace()). Build(t) diff --git a/providers/dns/allinkl/internal/fixtures/auth-request.xml b/providers/dns/allinkl/internal/fixtures/auth-request.xml new file mode 100644 index 000000000..1cba86f10 --- /dev/null +++ b/providers/dns/allinkl/internal/fixtures/auth-request.xml @@ -0,0 +1,7 @@ + + + + {"kas_login":"user","kas_auth_data":"secret","kas_auth_type":"plain","session_lifetime":60,"session_update_lifetime":"Y"} + + + diff --git a/providers/dns/allinkl/internal/fixtures/flood_protection.xml b/providers/dns/allinkl/internal/fixtures/flood_protection.xml new file mode 100644 index 000000000..b8da10fab --- /dev/null +++ b/providers/dns/allinkl/internal/fixtures/flood_protection.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + flood_protection + KasApi + 0.0688529014587 + + + diff --git a/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_not_found.xml b/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_not_found.xml new file mode 100644 index 000000000..478d07a3a --- /dev/null +++ b/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_not_found.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + zone_not_found + KasApi + example.com + + + diff --git a/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_syntax_incorrect.xml b/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_syntax_incorrect.xml new file mode 100644 index 000000000..c77d733db --- /dev/null +++ b/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + zone_syntax_incorrect + KasApi + _acme-challenge.example.com + + + diff --git a/providers/dns/allinkl/internal/identity.go b/providers/dns/allinkl/internal/identity.go index ba8d4d90e..e95e78899 100644 --- a/providers/dns/allinkl/internal/identity.go +++ b/providers/dns/allinkl/internal/identity.go @@ -6,14 +6,14 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) -// authEndpoint represents the Identity API endpoint to call. -const authEndpoint = "https://kasapi.kasserver.com/soap/KasAuth.php" +const authPath = "KasAuth.php" type token string @@ -24,17 +24,19 @@ type Identifier struct { login string password string - authEndpoint string - HTTPClient *http.Client + BaseURL *url.URL + HTTPClient *http.Client } // NewIdentifier creates a new Identifier. func NewIdentifier(login, password string) *Identifier { + baseURL, _ := url.Parse(defaultBaseURL) + return &Identifier{ - login: login, - password: password, - authEndpoint: authEndpoint, - HTTPClient: &http.Client{Timeout: 10 * time.Second}, + login: login, + password: password, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } @@ -62,7 +64,9 @@ func (c *Identifier) Authentication(ctx context.Context, sessionLifetime int, se payload := []byte(strings.TrimSpace(fmt.Sprintf(kasAuthEnvelope, body))) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.authEndpoint, bytes.NewReader(payload)) + endpoint := c.BaseURL.JoinPath(authPath) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload)) if err != nil { return "", fmt.Errorf("unable to create request: %w", err) } diff --git a/providers/dns/allinkl/internal/identity_test.go b/providers/dns/allinkl/internal/identity_test.go index 7b93b7688..41d092b13 100644 --- a/providers/dns/allinkl/internal/identity_test.go +++ b/providers/dns/allinkl/internal/identity_test.go @@ -3,6 +3,7 @@ package internal import ( "context" "net/http/httptest" + "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" @@ -12,7 +13,7 @@ import ( func setupIdentifierClient(server *httptest.Server) (*Identifier, error) { client := NewIdentifier("user", "secret") - client.authEndpoint = server.URL + client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil @@ -26,10 +27,13 @@ func mockContext(t *testing.T) context.Context { func TestIdentifier_Authentication(t *testing.T) { client := servermock.NewBuilder[*Identifier](setupIdentifierClient). - Route("POST /", servermock.ResponseFromFixture("auth.xml")). + Route("POST /KasAuth.php", + servermock.ResponseFromFixture("auth.xml"), + servermock.CheckRequestBodyFromFixture("auth-request.xml"). + IgnoreWhitespace()). Build(t) - credentialToken, err := client.Authentication(t.Context(), 60, false) + credentialToken, err := client.Authentication(t.Context(), 60, true) require.NoError(t, err) assert.Equal(t, "593959ca04f0de9689b586c6a647d15d", credentialToken) @@ -37,7 +41,7 @@ func TestIdentifier_Authentication(t *testing.T) { func TestIdentifier_Authentication_error(t *testing.T) { client := servermock.NewBuilder[*Identifier](setupIdentifierClient). - Route("POST /", servermock.ResponseFromFixture("auth_fault.xml")). + Route("POST /KasAuth.php", servermock.ResponseFromFixture("auth_fault.xml")). Build(t) _, err := client.Authentication(t.Context(), 60, false) diff --git a/providers/dns/allinkl/internal/types.go b/providers/dns/allinkl/internal/types.go index b0aa9b4ff..51f7065b5 100644 --- a/providers/dns/allinkl/internal/types.go +++ b/providers/dns/allinkl/internal/types.go @@ -26,10 +26,11 @@ type Fault struct { Code string `xml:"faultcode"` Message string `xml:"faultstring"` Actor string `xml:"faultactor"` + Detail string `xml:"detail"` } -func (f Fault) Error() string { - return fmt.Sprintf("%s: %s: %s", f.Actor, f.Code, f.Message) +func (f *Fault) Error() string { + return fmt.Sprintf("%s: %s: %s: %s", f.Actor, f.Code, f.Message, f.Detail) } // KasResponse a KAS SOAP response. diff --git a/providers/dns/allinkl/internal/types_api.go b/providers/dns/allinkl/internal/types_api.go index 22f2c32ed..a11f3aac0 100644 --- a/providers/dns/allinkl/internal/types_api.go +++ b/providers/dns/allinkl/internal/types_api.go @@ -53,8 +53,8 @@ type DNSRequest struct { // --- -type GetDNSSettingsAPIResponse struct { - Response GetDNSSettingsResponse `json:"Response" mapstructure:"Response"` +type APIResponse[T any] struct { + Response T `json:"Response" mapstructure:"Response"` } type GetDNSSettingsResponse struct { @@ -73,20 +73,12 @@ type ReturnInfo struct { Aux int `json:"record_aux,omitempty" mapstructure:"record_aux"` } -type AddDNSSettingsAPIResponse struct { - Response AddDNSSettingsResponse `json:"Response" mapstructure:"Response"` -} - type AddDNSSettingsResponse struct { KasFloodDelay float64 `json:"KasFloodDelay" mapstructure:"KasFloodDelay"` ReturnInfo string `json:"ReturnInfo" mapstructure:"ReturnInfo"` ReturnString string `json:"ReturnString" mapstructure:"ReturnString"` } -type DeleteDNSSettingsAPIResponse struct { - Response DeleteDNSSettingsResponse `json:"Response"` -} - type DeleteDNSSettingsResponse struct { KasFloodDelay float64 `json:"KasFloodDelay"` ReturnString string `json:"ReturnString"` From 44b89b7e929c78575d28a9c35be0427ed06b8628 Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Thu, 22 Jan 2026 05:10:35 +0100 Subject: [PATCH 03/21] allinkl: factorize findZone --- providers/dns/allinkl/allinkl.go | 33 ++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/providers/dns/allinkl/allinkl.go b/providers/dns/allinkl/allinkl.go index 0ccce7226..376b0903c 100644 --- a/providers/dns/allinkl/allinkl.go +++ b/providers/dns/allinkl/allinkl.go @@ -131,22 +131,9 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx = internal.WithContext(ctx, credential) - var authZone string - - for z := range dns01.DomainsSeq(info.EffectiveFQDN) { - _, errG := d.client.GetDNSSettings(ctx, z, "") - if errG != nil { - log.Infof("allinkl: get DNS settings zone[%q] %v", z, errG) - continue - } - - authZone = z - - break - } - - if authZone == "" { - return fmt.Errorf("allinkl: unable to find auth zone for '%s'", info.EffectiveFQDN) + authZone, err := d.findZone(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("allinkl: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) @@ -206,3 +193,17 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } + +func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (string, error) { + for z := range dns01.DomainsSeq(fqdn) { + _, errG := d.client.GetDNSSettings(ctx, z, "") + if errG != nil { + log.Infof("get DNS settings zone[%q] %v", z, errG) + continue + } + + return z, nil + } + + return "", fmt.Errorf("unable to find auth zone for '%s'", fqdn) +} From 2ce04a6586ea27253975e10d3ab7d7bb6214c79d Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Sun, 25 Jan 2026 14:51:12 +0100 Subject: [PATCH 04/21] alidns: add line record option (#2814) --- cmd/zz_gen_cmd_dnshelp.go | 2 ++ docs/content/dns/zz_gen_alidns.md | 2 ++ providers/dns/alidns/alidns.go | 13 +++++++++++-- providers/dns/alidns/alidns.toml | 2 ++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index 600e49753..357834a3c 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -263,8 +263,10 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Additional Configuration:`) ew.writeln(` - "ALICLOUD_HTTP_TIMEOUT": API request timeout in seconds (Default: 10)`) + ew.writeln(` - "ALICLOUD_LINE": Line (Default: default)`) ew.writeln(` - "ALICLOUD_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "ALICLOUD_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "ALICLOUD_REGION_ID": Region ID (Default: cn-hangzhou)`) ew.writeln(` - "ALICLOUD_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)`) ew.writeln() diff --git a/docs/content/dns/zz_gen_alidns.md b/docs/content/dns/zz_gen_alidns.md index e498a31dd..4ded782ab 100644 --- a/docs/content/dns/zz_gen_alidns.md +++ b/docs/content/dns/zz_gen_alidns.md @@ -58,8 +58,10 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | Environment Variable Name | Description | |--------------------------------|-------------| | `ALICLOUD_HTTP_TIMEOUT` | API request timeout in seconds (Default: 10) | +| `ALICLOUD_LINE` | Line (Default: default) | | `ALICLOUD_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `ALICLOUD_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `ALICLOUD_REGION_ID` | Region ID (Default: cn-hangzhou) | | `ALICLOUD_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. diff --git a/providers/dns/alidns/alidns.go b/providers/dns/alidns/alidns.go index a5c883fcb..cdd8e75e0 100644 --- a/providers/dns/alidns/alidns.go +++ b/providers/dns/alidns/alidns.go @@ -27,6 +27,7 @@ const ( EnvSecretKey = envNamespace + "SECRET_KEY" EnvSecurityToken = envNamespace + "SECURITY_TOKEN" EnvRegionID = envNamespace + "REGION_ID" + EnvLine = envNamespace + "LINE" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" @@ -45,6 +46,7 @@ type Config struct { SecretKey string SecurityToken string RegionID string + Line string PropagationTimeout time.Duration PollingInterval time.Duration TTL int @@ -74,6 +76,7 @@ type DNSProvider struct { func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() config.RegionID = env.GetOrFile(EnvRegionID) + config.Line = env.GetOrFile(EnvLine) values, err := env.Get(EnvRAMRole) if err == nil { @@ -254,12 +257,18 @@ func (d *DNSProvider) newTxtRecord(zone, fqdn, value string) (*alidns.AddDomainR return nil, err } - return new(alidns.AddDomainRecordRequest). + adrr := new(alidns.AddDomainRecordRequest). SetType("TXT"). SetDomainName(zone). SetRR(rr). SetValue(value). - SetTTL(int64(d.config.TTL)), nil + SetTTL(int64(d.config.TTL)) + + if d.config.Line != "" { + adrr.SetLine(d.config.Line) + } + + return adrr, nil } func (d *DNSProvider) findTxtRecords(ctx context.Context, fqdn string) ([]*alidns.DescribeDomainRecordsResponseBodyDomainRecordsRecord, error) { diff --git a/providers/dns/alidns/alidns.toml b/providers/dns/alidns/alidns.toml index 9a93bd24f..b78e1859d 100644 --- a/providers/dns/alidns/alidns.toml +++ b/providers/dns/alidns/alidns.toml @@ -23,6 +23,8 @@ lego --dns alidns - -d '*.example.com' -d example.com run ALICLOUD_SECRET_KEY = "Access Key secret" ALICLOUD_SECURITY_TOKEN = "STS Security Token (optional)" [Configuration.Additional] + ALICLOUD_REGION_ID = "Region ID (Default: cn-hangzhou)" + ALICLOUD_LINE = "Line (Default: default)" ALICLOUD_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" ALICLOUD_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" ALICLOUD_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 600)" From a7145a29ac5efc83c670248641ae25ff824876b3 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Wed, 28 Jan 2026 18:41:23 +0100 Subject: [PATCH 05/21] fix: use IPs to define the main domain (#2817) --- certcrypto/crypto.go | 14 +++++++++----- cmd/cmd_list.go | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/certcrypto/crypto.go b/certcrypto/crypto.go index 00f0654b9..800bb3f5b 100644 --- a/certcrypto/crypto.go +++ b/certcrypto/crypto.go @@ -242,15 +242,15 @@ func ParsePEMCertificate(cert []byte) (*x509.Certificate, error) { } func GetCertificateMainDomain(cert *x509.Certificate) (string, error) { - return getMainDomain(cert.Subject, cert.DNSNames) + return getMainDomain(cert.Subject, cert.DNSNames, cert.IPAddresses) } func GetCSRMainDomain(cert *x509.CertificateRequest) (string, error) { - return getMainDomain(cert.Subject, cert.DNSNames) + return getMainDomain(cert.Subject, cert.DNSNames, cert.IPAddresses) } -func getMainDomain(subject pkix.Name, dnsNames []string) (string, error) { - if subject.CommonName == "" && len(dnsNames) == 0 { +func getMainDomain(subject pkix.Name, dnsNames []string, ips []net.IP) (string, error) { + if subject.CommonName == "" && len(dnsNames) == 0 && len(ips) == 0 { return "", errors.New("missing domain") } @@ -258,7 +258,11 @@ func getMainDomain(subject pkix.Name, dnsNames []string) (string, error) { return subject.CommonName, nil } - return dnsNames[0], nil + if len(dnsNames) > 0 { + return dnsNames[0], nil + } + + return ips[0].String(), nil } func ExtractDomains(cert *x509.Certificate) []string { diff --git a/cmd/cmd_list.go b/cmd/cmd_list.go index 483592d47..53cd12c3c 100644 --- a/cmd/cmd_list.go +++ b/cmd/cmd_list.go @@ -3,6 +3,7 @@ package cmd import ( "encoding/json" "fmt" + "net" "net/url" "os" "path/filepath" @@ -100,6 +101,11 @@ func listCertificates(ctx *cli.Context) error { } else { fmt.Println(" Certificate Name:", name) fmt.Println(" Domains:", strings.Join(pCert.DNSNames, ", ")) + + if len(pCert.IPAddresses) > 0 { + fmt.Println(" IPs:", formatIPAddresses(pCert.IPAddresses)) + } + fmt.Println(" Expiry Date:", pCert.NotAfter) fmt.Println(" Certificate Path:", filename) fmt.Println() @@ -150,3 +156,12 @@ func listAccount(ctx *cli.Context) error { return nil } + +func formatIPAddresses(ipAddresses []net.IP) string { + var ips []string + for _, ip := range ipAddresses { + ips = append(ips, ip.String()) + } + + return strings.Join(ips, ", ") +} From fac5c39f5f9d36798a270af2d71578334001c2cf Mon Sep 17 00:00:00 2001 From: Mortie Torabi Date: Fri, 30 Jan 2026 19:36:46 +0000 Subject: [PATCH 06/21] fix: implement parsing for Retry-After header according to RFC 7231 (#2830) Co-authored-by: Fernandez Ludovic --- acme/api/service.go | 29 ++++++++++++++++++++++ acme/api/service_test.go | 37 ++++++++++++++++++++++++++++ certificate/renewal.go | 5 ++-- certificate/renewal_test.go | 36 +++++++++++++++++++++++++++ challenge/resolver/solver_manager.go | 17 ++++++------- 5 files changed, 112 insertions(+), 12 deletions(-) diff --git a/acme/api/service.go b/acme/api/service.go index 65518e1d9..22ce05124 100644 --- a/acme/api/service.go +++ b/acme/api/service.go @@ -1,8 +1,11 @@ package api import ( + "fmt" "net/http" "regexp" + "strconv" + "time" ) type service struct { @@ -56,3 +59,29 @@ func getRetryAfter(resp *http.Response) string { return resp.Header.Get("Retry-After") } + +// ParseRetryAfter parses the Retry-After header value according to RFC 7231. +// The header can be either delay-seconds (numeric) or HTTP-date (RFC 1123 format). +// https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.3 +// Returns the duration until the retry time. +// TODO(ldez): unexposed this function in v5. +func ParseRetryAfter(value string) (time.Duration, error) { + if value == "" { + return 0, nil + } + + if seconds, err := strconv.ParseInt(value, 10, 64); err == nil { + return time.Duration(seconds) * time.Second, nil + } + + if retryTime, err := time.Parse(time.RFC1123, value); err == nil { + duration := time.Until(retryTime) + if duration < 0 { + return 0, nil + } + + return duration, nil + } + + return 0, fmt.Errorf("invalid Retry-After value: %q", value) +} diff --git a/acme/api/service_test.go b/acme/api/service_test.go index 2dbd795c9..57ea45708 100644 --- a/acme/api/service_test.go +++ b/acme/api/service_test.go @@ -3,8 +3,10 @@ package api import ( "net/http" "testing" + "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_getLink(t *testing.T) { @@ -53,3 +55,38 @@ func Test_getLink(t *testing.T) { }) } } + +func TestParseRetryAfter(t *testing.T) { + testCases := []struct { + desc string + value string + expected time.Duration + }{ + { + desc: "empty header value", + value: "", + expected: time.Duration(0), + }, + { + desc: "delay-seconds", + value: "123", + expected: 123 * time.Second, + }, + { + desc: "HTTP-date", + value: time.Now().Add(3 * time.Second).Format(time.RFC1123), + expected: 3 * time.Second, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + rt, err := ParseRetryAfter(test.value) + require.NoError(t, err) + + assert.InDelta(t, test.expected.Seconds(), rt.Seconds(), 1) + }) + } +} diff --git a/certificate/renewal.go b/certificate/renewal.go index 15e804745..59d31cfb5 100644 --- a/certificate/renewal.go +++ b/certificate/renewal.go @@ -11,6 +11,7 @@ import ( "time" "github.com/go-acme/lego/v4/acme" + "github.com/go-acme/lego/v4/acme/api" ) // RenewalInfoRequest contains the necessary renewal information. @@ -92,9 +93,9 @@ func (c *Certifier) GetRenewalInfo(req RenewalInfoRequest) (*RenewalInfoResponse } if retry := resp.Header.Get("Retry-After"); retry != "" { - info.RetryAfter, err = time.ParseDuration(retry + "s") + info.RetryAfter, err = api.ParseRetryAfter(retry) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to parse Retry-After header: %w", err) } } diff --git a/certificate/renewal_test.go b/certificate/renewal_test.go index 6ce43e0aa..23209638a 100644 --- a/certificate/renewal_test.go +++ b/certificate/renewal_test.go @@ -74,6 +74,42 @@ func TestCertifier_GetRenewalInfo(t *testing.T) { assert.Equal(t, time.Duration(21600000000000), ri.RetryAfter) } +func TestCertifier_GetRenewalInfo_retryAfter(t *testing.T) { + leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM)) + require.NoError(t, err) + + server := tester.MockACMEServer(). + Route("GET /renewalInfo/"+ariLeafCertID, + servermock.RawStringResponse(`{ + "suggestedWindow": { + "start": "2020-03-17T17:51:09Z", + "end": "2020-03-17T18:21:09Z" + }, + "explanationUrl": "https://aricapable.ca.example/docs/renewal-advice/" + } + }`). + WithHeader("Content-Type", "application/json"). + WithHeader("Retry-After", time.Now().UTC().Add(6*time.Hour).Format(time.RFC1123))). + BuildHTTPS(t) + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "Could not generate test key") + + core, err := api.New(server.Client(), "lego-test", server.URL+"/dir", "", key) + require.NoError(t, err) + + certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048}) + + ri, err := certifier.GetRenewalInfo(RenewalInfoRequest{leaf}) + require.NoError(t, err) + require.NotNil(t, ri) + assert.Equal(t, "2020-03-17T17:51:09Z", ri.SuggestedWindow.Start.Format(time.RFC3339)) + assert.Equal(t, "2020-03-17T18:21:09Z", ri.SuggestedWindow.End.Format(time.RFC3339)) + assert.Equal(t, "https://aricapable.ca.example/docs/renewal-advice/", ri.ExplanationURL) + + assert.InDelta(t, 6, ri.RetryAfter.Hours(), 0.001) +} + func TestCertifier_GetRenewalInfo_errors(t *testing.T) { leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM)) require.NoError(t, err) diff --git a/challenge/resolver/solver_manager.go b/challenge/resolver/solver_manager.go index 48d9194b9..87cf6e2d8 100644 --- a/challenge/resolver/solver_manager.go +++ b/challenge/resolver/solver_manager.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "sort" - "strconv" "time" "github.com/cenkalti/backoff/v5" @@ -94,22 +93,20 @@ func validate(core *api.Core, domain string, chlg acme.Challenge) error { return nil } - ra, err := strconv.Atoi(chlng.RetryAfter) - if err != nil { + retryAfter, err := api.ParseRetryAfter(chlng.RetryAfter) + if err != nil || retryAfter == 0 { // The ACME server MUST return a Retry-After. - // If it doesn't, we'll just poll hard. + // If it doesn't, or if it's invalid, we'll just poll hard. // Boulder does not implement the ability to retry challenges or the Retry-After header. // https://github.com/letsencrypt/boulder/blob/master/docs/acme-divergences.md#section-82 - ra = 5 + retryAfter = 5 * time.Second } - initialInterval := time.Duration(ra) * time.Second - ctx := context.Background() bo := backoff.NewExponentialBackOff() - bo.InitialInterval = initialInterval - bo.MaxInterval = 10 * initialInterval + bo.InitialInterval = retryAfter + bo.MaxInterval = 10 * retryAfter // After the path is sent, the ACME server will access our server. // Repeatedly check the server for an updated status on our request. @@ -134,7 +131,7 @@ func validate(core *api.Core, domain string, chlg acme.Challenge) error { return wait.Retry(ctx, operation, backoff.WithBackOff(bo), - backoff.WithMaxElapsedTime(100*initialInterval)) + backoff.WithMaxElapsedTime(100*retryAfter)) } func checkChallengeStatus(chlng acme.ExtendedChallenge) (bool, error) { From c1aaf19aac0953ddffdea549b31b176dfcdddb1f Mon Sep 17 00:00:00 2001 From: Andy Warner Date: Mon, 9 Feb 2026 18:51:54 -0700 Subject: [PATCH 07/21] docs: make it more clear that any ACME CA may be used (#2841) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6324ece67..07e4f7dd6 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # Lego -Let's Encrypt client and ACME library written in Go. +[ACME](https://www.rfc-editor.org/rfc/rfc8555.html) client and library for Let's Encrypt and other ACME CAs written in Go. [![Go Reference](https://pkg.go.dev/badge/github.com/go-acme/lego/v4.svg)](https://pkg.go.dev/github.com/go-acme/lego/v4) [![Build Status](https://github.com//go-acme/lego/workflows/Main/badge.svg?branch=master)](https://github.com//go-acme/lego/actions) From 4c6d29882e14beea0287e67f36b3d75b15f27576 Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Thu, 12 Feb 2026 17:32:23 +0100 Subject: [PATCH 08/21] chore: update linter, and workflows --- .github/workflows/pr.yml | 2 +- .golangci.yml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 5df64db7f..fe3d35359 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest env: GO_VERSION: stable - GOLANGCI_LINT_VERSION: v2.8.0 + GOLANGCI_LINT_VERSION: v2.9.0 HUGO_VERSION: 0.148.2 CGO_ENABLED: 0 LEGO_E2E_TESTS: CI diff --git a/.golangci.yml b/.golangci.yml index b851169ff..b6ab51ccc 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -183,6 +183,9 @@ linters: - text: "var-naming: avoid meaningless package names" linters: - revive + - text: "var-naming: avoid package names that conflict with Go standard library package names" + linters: + - revive - path: certcrypto/crypto.go text: (tlsFeatureExtensionOID|ocspMustStapleFeature) is a global variable linters: From dc51d5ee65cde20de9d8a98356d629a221418acf Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Thu, 12 Feb 2026 18:01:57 +0100 Subject: [PATCH 09/21] chore: use memcache Docker image directly (#2846) --- .github/workflows/pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index fe3d35359..9c845ea62 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -50,7 +50,7 @@ jobs: run: go install github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv@v2.9.0 - name: Set up a Memcached server - uses: niden/actions-memcached@v7 + run: docker run -d --rm -p 11211:11211 memcached:1.6-alpine - name: Make run: | From 1991339cc15bc9468e4db101db465f50e568df88 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Thu, 12 Feb 2026 18:20:05 +0100 Subject: [PATCH 10/21] timewebcloud: fix subdomain support (#2845) --- providers/dns/timewebcloud/internal/types.go | 8 +++++--- providers/dns/timewebcloud/timewebcloud.go | 7 +------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/providers/dns/timewebcloud/internal/types.go b/providers/dns/timewebcloud/internal/types.go index 81da4df5c..80cdb2c70 100644 --- a/providers/dns/timewebcloud/internal/types.go +++ b/providers/dns/timewebcloud/internal/types.go @@ -3,9 +3,11 @@ package internal import "fmt" type DNSRecord struct { - ID int `json:"id,omitempty"` - Type string `json:"type,omitempty"` - Value string `json:"value,omitempty"` + ID int `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Value string `json:"value,omitempty"` + + // SubDomain is the full name of a subdomain (not only the subdomain label). SubDomain string `json:"subdomain,omitempty"` } diff --git a/providers/dns/timewebcloud/timewebcloud.go b/providers/dns/timewebcloud/timewebcloud.go index d71beea4b..a599566e3 100644 --- a/providers/dns/timewebcloud/timewebcloud.go +++ b/providers/dns/timewebcloud/timewebcloud.go @@ -110,15 +110,10 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return fmt.Errorf("timewebcloud: could not find zone for domain %q: %w", domain, err) } - subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) - if err != nil { - return fmt.Errorf("timewebcloud: %w", err) - } - record := internal.DNSRecord{ Type: "TXT", Value: info.Value, - SubDomain: subDomain, + SubDomain: dns01.UnFqdn(info.EffectiveFQDN), } response, err := d.client.CreateRecord(context.Background(), authZone, record) From 4a61728ff0db8179e060d185b73a8c0d539d4c91 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Thu, 12 Feb 2026 19:13:49 +0100 Subject: [PATCH 11/21] fix: deduplicate authz for DNS01 challenge (#2828) --- challenge/resolver/prober.go | 60 +++++++++++++++++++++++--- challenge/resolver/prober_mock_test.go | 55 ++++++++++++++++++----- challenge/resolver/prober_test.go | 48 +++++++++++++++++++-- 3 files changed, 142 insertions(+), 21 deletions(-) diff --git a/challenge/resolver/prober.go b/challenge/resolver/prober.go index aac1016d8..66b12c7a7 100644 --- a/challenge/resolver/prober.go +++ b/challenge/resolver/prober.go @@ -98,11 +98,24 @@ func (p *Prober) Solve(authorizations []acme.Authorization) error { } func sequentialSolve(authSolvers []*selectedAuthSolver, failures obtainError) { + // Some CA are using the same token, + // this can be a problem with the DNS01 challenge when the DNS provider doesn't support duplicate TXT records. + // In the sequential mode, this is not a problem because we can solve the challenges in order. + // But it can reduce the number of call the DNS provider APIs. + uniq := make(map[string]struct{}) + for i, authSolver := range authSolvers { // Submit the challenge domain := challenge.GetTargetedDomain(authSolver.authz) + chlg, _ := challenge.FindChallenge(challenge.DNS01, authSolver.authz) + if solvr, ok := authSolver.solver.(preSolver); ok { + if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok && chlg.Token != "" { + log.Infof("acme: duplicate token for %q (DNS-01); skipping pre-solve.", authSolver.authz.Identifier.Value) + continue + } + err := solvr.PreSolve(authSolver.authz) if err != nil { failures[domain] = err @@ -111,6 +124,8 @@ func sequentialSolve(authSolvers []*selectedAuthSolver, failures obtainError) { continue } + + uniq[authSolver.authz.Identifier.Value+chlg.Token] = struct{}{} } // Solve challenge @@ -123,22 +138,43 @@ func sequentialSolve(authSolvers []*selectedAuthSolver, failures obtainError) { continue } - // Clean challenge - cleanUp(authSolver.solver, authSolver.authz) + if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok || chlg.Token == "" { + // Clean challenge + cleanUp(authSolver.solver, authSolver.authz) - if len(authSolvers)-1 > i { - solvr := authSolver.solver.(sequential) - _, interval := solvr.Sequential() - log.Infof("sequence: wait for %s", interval) - time.Sleep(interval) + if len(authSolvers)-1 > i { + solvr := authSolver.solver.(sequential) + _, interval := solvr.Sequential() + log.Infof("sequence: wait for %s", interval) + time.Sleep(interval) + } + + delete(uniq, authSolver.authz.Identifier.Value+chlg.Token) + } else { + log.Infof("acme: duplicate token for %q (DNS-01); skipping cleanup.", authSolver.authz.Identifier.Value) } } } func parallelSolve(authSolvers []*selectedAuthSolver, failures obtainError) { + // Some CA are using the same token, + // this can be a problem with the DNS01 challenge when the DNS provider doesn't support duplicate TXT records. + uniq := make(map[string]struct{}) + // For all valid preSolvers, first submit the challenges, so they have max time to propagate for _, authSolver := range authSolvers { authz := authSolver.authz + + chlg, err := challenge.FindChallenge(challenge.DNS01, authz) + if err == nil { + if _, ok := uniq[authz.Identifier.Value+chlg.Token]; ok { + log.Infof("acme: duplicate token for %q (DNS-01); skipping pre-solve.", authSolver.authz.Identifier.Value) + continue + } + + uniq[authz.Identifier.Value+chlg.Token] = struct{}{} + } + if solvr, ok := authSolver.solver.(preSolver); ok { err := solvr.PreSolve(authz) if err != nil { @@ -150,6 +186,16 @@ func parallelSolve(authSolvers []*selectedAuthSolver, failures obtainError) { defer func() { // Clean all created TXT records for _, authSolver := range authSolvers { + chlg, err := challenge.FindChallenge(challenge.DNS01, authSolver.authz) + if err == nil { + if _, ok := uniq[authSolver.authz.Identifier.Value+chlg.Token]; ok { + delete(uniq, authSolver.authz.Identifier.Value+chlg.Token) + } else { + log.Infof("acme: duplicate token for %q (DNS-01); skipping cleanup.", authSolver.authz.Identifier.Value) + continue + } + } + cleanUp(authSolver.solver, authSolver.authz) } }() diff --git a/challenge/resolver/prober_mock_test.go b/challenge/resolver/prober_mock_test.go index 5a91fe075..dc7ad8dec 100644 --- a/challenge/resolver/prober_mock_test.go +++ b/challenge/resolver/prober_mock_test.go @@ -1,6 +1,7 @@ package resolver import ( + "fmt" "time" "github.com/go-acme/lego/v4/acme" @@ -11,34 +12,68 @@ type preSolverMock struct { preSolve map[string]error solve map[string]error cleanUp map[string]error + + preSolveCounter int + solveCounter int + cleanUpCounter int } func (s *preSolverMock) PreSolve(authorization acme.Authorization) error { + s.preSolveCounter++ + return s.preSolve[authorization.Identifier.Value] } func (s *preSolverMock) Solve(authorization acme.Authorization) error { + s.solveCounter++ + return s.solve[authorization.Identifier.Value] } func (s *preSolverMock) CleanUp(authorization acme.Authorization) error { + s.cleanUpCounter++ + return s.cleanUp[authorization.Identifier.Value] } +func (s *preSolverMock) String() string { + return fmt.Sprintf("PreSolve: %d, Solve: %d, CleanUp: %d", s.preSolveCounter, s.solveCounter, s.cleanUpCounter) +} + func createStubAuthorizationHTTP01(domain, status string) acme.Authorization { + return createStubAuthorization(domain, status, false, acme.Challenge{ + Type: challenge.HTTP01.String(), + Validated: time.Now(), + }) +} + +func createStubAuthorizationDNS01(domain string, wildcard bool) acme.Authorization { + var chlgs []acme.Challenge + + if wildcard { + chlgs = append(chlgs, acme.Challenge{ + Type: challenge.HTTP01.String(), + Validated: time.Now(), + }) + } + + chlgs = append(chlgs, acme.Challenge{ + Type: challenge.DNS01.String(), + Validated: time.Now(), + }) + + return createStubAuthorization(domain, acme.StatusProcessing, wildcard, chlgs...) +} + +func createStubAuthorization(domain, status string, wildcard bool, chlgs ...acme.Challenge) acme.Authorization { return acme.Authorization{ - Status: status, - Expires: time.Now(), + Wildcard: wildcard, + Status: status, + Expires: time.Now(), Identifier: acme.Identifier{ - Type: challenge.HTTP01.String(), + Type: "dns", Value: domain, }, - Challenges: []acme.Challenge{ - { - Type: challenge.HTTP01.String(), - Validated: time.Now(), - Error: nil, - }, - }, + Challenges: chlgs, } } diff --git a/challenge/resolver/prober_test.go b/challenge/resolver/prober_test.go index 06ff07d2c..829b16883 100644 --- a/challenge/resolver/prober_test.go +++ b/challenge/resolver/prober_test.go @@ -2,19 +2,22 @@ package resolver import ( "errors" + "fmt" "testing" "github.com/go-acme/lego/v4/acme" "github.com/go-acme/lego/v4/challenge" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestProber_Solve(t *testing.T) { testCases := []struct { - desc string - solvers map[challenge.Type]solver - authz []acme.Authorization - expectedError string + desc string + solvers map[challenge.Type]solver + authz []acme.Authorization + expectedError string + expectedCounters map[challenge.Type]string }{ { desc: "success", @@ -30,6 +33,30 @@ func TestProber_Solve(t *testing.T) { createStubAuthorizationHTTP01("example.org", acme.StatusProcessing), createStubAuthorizationHTTP01("example.net", acme.StatusProcessing), }, + expectedCounters: map[challenge.Type]string{ + challenge.HTTP01: "PreSolve: 3, Solve: 3, CleanUp: 3", + }, + }, + { + desc: "DNS-01 deduplicate", + solvers: map[challenge.Type]solver{ + challenge.DNS01: &preSolverMock{ + preSolve: map[string]error{}, + solve: map[string]error{}, + cleanUp: map[string]error{}, + }, + }, + authz: []acme.Authorization{ + createStubAuthorizationDNS01("a.example", false), + createStubAuthorizationDNS01("a.example", true), + createStubAuthorizationDNS01("b.example", false), + createStubAuthorizationDNS01("b.example", true), + createStubAuthorizationDNS01("c.example", true), + createStubAuthorizationDNS01("d.example", false), + }, + expectedCounters: map[challenge.Type]string{ + challenge.DNS01: "PreSolve: 4, Solve: 6, CleanUp: 4", + }, }, { desc: "already valid", @@ -45,6 +72,9 @@ func TestProber_Solve(t *testing.T) { createStubAuthorizationHTTP01("example.org", acme.StatusValid), createStubAuthorizationHTTP01("example.net", acme.StatusValid), }, + expectedCounters: map[challenge.Type]string{ + challenge.HTTP01: "PreSolve: 0, Solve: 0, CleanUp: 0", + }, }, { desc: "when preSolve fail, auth is flagged as error and skipped", @@ -69,6 +99,9 @@ func TestProber_Solve(t *testing.T) { expectedError: `error: one or more domains had a problem: [example.com] preSolve error example.com `, + expectedCounters: map[challenge.Type]string{ + challenge.HTTP01: "PreSolve: 3, Solve: 2, CleanUp: 3", + }, }, { desc: "errors at different stages", @@ -95,6 +128,9 @@ func TestProber_Solve(t *testing.T) { [example.com] preSolve error example.com [example.org] solve error example.org `, + expectedCounters: map[challenge.Type]string{ + challenge.HTTP01: "PreSolve: 3, Solve: 2, CleanUp: 3", + }, }, } @@ -112,6 +148,10 @@ func TestProber_Solve(t *testing.T) { } else { require.NoError(t, err) } + + for n, s := range test.solvers { + assert.Equal(t, test.expectedCounters[n], fmt.Sprintf("%s", s)) + } }) } } From 2e095b95a57621177a10ee1be2650406d8707524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Grigas=20=C5=A0ukys?= <135010329+grigassukys@users.noreply.github.com> Date: Sun, 15 Feb 2026 04:22:35 +0200 Subject: [PATCH 12/21] Add DNS provider for FusionLayer NameSurfer (#2852) Co-authored-by: Fernandez Ludovic --- README.md | 62 ++--- cmd/zz_gen_cmd_dnshelp.go | 25 ++ docs/content/dns/zz_gen_namesurfer.md | 73 ++++++ docs/data/zz_cli_help.toml | 2 +- providers/dns/namesurfer/internal/client.go | 226 ++++++++++++++++++ .../dns/namesurfer/internal/client_test.go | 158 ++++++++++++ .../fixtures/addDNSRecord-request.json | 16 ++ .../internal/fixtures/addDNSRecord.json | 4 + .../namesurfer/internal/fixtures/error.json | 24 ++ .../internal/fixtures/listZones-request.json | 9 + .../internal/fixtures/listZones.json | 17 ++ .../fixtures/searchDNSHosts-request.json | 9 + .../internal/fixtures/searchDNSHosts.json | 23 ++ .../fixtures/updateDNSHost-request.json | 17 ++ .../internal/fixtures/updateDNSHost.json | 4 + providers/dns/namesurfer/internal/types.go | 72 ++++++ providers/dns/namesurfer/namesurfer.go | 214 +++++++++++++++++ providers/dns/namesurfer/namesurfer.toml | 28 +++ providers/dns/namesurfer/namesurfer_test.go | 174 ++++++++++++++ providers/dns/zz_gen_dns_providers.go | 3 + 20 files changed, 1128 insertions(+), 32 deletions(-) create mode 100644 docs/content/dns/zz_gen_namesurfer.md create mode 100644 providers/dns/namesurfer/internal/client.go create mode 100644 providers/dns/namesurfer/internal/client_test.go create mode 100644 providers/dns/namesurfer/internal/fixtures/addDNSRecord-request.json create mode 100644 providers/dns/namesurfer/internal/fixtures/addDNSRecord.json create mode 100644 providers/dns/namesurfer/internal/fixtures/error.json create mode 100644 providers/dns/namesurfer/internal/fixtures/listZones-request.json create mode 100644 providers/dns/namesurfer/internal/fixtures/listZones.json create mode 100644 providers/dns/namesurfer/internal/fixtures/searchDNSHosts-request.json create mode 100644 providers/dns/namesurfer/internal/fixtures/searchDNSHosts.json create mode 100644 providers/dns/namesurfer/internal/fixtures/updateDNSHost-request.json create mode 100644 providers/dns/namesurfer/internal/fixtures/updateDNSHost.json create mode 100644 providers/dns/namesurfer/internal/types.go create mode 100644 providers/dns/namesurfer/namesurfer.go create mode 100644 providers/dns/namesurfer/namesurfer.toml create mode 100644 providers/dns/namesurfer/namesurfer_test.go diff --git a/README.md b/README.md index 07e4f7dd6..105ea53aa 100644 --- a/README.md +++ b/README.md @@ -141,160 +141,160 @@ If your DNS provider is not supported, please open an [issue](https://github.com F5 XC freemyip.com + FusionLayer NameSurfer G-Core Gandi Gandi Live DNS (v5) - Gigahost.no + Gigahost.no Glesys Go Daddy Google Cloud - Google Domains + Google Domains Gravity Hetzner Hosting.de - Hosting.nl + Hosting.nl Hostinger Hosttech HTTP request - http.net + http.net Huawei Cloud Hurricane Electric DNS HyperOne - IBM Cloud (SoftLayer) + IBM Cloud (SoftLayer) IIJ DNS Platform Service Infoblox Infomaniak - Internet Initiative Japan + Internet Initiative Japan Internet.bs INWX Ionos - Ionos Cloud + Ionos Cloud IPv64 ISPConfig 3 ISPConfig 3 - Dynamic DNS (DDNS) Module - iwantmyname (Deprecated) + iwantmyname (Deprecated) JD Cloud Joker Joohoi's ACME-DNS - KeyHelp + KeyHelp Liara Lima-City Linode (v4) - Liquid Web + Liquid Web Loopia LuaDNS Mail-in-a-Box - ManageEngine CloudDNS + ManageEngine CloudDNS Manual Metaname Metaregistrar - mijn.host + mijn.host Mittwald myaddr.{tools,dev,io} MyDNS.jp - MythicBeasts + MythicBeasts Name.com Namecheap Namesilo - NearlyFreeSpeech.NET + NearlyFreeSpeech.NET Neodigit Netcup Netlify - Nicmanager + Nicmanager NIFCloud Njalla Nodion - NS1 + NS1 Octenium Open Telekom Cloud Oracle Cloud - OVH + OVH plesk.com Porkbun PowerDNS - Rackspace + Rackspace Rain Yun/雨云 RcodeZero reg.ru - Regfish + Regfish RFC2136 RimuHosting RU CENTER - Sakura Cloud + Sakura Cloud Scaleway Selectel Selectel v2 - SelfHost.(de|eu) + SelfHost.(de|eu) Servercow Shellrent Simply.com - Sonic + Sonic Spaceship Stackpath Syse - Technitium + Technitium Tencent Cloud DNS Tencent EdgeOne Timeweb Cloud - TodayNIC/时代互联 + TodayNIC/时代互联 TransIP UKFast SafeDNS Ultradns - United-Domains + United-Domains Variomedia VegaDNS Vercel - Versio.[nl|eu|uk] + Versio.[nl|eu|uk] VinylDNS Virtualname VK Cloud - Volcano Engine/火山引擎 + Volcano Engine/火山引擎 Vscale Vultr webnames.ca - webnames.ru + webnames.ru Websupport WEDOS West.cn/西部数码 - Yandex 360 + Yandex 360 Yandex Cloud Yandex PDD Zone.ee - ZoneEdit + ZoneEdit Zonomi - diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index 357834a3c..cdee65371 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -131,6 +131,7 @@ func allDNSCodes() string { "namecheap", "namedotcom", "namesilo", + "namesurfer", "nearlyfreespeech", "neodigit", "netcup", @@ -2742,6 +2743,30 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/namesilo`) + case "namesurfer": + // generated from: providers/dns/namesurfer/namesurfer.toml + ew.writeln(`Configuration for FusionLayer NameSurfer.`) + ew.writeln(`Code: 'namesurfer'`) + ew.writeln(`Since: 'v4.32.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "NAMESURFER_API_KEY": API key name`) + ew.writeln(` - "NAMESURFER_API_SECRET": API secret`) + ew.writeln(` - "NAMESURFER_BASE_URL": The base URL of NameSurfer API (jsonrpc10) endpoint URL (e.g., https://foo.example.com:8443/API/NSService_10)`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "NAMESURFER_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "NAMESURFER_INSECURE_SKIP_VERIFY": Whether to verify the API certificate`) + ew.writeln(` - "NAMESURFER_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "NAMESURFER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 120)`) + ew.writeln(` - "NAMESURFER_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)`) + ew.writeln(` - "NAMESURFER_VIEW": DNS view name (optional, default: empty string)`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/namesurfer`) + case "nearlyfreespeech": // generated from: providers/dns/nearlyfreespeech/nearlyfreespeech.toml ew.writeln(`Configuration for NearlyFreeSpeech.NET.`) diff --git a/docs/content/dns/zz_gen_namesurfer.md b/docs/content/dns/zz_gen_namesurfer.md new file mode 100644 index 000000000..9a2802d0e --- /dev/null +++ b/docs/content/dns/zz_gen_namesurfer.md @@ -0,0 +1,73 @@ +--- +title: "FusionLayer NameSurfer" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: namesurfer +dnsprovider: + since: "v4.32.0" + code: "namesurfer" + url: "https://www.fusionlayer.com/" +--- + + + + + + +Configuration for [FusionLayer NameSurfer](https://www.fusionlayer.com/). + + + + +- Code: `namesurfer` +- Since: v4.32.0 + + +Here is an example bash command using the FusionLayer NameSurfer provider: + +```bash +NAMESURFER_BASE_URL=https://foo.example.com:8443/API/NSService_10 \ +NAMESURFER_API_KEY=xxx \ +NAMESURFER_API_SECRET=yyy \ +lego --dns namesurfer -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `NAMESURFER_API_KEY` | API key name | +| `NAMESURFER_API_SECRET` | API secret | +| `NAMESURFER_BASE_URL` | The base URL of NameSurfer API (jsonrpc10) endpoint URL (e.g., https://foo.example.com:8443/API/NSService_10) | + +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 | +|--------------------------------|-------------| +| `NAMESURFER_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `NAMESURFER_INSECURE_SKIP_VERIFY` | Whether to verify the API certificate | +| `NAMESURFER_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `NAMESURFER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 120) | +| `NAMESURFER_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 300) | +| `NAMESURFER_VIEW` | DNS view name (optional, default: empty string) | + +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://web.archive.org/web/20260213170737/http://95.128.3.201:8053/API/NSService_10) + + + + diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml index e31633567..759b8e84f 100644 --- a/docs/data/zz_cli_help.toml +++ b/docs/data/zz_cli_help.toml @@ -152,7 +152,7 @@ To display the documentation for a specific DNS provider, run: $ lego dnshelp -c code Supported DNS providers: - acme-dns, active24, alidns, aliesa, allinkl, alwaysdata, anexia, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bluecatv2, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, ddnss, derak, desec, designate, digitalocean, directadmin, dnsexit, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, jdcloud, joker, keyhelp, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, 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, syse, technitium, tencentcloud, timewebcloud, todaynic, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnames, webnamesca, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi + acme-dns, active24, alidns, aliesa, allinkl, alwaysdata, anexia, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bluecatv2, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, ddnss, derak, desec, designate, digitalocean, directadmin, dnsexit, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, jdcloud, joker, keyhelp, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, namesurfer, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, 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, syse, technitium, tencentcloud, timewebcloud, todaynic, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnames, webnamesca, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi More information: https://go-acme.github.io/lego/dns """ diff --git a/providers/dns/namesurfer/internal/client.go b/providers/dns/namesurfer/internal/client.go new file mode 100644 index 000000000..e40a7988c --- /dev/null +++ b/providers/dns/namesurfer/internal/client.go @@ -0,0 +1,226 @@ +package internal + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +type Client struct { + apiKey string + apiSecret string + + BaseURL *url.URL + HTTPClient *http.Client +} + +func NewClient(baseURL, apiKey, apiSecret string) (*Client, error) { + if apiKey == "" || apiSecret == "" { + return nil, errors.New("credentials missing") + } + + if baseURL == "" { + return nil, errors.New("base URL missing") + } + + apiEndpoint, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + + return &Client{ + apiKey: apiKey, + apiSecret: apiSecret, + BaseURL: apiEndpoint.JoinPath("jsonrpc10"), + HTTPClient: &http.Client{ + Timeout: 5 * time.Second, + }, + }, nil +} + +// AddDNSRecord adds a DNS record. +// http://95.128.3.201:8053/API/NSService_10#addDNSRecord +func (d *Client) AddDNSRecord(ctx context.Context, zoneName, viewName string, record DNSNode) error { + digest := d.computeDigest( + zoneName, + viewName, + record.Name, + record.Type, + strconv.Itoa(record.TTL), + record.Data, + ) + + // JSON-RPC 1.0 requires positional parameters array + params := []any{ + digest, + zoneName, + viewName, + record, + } + + var ok bool + + err := d.doRequest(ctx, "addDNSRecord", params, &ok) + if err != nil { + return err + } + + if !ok { + return errors.New("addDNSRecord failed") + } + + return nil +} + +// UpdateDNSHost updates a DNS host record. +// Passing an empty newNode removes the oldNode. +// http://95.128.3.201:8053/API/NSService_10#updateDNSHost +func (d *Client) UpdateDNSHost(ctx context.Context, zoneName, viewName string, oldNode, newNode DNSNode) error { + digest := d.computeDigest(zoneName, viewName) + + // JSON-RPC 1.0 requires positional parameters array + params := []any{ + digest, + zoneName, + viewName, + oldNode, + newNode, + } + + var ok bool + + err := d.doRequest(ctx, "updateDNSHost", params, &ok) + if err != nil { + return err + } + + if !ok { + return errors.New("updateDNSHost failed") + } + + return nil +} + +// SearchDNSHosts searches for DNS host records. +// http://95.128.3.201:8053/API/NSService_10#searchDNSHosts +func (d *Client) SearchDNSHosts(ctx context.Context, pattern string) ([]DNSNode, error) { + digest := d.computeDigest(pattern) + + // JSON-RPC 1.0 requires positional parameters array + params := []any{ + digest, + pattern, + } + + var nodes []DNSNode + + err := d.doRequest(ctx, "searchDNSHosts", params, &nodes) + if err != nil { + return nil, err + } + + return nodes, nil +} + +// ListZones lists DNS zones. +// http://95.128.3.201:8053/API/NSService_10#listZones +func (d *Client) ListZones(ctx context.Context, mode string) ([]DNSZone, error) { + digest := d.computeDigest() + + // JSON-RPC 1.0 requires positional parameters array + params := []any{ + digest, + mode, + } + + var zones []DNSZone + + err := d.doRequest(ctx, "listZones", params, &zones) + if err != nil { + return nil, err + } + + return zones, nil +} + +func (d *Client) doRequest(ctx context.Context, method string, params []any, result any) error { + payload := APIRequest{ + ID: 1, + Method: method, + Params: slices.Concat([]any{d.apiKey}, params), + } + + buf := new(bytes.Buffer) + + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return fmt.Errorf("failed to create request JSON body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.BaseURL.String(), buf) + if err != nil { + return fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := d.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + if resp.StatusCode/100 != 2 { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + var rpcResp APIResponse + + err = json.Unmarshal(raw, &rpcResp) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + if rpcResp.Error != nil { + return rpcResp.Error + } + + err = json.Unmarshal(rpcResp.Result, result) + if err != nil { + return fmt.Errorf("unable to unmarshal response: %w: %s", err, rpcResp.Result) + } + + return nil +} + +func (d *Client) computeDigest(parts ...string) string { + params := []string{d.apiKey} + params = append(params, parts...) + params = append(params, d.apiSecret) + + mac := hmac.New(sha256.New, []byte(d.apiSecret)) + mac.Write([]byte(strings.Join(params, "&"))) + + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/providers/dns/namesurfer/internal/client_test.go b/providers/dns/namesurfer/internal/client_test.go new file mode 100644 index 000000000..9e8f917bc --- /dev/null +++ b/providers/dns/namesurfer/internal/client_test.go @@ -0,0 +1,158 @@ +package internal + +import ( + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient(server.URL, "user", "secret") + if err != nil { + return nil, err + } + + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(), + ) +} + +func TestClient_AddDNSRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /jsonrpc10", + servermock.ResponseFromFixture("addDNSRecord.json"), + servermock.CheckRequestJSONBodyFromFixture("addDNSRecord-request.json"), + ). + Build(t) + + record := DNSNode{ + Name: "_acme-challenge", + Type: "TXT", + Data: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 300, + } + + err := client.AddDNSRecord(t.Context(), "example.com", "viewA", record) + require.NoError(t, err) +} + +func TestClient_AddDNSRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /jsonrpc10", + servermock.ResponseFromFixture("error.json"), + ). + Build(t) + + record := DNSNode{ + Name: "_acme-challenge", + Type: "TXT", + Data: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 300, + } + + err := client.AddDNSRecord(t.Context(), "example.com", "viewA", record) + require.EqualError(t, err, "code: Server.Keyfailure, "+ + "filename: service, line: 13, "+ + "message: Unknown keyname user, "+ + `detail: Traceback (most recent call last): File "/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/server/dispatcher.py", line 159, in dispatch_request result = self.call_method(method,req_dict,tc,export_dict,log_line) File "/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/server/dispatcher.py", line 96, in call_method result = getattr(service_class_instance,req_dict['methodname'])(*args) File "/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/ladonizer/decorator.py", line 77, in injector res = f(*args,**kw) File "/usr/local/namesurfer/webui2/webui/service/service10/NSService_10.py", line 502, in addDNSRecord key = validate_key(keyname, digest, [zonename, viewname, record.name, record.type, str(record.ttl), record.data]) File "/usr/local/namesurfer/webui2/webui/service/base/implementation.py", line 63, in validate_key raise ApiFault('Server.Keyfailure', 'Unknown keyname %s' % keyname) ApiFault: service(13): Unknown keyname user `) +} + +func TestClient_UpdateDNSHost(t *testing.T) { + client := mockBuilder(). + Route("POST /jsonrpc10", + servermock.ResponseFromFixture("updateDNSHost.json"), + servermock.CheckRequestJSONBodyFromFixture("updateDNSHost-request.json"), + ). + Build(t) + + record := DNSNode{ + Name: "_acme-challenge", + Type: "TXT", + Data: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + TTL: 300, + } + + err := client.UpdateDNSHost(t.Context(), "example.com", "viewA", record, DNSNode{}) + require.NoError(t, err) +} + +func TestClient_SearchDNSHosts(t *testing.T) { + client := mockBuilder(). + Route("POST /jsonrpc10", + servermock.ResponseFromFixture("searchDNSHosts.json"), + servermock.CheckRequestJSONBodyFromFixture("searchDNSHosts-request.json"), + ). + Build(t) + + records, err := client.SearchDNSHosts(t.Context(), "value") + require.NoError(t, err) + + expected := []DNSNode{ + {Name: "foo", Type: "TXT", Data: "xxx", TTL: 300}, + {Name: "_acme-challenge", Type: "TXT", Data: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 300}, + {Name: "bar", Type: "A", Data: "yyy", TTL: 300}, + } + + assert.Equal(t, expected, records) +} + +func TestClient_ListZones(t *testing.T) { + client := mockBuilder(). + Route("POST /jsonrpc10", + servermock.ResponseFromFixture("listZones.json"), + servermock.CheckRequestJSONBodyFromFixture("listZones-request.json"), + ). + Build(t) + + zones, err := client.ListZones(t.Context(), "value") + require.NoError(t, err) + + expected := []DNSZone{ + {Name: "example.com", View: "viewA"}, + {Name: "example.org", View: "viewB"}, + {Name: "example.net", View: "viewC"}, + } + + assert.Equal(t, expected, zones) +} + +func TestClient_computeDigest(t *testing.T) { + client, err := NewClient("https://test.example.com", "testkey", "testsecret") + require.NoError(t, err) + + testCases := []struct { + desc string + parts []string + expected string + }{ + { + desc: "no parts", + parts: []string{}, + expected: "99b5dcdc19bfc0ce2af3fe848f4bcb6f7beb352e9599e8ba50544d86de567282", + }, + { + desc: "parts", + parts: []string{"zone.example.com", "default"}, + expected: "94efef76383889b1ae620582a25d1c3aa9bd9ba9ac4bdccdf4aefbc3ae6e8329", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + digest := client.computeDigest(test.parts...) + + assert.Equal(t, test.expected, digest) + }) + } +} diff --git a/providers/dns/namesurfer/internal/fixtures/addDNSRecord-request.json b/providers/dns/namesurfer/internal/fixtures/addDNSRecord-request.json new file mode 100644 index 000000000..660109aae --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/addDNSRecord-request.json @@ -0,0 +1,16 @@ +{ + "id": 1, + "method": "addDNSRecord", + "params": [ + "user", + "4fcc5fa29531709b0381c8debea127a6a26e71cb9491727876819cf5805c4990", + "example.com", + "viewA", + { + "name": "_acme-challenge", + "type": "TXT", + "data": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "ttl": 300 + } + ] +} diff --git a/providers/dns/namesurfer/internal/fixtures/addDNSRecord.json b/providers/dns/namesurfer/internal/fixtures/addDNSRecord.json new file mode 100644 index 000000000..f41779e30 --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/addDNSRecord.json @@ -0,0 +1,4 @@ +{ + "id": 1, + "result": true +} diff --git a/providers/dns/namesurfer/internal/fixtures/error.json b/providers/dns/namesurfer/internal/fixtures/error.json new file mode 100644 index 000000000..8ddf8df25 --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/error.json @@ -0,0 +1,24 @@ +{ + "result": null, + "error": { + "filename": "service", + "lineno": 13, + "code": "Server.Keyfailure", + "string": "Unknown keyname user", + "detail": [ + "Traceback (most recent call last):", + " File \"/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/server/dispatcher.py\", line 159, in dispatch_request", + " result = self.call_method(method,req_dict,tc,export_dict,log_line)", + " File \"/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/server/dispatcher.py\", line 96, in call_method", + " result = getattr(service_class_instance,req_dict['methodname'])(*args)", + " File \"/usr/local/namesurfer/python/lib/python2.6/site-packages/ladon/ladonizer/decorator.py\", line 77, in injector", + " res = f(*args,**kw)", + " File \"/usr/local/namesurfer/webui2/webui/service/service10/NSService_10.py\", line 502, in addDNSRecord", + " key = validate_key(keyname, digest, [zonename, viewname, record.name, record.type, str(record.ttl), record.data])", + " File \"/usr/local/namesurfer/webui2/webui/service/base/implementation.py\", line 63, in validate_key", + " raise ApiFault('Server.Keyfailure', 'Unknown keyname %s' % keyname)", + "ApiFault: service(13): Unknown keyname user", + "" + ] + } +} diff --git a/providers/dns/namesurfer/internal/fixtures/listZones-request.json b/providers/dns/namesurfer/internal/fixtures/listZones-request.json new file mode 100644 index 000000000..06689de7a --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/listZones-request.json @@ -0,0 +1,9 @@ +{ + "id": 1, + "method": "listZones", + "params": [ + "user", + "2739461ea1a3dc51302993f724f40228409c53b78025d8d7b1d7bba3c1bf2d66", + "value" + ] +} diff --git a/providers/dns/namesurfer/internal/fixtures/listZones.json b/providers/dns/namesurfer/internal/fixtures/listZones.json new file mode 100644 index 000000000..37fa2053b --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/listZones.json @@ -0,0 +1,17 @@ +{ + "id": 1, + "result": [ + { + "name": "example.com", + "view": "viewA" + }, + { + "name": "example.org", + "view": "viewB" + }, + { + "name": "example.net", + "view": "viewC" + } + ] +} diff --git a/providers/dns/namesurfer/internal/fixtures/searchDNSHosts-request.json b/providers/dns/namesurfer/internal/fixtures/searchDNSHosts-request.json new file mode 100644 index 000000000..4a88340e2 --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/searchDNSHosts-request.json @@ -0,0 +1,9 @@ +{ + "id": 1, + "method": "searchDNSHosts", + "params": [ + "user", + "02cf1a2f6e124507d16738d595f583932185313fc96afc2d8404960acaec29b4", + "value" + ] +} diff --git a/providers/dns/namesurfer/internal/fixtures/searchDNSHosts.json b/providers/dns/namesurfer/internal/fixtures/searchDNSHosts.json new file mode 100644 index 000000000..822459148 --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/searchDNSHosts.json @@ -0,0 +1,23 @@ +{ + "id": 1, + "result": [ + { + "name": "foo", + "type": "TXT", + "data": "xxx", + "ttl": 300 + }, + { + "name": "_acme-challenge", + "type": "TXT", + "data": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "ttl": 300 + }, + { + "name": "bar", + "type": "A", + "data": "yyy", + "ttl": 300 + } + ] +} diff --git a/providers/dns/namesurfer/internal/fixtures/updateDNSHost-request.json b/providers/dns/namesurfer/internal/fixtures/updateDNSHost-request.json new file mode 100644 index 000000000..c99218ec5 --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/updateDNSHost-request.json @@ -0,0 +1,17 @@ +{ + "id": 1, + "method": "updateDNSHost", + "params": [ + "user", + "510e63288ac874c1d5ba313a9411591daa346e5621fb0153263adc278794e378", + "example.com", + "viewA", + { + "name": "_acme-challenge", + "type": "TXT", + "data": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", + "ttl": 300 + }, + {} + ] +} diff --git a/providers/dns/namesurfer/internal/fixtures/updateDNSHost.json b/providers/dns/namesurfer/internal/fixtures/updateDNSHost.json new file mode 100644 index 000000000..f41779e30 --- /dev/null +++ b/providers/dns/namesurfer/internal/fixtures/updateDNSHost.json @@ -0,0 +1,4 @@ +{ + "id": 1, + "result": true +} diff --git a/providers/dns/namesurfer/internal/types.go b/providers/dns/namesurfer/internal/types.go new file mode 100644 index 000000000..f95593745 --- /dev/null +++ b/providers/dns/namesurfer/internal/types.go @@ -0,0 +1,72 @@ +package internal + +import ( + "encoding/json" + "fmt" + "strings" +) + +// DNSNode represents a DNS record. +// http://95.128.3.201:8053/API/NSService_10#DNSNode +type DNSNode struct { + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Data string `json:"data,omitempty"` + TTL int `json:"ttl,omitempty"` +} + +// DNSZone represents a DNS zone. +// http://95.128.3.201:8053/API/NSService_10#DNSZone +type DNSZone struct { + Name string `json:"name,omitempty"` + View string `json:"view,omitempty"` +} + +// APIRequest represents a JSON-RPC request. +// https://www.jsonrpc.org/specification_v1#a1.1Requestmethodinvocation +type APIRequest struct { + ID any `json:"id"` // Can be int or string depending on API + Method string `json:"method"` + Params []any `json:"params"` +} + +// APIResponse represents a JSON-RPC response. +// https://www.jsonrpc.org/specification_v1#a1.2Response +type APIResponse struct { + ID any `json:"id"` // Can be int or string depending on API + Result json.RawMessage `json:"result"` + Error *APIError `json:"error"` +} + +// APIError represents an error. +type APIError struct { + Code any `json:"code"` // Can be int or string depending on API + Filename string `json:"filename"` + LineNumber int `json:"lineno"` + Message string `json:"string"` + Detail []string `json:"detail"` +} + +func (e *APIError) Error() string { + var msg strings.Builder + + msg.WriteString(fmt.Sprintf("code: %v", e.Code)) + + if e.Filename != "" { + msg.WriteString(fmt.Sprintf(", filename: %s", e.Filename)) + } + + if e.LineNumber > 0 { + msg.WriteString(fmt.Sprintf(", line: %d", e.LineNumber)) + } + + if e.Message != "" { + msg.WriteString(fmt.Sprintf(", message: %s", e.Message)) + } + + if len(e.Detail) > 0 { + msg.WriteString(fmt.Sprintf(", detail: %v", strings.Join(e.Detail, " "))) + } + + return msg.String() +} diff --git a/providers/dns/namesurfer/namesurfer.go b/providers/dns/namesurfer/namesurfer.go new file mode 100644 index 000000000..6b7f48402 --- /dev/null +++ b/providers/dns/namesurfer/namesurfer.go @@ -0,0 +1,214 @@ +// Package namesurfer implements a DNS provider for solving the DNS-01 challenge using FusionLayer NameSurfer API. +package namesurfer + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "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/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/namesurfer/internal" +) + +// Environment variables names. +const ( + envNamespace = "NAMESURFER_" + + EnvBaseURL = envNamespace + "BASE_URL" + EnvAPIKey = envNamespace + "API_KEY" + EnvAPISecret = envNamespace + "API_SECRET" + EnvView = envNamespace + "VIEW" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" + EnvInsecureSkipVerify = envNamespace + "INSECURE_SKIP_VERIFY" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + BaseURL string + APIKey string + APISecret string + View 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, 300), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), + 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 + + zones map[string]string + zonesMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for FusionLayer NameSurfer. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvBaseURL, EnvAPIKey, EnvAPISecret) + if err != nil { + return nil, fmt.Errorf("namesurfer: %w", err) + } + + config := NewDefaultConfig() + config.BaseURL = values[EnvBaseURL] + config.APIKey = values[EnvAPIKey] + config.APISecret = values[EnvAPISecret] + config.View = env.GetOrDefaultString(EnvView, "") + + if env.GetOrDefaultBool(EnvInsecureSkipVerify, false) { + config.HTTPClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + } + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for FusionLayer NameSurfer. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("namesurfer: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.BaseURL, config.APIKey, config.APISecret) + if err != nil { + return nil, fmt.Errorf("namesurfer: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + zones: make(map[string]string), + }, 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) + + zone, err := d.findZone(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("namesurfer: %w", err) + } + + d.zonesMu.Lock() + d.zones[token] = zone + d.zonesMu.Unlock() + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) + if err != nil { + return fmt.Errorf("namesurfer: %w", err) + } + + record := internal.DNSNode{ + Name: subDomain, + Type: "TXT", + TTL: d.config.TTL, + Data: info.Value, + } + + err = d.client.AddDNSRecord(ctx, zone, d.config.View, record) + if err != nil { + return fmt.Errorf("namesurfer: add DNS 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) + + d.zonesMu.Lock() + zone, ok := d.zones[token] + d.zonesMu.Unlock() + + if !ok { + return fmt.Errorf("namesurfer: unknown zone for '%s'", info.EffectiveFQDN) + } + + d.zonesMu.Lock() + delete(d.zones, token) + d.zonesMu.Unlock() + + existing, err := d.client.SearchDNSHosts(ctx, dns01.UnFqdn(info.EffectiveFQDN)) + if err != nil { + return fmt.Errorf("namesurfer: search DNS hosts: %w", err) + } + + for _, node := range existing { + if node.Type != "TXT" || node.Data != info.Value { + continue + } + + err = d.client.UpdateDNSHost(ctx, zone, d.config.View, node, internal.DNSNode{}) + if err != nil { + return fmt.Errorf("namesurfer: update DNS host: %w", err) + } + } + + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) findZone(ctx context.Context, fqdn string) (string, error) { + zones, err := d.client.ListZones(ctx, "forward") + if err != nil { + return "", fmt.Errorf("list zones: %w", err) + } + + domain := dns01.UnFqdn(fqdn) + + var zoneName string + + for _, zone := range zones { + if strings.HasSuffix(domain, zone.Name) && len(zone.Name) > len(zoneName) { + zoneName = zone.Name + } + } + + if zoneName == "" { + return "", fmt.Errorf("no zone found for %s", fqdn) + } + + return zoneName, nil +} diff --git a/providers/dns/namesurfer/namesurfer.toml b/providers/dns/namesurfer/namesurfer.toml new file mode 100644 index 000000000..fd914ec0c --- /dev/null +++ b/providers/dns/namesurfer/namesurfer.toml @@ -0,0 +1,28 @@ +Name = "FusionLayer NameSurfer" +Description = '''''' +URL = "https://www.fusionlayer.com/" +Code = "namesurfer" +Since = "v4.32.0" + +Example = ''' +NAMESURFER_BASE_URL=https://foo.example.com:8443/API/NSService_10 \ +NAMESURFER_API_KEY=xxx \ +NAMESURFER_API_SECRET=yyy \ +lego --dns namesurfer -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + NAMESURFER_BASE_URL = "The base URL of NameSurfer API (jsonrpc10) endpoint URL (e.g., https://foo.example.com:8443/API/NSService_10)" + NAMESURFER_API_KEY = "API key name" + NAMESURFER_API_SECRET = "API secret" + [Configuration.Additional] + NAMESURFER_VIEW = "DNS view name (optional, default: empty string)" + NAMESURFER_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + NAMESURFER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" + NAMESURFER_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 300)" + NAMESURFER_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + NAMESURFER_INSECURE_SKIP_VERIFY = "Whether to verify the API certificate" + +[Links] + API = "https://web.archive.org/web/20260213170737/http://95.128.3.201:8053/API/NSService_10" diff --git a/providers/dns/namesurfer/namesurfer_test.go b/providers/dns/namesurfer/namesurfer_test.go new file mode 100644 index 000000000..ce3aa37af --- /dev/null +++ b/providers/dns/namesurfer/namesurfer_test.go @@ -0,0 +1,174 @@ +package namesurfer + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvBaseURL, + EnvAPIKey, + EnvAPISecret, + EnvView, +).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvBaseURL: "https://example.com", + EnvAPIKey: "user", + EnvAPISecret: "secret", + }, + }, + { + desc: "missing base URL", + envVars: map[string]string{ + EnvBaseURL: "", + EnvAPIKey: "user", + EnvAPISecret: "secret", + }, + expected: "namesurfer: some credentials information are missing: NAMESURFER_BASE_URL", + }, + { + desc: "missing API key", + envVars: map[string]string{ + EnvBaseURL: "https://example.com", + EnvAPIKey: "", + EnvAPISecret: "secret", + }, + expected: "namesurfer: some credentials information are missing: NAMESURFER_API_KEY", + }, + { + desc: "missing API secret", + envVars: map[string]string{ + EnvBaseURL: "https://example.com", + EnvAPIKey: "user", + EnvAPISecret: "", + }, + expected: "namesurfer: some credentials information are missing: NAMESURFER_API_SECRET", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "namesurfer: some credentials information are missing: NAMESURFER_BASE_URL,NAMESURFER_API_KEY,NAMESURFER_API_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 + baseURL string + apiKey string + apiSecret string + expected string + }{ + { + desc: "success", + baseURL: "https://example.com", + apiKey: "user", + apiSecret: "secret", + }, + { + desc: "missing base URL", + apiKey: "user", + apiSecret: "secret", + expected: "namesurfer: base URL missing", + }, + { + desc: "missing API key", + baseURL: "https://example.com", + apiSecret: "secret", + expected: "namesurfer: credentials missing", + }, + { + desc: "missing API secret", + baseURL: "https://example.com", + apiKey: "user", + expected: "namesurfer: credentials missing", + }, + { + desc: "missing credentials", + expected: "namesurfer: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.BaseURL = test.baseURL + config.APIKey = test.apiKey + config.APISecret = test.apiSecret + + 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/zz_gen_dns_providers.go b/providers/dns/zz_gen_dns_providers.go index ae41f6a20..10fda2df1 100644 --- a/providers/dns/zz_gen_dns_providers.go +++ b/providers/dns/zz_gen_dns_providers.go @@ -125,6 +125,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/namecheap" "github.com/go-acme/lego/v4/providers/dns/namedotcom" "github.com/go-acme/lego/v4/providers/dns/namesilo" + "github.com/go-acme/lego/v4/providers/dns/namesurfer" "github.com/go-acme/lego/v4/providers/dns/nearlyfreespeech" "github.com/go-acme/lego/v4/providers/dns/neodigit" "github.com/go-acme/lego/v4/providers/dns/netcup" @@ -434,6 +435,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return namedotcom.NewDNSProvider() case "namesilo": return namesilo.NewDNSProvider() + case "namesurfer": + return namesurfer.NewDNSProvider() case "nearlyfreespeech": return nearlyfreespeech.NewDNSProvider() case "neodigit": From c06f378f0ed626fe9f8edddfbe8647d50f4f36f3 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Sun, 15 Feb 2026 10:42:48 +0100 Subject: [PATCH 13/21] namesurfer: fix updateDNSHost (#2854) --- .../internal/fixtures/updateDNSHost-request.json | 7 ++++++- providers/dns/namesurfer/internal/types.go | 8 ++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/providers/dns/namesurfer/internal/fixtures/updateDNSHost-request.json b/providers/dns/namesurfer/internal/fixtures/updateDNSHost-request.json index c99218ec5..494de20c6 100644 --- a/providers/dns/namesurfer/internal/fixtures/updateDNSHost-request.json +++ b/providers/dns/namesurfer/internal/fixtures/updateDNSHost-request.json @@ -12,6 +12,11 @@ "data": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "ttl": 300 }, - {} + { + "name": "", + "type": "", + "data": "", + "ttl": 0 + } ] } diff --git a/providers/dns/namesurfer/internal/types.go b/providers/dns/namesurfer/internal/types.go index f95593745..7b3f08013 100644 --- a/providers/dns/namesurfer/internal/types.go +++ b/providers/dns/namesurfer/internal/types.go @@ -9,10 +9,10 @@ import ( // DNSNode represents a DNS record. // http://95.128.3.201:8053/API/NSService_10#DNSNode type DNSNode struct { - Name string `json:"name,omitempty"` - Type string `json:"type,omitempty"` - Data string `json:"data,omitempty"` - TTL int `json:"ttl,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Data string `json:"data"` + TTL int `json:"ttl"` } // DNSZone represents a DNS zone. From 94e3bfb96af1e16a7defa7795e33d5185ed20e0f Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Tue, 17 Feb 2026 23:34:27 +0100 Subject: [PATCH 14/21] chore: update linter (#2857) --- .github/workflows/pr.yml | 2 +- acme/errors.go | 10 +++++----- providers/dns/binarylane/internal/types.go | 6 +++--- providers/dns/cloudflare/internal/types.go | 6 +++--- providers/dns/dnsexit/internal/types.go | 6 +++--- providers/dns/godaddy/internal/types.go | 4 ++-- providers/dns/gravity/internal/types.go | 8 ++++---- .../dns/hetzner/internal/hetznerv1/internal/types.go | 10 +++++----- providers/dns/hostinger/internal/types.go | 6 +++--- providers/dns/hosttech/internal/types.go | 6 +++--- providers/dns/mittwald/internal/types.go | 8 ++++---- providers/dns/namesurfer/internal/types.go | 12 ++++++------ 12 files changed, 42 insertions(+), 42 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9c845ea62..33ca106cc 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest env: GO_VERSION: stable - GOLANGCI_LINT_VERSION: v2.9.0 + GOLANGCI_LINT_VERSION: v2.10 HUGO_VERSION: 0.148.2 CGO_ENABLED: 0 LEGO_E2E_TESTS: CI diff --git a/acme/errors.go b/acme/errors.go index be4721c9d..cd447d7b4 100644 --- a/acme/errors.go +++ b/acme/errors.go @@ -29,18 +29,18 @@ type ProblemDetails struct { } func (p *ProblemDetails) Error() string { - var msg strings.Builder + msg := new(strings.Builder) - msg.WriteString(fmt.Sprintf("acme: error: %d", p.HTTPStatus)) + _, _ = fmt.Fprintf(msg, "acme: error: %d", p.HTTPStatus) if p.Method != "" || p.URL != "" { - msg.WriteString(fmt.Sprintf(" :: %s :: %s", p.Method, p.URL)) + _, _ = fmt.Fprintf(msg, " :: %s :: %s", p.Method, p.URL) } - msg.WriteString(fmt.Sprintf(" :: %s :: %s", p.Type, p.Detail)) + _, _ = fmt.Fprintf(msg, " :: %s :: %s", p.Type, p.Detail) for _, sub := range p.SubProblems { - msg.WriteString(fmt.Sprintf(", problem: %q :: %s", sub.Type, sub.Detail)) + _, _ = fmt.Fprintf(msg, ", problem: %q :: %s", sub.Type, sub.Detail) } if p.Instance != "" { diff --git a/providers/dns/binarylane/internal/types.go b/providers/dns/binarylane/internal/types.go index 987e5c356..06d4be5c0 100644 --- a/providers/dns/binarylane/internal/types.go +++ b/providers/dns/binarylane/internal/types.go @@ -15,12 +15,12 @@ type APIError struct { } func (a *APIError) Error() string { - var msg strings.Builder + msg := new(strings.Builder) - msg.WriteString(fmt.Sprintf("%d: %s: %s: %s: %s", a.Status, a.Type, a.Title, a.Detail, a.Instance)) + _, _ = fmt.Fprintf(msg, "%d: %s: %s: %s: %s", a.Status, a.Type, a.Title, a.Detail, a.Instance) for s, values := range a.Errors { - msg.WriteString(fmt.Sprintf(": %s: %s", s, strings.Join(values, ", "))) + _, _ = fmt.Fprintf(msg, ": %s: %s", s, strings.Join(values, ", ")) } return msg.String() diff --git a/providers/dns/cloudflare/internal/types.go b/providers/dns/cloudflare/internal/types.go index 4a7f9e031..50a7bbbf9 100644 --- a/providers/dns/cloudflare/internal/types.go +++ b/providers/dns/cloudflare/internal/types.go @@ -42,13 +42,13 @@ type ErrorChain struct { type Errors []Message func (e Errors) Error() string { - var msg strings.Builder + msg := new(strings.Builder) for _, item := range e { - msg.WriteString(fmt.Sprintf("%d: %s", item.Code, item.Message)) + _, _ = fmt.Fprintf(msg, "%d: %s", item.Code, item.Message) for _, link := range item.ErrorChain { - msg.WriteString(fmt.Sprintf("; %d: %s", link.Code, link.Message)) + _, _ = fmt.Fprintf(msg, "; %d: %s", link.Code, link.Message) } } diff --git a/providers/dns/dnsexit/internal/types.go b/providers/dns/dnsexit/internal/types.go index 060dd883e..db254549f 100644 --- a/providers/dns/dnsexit/internal/types.go +++ b/providers/dns/dnsexit/internal/types.go @@ -29,12 +29,12 @@ type APIResponse struct { } func (a APIResponse) Error() string { - var msg strings.Builder + msg := new(strings.Builder) - msg.WriteString(fmt.Sprintf("%s (code=%d)", a.Message, a.Code)) + _, _ = fmt.Fprintf(msg, "%s (code=%d)", a.Message, a.Code) for _, detail := range a.Details { - msg.WriteString(fmt.Sprintf(", %s", detail)) + _, _ = fmt.Fprintf(msg, ", %s", detail) } return msg.String() diff --git a/providers/dns/godaddy/internal/types.go b/providers/dns/godaddy/internal/types.go index c1e6d6638..3bd5c9560 100644 --- a/providers/dns/godaddy/internal/types.go +++ b/providers/dns/godaddy/internal/types.go @@ -26,9 +26,9 @@ type APIError struct { } func (a APIError) Error() string { - var msg strings.Builder + msg := new(strings.Builder) - msg.WriteString(fmt.Sprintf("%s: %s", a.Code, a.Message)) + _, _ = fmt.Fprintf(msg, "%s: %s", a.Code, a.Message) for _, field := range a.Fields { msg.WriteString(" ") diff --git a/providers/dns/gravity/internal/types.go b/providers/dns/gravity/internal/types.go index cb6e99083..872bc070f 100644 --- a/providers/dns/gravity/internal/types.go +++ b/providers/dns/gravity/internal/types.go @@ -13,17 +13,17 @@ type APIError struct { } func (a *APIError) Error() string { - var msg strings.Builder + msg := new(strings.Builder) - msg.WriteString(fmt.Sprintf("status: %s, error: %s", a.Status, a.ErrorMsg)) + _, _ = fmt.Fprintf(msg, "status: %s, error: %s", a.Status, a.ErrorMsg) if a.Code != 0 { - msg.WriteString(fmt.Sprintf(", code: %d", a.Code)) + _, _ = fmt.Fprintf(msg, ", code: %d", a.Code) } if len(a.Context) != 0 { for k, v := range a.Context { - msg.WriteString(fmt.Sprintf(", %s: %s", k, v)) + _, _ = fmt.Fprintf(msg, ", %s: %s", k, v) } } diff --git a/providers/dns/hetzner/internal/hetznerv1/internal/types.go b/providers/dns/hetzner/internal/hetznerv1/internal/types.go index 47e8a3f91..2b38a8a8c 100644 --- a/providers/dns/hetzner/internal/hetznerv1/internal/types.go +++ b/providers/dns/hetzner/internal/hetznerv1/internal/types.go @@ -16,20 +16,20 @@ type ErrorInfo struct { } func (i *ErrorInfo) Error() string { - var msg strings.Builder + msg := new(strings.Builder) - msg.WriteString(fmt.Sprintf("%s: %s", i.Code, i.Message)) + _, _ = fmt.Fprintf(msg, "%s: %s", i.Code, i.Message) if i.Details.Announcement != "" { - msg.WriteString(fmt.Sprintf(": %s", i.Details.Announcement)) + _, _ = fmt.Fprintf(msg, ": %s", i.Details.Announcement) } for _, limit := range i.Details.Limits { - msg.WriteString(fmt.Sprintf("limit: %s", limit.Name)) + _, _ = fmt.Fprintf(msg, "limit: %s", limit.Name) } for _, field := range i.Details.Fields { - msg.WriteString(fmt.Sprintf("field: %s: %s", field.Name, strings.Join(field.Messages, ", "))) + _, _ = fmt.Fprintf(msg, "field: %s: %s", field.Name, strings.Join(field.Messages, ", ")) } return msg.String() diff --git a/providers/dns/hostinger/internal/types.go b/providers/dns/hostinger/internal/types.go index 534dfa5e5..c1a02ff8c 100644 --- a/providers/dns/hostinger/internal/types.go +++ b/providers/dns/hostinger/internal/types.go @@ -12,12 +12,12 @@ type APIError struct { } func (a *APIError) Error() string { - var msg strings.Builder + msg := new(strings.Builder) - msg.WriteString(fmt.Sprintf("%s: %s", a.CorrelationID, a.Message)) + _, _ = fmt.Fprintf(msg, "%s: %s", a.CorrelationID, a.Message) for field, values := range a.Errors { - msg.WriteString(fmt.Sprintf(": %s: %s", field, strings.Join(values, ", "))) + _, _ = fmt.Fprintf(msg, ": %s: %s", field, strings.Join(values, ", ")) } return msg.String() diff --git a/providers/dns/hosttech/internal/types.go b/providers/dns/hosttech/internal/types.go index 854fc4883..a4b5b564d 100644 --- a/providers/dns/hosttech/internal/types.go +++ b/providers/dns/hosttech/internal/types.go @@ -16,12 +16,12 @@ type APIError struct { } func (a APIError) Error() string { - var msg strings.Builder + msg := new(strings.Builder) - msg.WriteString(fmt.Sprintf("%d: %s", a.StatusCode, a.Message)) + _, _ = fmt.Fprintf(msg, "%d: %s", a.StatusCode, a.Message) for k, v := range a.Errors { - msg.WriteString(fmt.Sprintf(" %s: %v", k, v)) + _, _ = fmt.Fprintf(msg, " %s: %v", k, v) } return msg.String() diff --git a/providers/dns/mittwald/internal/types.go b/providers/dns/mittwald/internal/types.go index ce49cb820..86cdf065c 100644 --- a/providers/dns/mittwald/internal/types.go +++ b/providers/dns/mittwald/internal/types.go @@ -61,14 +61,14 @@ type APIError struct { } func (a APIError) Error() string { - var msg strings.Builder + msg := new(strings.Builder) - msg.WriteString(fmt.Sprintf("%s: %s", a.Type, a.Message)) + _, _ = fmt.Fprintf(msg, "%s: %s", a.Type, a.Message) if len(a.ValidationErrors) > 0 { for _, validationError := range a.ValidationErrors { - msg.WriteString(fmt.Sprintf(" [%s: %s (%s, %s)]", - validationError.Type, validationError.Message, validationError.Path, validationError.Context.Format)) + _, _ = fmt.Fprintf(msg, " [%s: %s (%s, %s)]", + validationError.Type, validationError.Message, validationError.Path, validationError.Context.Format) } } diff --git a/providers/dns/namesurfer/internal/types.go b/providers/dns/namesurfer/internal/types.go index 7b3f08013..d364c1876 100644 --- a/providers/dns/namesurfer/internal/types.go +++ b/providers/dns/namesurfer/internal/types.go @@ -48,24 +48,24 @@ type APIError struct { } func (e *APIError) Error() string { - var msg strings.Builder + msg := new(strings.Builder) - msg.WriteString(fmt.Sprintf("code: %v", e.Code)) + _, _ = fmt.Fprintf(msg, "code: %v", e.Code) if e.Filename != "" { - msg.WriteString(fmt.Sprintf(", filename: %s", e.Filename)) + _, _ = fmt.Fprintf(msg, ", filename: %s", e.Filename) } if e.LineNumber > 0 { - msg.WriteString(fmt.Sprintf(", line: %d", e.LineNumber)) + _, _ = fmt.Fprintf(msg, ", line: %d", e.LineNumber) } if e.Message != "" { - msg.WriteString(fmt.Sprintf(", message: %s", e.Message)) + _, _ = fmt.Fprintf(msg, ", message: %s", e.Message) } if len(e.Detail) > 0 { - msg.WriteString(fmt.Sprintf(", detail: %v", strings.Join(e.Detail, " "))) + _, _ = fmt.Fprintf(msg, ", detail: %v", strings.Join(e.Detail, " ")) } return msg.String() From 84f3be40f0cfa471abae47db3d9e11a54ff1ae34 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Wed, 18 Feb 2026 23:26:31 +0100 Subject: [PATCH 15/21] chore: update dependencies (#2860) --- go.mod | 109 ++++++++++++++------------- go.sum | 233 +++++++++++++++++++++++++++++---------------------------- 2 files changed, 174 insertions(+), 168 deletions(-) diff --git a/go.mod b/go.mod index 42e11820b..b8e88428e 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24.0 require ( cloud.google.com/go/compute/metadata v0.9.0 github.com/Azure/azure-sdk-for-go v68.0.0+incompatible - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 @@ -15,28 +15,28 @@ require ( github.com/Azure/go-autorest/autorest/to v0.4.1 github.com/BurntSushi/toml v1.6.0 github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0 - github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 + github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.15 github.com/alibabacloud-go/tea v1.4.0 github.com/aliyun/credentials-go v1.4.7 - github.com/aws/aws-sdk-go-v2 v1.41.0 - github.com/aws/aws-sdk-go-v2/config v1.32.6 - github.com/aws/aws-sdk-go-v2/credentials v1.19.6 - github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.10 - github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0 - github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 - github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 + github.com/aws/aws-sdk-go-v2 v1.41.1 + github.com/aws/aws-sdk-go-v2/config v1.32.8 + github.com/aws/aws-sdk-go-v2/credentials v1.19.8 + github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.11 + github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1 + github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 + github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 github.com/aziontech/azionapi-go-sdk v0.144.0 - github.com/baidubce/bce-sdk-go v0.9.256 + github.com/baidubce/bce-sdk-go v0.9.260 github.com/cenkalti/backoff/v5 v5.0.3 github.com/dnsimple/dnsimple-go/v4 v4.0.0 github.com/exoscale/egoscale/v3 v3.1.33 github.com/go-acme/alidns-20150109/v4 v4.7.0 - github.com/go-acme/esa-20240910/v2 v2.44.0 + github.com/go-acme/esa-20240910/v2 v2.48.0 github.com/go-acme/jdcloud-sdk-go v1.64.0 - github.com/go-acme/tencentclouddnspod v1.1.25 - github.com/go-acme/tencentedgdeone v1.1.48 + github.com/go-acme/tencentclouddnspod v1.3.24 + github.com/go-acme/tencentedgdeone v1.3.38 github.com/go-jose/go-jose/v4 v4.1.3 - github.com/go-viper/mapstructure/v2 v2.4.0 + github.com/go-viper/mapstructure/v2 v2.5.0 github.com/google/go-cmp v0.7.0 github.com/google/go-querystring v1.2.0 github.com/google/uuid v1.6.0 @@ -44,18 +44,18 @@ require ( github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 github.com/hashicorp/go-retryablehttp v0.7.8 github.com/hashicorp/go-version v1.8.0 - github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.182 + github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187 github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df github.com/infobloxopen/infoblox-go-client/v2 v2.10.0 github.com/labbsr0x/bindman-dns-webhook v1.0.2 github.com/ldez/grignotin v0.10.1 - github.com/linode/linodego v1.64.0 + github.com/linode/linodego v1.65.0 github.com/liquidweb/liquidweb-go v1.6.4 github.com/mattn/go-isatty v0.0.20 - github.com/miekg/dns v1.1.69 + github.com/miekg/dns v1.1.72 github.com/mimuret/golang-iij-dpf v0.9.1 github.com/namedotcom/go/v4 v4.0.2 - github.com/nrdcg/auroradns v1.1.0 + github.com/nrdcg/auroradns v1.2.0 github.com/nrdcg/bunny-go v0.1.0 github.com/nrdcg/desec v0.11.1 github.com/nrdcg/dnspod-go v0.4.0 @@ -65,8 +65,8 @@ require ( github.com/nrdcg/mailinabox v0.3.0 github.com/nrdcg/namesilo v0.5.0 github.com/nrdcg/nodion v0.1.0 - github.com/nrdcg/oci-go-sdk/common/v1065 v1065.105.2 - github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.105.2 + github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2 + github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2 github.com/nrdcg/porkbun v0.4.0 github.com/nrdcg/vegadns v0.3.0 github.com/nzdjb/go-metaname v1.0.0 @@ -81,29 +81,29 @@ require ( github.com/selectel/go-selvpcclient/v4 v4.1.0 github.com/softlayer/softlayer-go v1.2.1 github.com/stretchr/testify v1.11.1 - github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.28 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.48 github.com/transip/gotransip/v6 v6.26.1 github.com/ultradns/ultradns-go-sdk v1.8.1-20250722213956-faef419 github.com/urfave/cli/v2 v2.27.7 github.com/vinyldns/go-vinyldns v0.9.17 - github.com/volcengine/volc-sdk-golang v1.0.233 - github.com/vultr/govultr/v3 v3.26.1 - github.com/yandex-cloud/go-genproto v0.43.0 - github.com/yandex-cloud/go-sdk/services/dns v0.0.25 - github.com/yandex-cloud/go-sdk/v2 v2.37.0 - golang.org/x/crypto v0.46.0 - golang.org/x/net v0.48.0 - golang.org/x/oauth2 v0.34.0 - golang.org/x/text v0.32.0 + github.com/volcengine/volc-sdk-golang v1.0.237 + github.com/vultr/govultr/v3 v3.27.0 + github.com/yandex-cloud/go-genproto v0.54.0 + github.com/yandex-cloud/go-sdk/services/dns v0.0.36 + github.com/yandex-cloud/go-sdk/v2 v2.56.0 + golang.org/x/crypto v0.48.0 + golang.org/x/net v0.50.0 + golang.org/x/oauth2 v0.35.0 + golang.org/x/text v0.34.0 golang.org/x/time v0.14.0 - google.golang.org/api v0.259.0 - gopkg.in/ns1/ns1-go.v2 v2.16.0 + google.golang.org/api v0.267.0 + gopkg.in/ns1/ns1-go.v2 v2.17.2 gopkg.in/yaml.v2 v2.4.0 software.sslmate.com/src/go-pkcs12 v0.7.0 ) require ( - cloud.google.com/go/auth v0.18.0 // indirect + cloud.google.com/go/auth v0.18.1 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect @@ -119,22 +119,23 @@ require ( github.com/alibabacloud-go/openapi-util v0.1.1 // indirect github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/benbjohnson/clock v1.3.5 // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -160,8 +161,8 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect - github.com/googleapis/gax-go/v2 v2.16.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect @@ -205,23 +206,23 @@ require ( go.mongodb.org/mongo-driver v1.13.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.38.0 // indirect - go.opentelemetry.io/otel/metric v1.38.0 // indirect - go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/ratelimit v0.3.1 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/exp v0.0.0-20241210194714-1829a127f884 // indirect - golang.org/x/mod v0.30.0 // indirect + golang.org/x/mod v0.32.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/tools v0.39.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/tools v0.41.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect google.golang.org/grpc v1.78.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/ini.v1 v1.67.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 889e6b5b5..f5b87c9fe 100644 --- a/go.sum +++ b/go.sum @@ -13,8 +13,8 @@ cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKV cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= -cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= +cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= +cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= @@ -42,8 +42,8 @@ github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 h1:Dy3M9aegiI7d7PF1LUdjbVigJReo+QOceYs github.com/AdamSLevy/jsonrpc2/v14 v14.1.0/go.mod h1:ZakZtbCXxCz82NJvq7MoREtiQesnDfrtF6RFUGzQfLo= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= @@ -121,8 +121,10 @@ github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8= github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc= github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc= -github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 h1:Q00FU3H94Ts0ZIHDmY+fYGgB7dV9D/YX6FGsgorQPgw= github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.14/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.15 h1:Mubp9hXZMTPWZK+WxrR+kKOVFp4Q/PDZrIIM7ByXI9Y= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.15/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE= github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg= github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ= github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo= @@ -169,54 +171,54 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:W github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/aws/aws-sdk-go v1.40.45/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= github.com/aws/aws-sdk-go-v2 v1.9.1/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= -github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= -github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= -github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= -github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE= -github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= +github.com/aws/aws-sdk-go-v2/config v1.32.8 h1:iu+64gwDKEoKnyTQskSku72dAwggKI5sV6rNvgSMpMs= +github.com/aws/aws-sdk-go-v2/config v1.32.8/go.mod h1:MI2XvA+qDi3i9AJxX1E2fu730syEBzp/jnXrjxuHwgI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.8 h1:Jp2JYH1lRT3KhX4mshHPvVYsR5qqRec3hGvEarNYoR0= +github.com/aws/aws-sdk-go-v2/credentials v1.19.8/go.mod h1:fZG9tuvyVfxknv1rKibIz3DobRaFw1Poe8IKtXB3XYY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0= github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.8.1/go.mod h1:CM+19rL1+4dFWnOQKwDc7H1KwXTz+h61oUSHyhV0b3o= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A= -github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.10 h1:MQuZZ6Tq1qQabPlkVxrCMdyVl70Ogl4AERZKo+y9Wzo= -github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.10/go.mod h1:U5C3JME1ibKESmpzBAqlRpTYZfVbTqrb5ICJm+sVVd8= -github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0 h1:80pDB3Tpmb2RCSZORrK9/3iQxsd+w6vSzVqpT1FGiwE= -github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0/go.mod h1:6EZUGGNLPLh5Unt30uEoA+KQcByERfXIkax9qrc80nA= -github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0= -github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= +github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.11 h1:VM5e5M39zRSs+aT0O9SoxHjUXqXxhbw3Yi0FdMQWPIc= +github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.11/go.mod h1:0jvzYPIQGCpnY/dmdaotTk2JH4QuBlnW0oeyrcGLWJ4= +github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1 h1:1jIdwWOulae7bBLIgB36OZ0DINACb1wxM6wdGlx4eHE= +github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1/go.mod h1:tE2zGlMIlxWv+7Otap7ctRp3qeKqtnja7DZguj3Vu/Y= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aziontech/azionapi-go-sdk v0.144.0 h1:T+/w18o+FCiZsk3Z0ACBVVe7c/5EGLG15S3P8JfuPfo= github.com/aziontech/azionapi-go-sdk v0.144.0/go.mod h1:OKxP/R0iVXnJJakYwMhh2BGAXnud8Ruy55Ak9ANuWoU= -github.com/baidubce/bce-sdk-go v0.9.256 h1:/6UwBzDp+dRFpKRIb5WsvxfSiG4SLOIOghvagOK/q4Y= -github.com/baidubce/bce-sdk-go v0.9.256/go.mod h1:zbYJMQwE4IZuyrJiFO8tO8NbtYiKTFTbwh4eIsqjVdg= +github.com/baidubce/bce-sdk-go v0.9.260 h1:1v1+2GTP+NGK3L24rJ+bnoiTaDaIy2YoaUM+ot2GTcw= +github.com/baidubce/bce-sdk-go v0.9.260/go.mod h1:zbYJMQwE4IZuyrJiFO8tO8NbtYiKTFTbwh4eIsqjVdg= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -239,6 +241,8 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -315,14 +319,14 @@ github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-acme/alidns-20150109/v4 v4.7.0 h1:PqJ/wR0JTpL4v0Owu1uM7bPQ1Yww0eQLAuuSdLjjQaQ= github.com/go-acme/alidns-20150109/v4 v4.7.0/go.mod h1:btQvB6xZoN6ykKB74cPhiR+uvhrEE2AFVXm6RDmCHm0= -github.com/go-acme/esa-20240910/v2 v2.44.0 h1:ACi2uFb7ig4ousFs/YiFBR+aw3A4SHtOxvkMWB2Hbcs= -github.com/go-acme/esa-20240910/v2 v2.44.0/go.mod h1:ZYdN9EN9ikn26SNapxCVjZ65pHT/1qm4fzuJ7QGVX6g= +github.com/go-acme/esa-20240910/v2 v2.48.0 h1:muSDyhjDTejxUGe3FTthCPCqRaEdYY9cG3N/AmU52Lc= +github.com/go-acme/esa-20240910/v2 v2.48.0/go.mod h1:shPb6hzc1rJL15IJBY8HQ4GZk4E8RC52+52twutEwIg= github.com/go-acme/jdcloud-sdk-go v1.64.0 h1:AW9j5khk8tRYbpBJPxKmqdwIqgLs2Fz3HUK3hn2YXjs= github.com/go-acme/jdcloud-sdk-go v1.64.0/go.mod h1:qc/m8HNX1Zgd7GAv2DSEinup8fwy3Ted3/VVx7LB5bU= -github.com/go-acme/tencentclouddnspod v1.1.25 h1:7H3ZKshkaHzCXfRpAHVB5nvxeDDl2XLeNZfrNHiZj/s= -github.com/go-acme/tencentclouddnspod v1.1.25/go.mod h1:XXfzp0AYV7UAUsHKT6R0KAUJFhqAUXmWGF07Elpa5cE= -github.com/go-acme/tencentedgdeone v1.1.48 h1:WLyLBsRVhSLFmtbEFXk0naLODSQn7X6J0Fc/qR8xVUk= -github.com/go-acme/tencentedgdeone v1.1.48/go.mod h1:mu6tA+bPhlSd+CKUfzRikE0mfxmTlBI6dVTn9LY9dRI= +github.com/go-acme/tencentclouddnspod v1.3.24 h1:uCSiOW1EJttcnOON+MVVyVDJguFL/Q4NIGkq1CrT9p8= +github.com/go-acme/tencentclouddnspod v1.3.24/go.mod h1:RKcB2wSoZncjBA0OEFj59s1ko1XDy+ZsAtk+9uMxUF0= +github.com/go-acme/tencentedgdeone v1.3.38 h1:5YsVl0H4A+cwtiUqR1eZbKFdr4OWfYp2KYJopifzKyQ= +github.com/go-acme/tencentedgdeone v1.3.38/go.mod h1:yyjTKVmGpMtFv5HqGODqehHnZJ4KWAbG6dAiwWDgCDY= github.com/go-cmd/cmd v1.0.5/go.mod h1:y8q8qlK5wQibcw63djSl/ntiHUHXHGdCkPk0j4QeW4s= github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= @@ -366,8 +370,8 @@ github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8Wd github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw= github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b h1:/vQ+oYKu+JoyaMPDsv5FzwuL2wwWBgBbtj/YLCi4LuA= github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw= @@ -467,12 +471,12 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= -github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= +github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= -github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/gophercloud/gophercloud v1.3.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= github.com/gophercloud/gophercloud v1.14.1 h1:DTCNaTVGl8/cFu58O1JwWgis9gtISAFONqpMKNg/Vpw= github.com/gophercloud/gophercloud v1.14.1/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= @@ -537,8 +541,8 @@ github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOn github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.182 h1:B3W9acgpqu5XsN8v+W8SOTfqn/6n4JsjgoKBsm30HFY= -github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.182/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI= +github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187 h1:J+U6+eUjIsBhefolFdZW5hQNJbkMj+7msxZrv56Cg2g= +github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI= github.com/hudl/fargo v1.4.0/go.mod h1:9Ai6uvFy5fQNq6VPKtg+Ceq1+eTY4nKUlR2JElEOcDo= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -612,8 +616,8 @@ github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufp github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/linode/linodego v1.64.0 h1:If6pULIwHuQytgogtpQaBdVLX7z2TTHUF5u1tj2TPiY= -github.com/linode/linodego v1.64.0/go.mod h1:GoiwLVuLdBQcAebxAVKVL3mMYUgJZR/puOUSla04xBE= +github.com/linode/linodego v1.65.0 h1:SdsuGD8VSsPWeShXpE7ihl5vec+fD3MgwhnfYC/rj7k= +github.com/linode/linodego v1.65.0/go.mod h1:tOFiTErdjkbVnV+4S0+NmIE9dqqZUEM2HsJaGu8wMh8= github.com/liquidweb/go-lwApi v0.0.0-20190605172801-52a4864d2738/go.mod h1:0sYF9rMXb0vlG+4SzdiGMXHheCZxjguMq+Zb4S2BfBs= github.com/liquidweb/liquidweb-cli v0.6.9 h1:acbIvdRauiwbxIsOCEMXGwF75aSJDbDiyAWPjVnwoYM= github.com/liquidweb/liquidweb-cli v0.6.9/go.mod h1:cE1uvQ+x24NGUL75D0QagOFCG8Wdvmwu8aL9TLmA/eQ= @@ -649,8 +653,8 @@ github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3N github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= github.com/miekg/dns v1.1.47/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= -github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc= -github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/mimuret/golang-iij-dpf v0.9.1 h1:Gj6EhHJkOhr+q2RnvRPJsPMcjuVnWPSccEHyoEehU34= github.com/mimuret/golang-iij-dpf v0.9.1/go.mod h1:sl9KyOkESib9+KRD3HaGpgi1xk7eoN2+d96LCLsME2M= github.com/minio/highwayhash v1.0.1/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY= @@ -691,8 +695,8 @@ github.com/nats-io/nkeys v0.2.0/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1t github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/nrdcg/auroradns v1.1.0 h1:KekGh8kmf2MNwqZVVYo/fw/ZONt8QMEmbMFOeljteWo= -github.com/nrdcg/auroradns v1.1.0/go.mod h1:O7tViUZbAcnykVnrGkXzIJTHoQCHcgalgAe6X1mzHfk= +github.com/nrdcg/auroradns v1.2.0 h1:Jg407vTdXZvZKsART9CNWMp8rQOyhBk04q0MsOf0YR4= +github.com/nrdcg/auroradns v1.2.0/go.mod h1:hnByA4Z7MOmV4EPRw5eOmEaNRFavcCIz6kONpNxp9LI= github.com/nrdcg/bunny-go v0.1.0 h1:GAHTRpHaG/TxfLZlqoJ8OJFzw8rI74+jOTkzxWh0uHA= github.com/nrdcg/bunny-go v0.1.0/go.mod h1:u+C9dgsspgtWVaAz6QkyV17s9fxD8viwwKoxb9XMz1A= github.com/nrdcg/desec v0.11.1 h1:ilpKmCr4gGsLcyq3RHfHNmlRzm9fzT2XbWxoVaUCS0s= @@ -711,10 +715,10 @@ github.com/nrdcg/namesilo v0.5.0 h1:6QNxT/XxE+f5B+7QlfWorthNzOzcGlBLRQxqi6YeBrE= github.com/nrdcg/namesilo v0.5.0/go.mod h1:4UkwlwQfDt74kSGmhLaDylnBrD94IfflnpoEaj6T2qw= github.com/nrdcg/nodion v0.1.0 h1:zLKaqTn2X0aDuBHHfyA1zFgeZfiCpmu/O9DM73okavw= github.com/nrdcg/nodion v0.1.0/go.mod h1:inbuh3neCtIWlMPZHtEpe43TmRXxHV6+hk97iCZicms= -github.com/nrdcg/oci-go-sdk/common/v1065 v1065.105.2 h1:l0tH15ACQADZAzC+LZ+mo2tIX4H6uZu0ulrVmG5Tqz0= -github.com/nrdcg/oci-go-sdk/common/v1065 v1065.105.2/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8= -github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.105.2 h1:gzB4c6ztb38C/jYiqEaFC+mCGcWFHDji9e6jwymY9d4= -github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.105.2/go.mod h1:l1qIPIq2uRV5WTSvkbhbl/ndbeOu7OCb3UZ+0+2ZSb8= +github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2 h1:OWijzl3nHUApvTivl+3+78dbBwmyEHOnb+W9m6ixGbk= +github.com/nrdcg/oci-go-sdk/common/v1065 v1065.108.2/go.mod h1:Gcs8GCaZXL3FdiDWgdnMxlOLEdRprJJnPYB22TX1jw8= +github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2 h1:9LsjN/zaIN7H8JE61NHpbWhxF0UGY96+kMlk3g8OvGU= +github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.108.2/go.mod h1:32vZH06TuwZSn+IDMO1qcDvC2vHVlzUALCwXGWPA+dc= github.com/nrdcg/porkbun v0.4.0 h1:rWweKlwo1PToQ3H+tEO9gPRW0wzzgmI/Ob3n2Guticw= github.com/nrdcg/porkbun v0.4.0/go.mod h1:/QMskrHEIM0IhC/wY7iTCUgINsxdT2WcOphktJ9+Q54= github.com/nrdcg/vegadns v0.3.0 h1:11FQMw7xVIRUWO9o5+Z/5YZhmPWlm4oxUUH3F6EVqQU= @@ -904,10 +908,10 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.25/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.48/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.28 h1:Rj1WXXNPm9AsPf0PJhWCvlsqfcKPUYdyVnkmEc3O8sI= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.28/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.24/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.38/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.48 h1:bCs+z6dxRaHWm/C1D/XkSOcCZ0+W2+/6HmIXjpAj+fY= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.48/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w= github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= @@ -922,10 +926,10 @@ github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/vinyldns/go-vinyldns v0.9.17 h1:hfPZfCaxcRBX6Gsgl42rLCeoal58/BH8kkvJShzjjdI= github.com/vinyldns/go-vinyldns v0.9.17/go.mod h1:pwWhE9K/leGDOIduVhRGvQ3ecVMHWRfEnKYUTEU3gB4= -github.com/volcengine/volc-sdk-golang v1.0.233 h1:Hh2pzwu/Wq19rsZgNo3HdpjQB28D/F0+m6EjLVggmhM= -github.com/volcengine/volc-sdk-golang v1.0.233/go.mod h1:zHJlaqiMbIB+0mcrsZPTwOb3FB7S/0MCfqlnO8R7hlM= -github.com/vultr/govultr/v3 v3.26.1 h1:G/M0rMQKwVSmL+gb0UgETbW5mcQi0Vf/o/ZSGdBCxJw= -github.com/vultr/govultr/v3 v3.26.1/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY= +github.com/volcengine/volc-sdk-golang v1.0.237 h1:hpLKiS2BwDcSBtZWSz034foCbd0h3FrHTKlUMqHIdc4= +github.com/volcengine/volc-sdk-golang v1.0.237/go.mod h1:zHJlaqiMbIB+0mcrsZPTwOb3FB7S/0MCfqlnO8R7hlM= +github.com/vultr/govultr/v3 v3.27.0 h1:J8etMyu/Jh5+idMsu2YZpOWmDXXHeW4VZnkYXmJYHx8= +github.com/vultr/govultr/v3 v3.27.0/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= @@ -934,12 +938,12 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= -github.com/yandex-cloud/go-genproto v0.43.0 h1:HjBesEmCN8ZOhjjh8gs605vvi9/MBJAW3P20OJ4iQnw= -github.com/yandex-cloud/go-genproto v0.43.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo= -github.com/yandex-cloud/go-sdk/services/dns v0.0.25 h1:BcGEuOnwq2X3LS2kvFC6BOdZkOq4Lc7XAYvzap/SJJY= -github.com/yandex-cloud/go-sdk/services/dns v0.0.25/go.mod h1:B4QHijALUHIjRxL3aqmOwDrHYUI2XdeeG4WKItth3jI= -github.com/yandex-cloud/go-sdk/v2 v2.37.0 h1:WvttW6p9xcWag9j+GQv+GJXPggggXGwOlIJNfkWmFWw= -github.com/yandex-cloud/go-sdk/v2 v2.37.0/go.mod h1:Dt4a81enjRsm4xMJyW5E1Y/vaUYwXJvUGRdDLuM2k6I= +github.com/yandex-cloud/go-genproto v0.54.0 h1:LjEwDPBAtF39HvcPQe8I+ImCnFasCPCOVh2b2Sr2eAg= +github.com/yandex-cloud/go-genproto v0.54.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo= +github.com/yandex-cloud/go-sdk/services/dns v0.0.36 h1:sD622+baDvJ2ujhCfoFsCH0XeNsaZNW6loRqvRavjtE= +github.com/yandex-cloud/go-sdk/services/dns v0.0.36/go.mod h1:Hh7IKJxULaRzmyM19lQZw+yUDyMM8M3Qrk1LbWqhCkc= +github.com/yandex-cloud/go-sdk/v2 v2.56.0 h1:rihPAZbPbHU/BKTLuT64nU1uhbBrO20HhdlLR3Hyoz0= +github.com/yandex-cloud/go-sdk/v2 v2.56.0/go.mod h1:jzVBQgamNHoiDsmjog2dPZHMXuGZqmxf/epH+Qb7Emc= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= @@ -969,16 +973,16 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= @@ -1031,8 +1035,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1076,8 +1080,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1135,16 +1139,16 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1248,8 +1252,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1264,8 +1268,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1284,8 +1288,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1351,8 +1355,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1381,8 +1385,8 @@ google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0M google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ= -google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4= +google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE= +google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -1421,12 +1425,12 @@ google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= -google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1475,11 +1479,12 @@ gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= +gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= -gopkg.in/ns1/ns1-go.v2 v2.16.0 h1:mUczKFnrCystSV7yIODzVSbENoud3T7DwstmyVZfqg4= -gopkg.in/ns1/ns1-go.v2 v2.16.0/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc= +gopkg.in/ns1/ns1-go.v2 v2.17.2 h1:x8YKHqCJWkC/hddfUhw7FRqTG0x3fr/0ZnWYN+i4THs= +gopkg.in/ns1/ns1-go.v2 v2.17.2/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= From d896c1f0366a5b60112c9a8b87861acacf037417 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Thu, 19 Feb 2026 12:25:10 +0100 Subject: [PATCH 16/21] fix: preserve domain order (#2862) --- acme/api/identifier.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/acme/api/identifier.go b/acme/api/identifier.go index 42a8fd391..245ed8515 100644 --- a/acme/api/identifier.go +++ b/acme/api/identifier.go @@ -2,7 +2,6 @@ package api import ( "cmp" - "maps" "net" "slices" @@ -10,7 +9,9 @@ import ( ) func createIdentifiers(domains []string) []acme.Identifier { - uniqIdentifiers := make(map[string]acme.Identifier) + uniqIdentifiers := make(map[string]struct{}) + + var identifiers []acme.Identifier for _, domain := range domains { if _, ok := uniqIdentifiers[domain]; ok { @@ -23,10 +24,12 @@ func createIdentifiers(domains []string) []acme.Identifier { ident.Type = "ip" } - uniqIdentifiers[domain] = ident + identifiers = append(identifiers, ident) + + uniqIdentifiers[domain] = struct{}{} } - return slices.AppendSeq(make([]acme.Identifier, 0, len(uniqIdentifiers)), maps.Values(uniqIdentifiers)) + return identifiers } // compareIdentifiers compares 2 slices of [acme.Identifier]. From 078a1889c87c750f6051a3dd9dc1e5e24e690ec8 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Thu, 19 Feb 2026 12:26:53 +0100 Subject: [PATCH 17/21] Add DNS provider for ArtFiles (#2859) --- README.md | 90 +++---- cmd/zz_gen_cmd_dnshelp.go | 22 ++ docs/content/dns/zz_gen_artfiles.md | 69 ++++++ docs/data/zz_cli_help.toml | 2 +- providers/dns/artfiles/artfiles.go | 204 ++++++++++++++++ providers/dns/artfiles/artfiles.toml | 24 ++ providers/dns/artfiles/artfiles_test.go | 228 ++++++++++++++++++ providers/dns/artfiles/internal/client.go | 133 ++++++++++ .../dns/artfiles/internal/client_test.go | 89 +++++++ .../artfiles/internal/fixtures/domains.txt | 3 + .../artfiles/internal/fixtures/get_dns.json | 16 ++ .../artfiles/internal/fixtures/set_dns.json | 4 + .../internal/fixtures/txt_record-multiple.txt | 8 + .../artfiles/internal/fixtures/txt_record.txt | 7 + providers/dns/artfiles/internal/types.go | 109 +++++++++ providers/dns/artfiles/internal/types_test.go | 183 ++++++++++++++ providers/dns/zz_gen_dns_providers.go | 3 + 17 files changed, 1148 insertions(+), 46 deletions(-) create mode 100644 docs/content/dns/zz_gen_artfiles.md create mode 100644 providers/dns/artfiles/artfiles.go create mode 100644 providers/dns/artfiles/artfiles.toml create mode 100644 providers/dns/artfiles/artfiles_test.go create mode 100644 providers/dns/artfiles/internal/client.go create mode 100644 providers/dns/artfiles/internal/client_test.go create mode 100644 providers/dns/artfiles/internal/fixtures/domains.txt create mode 100644 providers/dns/artfiles/internal/fixtures/get_dns.json create mode 100644 providers/dns/artfiles/internal/fixtures/set_dns.json create mode 100644 providers/dns/artfiles/internal/fixtures/txt_record-multiple.txt create mode 100644 providers/dns/artfiles/internal/fixtures/txt_record.txt create mode 100644 providers/dns/artfiles/internal/types.go create mode 100644 providers/dns/artfiles/internal/types_test.go diff --git a/README.md b/README.md index 105ea53aa..7e015e70f 100644 --- a/README.md +++ b/README.md @@ -73,228 +73,228 @@ If your DNS provider is not supported, please open an [issue](https://github.com Amazon Route 53 Anexia CloudDNS + ArtFiles ArvanCloud - Aurora DNS + Aurora DNS Autodns Axelname Azion - Azure (deprecated) + Azure (deprecated) Azure DNS Baidu Cloud Beget.com - Binary Lane + Binary Lane Bindman Bluecat Bluecat v2 - BookMyName + BookMyName Brandit (deprecated) Bunny Checkdomain - Civo + Civo Cloud.ru CloudDNS Cloudflare - ClouDNS + ClouDNS CloudXNS (Deprecated) ConoHa v2 ConoHa v3 - Constellix + Constellix Core-Networks CPanel/WHM DDnss (DynDNS Service) - Derak Cloud + Derak Cloud deSEC.io Designate DNSaaS for Openstack Digital Ocean - DirectAdmin + DirectAdmin DNS Made Easy DNSExit dnsHome.de - DNSimple + DNSimple DNSPod (deprecated) Domain Offensive (do.de) Domeneshop - DreamHost + DreamHost Duck DNS Dyn DynDnsFree.de - Dynu + Dynu EasyDNS EdgeCenter Efficient IP - Epik + Epik Exoscale External program F5 XC - freemyip.com + freemyip.com FusionLayer NameSurfer G-Core Gandi - Gandi Live DNS (v5) + Gandi Live DNS (v5) Gigahost.no Glesys Go Daddy - Google Cloud + Google Cloud Google Domains Gravity Hetzner - Hosting.de + Hosting.de Hosting.nl Hostinger Hosttech - HTTP request + HTTP request http.net Huawei Cloud Hurricane Electric DNS - HyperOne + HyperOne IBM Cloud (SoftLayer) IIJ DNS Platform Service Infoblox - Infomaniak + Infomaniak Internet Initiative Japan Internet.bs INWX - Ionos + Ionos Ionos Cloud IPv64 ISPConfig 3 - ISPConfig 3 - Dynamic DNS (DDNS) Module + ISPConfig 3 - Dynamic DNS (DDNS) Module iwantmyname (Deprecated) JD Cloud Joker - Joohoi's ACME-DNS + Joohoi's ACME-DNS KeyHelp Liara Lima-City - Linode (v4) + Linode (v4) Liquid Web Loopia LuaDNS - Mail-in-a-Box + Mail-in-a-Box ManageEngine CloudDNS Manual Metaname - Metaregistrar + Metaregistrar mijn.host Mittwald myaddr.{tools,dev,io} - MyDNS.jp + MyDNS.jp MythicBeasts Name.com Namecheap - Namesilo + Namesilo NearlyFreeSpeech.NET Neodigit Netcup - Netlify + Netlify Nicmanager NIFCloud Njalla - Nodion + Nodion NS1 Octenium Open Telekom Cloud - Oracle Cloud + Oracle Cloud OVH plesk.com Porkbun - PowerDNS + PowerDNS Rackspace Rain Yun/雨云 RcodeZero - reg.ru + reg.ru Regfish RFC2136 RimuHosting - RU CENTER + RU CENTER Sakura Cloud Scaleway Selectel - Selectel v2 + Selectel v2 SelfHost.(de|eu) Servercow Shellrent - Simply.com + Simply.com Sonic Spaceship Stackpath - Syse + Syse Technitium Tencent Cloud DNS Tencent EdgeOne - Timeweb Cloud + Timeweb Cloud TodayNIC/时代互联 TransIP UKFast SafeDNS - Ultradns + Ultradns United-Domains Variomedia VegaDNS - Vercel + Vercel Versio.[nl|eu|uk] VinylDNS Virtualname - VK Cloud + VK Cloud Volcano Engine/火山引擎 Vscale Vultr - webnames.ca + webnames.ca webnames.ru Websupport WEDOS - West.cn/西部数码 + West.cn/西部数码 Yandex 360 Yandex Cloud Yandex PDD - Zone.ee + Zone.ee ZoneEdit Zonomi - diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index cdee65371..9e83a7b25 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -19,6 +19,7 @@ func allDNSCodes() string { "allinkl", "alwaysdata", "anexia", + "artfiles", "arvancloud", "auroradns", "autodns", @@ -358,6 +359,27 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/anexia`) + case "artfiles": + // generated from: providers/dns/artfiles/artfiles.toml + ew.writeln(`Configuration for ArtFiles.`) + ew.writeln(`Code: 'artfiles'`) + ew.writeln(`Since: 'v4.32.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "ARTFILES_PASSWORD": API password`) + ew.writeln(` - "ARTFILES_USERNAME": API username`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "ARTFILES_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "ARTFILES_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "ARTFILES_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 360)`) + ew.writeln(` - "ARTFILES_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/artfiles`) + case "arvancloud": // generated from: providers/dns/arvancloud/arvancloud.toml ew.writeln(`Configuration for ArvanCloud.`) diff --git a/docs/content/dns/zz_gen_artfiles.md b/docs/content/dns/zz_gen_artfiles.md new file mode 100644 index 000000000..15ac2d964 --- /dev/null +++ b/docs/content/dns/zz_gen_artfiles.md @@ -0,0 +1,69 @@ +--- +title: "ArtFiles" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: artfiles +dnsprovider: + since: "v4.32.0" + code: "artfiles" + url: "https://www.artfiles.de/extras/domains/" +--- + + + + + + +Configuration for [ArtFiles](https://www.artfiles.de/extras/domains/). + + + + +- Code: `artfiles` +- Since: v4.32.0 + + +Here is an example bash command using the ArtFiles provider: + +```bash +ARTFILES_USERNAME="xxx" \ +ARTFILES_PASSWORD="yyy" \ +lego --dns artfiles -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `ARTFILES_PASSWORD` | API password | +| `ARTFILES_USERNAME` | API username | + +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 | +|--------------------------------|-------------| +| `ARTFILES_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `ARTFILES_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `ARTFILES_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 360) | +| `ARTFILES_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://support.artfiles.de/DCP-API#dns) + + + + diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml index 759b8e84f..c6d845bf2 100644 --- a/docs/data/zz_cli_help.toml +++ b/docs/data/zz_cli_help.toml @@ -152,7 +152,7 @@ To display the documentation for a specific DNS provider, run: $ lego dnshelp -c code Supported DNS providers: - acme-dns, active24, alidns, aliesa, allinkl, alwaysdata, anexia, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bluecatv2, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, ddnss, derak, desec, designate, digitalocean, directadmin, dnsexit, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, jdcloud, joker, keyhelp, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, namesurfer, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, 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, syse, technitium, tencentcloud, timewebcloud, todaynic, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnames, webnamesca, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi + acme-dns, active24, alidns, aliesa, allinkl, alwaysdata, anexia, artfiles, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bluecatv2, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, ddnss, derak, desec, designate, digitalocean, directadmin, dnsexit, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, jdcloud, joker, keyhelp, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, namesurfer, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, 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, syse, technitium, tencentcloud, timewebcloud, todaynic, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnames, webnamesca, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi More information: https://go-acme.github.io/lego/dns """ diff --git a/providers/dns/artfiles/artfiles.go b/providers/dns/artfiles/artfiles.go new file mode 100644 index 000000000..c918d77f6 --- /dev/null +++ b/providers/dns/artfiles/artfiles.go @@ -0,0 +1,204 @@ +// Package artfiles implements a DNS provider for solving the DNS-01 challenge using ArtFiles. +package artfiles + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "slices" + "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/artfiles/internal" + "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" +) + +// Environment variables names. +const ( + envNamespace = "ARTFILES_" + + EnvUsername = envNamespace + "USERNAME" + EnvPassword = envNamespace + "PASSWORD" + + 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 { + Username string + Password string + + PropagationTimeout time.Duration + PollingInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 6*time.Minute), + 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 ArtFiles. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvUsername, EnvPassword) + if err != nil { + return nil, fmt.Errorf("artfiles: %w", err) + } + + config := NewDefaultConfig() + config.Username = values[EnvUsername] + config.Password = values[EnvPassword] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for ArtFiles. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("artfiles: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.Username, config.Password) + if err != nil { + return nil, fmt.Errorf("artfiles: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.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) + + zone, err := d.findZone(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("artfiles: %w", err) + } + + records, err := d.client.GetRecords(ctx, zone) + if err != nil { + return fmt.Errorf("artfiles: get records: %w", err) + } + + rv := internal.RecordValue{} + + if len(records["TXT"]) > 0 { + var raw string + + err = json.Unmarshal(records["TXT"], &raw) + if err != nil { + return fmt.Errorf("artfiles: unmarshal TXT records: %w", err) + } + + rv = internal.ParseRecordValue(raw) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) + if err != nil { + return fmt.Errorf("artfiles: %w", err) + } + + rv.Add(subDomain, info.Value) + + err = d.client.SetRecords(ctx, zone, "TXT", rv) + if err != nil { + return fmt.Errorf("artfiles: set TXT records: %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) + + zone, err := d.findZone(ctx, info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("artfiles: %w", err) + } + + records, err := d.client.GetRecords(ctx, zone) + if err != nil { + return fmt.Errorf("artfiles: get records: %w", err) + } + + var raw string + + err = json.Unmarshal(records["TXT"], &raw) + if err != nil { + return fmt.Errorf("artfiles: unmarshal TXT records: %w", err) + } + + rv := internal.ParseRecordValue(raw) + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone) + if err != nil { + return fmt.Errorf("artfiles: %w", err) + } + + rv.RemoveValue(subDomain, info.Value) + + err = d.client.SetRecords(ctx, zone, "TXT", rv) + if err != nil { + return fmt.Errorf("artfiles: set TXT records: %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) findZone(ctx context.Context, fqdn string) (string, error) { + domains, err := d.client.GetDomains(ctx) + if err != nil { + return "", fmt.Errorf("artfiles: get domains: %w", err) + } + + var zone string + + for s := range dns01.UnFqdnDomainsSeq(fqdn) { + if slices.Contains(domains, s) { + zone = s + } + } + + if zone == "" { + return "", fmt.Errorf("artfiles: could not find the zone for domain %q", fqdn) + } + + return zone, nil +} diff --git a/providers/dns/artfiles/artfiles.toml b/providers/dns/artfiles/artfiles.toml new file mode 100644 index 000000000..00ff12342 --- /dev/null +++ b/providers/dns/artfiles/artfiles.toml @@ -0,0 +1,24 @@ +Name = "ArtFiles" +Description = '''''' +URL = "https://www.artfiles.de/extras/domains/" +Code = "artfiles" +Since = "v4.32.0" + +Example = ''' +ARTFILES_USERNAME="xxx" \ +ARTFILES_PASSWORD="yyy" \ +lego --dns artfiles -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + ARTFILES_USERNAME = "API username" + ARTFILES_PASSWORD = "API password" + [Configuration.Additional] + ARTFILES_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + ARTFILES_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 360)" + ARTFILES_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + ARTFILES_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://support.artfiles.de/DCP-API#dns" diff --git a/providers/dns/artfiles/artfiles_test.go b/providers/dns/artfiles/artfiles_test.go new file mode 100644 index 000000000..42490f10d --- /dev/null +++ b/providers/dns/artfiles/artfiles_test.go @@ -0,0 +1,228 @@ +package artfiles + +import ( + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvUsername, EnvPassword).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvUsername: "user", + EnvPassword: "secret", + }, + }, + { + desc: "missing username", + envVars: map[string]string{ + EnvUsername: "", + EnvPassword: "secret", + }, + expected: "artfiles: some credentials information are missing: ARTFILES_USERNAME", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvUsername: "user", + EnvPassword: "", + }, + expected: "artfiles: some credentials information are missing: ARTFILES_PASSWORD", + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "artfiles: some credentials information are missing: ARTFILES_USERNAME,ARTFILES_PASSWORD", + }, + } + + 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 + username string + password string + expected string + }{ + { + desc: "success", + username: "user", + password: "secret", + }, + { + desc: "missing username", + password: "secret", + expected: "artfiles: credentials missing", + }, + { + desc: "missing Example", + username: "user", + expected: "artfiles: credentials missing", + }, + { + desc: "missing credentials", + expected: "artfiles: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Username = test.username + config.Password = test.password + + 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) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.Username = "user" + config.Password = "secret" + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + + return p, nil + }, + servermock.CheckHeader(). + WithBasicAuth("user", "secret"), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("GET /domain/get_domains.html", + servermock.ResponseFromInternal("domains.txt"), + ). + Route("GET /dns/get_dns.html", + servermock.ResponseFromInternal("get_dns.json"), + servermock.CheckQueryParameter().Strict(). + With("domain", "example.com"), + ). + Route("POST /dns/set_dns.html", + servermock.ResponseFromInternal("set_dns.json"), + servermock.CheckQueryParameter().Strict(). + With("TXT", `@ "v=spf1 a mx ~all" +_acme-challenge "TheAcmeChallenge" +_acme-challenge "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" +_dmarc "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf" +_mta-sts "v=STSv1;id=yyyymmddTHHMMSS;" +_smtp._tls "v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com" +selector._domainkey "v=DKIM1;k=rsa;p=Base64Stuff" "MoreBase64Stuff" "Even++MoreBase64Stuff" "YesMoreBase64Stuff" "And+Yes+Even+MoreBase64Stuff" "Sure++MoreBase64Stuff" "LastBase64Stuff" +selectorecc._domainkey "v=DKIM1;k=ed25519;p=Base64Stuff"`). + With("domain", "example.com"), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("GET /domain/get_domains.html", + servermock.ResponseFromInternal("domains.txt"), + ). + Route("GET /dns/get_dns.html", + servermock.ResponseFromInternal("get_dns.json"), + servermock.CheckQueryParameter().Strict(). + With("domain", "example.com"), + ). + Route("POST /dns/set_dns.html", + servermock.ResponseFromInternal("set_dns.json"), + servermock.CheckQueryParameter().Strict(). + With("TXT", `@ "v=spf1 a mx ~all" +_acme-challenge "TheAcmeChallenge" +_dmarc "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf" +_mta-sts "v=STSv1;id=yyyymmddTHHMMSS;" +_smtp._tls "v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com" +selector._domainkey "v=DKIM1;k=rsa;p=Base64Stuff" "MoreBase64Stuff" "Even++MoreBase64Stuff" "YesMoreBase64Stuff" "And+Yes+Even+MoreBase64Stuff" "Sure++MoreBase64Stuff" "LastBase64Stuff" +selectorecc._domainkey "v=DKIM1;k=ed25519;p=Base64Stuff"`). + With("domain", "example.com"), + ). + Build(t) + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/artfiles/internal/client.go b/providers/dns/artfiles/internal/client.go new file mode 100644 index 000000000..61b350511 --- /dev/null +++ b/providers/dns/artfiles/internal/client.go @@ -0,0 +1,133 @@ +package internal + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" +) + +const defaultBaseURL = "https://dcp.c.artfiles.de/api/" + +// Client the ArtFiles API client. +type Client struct { + username string + password string + + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(username, password string) (*Client, error) { + if username == "" || password == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + username: username, + password: password, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (c *Client) GetDomains(ctx context.Context) ([]string, error) { + endpoint := c.BaseURL.JoinPath("domain", "get_domains.html") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + raw, err := c.do(req) + if err != nil { + return nil, err + } + + return parseDomains(string(raw)) +} + +func (c *Client) GetRecords(ctx context.Context, domain string) (map[string]json.RawMessage, error) { + endpoint := c.BaseURL.JoinPath("dns", "get_dns.html") + + query := endpoint.Query() + query.Set("domain", domain) + + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + raw, err := c.do(req) + if err != nil { + return nil, err + } + + var result Records + + err = json.Unmarshal(raw, &result) + if err != nil { + return nil, errutils.NewUnmarshalError(req, http.StatusOK, raw, err) + } + + return result.Data, nil +} + +func (c *Client) SetRecords(ctx context.Context, domain, rType string, value RecordValue) error { + endpoint := c.BaseURL.JoinPath("dns", "set_dns.html") + + query := endpoint.Query() + query.Set("domain", domain) + query.Set(rType, value.String()) + + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), nil) + if err != nil { + return fmt.Errorf("unable to create request: %w", err) + } + + _, err = c.do(req) + + return err +} + +func (c *Client) do(req *http.Request) ([]byte, error) { + useragent.SetHeader(req.Header) + + req.SetBasicAuth(c.username, c.password) + + if req.Method == http.MethodPost { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + if resp.StatusCode/100 != 2 { + return nil, errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return raw, nil +} diff --git a/providers/dns/artfiles/internal/client_test.go b/providers/dns/artfiles/internal/client_test.go new file mode 100644 index 000000000..cc76f06f5 --- /dev/null +++ b/providers/dns/artfiles/internal/client_test.go @@ -0,0 +1,89 @@ +package internal + +import ( + "encoding/json" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("user", "secret") + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithBasicAuth("user", "secret"), + ) +} + +func TestClient_GetDomains(t *testing.T) { + client := mockBuilder(). + Route("GET /domain/get_domains.html", + servermock.ResponseFromFixture("domains.txt"), + ). + Build(t) + + zones, err := client.GetDomains(t.Context()) + require.NoError(t, err) + + expected := []string{"example.com", "example.org", "example.net"} + + assert.Equal(t, expected, zones) +} + +func TestClient_GetRecords(t *testing.T) { + client := mockBuilder(). + Route("GET /dns/get_dns.html", + servermock.ResponseFromFixture("get_dns.json"), + servermock.CheckQueryParameter().Strict(). + With("domain", "example.com"), + ). + Build(t) + + records, err := client.GetRecords(t.Context(), "example.com") + require.NoError(t, err) + + expected := map[string]json.RawMessage{ + "A": json.RawMessage(strconv.Quote("sub1 1.2.3.4\nsub2 1.2.3.4\nsub3 1.2.3.4\nsub4 1.2.3.4\nsub5 1.2.3.4\nsub6 1.2.3.4\nsub7 1.2.3.4\nsub8 1.2.3.4\nsub9 1.2.3.4\nsub10 1.2.3.4\nsub11 1.2.3.4\nsub12 1.2.3.4\nsub13 1.2.3.4\nsub14 1.2.3.4\nsub15 1.2.3.4\nsub16 1.2.3.4\nsub17 1.2.3.4\nsub18 1.2.3.4\n@ 1.2.3.4")), + "AAAA": json.RawMessage(strconv.Quote("")), + "CAA": json.RawMessage(strconv.Quote("@ 128 iodef \"mailto:someone@example.tld\"\n@ 128 issue \"letsencrypt.org\"\n@ 128 issuewild \"letsencrypt.org\"")), + "CName": json.RawMessage(strconv.Quote("some cname.to.example.tld.")), + "MX": json.RawMessage(strconv.Quote("10 mail.example.tld.")), + "SRV": json.RawMessage(strconv.Quote("_imap._tcp 0 0 0 .\n_imaps._tcp 0 1 993 mail.example.tld.\n_pop3._tcp 0 0 0 .\n_pop3s._tcp 0 0 0 .")), + "TLSA": json.RawMessage(strconv.Quote("_25._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_25._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_25._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2\n_465._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_465._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_465._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2\n_587._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_587._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_587._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2")), + "TXT": json.RawMessage(strconv.Quote("_dmarc \"v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf\"\n_mta-sts \"v=STSv1;id=yyyymmddTHHMMSS;\"\n_smtp._tls \"v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com\"\n@ \"v=spf1 a mx ~all\"\nselector._domainkey \"v=DKIM1;k=rsa;p=Base64Stuff\" \"MoreBase64Stuff\" \"Even++MoreBase64Stuff\" \"YesMoreBase64Stuff\" \"And+Yes+Even+MoreBase64Stuff\" \"Sure++MoreBase64Stuff\" \"LastBase64Stuff\"\nselectorecc._domainkey \"v=DKIM1;k=ed25519;p=Base64Stuff\"\n_acme-challenge \"TheAcmeChallenge\"")), + "TTL": json.RawMessage("3600"), + "comment": json.RawMessage(strconv.Quote("TLSA RR:\nInfo -> https://dnssec-stats.ant.isi.edu/~viktor/x3hosts.html\nTest 1 -> https://stats.dnssec-tools.org/explore/?example.tld\nTest 2 -> https://dane.sys4.de/smtp/example.tld\n\nSMIMEA RR:\nGenerator -> https://www.smimea.info/smimea-generator.php\nTest -> https://www.smimea.info/smimea-test.php")), + "nameserver": json.RawMessage(strconv.Quote("auth1.artfiles.de.\nauth2.artfiles.de.")), + } + + assert.Equal(t, expected, records) +} + +func TestClient_SetRecords(t *testing.T) { + client := mockBuilder(). + Route("POST /dns/set_dns.html", + servermock.ResponseFromFixture("set_dns.json"), + servermock.CheckQueryParameter().Strict(). + With("TXT", "a b\nc \"d\""). + With("domain", "example.com"), + ). + Build(t) + + err := client.SetRecords(t.Context(), "example.com", "TXT", RecordValue{"c": []string{`"d"`}, "a": []string{"b"}}) + require.NoError(t, err) +} diff --git a/providers/dns/artfiles/internal/fixtures/domains.txt b/providers/dns/artfiles/internal/fixtures/domains.txt new file mode 100644 index 000000000..b8a1247d2 --- /dev/null +++ b/providers/dns/artfiles/internal/fixtures/domains.txt @@ -0,0 +1,3 @@ +example.com normal 2026-10-01 2017-09-18 163477 +example.org normal 2026-08-01 2016-07-07 156216 +example.net normal 2026-07-01 2017-06-06 162462 diff --git a/providers/dns/artfiles/internal/fixtures/get_dns.json b/providers/dns/artfiles/internal/fixtures/get_dns.json new file mode 100644 index 000000000..fa672e0e1 --- /dev/null +++ b/providers/dns/artfiles/internal/fixtures/get_dns.json @@ -0,0 +1,16 @@ +{ + "data": { + "SRV": "_imap._tcp 0 0 0 .\n_imaps._tcp 0 1 993 mail.example.tld.\n_pop3._tcp 0 0 0 .\n_pop3s._tcp 0 0 0 .", + "AAAA": "", + "MX": "10 mail.example.tld.", + "CAA": "@ 128 iodef \"mailto:someone@example.tld\"\n@ 128 issue \"letsencrypt.org\"\n@ 128 issuewild \"letsencrypt.org\"", + "TTL": 3600, + "comment": "TLSA RR:\nInfo -> https://dnssec-stats.ant.isi.edu/~viktor/x3hosts.html\nTest 1 -> https://stats.dnssec-tools.org/explore/?example.tld\nTest 2 -> https://dane.sys4.de/smtp/example.tld\n\nSMIMEA RR:\nGenerator -> https://www.smimea.info/smimea-generator.php\nTest -> https://www.smimea.info/smimea-test.php", + "TXT": "_dmarc \"v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf\"\n_mta-sts \"v=STSv1;id=yyyymmddTHHMMSS;\"\n_smtp._tls \"v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com\"\n@ \"v=spf1 a mx ~all\"\nselector._domainkey \"v=DKIM1;k=rsa;p=Base64Stuff\" \"MoreBase64Stuff\" \"Even++MoreBase64Stuff\" \"YesMoreBase64Stuff\" \"And+Yes+Even+MoreBase64Stuff\" \"Sure++MoreBase64Stuff\" \"LastBase64Stuff\"\nselectorecc._domainkey \"v=DKIM1;k=ed25519;p=Base64Stuff\"\n_acme-challenge \"TheAcmeChallenge\"", + "A": "sub1 1.2.3.4\nsub2 1.2.3.4\nsub3 1.2.3.4\nsub4 1.2.3.4\nsub5 1.2.3.4\nsub6 1.2.3.4\nsub7 1.2.3.4\nsub8 1.2.3.4\nsub9 1.2.3.4\nsub10 1.2.3.4\nsub11 1.2.3.4\nsub12 1.2.3.4\nsub13 1.2.3.4\nsub14 1.2.3.4\nsub15 1.2.3.4\nsub16 1.2.3.4\nsub17 1.2.3.4\nsub18 1.2.3.4\n@ 1.2.3.4", + "nameserver": "auth1.artfiles.de.\nauth2.artfiles.de.", + "CName": "some cname.to.example.tld.", + "TLSA": "_25._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_25._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_25._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2\n_465._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_465._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_465._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2\n_587._tcp.mail.example.tld. 2 1 1 CBBC559B44D524D6A132BDAC672744DA3407F12AAE5D5F722C5F6C7913871C75\n_587._tcp.mail.example.tld. 2 1 1 885BF0572252C6741DC9A52F5044487FEF2A93B811CDEDFAD7624CC283B7CDD5\n_587._tcp.mail.example.tld. 2 1 1 F1440A9B76E1E41E53A4CB461329BF6337B419726BE513E42E19F1C691C5D4B2" + }, + "status": "OK" +} diff --git a/providers/dns/artfiles/internal/fixtures/set_dns.json b/providers/dns/artfiles/internal/fixtures/set_dns.json new file mode 100644 index 000000000..7cacb33e5 --- /dev/null +++ b/providers/dns/artfiles/internal/fixtures/set_dns.json @@ -0,0 +1,4 @@ +{ + "status": "OK", + "error": "" +} diff --git a/providers/dns/artfiles/internal/fixtures/txt_record-multiple.txt b/providers/dns/artfiles/internal/fixtures/txt_record-multiple.txt new file mode 100644 index 000000000..461489c77 --- /dev/null +++ b/providers/dns/artfiles/internal/fixtures/txt_record-multiple.txt @@ -0,0 +1,8 @@ +_dmarc "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf" +_mta-sts "v=STSv1;id=yyyymmddTHHMMSS;" +_smtp._tls "v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com" +@ "v=spf1 a mx ~all" +selector._domainkey "v=DKIM1;k=rsa;p=Base64Stuff" "MoreBase64Stuff" "Even++MoreBase64Stuff" "YesMoreBase64Stuff" "And+Yes+Even+MoreBase64Stuff" "Sure++MoreBase64Stuff" "LastBase64Stuff" +selectorecc._domainkey "v=DKIM1;k=ed25519;p=Base64Stuff" +_acme-challenge "xxx" +_acme-challenge "yyy" diff --git a/providers/dns/artfiles/internal/fixtures/txt_record.txt b/providers/dns/artfiles/internal/fixtures/txt_record.txt new file mode 100644 index 000000000..5a6259b14 --- /dev/null +++ b/providers/dns/artfiles/internal/fixtures/txt_record.txt @@ -0,0 +1,7 @@ +_dmarc "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf" +_mta-sts "v=STSv1;id=yyyymmddTHHMMSS;" +_smtp._tls "v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com" +@ "v=spf1 a mx ~all" +selector._domainkey "v=DKIM1;k=rsa;p=Base64Stuff" "MoreBase64Stuff" "Even++MoreBase64Stuff" "YesMoreBase64Stuff" "And+Yes+Even+MoreBase64Stuff" "Sure++MoreBase64Stuff" "LastBase64Stuff" +selectorecc._domainkey "v=DKIM1;k=ed25519;p=Base64Stuff" +_acme-challenge "TheAcmeChallenge" diff --git a/providers/dns/artfiles/internal/types.go b/providers/dns/artfiles/internal/types.go new file mode 100644 index 000000000..c70ab34da --- /dev/null +++ b/providers/dns/artfiles/internal/types.go @@ -0,0 +1,109 @@ +package internal + +import ( + "encoding/csv" + "encoding/json" + "errors" + "io" + "maps" + "slices" + "strconv" + "strings" + "unicode" +) + +type Records struct { + Data map[string]json.RawMessage `json:"data"` + Status string `json:"status"` +} + +type RecordValue map[string][]string + +func (r RecordValue) Set(key, value string) { + r[key] = []string{strconv.Quote(value)} +} + +func (r RecordValue) Add(key, value string) { + r[key] = append(r[key], strconv.Quote(value)) +} + +func (r RecordValue) Delete(key string) { + delete(r, key) +} + +func (r RecordValue) RemoveValue(key, value string) { + if len(r[key]) == 0 { + return + } + + quotedValue := strconv.Quote(value) + + var data []string + + for _, s := range r[key] { + if s != quotedValue { + data = append(data, s) + } + } + + r[key] = data + + if len(r[key]) == 0 { + r.Delete(key) + } +} + +func (r RecordValue) String() string { + var parts []string + + for _, key := range slices.Sorted(maps.Keys(r)) { + for _, s := range r[key] { + parts = append(parts, key+" "+s) + } + } + + return strings.Join(parts, "\n") +} + +func ParseRecordValue(lines string) RecordValue { + data := make(RecordValue) + + for line := range strings.Lines(lines) { + line = strings.TrimSpace(line) + + idx := strings.IndexFunc(line, unicode.IsSpace) + + data[line[:idx]] = append(data[line[:idx]], line[idx+1:]) + } + + return data +} + +func parseDomains(input string) ([]string, error) { + reader := csv.NewReader(strings.NewReader(input)) + reader.Comma = '\t' + reader.TrimLeadingSpace = true + reader.LazyQuotes = true + + var data []string + + for { + record, err := reader.Read() + if errors.Is(err, io.EOF) { + break + } + + if err != nil { + return nil, err + } + + if len(record) < 1 { + // Malformed line + continue + } + + data = append(data, record[0]) + } + + return data, nil +} diff --git a/providers/dns/artfiles/internal/types_test.go b/providers/dns/artfiles/internal/types_test.go new file mode 100644 index 000000000..3b219f39f --- /dev/null +++ b/providers/dns/artfiles/internal/types_test.go @@ -0,0 +1,183 @@ +package internal + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRecordValue_Set(t *testing.T) { + rv := make(RecordValue) + + rv.Set("a", "1") + rv.Set("b", "2") + rv.Set("b", "3") + + assert.Equal(t, "a \"1\"\nb \"3\"", rv.String()) +} + +func TestRecordValue_Add(t *testing.T) { + rv := make(RecordValue) + + rv.Add("a", "1") + rv.Add("b", "2") + rv.Add("b", "3") + + assert.Equal(t, "a \"1\"\nb \"2\"\nb \"3\"", rv.String()) +} + +func TestRecordValue_Delete(t *testing.T) { + rv := make(RecordValue) + + rv.Set("a", "1") + rv.Add("b", "2") + + rv.Delete("b") + + assert.Equal(t, "a \"1\"", rv.String()) +} + +func TestRecordValue_RemoveValue(t *testing.T) { + testCases := []struct { + desc string + data map[string][]string + toRemove map[string][]string + expected string + }{ + { + desc: "remove the only value", + data: map[string][]string{ + "a": {"1"}, + }, + toRemove: map[string][]string{ + "a": {"1"}, + }, + expected: ``, + }, + { + desc: "remove value in the middle", + data: map[string][]string{ + "a": {"1", "2", "3"}, + }, + toRemove: map[string][]string{ + "a": {"2"}, + }, + expected: "a \"1\"\na \"3\"", + }, + { + desc: "remove value at the beginning", + data: map[string][]string{ + "a": {"1", "2", "3"}, + }, + toRemove: map[string][]string{ + "a": {"1"}, + }, + expected: "a \"2\"\na \"3\"", + }, + { + desc: "remove value at the end", + data: map[string][]string{ + "a": {"1", "2", "3"}, + }, + toRemove: map[string][]string{ + "a": {"3"}, + }, + expected: "a \"1\"\na \"2\"", + }, + { + desc: "remove all (delete)", + data: map[string][]string{ + "a": {"1", "2", "3"}, + }, + toRemove: map[string][]string{ + "a": {"1", "2", "3"}, + }, + expected: ``, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + rv := make(RecordValue) + + for k, values := range test.data { + for _, v := range values { + rv.Add(k, v) + } + } + + for k, values := range test.toRemove { + for _, v := range values { + rv.RemoveValue(k, v) + } + } + + assert.Equal(t, test.expected, rv.String()) + }) + } +} + +func TestParseRecordValue(t *testing.T) { + testCases := []struct { + desc string + filename string + expected RecordValue + }{ + { + desc: "simple", + filename: "txt_record.txt", + expected: RecordValue{ + "@": []string{"\"v=spf1 a mx ~all\""}, + "_acme-challenge": []string{"\"TheAcmeChallenge\""}, + "_dmarc": []string{"\"v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf\""}, + "_mta-sts": []string{"\"v=STSv1;id=yyyymmddTHHMMSS;\""}, + "_smtp._tls": []string{"\"v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com\""}, + "selector._domainkey": []string{"\"v=DKIM1;k=rsa;p=Base64Stuff\" \"MoreBase64Stuff\" \"Even++MoreBase64Stuff\" \"YesMoreBase64Stuff\" \"And+Yes+Even+MoreBase64Stuff\" \"Sure++MoreBase64Stuff\" \"LastBase64Stuff\""}, + "selectorecc._domainkey": []string{"\"v=DKIM1;k=ed25519;p=Base64Stuff\""}, + }, + }, + { + desc: "multiple values with the same key", + filename: "txt_record-multiple.txt", + expected: RecordValue{ + "@": []string{"\"v=spf1 a mx ~all\""}, + "_acme-challenge": []string{"\"xxx\"", "\"yyy\""}, + "_dmarc": []string{"\"v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;pct=100;rua=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;ri=86400;ruf=mailto:someone@in.mailhardener.com,mailto:postmaster@example.tld;fo=1;rf=afrf\""}, + "_mta-sts": []string{"\"v=STSv1;id=yyyymmddTHHMMSS;\""}, + "_smtp._tls": []string{"\"v=TLSRPTv1;rua=mailto:someone@in.mailhardener.com\""}, + "selector._domainkey": []string{"\"v=DKIM1;k=rsa;p=Base64Stuff\" \"MoreBase64Stuff\" \"Even++MoreBase64Stuff\" \"YesMoreBase64Stuff\" \"And+Yes+Even+MoreBase64Stuff\" \"Sure++MoreBase64Stuff\" \"LastBase64Stuff\""}, + "selectorecc._domainkey": []string{"\"v=DKIM1;k=ed25519;p=Base64Stuff\""}, + }, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + file, err := os.ReadFile(filepath.Join("fixtures", test.filename)) + require.NoError(t, err) + + data := ParseRecordValue(string(file)) + + assert.Equal(t, test.expected, data) + }) + } +} + +func Test_parseDomains(t *testing.T) { + file, err := os.ReadFile(filepath.FromSlash("./fixtures/domains.txt")) + require.NoError(t, err) + + domains, err := parseDomains(string(file)) + require.NoError(t, err) + + expected := []string{"example.com", "example.org", "example.net"} + + assert.Equal(t, expected, domains) +} diff --git a/providers/dns/zz_gen_dns_providers.go b/providers/dns/zz_gen_dns_providers.go index 10fda2df1..24474cf2f 100644 --- a/providers/dns/zz_gen_dns_providers.go +++ b/providers/dns/zz_gen_dns_providers.go @@ -13,6 +13,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/allinkl" "github.com/go-acme/lego/v4/providers/dns/alwaysdata" "github.com/go-acme/lego/v4/providers/dns/anexia" + "github.com/go-acme/lego/v4/providers/dns/artfiles" "github.com/go-acme/lego/v4/providers/dns/arvancloud" "github.com/go-acme/lego/v4/providers/dns/auroradns" "github.com/go-acme/lego/v4/providers/dns/autodns" @@ -211,6 +212,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return alwaysdata.NewDNSProvider() case "anexia": return anexia.NewDNSProvider() + case "artfiles": + return artfiles.NewDNSProvider() case "arvancloud": return arvancloud.NewDNSProvider() case "auroradns": From dd1ea80c08bb2a3551590f64ca40fc1fb2a7eb21 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Thu, 19 Feb 2026 12:52:04 +0100 Subject: [PATCH 18/21] Add DNS provider for Leaseweb (#2856) --- README.md | 44 ++-- cmd/zz_gen_cmd_dnshelp.go | 21 ++ docs/content/dns/zz_gen_leaseweb.md | 67 ++++++ docs/data/zz_cli_help.toml | 2 +- providers/dns/leaseweb/internal/client.go | 216 ++++++++++++++++++ .../dns/leaseweb/internal/client_test.go | 149 ++++++++++++ .../createResourceRecordSet-request.json | 8 + .../fixtures/createResourceRecordSet.json | 17 ++ .../leaseweb/internal/fixtures/error_400.json | 6 + .../leaseweb/internal/fixtures/error_401.json | 5 + .../leaseweb/internal/fixtures/error_404.json | 5 + .../fixtures/getResourceRecordSet.json | 18 ++ .../fixtures/getResourceRecordSet2.json | 17 ++ .../updateResourceRecordSet-request.json | 8 + .../updateResourceRecordSet-request2.json | 6 + .../fixtures/updateResourceRecordSet.json | 19 ++ providers/dns/leaseweb/internal/types.go | 35 +++ providers/dns/leaseweb/leaseweb.go | 187 +++++++++++++++ providers/dns/leaseweb/leaseweb.toml | 22 ++ providers/dns/leaseweb/leaseweb_test.go | 204 +++++++++++++++++ providers/dns/zz_gen_dns_providers.go | 3 + 21 files changed, 1036 insertions(+), 23 deletions(-) create mode 100644 docs/content/dns/zz_gen_leaseweb.md create mode 100644 providers/dns/leaseweb/internal/client.go create mode 100644 providers/dns/leaseweb/internal/client_test.go create mode 100644 providers/dns/leaseweb/internal/fixtures/createResourceRecordSet-request.json create mode 100644 providers/dns/leaseweb/internal/fixtures/createResourceRecordSet.json create mode 100644 providers/dns/leaseweb/internal/fixtures/error_400.json create mode 100644 providers/dns/leaseweb/internal/fixtures/error_401.json create mode 100644 providers/dns/leaseweb/internal/fixtures/error_404.json create mode 100644 providers/dns/leaseweb/internal/fixtures/getResourceRecordSet.json create mode 100644 providers/dns/leaseweb/internal/fixtures/getResourceRecordSet2.json create mode 100644 providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request.json create mode 100644 providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request2.json create mode 100644 providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet.json create mode 100644 providers/dns/leaseweb/internal/types.go create mode 100644 providers/dns/leaseweb/leaseweb.go create mode 100644 providers/dns/leaseweb/leaseweb.toml create mode 100644 providers/dns/leaseweb/leaseweb_test.go diff --git a/README.md b/README.md index 7e015e70f..9925979bd 100644 --- a/README.md +++ b/README.md @@ -188,113 +188,113 @@ If your DNS provider is not supported, please open an [issue](https://github.com Joohoi's ACME-DNS KeyHelp + Leaseweb Liara - Lima-City + Lima-City Linode (v4) Liquid Web Loopia - LuaDNS + LuaDNS Mail-in-a-Box ManageEngine CloudDNS Manual - Metaname + Metaname Metaregistrar mijn.host Mittwald - myaddr.{tools,dev,io} + myaddr.{tools,dev,io} MyDNS.jp MythicBeasts Name.com - Namecheap + Namecheap Namesilo NearlyFreeSpeech.NET Neodigit - Netcup + Netcup Netlify Nicmanager NIFCloud - Njalla + Njalla Nodion NS1 Octenium - Open Telekom Cloud + Open Telekom Cloud Oracle Cloud OVH plesk.com - Porkbun + Porkbun PowerDNS Rackspace Rain Yun/雨云 - RcodeZero + RcodeZero reg.ru Regfish RFC2136 - RimuHosting + RimuHosting RU CENTER Sakura Cloud Scaleway - Selectel + Selectel Selectel v2 SelfHost.(de|eu) Servercow - Shellrent + Shellrent Simply.com Sonic Spaceship - Stackpath + Stackpath Syse Technitium Tencent Cloud DNS - Tencent EdgeOne + Tencent EdgeOne Timeweb Cloud TodayNIC/时代互联 TransIP - UKFast SafeDNS + UKFast SafeDNS Ultradns United-Domains Variomedia - VegaDNS + VegaDNS Vercel Versio.[nl|eu|uk] VinylDNS - Virtualname + Virtualname VK Cloud Volcano Engine/火山引擎 Vscale - Vultr + Vultr webnames.ca webnames.ru Websupport - WEDOS + WEDOS West.cn/西部数码 Yandex 360 Yandex Cloud - Yandex PDD + Yandex PDD Zone.ee ZoneEdit Zonomi - diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index 9e83a7b25..161729c79 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -112,6 +112,7 @@ func allDNSCodes() string { "jdcloud", "joker", "keyhelp", + "leaseweb", "liara", "lightsail", "limacity", @@ -2358,6 +2359,26 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/keyhelp`) + case "leaseweb": + // generated from: providers/dns/leaseweb/leaseweb.toml + ew.writeln(`Configuration for Leaseweb.`) + ew.writeln(`Code: 'leaseweb'`) + ew.writeln(`Since: 'v4.32.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "LEASEWEB_API_KEY": API key`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "LEASEWEB_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) + ew.writeln(` - "LEASEWEB_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) + ew.writeln(` - "LEASEWEB_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "LEASEWEB_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/leaseweb`) + case "liara": // generated from: providers/dns/liara/liara.toml ew.writeln(`Configuration for Liara.`) diff --git a/docs/content/dns/zz_gen_leaseweb.md b/docs/content/dns/zz_gen_leaseweb.md new file mode 100644 index 000000000..13ded490a --- /dev/null +++ b/docs/content/dns/zz_gen_leaseweb.md @@ -0,0 +1,67 @@ +--- +title: "Leaseweb" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: leaseweb +dnsprovider: + since: "v4.32.0" + code: "leaseweb" + url: "https://www.leaseweb.com/en/" +--- + + + + + + +Configuration for [Leaseweb](https://www.leaseweb.com/en/). + + + + +- Code: `leaseweb` +- Since: v4.32.0 + + +Here is an example bash command using the Leaseweb provider: + +```bash +LEASEWEB_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns leaseweb -d '*.example.com' -d example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `LEASEWEB_API_KEY` | API key | + +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 | +|--------------------------------|-------------| +| `LEASEWEB_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | +| `LEASEWEB_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | +| `LEASEWEB_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `LEASEWEB_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://developer.leaseweb.com/docs/#tag/DNS) + + + + diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml index c6d845bf2..925ef0b21 100644 --- a/docs/data/zz_cli_help.toml +++ b/docs/data/zz_cli_help.toml @@ -152,7 +152,7 @@ To display the documentation for a specific DNS provider, run: $ lego dnshelp -c code Supported DNS providers: - acme-dns, active24, alidns, aliesa, allinkl, alwaysdata, anexia, artfiles, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bluecatv2, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, ddnss, derak, desec, designate, digitalocean, directadmin, dnsexit, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, jdcloud, joker, keyhelp, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, namesurfer, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, 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, syse, technitium, tencentcloud, timewebcloud, todaynic, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnames, webnamesca, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi + acme-dns, active24, alidns, aliesa, allinkl, alwaysdata, anexia, artfiles, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bluecatv2, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, com35, conoha, conohav3, constellix, corenetworks, cpanel, ddnss, derak, desec, designate, digitalocean, directadmin, dnsexit, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgecenter, edgedns, edgeone, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, gigahostno, glesys, godaddy, googledomains, gravity, hetzner, hostingde, hostinger, hostingnl, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ionoscloud, ipv64, ispconfig, ispconfigddns, iwantmyname, jdcloud, joker, keyhelp, leaseweb, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, namesurfer, nearlyfreespeech, neodigit, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, octenium, 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, syse, technitium, tencentcloud, timewebcloud, todaynic, transip, ultradns, uniteddomains, variomedia, vegadns, vercel, versio, vinyldns, virtualname, vkcloud, volcengine, vscale, vultr, webnames, webnamesca, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi More information: https://go-acme.github.io/lego/dns """ diff --git a/providers/dns/leaseweb/internal/client.go b/providers/dns/leaseweb/internal/client.go new file mode 100644 index 000000000..01619d49b --- /dev/null +++ b/providers/dns/leaseweb/internal/client.go @@ -0,0 +1,216 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" +) + +const defaultBaseURL = "https://api.leaseweb.com/hosting/v2" + +const AuthHeader = "X-LSW-Auth" + +// Client the Leaseweb API client. +type Client struct { + apiKey string + + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(apiKey string) (*Client, error) { + if apiKey == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + apiKey: apiKey, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// CreateRRSet creates a resource record set. +// https://developer.leaseweb.com/docs/#tag/DNS/operation/createResourceRecordSet +func (c *Client) CreateRRSet(ctx context.Context, domainName string, rrset RRSet) (*RRSet, error) { + endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, rrset) + if err != nil { + return nil, err + } + + result := &RRSet{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +// GetRRSet gets a resource record set. +// https://developer.leaseweb.com/docs/#tag/DNS/operation/getResourceRecordSet +func (c *Client) GetRRSet(ctx context.Context, domainName, name, rType string) (*RRSet, error) { + endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets", name, rType) + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + result := &RRSet{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +// UpdateRRSet updates a resource record set. +// https://developer.leaseweb.com/docs/#tag/DNS/operation/updateResourceRecordSet +func (c *Client) UpdateRRSet(ctx context.Context, domainName string, rrset RRSet) (*RRSet, error) { + endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets", rrset.Name, rrset.Type) + + // Reset values that are not allowed to be updated. + rrset.Name = "" + rrset.Type = "" + rrset.Editable = false + + req, err := newJSONRequest(ctx, http.MethodPut, endpoint, rrset) + if err != nil { + return nil, err + } + + result := &RRSet{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +// DeleteRRSet deletes a resource record set. +// https://developer.leaseweb.com/docs/#tag/DNS/operation/deleteResourceRecordSet +func (c *Client) DeleteRRSet(ctx context.Context, domainName, name, rType string) error { + endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets", name, rType) + + 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 { + useragent.SetHeader(req.Header) + + req.Header.Add(AuthHeader, c.apiKey) + + 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 { + if resp.StatusCode == http.StatusNotFound { + return &NotFoundError{APIError{ + CorrelationID: resp.Header.Get("Correlation-Id"), + ErrorCode: strconv.Itoa(http.StatusNotFound), + ErrorMessage: string(raw), + }} + } + + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + if errAPI.ErrorCode == strconv.Itoa(http.StatusNotFound) { + return &NotFoundError{APIError: errAPI} + } + + return &errAPI +} + +// TTLRounder rounds the given TTL in seconds to the next accepted value. +// Accepted TTL values are: 60, 300, 1800, 3600, 14400, 28800, 43200, 86400. +func TTLRounder(ttl int) int { + for _, validTTL := range []int{60, 300, 1800, 3600, 14400, 28800, 43200, 86400} { + if ttl <= validTTL { + return validTTL + } + } + + return 3600 +} diff --git a/providers/dns/leaseweb/internal/client_test.go b/providers/dns/leaseweb/internal/client_test.go new file mode 100644 index 000000000..5762aad4b --- /dev/null +++ b/providers/dns/leaseweb/internal/client_test.go @@ -0,0 +1,149 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret") + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With(AuthHeader, "secret"), + ) +} + +func TestClient_CreateRRSet(t *testing.T) { + client := mockBuilder(). + Route("POST /domains/example.com/resourceRecordSets", + servermock.ResponseFromFixture("createResourceRecordSet.json"), + servermock.CheckRequestJSONBodyFromFixture("createResourceRecordSet-request.json"), + ). + Build(t) + + rrset := RRSet{ + Content: []string{"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"}, + Name: "_acme-challenge.example.com.", + TTL: 300, + Type: "TXT", + } + + result, err := client.CreateRRSet(t.Context(), "example.com", rrset) + require.NoError(t, err) + + expected := &RRSet{ + Content: []string{"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"}, + Name: "_acme-challenge.example.com.", + Editable: true, + TTL: 300, + Type: "TXT", + } + + assert.Equal(t, expected, result) +} + +func TestClient_GetRRSet(t *testing.T) { + client := mockBuilder(). + Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromFixture("getResourceRecordSet.json"), + ). + Build(t) + + result, err := client.GetRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT") + require.NoError(t, err) + + expected := &RRSet{ + Content: []string{"foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo"}, + Name: "_acme-challenge.example.com.", + Editable: true, + TTL: 3600, + Type: "TXT", + } + + assert.Equal(t, expected, result) +} + +func TestClient_GetRRSet_error_404(t *testing.T) { + client := mockBuilder(). + Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromFixture("error_404.json"). + WithStatusCode(http.StatusNotFound), + ). + Build(t) + + _, err := client.GetRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT") + require.EqualError(t, err, "404: Resource not found (289346a1-3eaf-4da4-b707-62ef12eb08be)") + + target := &NotFoundError{} + require.ErrorAs(t, err, &target) +} + +func TestClient_UpdateRRSet(t *testing.T) { + client := mockBuilder(). + Route("PUT /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromFixture("updateResourceRecordSet.json"), + servermock.CheckRequestJSONBodyFromFixture("updateResourceRecordSet-request.json"), + ). + Build(t) + + rrset := RRSet{ + Content: []string{"foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"}, + Name: "_acme-challenge.example.com.", + TTL: 3600, + Type: "TXT", + } + + result, err := client.UpdateRRSet(t.Context(), "example.com", rrset) + require.NoError(t, err) + + expected := &RRSet{ + Content: []string{"foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"}, + Name: "_acme-challenge.example.com.", + Editable: true, + TTL: 3600, + Type: "TXT", + } + + assert.Equal(t, expected, result) +} + +func TestClient_DeleteRRSet(t *testing.T) { + client := mockBuilder(). + Route("DELETE /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.Noop(). + WithStatusCode(http.StatusNoContent), + ). + Build(t) + + err := client.DeleteRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT") + require.NoError(t, err) +} + +func TestClient_DeleteRRSet_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromFixture("error_401.json"). + WithStatusCode(http.StatusUnauthorized), + ). + Build(t) + + err := client.DeleteRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT") + require.EqualError(t, err, "401: You are not authorized to view this resource. (289346a1-3eaf-4da4-b707-62ef12eb08be)") +} diff --git a/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet-request.json b/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet-request.json new file mode 100644 index 000000000..af53fcf04 --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet-request.json @@ -0,0 +1,8 @@ +{ + "content": [ + "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" + ], + "name": "_acme-challenge.example.com.", + "ttl": 300, + "type": "TXT" +} diff --git a/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet.json b/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet.json new file mode 100644 index 000000000..8ca040d63 --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet.json @@ -0,0 +1,17 @@ +{ + "_links": { + "self": { + "href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT" + }, + "collection": { + "href": "/domains/example.com/resourceRecordSets" + } + }, + "content": [ + "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" + ], + "editable": true, + "name": "_acme-challenge.example.com.", + "ttl": 300, + "type": "TXT" +} diff --git a/providers/dns/leaseweb/internal/fixtures/error_400.json b/providers/dns/leaseweb/internal/fixtures/error_400.json new file mode 100644 index 000000000..1a980b6bb --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/error_400.json @@ -0,0 +1,6 @@ +{ + "correlationId": "289346a1-3eaf-4da4-b707-62ef12eb08be", + "errorCode": "400", + "errorDetails": {}, + "errorMessage": "The API could not interpret your request correctly." +} diff --git a/providers/dns/leaseweb/internal/fixtures/error_401.json b/providers/dns/leaseweb/internal/fixtures/error_401.json new file mode 100644 index 000000000..47d8a311d --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/error_401.json @@ -0,0 +1,5 @@ +{ + "correlationId": "289346a1-3eaf-4da4-b707-62ef12eb08be", + "errorCode": "401", + "errorMessage": "You are not authorized to view this resource." +} diff --git a/providers/dns/leaseweb/internal/fixtures/error_404.json b/providers/dns/leaseweb/internal/fixtures/error_404.json new file mode 100644 index 000000000..1deaf5606 --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/error_404.json @@ -0,0 +1,5 @@ +{ + "correlationId": "289346a1-3eaf-4da4-b707-62ef12eb08be", + "errorCode": "404", + "errorMessage": "Resource not found" +} diff --git a/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet.json b/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet.json new file mode 100644 index 000000000..fd48f60c6 --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet.json @@ -0,0 +1,18 @@ +{ + "_links": { + "self": { + "href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT" + }, + "collection": { + "href": "/domains/example.com/resourceRecordSets" + } + }, + "content": [ + "foo", + "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo" + ], + "editable": true, + "name": "_acme-challenge.example.com.", + "ttl": 3600, + "type": "TXT" +} diff --git a/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet2.json b/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet2.json new file mode 100644 index 000000000..abf3fb4c3 --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet2.json @@ -0,0 +1,17 @@ +{ + "_links": { + "self": { + "href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT" + }, + "collection": { + "href": "/domains/example.com/resourceRecordSets" + } + }, + "content": [ + "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo" + ], + "editable": true, + "name": "_acme-challenge.example.com.", + "ttl": 3600, + "type": "TXT" +} diff --git a/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request.json b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request.json new file mode 100644 index 000000000..e781958c8 --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request.json @@ -0,0 +1,8 @@ +{ + "content": [ + "foo", + "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo", + "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" + ], + "ttl": 3600 +} diff --git a/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request2.json b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request2.json new file mode 100644 index 000000000..0acc314de --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request2.json @@ -0,0 +1,6 @@ +{ + "content": [ + "foo" + ], + "ttl": 3600 +} diff --git a/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet.json b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet.json new file mode 100644 index 000000000..2b877982c --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet.json @@ -0,0 +1,19 @@ +{ + "_links": { + "self": { + "href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT" + }, + "collection": { + "href": "/domains/example.com/resourceRecordSets" + } + }, + "content": [ + "foo", + "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo", + "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" + ], + "editable": true, + "name": "_acme-challenge.example.com.", + "ttl": 3600, + "type": "TXT" +} diff --git a/providers/dns/leaseweb/internal/types.go b/providers/dns/leaseweb/internal/types.go new file mode 100644 index 000000000..7a4547584 --- /dev/null +++ b/providers/dns/leaseweb/internal/types.go @@ -0,0 +1,35 @@ +package internal + +import ( + "encoding/json" + "fmt" +) + +type NotFoundError struct { + APIError +} + +type APIError struct { + CorrelationID string `json:"correlationId,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + ErrorDetails json.RawMessage `json:"errorDetails,omitempty"` +} + +func (a *APIError) Error() string { + msg := fmt.Sprintf("%s: %s (%s)", a.ErrorCode, a.ErrorMessage, a.CorrelationID) + + if len(a.ErrorDetails) > 0 { + msg += fmt.Sprintf(": %s", string(a.ErrorDetails)) + } + + return msg +} + +type RRSet struct { + Content []string `json:"content,omitempty"` + Name string `json:"name,omitempty"` + Editable bool `json:"editable,omitempty"` + TTL int `json:"ttl,omitempty"` + Type string `json:"type,omitempty"` +} diff --git a/providers/dns/leaseweb/leaseweb.go b/providers/dns/leaseweb/leaseweb.go new file mode 100644 index 000000000..fafaf1c4d --- /dev/null +++ b/providers/dns/leaseweb/leaseweb.go @@ -0,0 +1,187 @@ +// Package leaseweb implements a DNS provider for solving the DNS-01 challenge using Leaseweb. +package leaseweb + +import ( + "context" + "errors" + "fmt" + "net/http" + "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/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/leaseweb/internal" +) + +// Environment variables names. +const ( + envNamespace = "LEASEWEB_" + + EnvAPIKey = envNamespace + "API_KEY" + + 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 + + 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 Leaseweb. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("leaseweb: %w", err) + } + + config := NewDefaultConfig() + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Leaseweb. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("leaseweb: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.APIKey) + if err != nil { + return nil, fmt.Errorf("leaseweb: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.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("leaseweb: could not find zone for domain %q: %w", domain, err) + } + + existingRRSet, err := d.client.GetRRSet(ctx, dns01.UnFqdn(authZone), info.EffectiveFQDN, "TXT") + if err != nil { + notfoundErr := &internal.NotFoundError{} + if !errors.As(err, ¬foundErr) { + return fmt.Errorf("leaseweb: get RRSet: %w", err) + } + + // Create the RRSet. + + rrset := internal.RRSet{ + Content: []string{info.Value}, + Name: info.EffectiveFQDN, + TTL: internal.TTLRounder(d.config.TTL), + Type: "TXT", + } + + _, err = d.client.CreateRRSet(ctx, dns01.UnFqdn(authZone), rrset) + if err != nil { + return fmt.Errorf("leaseweb: create RRSet: %w", err) + } + + return nil + } + + // Update the RRSet. + + existingRRSet.Content = append(existingRRSet.Content, info.Value) + + _, err = d.client.UpdateRRSet(ctx, dns01.UnFqdn(authZone), *existingRRSet) + if err != nil { + return fmt.Errorf("leaseweb: update RRSet: %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("leaseweb: could not find zone for domain %q: %w", domain, err) + } + + existingRRSet, err := d.client.GetRRSet(ctx, dns01.UnFqdn(authZone), info.EffectiveFQDN, "TXT") + if err != nil { + return fmt.Errorf("leaseweb: get RRSet: %w", err) + } + + var content []string + + for _, s := range existingRRSet.Content { + if s != info.Value { + content = append(content, s) + } + } + + if len(content) == 0 { + err = d.client.DeleteRRSet(ctx, dns01.UnFqdn(authZone), info.EffectiveFQDN, "TXT") + if err != nil { + return fmt.Errorf("leaseweb: delete RRSet: %w", err) + } + + return nil + } + + existingRRSet.Content = content + + _, err = d.client.UpdateRRSet(ctx, dns01.UnFqdn(authZone), *existingRRSet) + if err != nil { + return fmt.Errorf("leaseweb: update RRSet: %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 +} diff --git a/providers/dns/leaseweb/leaseweb.toml b/providers/dns/leaseweb/leaseweb.toml new file mode 100644 index 000000000..2c3503291 --- /dev/null +++ b/providers/dns/leaseweb/leaseweb.toml @@ -0,0 +1,22 @@ +Name = "Leaseweb" +Description = '''''' +URL = "https://www.leaseweb.com/en/" +Code = "leaseweb" +Since = "v4.32.0" + +Example = ''' +LEASEWEB_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns leaseweb -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + LEASEWEB_API_KEY = "API key" + [Configuration.Additional] + LEASEWEB_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + LEASEWEB_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + LEASEWEB_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + LEASEWEB_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://developer.leaseweb.com/docs/#tag/DNS" diff --git a/providers/dns/leaseweb/leaseweb_test.go b/providers/dns/leaseweb/leaseweb_test.go new file mode 100644 index 000000000..0450cd2c2 --- /dev/null +++ b/providers/dns/leaseweb/leaseweb_test.go @@ -0,0 +1,204 @@ +package leaseweb + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/go-acme/lego/v4/providers/dns/leaseweb/internal" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIKey: "secret", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "leaseweb: some credentials information are missing: LEASEWEB_API_KEY", + }, + } + + 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 + expected string + }{ + { + desc: "success", + apiKey: "secret", + }, + { + desc: "missing credentials", + expected: "leaseweb: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.apiKey + + 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) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.APIKey = "secret" + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + + return p, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With(internal.AuthHeader, "secret"), + ) +} + +func TestDNSProvider_Present_create(t *testing.T) { + provider := mockBuilder(). + Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromInternal("error_404.json"). + WithStatusCode(http.StatusNotFound), + ). + Route("POST /domains/example.com/resourceRecordSets", + servermock.ResponseFromInternal("createResourceRecordSet.json"), + servermock.CheckRequestJSONBodyFromInternal("createResourceRecordSet-request.json"), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_Present_update(t *testing.T) { + provider := mockBuilder(). + Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromInternal("getResourceRecordSet.json"), + ). + Route("PUT /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromInternal("updateResourceRecordSet.json"), + servermock.CheckRequestJSONBodyFromInternal("updateResourceRecordSet-request.json"), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp_delete(t *testing.T) { + provider := mockBuilder(). + Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromInternal("getResourceRecordSet2.json"), + ). + Route("DELETE /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.Noop(). + WithStatusCode(http.StatusNoContent), + ). + Build(t) + + err := provider.CleanUp("example.com", "abc", "1234d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp_update(t *testing.T) { + provider := mockBuilder(). + Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromInternal("getResourceRecordSet.json"), + ). + Route("PUT /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromInternal("updateResourceRecordSet.json"), + servermock.CheckRequestJSONBodyFromInternal("updateResourceRecordSet-request2.json"), + ). + Build(t) + + err := provider.CleanUp("example.com", "abc", "1234d==") + require.NoError(t, err) +} diff --git a/providers/dns/zz_gen_dns_providers.go b/providers/dns/zz_gen_dns_providers.go index 24474cf2f..e1b2cc989 100644 --- a/providers/dns/zz_gen_dns_providers.go +++ b/providers/dns/zz_gen_dns_providers.go @@ -106,6 +106,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/jdcloud" "github.com/go-acme/lego/v4/providers/dns/joker" "github.com/go-acme/lego/v4/providers/dns/keyhelp" + "github.com/go-acme/lego/v4/providers/dns/leaseweb" "github.com/go-acme/lego/v4/providers/dns/liara" "github.com/go-acme/lego/v4/providers/dns/lightsail" "github.com/go-acme/lego/v4/providers/dns/limacity" @@ -398,6 +399,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return joker.NewDNSProvider() case "keyhelp": return keyhelp.NewDNSProvider() + case "leaseweb": + return leaseweb.NewDNSProvider() case "liara": return liara.NewDNSProvider() case "lightsail": From c50918c54ee9d6797587158241d5faf40513fec4 Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Thu, 19 Feb 2026 12:55:08 +0100 Subject: [PATCH 19/21] Prepare release v4.32.0 --- CHANGELOG.md | 30 +++++++++++++++++++ acme/api/internal/sender/useragent.go | 4 +-- cmd/lego/zz_gen_version.go | 2 +- providers/dns/internal/useragent/useragent.go | 4 +-- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee191cdb4..ae73f70f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ Everybody thinks that the others will donate, but in the end, nobody does. So if you think that lego is worth it, please consider [donating](https://donate.ldez.dev). +## v4.32.0 + +- Release date: 2026-02-19 +- Tag: [v4.32.0](https://github.com/go-acme/lego/releases/tag/v4.32.0) + +### Added + +- **[dnsprovider]** Add DNS provider for ArtFiles +- **[dnsprovider]** Add DNS provider for Leaseweb +- **[dnsprovider]** Add DNS provider for FusionLayer NameSurfer +- **[dnsprovider]** Add DNS provider for DDNSS +- **[dnsprovider]** Add DNS provider for Bluecat v2 +- **[dnsprovider]** Add DNS provider for TodayNIC/时代互联 +- **[dnsprovider]** Add DNS provider for DNSExit +- **[dnsprovider]** alidns: add line record option + +### Changed + +- **[dnsprovider]** azure: reinforces deprecation +- **[dnsprovider]** allinkl: detect zone through API + +### Fixed + +- **[ari]** fix: implement parsing for Retry-After header according to RFC 7231 +- **[dnsprovider]** namesurfer: fix updateDNSHost +- **[dnsprovider]** timewebcloud: fix subdomain support +- **[dnsprovider]** fix: deduplicate authz for DNS01 challenge +- **[lib,cli]** fix: use IPs to define the main domain +- **[lib]** fix: preserve domain order + ## v4.31.0 - Release date: 2026-01-08 diff --git a/acme/api/internal/sender/useragent.go b/acme/api/internal/sender/useragent.go index 570b3b67c..feda18cc7 100644 --- a/acme/api/internal/sender/useragent.go +++ b/acme/api/internal/sender/useragent.go @@ -4,10 +4,10 @@ package sender const ( // ourUserAgent is the User-Agent of this underlying library package. - ourUserAgent = "xenolf-acme/4.31.0" + ourUserAgent = "xenolf-acme/4.32.0" // ourUserAgentComment is part of the UA comment linked to the version status of this underlying library package. // values: detach|release // NOTE: Update this with each tagged release. - ourUserAgentComment = "detach" + ourUserAgentComment = "release" ) diff --git a/cmd/lego/zz_gen_version.go b/cmd/lego/zz_gen_version.go index 57899e15e..f0ca21326 100644 --- a/cmd/lego/zz_gen_version.go +++ b/cmd/lego/zz_gen_version.go @@ -2,7 +2,7 @@ package main -const defaultVersion = "v4.31.0+dev-detach" +const defaultVersion = "v4.32.0+dev-release" var version = "" diff --git a/providers/dns/internal/useragent/useragent.go b/providers/dns/internal/useragent/useragent.go index da24329cc..43e77b23d 100644 --- a/providers/dns/internal/useragent/useragent.go +++ b/providers/dns/internal/useragent/useragent.go @@ -10,12 +10,12 @@ import ( const ( // ourUserAgent is the User-Agent of this underlying library package. - ourUserAgent = "goacme-lego/4.31.0" + ourUserAgent = "goacme-lego/4.32.0" // ourUserAgentComment is part of the UA comment linked to the version status of this underlying library package. // values: detach|release // NOTE: Update this with each tagged release. - ourUserAgentComment = "detach" + ourUserAgentComment = "release" ) // Get builds and returns the User-Agent string. From 4547c4317e7a3158d1927aa0c6b887404d7e8ac9 Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Thu, 19 Feb 2026 12:55:20 +0100 Subject: [PATCH 20/21] Detach v4.32.0 --- acme/api/internal/sender/useragent.go | 2 +- cmd/lego/zz_gen_version.go | 2 +- providers/dns/internal/useragent/useragent.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/acme/api/internal/sender/useragent.go b/acme/api/internal/sender/useragent.go index feda18cc7..51a1b4770 100644 --- a/acme/api/internal/sender/useragent.go +++ b/acme/api/internal/sender/useragent.go @@ -9,5 +9,5 @@ const ( // ourUserAgentComment is part of the UA comment linked to the version status of this underlying library package. // values: detach|release // NOTE: Update this with each tagged release. - ourUserAgentComment = "release" + ourUserAgentComment = "detach" ) diff --git a/cmd/lego/zz_gen_version.go b/cmd/lego/zz_gen_version.go index f0ca21326..cf9ad00ef 100644 --- a/cmd/lego/zz_gen_version.go +++ b/cmd/lego/zz_gen_version.go @@ -2,7 +2,7 @@ package main -const defaultVersion = "v4.32.0+dev-release" +const defaultVersion = "v4.32.0+dev-detach" var version = "" diff --git a/providers/dns/internal/useragent/useragent.go b/providers/dns/internal/useragent/useragent.go index 43e77b23d..090c9109a 100644 --- a/providers/dns/internal/useragent/useragent.go +++ b/providers/dns/internal/useragent/useragent.go @@ -15,7 +15,7 @@ const ( // ourUserAgentComment is part of the UA comment linked to the version status of this underlying library package. // values: detach|release // NOTE: Update this with each tagged release. - ourUserAgentComment = "release" + ourUserAgentComment = "detach" ) // Get builds and returns the User-Agent string. From 7d459b59c5882aac5cd8545cbda77e18852f5cd3 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Sun, 22 Feb 2026 14:14:18 +0100 Subject: [PATCH 21/21] liara: add support for team ID (#2867) --- cmd/zz_gen_cmd_dnshelp.go | 1 + docs/content/dns/zz_gen_liara.md | 1 + providers/dns/liara/internal/client.go | 27 +++++++--- providers/dns/liara/internal/client_test.go | 55 ++++++++++++++++++--- providers/dns/liara/liara.go | 7 ++- providers/dns/liara/liara.toml | 1 + 6 files changed, 76 insertions(+), 16 deletions(-) diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index 161729c79..8e3b4ebce 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -2394,6 +2394,7 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(` - "LIARA_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "LIARA_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "LIARA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "LIARA_TEAM_ID": The team ID to access services in a team`) ew.writeln(` - "LIARA_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)`) ew.writeln() diff --git a/docs/content/dns/zz_gen_liara.md b/docs/content/dns/zz_gen_liara.md index 8a6ddbd99..658ce8077 100644 --- a/docs/content/dns/zz_gen_liara.md +++ b/docs/content/dns/zz_gen_liara.md @@ -50,6 +50,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | `LIARA_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `LIARA_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `LIARA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `LIARA_TEAM_ID` | The team ID to access services in a team | | `LIARA_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. diff --git a/providers/dns/liara/internal/client.go b/providers/dns/liara/internal/client.go index 93cdcf7c8..95c39695b 100644 --- a/providers/dns/liara/internal/client.go +++ b/providers/dns/liara/internal/client.go @@ -20,17 +20,23 @@ const defaultBaseURL = "https://dns-service.iran.liara.ir" type Client struct { baseURL *url.URL httpClient *http.Client + + teamID string } // NewClient creates a new Client. -func NewClient(hc *http.Client) *Client { +func NewClient(hc *http.Client, teamID string) *Client { baseURL, _ := url.Parse(defaultBaseURL) if hc == nil { hc = &http.Client{Timeout: 10 * time.Second} } - return &Client{httpClient: hc, baseURL: baseURL} + return &Client{ + httpClient: hc, + baseURL: baseURL, + teamID: teamID, + } } // GetRecords gets the records of a domain. @@ -38,7 +44,7 @@ func NewClient(hc *http.Client) *Client { func (c *Client) GetRecords(ctx context.Context, domainName string) ([]Record, error) { endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records") - req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } @@ -73,7 +79,7 @@ func (c *Client) GetRecords(ctx context.Context, domainName string) ([]Record, e func (c *Client) CreateRecord(ctx context.Context, domainName string, record Record) (*Record, error) { endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records") - req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) + req, err := c.newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, fmt.Errorf("create request: %w", err) } @@ -108,7 +114,7 @@ func (c *Client) CreateRecord(ctx context.Context, domainName string, record Rec func (c *Client) GetRecord(ctx context.Context, domainName, recordID string) (*Record, error) { endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records", recordID) - req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + req, err := c.newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } @@ -143,7 +149,7 @@ func (c *Client) GetRecord(ctx context.Context, domainName, recordID string) (*R func (c *Client) DeleteRecord(ctx context.Context, domainName, recordID string) error { endpoint := c.baseURL.JoinPath("api", "v1", "zones", domainName, "dns-records", recordID) - req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + req, err := c.newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return fmt.Errorf("create request: %w", err) } @@ -162,7 +168,14 @@ func (c *Client) DeleteRecord(ctx context.Context, domainName, recordID string) return nil } -func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { +func (c *Client) newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + if c.teamID != "" { + query := endpoint.Query() + query.Set("teamID", c.teamID) + + endpoint.RawQuery = query.Encode() + } + buf := new(bytes.Buffer) if payload != nil { diff --git a/providers/dns/liara/internal/client_test.go b/providers/dns/liara/internal/client_test.go index 57ac7e8b3..b6d007046 100644 --- a/providers/dns/liara/internal/client_test.go +++ b/providers/dns/liara/internal/client_test.go @@ -13,10 +13,10 @@ import ( const apiKey = "key" -func mockBuilder() *servermock.Builder[*Client] { +func mockBuilder(teamID string) *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { - client := NewClient(OAuthStaticAccessToken(server.Client(), apiKey)) + client := NewClient(OAuthStaticAccessToken(server.Client(), apiKey), teamID) client.baseURL, _ = url.Parse(server.URL) return client, nil @@ -26,7 +26,7 @@ func mockBuilder() *servermock.Builder[*Client] { } func TestClient_GetRecords(t *testing.T) { - client := mockBuilder(). + client := mockBuilder(""). Route("GET /api/v1/zones/example.com/dns-records", servermock.ResponseFromFixture("RecordsResponse.json")). Build(t) @@ -50,7 +50,7 @@ func TestClient_GetRecords(t *testing.T) { } func TestClient_GetRecord(t *testing.T) { - client := mockBuilder(). + client := mockBuilder(""). Route("GET /api/v1/zones/example.com/dns-records/123", servermock.ResponseFromFixture("RecordResponse.json")). Build(t) @@ -72,7 +72,7 @@ func TestClient_GetRecord(t *testing.T) { } func TestClient_CreateRecord(t *testing.T) { - client := mockBuilder(). + client := mockBuilder(""). Route("POST /api/v1/zones/example.com/dns-records", servermock.ResponseFromFixture("RecordResponse.json"). WithStatusCode(http.StatusCreated), @@ -108,8 +108,47 @@ func TestClient_CreateRecord(t *testing.T) { assert.Equal(t, expected, record) } +func TestClient_CreateRecord_withTeamID(t *testing.T) { + client := mockBuilder("123"). + Route("POST /api/v1/zones/example.com/dns-records", + servermock.ResponseFromFixture("RecordResponse.json"). + WithStatusCode(http.StatusCreated), + servermock.CheckRequestJSONBody(`{"name":"string","type":"string","ttl":3600,"contents":[{"text":"string"}]}`), + servermock.CheckQueryParameter().Strict().With("teamID", "123"), + ). + Build(t) + + data := Record{ + Type: "string", + Name: "string", + Contents: []Content{ + { + Text: "string", + }, + }, + TTL: 3600, + } + + record, err := client.CreateRecord(t.Context(), "example.com", data) + require.NoError(t, err) + + expected := &Record{ + ID: "string", + Type: "string", + Name: "string", + Contents: []Content{ + { + Text: "string", + }, + }, + TTL: 3600, + } + + assert.Equal(t, expected, record) +} + func TestClient_DeleteRecord(t *testing.T) { - client := mockBuilder(). + client := mockBuilder(""). Route("DELETE /api/v1/zones/example.com/dns-records/123", servermock.Noop(). WithStatusCode(http.StatusNoContent)). @@ -120,7 +159,7 @@ func TestClient_DeleteRecord(t *testing.T) { } func TestClient_DeleteRecord_NotFound_Response(t *testing.T) { - client := mockBuilder(). + client := mockBuilder(""). Route("DELETE /api/v1/zones/example.com/dns-records/123", servermock.Noop(). WithStatusCode(http.StatusNotFound)). @@ -131,7 +170,7 @@ func TestClient_DeleteRecord_NotFound_Response(t *testing.T) { } func TestClient_DeleteRecord_error(t *testing.T) { - client := mockBuilder(). + client := mockBuilder(""). Route("DELETE /api/v1/zones/example.com/dns-records/123", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). diff --git a/providers/dns/liara/liara.go b/providers/dns/liara/liara.go index b91b004cc..c7e403eed 100644 --- a/providers/dns/liara/liara.go +++ b/providers/dns/liara/liara.go @@ -23,6 +23,7 @@ const ( envNamespace = "LIARA_" EnvAPIKey = envNamespace + "API_KEY" + EnvTeamID = envNamespace + "TEAM_ID" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" @@ -39,7 +40,9 @@ var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { - APIKey string + APIKey string + TeamID string + TTL int PropagationTimeout time.Duration PollingInterval time.Duration @@ -77,6 +80,7 @@ func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] + config.TeamID = env.GetOrFile(EnvTeamID) return NewDNSProviderConfig(config) } @@ -112,6 +116,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { clientdebug.Wrap( internal.OAuthStaticAccessToken(retryClient.StandardClient(), config.APIKey), ), + config.TeamID, ) return &DNSProvider{ diff --git a/providers/dns/liara/liara.toml b/providers/dns/liara/liara.toml index 4ed53ec75..f471de04e 100644 --- a/providers/dns/liara/liara.toml +++ b/providers/dns/liara/liara.toml @@ -13,6 +13,7 @@ lego --dns liara -d '*.example.com' -d example.com run [Configuration.Credentials] LIARA_API_KEY = "The API key" [Configuration.Additional] + LIARA_TEAM_ID = "The team ID to access services in a team" LIARA_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" LIARA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" LIARA_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)"