From 3da04e69ccf500a6559c37a8b690175aec0f751f Mon Sep 17 00:00:00 2001 From: alexeiser Date: Sun, 25 Apr 2021 02:37:35 -0700 Subject: [PATCH] Add DNS provider for Sonic (#1392) --- README.md | 8 +- cmd/zz_gen_cmd_dnshelp.go | 23 ++++ docs/content/dns/zz_gen_sonic.md | 81 +++++++++++ providers/dns/dns_providers.go | 3 + providers/dns/sonic/internal/client.go | 100 ++++++++++++++ providers/dns/sonic/internal/client_test.go | 60 +++++++++ providers/dns/sonic/sonic.go | 127 ++++++++++++++++++ providers/dns/sonic/sonic.toml | 45 +++++++ providers/dns/sonic/sonic_test.go | 141 ++++++++++++++++++++ 9 files changed, 584 insertions(+), 4 deletions(-) create mode 100644 docs/content/dns/zz_gen_sonic.md create mode 100644 providers/dns/sonic/internal/client.go create mode 100644 providers/dns/sonic/internal/client_test.go create mode 100644 providers/dns/sonic/sonic.go create mode 100644 providers/dns/sonic/sonic.toml create mode 100644 providers/dns/sonic/sonic_test.go diff --git a/README.md b/README.md index 471894bd..3b03a739 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,9 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). | [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) | [Njalla](https://go-acme.github.io/lego/dns/njalla/) | [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/) | [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) | -| [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [WEDOS](https://go-acme.github.io/lego/dns/wedos/) | [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/) | | | +| [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/) | [Sonic](https://go-acme.github.io/lego/dns/sonic/) | +| [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/) | +| [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [WEDOS](https://go-acme.github.io/lego/dns/wedos/) | +| [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 4142df71..b88123ac 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -88,6 +88,7 @@ func allDNSCodes() string { "scaleway", "selectel", "servercow", + "sonic", "stackpath", "transip", "vegadns", @@ -1684,6 +1685,28 @@ func displayDNSHelp(name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/servercow`) + case "sonic": + // generated from: providers/dns/sonic/sonic.toml + ew.writeln(`Configuration for Sonic.`) + ew.writeln(`Code: 'sonic'`) + ew.writeln(`Since: 'v4.4.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "SONIC_API_KEY": API Key`) + ew.writeln(` - "SONIC_USER_ID": User ID`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "SONIC_HTTP_TIMEOUT": API request timeout`) + ew.writeln(` - "SONIC_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "SONIC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "SONIC_SEQUENCE_INTERVAL": Interval between iteration`) + ew.writeln(` - "SONIC_TTL": The TTL of the TXT record used for the DNS challenge`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/sonic`) + case "stackpath": // generated from: providers/dns/stackpath/stackpath.toml ew.writeln(`Configuration for Stackpath.`) diff --git a/docs/content/dns/zz_gen_sonic.md b/docs/content/dns/zz_gen_sonic.md new file mode 100644 index 00000000..cd0def5a --- /dev/null +++ b/docs/content/dns/zz_gen_sonic.md @@ -0,0 +1,81 @@ +--- +title: "Sonic" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: sonic +--- + + + + + +Since: v4.4.0 + +Configuration for [Sonic](https://www.sonic.com/). + + + + +- Code: `sonic` + +Here is an example bash command using the Sonic provider: + +```bash +SONIC_USER_ID=12345 \ +SONIC_API_KEY=4d6fbf2f9ab0fa11697470918d37625851fc0c51 \ +lego --email myemail@example.com --dns sonic --domains my.example.org run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `SONIC_API_KEY` | API Key | +| `SONIC_USER_ID` | User ID | + +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). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `SONIC_HTTP_TIMEOUT` | API request timeout | +| `SONIC_POLLING_INTERVAL` | Time between DNS propagation check | +| `SONIC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `SONIC_SEQUENCE_INTERVAL` | Interval between iteration | +| `SONIC_TTL` | The TTL of the TXT record used for the DNS challenge | + +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). + +## API keys + +The API keys must be generated by calling the `dyndns/api_key` endpoint. + +Example: + +```bash +$ curl -X POST -H "Content-Type: application/json" --data '{"username":"notarealuser","password":"notarealpassword","hostname":"example.com"}' https://public-api.sonic.net/dyndns/api_key +{"userid":"12345","apikey":"4d6fbf2f9ab0fa11697470918d37625851fc0c51","result":200,"message":"OK"} +``` + +See https://public-api.sonic.net/dyndns/#requesting_an_api_key for additional details. + +This `userid` and `apikey` combo allow modifications to any DNS entries connected to the managed domain (hostname). + +Hostname should be the toplevel domain managed e.g `example.com` not `www.example.com`. + + + +## More information + +- [API documentation](https://public-api.sonic.net/dyndns/) + + + + diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 36ebfd09..bf8ba05c 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -79,6 +79,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/scaleway" "github.com/go-acme/lego/v4/providers/dns/selectel" "github.com/go-acme/lego/v4/providers/dns/servercow" + "github.com/go-acme/lego/v4/providers/dns/sonic" "github.com/go-acme/lego/v4/providers/dns/stackpath" "github.com/go-acme/lego/v4/providers/dns/transip" "github.com/go-acme/lego/v4/providers/dns/vegadns" @@ -245,6 +246,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return selectel.NewDNSProvider() case "servercow": return servercow.NewDNSProvider() + case "sonic": + return sonic.NewDNSProvider() case "stackpath": return stackpath.NewDNSProvider() case "transip": diff --git a/providers/dns/sonic/internal/client.go b/providers/dns/sonic/internal/client.go new file mode 100644 index 00000000..ad809061 --- /dev/null +++ b/providers/dns/sonic/internal/client.go @@ -0,0 +1,100 @@ +package internal + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "time" +) + +const baseURL = "https://public-api.sonic.net/dyndns" + +type APIResponse struct { + Message string `json:"message"` + Result int `json:"result"` +} + +// Record holds the Sonic API representation of a Domain Record. +type Record struct { + UserID string `json:"userid"` + APIKey string `json:"apikey"` + Hostname string `json:"hostname"` + Value string `json:"value"` + TTL int `json:"ttl"` + Type string `json:"type"` +} + +// Client Sonic client. +type Client struct { + userID string + apiKey string + baseURL string + HTTPClient *http.Client +} + +// NewClient creates a Client. +func NewClient(userID, apiKey string) (*Client, error) { + if userID == "" || apiKey == "" { + return nil, errors.New("credentials are missing") + } + + return &Client{ + userID: userID, + apiKey: apiKey, + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// SetRecord creates or updates a TXT records. +// Sonic does not provide a delete record API endpoint. +// https://public-api.sonic.net/dyndns#updating_or_adding_host_records +func (c *Client) SetRecord(hostname string, value string, ttl int) error { + payload := &Record{ + UserID: c.userID, + APIKey: c.apiKey, + Hostname: hostname, + Value: value, + TTL: ttl, + Type: "TXT", + } + + body, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPut, c.baseURL+"/host", bytes.NewReader(body)) + if err != nil { + return err + } + + req.Header.Set("content-type", "application/json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + + defer func() { _ = resp.Body.Close() }() + + raw, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + r := APIResponse{} + err = json.Unmarshal(raw, &r) + if err != nil { + return fmt.Errorf("failed to unmarshal response: %w: %s", err, string(raw)) + } + + if r.Result != 200 { + return fmt.Errorf("API response code: %d, %s", r.Result, r.Message) + } + + return nil +} diff --git a/providers/dns/sonic/internal/client_test.go b/providers/dns/sonic/internal/client_test.go new file mode 100644 index 00000000..6317e16d --- /dev/null +++ b/providers/dns/sonic/internal/client_test.go @@ -0,0 +1,60 @@ +package internal + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func setup(t *testing.T, body string) *Client { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + mux.HandleFunc("/host", func(rw http.ResponseWriter, req *http.Request) { + _, _ = fmt.Fprintln(rw, body) + }) + + client, err := NewClient("foo", "secret") + require.NoError(t, err) + + client.baseURL = server.URL + + return client +} + +func TestClient_SetRecord(t *testing.T) { + testCases := []struct { + desc string + response string + assert require.ErrorAssertionFunc + }{ + { + desc: "success", + response: `{"message":"OK","result":200}`, + assert: require.NoError, + }, + { + desc: "failure", + response: `{"message":"Not Found : the information you requested was not found.","result":404}`, + assert: require.Error, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + client := setup(t, test.response) + + err := client.SetRecord("example.com", "txttxttxt", 10) + test.assert(t, err) + }) + } +} diff --git a/providers/dns/sonic/sonic.go b/providers/dns/sonic/sonic.go new file mode 100644 index 00000000..3883cb9f --- /dev/null +++ b/providers/dns/sonic/sonic.go @@ -0,0 +1,127 @@ +// Package sonic implements a DNS provider for solving the DNS-01 challenge using Sonic. +package sonic + +import ( + "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/sonic/internal" +) + +// Environment variables names. +const ( + envNamespace = "SONIC_" + + EnvUserID = envNamespace + "USER_ID" + EnvAPIKey = envNamespace + "API_KEY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + UserID string + APIKey string + HTTPClient *http.Client + PropagationTimeout time.Duration + PollingInterval time.Duration + SequenceInterval time.Duration + TTL int +} + +// 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), + SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for Sonic. +// Credentials must be passed in the environment variables: +// SONIC_USERID and SONIC_APIKEY. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvUserID, EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("sonic: %w", err) + } + + config := NewDefaultConfig() + config.UserID = values[EnvUserID] + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Sonic. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("sonic: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.UserID, config.APIKey) + if err != nil { + return nil, fmt.Errorf("sonic: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + return &DNSProvider{client: client, config: config}, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domainName, token, keyAuth string) error { + fqdn, value := dns01.GetRecord(domainName, keyAuth) + + err := d.client.SetRecord(dns01.UnFqdn(fqdn), value, d.config.TTL) + if err != nil { + return fmt.Errorf("sonic: unable to create record for %s: %w", fqdn, err) + } + + return nil +} + +// CleanUp removes the TXT records matching the specified parameters. +func (d *DNSProvider) CleanUp(domainName, token, keyAuth string) error { + fqdn, _ := dns01.GetRecord(domainName, keyAuth) + + err := d.client.SetRecord(dns01.UnFqdn(fqdn), "_", d.config.TTL) + if err != nil { + return fmt.Errorf("sonic: unable to clean record for %s: %w", fqdn, 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 +} diff --git a/providers/dns/sonic/sonic.toml b/providers/dns/sonic/sonic.toml new file mode 100644 index 00000000..c39d17b6 --- /dev/null +++ b/providers/dns/sonic/sonic.toml @@ -0,0 +1,45 @@ +Name = "Sonic" +Description = '''''' +URL = "https://www.sonic.com/" +Code = "sonic" +Since = "v4.4.0" + +Example = ''' +SONIC_USER_ID=12345 \ +SONIC_API_KEY=4d6fbf2f9ab0fa11697470918d37625851fc0c51 \ +lego --email myemail@example.com --dns sonic --domains my.example.org run +''' + +Additional = ''' +## API keys + +The API keys must be generated by calling the `dyndns/api_key` endpoint. + +Example: + +```bash +$ curl -X POST -H "Content-Type: application/json" --data '{"username":"notarealuser","password":"notarealpassword","hostname":"example.com"}' https://public-api.sonic.net/dyndns/api_key +{"userid":"12345","apikey":"4d6fbf2f9ab0fa11697470918d37625851fc0c51","result":200,"message":"OK"} +``` + +See https://public-api.sonic.net/dyndns/#requesting_an_api_key for additional details. + +This `userid` and `apikey` combo allow modifications to any DNS entries connected to the managed domain (hostname). + +Hostname should be the toplevel domain managed e.g `example.com` not `www.example.com`. +''' + +[Configuration] + [Configuration.Credentials] + SONIC_USER_ID = "User ID" + SONIC_API_KEY = "API Key" + [Configuration.Additional] + SONIC_POLLING_INTERVAL = "Time between DNS propagation check" + SONIC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + SONIC_TTL = "The TTL of the TXT record used for the DNS challenge" + SONIC_HTTP_TIMEOUT = "API request timeout" + SONIC_SEQUENCE_INTERVAL = "Interval between iteration" + +[Links] + API = "https://public-api.sonic.net/dyndns/" + diff --git a/providers/dns/sonic/sonic_test.go b/providers/dns/sonic/sonic_test.go new file mode 100644 index 00000000..41923271 --- /dev/null +++ b/providers/dns/sonic/sonic_test.go @@ -0,0 +1,141 @@ +package sonic + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAPIKey, EnvUserID). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvUserID: "dummy", + EnvAPIKey: "dummy", + }, + }, + { + desc: "missing all credentials", + envVars: map[string]string{}, + expected: "sonic: some credentials information are missing: SONIC_USER_ID,SONIC_API_KEY", + }, + { + desc: "no userid", + envVars: map[string]string{ + EnvAPIKey: "dummy", + }, + expected: "sonic: some credentials information are missing: SONIC_USER_ID", + }, + { + desc: "no apikey", + envVars: map[string]string{ + EnvUserID: "dummy", + }, + expected: `sonic: some credentials information are missing: SONIC_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) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + userID string + apiKey string + expected string + }{ + { + desc: "success", + userID: "dummy", + apiKey: "dummy", + }, + { + desc: "missing all credentials", + expected: "sonic: credentials are missing", + }, + { + desc: "missing userid", + apiKey: "dummy", + expected: "sonic: credentials are missing", + }, + { + desc: "missing apikey", + userID: "dummy", + expected: "sonic: credentials are missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.UserID = test.userID + config.APIKey = test.apiKey + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + 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() + assert.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + assert.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + provider, err := NewDNSProvider() + assert.NoError(t, err) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + assert.NoError(t, err) +}