DNS Providers: Add ACME-DNS provider. (#591)

This commit adds a new DNS provider for
[acme-dns](https://github.com/joohoi/acme-dns) to allow Lego to set
DNS-01 challenge response TXT with an ACME-DNS server automatically.
ACME-DNS allows ceding minimal zone editing permissions to the ACME
client and can be useful when the primary DNS provider for the zone does
not allow scripting/API access but can set a CNAME to an ACME-DNS
server.

Lower level ACME-DNS API calls & account loading/storing is handled by
the `github.com/cpu/goacmedns` library.

The provider loads existing ACME-DNS accounts from the specified JSON
file on disk. Any accounts the provider registers on behalf of the user
will also be saved to this JSON file.

When required, the provider handles registering accounts with the
ACME-DNS server domains that do not already have an ACME-DNS account.
This will halt issuance with an error prompting the user to set the
one-time manual CNAME required to delegate the DNS-01 challenge record
to the ACME-DNS server. Subsequent runs will use the account from disk
and assume the CNAME is in-place.
This commit is contained in:
Daniel McCarney 2018-07-09 13:28:01 -04:00 committed by Ludovic Fernandez
parent d30c293647
commit 04e2d74406
9 changed files with 769 additions and 1 deletions

8
Gopkg.lock generated
View file

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

1
cli.go
View file

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

View file

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

View file

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

View file

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

21
vendor/github.com/cpu/goacmedns/LICENSE generated vendored Normal file
View file

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

11
vendor/github.com/cpu/goacmedns/account.go generated vendored Normal file
View file

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

191
vendor/github.com/cpu/goacmedns/client.go generated vendored Normal file
View file

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

89
vendor/github.com/cpu/goacmedns/storage.go generated vendored Normal file
View file

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