diff --git a/go.mod b/go.mod index 0a15d03c0..e5474f48d 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,6 @@ require ( github.com/baidubce/bce-sdk-go v0.9.223 github.com/cenkalti/backoff/v4 v4.3.0 github.com/civo/civogo v0.3.11 - github.com/cloudflare/cloudflare-go v0.115.0 github.com/dnsimple/dnsimple-go/v4 v4.0.0 github.com/exoscale/egoscale/v3 v3.1.13 github.com/go-jose/go-jose/v4 v4.0.5 @@ -148,7 +147,6 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.16.0 // indirect github.com/go-resty/resty/v2 v2.16.5 // indirect - github.com/goccy/go-json v0.10.5 // indirect github.com/gofrs/flock v0.12.1 // indirect github.com/golang-jwt/jwt/v4 v4.5.1 // indirect github.com/golang-jwt/jwt/v5 v5.2.1 // indirect diff --git a/go.sum b/go.sum index bb55586a4..417cc1532 100644 --- a/go.sum +++ b/go.sum @@ -249,8 +249,6 @@ github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5P github.com/clbanning/mxj/v2 v2.5.5 h1:oT81vUeEiQQ/DcHbzSytRngP6Ky9O+L+0Bw0zSJag9E= github.com/clbanning/mxj/v2 v2.5.5/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM= -github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= @@ -359,8 +357,6 @@ github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw= github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b h1:/vQ+oYKu+JoyaMPDsv5FzwuL2wwWBgBbtj/YLCi4LuA= github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= diff --git a/platform/tester/servermock/link_request_body_json.go b/platform/tester/servermock/link_request_body_json.go index 1d1fecce9..2e9985c3d 100644 --- a/platform/tester/servermock/link_request_body_json.go +++ b/platform/tester/servermock/link_request_body_json.go @@ -14,9 +14,10 @@ import ( // RequestBodyJSONLink validates JSON request bodies. type RequestBodyJSONLink struct { - body []byte - filename string - data any + body []byte + filename string + directory string + data any } // CheckRequestJSONBody creates a [RequestBodyJSONLink] initialized with a string. @@ -31,7 +32,10 @@ func CheckRequestJSONBodyFromStruct(data any) *RequestBodyJSONLink { // CheckRequestJSONBodyFromFile creates a [RequestBodyJSONLink] initialized with the provided request body file. func CheckRequestJSONBodyFromFile(filename string) *RequestBodyJSONLink { - return &RequestBodyJSONLink{filename: filename} + return &RequestBodyJSONLink{ + filename: filename, + directory: "fixtures", + } } func (l *RequestBodyJSONLink) Bind(next http.Handler) http.Handler { @@ -55,7 +59,7 @@ func (l *RequestBodyJSONLink) Bind(next http.Handler) http.Handler { switch { case l.filename != "": - expectedRaw, err = os.ReadFile(filepath.Join("fixtures", l.filename)) + expectedRaw, err = os.ReadFile(filepath.Join(l.directory, l.filename)) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return @@ -97,3 +101,9 @@ func (l *RequestBodyJSONLink) Bind(next http.Handler) http.Handler { next.ServeHTTP(rw, req) }) } + +func (l *RequestBodyJSONLink) WithDirectory(directory string) *RequestBodyJSONLink { + l.directory = directory + + return l +} diff --git a/providers/dns/cloudflare/cloudflare.go b/providers/dns/cloudflare/cloudflare.go index 0fa52b34d..5fd350925 100644 --- a/providers/dns/cloudflare/cloudflare.go +++ b/providers/dns/cloudflare/cloudflare.go @@ -11,11 +11,11 @@ import ( "sync" "time" - "github.com/cloudflare/cloudflare-go" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/cloudflare/internal" ) // Environment variables names. @@ -156,24 +156,26 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) + ctx := context.Background() + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("cloudflare: could not find zone for domain %q: %w", domain, err) } - zoneID, err := d.client.ZoneIDByName(authZone) + zoneID, err := d.client.ZoneIDByName(ctx, authZone) if err != nil { return fmt.Errorf("cloudflare: failed to find zone %s: %w", authZone, err) } - dnsRecord := cloudflare.CreateDNSRecordParams{ + dnsRecord := internal.Record{ Type: "TXT", Name: dns01.UnFqdn(info.EffectiveFQDN), Content: `"` + info.Value + `"`, TTL: d.config.TTL, } - response, err := d.client.CreateDNSRecord(context.Background(), zoneID, dnsRecord) + response, err := d.client.CreateDNSRecord(ctx, zoneID, dnsRecord) if err != nil { return fmt.Errorf("cloudflare: failed to create TXT record: %w", err) } @@ -196,7 +198,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("cloudflare: could not find zone for domain %q: %w", domain, err) } - zoneID, err := d.client.ZoneIDByName(authZone) + zoneID, err := d.client.ZoneIDByName(context.Background(), authZone) if err != nil { return fmt.Errorf("cloudflare: failed to find zone %s: %w", authZone, err) } diff --git a/providers/dns/cloudflare/cloudflare_test.go b/providers/dns/cloudflare/cloudflare_test.go index f3bba69d2..b288931f1 100644 --- a/providers/dns/cloudflare/cloudflare_test.go +++ b/providers/dns/cloudflare/cloudflare_test.go @@ -1,10 +1,13 @@ package cloudflare import ( + "net/http/httptest" + "path/filepath" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -232,22 +235,17 @@ func TestNewDNSProviderConfig(t *testing.T) { }, { desc: "missing credentials", - expected: "cloudflare: invalid credentials: key & email must not be empty", + expected: "cloudflare: invalid credentials: authEmail, authKey or authToken must be set", }, { desc: "missing email", authKey: "123", - expected: "cloudflare: invalid credentials: key & email must not be empty", + expected: "cloudflare: invalid credentials: authEmail and authKey must be set together", }, { desc: "missing api key", authEmail: "test@example.com", - expected: "cloudflare: invalid credentials: key & email must not be empty", - }, - { - desc: "missing api token, fallback to api key/email", - authToken: "", - expected: "cloudflare: invalid credentials: key & email must not be empty", + expected: "cloudflare: invalid credentials: authEmail and authKey must be set together", }, } @@ -299,3 +297,68 @@ func TestLiveCleanUp(t *testing.T) { err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.AuthEmail = "foo@example.com" + config.AuthKey = "secret" + config.BaseURL = server.URL + + return NewDNSProviderConfig(config) + }, + servermock.CheckHeader(). + WithRegexp("User-Agent", `goacme-lego/[0-9.]+ \(.+\)`). + With("X-Auth-Email", "foo@example.com"). + With("X-Auth-Key", "secret"), + ) +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + // https://developers.cloudflare.com/api/resources/zones/methods/list/ + Route("GET /zones", + responseFromFixture("zones.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com"). + With("per_page", "50")). + // https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/create/ + Route("POST /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records", + responseFromFixture("create_record.json"), + servermock.CheckHeader(). + WithContentType("application/json"), + servermock.CheckRequestJSONBodyFromFile("create_record-request.json"). + WithDirectory(filepath.Join("internal", "fixtures"))). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + // https://developers.cloudflare.com/api/resources/zones/methods/list/ + Route("GET /zones", + responseFromFixture("zones.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com"). + With("per_page", "50")). + // https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/delete/ + Route("DELETE /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records/xxx", + responseFromFixture("delete_record.json")). + Build(t) + + token := "abc" + + provider.recordIDsMu.Lock() + provider.recordIDs["abc"] = "xxx" + provider.recordIDsMu.Unlock() + + err := provider.CleanUp("example.com", token, "123d==") + require.NoError(t, err) +} + +func responseFromFixture(filename string) *servermock.ResponseFromFileHandler { + return servermock.ResponseFromFile(filepath.Join("internal", "fixtures", filename)) +} diff --git a/providers/dns/cloudflare/internal/client.go b/providers/dns/cloudflare/internal/client.go new file mode 100644 index 000000000..495ba5618 --- /dev/null +++ b/providers/dns/cloudflare/internal/client.go @@ -0,0 +1,197 @@ +/* +Package internal Cloudflare API client. + +The official client is huge and still growing. +- https://github.com/cloudflare/cloudflare-go/issues/4171 +*/ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" +) + +const defaultBaseURL = "https://api.cloudflare.com/client/v4" + +// Client the Cloudflare API client. +type Client struct { + authEmail string + authKey string + authToken string + + baseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(opts ...Option) (*Client, error) { + baseURL, _ := url.Parse(defaultBaseURL) + + client := &Client{ + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + } + + for _, opt := range opts { + err := opt(client) + if err != nil { + return nil, err + } + } + + if client.authToken != "" { + return client, nil + } + + if client.authEmail == "" && client.authKey == "" { + return nil, errors.New("invalid credentials: authEmail, authKey or authToken must be set") + } + + if client.authEmail == "" || client.authKey == "" { + return nil, errors.New("invalid credentials: authEmail and authKey must be set together") + } + + return client, nil +} + +// CreateDNSRecord creates a new DNS record for a zone. +// https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/create/ +func (c *Client) CreateDNSRecord(ctx context.Context, zoneID string, record Record) (*Record, error) { + endpoint := c.baseURL.JoinPath("zones", zoneID, "dns_records") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) + if err != nil { + return nil, err + } + + var result APIResponse[Record] + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return &result.Result, nil +} + +// DeleteDNSRecord Delete DNS record. +// https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/delete/ +func (c *Client) DeleteDNSRecord(ctx context.Context, zoneID, recordID string) error { + endpoint := c.baseURL.JoinPath("zones", zoneID, "dns_records", recordID) + + req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + return c.do(req, nil) +} + +// https://developers.cloudflare.com/api/resources/zones/methods/list/ +func (c *Client) ZonesByName(ctx context.Context, name string) ([]Zone, error) { + endpoint := c.baseURL.JoinPath("zones") + + query := endpoint.Query() + query.Set("name", name) + query.Set("per_page", "50") + endpoint.RawQuery = query.Encode() + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var result APIResponse[[]Zone] + + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return result.Result, nil +} + +func (c *Client) do(req *http.Request, result any) error { + // https://developers.cloudflare.com/fundamentals/api/how-to/make-api-calls/ + if c.authToken != "" { + req.Header.Set("Authorization", "Bearer "+c.authToken) + } else { + req.Header.Set("X-Auth-Email", c.authEmail) + req.Header.Set("X-Auth-Key", c.authKey) + } + + useragent.SetHeader(req.Header) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { + buf := new(bytes.Buffer) + + if payload != nil { + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, fmt.Errorf("failed to create request JSON body: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var response APIResponse[any] + err := json.Unmarshal(raw, &response) + if err != nil { + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + return fmt.Errorf("[status code %d] %w", resp.StatusCode, response.Errors) +} diff --git a/providers/dns/cloudflare/internal/client_test.go b/providers/dns/cloudflare/internal/client_test.go new file mode 100644 index 000000000..69ca2007c --- /dev/null +++ b/providers/dns/cloudflare/internal/client_test.go @@ -0,0 +1,176 @@ +package internal + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient( + WithAuthKey("foo@example.com", "secret"), + WithHTTPClient(server.Client()), + WithBaseURL(server.URL), + ) + if err != nil { + return nil, err + } + + return client, nil + }, + servermock.CheckHeader(). + WithRegexp("User-Agent", `goacme-lego/[0-9.]+ \(.+\)`). + WithAccept("application/json"). + With("X-Auth-Email", "foo@example.com"). + With("X-Auth-Key", "secret"), + ) +} + +func TestClient_CreateDNSRecord(t *testing.T) { + client := mockBuilder(). + Route("POST /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records", + servermock.ResponseFromFixture("create_record.json"), + servermock.CheckHeader(). + WithContentType("application/json"), + servermock.CheckRequestJSONBodyFromFile("create_record-request.json")). + Build(t) + + record := Record{ + Name: "_acme-challenge.example.com", + TTL: 120, + Type: "TXT", + Content: `"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"`, + } + + newRecord, err := client.CreateDNSRecord(t.Context(), "023e105f4ecef8ad9ca31a8372d0c353", record) + require.NoError(t, err) + + expected := &Record{ + ID: "023e105f4ecef8ad9ca31a8372d0c353", + Name: "example.com", + TTL: 3600, + Type: "A", + Comment: "Domain verification record", + Content: "198.51.100.4", + } + + assert.Equal(t, expected, newRecord) +} + +func TestClient_CreateDNSRecord_error(t *testing.T) { + client := mockBuilder(). + Route("POST /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) + + record := Record{ + Name: "_acme-challenge.example.com", + TTL: 120, + Type: "TXT", + Content: `"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"`, + } + + _, err := client.CreateDNSRecord(t.Context(), "023e105f4ecef8ad9ca31a8372d0c353", record) + require.EqualError(t, err, "[status code 400] 6003: Invalid request headers; 6103: Invalid format for X-Auth-Key header") +} + +func TestClient_DeleteDNSRecord(t *testing.T) { + client := mockBuilder(). + Route("DELETE /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records/xxx", + servermock.ResponseFromFixture("delete_record.json")). + Build(t) + + err := client.DeleteDNSRecord(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", "xxx") + require.NoError(t, err) +} + +func TestClient_DeleteDNSRecord_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records/xxx", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) + + err := client.DeleteDNSRecord(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", "xxx") + require.EqualError(t, err, "[status code 400] 6003: Invalid request headers; 6103: Invalid format for X-Auth-Key header") +} + +func TestClient_ZonesByName(t *testing.T) { + client := mockBuilder(). + Route("GET /zones", + servermock.ResponseFromFixture("zones.json"), + servermock.CheckQueryParameter().Strict(). + With("name", "example.com"). + With("per_page", "50")). + Build(t) + + zones, err := client.ZonesByName(context.Background(), "example.com") + require.NoError(t, err) + + expected := []Zone{ + { + ID: "023e105f4ecef8ad9ca31a8372d0c353", + Account: Account{ID: "023e105f4ecef8ad9ca31a8372d0c353", Name: "Example Account Name"}, + Meta: Meta{ + CdnOnly: true, + CustomCertificateQuota: 1, + DNSOnly: true, + FoundationDNS: true, + PageRuleQuota: 100, + PhishingDetected: false, + Step: 2, + }, + Name: "example.com", + Owner: Owner{ + ID: "023e105f4ecef8ad9ca31a8372d0c353", + Name: "Example Org", + Type: "organization", + }, + Plan: Plan{ + ID: "023e105f4ecef8ad9ca31a8372d0c353", + CanSubscribe: false, + Currency: "USD", + ExternallyManaged: false, + Frequency: "monthly", + IsSubscribed: false, + LegacyDiscount: false, + LegacyID: "free", + Price: 10, + Name: "Example Org", + }, + CnameSuffix: "cdn.cloudflare.com", + Paused: true, + Permissions: []string{"#worker:read"}, + Tenant: Tenant{ + ID: "023e105f4ecef8ad9ca31a8372d0c353", + Name: "Example Account Name", + }, + TenantUnit: TenantUnit{ + ID: "023e105f4ecef8ad9ca31a8372d0c353", + }, + Type: "full", + VanityNameServers: []string{"ns1.example.com", "ns2.example.com"}, + }, + } + + assert.Equal(t, expected, zones) +} + +func TestClient_ZonesByName_error(t *testing.T) { + client := mockBuilder(). + Route("GET /zones", + servermock.ResponseFromFixture("error.json"). + WithStatusCode(http.StatusBadRequest)). + Build(t) + + _, err := client.ZonesByName(context.Background(), "example.com") + require.EqualError(t, err, "[status code 400] 6003: Invalid request headers; 6103: Invalid format for X-Auth-Key header") +} diff --git a/providers/dns/cloudflare/internal/fixtures/create_record-request.json b/providers/dns/cloudflare/internal/fixtures/create_record-request.json new file mode 100644 index 000000000..1b8604dc9 --- /dev/null +++ b/providers/dns/cloudflare/internal/fixtures/create_record-request.json @@ -0,0 +1,6 @@ +{ + "type": "TXT", + "name": "_acme-challenge.example.com", + "content": "\"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY\"", + "ttl": 120 +} diff --git a/providers/dns/cloudflare/internal/fixtures/create_record.json b/providers/dns/cloudflare/internal/fixtures/create_record.json new file mode 100644 index 000000000..7e08e993b --- /dev/null +++ b/providers/dns/cloudflare/internal/fixtures/create_record.json @@ -0,0 +1,40 @@ +{ + "errors": [ + { + "code": 1000, + "message": "message", + "documentation_url": "documentation_url", + "source": { + "pointer": "pointer" + } + } + ], + "messages": [ + { + "code": 1000, + "message": "message", + "documentation_url": "documentation_url", + "source": { + "pointer": "pointer" + } + } + ], + "success": true, + "result": { + "name": "example.com", + "ttl": 3600, + "type": "A", + "comment": "Domain verification record", + "content": "198.51.100.4", + "proxied": true, + "settings": { + "ipv4_only": true, + "ipv6_only": true + }, + "tags": [ + "owner:dns-team" + ], + "id": "023e105f4ecef8ad9ca31a8372d0c353", + "proxiable": true + } +} diff --git a/providers/dns/cloudflare/internal/fixtures/delete_record.json b/providers/dns/cloudflare/internal/fixtures/delete_record.json new file mode 100644 index 000000000..038ac7b23 --- /dev/null +++ b/providers/dns/cloudflare/internal/fixtures/delete_record.json @@ -0,0 +1,5 @@ +{ + "result": { + "id": "023e105f4ecef8ad9ca31a8372d0c353" + } +} diff --git a/providers/dns/cloudflare/internal/fixtures/error.json b/providers/dns/cloudflare/internal/fixtures/error.json new file mode 100644 index 000000000..1b2360cc4 --- /dev/null +++ b/providers/dns/cloudflare/internal/fixtures/error.json @@ -0,0 +1,17 @@ +{ + "success": false, + "errors": [ + { + "code": 6003, + "message": "Invalid request headers", + "error_chain": [ + { + "code": 6103, + "message": "Invalid format for X-Auth-Key header" + } + ] + } + ], + "messages": [], + "result": null +} diff --git a/providers/dns/cloudflare/internal/fixtures/zones.json b/providers/dns/cloudflare/internal/fixtures/zones.json new file mode 100644 index 000000000..1dd94c4e3 --- /dev/null +++ b/providers/dns/cloudflare/internal/fixtures/zones.json @@ -0,0 +1,83 @@ +{ + "errors": [ + { + "code": 1000, + "message": "message", + "documentation_url": "documentation_url", + "source": { + "pointer": "pointer" + } + } + ], + "messages": [ + { + "code": 1000, + "message": "message", + "documentation_url": "documentation_url", + "source": { + "pointer": "pointer" + } + } + ], + "success": true, + "result": [ + { + "id": "023e105f4ecef8ad9ca31a8372d0c353", + "account": { + "id": "023e105f4ecef8ad9ca31a8372d0c353", + "name": "Example Account Name" + }, + "meta": { + "cdn_only": true, + "custom_certificate_quota": 1, + "dns_only": true, + "foundation_dns": true, + "page_rule_quota": 100, + "phishing_detected": false, + "step": 2 + }, + "name": "example.com", + "owner": { + "id": "023e105f4ecef8ad9ca31a8372d0c353", + "name": "Example Org", + "type": "organization" + }, + "plan": { + "id": "023e105f4ecef8ad9ca31a8372d0c353", + "can_subscribe": false, + "currency": "USD", + "externally_managed": false, + "frequency": "monthly", + "is_subscribed": false, + "legacy_discount": false, + "legacy_id": "free", + "price": 10, + "name": "Example Org" + }, + "cname_suffix": "cdn.cloudflare.com", + "paused": true, + "permissions": [ + "#worker:read" + ], + "tenant": { + "id": "023e105f4ecef8ad9ca31a8372d0c353", + "name": "Example Account Name" + }, + "tenant_unit": { + "id": "023e105f4ecef8ad9ca31a8372d0c353" + }, + "type": "full", + "vanity_name_servers": [ + "ns1.example.com", + "ns2.example.com" + ] + } + ], + "result_info": { + "count": 1, + "page": 1, + "per_page": 20, + "total_count": 1, + "total_pages": 1 + } +} diff --git a/providers/dns/cloudflare/internal/options.go b/providers/dns/cloudflare/internal/options.go new file mode 100644 index 000000000..aa551a422 --- /dev/null +++ b/providers/dns/cloudflare/internal/options.go @@ -0,0 +1,52 @@ +package internal + +import ( + "net/http" + "net/url" +) + +type Option func(c *Client) error + +func WithAuthKey(authEmail, authKey string) Option { + return func(c *Client) error { + c.authEmail = authEmail + c.authKey = authKey + + return nil + } +} + +func WithAuthToken(authToken string) Option { + return func(c *Client) error { + c.authToken = authToken + + return nil + } +} + +func WithBaseURL(baseURL string) Option { + return func(c *Client) error { + if baseURL == "" { + return nil + } + + bu, err := url.Parse(baseURL) + if err != nil { + return err + } + + c.baseURL = bu + + return nil + } +} + +func WithHTTPClient(client *http.Client) Option { + return func(c *Client) error { + if client != nil { + c.HTTPClient = client + } + + return nil + } +} diff --git a/providers/dns/cloudflare/internal/types.go b/providers/dns/cloudflare/internal/types.go new file mode 100644 index 000000000..2b6d2e2b6 --- /dev/null +++ b/providers/dns/cloudflare/internal/types.go @@ -0,0 +1,120 @@ +package internal + +import "fmt" + +type Record struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + TTL int `json:"ttl,omitempty"` + Type string `json:"type,omitempty"` + Comment string `json:"comment,omitempty"` + Content string `json:"content,omitempty"` +} + +type APIResponse[T any] struct { + Errors Errors `json:"errors,omitempty"` + Messages []Message `json:"messages,omitempty"` + Success bool `json:"success,omitempty"` + Result T `json:"result,omitempty"` + ResultInfo *ResultInfo `json:"result_info,omitempty"` +} + +type Message struct { + Code int `json:"code"` + Message string `json:"message"` + DocumentationURL string `json:"documentation_url"` + Source *Source `json:"source"` + ErrorChain []ErrorChain `json:"error_chain"` +} + +type Source struct { + Pointer string `json:"pointer"` +} + +type ErrorChain struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type Errors []Message + +func (e Errors) Error() string { + var msg string + + for _, item := range e { + msg = fmt.Sprintf("%d: %s", item.Code, item.Message) + + for _, link := range item.ErrorChain { + msg += fmt.Sprintf("; %d: %s", link.Code, link.Message) + } + } + + return msg +} + +type ResultInfo struct { + Count int `json:"count"` + Page int `json:"page"` + PerPage int `json:"per_page"` + TotalCount int `json:"total_count"` + TotalPages int `json:"total_pages"` +} + +type Zone struct { + ID string `json:"id"` + Account Account `json:"account"` + Meta Meta `json:"meta"` + Name string `json:"name"` + Owner Owner `json:"owner"` + Plan Plan `json:"plan"` + CnameSuffix string `json:"cname_suffix"` + Paused bool `json:"paused"` + Permissions []string `json:"permissions"` + Tenant Tenant `json:"tenant"` + TenantUnit TenantUnit `json:"tenant_unit"` + Type string `json:"type"` + VanityNameServers []string `json:"vanity_name_servers"` +} + +type Account struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type Meta struct { + CdnOnly bool `json:"cdn_only"` + CustomCertificateQuota int `json:"custom_certificate_quota"` + DNSOnly bool `json:"dns_only"` + FoundationDNS bool `json:"foundation_dns"` + PageRuleQuota int `json:"page_rule_quota"` + PhishingDetected bool `json:"phishing_detected"` + Step int `json:"step"` +} + +type Owner struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` +} + +type Plan struct { + ID string `json:"id"` + CanSubscribe bool `json:"can_subscribe"` + Currency string `json:"currency"` + ExternallyManaged bool `json:"externally_managed"` + Frequency string `json:"frequency"` + IsSubscribed bool `json:"is_subscribed"` + LegacyDiscount bool `json:"legacy_discount"` + LegacyID string `json:"legacy_id"` + Price int `json:"price"` + Name string `json:"name"` +} + +type Tenant struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type TenantUnit struct { + ID string `json:"id"` +} diff --git a/providers/dns/cloudflare/wrapper.go b/providers/dns/cloudflare/wrapper.go index 92733a57f..1ab36800d 100644 --- a/providers/dns/cloudflare/wrapper.go +++ b/providers/dns/cloudflare/wrapper.go @@ -2,29 +2,28 @@ package cloudflare import ( "context" + "errors" "sync" - "github.com/cloudflare/cloudflare-go" "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/providers/dns/cloudflare/internal" ) type metaClient struct { - clientEdit *cloudflare.API // needs Zone/DNS/Edit permissions - clientRead *cloudflare.API // needs Zone/Zone/Read permissions + clientEdit *internal.Client // needs Zone/DNS/Edit permissions + clientRead *internal.Client // needs Zone/Zone/Read permissions zones map[string]string // caches calls to ZoneIDByName, see lookupZoneID() zonesMu *sync.RWMutex } func newClient(config *Config) (*metaClient, error) { - options := []cloudflare.Option{cloudflare.HTTPClient(config.HTTPClient)} - if config.BaseURL != "" { - options = append(options, cloudflare.BaseURL(config.BaseURL)) - } - // with AuthKey/AuthEmail we can access all available APIs if config.AuthToken == "" { - client, err := cloudflare.New(config.AuthKey, config.AuthEmail, options...) + client, err := internal.NewClient( + internal.WithBaseURL(config.BaseURL), + internal.WithHTTPClient(config.HTTPClient), + internal.WithAuthKey(config.AuthEmail, config.AuthKey)) if err != nil { return nil, err } @@ -37,7 +36,10 @@ func newClient(config *Config) (*metaClient, error) { }, nil } - dns, err := cloudflare.NewWithAPIToken(config.AuthToken, options...) + dns, err := internal.NewClient( + internal.WithBaseURL(config.BaseURL), + internal.WithHTTPClient(config.HTTPClient), + internal.WithAuthToken(config.AuthToken)) if err != nil { return nil, err } @@ -51,7 +53,10 @@ func newClient(config *Config) (*metaClient, error) { }, nil } - zone, err := cloudflare.NewWithAPIToken(config.ZoneToken, options...) + zone, err := internal.NewClient( + internal.WithBaseURL(config.BaseURL), + internal.WithHTTPClient(config.HTTPClient), + internal.WithAuthToken(config.ZoneToken)) if err != nil { return nil, err } @@ -64,19 +69,15 @@ func newClient(config *Config) (*metaClient, error) { }, nil } -func (m *metaClient) CreateDNSRecord(ctx context.Context, zoneID string, rr cloudflare.CreateDNSRecordParams) (cloudflare.DNSRecord, error) { - return m.clientEdit.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(zoneID), rr) -} - -func (m *metaClient) DNSRecords(ctx context.Context, zoneID string, rr cloudflare.ListDNSRecordsParams) ([]cloudflare.DNSRecord, *cloudflare.ResultInfo, error) { - return m.clientEdit.ListDNSRecords(ctx, cloudflare.ZoneIdentifier(zoneID), rr) +func (m *metaClient) CreateDNSRecord(ctx context.Context, zoneID string, rr internal.Record) (*internal.Record, error) { + return m.clientEdit.CreateDNSRecord(ctx, zoneID, rr) } func (m *metaClient) DeleteDNSRecord(ctx context.Context, zoneID, recordID string) error { - return m.clientEdit.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(zoneID), recordID) + return m.clientEdit.DeleteDNSRecord(ctx, zoneID, recordID) } -func (m *metaClient) ZoneIDByName(fdqn string) (string, error) { +func (m *metaClient) ZoneIDByName(ctx context.Context, fdqn string) (string, error) { m.zonesMu.RLock() id := m.zones[fdqn] m.zonesMu.RUnlock() @@ -85,7 +86,12 @@ func (m *metaClient) ZoneIDByName(fdqn string) (string, error) { return id, nil } - id, err := m.clientRead.ZoneIDByName(dns01.UnFqdn(fdqn)) + zones, err := m.clientRead.ZonesByName(ctx, dns01.UnFqdn(fdqn)) + if err != nil { + return "", err + } + + id, err = extractZoneID(zones) if err != nil { return "", err } @@ -95,3 +101,14 @@ func (m *metaClient) ZoneIDByName(fdqn string) (string, error) { m.zonesMu.Unlock() return id, nil } + +func extractZoneID(res []internal.Zone) (string, error) { + switch len(res) { + case 0: + return "", errors.New("zone could not be found") + case 1: + return res[0].ID, nil + default: + return "", errors.New("ambiguous zone name; an account ID might help") + } +}