chore: replace official Cloudflare API client by an internal API client (#2583)

This commit is contained in:
Ludovic Fernandez 2025-07-13 18:09:26 +02:00 committed by GitHub
commit a8e19ef7f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 826 additions and 44 deletions

2
go.mod
View file

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

4
go.sum
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
{
"type": "TXT",
"name": "_acme-challenge.example.com",
"content": "\"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY\"",
"ttl": 120
}

View file

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

View file

@ -0,0 +1,5 @@
{
"result": {
"id": "023e105f4ecef8ad9ca31a8372d0c353"
}
}

View file

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

View file

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

View file

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

View file

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

View file

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