From c2b88e19da2c9641cb2cc44953700845bb8287d7 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Thu, 9 Jan 2025 22:12:05 +0100 Subject: [PATCH] acme-dns: HTTP storage (#2393) --- .golangci.yml | 13 +- cmd/zz_gen_cmd_dnshelp.go | 1 + docs/content/dns/zz_gen_acme-dns.md | 9 +- go.mod | 2 +- go.sum | 4 +- providers/dns/acmedns/acmedns.go | 66 ++++++-- providers/dns/acmedns/acmedns.toml | 9 +- providers/dns/acmedns/acmedns_test.go | 140 +---------------- .../dns/acmedns/internal/fixtures/error.json | 3 + .../acmedns/internal/fixtures/fetch-all.json | 16 ++ .../dns/acmedns/internal/fixtures/fetch.json | 7 + .../dns/acmedns/internal/http_storage.go | 139 +++++++++++++++++ .../dns/acmedns/internal/http_storage_test.go | 139 +++++++++++++++++ providers/dns/acmedns/internal/readme.md | 70 +++++++++ providers/dns/acmedns/mock_test.go | 143 ++++++++++++++++++ 15 files changed, 591 insertions(+), 170 deletions(-) create mode 100644 providers/dns/acmedns/internal/fixtures/error.json create mode 100644 providers/dns/acmedns/internal/fixtures/fetch-all.json create mode 100644 providers/dns/acmedns/internal/fixtures/fetch.json create mode 100644 providers/dns/acmedns/internal/http_storage.go create mode 100644 providers/dns/acmedns/internal/http_storage_test.go create mode 100644 providers/dns/acmedns/internal/readme.md create mode 100644 providers/dns/acmedns/mock_test.go diff --git a/.golangci.yml b/.golangci.yml index 9467cf549..9beeea718 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -165,9 +165,6 @@ issues: text: 'Error return value of `fmt.Fprintln` is not checked' linters: - errcheck - - path: providers/dns/dns_providers.go - linters: - - gocyclo - path: certcrypto/crypto.go text: '(tlsFeatureExtensionOID|ocspMustStapleFeature) is a global variable' linters: @@ -220,7 +217,7 @@ issues: text: 'testCases is a global variable' linters: - gochecknoglobals - - path: providers/dns/acmedns/acmedns_test.go + - path: providers/dns/acmedns/mock_test.go text: 'egTestAccount is a global variable' linters: - gochecknoglobals @@ -228,10 +225,6 @@ issues: text: 'memcachedHosts is a global variable' linters: - gochecknoglobals - - path: cmd/zz_gen_cmd_dnshelp.go - linters: - - gocyclo - - funlen - path: providers/dns/checkdomain/internal/types.go text: '`payed` is a misspelling of `paid`' linters: @@ -259,10 +252,6 @@ issues: text: 'cyclomatic complexity 13 of func `\(\*DNSProvider\)\.CleanUp` is high' linters: - gocyclo - - path: providers/dns/servercow/internal/types.go - text: 'the methods of "Value" use pointer receiver and non-pointer receiver.' - linters: - - recvcheck # Those elements have been replaced by non-exposed structures. - path: providers/dns/linode/linode_test.go diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index e5ae3b46d..e529ce234 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -176,6 +176,7 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(`Credentials:`) ew.writeln(` - "ACME_DNS_API_BASE": The ACME-DNS API address`) + ew.writeln(` - "ACME_DNS_STORAGE_BASE_URL": The ACME-DNS JSON account data server.`) ew.writeln(` - "ACME_DNS_STORAGE_PATH": The ACME-DNS JSON account data file. A per-domain account will be registered/persisted to this file and used for TXT updates.`) ew.writeln() diff --git a/docs/content/dns/zz_gen_acme-dns.md b/docs/content/dns/zz_gen_acme-dns.md index 0d57146ff..be901c512 100644 --- a/docs/content/dns/zz_gen_acme-dns.md +++ b/docs/content/dns/zz_gen_acme-dns.md @@ -29,6 +29,12 @@ Here is an example bash command using the Joohoi's ACME-DNS provider: ACME_DNS_API_BASE=http://10.0.0.8:4443 \ ACME_DNS_STORAGE_PATH=/root/.lego-acme-dns-accounts.json \ lego --email you@example.com --dns "acme-dns" -d '*.example.com' -d example.com run + +# or + +ACME_DNS_API_BASE=http://10.0.0.8:4443 \ +ACME_DNS_STORAGE_BASE_URL=http://10.10.10.10:80 \ +lego --email you@example.com --dns "acme-dns" -d '*.example.com' -d example.com run ``` @@ -39,6 +45,7 @@ lego --email you@example.com --dns "acme-dns" -d '*.example.com' -d example.com | Environment Variable Name | Description | |-----------------------|-------------| | `ACME_DNS_API_BASE` | The ACME-DNS API address | +| `ACME_DNS_STORAGE_BASE_URL` | The ACME-DNS JSON account data server. | | `ACME_DNS_STORAGE_PATH` | The ACME-DNS JSON account data file. A per-domain account will be registered/persisted to this file and used for TXT updates. | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. @@ -52,7 +59,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). ## More information - [API documentation](https://github.com/joohoi/acme-dns#api) -- [Go client](https://github.com/cpu/goacmedns) +- [Go client](https://github.com/nrdcg/goacmedns) diff --git a/go.mod b/go.mod index fcd88001a..171e71e5b 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,6 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 github.com/civo/civogo v0.3.11 github.com/cloudflare/cloudflare-go v0.112.0 - github.com/cpu/goacmedns v0.1.1 github.com/dnsimple/dnsimple-go v1.7.0 github.com/exoscale/egoscale/v3 v3.1.7 github.com/go-jose/go-jose/v4 v4.0.4 @@ -52,6 +51,7 @@ require ( github.com/nrdcg/desec v0.10.0 github.com/nrdcg/dnspod-go v0.4.0 github.com/nrdcg/freemyip v0.3.0 + github.com/nrdcg/goacmedns v0.2.0 github.com/nrdcg/goinwx v0.10.0 github.com/nrdcg/mailinabox v0.2.0 github.com/nrdcg/namesilo v0.2.1 diff --git a/go.sum b/go.sum index c5e736459..c7de23dd5 100644 --- a/go.sum +++ b/go.sum @@ -209,8 +209,6 @@ github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= -github.com/cpu/goacmedns v0.1.1 h1:DM3H2NiN2oam7QljgGY5ygy4yDXhK5Z4JUnqaugs2C4= -github.com/cpu/goacmedns v0.1.1/go.mod h1:MuaouqEhPAHxsbqjgnck5zeghuwBP1dLnPoobeGqugQ= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= @@ -638,6 +636,8 @@ github.com/nrdcg/dnspod-go v0.4.0 h1:c/jn1mLZNKF3/osJ6mz3QPxTudvPArXTjpkmYj0uK6U github.com/nrdcg/dnspod-go v0.4.0/go.mod h1:vZSoFSFeQVm2gWLMkyX61LZ8HI3BaqtHZWgPTGKr6KQ= github.com/nrdcg/freemyip v0.3.0 h1:0D2rXgvLwe2RRaVIjyUcQ4S26+cIS2iFwnhzDsEuuwc= github.com/nrdcg/freemyip v0.3.0/go.mod h1:c1PscDvA0ukBF0dwelU/IwOakNKnVxetpAQ863RMJoM= +github.com/nrdcg/goacmedns v0.2.0 h1:ADMbThobzEMnr6kg2ohs4KGa3LFqmgiBA22/6jUWJR0= +github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg= github.com/nrdcg/goinwx v0.10.0 h1:6W630bjDxQD6OuXKqrFRYVpTt0G/9GXXm3CeOrN0zJM= github.com/nrdcg/goinwx v0.10.0/go.mod h1:mnMSTi7CXBu2io4DzdOBoGFA1XclD0sEPWJaDhNgkA4= github.com/nrdcg/mailinabox v0.2.0 h1:IKq8mfKiVwNW2hQii/ng1dJ4yYMMv3HAP3fMFIq2CFk= diff --git a/providers/dns/acmedns/acmedns.go b/providers/dns/acmedns/acmedns.go index 7ba7f08d0..8aabaa14c 100644 --- a/providers/dns/acmedns/acmedns.go +++ b/providers/dns/acmedns/acmedns.go @@ -3,13 +3,16 @@ package acmedns import ( + "context" "errors" "fmt" - "github.com/cpu/goacmedns" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/acmedns/internal" + "github.com/nrdcg/goacmedns" + "github.com/nrdcg/goacmedns/storage" ) const ( @@ -19,9 +22,14 @@ const ( // EnvAPIBase is the environment variable name for the ACME-DNS API address. // (e.g. https://acmedns.your-domain.com). EnvAPIBase = envNamespace + "API_BASE" + // EnvStoragePath is the environment variable name for the ACME-DNS JSON account data file. // A per-domain account will be registered/persisted to this file and used for TXT updates. EnvStoragePath = envNamespace + "STORAGE_PATH" + + // EnvStorageBaseURL is the environment variable name for the ACME-DNS JSON account data. + // The URL to the storage server. + EnvStorageBaseURL = envNamespace + "STORAGE_BASE_URL" ) var _ challenge.Provider = (*DNSProvider)(nil) @@ -31,10 +39,10 @@ var _ challenge.Provider = (*DNSProvider)(nil) type acmeDNSClient interface { // UpdateTXTRecord updates the provided account's TXT record // to the given value or returns an error. - UpdateTXTRecord(account goacmedns.Account, value string) error + UpdateTXTRecord(ctx context.Context, account goacmedns.Account, value string) error // RegisterAccount registers and returns a new account // with the given allowFrom restriction or returns an error. - RegisterAccount(allowFrom []string) (goacmedns.Account, error) + RegisterAccount(ctx context.Context, allowFrom []string) (goacmedns.Account, error) } // DNSProvider implements the challenge.Provider interface. @@ -46,17 +54,41 @@ type DNSProvider struct { // NewDNSProvider creates an ACME-DNS provider using file based account storage. // Its configuration is loaded from the environment by reading EnvAPIBase and EnvStoragePath. func NewDNSProvider() (*DNSProvider, error) { - values, err := env.Get(EnvAPIBase, EnvStoragePath) + values, err := env.Get(EnvAPIBase) if err != nil { return nil, fmt.Errorf("acme-dns: %w", err) } - client := goacmedns.NewClient(values[EnvAPIBase]) - storage := goacmedns.NewFileStorage(values[EnvStoragePath], 0o600) - return NewDNSProviderClient(client, storage) + storagePath := env.GetOrFile(EnvStoragePath) + storageBaseURL := env.GetOrFile(EnvStorageBaseURL) + + if storagePath == "" && storageBaseURL == "" { + return nil, fmt.Errorf("acme-dns: %s or %s environment variables not set", EnvStoragePath, EnvStorageBaseURL) + } + + if storagePath != "" && storageBaseURL != "" { + return nil, fmt.Errorf("acme-dns: %s or %s environment variables cannot be used at the same time", EnvStoragePath, EnvStorageBaseURL) + } + + var st goacmedns.Storage + if storagePath != "" { + st = storage.NewFile(values[EnvStoragePath], 0o600) + } else { + st, err = internal.NewHTTPStorage(storageBaseURL) + if err != nil { + return nil, fmt.Errorf("acme-dns: new HTTP storage: %w", err) + } + } + + client, err := goacmedns.NewClient(values[EnvAPIBase]) + if err != nil { + return nil, fmt.Errorf("acme-dns: %w", err) + } + + return NewDNSProviderClient(client, st) } -// NewDNSProviderClient creates an ACME-DNS DNSProvider with the given acmeDNSClient and goacmedns.Storage. +// NewDNSProviderClient creates an ACME-DNS DNSProvider with the given acmeDNSClient and [goacmedns.Storage]. func NewDNSProviderClient(client acmeDNSClient, storage goacmedns.Storage) (*DNSProvider, error) { if client == nil { return nil, errors.New("ACME-DNS Client must be not nil") @@ -105,16 +137,18 @@ func (e ErrCNAMERequired) Error() string { // one will be created and registered with the ACME DNS server and an ErrCNAMERequired error is returned. // This will halt issuance and indicate to the user that a one-time manual setup is required for the domain. func (d *DNSProvider) Present(domain, _, keyAuth string) error { + ctx := context.Background() + // Compute the challenge response FQDN and TXT value for the domain based on the keyAuth. info := dns01.GetChallengeInfo(domain, keyAuth) // Check if credentials were previously saved for this domain. - account, err := d.storage.Fetch(domain) + account, err := d.storage.Fetch(ctx, domain) if err != nil { - if errors.Is(err, goacmedns.ErrDomainNotFound) { + if errors.Is(err, storage.ErrDomainNotFound) { // The account did not exist. // Create a new one and return an error indicating the required one-time manual CNAME setup. - return d.register(domain, info.FQDN) + return d.register(ctx, domain, info.FQDN) } // Errors other than goacmedns.ErrDomainNotFound are unexpected. @@ -122,7 +156,7 @@ func (d *DNSProvider) Present(domain, _, keyAuth string) error { } // Update the acme-dns TXT record. - return d.client.UpdateTXTRecord(account, info.Value) + return d.client.UpdateTXTRecord(ctx, account, info.Value) } // CleanUp removes the record matching the specified parameters. It is not @@ -137,19 +171,19 @@ func (d *DNSProvider) CleanUp(_, _, _ string) error { // If account creation works as expected a ErrCNAMERequired error is returned describing // the one-time manual CNAME setup required to complete setup of the ACME-DNS hook for the domain. // If any other error occurs it is returned as-is. -func (d *DNSProvider) register(domain, fqdn string) error { +func (d *DNSProvider) register(ctx context.Context, domain, fqdn string) error { // TODO(@cpu): Read CIDR whitelists from the environment - newAcct, err := d.client.RegisterAccount(nil) + newAcct, err := d.client.RegisterAccount(ctx, nil) if err != nil { return err } // Store the new account in the storage and call save to persist the data. - err = d.storage.Put(domain, newAcct) + err = d.storage.Put(ctx, domain, newAcct) if err != nil { return err } - err = d.storage.Save() + err = d.storage.Save(ctx) if err != nil { return err } diff --git a/providers/dns/acmedns/acmedns.toml b/providers/dns/acmedns/acmedns.toml index f4632411b..12d690414 100644 --- a/providers/dns/acmedns/acmedns.toml +++ b/providers/dns/acmedns/acmedns.toml @@ -9,13 +9,20 @@ Example = ''' ACME_DNS_API_BASE=http://10.0.0.8:4443 \ ACME_DNS_STORAGE_PATH=/root/.lego-acme-dns-accounts.json \ lego --email you@example.com --dns "acme-dns" -d '*.example.com' -d example.com run + +# or + +ACME_DNS_API_BASE=http://10.0.0.8:4443 \ +ACME_DNS_STORAGE_BASE_URL=http://10.10.10.10:80 \ +lego --email you@example.com --dns "acme-dns" -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] ACME_DNS_API_BASE = "The ACME-DNS API address" ACME_DNS_STORAGE_PATH = "The ACME-DNS JSON account data file. A per-domain account will be registered/persisted to this file and used for TXT updates." + ACME_DNS_STORAGE_BASE_URL = "The ACME-DNS JSON account data server." [Links] API = "https://github.com/joohoi/acme-dns#api" - GoClient = "https://github.com/cpu/goacmedns" + GoClient = "https://github.com/nrdcg/goacmedns" diff --git a/providers/dns/acmedns/acmedns_test.go b/providers/dns/acmedns/acmedns_test.go index 68e8f7406..e21a10522 100644 --- a/providers/dns/acmedns/acmedns_test.go +++ b/providers/dns/acmedns/acmedns_test.go @@ -1,21 +1,14 @@ package acmedns import ( - "errors" + "context" "testing" - "github.com/cpu/goacmedns" + "github.com/nrdcg/goacmedns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -var ( - // errorClientErr is used by the Client mocks that return an error. - errorClientErr = errors.New("errorClient always errors") - // errorStorageErr is used by the Storage mocks that return an error. - errorStorageErr = errors.New("errorStorage always errors") -) - const ( // Fixed test data for unit tests. egDomain = "example.com" @@ -23,133 +16,6 @@ const ( egKeyAuth = "⚷" ) -var egTestAccount = goacmedns.Account{ - FullDomain: "acme-dns." + egDomain, - SubDomain: "random-looking-junk." + egDomain, - Username: "spooky.mulder", - Password: "trustno1", -} - -// mockClient is a mock implementing the acmeDNSClient interface that always -// returns a fixed goacmedns.Account from calls to Register. -type mockClient struct { - mockAccount goacmedns.Account -} - -// UpdateTXTRecord does nothing. -func (c mockClient) UpdateTXTRecord(_ goacmedns.Account, _ string) error { - return nil -} - -// RegisterAccount returns c.mockAccount and no errors. -func (c mockClient) RegisterAccount(_ []string) (goacmedns.Account, error) { - return c.mockAccount, nil -} - -// mockUpdateClient is a mock implementing the acmeDNSClient interface that -// tracks the calls to UpdateTXTRecord in the records map. -type mockUpdateClient struct { - mockClient - records map[goacmedns.Account]string -} - -// UpdateTXTRecord saves a record value to c.records for the given acct. -func (c mockUpdateClient) UpdateTXTRecord(acct goacmedns.Account, value string) error { - c.records[acct] = value - return nil -} - -// errorUpdateClient is a mock implementing the acmeDNSClient interface that always -// returns errors from errorUpdateClient. -type errorUpdateClient struct { - mockClient -} - -// UpdateTXTRecord always returns an error. -func (c errorUpdateClient) UpdateTXTRecord(_ goacmedns.Account, _ string) error { - return errorClientErr -} - -// errorRegisterClient is a mock implementing the acmeDNSClient interface that always -// returns errors from RegisterAccount. -type errorRegisterClient struct { - mockClient -} - -// RegisterAccount always returns an error. -func (c errorRegisterClient) RegisterAccount(_ []string) (goacmedns.Account, error) { - return goacmedns.Account{}, errorClientErr -} - -// mockStorage is a mock implementing the goacmedns.Storage interface that -// returns static account data and ignores Save. -type mockStorage struct { - accounts map[string]goacmedns.Account -} - -// Save does nothing. -func (m mockStorage) Save() error { - return nil -} - -// Put stores an account for the given domain in m.accounts. -func (m mockStorage) Put(domain string, acct goacmedns.Account) error { - m.accounts[domain] = acct - return nil -} - -// Fetch retrieves an account for the given domain from m.accounts or returns -// goacmedns.ErrDomainNotFound. -func (m mockStorage) Fetch(domain string) (goacmedns.Account, error) { - if acct, ok := m.accounts[domain]; ok { - return acct, nil - } - return goacmedns.Account{}, goacmedns.ErrDomainNotFound -} - -// FetchAll returns all of m.accounts. -func (m mockStorage) FetchAll() map[string]goacmedns.Account { - return m.accounts -} - -// errorPutStorage is a mock implementing the goacmedns.Storage interface that -// always returns errors from Put. -type errorPutStorage struct { - mockStorage -} - -// Put always errors. -func (e errorPutStorage) Put(_ string, _ goacmedns.Account) error { - return errorStorageErr -} - -// errorSaveStorage is a mock implementing the goacmedns.Storage interface that -// always returns errors from Save. -type errorSaveStorage struct { - mockStorage -} - -// Save always errors. -func (e errorSaveStorage) Save() error { - return errorStorageErr -} - -// errorFetchStorage is a mock implementing the goacmedns.Storage interface that -// always returns errors from Fetch. -type errorFetchStorage struct { - mockStorage -} - -// Fetch always errors. -func (e errorFetchStorage) Fetch(_ string) (goacmedns.Account, error) { - return goacmedns.Account{}, errorStorageErr -} - -// FetchAll is a nop for errorFetchStorage. -func (e errorFetchStorage) FetchAll() map[string]goacmedns.Account { - return nil -} - // TestPresent tests that the ACME-DNS Present function for updating a DNS-01 // challenge response TXT record works as expected. func TestPresent(t *testing.T) { @@ -277,7 +143,7 @@ func TestRegister(t *testing.T) { } // Call register for the example domain/fqdn. - err = dp.register(egDomain, egFQDN) + err = dp.register(context.Background(), egDomain, egFQDN) if test.ExpectedError != nil { assert.Equal(t, test.ExpectedError, err) } else { diff --git a/providers/dns/acmedns/internal/fixtures/error.json b/providers/dns/acmedns/internal/fixtures/error.json new file mode 100644 index 000000000..d1b2ba3be --- /dev/null +++ b/providers/dns/acmedns/internal/fixtures/error.json @@ -0,0 +1,3 @@ +{ + "message": "There is an error" +} diff --git a/providers/dns/acmedns/internal/fixtures/fetch-all.json b/providers/dns/acmedns/internal/fixtures/fetch-all.json new file mode 100644 index 000000000..9ea557b38 --- /dev/null +++ b/providers/dns/acmedns/internal/fixtures/fetch-all.json @@ -0,0 +1,16 @@ +{ + "a": { + "fulldomain": "foo.example.com", + "subdomain": "foo", + "username": "user", + "password": "secret", + "server_url": "https://example.com" + }, + "b": { + "fulldomain": "bar.example.com", + "subdomain": "bar", + "username": "user", + "password": "secret", + "server_url": "https://example.com" + } +} diff --git a/providers/dns/acmedns/internal/fixtures/fetch.json b/providers/dns/acmedns/internal/fixtures/fetch.json new file mode 100644 index 000000000..d29cebc5b --- /dev/null +++ b/providers/dns/acmedns/internal/fixtures/fetch.json @@ -0,0 +1,7 @@ +{ + "fulldomain": "foo.example.com", + "subdomain": "foo", + "username": "user", + "password": "secret", + "server_url": "https://example.com" +} diff --git a/providers/dns/acmedns/internal/http_storage.go b/providers/dns/acmedns/internal/http_storage.go new file mode 100644 index 000000000..1a1e8e2ee --- /dev/null +++ b/providers/dns/acmedns/internal/http_storage.go @@ -0,0 +1,139 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/nrdcg/goacmedns" + "github.com/nrdcg/goacmedns/storage" +) + +var _ goacmedns.Storage = (*HTTPStorage)(nil) + +// HTTPStorage is an implementation of [acmedns.Storage] over HTTP. +type HTTPStorage struct { + client *http.Client + baseURL *url.URL +} + +// NewHTTPStorage created a new [HTTPStorage]. +func NewHTTPStorage(baseURL string) (*HTTPStorage, error) { + endpoint, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + + return &HTTPStorage{ + client: &http.Client{Timeout: 2 * time.Minute}, + baseURL: endpoint, + }, nil +} + +func (s *HTTPStorage) Save(_ context.Context) error { + return nil +} + +func (s *HTTPStorage) Put(ctx context.Context, domain string, account goacmedns.Account) error { + req, err := newJSONRequest(ctx, http.MethodPost, s.baseURL.JoinPath(domain), account) + if err != nil { + return fmt.Errorf("unable to create request: %w", err) + } + + return s.do(req, nil) +} + +func (s *HTTPStorage) Fetch(ctx context.Context, domain string) (goacmedns.Account, error) { + req, err := newJSONRequest(ctx, http.MethodGet, s.baseURL.JoinPath(domain), nil) + if err != nil { + return goacmedns.Account{}, fmt.Errorf("unable to create request: %w", err) + } + + var account goacmedns.Account + + err = s.do(req, &account) + if err != nil { + return goacmedns.Account{}, err + } + + return account, nil +} + +func (s *HTTPStorage) FetchAll(ctx context.Context) (map[string]goacmedns.Account, error) { + req, err := newJSONRequest(ctx, http.MethodGet, s.baseURL, nil) + if err != nil { + return nil, err + } + + var mapping map[string]goacmedns.Account + + err = s.do(req, &mapping) + if err != nil { + return nil, err + } + + return mapping, nil +} + +func (s *HTTPStorage) do(req *http.Request, result any) error { + resp, err := s.client.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return storage.ErrDomainNotFound + } + + if resp.StatusCode/100 != 2 { + return errutils.NewUnexpectedResponseStatusCodeError(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 +} diff --git a/providers/dns/acmedns/internal/http_storage_test.go b/providers/dns/acmedns/internal/http_storage_test.go new file mode 100644 index 000000000..a628f9a6d --- /dev/null +++ b/providers/dns/acmedns/internal/http_storage_test.go @@ -0,0 +1,139 @@ +package internal + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/nrdcg/goacmedns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTest(t *testing.T, pattern, filename string, statusCode int) *HTTPStorage { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + mux.HandleFunc(pattern, func(rw http.ResponseWriter, req *http.Request) { + if filename == "" { + rw.WriteHeader(statusCode) + return + } + + file, err := os.Open(filepath.Join("fixtures", filename)) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + defer func() { _ = file.Close() }() + + rw.WriteHeader(statusCode) + _, err = io.Copy(rw, file) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + storage, err := NewHTTPStorage(server.URL) + require.NoError(t, err) + + storage.client = server.Client() + + return storage +} + +func TestHTTPStorage_Fetch(t *testing.T) { + storage := setupTest(t, "GET /example.com", "fetch.json", http.StatusOK) + + account, err := storage.Fetch(context.Background(), "example.com") + require.NoError(t, err) + + expected := goacmedns.Account{ + FullDomain: "foo.example.com", + SubDomain: "foo", + Username: "user", + Password: "secret", + ServerURL: "https://example.com", + } + + assert.Equal(t, expected, account) +} + +func TestHTTPStorage_Fetch_error(t *testing.T) { + storage := setupTest(t, "GET /example.com", "error.json", http.StatusInternalServerError) + + _, err := storage.Fetch(context.Background(), "example.com") + require.Error(t, err) +} + +func TestHTTPStorage_FetchAll(t *testing.T) { + storage := setupTest(t, "GET /", "fetch-all.json", http.StatusOK) + + account, err := storage.FetchAll(context.Background()) + require.NoError(t, err) + + expected := map[string]goacmedns.Account{ + "a": { + FullDomain: "foo.example.com", + SubDomain: "foo", + Username: "user", + Password: "secret", + ServerURL: "https://example.com", + }, + "b": { + FullDomain: "bar.example.com", + SubDomain: "bar", + Username: "user", + Password: "secret", + ServerURL: "https://example.com", + }, + } + + assert.Equal(t, expected, account) +} + +func TestHTTPStorage_FetchAll_error(t *testing.T) { + storage := setupTest(t, "GET /", "error.json", http.StatusInternalServerError) + + _, err := storage.FetchAll(context.Background()) + require.Error(t, err) +} + +func TestHTTPStorage_Put(t *testing.T) { + storage := setupTest(t, "POST /example.com", "", http.StatusOK) + + account := goacmedns.Account{ + FullDomain: "foo.example.com", + SubDomain: "foo", + Username: "user", + Password: "secret", + ServerURL: "https://example.com", + } + + err := storage.Put(context.Background(), "example.com", account) + require.NoError(t, err) +} + +func TestHTTPStorage_Put_error(t *testing.T) { + storage := setupTest(t, "POST /example.com", "error.json", http.StatusInternalServerError) + + account := goacmedns.Account{ + FullDomain: "foo.example.com", + SubDomain: "foo", + Username: "user", + Password: "secret", + ServerURL: "https://example.com", + } + + err := storage.Put(context.Background(), "example.com", account) + require.Error(t, err) +} diff --git a/providers/dns/acmedns/internal/readme.md b/providers/dns/acmedns/internal/readme.md new file mode 100644 index 000000000..bccdc5388 --- /dev/null +++ b/providers/dns/acmedns/internal/readme.md @@ -0,0 +1,70 @@ +# HTTP Storage + +## Fetch + +### Request + +Endpoint: `GET /` + +### Response + +Response status code 200. + +Response body (account): + +```json +{ + "fulldomain": "foo.example.com", + "subdomain": "foo", + "username": "user", + "password": "secret", + "server_url": "https://example.com" +} +``` + +## Fetch All + +### Request + +Endpoint: `GET ` + +### Response + +Response status code 200. + +Response body (domain/account mapping): + +```json +{ + "foo.example.com": { + "fulldomain": "foo.example.com", + "subdomain": "foo", + "username": "user", + "password": "secret", + "server_url": "https://example.com" + }, + "bar.example.com": { + "fulldomain": "bar.example.com", + "subdomain": "bar", + "username": "user", + "password": "secret", + "server_url": "https://example.com" + } +} +``` + +## Put + +### Request + +Endpoint: `POST /` + +### Response + +Response status code 200. + +No expected body. + +## Save + +No dedicated endpoint. diff --git a/providers/dns/acmedns/mock_test.go b/providers/dns/acmedns/mock_test.go new file mode 100644 index 000000000..0d188ecc4 --- /dev/null +++ b/providers/dns/acmedns/mock_test.go @@ -0,0 +1,143 @@ +package acmedns + +import ( + "context" + "errors" + + "github.com/nrdcg/goacmedns" + "github.com/nrdcg/goacmedns/storage" +) + +var ( + // errorClientErr is used by the Client mocks that return an error. + errorClientErr = errors.New("errorClient always errors") + // errorStorageErr is used by the Storage mocks that return an error. + errorStorageErr = errors.New("errorStorage always errors") +) + +var egTestAccount = goacmedns.Account{ + FullDomain: "acme-dns." + egDomain, + SubDomain: "random-looking-junk." + egDomain, + Username: "spooky.mulder", + Password: "trustno1", +} + +// mockClient is a mock implementing the acmeDNSClient interface that always +// returns a fixed goacmedns.Account from calls to Register. +type mockClient struct { + mockAccount goacmedns.Account +} + +// UpdateTXTRecord does nothing. +func (c mockClient) UpdateTXTRecord(_ context.Context, _ goacmedns.Account, _ string) error { + return nil +} + +// RegisterAccount returns c.mockAccount and no errors. +func (c mockClient) RegisterAccount(_ context.Context, _ []string) (goacmedns.Account, error) { + return c.mockAccount, nil +} + +// mockUpdateClient is a mock implementing the acmeDNSClient interface that +// tracks the calls to UpdateTXTRecord in the records map. +type mockUpdateClient struct { + mockClient + records map[goacmedns.Account]string +} + +// UpdateTXTRecord saves a record value to c.records for the given acct. +func (c mockUpdateClient) UpdateTXTRecord(_ context.Context, acct goacmedns.Account, value string) error { + c.records[acct] = value + return nil +} + +// errorUpdateClient is a mock implementing the acmeDNSClient interface that always +// returns errors from errorUpdateClient. +type errorUpdateClient struct { + mockClient +} + +// UpdateTXTRecord always returns an error. +func (c errorUpdateClient) UpdateTXTRecord(_ context.Context, _ goacmedns.Account, _ string) error { + return errorClientErr +} + +// errorRegisterClient is a mock implementing the acmeDNSClient interface that always +// returns errors from RegisterAccount. +type errorRegisterClient struct { + mockClient +} + +// RegisterAccount always returns an error. +func (c errorRegisterClient) RegisterAccount(_ context.Context, _ []string) (goacmedns.Account, error) { + return goacmedns.Account{}, errorClientErr +} + +// mockStorage is a mock implementing the goacmedns.Storage interface that +// returns static account data and ignores Save. +type mockStorage struct { + accounts map[string]goacmedns.Account +} + +// Save does nothing. +func (m mockStorage) Save(_ context.Context) error { + return nil +} + +// Put stores an account for the given domain in m.accounts. +func (m mockStorage) Put(_ context.Context, domain string, acct goacmedns.Account) error { + m.accounts[domain] = acct + return nil +} + +// Fetch retrieves an account for the given domain from m.accounts or returns +// goacmedns.ErrDomainNotFound. +func (m mockStorage) Fetch(_ context.Context, domain string) (goacmedns.Account, error) { + if acct, ok := m.accounts[domain]; ok { + return acct, nil + } + return goacmedns.Account{}, storage.ErrDomainNotFound +} + +// FetchAll returns all of m.accounts. +func (m mockStorage) FetchAll(_ context.Context) (map[string]goacmedns.Account, error) { + return m.accounts, nil +} + +// errorPutStorage is a mock implementing the goacmedns.Storage interface that +// always returns errors from Put. +type errorPutStorage struct { + mockStorage +} + +// Put always errors. +func (e errorPutStorage) Put(_ context.Context, _ string, _ goacmedns.Account) error { + return errorStorageErr +} + +// errorSaveStorage is a mock implementing the goacmedns.Storage interface that +// always returns errors from Save. +type errorSaveStorage struct { + mockStorage +} + +// Save always errors. +func (e errorSaveStorage) Save(_ context.Context) error { + return errorStorageErr +} + +// errorFetchStorage is a mock implementing the goacmedns.Storage interface that +// always returns errors from Fetch. +type errorFetchStorage struct { + mockStorage +} + +// Fetch always errors. +func (e errorFetchStorage) Fetch(_ context.Context, _ string) (goacmedns.Account, error) { + return goacmedns.Account{}, errorStorageErr +} + +// FetchAll is a nop for errorFetchStorage. +func (e errorFetchStorage) FetchAll(_ context.Context) (map[string]goacmedns.Account, error) { + return nil, nil +}