@marcel91200 / caddy Mirror · read-only / commits / 16724842
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 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