diff --git a/README.md b/README.md index b65e5650..5c6cf1f1 100644 --- a/README.md +++ b/README.md @@ -56,15 +56,16 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). | [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/) | [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/) | +| [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 58cf5f7b..e812540c 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -56,6 +56,7 @@ func allDNSCodes() string { "iij", "infomaniak", "inwx", + "ionos", "joker", "lightsail", "linode", @@ -990,6 +991,26 @@ func displayDNSHelp(name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/inwx`) + case "ionos": + // generated from: providers/dns/ionos/ionos.toml + ew.writeln(`Configuration for Ionos.`) + ew.writeln(`Code: 'ionos'`) + ew.writeln(`Since: 'v4.2.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "IONOS_API_KEY": API key '.' https://developer.hosting.ionos.com/docs/getstarted`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "IONOS_HTTP_TIMEOUT": API request timeout`) + ew.writeln(` - "IONOS_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "IONOS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "IONOS_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/ionos`) + case "joker": // generated from: providers/dns/joker/joker.toml ew.writeln(`Configuration for Joker.`) diff --git a/docs/content/dns/zz_gen_ionos.md b/docs/content/dns/zz_gen_ionos.md new file mode 100644 index 00000000..abe960e7 --- /dev/null +++ b/docs/content/dns/zz_gen_ionos.md @@ -0,0 +1,62 @@ +--- +title: "Ionos" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: ionos +--- + + + + + +Since: v4.2.0 + +Configuration for [Ionos](https://ionos.com). + + + + +- Code: `ionos` + +Here is an example bash command using the Ionos provider: + +```bash +IONOS_API_KEY=xxxxxxxx \ +lego --email myemail@example.com --dns ionos --domains my.example.org run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `IONOS_API_KEY` | API key `.` https://developer.hosting.ionos.com/docs/getstarted | + +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 | +|--------------------------------|-------------| +| `IONOS_HTTP_TIMEOUT` | API request timeout | +| `IONOS_POLLING_INTERVAL` | Time between DNS propagation check | +| `IONOS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `IONOS_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). + + + + +## More information + +- [API documentation](https://developer.hosting.ionos.com/docs/dns) + + + + diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 4469a767..41c3404d 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -47,6 +47,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/iij" "github.com/go-acme/lego/v4/providers/dns/infomaniak" "github.com/go-acme/lego/v4/providers/dns/inwx" + "github.com/go-acme/lego/v4/providers/dns/ionos" "github.com/go-acme/lego/v4/providers/dns/joker" "github.com/go-acme/lego/v4/providers/dns/lightsail" "github.com/go-acme/lego/v4/providers/dns/linode" @@ -173,6 +174,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return infomaniak.NewDNSProvider() case "inwx": return inwx.NewDNSProvider() + case "ionos": + return ionos.NewDNSProvider() case "joker": return joker.NewDNSProvider() case "lightsail": diff --git a/providers/dns/ionos/internal/client.go b/providers/dns/ionos/internal/client.go new file mode 100644 index 00000000..d1ab0d9f --- /dev/null +++ b/providers/dns/ionos/internal/client.go @@ -0,0 +1,182 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "path" + + querystring "github.com/google/go-querystring/query" +) + +// defaultBaseURL represents the API endpoint to call. +const defaultBaseURL = "https://api.hosting.ionos.com/dns" + +// Client Ionos API client. +type Client struct { + HTTPClient *http.Client + BaseURL *url.URL + + apiKey string +} + +// NewClient creates a new Client. +func NewClient(apiKey string) (*Client, error) { + baseURL, err := url.Parse(defaultBaseURL) + if err != nil { + return nil, err + } + + return &Client{ + HTTPClient: http.DefaultClient, + BaseURL: baseURL, + apiKey: apiKey, + }, nil +} + +// ListZones gets all zones. +func (c *Client) ListZones(ctx context.Context) ([]Zone, error) { + req, err := c.makeRequest(ctx, http.MethodGet, "/v1/zones", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call API: %w", err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, readError(resp.Body, resp.StatusCode) + } + + var zones []Zone + err = json.NewDecoder(resp.Body).Decode(&zones) + if err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return zones, nil +} + +// ReplaceRecords replaces the some records of a zones. +func (c *Client) ReplaceRecords(ctx context.Context, zoneID string, records []Record) error { + body, err := json.Marshal(records) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + + req, err := c.makeRequest(ctx, http.MethodPatch, path.Join("/v1/zones", zoneID), bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("failed to call API: %w", err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return readError(resp.Body, resp.StatusCode) + } + + return nil +} + +// GetRecords gets the records of a zones. +func (c *Client) GetRecords(ctx context.Context, zoneID string, filter *RecordsFilter) ([]Record, error) { + req, err := c.makeRequest(ctx, http.MethodGet, path.Join("/v1/zones", zoneID), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + if filter != nil { + v, errQ := querystring.Values(filter) + if errQ != nil { + return nil, errQ + } + + req.URL.RawQuery = v.Encode() + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call API: %w", err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, readError(resp.Body, resp.StatusCode) + } + + var zone CustomerZone + err = json.NewDecoder(resp.Body).Decode(&zone) + if err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return zone.Records, nil +} + +// RemoveRecord removes a record. +func (c *Client) RemoveRecord(ctx context.Context, zoneID, recordID string) error { + req, err := c.makeRequest(ctx, http.MethodDelete, path.Join("/v1/zones", zoneID, "records", recordID), nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("failed to call API: %w", err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return readError(resp.Body, resp.StatusCode) + } + + return nil +} + +func (c *Client) makeRequest(ctx context.Context, method, uri string, body io.Reader) (*http.Request, error) { + endpoint, err := c.BaseURL.Parse(path.Join(c.BaseURL.Path, uri)) + if err != nil { + return nil, fmt.Errorf("failed to parse endpoint: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", c.apiKey) + + return req, nil +} + +func readError(body io.Reader, statusCode int) error { + bodyBytes, _ := ioutil.ReadAll(body) + + cErr := &ClientError{StatusCode: statusCode} + + err := json.Unmarshal(bodyBytes, &cErr.errors) + if err != nil { + cErr.message = string(bodyBytes) + return cErr + } + + return cErr +} diff --git a/providers/dns/ionos/internal/client_test.go b/providers/dns/ionos/internal/client_test.go new file mode 100644 index 00000000..7dcd91e8 --- /dev/null +++ b/providers/dns/ionos/internal/client_test.go @@ -0,0 +1,185 @@ +package internal + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_ListZones(t *testing.T) { + mux, client := setupTest(t) + + mux.HandleFunc("/v1/zones", mockHandler(http.MethodGet, http.StatusOK, "list_zones.json")) + + zones, err := client.ListZones(context.Background()) + require.NoError(t, err) + + expected := []Zone{{ + ID: "11af3414-ebba-11e9-8df5-66fbe8a334b4", + Name: "test.com", + Type: "NATIVE", + }} + + assert.Equal(t, expected, zones) +} + +func TestClient_ListZones_error(t *testing.T) { + mux, client := setupTest(t) + + mux.HandleFunc("/v1/zones", mockHandler(http.MethodGet, http.StatusUnauthorized, "list_zones_error.json")) + + zones, err := client.ListZones(context.Background()) + require.Error(t, err) + + assert.Nil(t, zones) + + var cErr *ClientError + assert.True(t, errors.As(err, &cErr)) + assert.Equal(t, http.StatusUnauthorized, cErr.StatusCode) +} + +func TestClient_GetRecords(t *testing.T) { + mux, client := setupTest(t) + + mux.HandleFunc("/v1/zones/azone01", mockHandler(http.MethodGet, http.StatusOK, "get_records.json")) + + records, err := client.GetRecords(context.Background(), "azone01", nil) + require.NoError(t, err) + + expected := []Record{{ + ID: "22af3414-abbe-9e11-5df5-66fbe8e334b4", + Name: "string", + Content: "string", + Type: "A", + }} + + assert.Equal(t, expected, records) +} + +func TestClient_GetRecords_error(t *testing.T) { + mux, client := setupTest(t) + + mux.HandleFunc("/v1/zones/azone01", mockHandler(http.MethodGet, http.StatusUnauthorized, "get_records_error.json")) + + records, err := client.GetRecords(context.Background(), "azone01", nil) + require.Error(t, err) + + assert.Nil(t, records) + + var cErr *ClientError + assert.True(t, errors.As(err, &cErr)) + assert.Equal(t, http.StatusUnauthorized, cErr.StatusCode) +} + +func TestClient_RemoveRecord(t *testing.T) { + mux, client := setupTest(t) + + mux.HandleFunc("/v1/zones/azone01/records/arecord01", mockHandler(http.MethodDelete, http.StatusOK, "")) + + err := client.RemoveRecord(context.Background(), "azone01", "arecord01") + require.NoError(t, err) +} + +func TestClient_RemoveRecord_error(t *testing.T) { + mux, client := setupTest(t) + + mux.HandleFunc("/v1/zones/azone01/records/arecord01", mockHandler(http.MethodDelete, http.StatusInternalServerError, "remove_record_error.json")) + + err := client.RemoveRecord(context.Background(), "azone01", "arecord01") + require.Error(t, err) + + var cErr *ClientError + assert.True(t, errors.As(err, &cErr)) + assert.Equal(t, http.StatusInternalServerError, cErr.StatusCode) +} + +func TestClient_ReplaceRecords(t *testing.T) { + mux, client := setupTest(t) + + mux.HandleFunc("/v1/zones/azone01", mockHandler(http.MethodPatch, http.StatusOK, "")) + + records := []Record{{ + ID: "22af3414-abbe-9e11-5df5-66fbe8e334b4", + Name: "string", + Content: "string", + Type: "A", + }} + + err := client.ReplaceRecords(context.Background(), "azone01", records) + require.NoError(t, err) +} + +func TestClient_ReplaceRecords_error(t *testing.T) { + mux, client := setupTest(t) + + mux.HandleFunc("/v1/zones/azone01", mockHandler(http.MethodPatch, http.StatusBadRequest, "replace_records_error.json")) + + records := []Record{{ + ID: "22af3414-abbe-9e11-5df5-66fbe8e334b4", + Name: "string", + Content: "string", + Type: "A", + }} + + err := client.ReplaceRecords(context.Background(), "azone01", records) + require.Error(t, err) + + var cErr *ClientError + assert.True(t, errors.As(err, &cErr)) + assert.Equal(t, http.StatusBadRequest, cErr.StatusCode) +} + +func setupTest(t *testing.T) (*http.ServeMux, *Client) { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + client, err := NewClient("secret") + require.NoError(t, err) + + client.BaseURL, _ = url.Parse(server.URL) + + return mux, client +} + +func mockHandler(method string, statusCode int, filename string) func(http.ResponseWriter, *http.Request) { + return func(rw http.ResponseWriter, req *http.Request) { + if req.Method != method { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) + return + } + + if filename == "" { + rw.WriteHeader(statusCode) + return + } + + file, err := os.Open(filepath.FromSlash(path.Join("./fixtures", filename))) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = file.Close() }() + + rw.WriteHeader(statusCode) + + _, err = io.Copy(rw, file) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + } +} diff --git a/providers/dns/ionos/internal/fixtures/get_records.json b/providers/dns/ionos/internal/fixtures/get_records.json new file mode 100644 index 00000000..7806871b --- /dev/null +++ b/providers/dns/ionos/internal/fixtures/get_records.json @@ -0,0 +1,18 @@ +{ + "id": "11af3414-ebba-11e9-8df5-66fbe8a334b4", + "name": "example-zone.de", + "type": "NATIVE", + "records": [ + { + "id": "22af3414-abbe-9e11-5df5-66fbe8e334b4", + "name": "string", + "rootName": "string", + "type": "A", + "content": "string", + "changeDate": "string", + "ttl": 0, + "prio": 0, + "disabled": false + } + ] +} diff --git a/providers/dns/ionos/internal/fixtures/get_records_error.json b/providers/dns/ionos/internal/fixtures/get_records_error.json new file mode 100644 index 00000000..dc5d95a7 --- /dev/null +++ b/providers/dns/ionos/internal/fixtures/get_records_error.json @@ -0,0 +1,6 @@ +[ + { + "code": "UNAUTHORIZED", + "message": "The customer is not authorized to do this operation." + } +] diff --git a/providers/dns/ionos/internal/fixtures/list_zones.json b/providers/dns/ionos/internal/fixtures/list_zones.json new file mode 100644 index 00000000..eb618ae7 --- /dev/null +++ b/providers/dns/ionos/internal/fixtures/list_zones.json @@ -0,0 +1,7 @@ +[ + { + "id": "11af3414-ebba-11e9-8df5-66fbe8a334b4", + "name": "test.com", + "type": "NATIVE" + } +] diff --git a/providers/dns/ionos/internal/fixtures/list_zones_error.json b/providers/dns/ionos/internal/fixtures/list_zones_error.json new file mode 100644 index 00000000..dc5d95a7 --- /dev/null +++ b/providers/dns/ionos/internal/fixtures/list_zones_error.json @@ -0,0 +1,6 @@ +[ + { + "code": "UNAUTHORIZED", + "message": "The customer is not authorized to do this operation." + } +] diff --git a/providers/dns/ionos/internal/fixtures/remove_record_error.json b/providers/dns/ionos/internal/fixtures/remove_record_error.json new file mode 100644 index 00000000..4af84682 --- /dev/null +++ b/providers/dns/ionos/internal/fixtures/remove_record_error.json @@ -0,0 +1,13 @@ +[ + { + "code": "UNAUTHORIZED", + "message": "The customer is not authorized to do this operation." + }, + { + "code": "RECORD_NOT_FOUND", + "message": "Record does not exist." + }, + { + "code": "INTERNAL_SERVER_ERROR" + } +] diff --git a/providers/dns/ionos/internal/fixtures/replace_records_error.json b/providers/dns/ionos/internal/fixtures/replace_records_error.json new file mode 100644 index 00000000..3f2917d4 --- /dev/null +++ b/providers/dns/ionos/internal/fixtures/replace_records_error.json @@ -0,0 +1,35 @@ +[ + { + "code": "INVALID_RECORD", + "message": "string", + "parameters": { + "errorRecord": { + "id": "string", + "name": "string", + "disabled": false, + "rootName": "string", + "changeDate": "string", + "type": "A", + "content": "string", + "ttl": 0, + "prio": 0 + }, + "requiredFields": [ + "string" + ], + "invalid": [ + "string" + ], + "invalidFields": [ + "string" + ] + } + }, + { + "code": "UNAUTHORIZED", + "message": "The customer is not authorized to do this operation." + }, + { + "code": "INTERNAL_SERVER_ERROR" + } +] \ No newline at end of file diff --git a/providers/dns/ionos/internal/types.go b/providers/dns/ionos/internal/types.go new file mode 100644 index 00000000..3b7acbec --- /dev/null +++ b/providers/dns/ionos/internal/types.go @@ -0,0 +1,107 @@ +package internal + +import ( + "fmt" + "strconv" +) + +// ClientError a detailed error. +type ClientError struct { + errors []Error + StatusCode int + message string +} + +func (f ClientError) Error() string { + msg := strconv.Itoa(f.StatusCode) + ": " + + if f.message != "" { + msg += f.message + ": " + } + + for i, e := range f.errors { + if i != 0 { + msg += ", " + } + + msg += e.Error() + } + + return msg +} + +func (f ClientError) Unwrap() error { + if len(f.errors) == 0 { + return nil + } + + return &f.errors[0] +} + +// Error defines model for error. +type Error struct { + // The error code. + Code string `json:"code,omitempty"` + + // The error message. + Message string `json:"message,omitempty"` +} + +func (e Error) Error() string { + return fmt.Sprintf("%s: %s", e.Code, e.Message) +} + +// Zone defines model for zone. +type Zone struct { + // The zone id. + ID string `json:"id,omitempty"` + + // The zone name. + Name string `json:"name,omitempty"` + + // Represents the possible zone types. + Type string `json:"type,omitempty"` +} + +// CustomerZone defines model for customer-zone. +type CustomerZone struct { + // The zone id. + ID string `json:"id,omitempty"` + + // The zone name + Name string `json:"name,omitempty"` + Records []Record `json:"records,omitempty"` + + // Represents the possible zone types. + Type string `json:"type,omitempty"` +} + +// Record defines model for record. +type Record struct { + ID string `json:"id,omitempty"` + + Name string `json:"name,omitempty"` + Content string `json:"content,omitempty"` + + // Time to live for the record, recommended 3600. + TTL int `json:"ttl,omitempty"` + + // Holds supported dns record types. + Type string `json:"type,omitempty"` + + Priority int `json:"prio,omitempty"` + + // When is true, the record is not visible for lookup. + Disabled bool `json:"disabled,omitempty"` +} + +type RecordsFilter struct { + // The FQDN used to filter all the record names that end with it. + Suffix string `url:"suffix,omitempty"` + + // The record names that should be included (same as name field of Record) + RecordName string `url:"recordName,omitempty"` + + // A comma-separated list of record types that should be included + RecordType string `url:"recordType,omitempty"` +} diff --git a/providers/dns/ionos/ionos.go b/providers/dns/ionos/ionos.go new file mode 100644 index 00000000..6bf6c7a5 --- /dev/null +++ b/providers/dns/ionos/ionos.go @@ -0,0 +1,200 @@ +// Package ionos implements a DNS provider for solving the DNS-01 challenge using Ionos/1&1. +package ionos + +import ( + "context" + "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/ionos/internal" +) + +const minTTL = 300 + +// Environment variables names. +const ( + envNamespace = "IONOS_" + + EnvAPIKey = envNamespace + "API_KEY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIKey string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, minTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for Ionos. +// Credentials must be passed in the environment variables: IONOS_API_KEY. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("ionos: %w", err) + } + + config := NewDefaultConfig() + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Ionos. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("ionos: the configuration of the DNS provider is nil") + } + + if config.APIKey == "" { + return nil, errors.New("ionos: credentials missing") + } + + if config.TTL < minTTL { + return nil, fmt.Errorf("ionos: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) + } + + client, err := internal.NewClient(config.APIKey) + if err != nil { + return nil, fmt.Errorf("ionos: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + return &DNSProvider{ + config: config, + client: client, + }, 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 using the specified parameters. +func (d *DNSProvider) Present(domain, _, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + ctx := context.Background() + + zones, err := d.client.ListZones(ctx) + if err != nil { + return fmt.Errorf("ionos: failed to get zones: %w", err) + } + + zone := findZone(zones, domain) + if zone == nil { + return errors.New("ionos: no matching zone found for domain") + } + + filter := &internal.RecordsFilter{ + Suffix: dns01.UnFqdn(fqdn), + RecordType: "TXT", + } + + records, err := d.client.GetRecords(ctx, zone.ID, filter) + if err != nil { + return fmt.Errorf("ionos: failed to get records (zone=%s): %w", zone.ID, err) + } + + records = append(records, internal.Record{ + Name: dns01.UnFqdn(fqdn), + Content: value, + TTL: d.config.TTL, + Type: "TXT", + }) + + err = d.client.ReplaceRecords(ctx, zone.ID, records) + if err != nil { + return fmt.Errorf("ionos: failed to create/update records (zone=%s): %w", zone.ID, err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + ctx := context.Background() + + zones, err := d.client.ListZones(ctx) + if err != nil { + return fmt.Errorf("ionos: failed to get zones: %w", err) + } + + zone := findZone(zones, domain) + if zone == nil { + return errors.New("ionos: no matching zone found for domain") + } + + filter := &internal.RecordsFilter{ + Suffix: dns01.UnFqdn(fqdn), + RecordType: "TXT", + } + + records, err := d.client.GetRecords(ctx, zone.ID, filter) + if err != nil { + return fmt.Errorf("ionos: failed to get records (zone=%s): %w", zone.ID, err) + } + + for _, record := range records { + if record.Name == dns01.UnFqdn(fqdn) && record.Content == value { + err := d.client.RemoveRecord(ctx, zone.ID, record.ID) + if err != nil { + return fmt.Errorf("ionos: failed to remove record (zone=%s, record=%s): %w", zone.ID, record.ID, err) + } + return nil + } + } + + return nil +} + +func findZone(zones []internal.Zone, domain string) *internal.Zone { + var result *internal.Zone + + for _, zone := range zones { + zone := zone + if zone.Name != "" && strings.HasSuffix(domain, zone.Name) { + if result == nil || len(zone.Name) > len(result.Name) { + result = &zone + } + } + } + + return result +} diff --git a/providers/dns/ionos/ionos.toml b/providers/dns/ionos/ionos.toml new file mode 100644 index 00000000..15668187 --- /dev/null +++ b/providers/dns/ionos/ionos.toml @@ -0,0 +1,22 @@ +Name = "Ionos" +Description = '''''' +URL = "https://ionos.com" +Code = "ionos" +Since = "v4.2.0" + +Example = ''' +IONOS_API_KEY=xxxxxxxx \ +lego --email myemail@example.com --dns ionos --domains my.example.org run +''' + +[Configuration] + [Configuration.Credentials] + IONOS_API_KEY = "API key `.` https://developer.hosting.ionos.com/docs/getstarted" + [Configuration.Additional] + IONOS_POLLING_INTERVAL = "Time between DNS propagation check" + IONOS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + IONOS_TTL = "The TTL of the TXT record used for the DNS challenge" + IONOS_HTTP_TIMEOUT = "API request timeout" + +[Links] + API = "https://developer.hosting.ionos.com/docs/dns" diff --git a/providers/dns/ionos/ionos_test.go b/providers/dns/ionos/ionos_test.go new file mode 100644 index 00000000..c6f3176b --- /dev/null +++ b/providers/dns/ionos/ionos_test.go @@ -0,0 +1,127 @@ +package ionos + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest( + EnvAPIKey). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIKey: "123", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + EnvAPIKey: "", + }, + expected: "ionos: some credentials information are missing: IONOS_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 len(test.expected) == 0 { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiKey string + tll int + expected string + }{ + { + desc: "success", + apiKey: "123", + tll: minTTL, + }, + { + desc: "missing credentials", + tll: minTTL, + expected: "ionos: credentials missing", + }, + { + desc: "invalid TTL", + apiKey: "123", + tll: 30, + expected: "ionos: invalid TTL, TTL (30) must be greater than 300", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.apiKey + config.TTL = test.tll + + p, err := NewDNSProviderConfig(config) + + if len(test.expected) == 0 { + 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) +}