Merge pull request #184 from bnema/feat/global-dns-acme-dns01
Add global DNS config for ACME DNS-01
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 | |||
| 23 | 23 | proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs) | |
| 24 | 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 | 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 | 212 | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | |
| 205 | 213 | | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | |
| 206 | 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 | 218 | | `tls.acme.enabled` | `false` | Enable public ACME certificates (requires `server.tls_port > 0`) | | |
| 208 | 219 | | `tls.acme.email` | `""` | ACME account email when enabled | | |
| 209 | 220 | | `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 | |||
| 92 | 92 | [server] | |
| 93 | 93 | 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 | 100 | [tls.acme] | |
| 96 | 101 | enabled = true | |
| 97 | 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 | 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 | 128 | #### Direct HTTP Onboarding | |
| 118 | 129 | ||
| 119 | 130 | 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 = 8443 | 93 | 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 = true | 101 | 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 Onboarding | 128 | #### 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 ( | |||
| 7 | 7 | "crypto/x509" | |
| 8 | 8 | "encoding/json" | |
| 9 | 9 | "fmt" | |
| 10 | + | "net" | |
| 10 | 11 | "net/http" | |
| 12 | + | "strconv" | |
| 11 | 13 | "strings" | |
| 12 | 14 | "sync" | |
| 13 | 15 | "time" | |
| @@ -15,6 +17,7 @@ import ( | |||
| 15 | 17 | "github.com/go-acme/lego/v4/acme" | |
| 16 | 18 | "github.com/go-acme/lego/v4/certcrypto" | |
| 17 | 19 | "github.com/go-acme/lego/v4/certificate" | |
| 20 | + | "github.com/go-acme/lego/v4/challenge/dns01" | |
| 18 | 21 | "github.com/go-acme/lego/v4/lego" | |
| 19 | 22 | "github.com/go-acme/lego/v4/providers/dns/cloudflare" | |
| 20 | 23 | "github.com/go-acme/lego/v4/registration" | |
| @@ -34,6 +37,15 @@ type Config struct { | |||
| 34 | 37 | // Token is the Cloudflare API token (required for DNS-01 challenge). | |
| 35 | 38 | 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 | 49 | // Store persists ACME accounts and certificates. | |
| 38 | 50 | Store out.CertificateStore | |
| 39 | 51 | ||
| @@ -81,6 +93,20 @@ func NewIssuer(cfg Config) (*Issuer, error) { | |||
| 81 | 93 | if cfg.Token == "" { | |
| 82 | 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 | 110 | default: | |
| 85 | 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 | 114 | 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 | 145 | // compile-time interface check | |
| 92 | 146 | var _ out.PublicCertificateIssuer = (*Issuer)(nil) | |
| 93 | 147 | ||
| @@ -208,13 +262,11 @@ func (i *Issuer) buildClient(ctx context.Context) (*AccountUser, *lego.Client, e | |||
| 208 | 262 | return nil, nil, fmt.Errorf("set http-01 provider: %w", err) | |
| 209 | 263 | } | |
| 210 | 264 | 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)) | |
| 214 | 266 | if err != nil { | |
| 215 | 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 | 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 | 364 | 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 | 376 | // resourceToStored converts a lego certificate.Resource to out.StoredCertificate. | |
| 316 | 377 | func resourceToStored(order out.CertificateOrder, res *certificate.Resource) (*out.StoredCertificate, error) { | |
| 317 | 378 | 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 string | 38 | 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.CertificateStore | 50 | 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}, nil | 114 | 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 check | 145 | // 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 legoCfg | 364 | 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 []byte | 378 | var fullchainPEM []byte | ||
M internal/adapters/out/acmelego/issuer_test.go +93 -0
| @@ -69,6 +69,85 @@ func TestNewIssuerValidatesCloudflareToken(t *testing.T) { | |||
| 69 | 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 | 151 | func TestNewIssuerRejectsNonHTTPSURL(t *testing.T) { | |
| 73 | 152 | _, err := NewIssuer(Config{ | |
| 74 | 153 | Email: "test@example.com", | |
| @@ -102,6 +181,20 @@ func TestNewIssuerValidWithDefaults(t *testing.T) { | |||
| 102 | 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 | 198 | func TestPrivateKeyRoundTrip(t *testing.T) { | |
| 106 | 199 | // Generate an ECDSA P-256 private key | |
| 107 | 200 | 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 key | 199 | // 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) { | |||
| 15 | 15 | assert.Equal(t, "", v.GetString("tls.acme.email")) | |
| 16 | 16 | assert.Equal(t, "auto", v.GetString("tls.acme.challenge")) | |
| 17 | 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 | } | |
| @@ -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 { | |||
| 204 | 204 | ObtainBatchSize int `mapstructure:"obtain_batch_size"` | |
| 205 | 205 | } `mapstructure:"acme"` | |
| 206 | 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 | 215 | // services holds all the services used by the application. | |
| @@ -547,6 +553,11 @@ func (si *serviceInit) initPublicTLS() error { | |||
| 547 | 553 | ctx := si.ctx | |
| 548 | 554 | 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 | 561 | publicTLSCfg := publictls.Config{ | |
| 551 | 562 | Enabled: si.cfg.TLS.ACME.Enabled, | |
| 552 | 563 | Email: si.cfg.TLS.ACME.Email, | |
| @@ -555,6 +566,7 @@ func (si *serviceInit) initPublicTLS() error { | |||
| 555 | 566 | TLSPort: si.cfg.Server.TLSPort, | |
| 556 | 567 | DataDir: resolveDataDir(si.cfg.Server.DataDir), | |
| 557 | 568 | ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize, | |
| 569 | + | DNS: dnsCfg, | |
| 558 | 570 | } | |
| 559 | 571 | ||
| 560 | 572 | tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{}) | |
| @@ -577,11 +589,14 @@ func (si *serviceInit) initPublicTLS() error { | |||
| 577 | 589 | } | |
| 578 | 590 | ||
| 579 | 591 | 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, | |
| 585 | 600 | }) | |
| 586 | 601 | if err != nil { | |
| 587 | 602 | return log.WrapErr(err, "create ACME issuer") | |
| @@ -1748,6 +1763,44 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro | |||
| 1748 | 1763 | }, 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 | 1804 | // createContainerService creates the container service with configuration. | |
| 1752 | 1805 | func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) { | |
| 1753 | 1806 | // Parse and validate container resource limits from config | |
| @@ -3269,6 +3322,9 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 3269 | 3322 | v.SetDefault("tls.acme.email", "") | |
| 3270 | 3323 | v.SetDefault("tls.acme.challenge", "auto") | |
| 3271 | 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 | 3328 | v.SetDefault("server.force_https_redirect", false) | |
| 3273 | 3329 | v.SetDefault("server.data_dir", DefaultDataDir()) | |
| 3274 | 3330 | 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.ctx | 553 | ctx := si.ctx | ||
| 548 | log := si.log | 554 | 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 | }, nil | 1763 | }, 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 config | 1806 | // 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 ( | |||
| 96 | 96 | ErrACMEDisabled = errors.New("acme disabled") | |
| 97 | 97 | ErrACMEEmailRequired = errors.New("acme email required") | |
| 98 | 98 | ErrACMEChallengeInvalid = errors.New("acme challenge invalid") | |
| 99 | + | ErrDNSConfigInvalid = errors.New("dns configuration invalid") | |
| 99 | 100 | ErrCloudflareTokenMissing = errors.New("cloudflare api token missing") | |
| 100 | 101 | ErrCertificateStoreRequired = errors.New("certificate store required") | |
| 101 | 102 | 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 { | |||
| 17 | 17 | TLSPort int | |
| 18 | 18 | DataDir string | |
| 19 | 19 | ObtainBatchSize int | |
| 20 | + | DNS DNSConfig | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 22 | 23 | // EffectiveChallenge represents the resolved ACME challenge configuration. | |
| @@ -17,6 +17,7 @@ type Config struct { | |||||
| 17 | TLSPort int | 17 | TLSPort int | ||
| 18 | DataDir string | 18 | DataDir string | ||
| 19 | ObtainBatchSize int | 19 | 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 | + | } | |||