forked from TrueCloudLab/lego
easydns: fix zone detection (#2121)
This commit is contained in:
parent
a7ca3d7f1c
commit
6933296e2f
9 changed files with 366 additions and 61 deletions
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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, "")
|
||||
|
||||
|
|
4
providers/dns/easydns/internal/fixtures/error.json
Normal file
4
providers/dns/easydns/internal/fixtures/error.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"msg": "Enhance your calm",
|
||||
"status": 403
|
||||
}
|
6
providers/dns/easydns/internal/fixtures/error1.json
Normal file
6
providers/dns/easydns/internal/fixtures/error1.json
Normal 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!"
|
||||
}
|
||||
}
|
22
providers/dns/easydns/internal/fixtures/list-zone.json
Normal file
22
providers/dns/easydns/internal/fixtures/list-zone.json
Normal 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
|
||||
}
|
57
providers/dns/easydns/internal/readme.md
Normal file
57
providers/dns/easydns/internal/readme.md
Normal 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.
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue