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