mirror of
https://github.com/go-acme/lego
synced 2026-03-14 22:45:48 +01:00
chore: replace official Cloudflare API client by an internal API client (#2583)
This commit is contained in:
parent
b0e3fd2682
commit
a8e19ef7f3
15 changed files with 826 additions and 44 deletions
2
go.mod
2
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
|
||||
|
|
|
|||
4
go.sum
4
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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
197
providers/dns/cloudflare/internal/client.go
Normal file
197
providers/dns/cloudflare/internal/client.go
Normal 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)
|
||||
}
|
||||
176
providers/dns/cloudflare/internal/client_test.go
Normal file
176
providers/dns/cloudflare/internal/client_test.go
Normal 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")
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"type": "TXT",
|
||||
"name": "_acme-challenge.example.com",
|
||||
"content": "\"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY\"",
|
||||
"ttl": 120
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"result": {
|
||||
"id": "023e105f4ecef8ad9ca31a8372d0c353"
|
||||
}
|
||||
}
|
||||
17
providers/dns/cloudflare/internal/fixtures/error.json
Normal file
17
providers/dns/cloudflare/internal/fixtures/error.json
Normal 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
|
||||
}
|
||||
83
providers/dns/cloudflare/internal/fixtures/zones.json
Normal file
83
providers/dns/cloudflare/internal/fixtures/zones.json
Normal 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
|
||||
}
|
||||
}
|
||||
52
providers/dns/cloudflare/internal/options.go
Normal file
52
providers/dns/cloudflare/internal/options.go
Normal 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
|
||||
}
|
||||
}
|
||||
120
providers/dns/cloudflare/internal/types.go
Normal file
120
providers/dns/cloudflare/internal/types.go
Normal 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"`
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue