From d2e526e8dde83836ae3e2fc95e93b78afa6b51da Mon Sep 17 00:00:00 2001 From: Jens L Date: Fri, 27 Aug 2021 12:47:28 +0200 Subject: [PATCH] Add DNS provider for nicmanager (#1473) Co-authored-by: Fernandez Ludovic --- README.md | 16 +- cmd/zz_gen_cmd_dnshelp.go | 26 +++ docs/content/dns/zz_gen_nicmanager.md | 89 ++++++++ providers/dns/dns_providers.go | 3 + providers/dns/nicmanager/internal/client.go | 185 ++++++++++++++++ .../dns/nicmanager/internal/client_test.go | 145 +++++++++++++ .../nicmanager/internal/fixtures/error.json | 3 + .../nicmanager/internal/fixtures/zone.json | 51 +++++ providers/dns/nicmanager/internal/types.go | 34 +++ providers/dns/nicmanager/nicmanager.go | 200 ++++++++++++++++++ providers/dns/nicmanager/nicmanager.toml | 52 +++++ providers/dns/nicmanager/nicmanager_test.go | 182 ++++++++++++++++ 12 files changed, 978 insertions(+), 8 deletions(-) create mode 100644 docs/content/dns/zz_gen_nicmanager.md create mode 100644 providers/dns/nicmanager/internal/client.go create mode 100644 providers/dns/nicmanager/internal/client_test.go create mode 100644 providers/dns/nicmanager/internal/fixtures/error.json create mode 100644 providers/dns/nicmanager/internal/fixtures/zone.json create mode 100644 providers/dns/nicmanager/internal/types.go create mode 100644 providers/dns/nicmanager/nicmanager.go create mode 100644 providers/dns/nicmanager/nicmanager.toml create mode 100644 providers/dns/nicmanager/nicmanager_test.go diff --git a/README.md b/README.md index c4c5ee80..75ef0f2a 100644 --- a/README.md +++ b/README.md @@ -62,14 +62,14 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). | [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/) | [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/) | -| [Porkbun](https://go-acme.github.io/lego/dns/porkbun/) | [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/) | [Simply.com](https://go-acme.github.io/lego/dns/simply/) | [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/) | | +| [Netcup](https://go-acme.github.io/lego/dns/netcup/) | [Netlify](https://go-acme.github.io/lego/dns/netlify/) | [Nicmanager](https://go-acme.github.io/lego/dns/nicmanager/) | [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/) | [Porkbun](https://go-acme.github.io/lego/dns/porkbun/) | [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/) | [Simply.com](https://go-acme.github.io/lego/dns/simply/) | +| [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 8c5a2161..e41a8503 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -78,6 +78,7 @@ func allDNSCodes() string { "namesilo", "netcup", "netlify", + "nicmanager", "nifcloud", "njalla", "ns1", @@ -1472,6 +1473,31 @@ func displayDNSHelp(name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/netlify`) + case "nicmanager": + // generated from: providers/dns/nicmanager/nicmanager.toml + ew.writeln(`Configuration for Nicmanager.`) + ew.writeln(`Code: 'nicmanager'`) + ew.writeln(`Since: 'v4.5.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "NICMANAGER_API_EMAIL": Email-based login`) + ew.writeln(` - "NICMANAGER_API_LOGIN": Login, used for Username-based login`) + ew.writeln(` - "NICMANAGER_API_PASSWORD": Password, always required`) + ew.writeln(` - "NICMANAGER_API_USERNAME": Username, used for Username-based login`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "NICMANAGER_API_MODE": mode: 'anycast' or 'zone' (default: 'anycast')`) + ew.writeln(` - "NICMANAGER_API_OTP": TOTP Secret (optional)`) + ew.writeln(` - "NICMANAGER_HTTP_TIMEOUT": API request timeout`) + ew.writeln(` - "NICMANAGER_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "NICMANAGER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "NICMANAGER_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/nicmanager`) + case "nifcloud": // generated from: providers/dns/nifcloud/nifcloud.toml ew.writeln(`Configuration for NIFCloud.`) diff --git a/docs/content/dns/zz_gen_nicmanager.md b/docs/content/dns/zz_gen_nicmanager.md new file mode 100644 index 00000000..be0e7d79 --- /dev/null +++ b/docs/content/dns/zz_gen_nicmanager.md @@ -0,0 +1,89 @@ +--- +title: "Nicmanager" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: nicmanager +--- + + + + + +Since: v4.5.0 + +Configuration for [Nicmanager](https://www.nicmanager.com/). + + + + +- Code: `nicmanager` + +Here is an example bash command using the Nicmanager provider: + +```bash +## Login using email + +NICMANAGER_API_EMAIL = "foo@bar.baz" \ +NICMANAGER_API_PASSWORD = "password" \ + +# Optionally, if your account has TOTP enabled, set the secret here +NICMANAGER_API_OTP = "long-secret" \ + +lego --email myemail@example.com --dns nicmanager --domains my.example.org run + +## Login using account name + username + +NICMANAGER_API_LOGIN = "myaccount" \ +NICMANAGER_API_USERNAME = "myuser" \ +NICMANAGER_API_PASSWORD = "password" \ + +# Optionally, if your account has TOTP enabled, set the secret here +NICMANAGER_API_OTP = "long-secret" \ + +lego --email myemail@example.com --dns nicmanager --domains my.example.org run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `NICMANAGER_API_EMAIL` | Email-based login | +| `NICMANAGER_API_LOGIN` | Login, used for Username-based login | +| `NICMANAGER_API_PASSWORD` | Password, always required | +| `NICMANAGER_API_USERNAME` | Username, used for Username-based login | + +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 | +|--------------------------------|-------------| +| `NICMANAGER_API_MODE` | mode: 'anycast' or 'zone' (default: 'anycast') | +| `NICMANAGER_API_OTP` | TOTP Secret (optional) | +| `NICMANAGER_HTTP_TIMEOUT` | API request timeout | +| `NICMANAGER_POLLING_INTERVAL` | Time between DNS propagation check | +| `NICMANAGER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `NICMANAGER_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). + +## Description + +You can login using your account name + username or using your email address. +Optionally if TOTP is configured for your account, set `NICMANAGER_API_OTP`. + + + +## More information + +- [API documentation](https://api.nicmanager.com/docs/v1/) + + + + diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index dc79aa7d..a2958639 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -69,6 +69,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/namesilo" "github.com/go-acme/lego/v4/providers/dns/netcup" "github.com/go-acme/lego/v4/providers/dns/netlify" + "github.com/go-acme/lego/v4/providers/dns/nicmanager" "github.com/go-acme/lego/v4/providers/dns/nifcloud" "github.com/go-acme/lego/v4/providers/dns/njalla" "github.com/go-acme/lego/v4/providers/dns/ns1" @@ -234,6 +235,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return netcup.NewDNSProvider() case "netlify": return netlify.NewDNSProvider() + case "nicmanager": + return nicmanager.NewDNSProvider() case "nifcloud": return nifcloud.NewDNSProvider() case "njalla": diff --git a/providers/dns/nicmanager/internal/client.go b/providers/dns/nicmanager/internal/client.go new file mode 100644 index 00000000..31e35fb2 --- /dev/null +++ b/providers/dns/nicmanager/internal/client.go @@ -0,0 +1,185 @@ +package internal + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "path" + "strconv" + "time" + + "github.com/pquerna/otp/totp" +) + +const ( + defaultBaseURL = "https://api.nicmanager.com/v1" + headerTOTPToken = "X-Auth-Token" +) + +// Modes. +const ( + ModeAnycast = "anycast" + ModeZone = "zone" +) + +// Options the Client options. +type Options struct { + Login string + Username string + + Email string + + Password string + OTP string + + Mode string +} + +// Client a nicmanager DNS client. +type Client struct { + HTTPClient *http.Client + baseURL *url.URL + + username string + password string + otp string + + mode string +} + +// NewClient create a new Client. +func NewClient(opts Options) *Client { + c := &Client{ + mode: ModeAnycast, + username: opts.Email, + password: opts.Password, + otp: opts.OTP, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + } + + c.baseURL, _ = url.Parse(defaultBaseURL) + + if opts.Mode != "" { + c.mode = opts.Mode + } + + if opts.Login != "" && opts.Username != "" { + c.username = fmt.Sprintf("%s.%s", opts.Login, opts.Username) + } + + return c +} + +func (c Client) GetZone(name string) (*Zone, error) { + resp, err := c.do(http.MethodGet, name, nil) + if err != nil { + return nil, err + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= http.StatusBadRequest { + b, _ := ioutil.ReadAll(resp.Body) + + msg := APIError{StatusCode: resp.StatusCode} + if err = json.Unmarshal(b, &msg); err != nil { + return nil, fmt.Errorf("failed to get zone info for %s", name) + } + + return nil, msg + } + + var zone Zone + err = json.NewDecoder(resp.Body).Decode(&zone) + if err != nil { + return nil, err + } + + return &zone, nil +} + +func (c Client) AddRecord(zone string, req RecordCreateUpdate) error { + resp, err := c.do(http.MethodPost, path.Join(zone, "records"), req) + if err != nil { + return err + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusAccepted { + b, _ := ioutil.ReadAll(resp.Body) + + msg := APIError{StatusCode: resp.StatusCode} + if err = json.Unmarshal(b, &msg); err != nil { + return fmt.Errorf("records create should've returned %d but returned %d", http.StatusAccepted, resp.StatusCode) + } + + return msg + } + + return nil +} + +func (c Client) DeleteRecord(zone string, record int) error { + resp, err := c.do(http.MethodDelete, path.Join(zone, "records", strconv.Itoa(record)), nil) + if err != nil { + return err + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusAccepted { + b, _ := ioutil.ReadAll(resp.Body) + + msg := APIError{StatusCode: resp.StatusCode} + if err = json.Unmarshal(b, &msg); err != nil { + return fmt.Errorf("records delete should've returned %d but returned %d", http.StatusAccepted, resp.StatusCode) + } + + return msg + } + + return nil +} + +func (c Client) do(method, uri string, body interface{}) (*http.Response, error) { + var reqBody io.Reader + if body != nil { + jsonValue, err := json.Marshal(body) + if err != nil { + return nil, err + } + + reqBody = bytes.NewBuffer(jsonValue) + } + + endpoint, err := c.baseURL.Parse(path.Join(c.baseURL.Path, c.mode, uri)) + if err != nil { + return nil, err + } + + r, err := http.NewRequest(method, endpoint.String(), reqBody) + if err != nil { + return nil, err + } + + r.Header.Set("Accept", "application/json") + r.Header.Set("Content-Type", "application/json") + + r.SetBasicAuth(c.username, c.password) + + if c.otp != "" { + tan, err := totp.GenerateCode(c.otp, time.Now()) + if err != nil { + return nil, err + } + + r.Header.Set(headerTOTPToken, tan) + } + + return c.HTTPClient.Do(r) +} diff --git a/providers/dns/nicmanager/internal/client_test.go b/providers/dns/nicmanager/internal/client_test.go new file mode 100644 index 00000000..3823020b --- /dev/null +++ b/providers/dns/nicmanager/internal/client_test.go @@ -0,0 +1,145 @@ +package internal + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_GetZone(t *testing.T) { + client := setupTest(t, "/anycast/nicmanager-anycastdns4.net", testHandler(http.MethodGet, http.StatusOK, "zone.json")) + + zone, err := client.GetZone("nicmanager-anycastdns4.net") + require.NoError(t, err) + + expected := &Zone{ + Name: "nicmanager-anycastdns4.net", + Active: true, + Records: []Record{ + { + ID: 186, + Name: "nicmanager-anycastdns4.net", + Type: "A", + Content: "123.123.123.123", + TTL: 3600, + }, + }, + } + + assert.Equal(t, expected, zone) +} + +func TestClient_GetZone_error(t *testing.T) { + client := setupTest(t, "/anycast/foo", testHandler(http.MethodGet, http.StatusNotFound, "error.json")) + + _, err := client.GetZone("foo") + require.Error(t, err) +} + +func TestClient_AddRecord(t *testing.T) { + client := setupTest(t, "/anycast/zonedomain.tld/records", testHandler(http.MethodPost, http.StatusAccepted, "error.json")) + + record := RecordCreateUpdate{ + Type: "TXT", + Name: "lego", + Value: "content", + TTL: 3600, + } + + err := client.AddRecord("zonedomain.tld", record) + require.NoError(t, err) +} + +func TestClient_AddRecord_error(t *testing.T) { + client := setupTest(t, "/anycast/zonedomain.tld", testHandler(http.MethodPost, http.StatusUnauthorized, "error.json")) + + record := RecordCreateUpdate{ + Type: "TXT", + Name: "zonedomain.tld", + Value: "content", + TTL: 3600, + } + + err := client.AddRecord("zonedomain.tld", record) + require.Error(t, err) +} + +func TestClient_DeleteRecord(t *testing.T) { + client := setupTest(t, "/anycast/zonedomain.tld/records/6", testHandler(http.MethodDelete, http.StatusAccepted, "error.json")) + + err := client.DeleteRecord("zonedomain.tld", 6) + require.NoError(t, err) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + client := setupTest(t, "/anycast/zonedomain.tld/records/6", testHandler(http.MethodDelete, http.StatusNoContent, "")) + + err := client.DeleteRecord("zonedomain.tld", 7) + require.Error(t, err) +} + +func setupTest(t *testing.T, path string, handler http.Handler) *Client { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + mux.Handle(path, handler) + + opts := Options{ + Login: "foo", + Username: "bar", + Password: "foo", + OTP: "2hsn", + } + + client := NewClient(opts) + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return client +} + +func testHandler(method string, statusCode int, filename string) http.HandlerFunc { + return func(rw http.ResponseWriter, req *http.Request) { + if req.Method != method { + http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed) + return + } + + username, password, ok := req.BasicAuth() + if !ok || username != "foo.bar" || password != "foo" { + http.Error(rw, `{"message":"Unauthenticated"}`, http.StatusUnauthorized) + return + } + + rw.WriteHeader(statusCode) + + if statusCode == http.StatusNoContent { + return + } + + file, err := os.Open(filepath.Join("fixtures", filename)) + if err != nil { + http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) + return + } + + defer func() { _ = file.Close() }() + + _, err = io.Copy(rw, file) + if err != nil { + http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) + return + } + } +} diff --git a/providers/dns/nicmanager/internal/fixtures/error.json b/providers/dns/nicmanager/internal/fixtures/error.json new file mode 100644 index 00000000..52d71577 --- /dev/null +++ b/providers/dns/nicmanager/internal/fixtures/error.json @@ -0,0 +1,3 @@ +{ + "message": "Not Found" +} diff --git a/providers/dns/nicmanager/internal/fixtures/zone.json b/providers/dns/nicmanager/internal/fixtures/zone.json new file mode 100644 index 00000000..1ca4f4c7 --- /dev/null +++ b/providers/dns/nicmanager/internal/fixtures/zone.json @@ -0,0 +1,51 @@ +{ + "order_id": 9053, + "name": "nicmanager-anycastdns4.net", + "order_status": "active", + "event_status": "done", + "active": true, + "dnssec": "inactive", + "master1": null, + "master2": null, + "soa": { + "primary": "ns1.nic53.net", + "mail": "hostmaster.nicmanager.de", + "serial": 1481109046, + "refresh": 14400, + "retry": 1800, + "expire": 1209600, + "default": 3600, + "ttl": 86400 + }, + "updated_datetime": "2016-09-02T13:52:18Z", + "order_datetime": "2016-09-02T13:52:18Z", + "records": [ + { + "id": 186, + "name": "nicmanager-anycastdns4.net", + "type": "A", + "content": "123.123.123.123", + "ttl": 3600, + "priority": 0, + "active": true, + "updated_datetime": "2016-09-02T13:52:18Z" + } + ], + "redirects": [ + { + "id": 10, + "name": "test.nicmanager-anycastdns4.net", + "target": "https:\/\/www.nicmanager.com\/", + "type": "frame", + "updated_datetime": "2016-12-05T14:40:47Z", + "request_uri": true, + "ssl": false, + "meta": { + "title": "My frame", + "keywords": "foo,bar", + "description": "Just a Test" + }, + "subdomain": "test" + } + ] +} diff --git a/providers/dns/nicmanager/internal/types.go b/providers/dns/nicmanager/internal/types.go new file mode 100644 index 00000000..ebfb0213 --- /dev/null +++ b/providers/dns/nicmanager/internal/types.go @@ -0,0 +1,34 @@ +package internal + +import "fmt" + +type Record struct { + ID int `json:"id"` + Name string `json:"name"` + + Type string `json:"type"` + Content string `json:"content"` + TTL int `json:"ttl"` +} + +type Zone struct { + Name string `json:"name"` + Active bool `json:"active"` + Records []Record `json:"records"` +} + +type RecordCreateUpdate struct { + Name string `json:"name"` + Value string `json:"value"` + TTL int `json:"ttl"` + Type string `json:"type"` +} + +type APIError struct { + Message string `json:"message"` + StatusCode int `json:"-"` +} + +func (a APIError) Error() string { + return fmt.Sprintf("%d: %s", a.StatusCode, a.Message) +} diff --git a/providers/dns/nicmanager/nicmanager.go b/providers/dns/nicmanager/nicmanager.go new file mode 100644 index 00000000..c485b6b5 --- /dev/null +++ b/providers/dns/nicmanager/nicmanager.go @@ -0,0 +1,200 @@ +// Package nicmanager implements a DNS provider for solving the DNS-01 challenge using nicmanager DNS. +package nicmanager + +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/nicmanager/internal" +) + +// Environment variables names. +const ( + envNamespace = "NICMANAGER_" + + EnvLogin = envNamespace + "API_LOGIN" + EnvUsername = envNamespace + "API_USERNAME" + EnvEmail = envNamespace + "API_EMAIL" + EnvPassword = envNamespace + "API_PASSWORD" + EnvOTP = envNamespace + "API_OTP" + EnvMode = envNamespace + "MODE" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +const minTTL = 900 + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Login string + Username string + Email string + Password string + OTPSecret string + Mode string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, minTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), + 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 { + client *internal.Client + config *Config +} + +// NewDNSProvider returns a DNSProvider instance configured for nicmanager. +// Credentials must be passed in the environment variables: +// NICMANAGER_API_LOGIN, NICMANAGER_API_USERNAME +// NICMANAGER_API_EMAIL +// NICMANAGER_API_PASSWORD +// NICMANAGER_API_OTP +// NICMANAGER_API_MODE. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvPassword) + if err != nil { + return nil, fmt.Errorf("nicmanager: %w", err) + } + + config := NewDefaultConfig() + config.Password = values[EnvPassword] + + config.Mode = env.GetOrDefaultString(EnvMode, internal.ModeAnycast) + config.Username = env.GetOrFile(EnvUsername) + config.Login = env.GetOrFile(EnvLogin) + config.Email = env.GetOrFile(EnvEmail) + config.OTPSecret = env.GetOrFile(EnvOTP) + + if config.TTL < minTTL { + return nil, fmt.Errorf("TTL must be higher than %d: %d", minTTL, config.TTL) + } + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for nicmanager. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("nicmanager: the configuration of the DNS provider is nil") + } + + opts := internal.Options{ + Password: config.Password, + OTP: config.OTPSecret, + Mode: config.Mode, + } + + switch { + case config.Password == "": + return nil, errors.New("nicmanager: credentials missing") + case config.Email != "": + opts.Email = config.Email + case config.Login != "" && config.Username != "": + opts.Login = config.Login + opts.Username = config.Username + default: + return nil, errors.New("nicmanager: credentials missing") + } + + client := internal.NewClient(opts) + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + return &DNSProvider{client: client, config: config}, 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 +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + rootDomain, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain)) + if err != nil { + return fmt.Errorf("nicmanager: could not determine zone for domain %q: %w", domain, err) + } + + zone, err := d.client.GetZone(dns01.UnFqdn(rootDomain)) + if err != nil { + return fmt.Errorf("nicmanager: failed to get zone %q: %w", rootDomain, err) + } + + // The way nic manager deals with record with multiple values is that they are completely different records with unique ids + // Hence we don't check for an existing record here, but rather just create one + record := internal.RecordCreateUpdate{ + Name: fqdn, + Type: "TXT", + TTL: d.config.TTL, + Value: value, + } + + err = d.client.AddRecord(zone.Name, record) + if err != nil { + return fmt.Errorf("nicmanager: failed to create record [zone: %q, fqdn: %q]: %w", zone.Name, fqdn, err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + rootDomain, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain)) + if err != nil { + return fmt.Errorf("nicmanager: could not determine zone for domain %q: %w", domain, err) + } + + zone, err := d.client.GetZone(dns01.UnFqdn(rootDomain)) + if err != nil { + return fmt.Errorf("nicmanager: failed to get zone %q: %w", rootDomain, err) + } + + name := dns01.UnFqdn(fqdn) + + var existingRecord internal.Record + var existingRecordFound bool + for _, record := range zone.Records { + if strings.EqualFold(record.Type, "TXT") && strings.EqualFold(record.Name, name) && record.Content == value { + existingRecord = record + existingRecordFound = true + } + } + + if existingRecordFound { + err = d.client.DeleteRecord(zone.Name, existingRecord.ID) + if err != nil { + return fmt.Errorf("nicmanager: failed to delete record [zone: %q, domain: %q]: %w", zone.Name, name, err) + } + } + + return fmt.Errorf("nicmanager: no record found to cleanup") +} diff --git a/providers/dns/nicmanager/nicmanager.toml b/providers/dns/nicmanager/nicmanager.toml new file mode 100644 index 00000000..d7deca5e --- /dev/null +++ b/providers/dns/nicmanager/nicmanager.toml @@ -0,0 +1,52 @@ +Name = "Nicmanager" +Description = '''''' +URL = "https://www.nicmanager.com/" +Code = "nicmanager" +Since = "v4.5.0" + +Example = ''' +## Login using email + +NICMANAGER_API_EMAIL = "foo@bar.baz" \ +NICMANAGER_API_PASSWORD = "password" \ + +# Optionally, if your account has TOTP enabled, set the secret here +NICMANAGER_API_OTP = "long-secret" \ + +lego --email myemail@example.com --dns nicmanager --domains my.example.org run + +## Login using account name + username + +NICMANAGER_API_LOGIN = "myaccount" \ +NICMANAGER_API_USERNAME = "myuser" \ +NICMANAGER_API_PASSWORD = "password" \ + +# Optionally, if your account has TOTP enabled, set the secret here +NICMANAGER_API_OTP = "long-secret" \ + +lego --email myemail@example.com --dns nicmanager --domains my.example.org run +''' + +Additional = ''' +## Description + +You can login using your account name + username or using your email address. +Optionally if TOTP is configured for your account, set `NICMANAGER_API_OTP`. +''' + +[Configuration] + [Configuration.Credentials] + NICMANAGER_API_LOGIN = "Login, used for Username-based login" + NICMANAGER_API_USERNAME = "Username, used for Username-based login" + NICMANAGER_API_EMAIL = "Email-based login" + NICMANAGER_API_PASSWORD = "Password, always required" + [Configuration.Additional] + NICMANAGER_API_OTP = "TOTP Secret (optional)" + NICMANAGER_API_MODE = "mode: 'anycast' or 'zone' (default: 'anycast')" + NICMANAGER_POLLING_INTERVAL = "Time between DNS propagation check" + NICMANAGER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + NICMANAGER_TTL = "The TTL of the TXT record used for the DNS challenge" + NICMANAGER_HTTP_TIMEOUT = "API request timeout" + +[Links] + API = "https://api.nicmanager.com/docs/v1/" diff --git a/providers/dns/nicmanager/nicmanager_test.go b/providers/dns/nicmanager/nicmanager_test.go new file mode 100644 index 00000000..bc2f50cc --- /dev/null +++ b/providers/dns/nicmanager/nicmanager_test.go @@ -0,0 +1,182 @@ +package nicmanager + +import ( + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvUsername, EnvLogin, EnvEmail, EnvPassword, EnvOTP). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success (email)", + envVars: map[string]string{ + EnvEmail: "foo@example.com", + EnvPassword: "secret", + }, + }, + { + desc: "success (login.username)", + envVars: map[string]string{ + EnvLogin: "foo", + EnvUsername: "bar", + EnvPassword: "secret", + }, + }, + { + desc: "missing credentials", + expected: "nicmanager: some credentials information are missing: NICMANAGER_API_PASSWORD", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvEmail: "foo@example.com", + }, + expected: "nicmanager: some credentials information are missing: NICMANAGER_API_PASSWORD", + }, + { + desc: "missing username", + envVars: map[string]string{ + EnvLogin: "foo", + EnvPassword: "secret", + }, + expected: "nicmanager: credentials missing", + }, + { + desc: "missing login", + envVars: map[string]string{ + EnvUsername: "bar", + EnvPassword: "secret", + }, + expected: "nicmanager: credentials missing", + }, + } + + 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 + login string + username string + email string + password string + otpSecret string + expected string + }{ + { + desc: "success (email)", + email: "foo@example.com", + password: "secret", + }, + { + desc: "success (login.username)", + login: "john", + username: "doe", + password: "secret", + }, + { + desc: "missing credentials", + expected: "nicmanager: credentials missing", + }, + { + desc: "missing password", + email: "foo@example.com", + expected: "nicmanager: credentials missing", + }, + { + desc: "missing login", + login: "", + username: "doe", + password: "secret", + expected: "nicmanager: credentials missing", + }, + { + desc: "missing username", + login: "john", + username: "", + password: "secret", + expected: "nicmanager: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Login = test.login + config.Username = test.username + config.Email = test.email + config.Password = test.password + config.OTPSecret = test.otpSecret + + 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) + + time.Sleep(1 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +}