easydns: fix zone detection (#2121)

This commit is contained in:
Ludovic Fernandez 2024-03-03 15:41:55 +01:00 committed by GitHub
parent a7ca3d7f1c
commit 6933296e2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 366 additions and 61 deletions

View file

@ -15,7 +15,6 @@ import (
"github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/easydns/internal" "github.com/go-acme/lego/v4/providers/dns/easydns/internal"
"github.com/miekg/dns"
) )
// Environment variables names. // Environment variables names.
@ -117,20 +116,34 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
// Present creates a TXT record to fulfill the dns-01 challenge. // Present creates a TXT record to fulfill the dns-01 challenge.
func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) Present(domain, token, keyAuth string) error {
ctx := context.Background()
info := dns01.GetChallengeInfo(domain, keyAuth) 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{ record := internal.ZoneRecord{
Domain: apiDomain, Domain: authZone,
Host: apiHost, Host: subDomain,
Type: "TXT", Type: "TXT",
Rdata: info.Value, Rdata: info.Value,
TTL: strconv.Itoa(d.config.TTL), TTL: strconv.Itoa(d.config.TTL),
Priority: "0", Priority: "0",
} }
recordID, err := d.client.AddRecord(context.Background(), apiDomain, record) recordID, err := d.client.AddRecord(ctx, dns01.UnFqdn(authZone), record)
if err != nil { if err != nil {
return fmt.Errorf("easydns: error adding zone record: %w", err) 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. // CleanUp removes the TXT record matching the specified parameters.
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
ctx := context.Background()
info := dns01.GetChallengeInfo(domain, keyAuth) info := dns01.GetChallengeInfo(domain, keyAuth)
key := getMapKey(info.EffectiveFQDN, info.Value) key := getMapKey(info.EffectiveFQDN, info.Value)
@ -158,9 +173,16 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return nil 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() d.recordIDsMu.Lock()
defer delete(d.recordIDs, key) defer delete(d.recordIDs, key)
@ -185,15 +207,28 @@ func (d *DNSProvider) Sequential() time.Duration {
return d.config.SequenceInterval 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 { func getMapKey(fqdn, value string) string {
return fqdn + "|" + value 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
}

View file

@ -147,6 +147,39 @@ func TestNewDNSProviderConfig(t *testing.T) {
func TestDNSProvider_Present(t *testing.T) { func TestDNSProvider_Present(t *testing.T) {
provider, mux := 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
}
})
mux.HandleFunc("/zones/records/add/example.com/TXT", func(w http.ResponseWriter, r *http.Request) { 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, http.MethodPut, r.Method, "method")
assert.Equal(t, "format=json", r.URL.RawQuery, "query") 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) { 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") err := provider.CleanUp("example.com", "token", "keyAuth")
require.NoError(t, err) require.NoError(t, err)
@ -200,6 +266,39 @@ func TestDNSProvider_Cleanup_WhenRecordIdNotSet_NoOp(t *testing.T) {
func TestDNSProvider_Cleanup_WhenRecordIdSet_DeletesTxtRecord(t *testing.T) { func TestDNSProvider_Cleanup_WhenRecordIdSet_DeletesTxtRecord(t *testing.T) {
provider, mux := 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
}
})
mux.HandleFunc("/zones/records/example.com/123456", func(w http.ResponseWriter, r *http.Request) { 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, http.MethodDelete, r.Method, "method")
assert.Equal(t, "format=json", r.URL.RawQuery, "query") 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) { func TestDNSProvider_Cleanup_WhenHttpError_ReturnsError(t *testing.T) {
provider, mux := 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
}
})
errorMessage := `{ errorMessage := `{
"error": { "error": {
"code": 406, "code": 406,
@ -253,43 +385,6 @@ func TestDNSProvider_Cleanup_WhenHttpError_ReturnsError(t *testing.T) {
require.EqualError(t, err, expectedError) 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) { func TestLivePresent(t *testing.T) {
if !envTest.IsLiveTest() { if !envTest.IsLiveTest() {
t.Skip("skipping live test") t.Skip("skipping live test")

View file

@ -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) { func (c *Client) AddRecord(ctx context.Context, domain string, record ZoneRecord) (string, error) {
endpoint := c.BaseURL.JoinPath("zones", "records", "add", domain, "TXT") 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 return "", err
} }
response := &addRecordResponse{} response := &apiResponse[*ZoneRecord]{}
err = c.do(req, response) err = c.do(req, response)
if err != nil { if err != nil {
return "", err return "", err
} }
if response.Error != nil {
return "", response.Error
}
recordID := response.Data.ID recordID := response.Data.ID
return recordID, nil return recordID, nil
@ -64,7 +89,9 @@ func (c *Client) DeleteRecord(ctx context.Context, domain, recordID string) erro
return err return err
} }
return c.do(req, nil) err = c.do(req, nil)
return err
} }
func (c *Client) do(req *http.Request, result any) error { func (c *Client) do(req *http.Request, result any) error {

View file

@ -67,6 +67,33 @@ func setupTest(t *testing.T, method, pattern string, status int, file string) *C
return client 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) { func TestClient_AddRecord(t *testing.T) {
client := setupTest(t, http.MethodPut, "/zones/records/add/example.com/TXT", http.StatusCreated, "add-record.json") 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) 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) { func TestClient_DeleteRecord(t *testing.T) {
client := setupTest(t, http.MethodDelete, "/zones/records/example.com/xxx", http.StatusOK, "") client := setupTest(t, http.MethodDelete, "/zones/records/example.com/xxx", http.StatusOK, "")

View file

@ -0,0 +1,4 @@
{
"msg": "Enhance your calm",
"status": 403
}

View file

@ -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!"
}
}

View file

@ -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
}

View file

@ -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.

View file

@ -1,5 +1,19 @@
package internal 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 { type ZoneRecord struct {
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
Domain string `json:"domain"` Domain string `json:"domain"`
@ -13,9 +27,11 @@ type ZoneRecord struct {
NewHost string `json:"new_host,omitempty"` NewHost string `json:"new_host,omitempty"`
} }
type addRecordResponse struct { type Error struct {
Msg string `json:"msg"` Code int `json:"code"`
Tm int `json:"tm"` Message string `json:"message"`
Data ZoneRecord `json:"data"` }
Status int `json:"status"`
func (e *Error) Error() string {
return fmt.Sprintf("code %d: %s", e.Code, e.Message)
} }