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