detached 0d280f4

Merge pull request #184 from bnema/feat/global-dns-acme-dns01

Add global DNS config for ACME DNS-01
commit
0d280f449a7ba4f47189d9cd48972d8c3385a431
author
bnema · · 2026-05-01 17:48 UTC
committer
GitHub · · 2026-05-01 17:48 UTC
merge of
6a38a35 + 51f9860 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.31.0-dev13
11 files · +471 -9
M docs/config/reference.md +11 -0
@@ -23,6 +23,14 @@ registry_allowed_ips = [] # IPs or CIDR ranges allowed to acc
2323 proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs)
2424 registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only)
2525
26+# =============================================================================
27+# DNS
28+# =============================================================================
29+[dns]
30+resolvers = ["1.1.1.1:53", "8.8.8.8:53"] # Recursive resolvers for public DNS visibility checks
31+propagation_timeout = "5m" # Max wait for DNS-01 TXT propagation
32+polling_interval = "5s" # Interval between DNS-01 propagation checks
33+
2634 # =============================================================================
2735 # AUTHENTICATION (required - Gordon won't start without credentials configured)
2836 # =============================================================================
@@ -204,6 +212,9 @@ keep_last = 3 # Keep N newest tags per repository
204212 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
205213 | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
206214 | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) |
215+| `dns.resolvers` | `["1.1.1.1:53", "8.8.8.8:53"]` | Recursive resolvers used for public DNS visibility checks, including ACME DNS-01 propagation |
216+| `dns.propagation_timeout` | `"5m"` | Maximum time to wait for DNS-01 TXT records to become visible through configured recursive resolvers |
217+| `dns.polling_interval` | `"5s"` | Interval between DNS-01 propagation checks |
207218 | `tls.acme.enabled` | `false` | Enable public ACME certificates (requires `server.tls_port > 0`) |
208219 | `tls.acme.email` | `""` | ACME account email when enabled |
209220 | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` |
@@ -23,6 +23,14 @@ registry_allowed_ips = [] # IPs or CIDR ranges allowed to acc
23 proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs)23 proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs)
24 registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only)24 registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only)
25 25
26+# =============================================================================
27+# DNS
28+# =============================================================================
29+[dns]
30+resolvers = ["1.1.1.1:53", "8.8.8.8:53"] # Recursive resolvers for public DNS visibility checks
31+propagation_timeout = "5m" # Max wait for DNS-01 TXT propagation
32+polling_interval = "5s" # Interval between DNS-01 propagation checks
33+
26 # =============================================================================34 # =============================================================================
27 # AUTHENTICATION (required - Gordon won't start without credentials configured)35 # AUTHENTICATION (required - Gordon won't start without credentials configured)
28 # =============================================================================36 # =============================================================================
@@ -204,6 +212,9 @@ keep_last = 3 # Keep N newest tags per repository
204 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |212 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
205 | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |213 | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
206 | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) |214 | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) |
215+| `dns.resolvers` | `["1.1.1.1:53", "8.8.8.8:53"]` | Recursive resolvers used for public DNS visibility checks, including ACME DNS-01 propagation |
216+| `dns.propagation_timeout` | `"5m"` | Maximum time to wait for DNS-01 TXT records to become visible through configured recursive resolvers |
217+| `dns.polling_interval` | `"5s"` | Interval between DNS-01 propagation checks |
207 | `tls.acme.enabled` | `false` | Enable public ACME certificates (requires `server.tls_port > 0`) |218 | `tls.acme.enabled` | `false` | Enable public ACME certificates (requires `server.tls_port > 0`) |
208 | `tls.acme.email` | `""` | ACME account email when enabled |219 | `tls.acme.email` | `""` | ACME account email when enabled |
209 | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` |220 | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` |
M docs/config/server.md +11 -0
@@ -92,6 +92,11 @@ Gordon can obtain public certificates through Let's Encrypt-compatible ACME when
9292 [server]
9393 tls_port = 8443
9494
95+[dns]
96+resolvers = ["1.1.1.1:53", "8.8.8.8:53"]
97+propagation_timeout = "5m"
98+polling_interval = "5s"
99+
95100 [tls.acme]
96101 enabled = true
97102 email = "admin@example.com"
@@ -114,6 +119,12 @@ For Cloudflare Full/Strict, Cloudflare terminates browser TLS at the edge and co
114119
115120 Static certificates have priority, then public ACME certificates, then Gordon's internal CA fallback. Gordon uses the `go-acme/lego` ACME client, including its DNS-provider support for Cloudflare DNS-01.
116121
122+DNS-01 uses the Cloudflare API to create TXT records, then checks public DNS visibility through `[dns].resolvers`. These resolvers are recursive resolvers (for example Cloudflare DNS or Google DNS), not the authoritative DNS provider. A domain can be hosted at Cloudflare while Gordon verifies propagation through Google DNS.
123+
124+The defaults avoid relying on host-local DNS. This matters on hosts using Tailscale MagicDNS, split-horizon corporate DNS, or Pi-hole, where `/etc/resolv.conf` may not reflect public DNS visibility as Let's Encrypt sees it.
125+
126+Because Gordon's ACME challenge mode is global, `cloudflare-dns-01` requires a Cloudflare token that can read zones and edit DNS records for every zone used by configured HTTPS routes. If that is not desirable, use `http-01` until Gordon supports per-route or per-zone challenge policy.
127+
117128 #### Direct HTTP Onboarding
118129
119130 When `tls_port` is non-zero, direct HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only:
@@ -92,6 +92,11 @@ Gordon can obtain public certificates through Let's Encrypt-compatible ACME when
92 [server]92 [server]
93 tls_port = 844393 tls_port = 8443
94 94
95+[dns]
96+resolvers = ["1.1.1.1:53", "8.8.8.8:53"]
97+propagation_timeout = "5m"
98+polling_interval = "5s"
99+
95 [tls.acme]100 [tls.acme]
96 enabled = true101 enabled = true
97 email = "admin@example.com"102 email = "admin@example.com"
@@ -114,6 +119,12 @@ For Cloudflare Full/Strict, Cloudflare terminates browser TLS at the edge and co
114 119
115 Static certificates have priority, then public ACME certificates, then Gordon's internal CA fallback. Gordon uses the `go-acme/lego` ACME client, including its DNS-provider support for Cloudflare DNS-01.120 Static certificates have priority, then public ACME certificates, then Gordon's internal CA fallback. Gordon uses the `go-acme/lego` ACME client, including its DNS-provider support for Cloudflare DNS-01.
116 121
122+DNS-01 uses the Cloudflare API to create TXT records, then checks public DNS visibility through `[dns].resolvers`. These resolvers are recursive resolvers (for example Cloudflare DNS or Google DNS), not the authoritative DNS provider. A domain can be hosted at Cloudflare while Gordon verifies propagation through Google DNS.
123+
124+The defaults avoid relying on host-local DNS. This matters on hosts using Tailscale MagicDNS, split-horizon corporate DNS, or Pi-hole, where `/etc/resolv.conf` may not reflect public DNS visibility as Let's Encrypt sees it.
125+
126+Because Gordon's ACME challenge mode is global, `cloudflare-dns-01` requires a Cloudflare token that can read zones and edit DNS records for every zone used by configured HTTPS routes. If that is not desirable, use `http-01` until Gordon supports per-route or per-zone challenge policy.
127+
117 #### Direct HTTP Onboarding128 #### Direct HTTP Onboarding
118 129
119 When `tls_port` is non-zero, direct HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only:130 When `tls_port` is non-zero, direct HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only:
M internal/adapters/out/acmelego/issuer.go +65 -4
@@ -7,7 +7,9 @@ import (
77 "crypto/x509"
88 "encoding/json"
99 "fmt"
10+ "net"
1011 "net/http"
12+ "strconv"
1113 "strings"
1214 "sync"
1315 "time"
@@ -15,6 +17,7 @@ import (
1517 "github.com/go-acme/lego/v4/acme"
1618 "github.com/go-acme/lego/v4/certcrypto"
1719 "github.com/go-acme/lego/v4/certificate"
20+ "github.com/go-acme/lego/v4/challenge/dns01"
1821 "github.com/go-acme/lego/v4/lego"
1922 "github.com/go-acme/lego/v4/providers/dns/cloudflare"
2023 "github.com/go-acme/lego/v4/registration"
@@ -34,6 +37,15 @@ type Config struct {
3437 // Token is the Cloudflare API token (required for DNS-01 challenge).
3538 Token string
3639
40+ // DNSResolvers are recursive resolvers used for DNS-01 propagation checks.
41+ DNSResolvers []string
42+
43+ // DNSPropagationTimeout is the maximum wait for DNS-01 TXT propagation.
44+ DNSPropagationTimeout time.Duration
45+
46+ // DNSPollingInterval is the interval between DNS-01 propagation checks.
47+ DNSPollingInterval time.Duration
48+
3749 // Store persists ACME accounts and certificates.
3850 Store out.CertificateStore
3951
@@ -81,6 +93,20 @@ func NewIssuer(cfg Config) (*Issuer, error) {
8193 if cfg.Token == "" {
8294 return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing)
8395 }
96+ resolvers, err := normalizeDNSResolvers(cfg.DNSResolvers)
97+ if err != nil {
98+ return nil, err
99+ }
100+ cfg.DNSResolvers = resolvers
101+ if cfg.DNSPropagationTimeout <= 0 {
102+ return nil, fmt.Errorf("acmelego: %w: DNSPropagationTimeout must be positive", domain.ErrDNSConfigInvalid)
103+ }
104+ if cfg.DNSPollingInterval <= 0 {
105+ return nil, fmt.Errorf("acmelego: %w: DNSPollingInterval must be positive", domain.ErrDNSConfigInvalid)
106+ }
107+ if cfg.DNSPollingInterval >= cfg.DNSPropagationTimeout {
108+ return nil, fmt.Errorf("acmelego: %w: DNSPollingInterval must be less than DNSPropagationTimeout", domain.ErrDNSConfigInvalid)
109+ }
84110 default:
85111 return nil, fmt.Errorf("acmelego: %w: %s", domain.ErrACMEChallengeInvalid, cfg.Challenge)
86112 }
@@ -88,6 +114,34 @@ func NewIssuer(cfg Config) (*Issuer, error) {
88114 return &Issuer{cfg: cfg}, nil
89115 }
90116
117+func normalizeDNSResolvers(resolvers []string) ([]string, error) {
118+ if len(resolvers) == 0 {
119+ return nil, fmt.Errorf("acmelego: %w: DNSResolvers must contain at least one resolver", domain.ErrDNSConfigInvalid)
120+ }
121+
122+ normalized := make([]string, len(resolvers))
123+ for i, resolver := range resolvers {
124+ trimmed := strings.TrimSpace(resolver)
125+ if trimmed == "" {
126+ return nil, fmt.Errorf("acmelego: %w: invalid DNSResolvers entry at index %d value %q", domain.ErrDNSConfigInvalid, i, trimmed)
127+ }
128+ host, port, err := net.SplitHostPort(trimmed)
129+ if err != nil {
130+ return nil, fmt.Errorf("acmelego: %w: invalid DNSResolvers entry at index %d value %q: %v", domain.ErrDNSConfigInvalid, i, trimmed, err)
131+ }
132+ if host == "" {
133+ return nil, fmt.Errorf("acmelego: %w: invalid DNSResolvers entry at index %d value %q: host must not be empty", domain.ErrDNSConfigInvalid, i, trimmed)
134+ }
135+ portNumber, err := strconv.Atoi(port)
136+ if err != nil || portNumber < 1 || portNumber > 65535 {
137+ return nil, fmt.Errorf("acmelego: %w: invalid DNSResolvers entry at index %d value %q: port must be a number between 1 and 65535", domain.ErrDNSConfigInvalid, i, trimmed)
138+ }
139+ normalized[i] = trimmed
140+ }
141+
142+ return normalized, nil
143+}
144+
91145 // compile-time interface check
92146 var _ out.PublicCertificateIssuer = (*Issuer)(nil)
93147
@@ -208,13 +262,11 @@ func (i *Issuer) buildClient(ctx context.Context) (*AccountUser, *lego.Client, e
208262 return nil, nil, fmt.Errorf("set http-01 provider: %w", err)
209263 }
210264 case domain.ACMEChallengeCloudflareDNS01:
211- cfCfg := cloudflare.NewDefaultConfig()
212- cfCfg.AuthToken = i.cfg.Token
213- cfProvider, err := cloudflare.NewDNSProviderConfig(cfCfg)
265+ cfProvider, err := cloudflare.NewDNSProviderConfig(newCloudflareDNSProviderConfig(i.cfg))
214266 if err != nil {
215267 return nil, nil, fmt.Errorf("create cloudflare dns provider: %w", err)
216268 }
217- if err := client.Challenge.SetDNS01Provider(cfProvider); err != nil {
269+ if err := client.Challenge.SetDNS01Provider(cfProvider, dns01.AddRecursiveNameservers(i.cfg.DNSResolvers)); err != nil {
218270 return nil, nil, fmt.Errorf("set dns-01 provider: %w", err)
219271 }
220272 }
@@ -312,6 +364,15 @@ func (i *Issuer) newLegoConfig(user *AccountUser) *lego.Config {
312364 return legoCfg
313365 }
314366
367+// newCloudflareDNSProviderConfig creates a cloudflare DNS provider config from the issuer config.
368+func newCloudflareDNSProviderConfig(cfg Config) *cloudflare.Config {
369+ cfCfg := cloudflare.NewDefaultConfig()
370+ cfCfg.AuthToken = cfg.Token
371+ cfCfg.PropagationTimeout = cfg.DNSPropagationTimeout
372+ cfCfg.PollingInterval = cfg.DNSPollingInterval
373+ return cfCfg
374+}
375+
315376 // resourceToStored converts a lego certificate.Resource to out.StoredCertificate.
316377 func resourceToStored(order out.CertificateOrder, res *certificate.Resource) (*out.StoredCertificate, error) {
317378 var fullchainPEM []byte
@@ -7,7 +7,9 @@ import (
7 "crypto/x509"7 "crypto/x509"
8 "encoding/json"8 "encoding/json"
9 "fmt"9 "fmt"
10+ "net"
10 "net/http"11 "net/http"
12+ "strconv"
11 "strings"13 "strings"
12 "sync"14 "sync"
13 "time"15 "time"
@@ -15,6 +17,7 @@ import (
15 "github.com/go-acme/lego/v4/acme"17 "github.com/go-acme/lego/v4/acme"
16 "github.com/go-acme/lego/v4/certcrypto"18 "github.com/go-acme/lego/v4/certcrypto"
17 "github.com/go-acme/lego/v4/certificate"19 "github.com/go-acme/lego/v4/certificate"
20+ "github.com/go-acme/lego/v4/challenge/dns01"
18 "github.com/go-acme/lego/v4/lego"21 "github.com/go-acme/lego/v4/lego"
19 "github.com/go-acme/lego/v4/providers/dns/cloudflare"22 "github.com/go-acme/lego/v4/providers/dns/cloudflare"
20 "github.com/go-acme/lego/v4/registration"23 "github.com/go-acme/lego/v4/registration"
@@ -34,6 +37,15 @@ type Config struct {
34 // Token is the Cloudflare API token (required for DNS-01 challenge).37 // Token is the Cloudflare API token (required for DNS-01 challenge).
35 Token string38 Token string
36 39
40+ // DNSResolvers are recursive resolvers used for DNS-01 propagation checks.
41+ DNSResolvers []string
42+
43+ // DNSPropagationTimeout is the maximum wait for DNS-01 TXT propagation.
44+ DNSPropagationTimeout time.Duration
45+
46+ // DNSPollingInterval is the interval between DNS-01 propagation checks.
47+ DNSPollingInterval time.Duration
48+
37 // Store persists ACME accounts and certificates.49 // Store persists ACME accounts and certificates.
38 Store out.CertificateStore50 Store out.CertificateStore
39 51
@@ -81,6 +93,20 @@ func NewIssuer(cfg Config) (*Issuer, error) {
81 if cfg.Token == "" {93 if cfg.Token == "" {
82 return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing)94 return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing)
83 }95 }
96+ resolvers, err := normalizeDNSResolvers(cfg.DNSResolvers)
97+ if err != nil {
98+ return nil, err
99+ }
100+ cfg.DNSResolvers = resolvers
101+ if cfg.DNSPropagationTimeout <= 0 {
102+ return nil, fmt.Errorf("acmelego: %w: DNSPropagationTimeout must be positive", domain.ErrDNSConfigInvalid)
103+ }
104+ if cfg.DNSPollingInterval <= 0 {
105+ return nil, fmt.Errorf("acmelego: %w: DNSPollingInterval must be positive", domain.ErrDNSConfigInvalid)
106+ }
107+ if cfg.DNSPollingInterval >= cfg.DNSPropagationTimeout {
108+ return nil, fmt.Errorf("acmelego: %w: DNSPollingInterval must be less than DNSPropagationTimeout", domain.ErrDNSConfigInvalid)
109+ }
84 default:110 default:
85 return nil, fmt.Errorf("acmelego: %w: %s", domain.ErrACMEChallengeInvalid, cfg.Challenge)111 return nil, fmt.Errorf("acmelego: %w: %s", domain.ErrACMEChallengeInvalid, cfg.Challenge)
86 }112 }
@@ -88,6 +114,34 @@ func NewIssuer(cfg Config) (*Issuer, error) {
88 return &Issuer{cfg: cfg}, nil114 return &Issuer{cfg: cfg}, nil
89 }115 }
90 116
117+func normalizeDNSResolvers(resolvers []string) ([]string, error) {
118+ if len(resolvers) == 0 {
119+ return nil, fmt.Errorf("acmelego: %w: DNSResolvers must contain at least one resolver", domain.ErrDNSConfigInvalid)
120+ }
121+
122+ normalized := make([]string, len(resolvers))
123+ for i, resolver := range resolvers {
124+ trimmed := strings.TrimSpace(resolver)
125+ if trimmed == "" {
126+ return nil, fmt.Errorf("acmelego: %w: invalid DNSResolvers entry at index %d value %q", domain.ErrDNSConfigInvalid, i, trimmed)
127+ }
128+ host, port, err := net.SplitHostPort(trimmed)
129+ if err != nil {
130+ return nil, fmt.Errorf("acmelego: %w: invalid DNSResolvers entry at index %d value %q: %v", domain.ErrDNSConfigInvalid, i, trimmed, err)
131+ }
132+ if host == "" {
133+ return nil, fmt.Errorf("acmelego: %w: invalid DNSResolvers entry at index %d value %q: host must not be empty", domain.ErrDNSConfigInvalid, i, trimmed)
134+ }
135+ portNumber, err := strconv.Atoi(port)
136+ if err != nil || portNumber < 1 || portNumber > 65535 {
137+ return nil, fmt.Errorf("acmelego: %w: invalid DNSResolvers entry at index %d value %q: port must be a number between 1 and 65535", domain.ErrDNSConfigInvalid, i, trimmed)
138+ }
139+ normalized[i] = trimmed
140+ }
141+
142+ return normalized, nil
143+}
144+
91 // compile-time interface check145 // compile-time interface check
92 var _ out.PublicCertificateIssuer = (*Issuer)(nil)146 var _ out.PublicCertificateIssuer = (*Issuer)(nil)
93 147
@@ -208,13 +262,11 @@ func (i *Issuer) buildClient(ctx context.Context) (*AccountUser, *lego.Client, e
208 return nil, nil, fmt.Errorf("set http-01 provider: %w", err)262 return nil, nil, fmt.Errorf("set http-01 provider: %w", err)
209 }263 }
210 case domain.ACMEChallengeCloudflareDNS01:264 case domain.ACMEChallengeCloudflareDNS01:
211- cfCfg := cloudflare.NewDefaultConfig()265+ cfProvider, err := cloudflare.NewDNSProviderConfig(newCloudflareDNSProviderConfig(i.cfg))
212- cfCfg.AuthToken = i.cfg.Token
213- cfProvider, err := cloudflare.NewDNSProviderConfig(cfCfg)
214 if err != nil {266 if err != nil {
215 return nil, nil, fmt.Errorf("create cloudflare dns provider: %w", err)267 return nil, nil, fmt.Errorf("create cloudflare dns provider: %w", err)
216 }268 }
217- if err := client.Challenge.SetDNS01Provider(cfProvider); err != nil {269+ 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)270 return nil, nil, fmt.Errorf("set dns-01 provider: %w", err)
219 }271 }
220 }272 }
@@ -312,6 +364,15 @@ func (i *Issuer) newLegoConfig(user *AccountUser) *lego.Config {
312 return legoCfg364 return legoCfg
313 }365 }
314 366
367+// newCloudflareDNSProviderConfig creates a cloudflare DNS provider config from the issuer config.
368+func newCloudflareDNSProviderConfig(cfg Config) *cloudflare.Config {
369+ cfCfg := cloudflare.NewDefaultConfig()
370+ cfCfg.AuthToken = cfg.Token
371+ cfCfg.PropagationTimeout = cfg.DNSPropagationTimeout
372+ cfCfg.PollingInterval = cfg.DNSPollingInterval
373+ return cfCfg
374+}
375+
315 // resourceToStored converts a lego certificate.Resource to out.StoredCertificate.376 // resourceToStored converts a lego certificate.Resource to out.StoredCertificate.
316 func resourceToStored(order out.CertificateOrder, res *certificate.Resource) (*out.StoredCertificate, error) {377 func resourceToStored(order out.CertificateOrder, res *certificate.Resource) (*out.StoredCertificate, error) {
317 var fullchainPEM []byte378 var fullchainPEM []byte
M internal/adapters/out/acmelego/issuer_test.go +93 -0
@@ -69,6 +69,85 @@ 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.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
84+ assert.Contains(t, err.Error(), "DNSResolvers")
85+
86+ _, err = NewIssuer(Config{
87+ Email: "test@example.com",
88+ Challenge: domain.ACMEChallengeCloudflareDNS01,
89+ Token: "token",
90+ Store: outmocks.NewMockCertificateStore(t),
91+ DNSResolvers: []string{"1.1.1.1:abc"},
92+ DNSPropagationTimeout: 5 * time.Minute,
93+ DNSPollingInterval: 5 * time.Second,
94+ })
95+ require.Error(t, err)
96+ assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
97+ assert.Contains(t, err.Error(), "invalid DNSResolvers entry")
98+ assert.Contains(t, err.Error(), "index 0")
99+ assert.Contains(t, err.Error(), "1.1.1.1:abc")
100+
101+ _, err = NewIssuer(Config{
102+ Email: "test@example.com",
103+ Challenge: domain.ACMEChallengeCloudflareDNS01,
104+ Token: "token",
105+ Store: outmocks.NewMockCertificateStore(t),
106+ DNSResolvers: []string{"1.1.1.1:53"},
107+ DNSPropagationTimeout: 5 * time.Second,
108+ DNSPollingInterval: 5 * time.Second,
109+ })
110+ require.Error(t, err)
111+ assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
112+ assert.Contains(t, err.Error(), "DNSPollingInterval")
113+
114+ issuer, err := NewIssuer(Config{
115+ Email: "test@example.com",
116+ Challenge: domain.ACMEChallengeCloudflareDNS01,
117+ Token: "token",
118+ Store: outmocks.NewMockCertificateStore(t),
119+ DNSResolvers: []string{" 1.1.1.1:53 ", "\t8.8.8.8:53\n"},
120+ DNSPropagationTimeout: 5 * time.Minute,
121+ DNSPollingInterval: 5 * time.Second,
122+ })
123+ require.NoError(t, err)
124+ assert.Equal(t, []string{"1.1.1.1:53", "8.8.8.8:53"}, issuer.cfg.DNSResolvers)
125+}
126+
127+func TestNormalizeDNSResolversErrorsIncludeEntryContext(t *testing.T) {
128+ tests := []struct {
129+ name string
130+ resolver string
131+ want string
132+ }{
133+ {name: "empty after trimming", resolver: " \t ", want: "value \"\""},
134+ {name: "missing port", resolver: "1.1.1.1", want: "value \"1.1.1.1\""},
135+ {name: "empty host", resolver: ":53", want: "value \":53\""},
136+ {name: "invalid port", resolver: "1.1.1.1:abc", want: "value \"1.1.1.1:abc\""},
137+ }
138+
139+ for _, tt := range tests {
140+ t.Run(tt.name, func(t *testing.T) {
141+ _, err := normalizeDNSResolvers([]string{"8.8.8.8:53", tt.resolver})
142+ require.Error(t, err)
143+ assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
144+ assert.Contains(t, err.Error(), "invalid DNSResolvers entry")
145+ assert.Contains(t, err.Error(), "index 1")
146+ assert.Contains(t, err.Error(), tt.want)
147+ })
148+ }
149+}
150+
72151 func TestNewIssuerRejectsNonHTTPSURL(t *testing.T) {
73152 _, err := NewIssuer(Config{
74153 Email: "test@example.com",
@@ -102,6 +181,20 @@ func TestNewIssuerValidWithDefaults(t *testing.T) {
102181 assert.Equal(t, domain.ACMEChallengeHTTP01, issuer.cfg.Challenge)
103182 }
104183
184+func TestCloudflareDNSProviderConfigUsesDNSSettings(t *testing.T) {
185+ cfg := Config{
186+ Token: "token",
187+ DNSPropagationTimeout: 7 * time.Minute,
188+ DNSPollingInterval: 11 * time.Second,
189+ }
190+
191+ cfCfg := newCloudflareDNSProviderConfig(cfg)
192+
193+ assert.Equal(t, "token", cfCfg.AuthToken)
194+ assert.Equal(t, 7*time.Minute, cfCfg.PropagationTimeout)
195+ assert.Equal(t, 11*time.Second, cfCfg.PollingInterval)
196+}
197+
105198 func TestPrivateKeyRoundTrip(t *testing.T) {
106199 // Generate an ECDSA P-256 private key
107200 privateKey, err := certcrypto.GeneratePrivateKey(certcrypto.EC256)
@@ -69,6 +69,85 @@ 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.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
84+ assert.Contains(t, err.Error(), "DNSResolvers")
85+
86+ _, err = NewIssuer(Config{
87+ Email: "test@example.com",
88+ Challenge: domain.ACMEChallengeCloudflareDNS01,
89+ Token: "token",
90+ Store: outmocks.NewMockCertificateStore(t),
91+ DNSResolvers: []string{"1.1.1.1:abc"},
92+ DNSPropagationTimeout: 5 * time.Minute,
93+ DNSPollingInterval: 5 * time.Second,
94+ })
95+ require.Error(t, err)
96+ assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
97+ assert.Contains(t, err.Error(), "invalid DNSResolvers entry")
98+ assert.Contains(t, err.Error(), "index 0")
99+ assert.Contains(t, err.Error(), "1.1.1.1:abc")
100+
101+ _, err = NewIssuer(Config{
102+ Email: "test@example.com",
103+ Challenge: domain.ACMEChallengeCloudflareDNS01,
104+ Token: "token",
105+ Store: outmocks.NewMockCertificateStore(t),
106+ DNSResolvers: []string{"1.1.1.1:53"},
107+ DNSPropagationTimeout: 5 * time.Second,
108+ DNSPollingInterval: 5 * time.Second,
109+ })
110+ require.Error(t, err)
111+ assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
112+ assert.Contains(t, err.Error(), "DNSPollingInterval")
113+
114+ issuer, err := NewIssuer(Config{
115+ Email: "test@example.com",
116+ Challenge: domain.ACMEChallengeCloudflareDNS01,
117+ Token: "token",
118+ Store: outmocks.NewMockCertificateStore(t),
119+ DNSResolvers: []string{" 1.1.1.1:53 ", "\t8.8.8.8:53\n"},
120+ DNSPropagationTimeout: 5 * time.Minute,
121+ DNSPollingInterval: 5 * time.Second,
122+ })
123+ require.NoError(t, err)
124+ assert.Equal(t, []string{"1.1.1.1:53", "8.8.8.8:53"}, issuer.cfg.DNSResolvers)
125+}
126+
127+func TestNormalizeDNSResolversErrorsIncludeEntryContext(t *testing.T) {
128+ tests := []struct {
129+ name string
130+ resolver string
131+ want string
132+ }{
133+ {name: "empty after trimming", resolver: " \t ", want: "value \"\""},
134+ {name: "missing port", resolver: "1.1.1.1", want: "value \"1.1.1.1\""},
135+ {name: "empty host", resolver: ":53", want: "value \":53\""},
136+ {name: "invalid port", resolver: "1.1.1.1:abc", want: "value \"1.1.1.1:abc\""},
137+ }
138+
139+ for _, tt := range tests {
140+ t.Run(tt.name, func(t *testing.T) {
141+ _, err := normalizeDNSResolvers([]string{"8.8.8.8:53", tt.resolver})
142+ require.Error(t, err)
143+ assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
144+ assert.Contains(t, err.Error(), "invalid DNSResolvers entry")
145+ assert.Contains(t, err.Error(), "index 1")
146+ assert.Contains(t, err.Error(), tt.want)
147+ })
148+ }
149+}
150+
72 func TestNewIssuerRejectsNonHTTPSURL(t *testing.T) {151 func TestNewIssuerRejectsNonHTTPSURL(t *testing.T) {
73 _, err := NewIssuer(Config{152 _, err := NewIssuer(Config{
74 Email: "test@example.com",153 Email: "test@example.com",
@@ -102,6 +181,20 @@ func TestNewIssuerValidWithDefaults(t *testing.T) {
102 assert.Equal(t, domain.ACMEChallengeHTTP01, issuer.cfg.Challenge)181 assert.Equal(t, domain.ACMEChallengeHTTP01, issuer.cfg.Challenge)
103 }182 }
104 183
184+func TestCloudflareDNSProviderConfigUsesDNSSettings(t *testing.T) {
185+ cfg := Config{
186+ Token: "token",
187+ DNSPropagationTimeout: 7 * time.Minute,
188+ DNSPollingInterval: 11 * time.Second,
189+ }
190+
191+ cfCfg := newCloudflareDNSProviderConfig(cfg)
192+
193+ assert.Equal(t, "token", cfCfg.AuthToken)
194+ assert.Equal(t, 7*time.Minute, cfCfg.PropagationTimeout)
195+ assert.Equal(t, 11*time.Second, cfCfg.PollingInterval)
196+}
197+
105 func TestPrivateKeyRoundTrip(t *testing.T) {198 func TestPrivateKeyRoundTrip(t *testing.T) {
106 // Generate an ECDSA P-256 private key199 // Generate an ECDSA P-256 private key
107 privateKey, err := certcrypto.GeneratePrivateKey(certcrypto.EC256)200 privateKey, err := certcrypto.GeneratePrivateKey(certcrypto.EC256)
M internal/app/acme_config_test.go +3 -0
@@ -15,4 +15,7 @@ func TestLoadConfig_ACMEDefaults(t *testing.T) {
1515 assert.Equal(t, "", v.GetString("tls.acme.email"))
1616 assert.Equal(t, "auto", v.GetString("tls.acme.challenge"))
1717 assert.Equal(t, 1, v.GetInt("tls.acme.obtain_batch_size"))
18+ assert.Equal(t, []string{"1.1.1.1:53", "8.8.8.8:53"}, v.GetStringSlice("dns.resolvers"))
19+ assert.Equal(t, "5m", v.GetString("dns.propagation_timeout"))
20+ assert.Equal(t, "5s", v.GetString("dns.polling_interval"))
1821 }
@@ -15,4 +15,7 @@ func TestLoadConfig_ACMEDefaults(t *testing.T) {
15 assert.Equal(t, "", v.GetString("tls.acme.email"))15 assert.Equal(t, "", v.GetString("tls.acme.email"))
16 assert.Equal(t, "auto", v.GetString("tls.acme.challenge"))16 assert.Equal(t, "auto", v.GetString("tls.acme.challenge"))
17 assert.Equal(t, 1, v.GetInt("tls.acme.obtain_batch_size"))17 assert.Equal(t, 1, v.GetInt("tls.acme.obtain_batch_size"))
18+ assert.Equal(t, []string{"1.1.1.1:53", "8.8.8.8:53"}, v.GetStringSlice("dns.resolvers"))
19+ assert.Equal(t, "5m", v.GetString("dns.propagation_timeout"))
20+ assert.Equal(t, "5s", v.GetString("dns.polling_interval"))
18 }21 }
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 +61 -5
@@ -204,6 +204,12 @@ type Config struct {
204204 ObtainBatchSize int `mapstructure:"obtain_batch_size"`
205205 } `mapstructure:"acme"`
206206 } `mapstructure:"tls"`
207+
208+ DNS struct {
209+ Resolvers []string `mapstructure:"resolvers"`
210+ PropagationTimeout string `mapstructure:"propagation_timeout"`
211+ PollingInterval string `mapstructure:"polling_interval"`
212+ } `mapstructure:"dns"`
207213 }
208214
209215 // services holds all the services used by the application.
@@ -547,6 +553,11 @@ func (si *serviceInit) initPublicTLS() error {
547553 ctx := si.ctx
548554 log := si.log
549555
556+ dnsCfg, err := buildDNSConfig(si.cfg)
557+ if err != nil {
558+ return log.WrapErr(err, "invalid DNS configuration")
559+ }
560+
550561 publicTLSCfg := publictls.Config{
551562 Enabled: si.cfg.TLS.ACME.Enabled,
552563 Email: si.cfg.TLS.ACME.Email,
@@ -555,6 +566,7 @@ func (si *serviceInit) initPublicTLS() error {
555566 TLSPort: si.cfg.Server.TLSPort,
556567 DataDir: resolveDataDir(si.cfg.Server.DataDir),
557568 ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize,
569+ DNS: dnsCfg,
558570 }
559571
560572 tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{})
@@ -577,11 +589,14 @@ func (si *serviceInit) initPublicTLS() error {
577589 }
578590
579591 issuer, err := acmelego.NewIssuer(acmelego.Config{
580- Email: si.cfg.TLS.ACME.Email,
581- Challenge: effective.Mode,
582- Token: effective.Token,
583- Store: store,
584- 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,
585600 })
586601 if err != nil {
587602 return log.WrapErr(err, "create ACME issuer")
@@ -1748,6 +1763,44 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
17481763 }, nil
17491764 }
17501765
1766+// buildDNSConfig parses the raw dns config section into a publictls.DNSConfig.
1767+func buildDNSConfig(cfg Config) (publictls.DNSConfig, error) {
1768+ defaults := publictls.DefaultDNSConfig()
1769+
1770+ resolvers := cfg.DNS.Resolvers
1771+ if len(resolvers) == 0 {
1772+ resolvers = defaults.Resolvers
1773+ }
1774+
1775+ propagationTimeout := defaults.PropagationTimeout
1776+ if cfg.DNS.PropagationTimeout != "" {
1777+ parsed, err := time.ParseDuration(cfg.DNS.PropagationTimeout)
1778+ if err != nil {
1779+ return publictls.DNSConfig{}, fmt.Errorf("invalid dns.propagation_timeout: %w", err)
1780+ }
1781+ propagationTimeout = parsed
1782+ }
1783+
1784+ pollingInterval := defaults.PollingInterval
1785+ if cfg.DNS.PollingInterval != "" {
1786+ parsed, err := time.ParseDuration(cfg.DNS.PollingInterval)
1787+ if err != nil {
1788+ return publictls.DNSConfig{}, fmt.Errorf("invalid dns.polling_interval: %w", err)
1789+ }
1790+ pollingInterval = parsed
1791+ }
1792+
1793+ dnsCfg := publictls.DNSConfig{
1794+ Resolvers: append([]string(nil), resolvers...),
1795+ PropagationTimeout: propagationTimeout,
1796+ PollingInterval: pollingInterval,
1797+ }
1798+ if err := dnsCfg.Validate(); err != nil {
1799+ return publictls.DNSConfig{}, err
1800+ }
1801+ return dnsCfg, nil
1802+}
1803+
17511804 // createContainerService creates the container service with configuration.
17521805 func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
17531806 // Parse and validate container resource limits from config
@@ -3269,6 +3322,9 @@ func loadConfig(v *viper.Viper, configPath string) error {
32693322 v.SetDefault("tls.acme.email", "")
32703323 v.SetDefault("tls.acme.challenge", "auto")
32713324 v.SetDefault("tls.acme.obtain_batch_size", 1)
3325+ v.SetDefault("dns.resolvers", publictls.DefaultDNSResolvers)
3326+ v.SetDefault("dns.propagation_timeout", "5m")
3327+ v.SetDefault("dns.polling_interval", "5s")
32723328 v.SetDefault("server.force_https_redirect", false)
32733329 v.SetDefault("server.data_dir", DefaultDataDir())
32743330 v.SetDefault("server.runtime", "auto")
@@ -204,6 +204,12 @@ type Config struct {
204 ObtainBatchSize int `mapstructure:"obtain_batch_size"`204 ObtainBatchSize int `mapstructure:"obtain_batch_size"`
205 } `mapstructure:"acme"`205 } `mapstructure:"acme"`
206 } `mapstructure:"tls"`206 } `mapstructure:"tls"`
207+
208+ DNS struct {
209+ Resolvers []string `mapstructure:"resolvers"`
210+ PropagationTimeout string `mapstructure:"propagation_timeout"`
211+ PollingInterval string `mapstructure:"polling_interval"`
212+ } `mapstructure:"dns"`
207 }213 }
208 214
209 // services holds all the services used by the application.215 // services holds all the services used by the application.
@@ -547,6 +553,11 @@ func (si *serviceInit) initPublicTLS() error {
547 ctx := si.ctx553 ctx := si.ctx
548 log := si.log554 log := si.log
549 555
556+ dnsCfg, err := buildDNSConfig(si.cfg)
557+ if err != nil {
558+ return log.WrapErr(err, "invalid DNS configuration")
559+ }
560+
550 publicTLSCfg := publictls.Config{561 publicTLSCfg := publictls.Config{
551 Enabled: si.cfg.TLS.ACME.Enabled,562 Enabled: si.cfg.TLS.ACME.Enabled,
552 Email: si.cfg.TLS.ACME.Email,563 Email: si.cfg.TLS.ACME.Email,
@@ -555,6 +566,7 @@ func (si *serviceInit) initPublicTLS() error {
555 TLSPort: si.cfg.Server.TLSPort,566 TLSPort: si.cfg.Server.TLSPort,
556 DataDir: resolveDataDir(si.cfg.Server.DataDir),567 DataDir: resolveDataDir(si.cfg.Server.DataDir),
557 ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize,568 ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize,
569+ DNS: dnsCfg,
558 }570 }
559 571
560 tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{})572 tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{})
@@ -577,11 +589,14 @@ func (si *serviceInit) initPublicTLS() error {
577 }589 }
578 590
579 issuer, err := acmelego.NewIssuer(acmelego.Config{591 issuer, err := acmelego.NewIssuer(acmelego.Config{
580- Email: si.cfg.TLS.ACME.Email,592+ Email: si.cfg.TLS.ACME.Email,
581- Challenge: effective.Mode,593+ Challenge: effective.Mode,
582- Token: effective.Token,594+ Token: effective.Token,
583- Store: store,595+ Store: store,
584- HTTPChallengeSink: challenges,596+ HTTPChallengeSink: challenges,
597+ DNSResolvers: publicTLSCfg.DNS.Resolvers,
598+ DNSPropagationTimeout: publicTLSCfg.DNS.PropagationTimeout,
599+ DNSPollingInterval: publicTLSCfg.DNS.PollingInterval,
585 })600 })
586 if err != nil {601 if err != nil {
587 return log.WrapErr(err, "create ACME issuer")602 return log.WrapErr(err, "create ACME issuer")
@@ -1748,6 +1763,44 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
1748 }, nil1763 }, nil
1749 }1764 }
1750 1765
1766+// buildDNSConfig parses the raw dns config section into a publictls.DNSConfig.
1767+func buildDNSConfig(cfg Config) (publictls.DNSConfig, error) {
1768+ defaults := publictls.DefaultDNSConfig()
1769+
1770+ resolvers := cfg.DNS.Resolvers
1771+ if len(resolvers) == 0 {
1772+ resolvers = defaults.Resolvers
1773+ }
1774+
1775+ propagationTimeout := defaults.PropagationTimeout
1776+ if cfg.DNS.PropagationTimeout != "" {
1777+ parsed, err := time.ParseDuration(cfg.DNS.PropagationTimeout)
1778+ if err != nil {
1779+ return publictls.DNSConfig{}, fmt.Errorf("invalid dns.propagation_timeout: %w", err)
1780+ }
1781+ propagationTimeout = parsed
1782+ }
1783+
1784+ pollingInterval := defaults.PollingInterval
1785+ if cfg.DNS.PollingInterval != "" {
1786+ parsed, err := time.ParseDuration(cfg.DNS.PollingInterval)
1787+ if err != nil {
1788+ return publictls.DNSConfig{}, fmt.Errorf("invalid dns.polling_interval: %w", err)
1789+ }
1790+ pollingInterval = parsed
1791+ }
1792+
1793+ dnsCfg := publictls.DNSConfig{
1794+ Resolvers: append([]string(nil), resolvers...),
1795+ PropagationTimeout: propagationTimeout,
1796+ PollingInterval: pollingInterval,
1797+ }
1798+ if err := dnsCfg.Validate(); err != nil {
1799+ return publictls.DNSConfig{}, err
1800+ }
1801+ return dnsCfg, nil
1802+}
1803+
1751 // createContainerService creates the container service with configuration.1804 // createContainerService creates the container service with configuration.
1752 func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {1805 func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
1753 // Parse and validate container resource limits from config1806 // Parse and validate container resource limits from config
@@ -3269,6 +3322,9 @@ func loadConfig(v *viper.Viper, configPath string) error {
3269 v.SetDefault("tls.acme.email", "")3322 v.SetDefault("tls.acme.email", "")
3270 v.SetDefault("tls.acme.challenge", "auto")3323 v.SetDefault("tls.acme.challenge", "auto")
3271 v.SetDefault("tls.acme.obtain_batch_size", 1)3324 v.SetDefault("tls.acme.obtain_batch_size", 1)
3325+ v.SetDefault("dns.resolvers", publictls.DefaultDNSResolvers)
3326+ v.SetDefault("dns.propagation_timeout", "5m")
3327+ v.SetDefault("dns.polling_interval", "5s")
3272 v.SetDefault("server.force_https_redirect", false)3328 v.SetDefault("server.force_https_redirect", false)
3273 v.SetDefault("server.data_dir", DefaultDataDir())3329 v.SetDefault("server.data_dir", DefaultDataDir())
3274 v.SetDefault("server.runtime", "auto")3330 v.SetDefault("server.runtime", "auto")
M internal/domain/errors.go +1 -0
@@ -96,6 +96,7 @@ var (
9696 ErrACMEDisabled = errors.New("acme disabled")
9797 ErrACMEEmailRequired = errors.New("acme email required")
9898 ErrACMEChallengeInvalid = errors.New("acme challenge invalid")
99+ ErrDNSConfigInvalid = errors.New("dns configuration invalid")
99100 ErrCloudflareTokenMissing = errors.New("cloudflare api token missing")
100101 ErrCertificateStoreRequired = errors.New("certificate store required")
101102 ErrCertificateIssuerRequired = errors.New("certificate issuer required")
@@ -96,6 +96,7 @@ var (
96 ErrACMEDisabled = errors.New("acme disabled")96 ErrACMEDisabled = errors.New("acme disabled")
97 ErrACMEEmailRequired = errors.New("acme email required")97 ErrACMEEmailRequired = errors.New("acme email required")
98 ErrACMEChallengeInvalid = errors.New("acme challenge invalid")98 ErrACMEChallengeInvalid = errors.New("acme challenge invalid")
99+ ErrDNSConfigInvalid = errors.New("dns configuration invalid")
99 ErrCloudflareTokenMissing = errors.New("cloudflare api token missing")100 ErrCloudflareTokenMissing = errors.New("cloudflare api token missing")
100 ErrCertificateStoreRequired = errors.New("certificate store required")101 ErrCertificateStoreRequired = errors.New("certificate store required")
101 ErrCertificateIssuerRequired = errors.New("certificate issuer required")102 ErrCertificateIssuerRequired = errors.New("certificate issuer required")
M internal/usecase/publictls/config.go +1 -0
@@ -17,6 +17,7 @@ type Config struct {
1717 TLSPort int
1818 DataDir string
1919 ObtainBatchSize int
20+ DNS DNSConfig
2021 }
2122
2223 // EffectiveChallenge represents the resolved ACME challenge configuration.
@@ -17,6 +17,7 @@ type Config struct {
17 TLSPort int17 TLSPort int
18 DataDir string18 DataDir string
19 ObtainBatchSize int19 ObtainBatchSize int
20+ DNS DNSConfig
20 }21 }
21 22
22 // EffectiveChallenge represents the resolved ACME challenge configuration.23 // EffectiveChallenge represents the resolved ACME challenge configuration.
A internal/usecase/publictls/dns_config.go +71 -0
@@ -0,0 +1,71 @@
1+package publictls
2+
3+import (
4+ "fmt"
5+ "net"
6+ "strconv"
7+ "strings"
8+ "time"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+const (
14+ DefaultDNSPropagationTimeout = 5 * time.Minute
15+ DefaultDNSPollingInterval = 5 * time.Second
16+)
17+
18+var DefaultDNSResolvers = []string{"1.1.1.1:53", "8.8.8.8:53"}
19+
20+// DNSConfig controls public DNS visibility checks used by ACME DNS-01.
21+// These are recursive resolvers, not authoritative DNS providers.
22+type DNSConfig struct {
23+ Resolvers []string
24+ PropagationTimeout time.Duration
25+ PollingInterval time.Duration
26+}
27+
28+// DefaultDNSConfig returns Gordon's default public DNS check configuration.
29+func DefaultDNSConfig() DNSConfig {
30+ resolvers := append([]string(nil), DefaultDNSResolvers...)
31+ return DNSConfig{
32+ Resolvers: resolvers,
33+ PropagationTimeout: DefaultDNSPropagationTimeout,
34+ PollingInterval: DefaultDNSPollingInterval,
35+ }
36+}
37+
38+// Validate checks DNSConfig for ACME DNS-01 usage and normalizes resolver whitespace.
39+func (c *DNSConfig) Validate() error {
40+ if len(c.Resolvers) == 0 {
41+ return fmt.Errorf("%w: dns.resolvers must contain at least one resolver", domain.ErrDNSConfigInvalid)
42+ }
43+ for i, resolver := range c.Resolvers {
44+ trimmed := strings.TrimSpace(resolver)
45+ if trimmed == "" {
46+ return fmt.Errorf("%w: invalid dns.resolvers[%d]: empty resolver", domain.ErrDNSConfigInvalid, i)
47+ }
48+ host, port, err := net.SplitHostPort(trimmed)
49+ if err != nil {
50+ return fmt.Errorf("%w: invalid dns.resolvers[%d]: %q: expected host:port: %v", domain.ErrDNSConfigInvalid, i, trimmed, err)
51+ }
52+ if host == "" {
53+ return fmt.Errorf("%w: invalid dns.resolvers[%d]: %q: host must not be empty", domain.ErrDNSConfigInvalid, i, trimmed)
54+ }
55+ portNumber, err := strconv.Atoi(port)
56+ if err != nil || portNumber < 1 || portNumber > 65535 {
57+ return fmt.Errorf("%w: invalid dns.resolvers[%d]: %q: port must be a number between 1 and 65535", domain.ErrDNSConfigInvalid, i, trimmed)
58+ }
59+ c.Resolvers[i] = trimmed
60+ }
61+ if c.PropagationTimeout <= 0 {
62+ return fmt.Errorf("%w: dns.propagation_timeout must be positive", domain.ErrDNSConfigInvalid)
63+ }
64+ if c.PollingInterval <= 0 {
65+ return fmt.Errorf("%w: dns.polling_interval must be positive", domain.ErrDNSConfigInvalid)
66+ }
67+ if c.PollingInterval >= c.PropagationTimeout {
68+ return fmt.Errorf("%w: dns.polling_interval must be less than dns.propagation_timeout", domain.ErrDNSConfigInvalid)
69+ }
70+ return nil
71+}
@@ -0,0 +1,71 @@
1+package publictls
2+
3+import (
4+ "fmt"
5+ "net"
6+ "strconv"
7+ "strings"
8+ "time"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+const (
14+ DefaultDNSPropagationTimeout = 5 * time.Minute
15+ DefaultDNSPollingInterval = 5 * time.Second
16+)
17+
18+var DefaultDNSResolvers = []string{"1.1.1.1:53", "8.8.8.8:53"}
19+
20+// DNSConfig controls public DNS visibility checks used by ACME DNS-01.
21+// These are recursive resolvers, not authoritative DNS providers.
22+type DNSConfig struct {
23+ Resolvers []string
24+ PropagationTimeout time.Duration
25+ PollingInterval time.Duration
26+}
27+
28+// DefaultDNSConfig returns Gordon's default public DNS check configuration.
29+func DefaultDNSConfig() DNSConfig {
30+ resolvers := append([]string(nil), DefaultDNSResolvers...)
31+ return DNSConfig{
32+ Resolvers: resolvers,
33+ PropagationTimeout: DefaultDNSPropagationTimeout,
34+ PollingInterval: DefaultDNSPollingInterval,
35+ }
36+}
37+
38+// Validate checks DNSConfig for ACME DNS-01 usage and normalizes resolver whitespace.
39+func (c *DNSConfig) Validate() error {
40+ if len(c.Resolvers) == 0 {
41+ return fmt.Errorf("%w: dns.resolvers must contain at least one resolver", domain.ErrDNSConfigInvalid)
42+ }
43+ for i, resolver := range c.Resolvers {
44+ trimmed := strings.TrimSpace(resolver)
45+ if trimmed == "" {
46+ return fmt.Errorf("%w: invalid dns.resolvers[%d]: empty resolver", domain.ErrDNSConfigInvalid, i)
47+ }
48+ host, port, err := net.SplitHostPort(trimmed)
49+ if err != nil {
50+ return fmt.Errorf("%w: invalid dns.resolvers[%d]: %q: expected host:port: %v", domain.ErrDNSConfigInvalid, i, trimmed, err)
51+ }
52+ if host == "" {
53+ return fmt.Errorf("%w: invalid dns.resolvers[%d]: %q: host must not be empty", domain.ErrDNSConfigInvalid, i, trimmed)
54+ }
55+ portNumber, err := strconv.Atoi(port)
56+ if err != nil || portNumber < 1 || portNumber > 65535 {
57+ return fmt.Errorf("%w: invalid dns.resolvers[%d]: %q: port must be a number between 1 and 65535", domain.ErrDNSConfigInvalid, i, trimmed)
58+ }
59+ c.Resolvers[i] = trimmed
60+ }
61+ if c.PropagationTimeout <= 0 {
62+ return fmt.Errorf("%w: dns.propagation_timeout must be positive", domain.ErrDNSConfigInvalid)
63+ }
64+ if c.PollingInterval <= 0 {
65+ return fmt.Errorf("%w: dns.polling_interval must be positive", domain.ErrDNSConfigInvalid)
66+ }
67+ if c.PollingInterval >= c.PropagationTimeout {
68+ return fmt.Errorf("%w: dns.polling_interval must be less than dns.propagation_timeout", domain.ErrDNSConfigInvalid)
69+ }
70+ return nil
71+}
A internal/usecase/publictls/dns_config_test.go +123 -0
@@ -0,0 +1,123 @@
1+package publictls
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+func TestDefaultDNSConfig(t *testing.T) {
14+ cfg := DefaultDNSConfig()
15+
16+ assert.Equal(t, []string{"1.1.1.1:53", "8.8.8.8:53"}, cfg.Resolvers)
17+ assert.Equal(t, 5*time.Minute, cfg.PropagationTimeout)
18+ assert.Equal(t, 5*time.Second, cfg.PollingInterval)
19+}
20+
21+func TestDNSConfigValidateNormalizesResolvers(t *testing.T) {
22+ cfg := DNSConfig{
23+ Resolvers: []string{" 1.1.1.1:53 ", "\t8.8.8.8:53\n"},
24+ PropagationTimeout: 5 * time.Minute,
25+ PollingInterval: 5 * time.Second,
26+ }
27+
28+ require.NoError(t, cfg.Validate())
29+ assert.Equal(t, []string{"1.1.1.1:53", "8.8.8.8:53"}, cfg.Resolvers)
30+}
31+
32+func TestDNSConfigValidate(t *testing.T) {
33+ tests := []struct {
34+ name string
35+ cfg DNSConfig
36+ wantErr string
37+ }{
38+ {
39+ name: "valid",
40+ cfg: DNSConfig{
41+ Resolvers: []string{"1.1.1.1:53", "8.8.8.8:53"},
42+ PropagationTimeout: 5 * time.Minute,
43+ PollingInterval: 5 * time.Second,
44+ },
45+ },
46+ {
47+ name: "empty resolvers",
48+ cfg: DNSConfig{
49+ Resolvers: nil,
50+ PropagationTimeout: 5 * time.Minute,
51+ PollingInterval: 5 * time.Second,
52+ },
53+ wantErr: "dns.resolvers must contain at least one resolver",
54+ },
55+ {
56+ name: "resolver missing port",
57+ cfg: DNSConfig{
58+ Resolvers: []string{"1.1.1.1"},
59+ PropagationTimeout: 5 * time.Minute,
60+ PollingInterval: 5 * time.Second,
61+ },
62+ wantErr: "invalid dns.resolvers[0]",
63+ },
64+ {
65+ name: "whitespace-only resolver",
66+ cfg: DNSConfig{
67+ Resolvers: []string{" "},
68+ PropagationTimeout: 5 * time.Minute,
69+ PollingInterval: 5 * time.Second,
70+ },
71+ wantErr: "invalid dns.resolvers[0]",
72+ },
73+ {
74+ name: "malformed resolver port",
75+ cfg: DNSConfig{
76+ Resolvers: []string{"1.1.1.1:abc"},
77+ PropagationTimeout: 5 * time.Minute,
78+ PollingInterval: 5 * time.Second,
79+ },
80+ wantErr: "invalid dns.resolvers[0]",
81+ },
82+ {
83+ name: "zero timeout",
84+ cfg: DNSConfig{
85+ Resolvers: []string{"1.1.1.1:53"},
86+ PropagationTimeout: 0,
87+ PollingInterval: 5 * time.Second,
88+ },
89+ wantErr: "dns.propagation_timeout must be positive",
90+ },
91+ {
92+ name: "zero polling interval",
93+ cfg: DNSConfig{
94+ Resolvers: []string{"1.1.1.1:53"},
95+ PropagationTimeout: 5 * time.Minute,
96+ PollingInterval: 0,
97+ },
98+ wantErr: "dns.polling_interval must be positive",
99+ },
100+ {
101+ name: "polling interval equals timeout",
102+ cfg: DNSConfig{
103+ Resolvers: []string{"1.1.1.1:53"},
104+ PropagationTimeout: 5 * time.Second,
105+ PollingInterval: 5 * time.Second,
106+ },
107+ wantErr: "dns.polling_interval must be less than dns.propagation_timeout",
108+ },
109+ }
110+
111+ for _, tt := range tests {
112+ t.Run(tt.name, func(t *testing.T) {
113+ err := tt.cfg.Validate()
114+ if tt.wantErr == "" {
115+ require.NoError(t, err)
116+ return
117+ }
118+ require.Error(t, err)
119+ assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
120+ assert.Contains(t, err.Error(), tt.wantErr)
121+ })
122+ }
123+}
@@ -0,0 +1,123 @@
1+package publictls
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+func TestDefaultDNSConfig(t *testing.T) {
14+ cfg := DefaultDNSConfig()
15+
16+ assert.Equal(t, []string{"1.1.1.1:53", "8.8.8.8:53"}, cfg.Resolvers)
17+ assert.Equal(t, 5*time.Minute, cfg.PropagationTimeout)
18+ assert.Equal(t, 5*time.Second, cfg.PollingInterval)
19+}
20+
21+func TestDNSConfigValidateNormalizesResolvers(t *testing.T) {
22+ cfg := DNSConfig{
23+ Resolvers: []string{" 1.1.1.1:53 ", "\t8.8.8.8:53\n"},
24+ PropagationTimeout: 5 * time.Minute,
25+ PollingInterval: 5 * time.Second,
26+ }
27+
28+ require.NoError(t, cfg.Validate())
29+ assert.Equal(t, []string{"1.1.1.1:53", "8.8.8.8:53"}, cfg.Resolvers)
30+}
31+
32+func TestDNSConfigValidate(t *testing.T) {
33+ tests := []struct {
34+ name string
35+ cfg DNSConfig
36+ wantErr string
37+ }{
38+ {
39+ name: "valid",
40+ cfg: DNSConfig{
41+ Resolvers: []string{"1.1.1.1:53", "8.8.8.8:53"},
42+ PropagationTimeout: 5 * time.Minute,
43+ PollingInterval: 5 * time.Second,
44+ },
45+ },
46+ {
47+ name: "empty resolvers",
48+ cfg: DNSConfig{
49+ Resolvers: nil,
50+ PropagationTimeout: 5 * time.Minute,
51+ PollingInterval: 5 * time.Second,
52+ },
53+ wantErr: "dns.resolvers must contain at least one resolver",
54+ },
55+ {
56+ name: "resolver missing port",
57+ cfg: DNSConfig{
58+ Resolvers: []string{"1.1.1.1"},
59+ PropagationTimeout: 5 * time.Minute,
60+ PollingInterval: 5 * time.Second,
61+ },
62+ wantErr: "invalid dns.resolvers[0]",
63+ },
64+ {
65+ name: "whitespace-only resolver",
66+ cfg: DNSConfig{
67+ Resolvers: []string{" "},
68+ PropagationTimeout: 5 * time.Minute,
69+ PollingInterval: 5 * time.Second,
70+ },
71+ wantErr: "invalid dns.resolvers[0]",
72+ },
73+ {
74+ name: "malformed resolver port",
75+ cfg: DNSConfig{
76+ Resolvers: []string{"1.1.1.1:abc"},
77+ PropagationTimeout: 5 * time.Minute,
78+ PollingInterval: 5 * time.Second,
79+ },
80+ wantErr: "invalid dns.resolvers[0]",
81+ },
82+ {
83+ name: "zero timeout",
84+ cfg: DNSConfig{
85+ Resolvers: []string{"1.1.1.1:53"},
86+ PropagationTimeout: 0,
87+ PollingInterval: 5 * time.Second,
88+ },
89+ wantErr: "dns.propagation_timeout must be positive",
90+ },
91+ {
92+ name: "zero polling interval",
93+ cfg: DNSConfig{
94+ Resolvers: []string{"1.1.1.1:53"},
95+ PropagationTimeout: 5 * time.Minute,
96+ PollingInterval: 0,
97+ },
98+ wantErr: "dns.polling_interval must be positive",
99+ },
100+ {
101+ name: "polling interval equals timeout",
102+ cfg: DNSConfig{
103+ Resolvers: []string{"1.1.1.1:53"},
104+ PropagationTimeout: 5 * time.Second,
105+ PollingInterval: 5 * time.Second,
106+ },
107+ wantErr: "dns.polling_interval must be less than dns.propagation_timeout",
108+ },
109+ }
110+
111+ for _, tt := range tests {
112+ t.Run(tt.name, func(t *testing.T) {
113+ err := tt.cfg.Validate()
114+ if tt.wantErr == "" {
115+ require.NoError(t, err)
116+ return
117+ }
118+ require.Error(t, err)
119+ assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
120+ assert.Contains(t, err.Error(), tt.wantErr)
121+ })
122+ }
123+}

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