@marcel91200 / caddy Mirror · read-only / commits / f145bce5
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 14:45 UTC

tls: Add `tls_resolvers` global option for DNS challenge configuration (#7297)

Co-authored-by: Francis Lavoie <lavofr@gmail.com>

Pavel Siomachkin committed Mar 1, 2026 at 21:32 UTC f145bce553a306ca6890fea782ca4e3b06787bbb
10 files changed +547 -2
caddyconfig/httpcaddyfile/options.go
+10
@@ -64,6 +64,7 @@ func init() {
64 RegisterGlobalOption("preferred_chains", parseOptPreferredChains)
65 RegisterGlobalOption("persist_config", parseOptPersistConfig)
66 RegisterGlobalOption("dns", parseOptDNS)
67 + RegisterGlobalOption("tls_resolvers", parseOptTLSResolvers)
68 RegisterGlobalOption("ech", parseOptECH)
69 RegisterGlobalOption("renewal_window_ratio", parseOptRenewalWindowRatio)
70 }
@@ -306,6 +307,15 @@ func parseOptSingleString(d *caddyfile.Dispenser, _ any) (any, error) {
307 return val, nil
308 }
309
310 +func parseOptTLSResolvers(d *caddyfile.Dispenser, _ any) (any, error) {
311 + d.Next() // consume option name
312 + resolvers := d.RemainingArgs()
313 + if len(resolvers) == 0 {
314 + return nil, d.ArgErr()
315 + }
316 + return resolvers, nil
317 +}
318 +
319 func parseOptDefaultBind(d *caddyfile.Dispenser, _ any) (any, error) {
320 d.Next() // consume option name
321
caddyconfig/httpcaddyfile/options_test.go
+104
@@ -1,9 +1,11 @@
1 package httpcaddyfile
2
3 import (
4 + "encoding/json"
5 "testing"
6
7 "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
8 + "github.com/caddyserver/caddy/v2/modules/caddytls"
9 _ "github.com/caddyserver/caddy/v2/modules/logging"
10 )
11
@@ -62,3 +64,105 @@ func TestGlobalLogOptionSyntax(t *testing.T) {
64 }
65 }
66 }
67 +
68 +func TestGlobalResolversOption(t *testing.T) {
69 + tests := []struct {
70 + name string
71 + input string
72 + expectResolvers []string
73 + expectError bool
74 + }{
75 + {
76 + name: "single resolver",
77 + input: `{
78 + tls_resolvers 1.1.1.1
79 + }
80 + example.com {
81 + }`,
82 + expectResolvers: []string{"1.1.1.1"},
83 + expectError: false,
84 + },
85 + {
86 + name: "two resolvers",
87 + input: `{
88 + tls_resolvers 1.1.1.1 8.8.8.8
89 + }
90 + example.com {
91 + }`,
92 + expectResolvers: []string{"1.1.1.1", "8.8.8.8"},
93 + expectError: false,
94 + },
95 + {
96 + name: "multiple resolvers",
97 + input: `{
98 + tls_resolvers 1.1.1.1 8.8.8.8 9.9.9.9
99 + }
100 + example.com {
101 + }`,
102 + expectResolvers: []string{"1.1.1.1", "8.8.8.8", "9.9.9.9"},
103 + expectError: false,
104 + },
105 + {
106 + name: "no resolvers specified",
107 + input: `{
108 + }
109 + example.com {
110 + }`,
111 + expectResolvers: nil,
112 + expectError: false,
113 + },
114 + }
115 +
116 + for _, tc := range tests {
117 + t.Run(tc.name, func(t *testing.T) {
118 + adapter := caddyfile.Adapter{
119 + ServerType: ServerType{},
120 + }
121 +
122 + out, _, err := adapter.Adapt([]byte(tc.input), nil)
123 +
124 + if (err != nil) != tc.expectError {
125 + t.Errorf("error expectation failed. Expected error: %v, got: %v", tc.expectError, err)
126 + return
127 + }
128 +
129 + if tc.expectError {
130 + return
131 + }
132 +
133 + // Parse the output JSON to check resolvers
134 + var config struct {
135 + Apps struct {
136 + TLS *caddytls.TLS `json:"tls"`
137 + } `json:"apps"`
138 + }
139 +
140 + if err := json.Unmarshal(out, &config); err != nil {
141 + t.Errorf("failed to unmarshal output: %v", err)
142 + return
143 + }
144 +
145 + // Check if resolvers match expected
146 + if config.Apps.TLS == nil {
147 + if tc.expectResolvers != nil {
148 + t.Errorf("Expected TLS config with resolvers %v, but TLS config is nil", tc.expectResolvers)
149 + }
150 + return
151 + }
152 +
153 + actualResolvers := config.Apps.TLS.Resolvers
154 + if len(tc.expectResolvers) == 0 && len(actualResolvers) == 0 {
155 + return // Both empty, ok
156 + }
157 + if len(actualResolvers) != len(tc.expectResolvers) {
158 + t.Errorf("Expected %d resolvers, got %d. Expected: %v, got: %v", len(tc.expectResolvers), len(actualResolvers), tc.expectResolvers, actualResolvers)
159 + return
160 + }
161 + for j, expected := range tc.expectResolvers {
162 + if actualResolvers[j] != expected {
163 + t.Errorf("Resolver %d mismatch. Expected: %s, got: %s", j, expected, actualResolvers[j])
164 + }
165 + }
166 + })
167 + }
168 +}
caddyconfig/httpcaddyfile/tlsapp.go
+14
@@ -334,6 +334,11 @@ func (st ServerType) buildTLSApp(
334 tlsApp.DNSRaw = caddyconfig.JSONModuleObject(globalDNS, "name", globalDNS.(caddy.Module).CaddyModule().ID.Name(), nil)
335 }
336
337 + // set up "global" (to the TLS app) DNS resolvers config
338 + if globalResolvers, ok := options["tls_resolvers"]; ok && globalResolvers != nil {
339 + tlsApp.Resolvers = globalResolvers.([]string)
340 + }
341 +
342 // set up ECH from Caddyfile options
343 if ech, ok := options["ech"].(*caddytls.ECH); ok {
344 tlsApp.EncryptedClientHello = ech
@@ -595,6 +600,15 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
600 if globalCertLifetime != nil && acmeIssuer.CertificateLifetime == 0 {
601 acmeIssuer.CertificateLifetime = globalCertLifetime.(caddy.Duration)
602 }
603 + // apply global resolvers if DNS challenge is configured and resolvers are not already set
604 + globalResolvers := options["tls_resolvers"]
605 + if globalResolvers != nil && acmeIssuer.Challenges != nil && acmeIssuer.Challenges.DNS != nil {
606 + // Check if DNS challenge is actually configured
607 + hasDNSChallenge := globalACMEDNSok || acmeIssuer.Challenges.DNS.ProviderRaw != nil
608 + if hasDNSChallenge && len(acmeIssuer.Challenges.DNS.Resolvers) == 0 {
609 + acmeIssuer.Challenges.DNS.Resolvers = globalResolvers.([]string)
610 + }
611 + }
612 return nil
613 }
614
caddytest/integration/caddyfile_adapt/global_options_resolvers.caddyfiletest new
+77
@@ -0,0 +1,77 @@
1 +{
2 + email test@example.com
3 + dns mock
4 + tls_resolvers 1.1.1.1 8.8.8.8
5 + acme_dns
6 +}
7 +
8 +example.com {
9 +}
10 +----------
11 +{
12 + "apps": {
13 + "http": {
14 + "servers": {
15 + "srv0": {
16 + "listen": [
17 + ":443"
18 + ],
19 + "routes": [
20 + {
21 + "match": [
22 + {
23 + "host": [
24 + "example.com"
25 + ]
26 + }
27 + ],
28 + "terminal": true
29 + }
30 + ]
31 + }
32 + }
33 + },
34 + "tls": {
35 + "automation": {
36 + "policies": [
37 + {
38 + "issuers": [
39 + {
40 + "challenges": {
41 + "dns": {
42 + "resolvers": [
43 + "1.1.1.1",
44 + "8.8.8.8"
45 + ]
46 + }
47 + },
48 + "email": "test@example.com",
49 + "module": "acme"
50 + },
51 + {
52 + "ca": "https://acme.zerossl.com/v2/DV90",
53 + "challenges": {
54 + "dns": {
55 + "resolvers": [
56 + "1.1.1.1",
57 + "8.8.8.8"
58 + ]
59 + }
60 + },
61 + "email": "test@example.com",
62 + "module": "acme"
63 + }
64 + ]
65 + }
66 + ]
67 + },
68 + "dns": {
69 + "name": "mock"
70 + },
71 + "resolvers": [
72 + "1.1.1.1",
73 + "8.8.8.8"
74 + ]
75 + }
76 + }
77 +}
caddytest/integration/caddyfile_adapt/global_options_resolvers_http_challenge.caddyfiletest new
+38
@@ -0,0 +1,38 @@
1 +{
2 + tls_resolvers 1.1.1.1 8.8.8.8
3 +}
4 +
5 +example.com {
6 +}
7 +----------
8 +{
9 + "apps": {
10 + "http": {
11 + "servers": {
12 + "srv0": {
13 + "listen": [
14 + ":443"
15 + ],
16 + "routes": [
17 + {
18 + "match": [
19 + {
20 + "host": [
21 + "example.com"
22 + ]
23 + }
24 + ],
25 + "terminal": true
26 + }
27 + ]
28 + }
29 + }
30 + },
31 + "tls": {
32 + "resolvers": [
33 + "1.1.1.1",
34 + "8.8.8.8"
35 + ]
36 + }
37 + }
38 +}
caddytest/integration/caddyfile_adapt/global_options_resolvers_local_dns_inherit.caddyfiletest new
+72
@@ -0,0 +1,72 @@
1 +{
2 + email test@example.com
3 + dns mock
4 + tls_resolvers 1.1.1.1 8.8.8.8
5 +}
6 +
7 +example.com {
8 + tls {
9 + dns mock
10 + }
11 +}
12 +----------
13 +{
14 + "apps": {
15 + "http": {
16 + "servers": {
17 + "srv0": {
18 + "listen": [
19 + ":443"
20 + ],
21 + "routes": [
22 + {
23 + "match": [
24 + {
25 + "host": [
26 + "example.com"
27 + ]
28 + }
29 + ],
30 + "terminal": true
31 + }
32 + ]
33 + }
34 + }
35 + },
36 + "tls": {
37 + "automation": {
38 + "policies": [
39 + {
40 + "subjects": [
41 + "example.com"
42 + ],
43 + "issuers": [
44 + {
45 + "challenges": {
46 + "dns": {
47 + "provider": {
48 + "name": "mock"
49 + },
50 + "resolvers": [
51 + "1.1.1.1",
52 + "8.8.8.8"
53 + ]
54 + }
55 + },
56 + "email": "test@example.com",
57 + "module": "acme"
58 + }
59 + ]
60 + }
61 + ]
62 + },
63 + "dns": {
64 + "name": "mock"
65 + },
66 + "resolvers": [
67 + "1.1.1.1",
68 + "8.8.8.8"
69 + ]
70 + }
71 + }
72 +}
caddytest/integration/caddyfile_adapt/global_options_resolvers_local_override.caddyfiletest new
+98
@@ -0,0 +1,98 @@
1 +{
2 + email test@example.com
3 + dns mock
4 + tls_resolvers 1.1.1.1 8.8.8.8
5 + acme_dns
6 +}
7 +
8 +example.com {
9 + tls {
10 + resolvers 9.9.9.9
11 + }
12 +}
13 +----------
14 +{
15 + "apps": {
16 + "http": {
17 + "servers": {
18 + "srv0": {
19 + "listen": [
20 + ":443"
21 + ],
22 + "routes": [
23 + {
24 + "match": [
25 + {
26 + "host": [
27 + "example.com"
28 + ]
29 + }
30 + ],
31 + "terminal": true
32 + }
33 + ]
34 + }
35 + }
36 + },
37 + "tls": {
38 + "automation": {
39 + "policies": [
40 + {
41 + "subjects": [
42 + "example.com"
43 + ],
44 + "issuers": [
45 + {
46 + "challenges": {
47 + "dns": {
48 + "resolvers": [
49 + "9.9.9.9"
50 + ]
51 + }
52 + },
53 + "email": "test@example.com",
54 + "module": "acme"
55 + }
56 + ]
57 + },
58 + {
59 + "issuers": [
60 + {
61 + "challenges": {
62 + "dns": {
63 + "resolvers": [
64 + "1.1.1.1",
65 + "8.8.8.8"
66 + ]
67 + }
68 + },
69 + "email": "test@example.com",
70 + "module": "acme"
71 + },
72 + {
73 + "ca": "https://acme.zerossl.com/v2/DV90",
74 + "challenges": {
75 + "dns": {
76 + "resolvers": [
77 + "1.1.1.1",
78 + "8.8.8.8"
79 + ]
80 + }
81 + },
82 + "email": "test@example.com",
83 + "module": "acme"
84 + }
85 + ]
86 + }
87 + ]
88 + },
89 + "dns": {
90 + "name": "mock"
91 + },
92 + "resolvers": [
93 + "1.1.1.1",
94 + "8.8.8.8"
95 + ]
96 + }
97 + }
98 +}
caddytest/integration/caddyfile_adapt/global_options_resolvers_mixed.caddyfiletest new
+112
@@ -0,0 +1,112 @@
1 +{
2 + email test@example.com
3 + dns mock
4 + tls_resolvers 1.1.1.1 8.8.8.8
5 + acme_dns
6 +}
7 +
8 +site1.example.com {
9 +}
10 +
11 +site2.example.com {
12 + tls {
13 + resolvers 9.9.9.9 8.8.4.4
14 + }
15 +}
16 +----------
17 +{
18 + "apps": {
19 + "http": {
20 + "servers": {
21 + "srv0": {
22 + "listen": [
23 + ":443"
24 + ],
25 + "routes": [
26 + {
27 + "match": [
28 + {
29 + "host": [
30 + "site1.example.com"
31 + ]
32 + }
33 + ],
34 + "terminal": true
35 + },
36 + {
37 + "match": [
38 + {
39 + "host": [
40 + "site2.example.com"
41 + ]
42 + }
43 + ],
44 + "terminal": true
45 + }
46 + ]
47 + }
48 + }
49 + },
50 + "tls": {
51 + "automation": {
52 + "policies": [
53 + {
54 + "subjects": [
55 + "site2.example.com"
56 + ],
57 + "issuers": [
58 + {
59 + "challenges": {
60 + "dns": {
61 + "resolvers": [
62 + "9.9.9.9",
63 + "8.8.4.4"
64 + ]
65 + }
66 + },
67 + "email": "test@example.com",
68 + "module": "acme"
69 + }
70 + ]
71 + },
72 + {
73 + "issuers": [
74 + {
75 + "challenges": {
76 + "dns": {
77 + "resolvers": [
78 + "1.1.1.1",
79 + "8.8.8.8"
80 + ]
81 + }
82 + },
83 + "email": "test@example.com",
84 + "module": "acme"
85 + },
86 + {
87 + "ca": "https://acme.zerossl.com/v2/DV90",
88 + "challenges": {
89 + "dns": {
90 + "resolvers": [
91 + "1.1.1.1",
92 + "8.8.8.8"
93 + ]
94 + }
95 + },
96 + "email": "test@example.com",
97 + "module": "acme"
98 + }
99 + ]
100 + }
101 + ]
102 + },
103 + "dns": {
104 + "name": "mock"
105 + },
106 + "resolvers": [
107 + "1.1.1.1",
108 + "8.8.8.8"
109 + ]
110 + }
111 + }
112 +}
modules/caddypki/acmeserver/acmeserver.go
+14 -1
@@ -40,6 +40,7 @@ import (
40 "github.com/caddyserver/caddy/v2"
41 "github.com/caddyserver/caddy/v2/modules/caddyhttp"
42 "github.com/caddyserver/caddy/v2/modules/caddypki"
43 + "github.com/caddyserver/caddy/v2/modules/caddytls"
44 )
45
46 func init() {
@@ -304,7 +305,19 @@ func (ash Handler) openDatabase() (*db.AuthDB, error) {
305 // makeClient creates an ACME client which will use a custom
306 // resolver instead of net.DefaultResolver.
307 func (ash Handler) makeClient() (acme.Client, error) {
307 - for _, v := range ash.Resolvers {
308 + // If no local resolvers are configured, check for global resolvers from TLS app
309 + resolversToUse := ash.Resolvers
310 + if len(resolversToUse) == 0 {
311 + tlsAppIface, err := ash.ctx.App("tls")
312 + if err == nil {
313 + tlsApp := tlsAppIface.(*caddytls.TLS)
314 + if len(tlsApp.Resolvers) > 0 {
315 + resolversToUse = tlsApp.Resolvers
316 + }
317 + }
318 + }
319 +
320 + for _, v := range resolversToUse {
321 addr, err := caddy.ParseNetworkAddressWithDefaults(v, "udp", 53)
322 if err != nil {
323 return nil, err
modules/caddytls/tls.go
+8 -1
@@ -123,8 +123,15 @@ type TLS struct {
123 //
124 // EXPERIMENTAL: Subject to change.
125 DNSRaw json.RawMessage `json:"dns,omitempty" caddy:"namespace=dns.providers inline_key=name"`
126 - dns any // technically, it should be any/all of the libdns interfaces (RecordSetter, RecordAppender, etc.)
126
127 + // The default DNS resolvers to use for TLS-related DNS operations, specifically
128 + // for ACME DNS challenges and ACME server DNS validations.
129 + // If not specified, the system default resolvers will be used.
130 + //
131 + // EXPERIMENTAL: Subject to change.
132 + Resolvers []string `json:"resolvers,omitempty"`
133 +
134 + dns any // technically, it should be any/all of the libdns interfaces (RecordSetter, RecordAppender, etc.)
135 certificateLoaders []CertificateLoader
136 automateNames map[string]struct{}
137 ctx caddy.Context