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