diff --git a/providers/dns/easydns/easydns.go b/providers/dns/easydns/easydns.go index 40d2ec0c..20ad2754 100644 --- a/providers/dns/easydns/easydns.go +++ b/providers/dns/easydns/easydns.go @@ -15,7 +15,6 @@ import ( "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/easydns/internal" - "github.com/miekg/dns" ) // Environment variables names. @@ -117,20 +116,34 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) - apiHost, apiDomain := splitFqdn(info.EffectiveFQDN) + authZone, err := d.findZone(ctx, dns01.UnFqdn(info.EffectiveFQDN)) + if err != nil { + return fmt.Errorf("easydns: %w", err) + } + + if authZone == "" { + return fmt.Errorf("easydns: could not find zone for domain %q", domain) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("easydns: %w", err) + } record := internal.ZoneRecord{ - Domain: apiDomain, - Host: apiHost, + Domain: authZone, + Host: subDomain, Type: "TXT", Rdata: info.Value, TTL: strconv.Itoa(d.config.TTL), Priority: "0", } - recordID, err := d.client.AddRecord(context.Background(), apiDomain, record) + recordID, err := d.client.AddRecord(ctx, dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("easydns: error adding zone record: %w", err) } @@ -146,6 +159,8 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + info := dns01.GetChallengeInfo(domain, keyAuth) key := getMapKey(info.EffectiveFQDN, info.Value) @@ -158,9 +173,16 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } - _, apiDomain := splitFqdn(info.EffectiveFQDN) + authZone, err := d.findZone(ctx, dns01.UnFqdn(info.EffectiveFQDN)) + if err != nil { + return fmt.Errorf("easydns: %w", err) + } - err := d.client.DeleteRecord(context.Background(), apiDomain, recordID) + if authZone == "" { + return fmt.Errorf("easydns: could not find zone for domain %q", domain) + } + + err = d.client.DeleteRecord(ctx, dns01.UnFqdn(authZone), recordID) d.recordIDsMu.Lock() defer delete(d.recordIDs, key) @@ -185,15 +207,28 @@ func (d *DNSProvider) Sequential() time.Duration { return d.config.SequenceInterval } -func splitFqdn(fqdn string) (host, domain string) { - parts := dns.SplitDomainName(fqdn) - length := len(parts) - - host = strings.Join(parts[0:length-2], ".") - domain = strings.Join(parts[length-2:length], ".") - return -} - func getMapKey(fqdn, value string) string { return fqdn + "|" + value } + +func (d *DNSProvider) findZone(ctx context.Context, domain string) (string, error) { + var errAll error + + for { + i := strings.Index(domain, ".") + if i == -1 { + break + } + + _, err := d.client.ListZones(ctx, domain) + if err == nil { + return domain, nil + } + + errAll = errors.Join(errAll, err) + + domain = domain[i+1:] + } + + return "", errAll +} diff --git a/providers/dns/easydns/easydns_test.go b/providers/dns/easydns/easydns_test.go index ea1f854c..972ff8cd 100644 --- a/providers/dns/easydns/easydns_test.go +++ b/providers/dns/easydns/easydns_test.go @@ -147,6 +147,39 @@ func TestNewDNSProviderConfig(t *testing.T) { func TestDNSProvider_Present(t *testing.T) { provider, mux := setupTest(t) + mux.HandleFunc("/zones/records/all/example.com", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "method") + assert.Equal(t, "format=json", r.URL.RawQuery, "query") + assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) + + w.WriteHeader(http.StatusOK) + _, err := fmt.Fprintf(w, `{ + "msg": "string", + "status": 200, + "tm": 0, + "data": [{ + "id": "60898922", + "domain": "example.com", + "host": "hosta", + "ttl": "300", + "prio": "0", + "geozone_id": "0", + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + }], + "count": 0, + "total": 0, + "start": 0, + "max": 0 +} +`) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) + mux.HandleFunc("/zones/records/add/example.com/TXT", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodPut, r.Method, "method") assert.Equal(t, "format=json", r.URL.RawQuery, "query") @@ -191,7 +224,40 @@ func TestDNSProvider_Present(t *testing.T) { } func TestDNSProvider_Cleanup_WhenRecordIdNotSet_NoOp(t *testing.T) { - provider, _ := setupTest(t) + provider, mux := setupTest(t) + + mux.HandleFunc("/zones/records/all/example.com", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "method") + assert.Equal(t, "format=json", r.URL.RawQuery, "query") + assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) + + w.WriteHeader(http.StatusOK) + _, err := fmt.Fprintf(w, `{ + "msg": "string", + "status": 200, + "tm": 0, + "data": [{ + "id": "60898922", + "domain": "example.com", + "host": "hosta", + "ttl": "300", + "prio": "0", + "geozone_id": "0", + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + }], + "count": 0, + "total": 0, + "start": 0, + "max": 0 +} +`) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) err := provider.CleanUp("example.com", "token", "keyAuth") require.NoError(t, err) @@ -200,6 +266,39 @@ func TestDNSProvider_Cleanup_WhenRecordIdNotSet_NoOp(t *testing.T) { func TestDNSProvider_Cleanup_WhenRecordIdSet_DeletesTxtRecord(t *testing.T) { provider, mux := setupTest(t) + mux.HandleFunc("/zones/records/all/example.com", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "method") + assert.Equal(t, "format=json", r.URL.RawQuery, "query") + assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) + + w.WriteHeader(http.StatusOK) + _, err := fmt.Fprintf(w, `{ + "msg": "string", + "status": 200, + "tm": 0, + "data": [{ + "id": "60898922", + "domain": "example.com", + "host": "hosta", + "ttl": "300", + "prio": "0", + "geozone_id": "0", + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + }], + "count": 0, + "total": 0, + "start": 0, + "max": 0 +} +`) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) + mux.HandleFunc("/zones/records/example.com/123456", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodDelete, r.Method, "method") assert.Equal(t, "format=json", r.URL.RawQuery, "query") @@ -228,6 +327,39 @@ func TestDNSProvider_Cleanup_WhenRecordIdSet_DeletesTxtRecord(t *testing.T) { func TestDNSProvider_Cleanup_WhenHttpError_ReturnsError(t *testing.T) { provider, mux := setupTest(t) + mux.HandleFunc("/zones/records/all/example.com", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "method") + assert.Equal(t, "format=json", r.URL.RawQuery, "query") + assert.Equal(t, "Basic VE9LRU46U0VDUkVU", r.Header.Get(authorizationHeader), authorizationHeader) + + w.WriteHeader(http.StatusOK) + _, err := fmt.Fprintf(w, `{ + "msg": "string", + "status": 200, + "tm": 0, + "data": [{ + "id": "60898922", + "domain": "example.com", + "host": "hosta", + "ttl": "300", + "prio": "0", + "geozone_id": "0", + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + }], + "count": 0, + "total": 0, + "start": 0, + "max": 0 +} +`) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) + errorMessage := `{ "error": { "code": 406, @@ -253,43 +385,6 @@ func TestDNSProvider_Cleanup_WhenHttpError_ReturnsError(t *testing.T) { require.EqualError(t, err, expectedError) } -func TestSplitFqdn(t *testing.T) { - testCases := []struct { - desc string - fqdn string - expectedHost string - expectedDomain string - }{ - { - desc: "domain only", - fqdn: "domain.com.", - expectedHost: "", - expectedDomain: "domain.com", - }, - { - desc: "single-part host", - fqdn: "_acme-challenge.domain.com.", - expectedHost: "_acme-challenge", - expectedDomain: "domain.com", - }, - { - desc: "multi-part host", - fqdn: "_acme-challenge.sub.domain.com.", - expectedHost: "_acme-challenge.sub", - expectedDomain: "domain.com", - }, - } - - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - actualHost, actualDomain := splitFqdn(test.fqdn) - - require.Equal(t, test.expectedHost, actualHost) - require.Equal(t, test.expectedDomain, actualDomain) - }) - } -} - func TestLivePresent(t *testing.T) { if !envTest.IsLiveTest() { t.Skip("skipping live test") diff --git a/providers/dns/easydns/internal/client.go b/providers/dns/easydns/internal/client.go index 363a2fc7..3568eeea 100644 --- a/providers/dns/easydns/internal/client.go +++ b/providers/dns/easydns/internal/client.go @@ -37,6 +37,27 @@ func NewClient(token string, key string) *Client { } } +func (c *Client) ListZones(ctx context.Context, domain string) ([]ZoneRecord, error) { + endpoint := c.BaseURL.JoinPath("zones", "records", "all", domain) + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + response := &apiResponse[[]ZoneRecord]{} + err = c.do(req, response) + if err != nil { + return nil, err + } + + if response.Error != nil { + return nil, response.Error + } + + return response.Data, nil +} + func (c *Client) AddRecord(ctx context.Context, domain string, record ZoneRecord) (string, error) { endpoint := c.BaseURL.JoinPath("zones", "records", "add", domain, "TXT") @@ -45,12 +66,16 @@ func (c *Client) AddRecord(ctx context.Context, domain string, record ZoneRecord return "", err } - response := &addRecordResponse{} + response := &apiResponse[*ZoneRecord]{} err = c.do(req, response) if err != nil { return "", err } + if response.Error != nil { + return "", response.Error + } + recordID := response.Data.ID return recordID, nil @@ -64,7 +89,9 @@ func (c *Client) DeleteRecord(ctx context.Context, domain, recordID string) erro return err } - return c.do(req, nil) + err = c.do(req, nil) + + return err } func (c *Client) do(req *http.Request, result any) error { diff --git a/providers/dns/easydns/internal/client_test.go b/providers/dns/easydns/internal/client_test.go index 7ea61d3c..030b28f3 100644 --- a/providers/dns/easydns/internal/client_test.go +++ b/providers/dns/easydns/internal/client_test.go @@ -67,6 +67,33 @@ func setupTest(t *testing.T, method, pattern string, status int, file string) *C return client } +func TestClient_ListZones(t *testing.T) { + client := setupTest(t, http.MethodGet, "/zones/records/all/example.com", http.StatusOK, "list-zone.json") + + zones, err := client.ListZones(context.Background(), "example.com") + require.NoError(t, err) + + expected := []ZoneRecord{{ + ID: "60898922", + Domain: "example.com", + Host: "hosta", + TTL: "300", + Priority: "0", + Type: "A", + Rdata: "1.2.3.4", + LastMod: "2019-08-28 19:09:50", + }} + + assert.Equal(t, expected, zones) +} + +func TestClient_ListZones_error(t *testing.T) { + client := setupTest(t, http.MethodGet, "/zones/records/all/example.com", http.StatusOK, "error1.json") + + _, err := client.ListZones(context.Background(), "example.com") + require.EqualError(t, err, "code 420: Enhance Your Calm. Rate limit exceeded (too many requests) OR you did NOT provide any credentials with your request!") +} + func TestClient_AddRecord(t *testing.T) { client := setupTest(t, http.MethodPut, "/zones/records/add/example.com/TXT", http.StatusCreated, "add-record.json") @@ -85,6 +112,22 @@ func TestClient_AddRecord(t *testing.T) { assert.Equal(t, "xxx", recordID) } +func TestClient_AddRecord_error(t *testing.T) { + client := setupTest(t, http.MethodPut, "/zones/records/add/example.com/TXT", http.StatusCreated, "error1.json") + + record := ZoneRecord{ + Domain: "example.com", + Host: "test631", + Type: "TXT", + Rdata: "txt", + TTL: "300", + Priority: "0", + } + + _, err := client.AddRecord(context.Background(), "example.com", record) + require.EqualError(t, err, "code 420: Enhance Your Calm. Rate limit exceeded (too many requests) OR you did NOT provide any credentials with your request!") +} + func TestClient_DeleteRecord(t *testing.T) { client := setupTest(t, http.MethodDelete, "/zones/records/example.com/xxx", http.StatusOK, "") diff --git a/providers/dns/easydns/internal/fixtures/error.json b/providers/dns/easydns/internal/fixtures/error.json new file mode 100644 index 00000000..3ea1674a --- /dev/null +++ b/providers/dns/easydns/internal/fixtures/error.json @@ -0,0 +1,4 @@ +{ + "msg": "Enhance your calm", + "status": 403 +} diff --git a/providers/dns/easydns/internal/fixtures/error1.json b/providers/dns/easydns/internal/fixtures/error1.json new file mode 100644 index 00000000..02982c46 --- /dev/null +++ b/providers/dns/easydns/internal/fixtures/error1.json @@ -0,0 +1,6 @@ +{ + "error": { + "code": 420, + "message": "Enhance Your Calm. Rate limit exceeded (too many requests) OR you did NOT provide any credentials with your request!" + } +} diff --git a/providers/dns/easydns/internal/fixtures/list-zone.json b/providers/dns/easydns/internal/fixtures/list-zone.json new file mode 100644 index 00000000..561a45df --- /dev/null +++ b/providers/dns/easydns/internal/fixtures/list-zone.json @@ -0,0 +1,22 @@ +{ + "msg": "message", + "status": 200, + "tm": 0, + "data": [ + { + "id": "60898922", + "domain": "example.com", + "host": "hosta", + "ttl": "300", + "prio": "0", + "geozone_id": "0", + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + } + ], + "count": 43, + "total": 43, + "start": 0, + "max": 1000 +} diff --git a/providers/dns/easydns/internal/readme.md b/providers/dns/easydns/internal/readme.md new file mode 100644 index 00000000..aa3a54b7 --- /dev/null +++ b/providers/dns/easydns/internal/readme.md @@ -0,0 +1,57 @@ +The API doc is mainly wrong on the response schema: + +ex: + +- the doc for `/zones/records/all/{domain}` + +```json +{ + "msg": "string", + "status": 200, + "tm": 1709190001, + "data": { + "id": 60898922, + "domain": "example.com", + "host": "hosta", + "ttl": 300, + "prio": 0, + "geozone_id": 0, + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + }, + "count": 0, + "total": 0, + "start": 0, + "max": 0 +} +``` + +- The reality: + +```json +{ + "tm": 1709190001, + "data": [ + { + "id": "60898922", + "domain": "example.com", + "host": "hosta", + "ttl": "300", + "prio": "0", + "geozone_id": "0", + "type": "A", + "rdata": "1.2.3.4", + "last_mod": "2019-08-28 19:09:50" + } + ], + "count": 0, + "total": 0, + "start": 0, + "max": 0, + "status": 200 +} +``` + +`data` is an array. +`id`, `ttl`, `geozone_id` are strings. diff --git a/providers/dns/easydns/internal/types.go b/providers/dns/easydns/internal/types.go index 5235c4d7..035e992e 100644 --- a/providers/dns/easydns/internal/types.go +++ b/providers/dns/easydns/internal/types.go @@ -1,5 +1,19 @@ package internal +import "fmt" + +type apiResponse[T any] struct { + Msg string `json:"msg"` + Status int `json:"status"` + Tm int `json:"tm"` + Data T `json:"data"` + Count int `json:"count"` + Total int `json:"total"` + Start int `json:"start"` + Max int `json:"max"` + Error *Error `json:"error,omitempty"` +} + type ZoneRecord struct { ID string `json:"id,omitempty"` Domain string `json:"domain"` @@ -13,9 +27,11 @@ type ZoneRecord struct { NewHost string `json:"new_host,omitempty"` } -type addRecordResponse struct { - Msg string `json:"msg"` - Tm int `json:"tm"` - Data ZoneRecord `json:"data"` - Status int `json:"status"` +type Error struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e *Error) Error() string { + return fmt.Sprintf("code %d: %s", e.Code, e.Message) }