diff --git a/Gopkg.lock b/Gopkg.lock index bd751230d1..08de54a374 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -88,6 +88,12 @@ revision = "398d14696895d68a3409bb3ccb1cfe8abc2d4376" version = "v1.13.57" +[[projects]] + branch = "master" + name = "github.com/cpu/goacmedns" + packages = ["."] + revision = "f232997f461a5a58982d536108cfc382e512481e" + [[projects]] name = "github.com/davecgh/go-spew" packages = ["spew"] @@ -380,6 +386,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "68128f1cf61f649cdac1483e18c54541782cbd344605cab3cf6c2b448a86bda0" + inputs-digest = "999db8b5286c234be2ff3302595ff6776e73c6773008f5010e0c3d105314e334" solver-name = "gps-cdcl" solver-version = 1 diff --git a/cli.go b/cli.go index b007d8bb47..1329d25222 100644 --- a/cli.go +++ b/cli.go @@ -198,6 +198,7 @@ Here is an example bash command using the CloudFlare DNS provider: w := tabwriter.NewWriter(os.Stdout, 0, 8, 1, '\t', 0) fmt.Fprintln(w, "Valid providers and their associated credential environment variables:") fmt.Fprintln(w) + fmt.Fprintln(w, "\tacme-dns:\tACME_DNS_API_BASE, ACME_DNS_STORAGE_PATH") fmt.Fprintln(w, "\tazure:\tAZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_SUBSCRIPTION_ID, AZURE_TENANT_ID, AZURE_RESOURCE_GROUP") fmt.Fprintln(w, "\tauroradns:\tAURORA_USER_ID, AURORA_KEY, AURORA_ENDPOINT") fmt.Fprintln(w, "\tbluecat:\tBLUECAT_SERVER_URL, BLUECAT_USER_NAME, BLUECAT_PASSWORD, BLUECAT_CONFIG_NAME, BLUECAT_DNS_VIEW") diff --git a/providers/dns/acmedns/acmedns.go b/providers/dns/acmedns/acmedns.go new file mode 100644 index 0000000000..cce0d8d876 --- /dev/null +++ b/providers/dns/acmedns/acmedns.go @@ -0,0 +1,170 @@ +// Package acmedns implements a DNS provider for solving DNS-01 challenges using +// Joohoi's acme-dns project. For more information see the ACME-DNS homepage: +// https://github.com/joohoi/acme-dns +package acmedns + +import ( + "errors" + "fmt" + + "github.com/cpu/goacmedns" + "github.com/xenolf/lego/acme" + "github.com/xenolf/lego/platform/config/env" +) + +const ( + // envNamespace is the prefix for ACME-DNS environment variables. + envNamespace = "ACME_DNS_" + // apiBaseEnvVar is the environment variable name for the ACME-DNS API address + // (e.g. https://acmedns.your-domain.com). + apiBaseEnvVar = envNamespace + "API_BASE" + // storagePathEnvVar 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. + storagePathEnvVar = envNamespace + "STORAGE_PATH" +) + +// acmeDNSClient is an interface describing the goacmedns.Client functions +// the DNSProvider uses. It makes it easier for tests to shim a mock Client into +// the DNSProvider. +type acmeDNSClient interface { + // UpdateTXTRecord updates the provided account's TXT record to the given + // value or returns an error. + UpdateTXTRecord(goacmedns.Account, string) error + // RegisterAccount registers and returns a new account with the given + // allowFrom restriction or returns an error. + RegisterAccount([]string) (goacmedns.Account, error) +} + +// DNSProvider is an implementation of the acme.ChallengeProvider interface for +// an ACME-DNS server. +type DNSProvider struct { + client acmeDNSClient + storage goacmedns.Storage +} + +// NewDNSProvider creates an ACME-DNS provider using file based account storage. +// Its configuration is loaded from the environment by reading apiBaseEnvVar and +// storagePathEnvVar. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(apiBaseEnvVar, storagePathEnvVar) + if err != nil { + return nil, fmt.Errorf("acme-dns: %v", err) + } + + client := goacmedns.NewClient(values[apiBaseEnvVar]) + storage := goacmedns.NewFileStorage(values[storagePathEnvVar], 0600) + return NewDNSProviderClient(client, 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") + } + + if storage == nil { + return nil, errors.New("ACME-DNS Storage must be not nil") + } + + return &DNSProvider{ + client: client, + storage: storage, + }, nil +} + +// ErrCNAMERequired is returned by Present when the Domain indicated had no +// existing ACME-DNS account in the Storage and additional setup is required. +// The user must create a CNAME in the DNS zone for Domain that aliases FQDN +// to Target in order to complete setup for the ACME-DNS account that was +// created. +type ErrCNAMERequired struct { + // The Domain that is being issued for. + Domain string + // The alias of the CNAME (left hand DNS label). + FQDN string + // The RDATA of the CNAME (right hand side, canonical name). + Target string +} + +// Error returns a descriptive message for the ErrCNAMERequired instance telling +// the user that a CNAME needs to be added to the DNS zone of c.Domain before +// the ACME-DNS hook will work. The CNAME to be created should be of the form: +// {{ c.FQDN }} CNAME {{ c.Target }} +func (e ErrCNAMERequired) Error() string { + return fmt.Sprintf("acme-dns: new account created for %q. "+ + "To complete setup for %q you must provision the following "+ + "CNAME in your DNS zone and re-run this provider when it is "+ + "in place:\n"+ + "%s CNAME %s.", + e.Domain, e.Domain, e.FQDN, e.Target) +} + +// Present creates a TXT record to fulfil the DNS-01 challenge. If there is an +// existing account for the domain in the provider's storage then it will be +// used to set the challenge response TXT record with the ACME-DNS server and +// issuance will continue. If there is not an account for the given domain +// present in the DNSProvider storage 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 { + // Compute the challenge response FQDN and TXT value for the domain based + // on the keyAuth. + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + + // Check if credentials were previously saved for this domain. + account, err := d.storage.Fetch(domain) + // Errors other than goacmeDNS.ErrDomainNotFound are unexpected. + if err != nil && err != goacmedns.ErrDomainNotFound { + return err + } + if err == goacmedns.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, fqdn) + } + + // Update the acme-dns TXT record. + return d.client.UpdateTXTRecord(account, value) +} + +// CleanUp removes the record matching the specified parameters. It is not +// implemented for the ACME-DNS provider. +func (d *DNSProvider) CleanUp(_, _, _ string) error { + // ACME-DNS doesn't support the notion of removing a record. For users of + // ACME-DNS it is expected the stale records remain in-place. + return nil +} + +// register creates a new ACME-DNS account for the given domain. 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 { + // TODO(@cpu): Read CIDR whitelists from the environment + newAcct, err := d.client.RegisterAccount(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) + if err != nil { + return err + } + err = d.storage.Save() + if err != nil { + return err + } + + // Stop issuance by returning an error. The user needs to perform a manual + // one-time CNAME setup in their DNS zone to complete the setup of the new + // account we created. + return ErrCNAMERequired{ + Domain: domain, + FQDN: fqdn, + Target: newAcct.FullDomain, + } +} diff --git a/providers/dns/acmedns/acmedns_test.go b/providers/dns/acmedns/acmedns_test.go new file mode 100644 index 0000000000..d1131b10ab --- /dev/null +++ b/providers/dns/acmedns/acmedns_test.go @@ -0,0 +1,276 @@ +package acmedns + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cpu/goacmedns" +) + +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") + + // Fixed test data for unit tests. + egDomain = "threeletter.agency" + egFQDN = "_acme-challenge." + egDomain + "." + egKeyAuth = "⚷" + egAccount = 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 +} + +// errorRegisterClient 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 +} + +// 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 +} + +// errorSaveStoragr 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 +} + +// 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) { + // validAccountStorage is a mockStorage configured to return the egAccount. + validAccountStorage := mockStorage{ + map[string]goacmedns.Account{ + egDomain: egAccount, + }, + } + // validUpdateClient is a mockClient configured with the egAccount that will + // track TXT updates in a map. + validUpdateClient := mockUpdateClient{ + mockClient{egAccount}, + make(map[goacmedns.Account]string), + } + + testCases := []struct { + Name string + Client acmeDNSClient + Storage goacmedns.Storage + ExpectedError error + }{ + { + Name: "present when client storage returns unexpected error", + Client: mockClient{egAccount}, + Storage: errorFetchStorage{}, + ExpectedError: errorStorageErr, + }, + { + Name: "present when client storage returns ErrDomainNotFound", + Client: mockClient{egAccount}, + ExpectedError: ErrCNAMERequired{ + Domain: egDomain, + FQDN: egFQDN, + Target: egAccount.FullDomain, + }, + }, + { + Name: "present when client UpdateTXTRecord returns unexpected error", + Client: errorUpdateClient{}, + Storage: validAccountStorage, + ExpectedError: errorClientErr, + }, + { + Name: "present when everything works", + Storage: validAccountStorage, + Client: validUpdateClient, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + dp, err := NewDNSProviderClient(tc.Client, mockStorage{make(map[string]goacmedns.Account)}) + require.NoError(t, err) + + // override the storage mock if required by the testcase. + if tc.Storage != nil { + dp.storage = tc.Storage + } + + // call Present. The token argument can be garbage because the ACME-DNS + // provider does not use it. + err = dp.Present(egDomain, "foo", egKeyAuth) + if tc.ExpectedError != nil { + assert.Equal(t, tc.ExpectedError, err) + } else { + require.NoError(t, err) + } + }) + } + + // Check that the success testcase set a record. + assert.Len(t, validUpdateClient.records, 1) + + // Check that the success testcase set the right record for the right account. + assert.Len(t, validUpdateClient.records[egAccount], 43) +} + +// TestRegister tests that the ACME-DNS register function works correctly. +func TestRegister(t *testing.T) { + testCases := []struct { + Name string + Client acmeDNSClient + Storage goacmedns.Storage + Domain string + FQDN string + ExpectedError error + }{ + { + Name: "register when acme-dns client returns an error", + Client: errorRegisterClient{}, + ExpectedError: errorClientErr, + }, + { + Name: "register when acme-dns storage put returns an error", + Client: mockClient{egAccount}, + Storage: errorPutStorage{mockStorage{make(map[string]goacmedns.Account)}}, + ExpectedError: errorStorageErr, + }, + { + Name: "register when acme-dns storage save returns an error", + Client: mockClient{egAccount}, + Storage: errorSaveStorage{mockStorage{make(map[string]goacmedns.Account)}}, + ExpectedError: errorStorageErr, + }, + { + Name: "register when everything works", + Client: mockClient{egAccount}, + ExpectedError: ErrCNAMERequired{ + Domain: egDomain, + FQDN: egFQDN, + Target: egAccount.FullDomain, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + dp, err := NewDNSProviderClient(tc.Client, mockStorage{make(map[string]goacmedns.Account)}) + require.NoError(t, err) + + // override the storage mock if required by the testcase. + if tc.Storage != nil { + dp.storage = tc.Storage + } + + // Call register for the example domain/fqdn. + err = dp.register(egDomain, egFQDN) + if tc.ExpectedError != nil { + assert.Equal(t, tc.ExpectedError, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 96881c19d7..ec8c31875a 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/xenolf/lego/acme" + "github.com/xenolf/lego/providers/dns/acmedns" "github.com/xenolf/lego/providers/dns/auroradns" "github.com/xenolf/lego/providers/dns/azure" "github.com/xenolf/lego/providers/dns/bluecat" @@ -43,6 +44,8 @@ import ( // NewDNSChallengeProviderByName Factory for DNS providers func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error) { switch name { + case "acme-dns": + return acmedns.NewDNSProvider() case "azure": return azure.NewDNSProvider() case "auroradns": diff --git a/vendor/github.com/cpu/goacmedns/LICENSE b/vendor/github.com/cpu/goacmedns/LICENSE new file mode 100644 index 0000000000..3215897de7 --- /dev/null +++ b/vendor/github.com/cpu/goacmedns/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Daniel McCarney + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/cpu/goacmedns/account.go b/vendor/github.com/cpu/goacmedns/account.go new file mode 100644 index 0000000000..9a1d9c81a2 --- /dev/null +++ b/vendor/github.com/cpu/goacmedns/account.go @@ -0,0 +1,11 @@ +package goacmedns + +// Account is a struct that holds the registration response from an ACME-DNS +// server. It represents an API username/key that can be used to update TXT +// records for the account's subdomain. +type Account struct { + FullDomain string + SubDomain string + Username string + Password string +} diff --git a/vendor/github.com/cpu/goacmedns/client.go b/vendor/github.com/cpu/goacmedns/client.go new file mode 100644 index 0000000000..8be70306e9 --- /dev/null +++ b/vendor/github.com/cpu/goacmedns/client.go @@ -0,0 +1,191 @@ +package goacmedns + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net" + "net/http" + "runtime" + "time" +) + +const ( + // ua is a custom user-agent identifier + ua = "goacmedns" +) + +// userAgent returns a string that can be used as a HTTP request `User-Agent` +// header. It includes the `ua` string alongside the OS and architecture of the +// system. +func userAgent() string { + return fmt.Sprintf("%s (%s; %s)", ua, runtime.GOOS, runtime.GOARCH) +} + +var ( + // defaultTimeout is used for the httpClient Timeout settings + defaultTimeout = 30 * time.Second + // httpClient is a `http.Client` that is customized with the `defaultTimeout` + httpClient = http.Client{ + Transport: &http.Transport{ + Dial: (&net.Dialer{ + Timeout: defaultTimeout, + KeepAlive: defaultTimeout, + }).Dial, + TLSHandshakeTimeout: defaultTimeout, + ResponseHeaderTimeout: defaultTimeout, + ExpectContinueTimeout: 1 * time.Second, + }, + } +) + +// postAPI makes an HTTP POST request to the given URL, sending the given body +// and attaching the requested custom headers to the request. If there is no +// error the HTTP response body and HTTP response object are returned, otherwise +// an error is returned.. All POST requests include a `User-Agent` header +// populated with the `userAgent` function and a `Content-Type` header of +// `application/json`. +func postAPI(url string, body []byte, headers map[string]string) ([]byte, *http.Response, error) { + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(body)) + if err != nil { + fmt.Printf("Failed to make req: %s\n", err.Error()) + return nil, nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", userAgent()) + for h, v := range headers { + req.Header.Set(h, v) + } + + resp, err := httpClient.Do(req) + if err != nil { + fmt.Printf("Failed to do req: %s\n", err.Error()) + return nil, resp, err + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + fmt.Printf("Failed to read body: %s\n", err.Error()) + return nil, resp, err + } + return respBody, resp, nil +} + +// ClientError represents an error from the ACME-DNS server. It holds +// a `Message` describing the operation the client was doing, a `HTTPStatus` +// code returned by the server, and the `Body` of the HTTP Response from the +// server. +type ClientError struct { + // Message is a string describing the client operation that failed + Message string + // HTTPStatus is the HTTP status code the ACME DNS server returned + HTTPStatus int + // Body is the response body the ACME DNS server returned + Body []byte +} + +// Error collects all of the ClientError fields into a single string +func (e ClientError) Error() string { + return fmt.Sprintf("%s : status code %d response: %s", + e.Message, e.HTTPStatus, string(e.Body)) +} + +// newClientError creates a ClientError instance populated with the given +// arguments +func newClientError(msg string, respCode int, respBody []byte) ClientError { + return ClientError{ + Message: msg, + HTTPStatus: respCode, + Body: respBody, + } +} + +// Client is a struct that can be used to interact with an ACME DNS server to +// register accounts and update TXT records. +type Client struct { + // baseURL is the address of the ACME DNS server + baseURL string +} + +// NewClient returns a Client configured to interact with the ACME DNS server at +// the given URL. +func NewClient(url string) Client { + return Client{ + baseURL: url, + } +} + +// RegisterAccount creates an Account with the ACME DNS server. The optional +// `allowFrom` argument is used to constrain which CIDR ranges can use the +// created Account. +func (c Client) RegisterAccount(allowFrom []string) (Account, error) { + var body []byte + if len(allowFrom) > 0 { + req := struct { + AllowFrom []string + }{ + AllowFrom: allowFrom, + } + reqBody, err := json.Marshal(req) + if err != nil { + return Account{}, err + } + body = reqBody + } + + url := fmt.Sprintf("%s/register", c.baseURL) + respBody, resp, err := postAPI(url, body, nil) + if err != nil { + return Account{}, err + } + + if resp.StatusCode != http.StatusCreated { + return Account{}, newClientError( + "failed to register account", resp.StatusCode, respBody) + } + + var acct Account + err = json.Unmarshal(respBody, &acct) + if err != nil { + return Account{}, err + } + + return acct, nil +} + +// UpdateTXTRecord updates a TXT record with the ACME DNS server to the `value` +// provided using the `account` specified. +func (c Client) UpdateTXTRecord(account Account, value string) error { + update := struct { + SubDomain string + Txt string + }{ + SubDomain: account.SubDomain, + Txt: value, + } + updateBody, err := json.Marshal(update) + if err != nil { + fmt.Printf("Failed to marshal update: %s\n", update) + return err + } + + headers := map[string]string{ + "X-Api-User": account.Username, + "X-Api-Key": account.Password, + } + + url := fmt.Sprintf("%s/update", c.baseURL) + respBody, resp, err := postAPI(url, updateBody, headers) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return newClientError( + "failed to update txt record", resp.StatusCode, respBody) + } + + return nil +} diff --git a/vendor/github.com/cpu/goacmedns/storage.go b/vendor/github.com/cpu/goacmedns/storage.go new file mode 100644 index 0000000000..6e0186b0c7 --- /dev/null +++ b/vendor/github.com/cpu/goacmedns/storage.go @@ -0,0 +1,89 @@ +package goacmedns + +import ( + "encoding/json" + "errors" + "io/ioutil" + "os" +) + +// Storage is an interface describing the required functions for an ACME DNS +// Account storage mechanism. +type Storage interface { + // Save will persist the `Account` data that has been `Put` so far + Save() error + // Put will add an `Account` for the given domain to the storage. It may not + // be persisted until `Save` is called. + Put(string, Account) error + // Fetch will retrieve an `Account` for the given domain from the storage. If + // the provided domain does not have an `Account` saved in the storage + // `ErrDomainNotFound` will be returned + Fetch(string) (Account, error) +} + +var ( + // ErrDomainNotFound is returned from `Fetch` when the provided domain is not + // present in the storage. + ErrDomainNotFound = errors.New("requested domain is not present in storage") +) + +// fileStorage implements the `Storage` interface and persists `Accounts` to +// a JSON file on disk. +type fileStorage struct { + // path is the filepath that the `accounts` are persisted to when the `Save` + // function is called. + path string + // mode is the file mode used when the `path` JSON file must be created + mode os.FileMode + // accounts holds the `Account` data that has been `Put` into the storage + accounts map[string]Account +} + +// NewFileStorage returns a `Storage` implementation backed by JSON content +// saved into the provided `path` on disk. The file at `path` will be created if +// required. When creating a new file the provided `mode` is used to set the +// permissions. +func NewFileStorage(path string, mode os.FileMode) Storage { + fs := fileStorage{ + path: path, + mode: mode, + accounts: make(map[string]Account), + } + // Opportunistically try to load the account data. Return an empty account if + // any errors occur. + if jsonData, err := ioutil.ReadFile(path); err == nil { + if err := json.Unmarshal(jsonData, &fs.accounts); err != nil { + return fs + } + } + return fs +} + +// Save persists the `Account` data to the fileStorage's configured path. The +// file at that path will be created with the fileStorage's mode if required. +func (f fileStorage) Save() error { + if serialized, err := json.Marshal(f.accounts); err != nil { + return err + } else if err = ioutil.WriteFile(f.path, serialized, f.mode); err != nil { + return err + } + return nil +} + +// Put saves an `Account` for the given `Domain` into the in-memory accounts of +// the fileStorage instance. The `Account` data will not be written to disk +// until the `Save` function is called +func (f fileStorage) Put(domain string, acct Account) error { + f.accounts[domain] = acct + return nil +} + +// Fetch retrieves the `Account` object for the given `domain` from the +// fileStorage in-memory accounts. If the `domain` provided does not have an +// `Account` in the storage an `ErrDomainNotFound` error is returned. +func (f fileStorage) Fetch(domain string) (Account, error) { + if acct, exists := f.accounts[domain]; exists { + return acct, nil + } + return Account{}, ErrDomainNotFound +}