acmedns.go raw

   1  // Package acmedns implements a DNS provider for solving DNS-01 challenges using Joohoi's acme-dns project.
   2  // For more information see the ACME-DNS homepage: https://github.com/joohoi/acme-dns
   3  package acmedns
   4  
   5  import (
   6  	"context"
   7  	"errors"
   8  	"fmt"
   9  	"strings"
  10  
  11  	"github.com/go-acme/lego/v4/challenge"
  12  	"github.com/go-acme/lego/v4/challenge/dns01"
  13  	"github.com/go-acme/lego/v4/platform/config/env"
  14  	"github.com/go-acme/lego/v4/providers/dns/acmedns/internal"
  15  	"github.com/nrdcg/goacmedns"
  16  	"github.com/nrdcg/goacmedns/storage"
  17  )
  18  
  19  const (
  20  	// envNamespace is the prefix for ACME-DNS environment variables.
  21  	envNamespace = "ACME_DNS_"
  22  
  23  	// EnvAPIBase is the environment variable name for the ACME-DNS API address.
  24  	// (e.g. https://acmedns.your-domain.com).
  25  	EnvAPIBase = envNamespace + "API_BASE"
  26  
  27  	// EnvAllowList are source networks using CIDR notation,
  28  	// e.g. "192.168.100.1/24,1.2.3.4/32,2002:c0a8:2a00::0/40".
  29  	EnvAllowList = envNamespace + "ALLOWLIST"
  30  
  31  	// EnvStoragePath is the environment variable name for the ACME-DNS JSON account data file.
  32  	// A per-domain account will be registered/persisted to this file and used for TXT updates.
  33  	EnvStoragePath = envNamespace + "STORAGE_PATH"
  34  
  35  	// EnvStorageBaseURL  is the environment variable name for the ACME-DNS JSON account data.
  36  	// The URL to the storage server.
  37  	EnvStorageBaseURL = envNamespace + "STORAGE_BASE_URL"
  38  )
  39  
  40  var _ challenge.Provider = (*DNSProvider)(nil)
  41  
  42  // Config is used to configure the creation of the DNSProvider.
  43  type Config struct {
  44  	APIBase        string
  45  	AllowList      []string
  46  	StoragePath    string
  47  	StorageBaseURL string
  48  }
  49  
  50  // NewDefaultConfig returns a default configuration for the DNSProvider.
  51  func NewDefaultConfig() *Config {
  52  	return &Config{}
  53  }
  54  
  55  // acmeDNSClient is an interface describing the goacmedns.Client functions the DNSProvider uses.
  56  // It makes it easier for tests to shim a mock Client into the DNSProvider.
  57  type acmeDNSClient interface {
  58  	// UpdateTXTRecord updates the provided account's TXT record
  59  	// to the given value or returns an error.
  60  	UpdateTXTRecord(ctx context.Context, account goacmedns.Account, value string) error
  61  	// RegisterAccount registers and returns a new account
  62  	// with the given allowFrom restriction or returns an error.
  63  	RegisterAccount(ctx context.Context, allowFrom []string) (goacmedns.Account, error)
  64  }
  65  
  66  // DNSProvider implements the challenge.Provider interface.
  67  type DNSProvider struct {
  68  	config  *Config
  69  	client  acmeDNSClient
  70  	storage goacmedns.Storage
  71  }
  72  
  73  // NewDNSProvider returns a DNSProvider instance configured for Joohoi's acme-dns.
  74  func NewDNSProvider() (*DNSProvider, error) {
  75  	values, err := env.Get(EnvAPIBase)
  76  	if err != nil {
  77  		return nil, fmt.Errorf("acme-dns: %w", err)
  78  	}
  79  
  80  	config := NewDefaultConfig()
  81  	config.APIBase = values[EnvAPIBase]
  82  	config.StoragePath = env.GetOrFile(EnvStoragePath)
  83  	config.StorageBaseURL = env.GetOrFile(EnvStorageBaseURL)
  84  
  85  	allowList := env.GetOrFile(EnvAllowList)
  86  	if allowList != "" {
  87  		config.AllowList = strings.Split(allowList, ",")
  88  	}
  89  
  90  	return NewDNSProviderConfig(config)
  91  }
  92  
  93  // NewDNSProviderConfig return a DNSProvider instance configured for Joohoi's acme-dns.
  94  func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
  95  	if config == nil {
  96  		return nil, errors.New("acme-dns: the configuration of the DNS provider is nil")
  97  	}
  98  
  99  	st, err := getStorage(config)
 100  	if err != nil {
 101  		return nil, fmt.Errorf("acme-dns: %w", err)
 102  	}
 103  
 104  	client, err := goacmedns.NewClient(config.APIBase)
 105  	if err != nil {
 106  		return nil, fmt.Errorf("acme-dns: new client: %w", err)
 107  	}
 108  
 109  	return &DNSProvider{
 110  		config:  config,
 111  		client:  client,
 112  		storage: st,
 113  	}, nil
 114  }
 115  
 116  // NewDNSProviderClient creates an ACME-DNS DNSProvider with the given acmeDNSClient and [goacmedns.Storage].
 117  //
 118  // Deprecated: use [NewDNSProviderConfig] instead.
 119  func NewDNSProviderClient(client acmeDNSClient, store goacmedns.Storage) (*DNSProvider, error) {
 120  	if client == nil {
 121  		return nil, errors.New("acme-dns: Client must be not nil")
 122  	}
 123  
 124  	if store == nil {
 125  		return nil, errors.New("acme-dns: Storage must be not nil")
 126  	}
 127  
 128  	return &DNSProvider{
 129  		config:  NewDefaultConfig(),
 130  		client:  client,
 131  		storage: store,
 132  	}, nil
 133  }
 134  
 135  // ErrCNAMERequired is returned by Present when the Domain indicated had no
 136  // existing ACME-DNS account in the Storage and additional setup is required.
 137  // The user must create a CNAME in the DNS zone for Domain that aliases FQDN
 138  // to Target in order to complete setup for the ACME-DNS account that was created.
 139  type ErrCNAMERequired struct {
 140  	// The Domain that is being issued for.
 141  	Domain string
 142  	// The alias of the CNAME (left hand DNS label).
 143  	FQDN string
 144  	// The RDATA of the CNAME (right hand side, canonical name).
 145  	Target string
 146  }
 147  
 148  // Error returns a descriptive message for the ErrCNAMERequired instance telling
 149  // the user that a CNAME needs to be added to the DNS zone of c.Domain before
 150  // the ACME-DNS hook will work.
 151  // The CNAME to be created should be of the form: {{ c.FQDN }} 	CNAME	{{ c.Target }}.
 152  func (e ErrCNAMERequired) Error() string {
 153  	return fmt.Sprintf("acme-dns: new account created for %q. "+
 154  		"To complete setup for %q you must provision the following "+
 155  		"CNAME in your DNS zone and re-run this provider when it is "+
 156  		"in place:\n"+
 157  		"%s CNAME %s.",
 158  		e.Domain, e.Domain, e.FQDN, e.Target)
 159  }
 160  
 161  // Present creates a TXT record to fulfill the DNS-01 challenge.
 162  // If there is an existing account for the domain in the provider's storage
 163  // then it will be used to set the challenge response TXT record with the ACME-DNS server and issuance will continue.
 164  // If there is not an account for the given domain present in the DNSProvider storage
 165  // one will be created and registered with the ACME DNS server and an ErrCNAMERequired error is returned.
 166  // This will halt issuance and indicate to the user that a one-time manual setup is required for the domain.
 167  func (d *DNSProvider) Present(domain, _, keyAuth string) error {
 168  	ctx := context.Background()
 169  
 170  	// Compute the challenge response FQDN and TXT value for the domain based on the keyAuth.
 171  	info := dns01.GetChallengeInfo(domain, keyAuth)
 172  
 173  	// Check if credentials were previously saved for this domain.
 174  	account, err := d.storage.Fetch(ctx, domain)
 175  	if err != nil {
 176  		if !errors.Is(err, storage.ErrDomainNotFound) {
 177  			return err
 178  		}
 179  
 180  		// The account did not exist.
 181  		// Create a new one and return an error indicating the required one-time manual CNAME setup.
 182  		account, err = d.register(ctx, domain, info.FQDN)
 183  		if err != nil {
 184  			return err
 185  		}
 186  	}
 187  
 188  	// Update the acme-dns TXT record.
 189  	return d.client.UpdateTXTRecord(ctx, account, info.Value)
 190  }
 191  
 192  // CleanUp removes the record matching the specified parameters. It is not
 193  // implemented for the ACME-DNS provider.
 194  func (d *DNSProvider) CleanUp(_, _, _ string) error {
 195  	// ACME-DNS doesn't support the notion of removing a record.
 196  	// For users of ACME-DNS it is expected the stale records remain in-place.
 197  	return nil
 198  }
 199  
 200  // register creates a new ACME-DNS account for the given domain.
 201  // If account creation works as expected a ErrCNAMERequired error is returned describing
 202  // the one-time manual CNAME setup required to complete setup of the ACME-DNS hook for the domain.
 203  // If any other error occurs it is returned as-is.
 204  func (d *DNSProvider) register(ctx context.Context, domain, fqdn string) (goacmedns.Account, error) {
 205  	newAcct, err := d.client.RegisterAccount(ctx, d.config.AllowList)
 206  	if err != nil {
 207  		return goacmedns.Account{}, err
 208  	}
 209  
 210  	var cnameCreated bool
 211  
 212  	// Store the new account in the storage and call save to persist the data.
 213  	err = d.storage.Put(ctx, domain, newAcct)
 214  	if err != nil {
 215  		cnameCreated = errors.Is(err, internal.ErrCNAMEAlreadyCreated)
 216  		if !cnameCreated {
 217  			return goacmedns.Account{}, err
 218  		}
 219  	}
 220  
 221  	err = d.storage.Save(ctx)
 222  	if err != nil {
 223  		return goacmedns.Account{}, err
 224  	}
 225  
 226  	if cnameCreated {
 227  		return newAcct, nil
 228  	}
 229  
 230  	// Stop issuance by returning an error.
 231  	// The user needs to perform a manual one-time CNAME setup in their DNS zone
 232  	// to complete the setup of the new account we created.
 233  	return goacmedns.Account{}, ErrCNAMERequired{
 234  		Domain: domain,
 235  		FQDN:   fqdn,
 236  		Target: newAcct.FullDomain,
 237  	}
 238  }
 239  
 240  func getStorage(config *Config) (goacmedns.Storage, error) {
 241  	if config.StoragePath == "" && config.StorageBaseURL == "" {
 242  		return nil, errors.New("storagePath or storageBaseURL is not set")
 243  	}
 244  
 245  	if config.StoragePath != "" && config.StorageBaseURL != "" {
 246  		return nil, errors.New("storagePath and storageBaseURL cannot be used at the same time")
 247  	}
 248  
 249  	if config.StoragePath != "" {
 250  		return storage.NewFile(config.StoragePath, 0o600), nil
 251  	}
 252  
 253  	st, err := internal.NewHTTPStorage(config.StorageBaseURL)
 254  	if err != nil {
 255  		return nil, fmt.Errorf("new HTTP storage: %w", err)
 256  	}
 257  
 258  	return st, nil
 259  }
 260