acme-dns: HTTP storage (#2393)

This commit is contained in:
Ludovic Fernandez
2025-01-09 22:12:05 +01:00
committed by GitHub
parent b83c1d5f64
commit c2b88e19da
15 changed files with 591 additions and 170 deletions

View File

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

View File

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

View File

@@ -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)
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/acmedns/acmedns.toml -->

2
go.mod
View File

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

4
go.sum
View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
{
"message": "There is an error"
}

View File

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

View File

@@ -0,0 +1,7 @@
{
"fulldomain": "foo.example.com",
"subdomain": "foo",
"username": "user",
"password": "secret",
"server_url": "https://example.com"
}

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
# HTTP Storage
## Fetch
### Request
Endpoint: `GET <BaseURL>/<domain>`
### 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 <BaseURL>`
### 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 <BaseURL>/<domain>`
### Response
Response status code 200.
No expected body.
## Save
No dedicated endpoint.

View File

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