From 620a48a3743435c6a9324784ed291c7062edf57d Mon Sep 17 00:00:00 2001 From: Ben Lewis <1884340+ben-zen@users.noreply.github.com> Date: Sun, 14 Feb 2021 07:33:52 -0800 Subject: [PATCH] Add DNS provider for Hurricane Electric (#1344) --- README.md | 22 +-- cmd/zz_gen_cmd_dnshelp.go | 15 ++ docs/content/dns/zz_gen_hurricane.md | 74 +++++++++ providers/dns/dns_providers.go | 3 + providers/dns/hurricane/hurricane.go | 134 +++++++++++++++++ providers/dns/hurricane/hurricane.toml | 48 ++++++ providers/dns/hurricane/hurricane_test.go | 141 ++++++++++++++++++ providers/dns/hurricane/internal/client.go | 103 +++++++++++++ .../dns/hurricane/internal/client_test.go | 81 ++++++++++ 9 files changed, 610 insertions(+), 11 deletions(-) create mode 100644 docs/content/dns/zz_gen_hurricane.md create mode 100644 providers/dns/hurricane/hurricane.go create mode 100644 providers/dns/hurricane/hurricane.toml create mode 100644 providers/dns/hurricane/hurricane_test.go create mode 100644 providers/dns/hurricane/internal/client.go create mode 100644 providers/dns/hurricane/internal/client_test.go diff --git a/README.md b/README.md index c0fc1396..baf8e960 100644 --- a/README.md +++ b/README.md @@ -56,16 +56,16 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). | [Dynu](https://go-acme.github.io/lego/dns/dynu/) | [EasyDNS](https://go-acme.github.io/lego/dns/easydns/) | [Exoscale](https://go-acme.github.io/lego/dns/exoscale/) | [External program](https://go-acme.github.io/lego/dns/exec/) | | [Gandi Live DNS (v5)](https://go-acme.github.io/lego/dns/gandiv5/) | [Gandi](https://go-acme.github.io/lego/dns/gandi/) | [Glesys](https://go-acme.github.io/lego/dns/glesys/) | [Go Daddy](https://go-acme.github.io/lego/dns/godaddy/) | | [Google Cloud](https://go-acme.github.io/lego/dns/gcloud/) | [Hetzner](https://go-acme.github.io/lego/dns/hetzner/) | [Hosting.de](https://go-acme.github.io/lego/dns/hostingde/) | [HTTP request](https://go-acme.github.io/lego/dns/httpreq/) | -| [HyperOne](https://go-acme.github.io/lego/dns/hyperone/) | [Infomaniak](https://go-acme.github.io/lego/dns/infomaniak/) | [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/) | [INWX](https://go-acme.github.io/lego/dns/inwx/) | -| [Ionos](https://go-acme.github.io/lego/dns/ionos/) | [Joker](https://go-acme.github.io/lego/dns/joker/) | [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns/) | [Linode (v4)](https://go-acme.github.io/lego/dns/linode/) | -| [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) | [Loopia](https://go-acme.github.io/lego/dns/loopia/) | [LuaDNS](https://go-acme.github.io/lego/dns/luadns/) | [Manual](https://go-acme.github.io/lego/dns/manual/) | -| [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | [MythicBeasts](https://go-acme.github.io/lego/dns/mythicbeasts/) | [Name.com](https://go-acme.github.io/lego/dns/namedotcom/) | [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | -| [Namesilo](https://go-acme.github.io/lego/dns/namesilo/) | [Netcup](https://go-acme.github.io/lego/dns/netcup/) | [Netlify](https://go-acme.github.io/lego/dns/netlify/) | [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) | -| [NS1](https://go-acme.github.io/lego/dns/ns1/) | [Open Telekom Cloud](https://go-acme.github.io/lego/dns/otc/) | [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) | [OVH](https://go-acme.github.io/lego/dns/ovh/) | -| [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | [reg.ru](https://go-acme.github.io/lego/dns/regru/) | [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | -| [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) | [Selectel](https://go-acme.github.io/lego/dns/selectel/) | -| [Servercow](https://go-acme.github.io/lego/dns/servercow/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | [TransIP](https://go-acme.github.io/lego/dns/transip/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | -| [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [Yandex](https://go-acme.github.io/lego/dns/yandex/) | -| [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | | | +| [Hurricane Electric DNS](https://go-acme.github.io/lego/dns/hurricane/) | [HyperOne](https://go-acme.github.io/lego/dns/hyperone/) | [Infomaniak](https://go-acme.github.io/lego/dns/infomaniak/) | [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/) | +| [INWX](https://go-acme.github.io/lego/dns/inwx/) | [Ionos](https://go-acme.github.io/lego/dns/ionos/) | [Joker](https://go-acme.github.io/lego/dns/joker/) | [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns/) | +| [Linode (v4)](https://go-acme.github.io/lego/dns/linode/) | [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) | [Loopia](https://go-acme.github.io/lego/dns/loopia/) | [LuaDNS](https://go-acme.github.io/lego/dns/luadns/) | +| [Manual](https://go-acme.github.io/lego/dns/manual/) | [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | [MythicBeasts](https://go-acme.github.io/lego/dns/mythicbeasts/) | [Name.com](https://go-acme.github.io/lego/dns/namedotcom/) | +| [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | [Namesilo](https://go-acme.github.io/lego/dns/namesilo/) | [Netcup](https://go-acme.github.io/lego/dns/netcup/) | [Netlify](https://go-acme.github.io/lego/dns/netlify/) | +| [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) | [NS1](https://go-acme.github.io/lego/dns/ns1/) | [Open Telekom Cloud](https://go-acme.github.io/lego/dns/otc/) | [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) | +| [OVH](https://go-acme.github.io/lego/dns/ovh/) | [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | [reg.ru](https://go-acme.github.io/lego/dns/regru/) | +| [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) | +| [Selectel](https://go-acme.github.io/lego/dns/selectel/) | [Servercow](https://go-acme.github.io/lego/dns/servercow/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | [TransIP](https://go-acme.github.io/lego/dns/transip/) | +| [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | +| [Yandex](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | | diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index d9da39a6..634d8671 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -53,6 +53,7 @@ func allDNSCodes() string { "hetzner", "hostingde", "httpreq", + "hurricane", "hyperone", "iij", "infomaniak", @@ -930,6 +931,20 @@ func displayDNSHelp(name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/httpreq`) + case "hurricane": + // generated from: providers/dns/hurricane/hurricane.toml + ew.writeln(`Configuration for Hurricane Electric DNS.`) + ew.writeln(`Code: 'hurricane'`) + ew.writeln(`Since: 'v4.3.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "HURRICANE_TOKENS": TXT record names and tokens`) + ew.writeln() + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/hurricane`) + case "hyperone": // generated from: providers/dns/hyperone/hyperone.toml ew.writeln(`Configuration for HyperOne.`) diff --git a/docs/content/dns/zz_gen_hurricane.md b/docs/content/dns/zz_gen_hurricane.md new file mode 100644 index 00000000..6a791cb2 --- /dev/null +++ b/docs/content/dns/zz_gen_hurricane.md @@ -0,0 +1,74 @@ +--- +title: "Hurricane Electric DNS" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: hurricane +--- + + + + + +Since: v4.3.0 + +Configuration for [Hurricane Electric DNS](https://dns.he.net/). + + + + +- Code: `hurricane` + +Here is an example bash command using the Hurricane Electric DNS provider: + +```bash +HURRICANE_TOKENS=example.org:token \ +lego --email myemail@example.com --dns hurricane -d example.org -d *.example.org run + +HURRICANE_TOKENS=my.example.org:token1,demo.example.org:token2 \ +lego -m myemail@example.com --dns hurricane -d my.example.org -d demo.example.org +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `HURRICANE_TOKENS` | TXT record names and tokens | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here](/lego/dns/#configuration-and-credentials). + + + +Before using lego to request a certificate for a given domain or wildcard (such as `my.example.org` or `*.my.example.org`), +create a TXT record named `_acme-challenge.my.example.org`, and enable dynamic updates on it. +Generate a token for each URL with Hurricane Electric's UI, and copy it down. +Stick to alphanumeric tokens for greatest reliability. + +To authenticate with the Hurricane Electric API, +add each record name/token pair you want to update to the `HURRICANE_TOKENS` environment variable, as shown in the examples. +Record names (without the `_acme-challenge.` component) and their tokens are separated with colons, +while the credential pairs are concatenated into a comma-separated list, like so: + +``` +HURRICANE_TOKENS=my.example.org:token1,demo.example.org:token2 +``` + +If you are issuing both a wildcard certificate and a standard certificate for a given subdomain, +you should not have repeat entries for that name, as both will use the same credential. + +``` +HURRICANE_TOKENS=example.org:token +``` + + + +## More information + +- [API documentation](https://dns.he.org/) + + + + diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index c858f721..1ba6edf3 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -44,6 +44,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/hetzner" "github.com/go-acme/lego/v4/providers/dns/hostingde" "github.com/go-acme/lego/v4/providers/dns/httpreq" + "github.com/go-acme/lego/v4/providers/dns/hurricane" "github.com/go-acme/lego/v4/providers/dns/hyperone" "github.com/go-acme/lego/v4/providers/dns/iij" "github.com/go-acme/lego/v4/providers/dns/infomaniak" @@ -169,6 +170,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return hostingde.NewDNSProvider() case "httpreq": return httpreq.NewDNSProvider() + case "hurricane": + return hurricane.NewDNSProvider() case "hyperone": return hyperone.NewDNSProvider() case "iij": diff --git a/providers/dns/hurricane/hurricane.go b/providers/dns/hurricane/hurricane.go new file mode 100644 index 00000000..d769e134 --- /dev/null +++ b/providers/dns/hurricane/hurricane.go @@ -0,0 +1,134 @@ +package hurricane + +import ( + "errors" + "fmt" + "net/http" + "strings" + "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/hurricane/internal" +) + +// Environment variables names. +const ( + envNamespace = "HURRICANE_" + + EnvTokens = envNamespace + "TOKENS" + + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" + EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Credentials map[string]string + PropagationTimeout time.Duration + PollingInterval time.Duration + SequenceInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 300*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), + 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 Hurricane Electric. +func NewDNSProvider() (*DNSProvider, error) { + config := NewDefaultConfig() + values, err := env.Get(EnvTokens) + if err != nil { + return nil, fmt.Errorf("hurricane: %w", err) + } + + credentials, err := parseCredentials(values[EnvTokens]) + if err != nil { + return nil, fmt.Errorf("hurricane: %w", err) + } + + config.Credentials = credentials + + return NewDNSProviderConfig(config) +} + +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("hurricane: the configuration of the DNS provider is nil") + } + + if len(config.Credentials) == 0 { + return nil, errors.New("hurricane: credentials missing") + } + + client := internal.NewClient(config.Credentials) + + return &DNSProvider{config: config, client: client}, nil +} + +// Present updates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, _, keyAuth string) error { + _, txtRecord := dns01.GetRecord(domain, keyAuth) + + err := d.client.UpdateTxtRecord(domain, txtRecord) + if err != nil { + return fmt.Errorf("hurricane: %w", err) + } + + return nil +} + +// CleanUp updates the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, _, _ string) error { + err := d.client.UpdateTxtRecord(domain, ".") + if err != nil { + return fmt.Errorf("hurricane: %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 +} + +// Sequential All DNS challenges for this provider will be resolved sequentially. +// Returns the interval between each iteration. +func (d *DNSProvider) Sequential() time.Duration { + return d.config.SequenceInterval +} + +func parseCredentials(raw string) (map[string]string, error) { + credentials := make(map[string]string) + + credStrings := strings.Split(strings.TrimSuffix(raw, ","), ",") + for _, credPair := range credStrings { + data := strings.Split(credPair, ":") + if len(data) != 2 { + return nil, fmt.Errorf("incorrect credential pair: %s", credPair) + } + + credentials[strings.TrimSpace(data[0])] = strings.TrimSpace(data[1]) + } + + return credentials, nil +} diff --git a/providers/dns/hurricane/hurricane.toml b/providers/dns/hurricane/hurricane.toml new file mode 100644 index 00000000..0de7f500 --- /dev/null +++ b/providers/dns/hurricane/hurricane.toml @@ -0,0 +1,48 @@ +Name = "Hurricane Electric DNS" +Description = '''''' +URL = "https://dns.he.net/" +Code = "hurricane" +Since = "v4.3.0" + +Example = ''' +HURRICANE_TOKENS=example.org:token \ +lego --email myemail@example.com --dns hurricane -d example.org -d *.example.org run + +HURRICANE_TOKENS=my.example.org:token1,demo.example.org:token2 \ +lego -m myemail@example.com --dns hurricane -d my.example.org -d demo.example.org +''' + +Additional = """ +Before using lego to request a certificate for a given domain or wildcard (such as `my.example.org` or `*.my.example.org`), +create a TXT record named `_acme-challenge.my.example.org`, and enable dynamic updates on it. +Generate a token for each URL with Hurricane Electric's UI, and copy it down. +Stick to alphanumeric tokens for greatest reliability. + +To authenticate with the Hurricane Electric API, +add each record name/token pair you want to update to the `HURRICANE_TOKENS` environment variable, as shown in the examples. +Record names (without the `_acme-challenge.` component) and their tokens are separated with colons, +while the credential pairs are concatenated into a comma-separated list, like so: + +``` +HURRICANE_TOKENS=my.example.org:token1,demo.example.org:token2 +``` + +If you are issuing both a wildcard certificate and a standard certificate for a given subdomain, +you should not have repeat entries for that name, as both will use the same credential. + +``` +HURRICANE_TOKENS=example.org:token +``` +""" + +[Configuration] + + [Configuration.Credentials] + HURRICANE_TOKENS = "TXT record names and tokens" + [Configuration.Addtional] + HURRICANE_POLLING_INTERVAL = "Time between DNS propagation checks" + HURRICANE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation; defaults to 300s (5 minutes)" + + +[Links] + API = "https://dns.he.org/" diff --git a/providers/dns/hurricane/hurricane_test.go b/providers/dns/hurricane/hurricane_test.go new file mode 100644 index 00000000..1622285c --- /dev/null +++ b/providers/dns/hurricane/hurricane_test.go @@ -0,0 +1,141 @@ +package hurricane + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvTokens).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvTokens: "example.org:123", + }, + }, + { + desc: "success multiple domains", + envVars: map[string]string{ + EnvTokens: "example.org:123,example.com:456,example.net:789", + }, + }, + { + desc: "invalid credentials", + envVars: map[string]string{ + EnvTokens: ",", + }, + expected: "hurricane: incorrect credential pair: ", + }, + { + desc: "invalid credentials, partial", + envVars: map[string]string{ + EnvTokens: "example.org:123,example.net", + }, + expected: "hurricane: incorrect credential pair: example.net", + }, + { + desc: "missing credentials", + envVars: map[string]string{ + EnvTokens: "", + }, + expected: "hurricane: some credentials information are missing: HURRICANE_TOKENS", + }, + } + + 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 len(test.expected) == 0 { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + creds map[string]string + expected string + }{ + { + desc: "success", + creds: map[string]string{"example.org": "123"}, + }, + { + desc: "success multiple domains", + creds: map[string]string{ + "example.org": "123", + "example.com": "456", + "example.net": "789", + }, + }, + { + desc: "missing credentials", + expected: "hurricane: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Credentials = test.creds + + p, err := NewDNSProviderConfig(config) + + if len(test.expected) == 0 { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } 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/hurricane/internal/client.go b/providers/dns/hurricane/internal/client.go new file mode 100644 index 00000000..eb92803e --- /dev/null +++ b/providers/dns/hurricane/internal/client.go @@ -0,0 +1,103 @@ +package internal + +import ( + "bytes" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "sync" + "time" +) + +const defaultBaseURL = "https://dyn.dns.he.net/nic/update" + +const ( + codeGood = "good" + codeNoChg = "nochg" + codeAbuse = "abuse" + codeBadAgent = "badagent" + codeBadAuth = "badauth" + codeNoHost = "nohost" + codeNotFqdn = "notfqdn" +) + +// Client the Hurricane Electric client. +type Client struct { + HTTPClient *http.Client + baseURL string + + credentials map[string]string + credMu sync.Mutex +} + +// NewClient Creates a new Client. +func NewClient(credentials map[string]string) *Client { + return &Client{ + HTTPClient: &http.Client{Timeout: 5 * time.Second}, + baseURL: defaultBaseURL, + credentials: credentials, + } +} + +// UpdateTxtRecord updates a TXT record. +func (c *Client) UpdateTxtRecord(domain string, txt string) error { + hostname := fmt.Sprintf("_acme-challenge.%s", domain) + + c.credMu.Lock() + token, ok := c.credentials[domain] + c.credMu.Unlock() + + if !ok { + return fmt.Errorf("hurricane: Domain %s not found in credentials, check your credentials map", domain) + } + + data := url.Values{} + data.Set("password", token) + data.Set("hostname", hostname) + data.Set("txt", txt) + + resp, err := c.HTTPClient.PostForm(c.baseURL, data) + if err != nil { + return err + } + + defer func() { _ = resp.Body.Close() }() + + bodyBytes, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + + body := string(bytes.TrimSpace(bodyBytes)) + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%d: attempt to change TXT record %s returned %s", resp.StatusCode, hostname, body) + } + + return evaluateBody(body, hostname) +} + +func evaluateBody(body string, hostname string) error { + switch body { + case codeGood: + return nil + case codeNoChg: + log.Printf("%s: unchanged content written to TXT record %s", body, hostname) + return nil + case codeAbuse: + return fmt.Errorf("%s: blocked hostname for abuse: %s", body, hostname) + case codeBadAgent: + return fmt.Errorf("%s: user agent not sent or HTTP method not recognized; open an issue on go-acme/lego on Github", body) + case codeBadAuth: + return fmt.Errorf("%s: wrong authentication token provided for TXT record %s", body, hostname) + case codeNoHost: + return fmt.Errorf("%s: the record provided does not exist in this account: %s", body, hostname) + case codeNotFqdn: + return fmt.Errorf("%s: the record provided isn't an FQDN: %s", body, hostname) + default: + // This is basically only server errors. + return fmt.Errorf("attempt to change TXT record %s returned %s", hostname, body) + } +} diff --git a/providers/dns/hurricane/internal/client_test.go b/providers/dns/hurricane/internal/client_test.go new file mode 100644 index 00000000..ef8d525d --- /dev/null +++ b/providers/dns/hurricane/internal/client_test.go @@ -0,0 +1,81 @@ +package internal + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestClient_UpdateTxtRecord(t *testing.T) { + testCases := []struct { + code string + expected assert.ErrorAssertionFunc + }{ + { + code: codeGood, + expected: assert.NoError, + }, + { + code: codeNoChg, + expected: assert.NoError, + }, + { + code: codeAbuse, + expected: assert.Error, + }, + { + code: codeBadAgent, + expected: assert.Error, + }, + { + code: codeBadAuth, + expected: assert.Error, + }, + { + code: codeNoHost, + expected: assert.Error, + }, + { + code: codeNotFqdn, + expected: assert.Error, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.code, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) + return + } + + if err := req.ParseForm(); err != nil { + http.Error(rw, "failed to parse form data", http.StatusBadRequest) + return + } + + if req.PostForm.Encode() != "hostname=_acme-challenge.example.com&password=secret&txt=foo" { + http.Error(rw, "invalid form data", http.StatusBadRequest) + return + } + + _, _ = rw.Write([]byte(test.code)) + }) + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + client := NewClient(map[string]string{"example.com": "secret"}) + client.baseURL = server.URL + + err := client.UpdateTxtRecord("example.com", "foo") + test.expected(t, err) + }) + } +}