Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 12, 07:00 UTC
caddyhttp: Implement `auto_https prefer_wildcard` option (#6146)
* Allow specifying multiple `auto_https` options * Implement `auto_https prefer_wildcard` option * Adapt tests, add mock DNS module for config testing * Rebase fix
Francis Lavoie committed
Oct 2, 2024 at 09:31 UTC
16724842d9b9096b800326d0b7667a4361552552
7 files changed
+449
-20
caddyconfig/httpcaddyfile/httptype.go
+49
-7
@@ -534,8 +534,8 @@ func (st *ServerType) serversFromPairings(
534
if hsp, ok := options["https_port"].(int); ok {
535
httpsPort = strconv.Itoa(hsp)
536
}
537
- autoHTTPS := "on"
538
- if ah, ok := options["auto_https"].(string); ok {
537
+ autoHTTPS := []string{}
538
+ if ah, ok := options["auto_https"].([]string); ok {
539
autoHTTPS = ah
540
}
541
@@ -594,17 +594,37 @@ func (st *ServerType) serversFromPairings(
594
}
595
596
// handle the auto_https global option
597
- if autoHTTPS != "on" {
598
- srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig)
599
- switch autoHTTPS {
597
+ for _, val := range autoHTTPS {
598
+ switch val {
599
case "off":
600
+ if srv.AutoHTTPS == nil {
601
+ srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig)
602
+ }
603
srv.AutoHTTPS.Disabled = true
604
+
605
case "disable_redirects":
606
+ if srv.AutoHTTPS == nil {
607
+ srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig)
608
+ }
609
srv.AutoHTTPS.DisableRedir = true
610
+
611
case "disable_certs":
612
+ if srv.AutoHTTPS == nil {
613
+ srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig)
614
+ }
615
srv.AutoHTTPS.DisableCerts = true
616
+
617
case "ignore_loaded_certs":
618
+ if srv.AutoHTTPS == nil {
619
+ srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig)
620
+ }
621
srv.AutoHTTPS.IgnoreLoadedCerts = true
622
+
623
+ case "prefer_wildcard":
624
+ if srv.AutoHTTPS == nil {
625
+ srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig)
626
+ }
627
+ srv.AutoHTTPS.PreferWildcard = true
628
}
629
}
630
@@ -673,7 +693,7 @@ func (st *ServerType) serversFromPairings(
693
})
694
695
var hasCatchAllTLSConnPolicy, addressQualifiesForTLS bool
676
- autoHTTPSWillAddConnPolicy := autoHTTPS != "off"
696
+ autoHTTPSWillAddConnPolicy := srv.AutoHTTPS == nil || !srv.AutoHTTPS.Disabled
697
698
// if needed, the ServerLogConfig is initialized beforehand so
699
// that all server blocks can populate it with data, even when not
@@ -757,6 +777,13 @@ func (st *ServerType) serversFromPairings(
777
}
778
}
779
780
+ wildcardHosts := []string{}
781
+ for _, addr := range sblock.parsedKeys {
782
+ if strings.HasPrefix(addr.Host, "*.") {
783
+ wildcardHosts = append(wildcardHosts, addr.Host[2:])
784
+ }
785
+ }
786
+
787
for _, addr := range sblock.parsedKeys {
788
// if server only uses HTTP port, auto-HTTPS will not apply
789
if listenersUseAnyPortOtherThan(srv.Listen, httpPort) {
@@ -772,6 +799,18 @@ func (st *ServerType) serversFromPairings(
799
}
800
}
801
802
+ // If prefer wildcard is enabled, then we add hosts that are
803
+ // already covered by the wildcard to the skip list
804
+ if srv.AutoHTTPS != nil && srv.AutoHTTPS.PreferWildcard && addr.Scheme == "https" {
805
+ baseDomain := addr.Host
806
+ if idx := strings.Index(baseDomain, "."); idx != -1 {
807
+ baseDomain = baseDomain[idx+1:]
808
+ }
809
+ if !strings.HasPrefix(addr.Host, "*.") && slices.Contains(wildcardHosts, baseDomain) {
810
+ srv.AutoHTTPS.Skip = append(srv.AutoHTTPS.Skip, addr.Host)
811
+ }
812
+ }
813
+
814
// If TLS is specified as directive, it will also result in 1 or more connection policy being created
815
// Thus, catch-all address with non-standard port, e.g. :8443, can have TLS enabled without
816
// specifying prefix "https://"
@@ -919,7 +958,10 @@ func (st *ServerType) serversFromPairings(
958
if addressQualifiesForTLS &&
959
!hasCatchAllTLSConnPolicy &&
960
(len(srv.TLSConnPolicies) > 0 || !autoHTTPSWillAddConnPolicy || defaultSNI != "" || fallbackSNI != "") {
922
- srv.TLSConnPolicies = append(srv.TLSConnPolicies, &caddytls.ConnectionPolicy{DefaultSNI: defaultSNI, FallbackSNI: fallbackSNI})
961
+ srv.TLSConnPolicies = append(srv.TLSConnPolicies, &caddytls.ConnectionPolicy{
962
+ DefaultSNI: defaultSNI,
963
+ FallbackSNI: fallbackSNI,
964
+ })
965
}
966
967
// tidy things up a bit
caddyconfig/httpcaddyfile/options.go
+14
-7
@@ -452,15 +452,22 @@ func parseOptPersistConfig(d *caddyfile.Dispenser, _ any) (any, error) {
452
453
func parseOptAutoHTTPS(d *caddyfile.Dispenser, _ any) (any, error) {
454
d.Next() // consume option name
455
- if !d.Next() {
456
- return "", d.ArgErr()
457
- }
458
- val := d.Val()
459
- if d.Next() {
455
+ val := d.RemainingArgs()
456
+ if len(val) == 0 {
457
return "", d.ArgErr()
458
}
462
- if val != "off" && val != "disable_redirects" && val != "disable_certs" && val != "ignore_loaded_certs" {
463
- return "", d.Errf("auto_https must be one of 'off', 'disable_redirects', 'disable_certs', or 'ignore_loaded_certs'")
459
+ for _, v := range val {
460
+ switch v {
461
+ case "off":
462
+ case "disable_redirects":
463
+ case "disable_certs":
464
+ case "ignore_loaded_certs":
465
+ case "prefer_wildcard":
466
+ break
467
+
468
+ default:
469
+ return "", d.Errf("auto_https must be one of 'off', 'disable_redirects', 'disable_certs', 'ignore_loaded_certs', or 'prefer_wildcard'")
470
+ }
471
}
472
return val, nil
473
}
caddyconfig/httpcaddyfile/tlsapp.go
+35
-6
@@ -45,8 +45,8 @@ func (st ServerType) buildTLSApp(
45
if hp, ok := options["http_port"].(int); ok {
46
httpPort = strconv.Itoa(hp)
47
}
48
- autoHTTPS := "on"
49
- if ah, ok := options["auto_https"].(string); ok {
48
+ autoHTTPS := []string{}
49
+ if ah, ok := options["auto_https"].([]string); ok {
50
autoHTTPS = ah
51
}
52
@@ -54,13 +54,14 @@ func (st ServerType) buildTLSApp(
54
// key, so that they don't get forgotten/omitted by auto-HTTPS
55
// (since they won't appear in route matchers)
56
httpsHostsSharedWithHostlessKey := make(map[string]struct{})
57
- if autoHTTPS != "off" {
57
+ if !slices.Contains(autoHTTPS, "off") {
58
for _, pair := range pairings {
59
for _, sb := range pair.serverBlocks {
60
for _, addr := range sb.parsedKeys {
61
if addr.Host != "" {
62
continue
63
}
64
+
65
// this server block has a hostless key, now
66
// go through and add all the hosts to the set
67
for _, otherAddr := range sb.parsedKeys {
@@ -350,7 +351,7 @@ func (st ServerType) buildTLSApp(
351
internalAP := &caddytls.AutomationPolicy{
352
IssuersRaw: []json.RawMessage{json.RawMessage(`{"module":"internal"}`)},
353
}
353
- if autoHTTPS != "off" && autoHTTPS != "disable_certs" {
354
+ if !slices.Contains(autoHTTPS, "off") && !slices.Contains(autoHTTPS, "disable_certs") {
355
for h := range httpsHostsSharedWithHostlessKey {
356
al = append(al, h)
357
if !certmagic.SubjectQualifiesForPublicCert(h) {
@@ -417,7 +418,10 @@ func (st ServerType) buildTLSApp(
418
}
419
420
// consolidate automation policies that are the exact same
420
- tlsApp.Automation.Policies = consolidateAutomationPolicies(tlsApp.Automation.Policies)
421
+ tlsApp.Automation.Policies = consolidateAutomationPolicies(
422
+ tlsApp.Automation.Policies,
423
+ slices.Contains(autoHTTPS, "prefer_wildcard"),
424
+ )
425
426
// ensure automation policies don't overlap subjects (this should be
427
// an error at provision-time as well, but catch it in the adapt phase
@@ -563,7 +567,7 @@ func newBaseAutomationPolicy(
567
568
// consolidateAutomationPolicies combines automation policies that are the same,
569
// for a cleaner overall output.
566
-func consolidateAutomationPolicies(aps []*caddytls.AutomationPolicy) []*caddytls.AutomationPolicy {
570
+func consolidateAutomationPolicies(aps []*caddytls.AutomationPolicy, preferWildcard bool) []*caddytls.AutomationPolicy {
571
// sort from most specific to least specific; we depend on this ordering
572
sort.SliceStable(aps, func(i, j int) bool {
573
if automationPolicyIsSubset(aps[i], aps[j]) {
@@ -648,6 +652,31 @@ outer:
652
j--
653
}
654
}
655
+
656
+ if preferWildcard {
657
+ // remove subjects from i if they're covered by a wildcard in j
658
+ iSubjs := aps[i].SubjectsRaw
659
+ for iSubj := 0; iSubj < len(iSubjs); iSubj++ {
660
+ for jSubj := range aps[j].SubjectsRaw {
661
+ if !strings.HasPrefix(aps[j].SubjectsRaw[jSubj], "*.") {
662
+ continue
663
+ }
664
+ if certmagic.MatchWildcard(aps[i].SubjectsRaw[iSubj], aps[j].SubjectsRaw[jSubj]) {
665
+ iSubjs = slices.Delete(iSubjs, iSubj, iSubj+1)
666
+ iSubj--
667
+ break
668
+ }
669
+ }
670
+ }
671
+ aps[i].SubjectsRaw = iSubjs
672
+
673
+ // remove i if it has no subjects left
674
+ if len(aps[i].SubjectsRaw) == 0 {
675
+ aps = slices.Delete(aps, i, i+1)
676
+ i--
677
+ continue outer
678
+ }
679
+ }
680
}
681
}
682
caddytest/integration/caddyfile_adapt/auto_https_prefer_wildcard.caddyfiletest
new
+106
@@ -0,0 +1,106 @@
1
+{
2
+ auto_https prefer_wildcard
3
+}
4
+
5
+*.example.com {
6
+ tls {
7
+ dns mock
8
+ }
9
+ respond "fallback"
10
+}
11
+
12
+foo.example.com {
13
+ respond "foo"
14
+}
15
+----------
16
+{
17
+ "apps": {
18
+ "http": {
19
+ "servers": {
20
+ "srv0": {
21
+ "listen": [
22
+ ":443"
23
+ ],
24
+ "routes": [
25
+ {
26
+ "match": [
27
+ {
28
+ "host": [
29
+ "foo.example.com"
30
+ ]
31
+ }
32
+ ],
33
+ "handle": [
34
+ {
35
+ "handler": "subroute",
36
+ "routes": [
37
+ {
38
+ "handle": [
39
+ {
40
+ "body": "foo",
41
+ "handler": "static_response"
42
+ }
43
+ ]
44
+ }
45
+ ]
46
+ }
47
+ ],
48
+ "terminal": true
49
+ },
50
+ {
51
+ "match": [
52
+ {
53
+ "host": [
54
+ "*.example.com"
55
+ ]
56
+ }
57
+ ],
58
+ "handle": [
59
+ {
60
+ "handler": "subroute",
61
+ "routes": [
62
+ {
63
+ "handle": [
64
+ {
65
+ "body": "fallback",
66
+ "handler": "static_response"
67
+ }
68
+ ]
69
+ }
70
+ ]
71
+ }
72
+ ],
73
+ "terminal": true
74
+ }
75
+ ],
76
+ "automatic_https": {
77
+ "prefer_wildcard": true
78
+ }
79
+ }
80
+ }
81
+ },
82
+ "tls": {
83
+ "automation": {
84
+ "policies": [
85
+ {
86
+ "subjects": [
87
+ "*.example.com"
88
+ ],
89
+ "issuers": [
90
+ {
91
+ "challenges": {
92
+ "dns": {
93
+ "provider": {
94
+ "name": "mock"
95
+ }
96
+ }
97
+ },
98
+ "module": "acme"
99
+ }
100
+ ]
101
+ }
102
+ ]
103
+ }
104
+ }
105
+ }
106
+}
\ No newline at end of file
caddytest/integration/caddyfile_adapt/wildcard_pattern.caddyfiletest
new
+157
@@ -0,0 +1,157 @@
1
+*.example.com {
2
+ tls foo@example.com {
3
+ dns mock
4
+ }
5
+
6
+ @foo host foo.example.com
7
+ handle @foo {
8
+ respond "Foo!"
9
+ }
10
+
11
+ @bar host bar.example.com
12
+ handle @bar {
13
+ respond "Bar!"
14
+ }
15
+
16
+ # Fallback for otherwise unhandled domains
17
+ handle {
18
+ abort
19
+ }
20
+}
21
+----------
22
+{
23
+ "apps": {
24
+ "http": {
25
+ "servers": {
26
+ "srv0": {
27
+ "listen": [
28
+ ":443"
29
+ ],
30
+ "routes": [
31
+ {
32
+ "match": [
33
+ {
34
+ "host": [
35
+ "*.example.com"
36
+ ]
37
+ }
38
+ ],
39
+ "handle": [
40
+ {
41
+ "handler": "subroute",
42
+ "routes": [
43
+ {
44
+ "group": "group3",
45
+ "handle": [
46
+ {
47
+ "handler": "subroute",
48
+ "routes": [
49
+ {
50
+ "handle": [
51
+ {
52
+ "body": "Foo!",
53
+ "handler": "static_response"
54
+ }
55
+ ]
56
+ }
57
+ ]
58
+ }
59
+ ],
60
+ "match": [
61
+ {
62
+ "host": [
63
+ "foo.example.com"
64
+ ]
65
+ }
66
+ ]
67
+ },
68
+ {
69
+ "group": "group3",
70
+ "handle": [
71
+ {
72
+ "handler": "subroute",
73
+ "routes": [
74
+ {
75
+ "handle": [
76
+ {
77
+ "body": "Bar!",
78
+ "handler": "static_response"
79
+ }
80
+ ]
81
+ }
82
+ ]
83
+ }
84
+ ],
85
+ "match": [
86
+ {
87
+ "host": [
88
+ "bar.example.com"
89
+ ]
90
+ }
91
+ ]
92
+ },
93
+ {
94
+ "group": "group3",
95
+ "handle": [
96
+ {
97
+ "handler": "subroute",
98
+ "routes": [
99
+ {
100
+ "handle": [
101
+ {
102
+ "abort": true,
103
+ "handler": "static_response"
104
+ }
105
+ ]
106
+ }
107
+ ]
108
+ }
109
+ ]
110
+ }
111
+ ]
112
+ }
113
+ ],
114
+ "terminal": true
115
+ }
116
+ ]
117
+ }
118
+ }
119
+ },
120
+ "tls": {
121
+ "automation": {
122
+ "policies": [
123
+ {
124
+ "subjects": [
125
+ "*.example.com"
126
+ ],
127
+ "issuers": [
128
+ {
129
+ "challenges": {
130
+ "dns": {
131
+ "provider": {
132
+ "name": "mock"
133
+ }
134
+ }
135
+ },
136
+ "email": "foo@example.com",
137
+ "module": "acme"
138
+ },
139
+ {
140
+ "ca": "https://acme.zerossl.com/v2/DV90",
141
+ "challenges": {
142
+ "dns": {
143
+ "provider": {
144
+ "name": "mock"
145
+ }
146
+ }
147
+ },
148
+ "email": "foo@example.com",
149
+ "module": "acme"
150
+ }
151
+ ]
152
+ }
153
+ ]
154
+ }
155
+ }
156
+ }
157
+}
\ No newline at end of file
caddytest/integration/mockdns_test.go
new
+61
@@ -0,0 +1,61 @@
1
+package integration
2
+
3
+import (
4
+ "context"
5
+
6
+ "github.com/caddyserver/caddy/v2"
7
+ "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
8
+ "github.com/caddyserver/certmagic"
9
+ "github.com/libdns/libdns"
10
+)
11
+
12
+func init() {
13
+ caddy.RegisterModule(MockDNSProvider{})
14
+}
15
+
16
+// MockDNSProvider is a mock DNS provider, for testing config with DNS modules.
17
+type MockDNSProvider struct{}
18
+
19
+// CaddyModule returns the Caddy module information.
20
+func (MockDNSProvider) CaddyModule() caddy.ModuleInfo {
21
+ return caddy.ModuleInfo{
22
+ ID: "dns.providers.mock",
23
+ New: func() caddy.Module { return new(MockDNSProvider) },
24
+ }
25
+}
26
+
27
+// Provision sets up the module.
28
+func (MockDNSProvider) Provision(ctx caddy.Context) error {
29
+ return nil
30
+}
31
+
32
+// UnmarshalCaddyfile sets up the module from Caddyfile tokens.
33
+func (MockDNSProvider) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
34
+ return nil
35
+}
36
+
37
+// AppendsRecords appends DNS records to the zone.
38
+func (MockDNSProvider) AppendRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) {
39
+ return nil, nil
40
+}
41
+
42
+// DeleteRecords deletes DNS records from the zone.
43
+func (MockDNSProvider) DeleteRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) {
44
+ return nil, nil
45
+}
46
+
47
+// GetRecords gets DNS records from the zone.
48
+func (MockDNSProvider) GetRecords(ctx context.Context, zone string) ([]libdns.Record, error) {
49
+ return nil, nil
50
+}
51
+
52
+// SetRecords sets DNS records in the zone.
53
+func (MockDNSProvider) SetRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) {
54
+ return nil, nil
55
+}
56
+
57
+// Interface guard
58
+var _ caddyfile.Unmarshaler = (*MockDNSProvider)(nil)
59
+var _ certmagic.DNSProvider = (*MockDNSProvider)(nil)
60
+var _ caddy.Provisioner = (*MockDNSProvider)(nil)
61
+var _ caddy.Module = (*MockDNSProvider)(nil)
modules/caddyhttp/autohttps.go
+27
@@ -65,6 +65,12 @@ type AutoHTTPSConfig struct {
65
// enabled. To force automated certificate management
66
// regardless of loaded certificates, set this to true.
67
IgnoreLoadedCerts bool `json:"ignore_loaded_certificates,omitempty"`
68
+
69
+ // If true, automatic HTTPS will prefer wildcard names
70
+ // and ignore non-wildcard names if both are available.
71
+ // This allows for writing a config with top-level host
72
+ // matchers without having those names produce certificates.
73
+ PreferWildcard bool `json:"prefer_wildcard,omitempty"`
74
}
75
76
// automaticHTTPSPhase1 provisions all route matchers, determines
@@ -157,6 +163,27 @@ func (app *App) automaticHTTPSPhase1(ctx caddy.Context, repl *caddy.Replacer) er
163
}
164
}
165
166
+ if srv.AutoHTTPS.PreferWildcard {
167
+ wildcards := make(map[string]struct{})
168
+ for d := range serverDomainSet {
169
+ if strings.HasPrefix(d, "*.") {
170
+ wildcards[d[2:]] = struct{}{}
171
+ }
172
+ }
173
+ for d := range serverDomainSet {
174
+ if strings.HasPrefix(d, "*.") {
175
+ continue
176
+ }
177
+ base := d
178
+ if idx := strings.Index(d, "."); idx != -1 {
179
+ base = d[idx+1:]
180
+ }
181
+ if _, ok := wildcards[base]; ok {
182
+ delete(serverDomainSet, d)
183
+ }
184
+ }
185
+ }
186
+
187
// nothing more to do here if there are no domains that qualify for
188
// automatic HTTPS and there are no explicit TLS connection policies:
189
// if there is at least one domain but no TLS conn policy (F&&T), we'll