detached 0537081

Wire DNS config into ACME DNS-01

commit
0537081edee3216f48eae0936d5240f5beb095a4
author
brice · · 2026-05-01 13:51 UTC
parent
68f340d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +114 -9
M internal/adapters/out/acmelego/issuer.go +30 -4
@@ -15,6 +15,7 @@ import (
1515 "github.com/go-acme/lego/v4/acme"
1616 "github.com/go-acme/lego/v4/certcrypto"
1717 "github.com/go-acme/lego/v4/certificate"
18+ "github.com/go-acme/lego/v4/challenge/dns01"
1819 "github.com/go-acme/lego/v4/lego"
1920 "github.com/go-acme/lego/v4/providers/dns/cloudflare"
2021 "github.com/go-acme/lego/v4/registration"
@@ -34,6 +35,15 @@ type Config struct {
3435 // Token is the Cloudflare API token (required for DNS-01 challenge).
3536 Token string
3637
38+ // DNSResolvers are recursive resolvers used for DNS-01 propagation checks.
39+ DNSResolvers []string
40+
41+ // DNSPropagationTimeout is the maximum wait for DNS-01 TXT propagation.
42+ DNSPropagationTimeout time.Duration
43+
44+ // DNSPollingInterval is the interval between DNS-01 propagation checks.
45+ DNSPollingInterval time.Duration
46+
3747 // Store persists ACME accounts and certificates.
3848 Store out.CertificateStore
3949
@@ -81,6 +91,15 @@ func NewIssuer(cfg Config) (*Issuer, error) {
8191 if cfg.Token == "" {
8292 return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing)
8393 }
94+ if len(cfg.DNSResolvers) == 0 {
95+ return nil, fmt.Errorf("acmelego: DNSResolvers must contain at least one resolver")
96+ }
97+ if cfg.DNSPropagationTimeout <= 0 {
98+ return nil, fmt.Errorf("acmelego: DNSPropagationTimeout must be positive")
99+ }
100+ if cfg.DNSPollingInterval <= 0 {
101+ return nil, fmt.Errorf("acmelego: DNSPollingInterval must be positive")
102+ }
84103 default:
85104 return nil, fmt.Errorf("acmelego: %w: %s", domain.ErrACMEChallengeInvalid, cfg.Challenge)
86105 }
@@ -208,13 +227,11 @@ func (i *Issuer) buildClient(ctx context.Context) (*AccountUser, *lego.Client, e
208227 return nil, nil, fmt.Errorf("set http-01 provider: %w", err)
209228 }
210229 case domain.ACMEChallengeCloudflareDNS01:
211- cfCfg := cloudflare.NewDefaultConfig()
212- cfCfg.AuthToken = i.cfg.Token
213- cfProvider, err := cloudflare.NewDNSProviderConfig(cfCfg)
230+ cfProvider, err := cloudflare.NewDNSProviderConfig(newCloudflareDNSProviderConfig(i.cfg))
214231 if err != nil {
215232 return nil, nil, fmt.Errorf("create cloudflare dns provider: %w", err)
216233 }
217- if err := client.Challenge.SetDNS01Provider(cfProvider); err != nil {
234+ if err := client.Challenge.SetDNS01Provider(cfProvider, dns01.AddRecursiveNameservers(i.cfg.DNSResolvers)); err != nil {
218235 return nil, nil, fmt.Errorf("set dns-01 provider: %w", err)
219236 }
220237 }
@@ -312,6 +329,15 @@ func (i *Issuer) newLegoConfig(user *AccountUser) *lego.Config {
312329 return legoCfg
313330 }
314331
332+// newCloudflareDNSProviderConfig creates a cloudflare DNS provider config from the issuer config.
333+func newCloudflareDNSProviderConfig(cfg Config) *cloudflare.Config {
334+ cfCfg := cloudflare.NewDefaultConfig()
335+ cfCfg.AuthToken = cfg.Token
336+ cfCfg.PropagationTimeout = cfg.DNSPropagationTimeout
337+ cfCfg.PollingInterval = cfg.DNSPollingInterval
338+ return cfCfg
339+}
340+
315341 // resourceToStored converts a lego certificate.Resource to out.StoredCertificate.
316342 func resourceToStored(order out.CertificateOrder, res *certificate.Resource) (*out.StoredCertificate, error) {
317343 var fullchainPEM []byte
@@ -15,6 +15,7 @@ import (
15 "github.com/go-acme/lego/v4/acme"15 "github.com/go-acme/lego/v4/acme"
16 "github.com/go-acme/lego/v4/certcrypto"16 "github.com/go-acme/lego/v4/certcrypto"
17 "github.com/go-acme/lego/v4/certificate"17 "github.com/go-acme/lego/v4/certificate"
18+ "github.com/go-acme/lego/v4/challenge/dns01"
18 "github.com/go-acme/lego/v4/lego"19 "github.com/go-acme/lego/v4/lego"
19 "github.com/go-acme/lego/v4/providers/dns/cloudflare"20 "github.com/go-acme/lego/v4/providers/dns/cloudflare"
20 "github.com/go-acme/lego/v4/registration"21 "github.com/go-acme/lego/v4/registration"
@@ -34,6 +35,15 @@ type Config struct {
34 // Token is the Cloudflare API token (required for DNS-01 challenge).35 // Token is the Cloudflare API token (required for DNS-01 challenge).
35 Token string36 Token string
36 37
38+ // DNSResolvers are recursive resolvers used for DNS-01 propagation checks.
39+ DNSResolvers []string
40+
41+ // DNSPropagationTimeout is the maximum wait for DNS-01 TXT propagation.
42+ DNSPropagationTimeout time.Duration
43+
44+ // DNSPollingInterval is the interval between DNS-01 propagation checks.
45+ DNSPollingInterval time.Duration
46+
37 // Store persists ACME accounts and certificates.47 // Store persists ACME accounts and certificates.
38 Store out.CertificateStore48 Store out.CertificateStore
39 49
@@ -81,6 +91,15 @@ func NewIssuer(cfg Config) (*Issuer, error) {
81 if cfg.Token == "" {91 if cfg.Token == "" {
82 return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing)92 return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing)
83 }93 }
94+ if len(cfg.DNSResolvers) == 0 {
95+ return nil, fmt.Errorf("acmelego: DNSResolvers must contain at least one resolver")
96+ }
97+ if cfg.DNSPropagationTimeout <= 0 {
98+ return nil, fmt.Errorf("acmelego: DNSPropagationTimeout must be positive")
99+ }
100+ if cfg.DNSPollingInterval <= 0 {
101+ return nil, fmt.Errorf("acmelego: DNSPollingInterval must be positive")
102+ }
84 default:103 default:
85 return nil, fmt.Errorf("acmelego: %w: %s", domain.ErrACMEChallengeInvalid, cfg.Challenge)104 return nil, fmt.Errorf("acmelego: %w: %s", domain.ErrACMEChallengeInvalid, cfg.Challenge)
86 }105 }
@@ -208,13 +227,11 @@ func (i *Issuer) buildClient(ctx context.Context) (*AccountUser, *lego.Client, e
208 return nil, nil, fmt.Errorf("set http-01 provider: %w", err)227 return nil, nil, fmt.Errorf("set http-01 provider: %w", err)
209 }228 }
210 case domain.ACMEChallengeCloudflareDNS01:229 case domain.ACMEChallengeCloudflareDNS01:
211- cfCfg := cloudflare.NewDefaultConfig()230+ cfProvider, err := cloudflare.NewDNSProviderConfig(newCloudflareDNSProviderConfig(i.cfg))
212- cfCfg.AuthToken = i.cfg.Token
213- cfProvider, err := cloudflare.NewDNSProviderConfig(cfCfg)
214 if err != nil {231 if err != nil {
215 return nil, nil, fmt.Errorf("create cloudflare dns provider: %w", err)232 return nil, nil, fmt.Errorf("create cloudflare dns provider: %w", err)
216 }233 }
217- if err := client.Challenge.SetDNS01Provider(cfProvider); err != nil {234+ if err := client.Challenge.SetDNS01Provider(cfProvider, dns01.AddRecursiveNameservers(i.cfg.DNSResolvers)); err != nil {
218 return nil, nil, fmt.Errorf("set dns-01 provider: %w", err)235 return nil, nil, fmt.Errorf("set dns-01 provider: %w", err)
219 }236 }
220 }237 }
@@ -312,6 +329,15 @@ func (i *Issuer) newLegoConfig(user *AccountUser) *lego.Config {
312 return legoCfg329 return legoCfg
313 }330 }
314 331
332+// newCloudflareDNSProviderConfig creates a cloudflare DNS provider config from the issuer config.
333+func newCloudflareDNSProviderConfig(cfg Config) *cloudflare.Config {
334+ cfCfg := cloudflare.NewDefaultConfig()
335+ cfCfg.AuthToken = cfg.Token
336+ cfCfg.PropagationTimeout = cfg.DNSPropagationTimeout
337+ cfCfg.PollingInterval = cfg.DNSPollingInterval
338+ return cfCfg
339+}
340+
315 // resourceToStored converts a lego certificate.Resource to out.StoredCertificate.341 // resourceToStored converts a lego certificate.Resource to out.StoredCertificate.
316 func resourceToStored(order out.CertificateOrder, res *certificate.Resource) (*out.StoredCertificate, error) {342 func resourceToStored(order out.CertificateOrder, res *certificate.Resource) (*out.StoredCertificate, error) {
317 var fullchainPEM []byte343 var fullchainPEM []byte
M internal/adapters/out/acmelego/issuer_test.go +39 -0
@@ -69,6 +69,31 @@ func TestNewIssuerValidatesCloudflareToken(t *testing.T) {
6969 assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)
7070 }
7171
72+func TestNewIssuerValidatesCloudflareDNSConfig(t *testing.T) {
73+ _, err := NewIssuer(Config{
74+ Email: "test@example.com",
75+ Challenge: domain.ACMEChallengeCloudflareDNS01,
76+ Token: "token",
77+ Store: outmocks.NewMockCertificateStore(t),
78+ DNSResolvers: nil,
79+ DNSPropagationTimeout: 5 * time.Minute,
80+ DNSPollingInterval: 5 * time.Second,
81+ })
82+ require.Error(t, err)
83+ assert.Contains(t, err.Error(), "DNSResolvers")
84+
85+ _, err = NewIssuer(Config{
86+ Email: "test@example.com",
87+ Challenge: domain.ACMEChallengeCloudflareDNS01,
88+ Token: "token",
89+ Store: outmocks.NewMockCertificateStore(t),
90+ DNSResolvers: []string{"1.1.1.1:53"},
91+ DNSPropagationTimeout: 5 * time.Minute,
92+ DNSPollingInterval: 5 * time.Second,
93+ })
94+ require.NoError(t, err)
95+}
96+
7297 func TestNewIssuerRejectsNonHTTPSURL(t *testing.T) {
7398 _, err := NewIssuer(Config{
7499 Email: "test@example.com",
@@ -102,6 +127,20 @@ func TestNewIssuerValidWithDefaults(t *testing.T) {
102127 assert.Equal(t, domain.ACMEChallengeHTTP01, issuer.cfg.Challenge)
103128 }
104129
130+func TestCloudflareDNSProviderConfigUsesDNSSettings(t *testing.T) {
131+ cfg := Config{
132+ Token: "token",
133+ DNSPropagationTimeout: 7 * time.Minute,
134+ DNSPollingInterval: 11 * time.Second,
135+ }
136+
137+ cfCfg := newCloudflareDNSProviderConfig(cfg)
138+
139+ assert.Equal(t, "token", cfCfg.AuthToken)
140+ assert.Equal(t, 7*time.Minute, cfCfg.PropagationTimeout)
141+ assert.Equal(t, 11*time.Second, cfCfg.PollingInterval)
142+}
143+
105144 func TestPrivateKeyRoundTrip(t *testing.T) {
106145 // Generate an ECDSA P-256 private key
107146 privateKey, err := certcrypto.GeneratePrivateKey(certcrypto.EC256)
@@ -69,6 +69,31 @@ func TestNewIssuerValidatesCloudflareToken(t *testing.T) {
69 assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)69 assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)
70 }70 }
71 71
72+func TestNewIssuerValidatesCloudflareDNSConfig(t *testing.T) {
73+ _, err := NewIssuer(Config{
74+ Email: "test@example.com",
75+ Challenge: domain.ACMEChallengeCloudflareDNS01,
76+ Token: "token",
77+ Store: outmocks.NewMockCertificateStore(t),
78+ DNSResolvers: nil,
79+ DNSPropagationTimeout: 5 * time.Minute,
80+ DNSPollingInterval: 5 * time.Second,
81+ })
82+ require.Error(t, err)
83+ assert.Contains(t, err.Error(), "DNSResolvers")
84+
85+ _, err = NewIssuer(Config{
86+ Email: "test@example.com",
87+ Challenge: domain.ACMEChallengeCloudflareDNS01,
88+ Token: "token",
89+ Store: outmocks.NewMockCertificateStore(t),
90+ DNSResolvers: []string{"1.1.1.1:53"},
91+ DNSPropagationTimeout: 5 * time.Minute,
92+ DNSPollingInterval: 5 * time.Second,
93+ })
94+ require.NoError(t, err)
95+}
96+
72 func TestNewIssuerRejectsNonHTTPSURL(t *testing.T) {97 func TestNewIssuerRejectsNonHTTPSURL(t *testing.T) {
73 _, err := NewIssuer(Config{98 _, err := NewIssuer(Config{
74 Email: "test@example.com",99 Email: "test@example.com",
@@ -102,6 +127,20 @@ func TestNewIssuerValidWithDefaults(t *testing.T) {
102 assert.Equal(t, domain.ACMEChallengeHTTP01, issuer.cfg.Challenge)127 assert.Equal(t, domain.ACMEChallengeHTTP01, issuer.cfg.Challenge)
103 }128 }
104 129
130+func TestCloudflareDNSProviderConfigUsesDNSSettings(t *testing.T) {
131+ cfg := Config{
132+ Token: "token",
133+ DNSPropagationTimeout: 7 * time.Minute,
134+ DNSPollingInterval: 11 * time.Second,
135+ }
136+
137+ cfCfg := newCloudflareDNSProviderConfig(cfg)
138+
139+ assert.Equal(t, "token", cfCfg.AuthToken)
140+ assert.Equal(t, 7*time.Minute, cfCfg.PropagationTimeout)
141+ assert.Equal(t, 11*time.Second, cfCfg.PollingInterval)
142+}
143+
105 func TestPrivateKeyRoundTrip(t *testing.T) {144 func TestPrivateKeyRoundTrip(t *testing.T) {
106 // Generate an ECDSA P-256 private key145 // Generate an ECDSA P-256 private key
107 privateKey, err := certcrypto.GeneratePrivateKey(certcrypto.EC256)146 privateKey, err := certcrypto.GeneratePrivateKey(certcrypto.EC256)
A internal/app/dns_config_test.go +31 -0
@@ -0,0 +1,31 @@
1+package app
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestBuildDNSConfigFromConfig(t *testing.T) {
12+ cfg := Config{}
13+ cfg.DNS.Resolvers = []string{"9.9.9.9:53", "1.1.1.1:53"}
14+ cfg.DNS.PropagationTimeout = "10m"
15+ cfg.DNS.PollingInterval = "10s"
16+
17+ dnsCfg, err := buildDNSConfig(cfg)
18+ require.NoError(t, err)
19+ assert.Equal(t, []string{"9.9.9.9:53", "1.1.1.1:53"}, dnsCfg.Resolvers)
20+ assert.Equal(t, 10*time.Minute, dnsCfg.PropagationTimeout)
21+ assert.Equal(t, 10*time.Second, dnsCfg.PollingInterval)
22+}
23+
24+func TestBuildDNSConfigRejectsInvalidDuration(t *testing.T) {
25+ cfg := Config{}
26+ cfg.DNS.PropagationTimeout = "not-a-duration"
27+
28+ _, err := buildDNSConfig(cfg)
29+ require.Error(t, err)
30+ assert.Contains(t, err.Error(), "dns.propagation_timeout")
31+}
@@ -0,0 +1,31 @@
1+package app
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestBuildDNSConfigFromConfig(t *testing.T) {
12+ cfg := Config{}
13+ cfg.DNS.Resolvers = []string{"9.9.9.9:53", "1.1.1.1:53"}
14+ cfg.DNS.PropagationTimeout = "10m"
15+ cfg.DNS.PollingInterval = "10s"
16+
17+ dnsCfg, err := buildDNSConfig(cfg)
18+ require.NoError(t, err)
19+ assert.Equal(t, []string{"9.9.9.9:53", "1.1.1.1:53"}, dnsCfg.Resolvers)
20+ assert.Equal(t, 10*time.Minute, dnsCfg.PropagationTimeout)
21+ assert.Equal(t, 10*time.Second, dnsCfg.PollingInterval)
22+}
23+
24+func TestBuildDNSConfigRejectsInvalidDuration(t *testing.T) {
25+ cfg := Config{}
26+ cfg.DNS.PropagationTimeout = "not-a-duration"
27+
28+ _, err := buildDNSConfig(cfg)
29+ require.Error(t, err)
30+ assert.Contains(t, err.Error(), "dns.propagation_timeout")
31+}
M internal/app/run.go +14 -5
@@ -553,6 +553,11 @@ func (si *serviceInit) initPublicTLS() error {
553553 ctx := si.ctx
554554 log := si.log
555555
556+ dnsCfg, err := buildDNSConfig(si.cfg)
557+ if err != nil {
558+ return log.WrapErr(err, "invalid DNS configuration")
559+ }
560+
556561 publicTLSCfg := publictls.Config{
557562 Enabled: si.cfg.TLS.ACME.Enabled,
558563 Email: si.cfg.TLS.ACME.Email,
@@ -561,6 +566,7 @@ func (si *serviceInit) initPublicTLS() error {
561566 TLSPort: si.cfg.Server.TLSPort,
562567 DataDir: resolveDataDir(si.cfg.Server.DataDir),
563568 ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize,
569+ DNS: dnsCfg,
564570 }
565571
566572 tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{})
@@ -583,11 +589,14 @@ func (si *serviceInit) initPublicTLS() error {
583589 }
584590
585591 issuer, err := acmelego.NewIssuer(acmelego.Config{
586- Email: si.cfg.TLS.ACME.Email,
587- Challenge: effective.Mode,
588- Token: effective.Token,
589- Store: store,
590- HTTPChallengeSink: challenges,
592+ Email: si.cfg.TLS.ACME.Email,
593+ Challenge: effective.Mode,
594+ Token: effective.Token,
595+ Store: store,
596+ HTTPChallengeSink: challenges,
597+ DNSResolvers: publicTLSCfg.DNS.Resolvers,
598+ DNSPropagationTimeout: publicTLSCfg.DNS.PropagationTimeout,
599+ DNSPollingInterval: publicTLSCfg.DNS.PollingInterval,
591600 })
592601 if err != nil {
593602 return log.WrapErr(err, "create ACME issuer")
@@ -553,6 +553,11 @@ func (si *serviceInit) initPublicTLS() error {
553 ctx := si.ctx553 ctx := si.ctx
554 log := si.log554 log := si.log
555 555
556+ dnsCfg, err := buildDNSConfig(si.cfg)
557+ if err != nil {
558+ return log.WrapErr(err, "invalid DNS configuration")
559+ }
560+
556 publicTLSCfg := publictls.Config{561 publicTLSCfg := publictls.Config{
557 Enabled: si.cfg.TLS.ACME.Enabled,562 Enabled: si.cfg.TLS.ACME.Enabled,
558 Email: si.cfg.TLS.ACME.Email,563 Email: si.cfg.TLS.ACME.Email,
@@ -561,6 +566,7 @@ func (si *serviceInit) initPublicTLS() error {
561 TLSPort: si.cfg.Server.TLSPort,566 TLSPort: si.cfg.Server.TLSPort,
562 DataDir: resolveDataDir(si.cfg.Server.DataDir),567 DataDir: resolveDataDir(si.cfg.Server.DataDir),
563 ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize,568 ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize,
569+ DNS: dnsCfg,
564 }570 }
565 571
566 tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{})572 tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{})
@@ -583,11 +589,14 @@ func (si *serviceInit) initPublicTLS() error {
583 }589 }
584 590
585 issuer, err := acmelego.NewIssuer(acmelego.Config{591 issuer, err := acmelego.NewIssuer(acmelego.Config{
586- Email: si.cfg.TLS.ACME.Email,592+ Email: si.cfg.TLS.ACME.Email,
587- Challenge: effective.Mode,593+ Challenge: effective.Mode,
588- Token: effective.Token,594+ Token: effective.Token,
589- Store: store,595+ Store: store,
590- HTTPChallengeSink: challenges,596+ HTTPChallengeSink: challenges,
597+ DNSResolvers: publicTLSCfg.DNS.Resolvers,
598+ DNSPropagationTimeout: publicTLSCfg.DNS.PropagationTimeout,
599+ DNSPollingInterval: publicTLSCfg.DNS.PollingInterval,
591 })600 })
592 if err != nil {601 if err != nil {
593 return log.WrapErr(err, "create ACME issuer")602 return log.WrapErr(err, "create ACME issuer")

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close