From 078a1889c87c750f6051a3dd9dc1e5e24e690ec8 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Thu, 19 Feb 2026 12:26:53 +0100 Subject: [PATCH] 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":