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
This commit is contained in:
Francis Lavoie 2024-10-02 09:31:58 -04:00 committed by GitHub
parent 792f1c7ed7
commit 16724842d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 449 additions and 20 deletions

View File

@ -534,8 +534,8 @@ func (st *ServerType) serversFromPairings(
if hsp, ok := options["https_port"].(int); ok { if hsp, ok := options["https_port"].(int); ok {
httpsPort = strconv.Itoa(hsp) httpsPort = strconv.Itoa(hsp)
} }
autoHTTPS := "on" autoHTTPS := []string{}
if ah, ok := options["auto_https"].(string); ok { if ah, ok := options["auto_https"].([]string); ok {
autoHTTPS = ah autoHTTPS = ah
} }
@ -594,17 +594,37 @@ func (st *ServerType) serversFromPairings(
} }
// handle the auto_https global option // handle the auto_https global option
if autoHTTPS != "on" { for _, val := range autoHTTPS {
srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig) switch val {
switch autoHTTPS {
case "off": case "off":
if srv.AutoHTTPS == nil {
srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig)
}
srv.AutoHTTPS.Disabled = true srv.AutoHTTPS.Disabled = true
case "disable_redirects": case "disable_redirects":
if srv.AutoHTTPS == nil {
srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig)
}
srv.AutoHTTPS.DisableRedir = true srv.AutoHTTPS.DisableRedir = true
case "disable_certs": case "disable_certs":
if srv.AutoHTTPS == nil {
srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig)
}
srv.AutoHTTPS.DisableCerts = true srv.AutoHTTPS.DisableCerts = true
case "ignore_loaded_certs": case "ignore_loaded_certs":
if srv.AutoHTTPS == nil {
srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig)
}
srv.AutoHTTPS.IgnoreLoadedCerts = true srv.AutoHTTPS.IgnoreLoadedCerts = true
case "prefer_wildcard":
if srv.AutoHTTPS == nil {
srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig)
}
srv.AutoHTTPS.PreferWildcard = true
} }
} }
@ -673,7 +693,7 @@ func (st *ServerType) serversFromPairings(
}) })
var hasCatchAllTLSConnPolicy, addressQualifiesForTLS bool var hasCatchAllTLSConnPolicy, addressQualifiesForTLS bool
autoHTTPSWillAddConnPolicy := autoHTTPS != "off" autoHTTPSWillAddConnPolicy := srv.AutoHTTPS == nil || !srv.AutoHTTPS.Disabled
// if needed, the ServerLogConfig is initialized beforehand so // if needed, the ServerLogConfig is initialized beforehand so
// that all server blocks can populate it with data, even when not // that all server blocks can populate it with data, even when not
@ -757,6 +777,13 @@ func (st *ServerType) serversFromPairings(
} }
} }
wildcardHosts := []string{}
for _, addr := range sblock.parsedKeys {
if strings.HasPrefix(addr.Host, "*.") {
wildcardHosts = append(wildcardHosts, addr.Host[2:])
}
}
for _, addr := range sblock.parsedKeys { for _, addr := range sblock.parsedKeys {
// if server only uses HTTP port, auto-HTTPS will not apply // if server only uses HTTP port, auto-HTTPS will not apply
if listenersUseAnyPortOtherThan(srv.Listen, httpPort) { if listenersUseAnyPortOtherThan(srv.Listen, httpPort) {
@ -772,6 +799,18 @@ func (st *ServerType) serversFromPairings(
} }
} }
// If prefer wildcard is enabled, then we add hosts that are
// already covered by the wildcard to the skip list
if srv.AutoHTTPS != nil && srv.AutoHTTPS.PreferWildcard && addr.Scheme == "https" {
baseDomain := addr.Host
if idx := strings.Index(baseDomain, "."); idx != -1 {
baseDomain = baseDomain[idx+1:]
}
if !strings.HasPrefix(addr.Host, "*.") && slices.Contains(wildcardHosts, baseDomain) {
srv.AutoHTTPS.Skip = append(srv.AutoHTTPS.Skip, addr.Host)
}
}
// If TLS is specified as directive, it will also result in 1 or more connection policy being created // If TLS is specified as directive, it will also result in 1 or more connection policy being created
// Thus, catch-all address with non-standard port, e.g. :8443, can have TLS enabled without // Thus, catch-all address with non-standard port, e.g. :8443, can have TLS enabled without
// specifying prefix "https://" // specifying prefix "https://"
@ -919,7 +958,10 @@ func (st *ServerType) serversFromPairings(
if addressQualifiesForTLS && if addressQualifiesForTLS &&
!hasCatchAllTLSConnPolicy && !hasCatchAllTLSConnPolicy &&
(len(srv.TLSConnPolicies) > 0 || !autoHTTPSWillAddConnPolicy || defaultSNI != "" || fallbackSNI != "") { (len(srv.TLSConnPolicies) > 0 || !autoHTTPSWillAddConnPolicy || defaultSNI != "" || fallbackSNI != "") {
srv.TLSConnPolicies = append(srv.TLSConnPolicies, &caddytls.ConnectionPolicy{DefaultSNI: defaultSNI, FallbackSNI: fallbackSNI}) srv.TLSConnPolicies = append(srv.TLSConnPolicies, &caddytls.ConnectionPolicy{
DefaultSNI: defaultSNI,
FallbackSNI: fallbackSNI,
})
} }
// tidy things up a bit // tidy things up a bit

View File

@ -452,15 +452,22 @@ func parseOptPersistConfig(d *caddyfile.Dispenser, _ any) (any, error) {
func parseOptAutoHTTPS(d *caddyfile.Dispenser, _ any) (any, error) { func parseOptAutoHTTPS(d *caddyfile.Dispenser, _ any) (any, error) {
d.Next() // consume option name d.Next() // consume option name
if !d.Next() { val := d.RemainingArgs()
if len(val) == 0 {
return "", d.ArgErr() return "", d.ArgErr()
} }
val := d.Val() for _, v := range val {
if d.Next() { switch v {
return "", d.ArgErr() case "off":
} case "disable_redirects":
if val != "off" && val != "disable_redirects" && val != "disable_certs" && val != "ignore_loaded_certs" { case "disable_certs":
return "", d.Errf("auto_https must be one of 'off', 'disable_redirects', 'disable_certs', or 'ignore_loaded_certs'") case "ignore_loaded_certs":
case "prefer_wildcard":
break
default:
return "", d.Errf("auto_https must be one of 'off', 'disable_redirects', 'disable_certs', 'ignore_loaded_certs', or 'prefer_wildcard'")
}
} }
return val, nil return val, nil
} }

View File

@ -45,8 +45,8 @@ func (st ServerType) buildTLSApp(
if hp, ok := options["http_port"].(int); ok { if hp, ok := options["http_port"].(int); ok {
httpPort = strconv.Itoa(hp) httpPort = strconv.Itoa(hp)
} }
autoHTTPS := "on" autoHTTPS := []string{}
if ah, ok := options["auto_https"].(string); ok { if ah, ok := options["auto_https"].([]string); ok {
autoHTTPS = ah autoHTTPS = ah
} }
@ -54,13 +54,14 @@ func (st ServerType) buildTLSApp(
// key, so that they don't get forgotten/omitted by auto-HTTPS // key, so that they don't get forgotten/omitted by auto-HTTPS
// (since they won't appear in route matchers) // (since they won't appear in route matchers)
httpsHostsSharedWithHostlessKey := make(map[string]struct{}) httpsHostsSharedWithHostlessKey := make(map[string]struct{})
if autoHTTPS != "off" { if !slices.Contains(autoHTTPS, "off") {
for _, pair := range pairings { for _, pair := range pairings {
for _, sb := range pair.serverBlocks { for _, sb := range pair.serverBlocks {
for _, addr := range sb.parsedKeys { for _, addr := range sb.parsedKeys {
if addr.Host != "" { if addr.Host != "" {
continue continue
} }
// this server block has a hostless key, now // this server block has a hostless key, now
// go through and add all the hosts to the set // go through and add all the hosts to the set
for _, otherAddr := range sb.parsedKeys { for _, otherAddr := range sb.parsedKeys {
@ -350,7 +351,7 @@ func (st ServerType) buildTLSApp(
internalAP := &caddytls.AutomationPolicy{ internalAP := &caddytls.AutomationPolicy{
IssuersRaw: []json.RawMessage{json.RawMessage(`{"module":"internal"}`)}, IssuersRaw: []json.RawMessage{json.RawMessage(`{"module":"internal"}`)},
} }
if autoHTTPS != "off" && autoHTTPS != "disable_certs" { if !slices.Contains(autoHTTPS, "off") && !slices.Contains(autoHTTPS, "disable_certs") {
for h := range httpsHostsSharedWithHostlessKey { for h := range httpsHostsSharedWithHostlessKey {
al = append(al, h) al = append(al, h)
if !certmagic.SubjectQualifiesForPublicCert(h) { if !certmagic.SubjectQualifiesForPublicCert(h) {
@ -417,7 +418,10 @@ func (st ServerType) buildTLSApp(
} }
// consolidate automation policies that are the exact same // consolidate automation policies that are the exact same
tlsApp.Automation.Policies = consolidateAutomationPolicies(tlsApp.Automation.Policies) tlsApp.Automation.Policies = consolidateAutomationPolicies(
tlsApp.Automation.Policies,
slices.Contains(autoHTTPS, "prefer_wildcard"),
)
// ensure automation policies don't overlap subjects (this should be // ensure automation policies don't overlap subjects (this should be
// an error at provision-time as well, but catch it in the adapt phase // an error at provision-time as well, but catch it in the adapt phase
@ -563,7 +567,7 @@ func newBaseAutomationPolicy(
// consolidateAutomationPolicies combines automation policies that are the same, // consolidateAutomationPolicies combines automation policies that are the same,
// for a cleaner overall output. // for a cleaner overall output.
func consolidateAutomationPolicies(aps []*caddytls.AutomationPolicy) []*caddytls.AutomationPolicy { func consolidateAutomationPolicies(aps []*caddytls.AutomationPolicy, preferWildcard bool) []*caddytls.AutomationPolicy {
// sort from most specific to least specific; we depend on this ordering // sort from most specific to least specific; we depend on this ordering
sort.SliceStable(aps, func(i, j int) bool { sort.SliceStable(aps, func(i, j int) bool {
if automationPolicyIsSubset(aps[i], aps[j]) { if automationPolicyIsSubset(aps[i], aps[j]) {
@ -648,6 +652,31 @@ outer:
j-- j--
} }
} }
if preferWildcard {
// remove subjects from i if they're covered by a wildcard in j
iSubjs := aps[i].SubjectsRaw
for iSubj := 0; iSubj < len(iSubjs); iSubj++ {
for jSubj := range aps[j].SubjectsRaw {
if !strings.HasPrefix(aps[j].SubjectsRaw[jSubj], "*.") {
continue
}
if certmagic.MatchWildcard(aps[i].SubjectsRaw[iSubj], aps[j].SubjectsRaw[jSubj]) {
iSubjs = slices.Delete(iSubjs, iSubj, iSubj+1)
iSubj--
break
}
}
}
aps[i].SubjectsRaw = iSubjs
// remove i if it has no subjects left
if len(aps[i].SubjectsRaw) == 0 {
aps = slices.Delete(aps, i, i+1)
i--
continue outer
}
}
} }
} }

View File

@ -0,0 +1,106 @@
{
auto_https prefer_wildcard
}
*.example.com {
tls {
dns mock
}
respond "fallback"
}
foo.example.com {
respond "foo"
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"foo.example.com"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "foo",
"handler": "static_response"
}
]
}
]
}
],
"terminal": true
},
{
"match": [
{
"host": [
"*.example.com"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "fallback",
"handler": "static_response"
}
]
}
]
}
],
"terminal": true
}
],
"automatic_https": {
"prefer_wildcard": true
}
}
}
},
"tls": {
"automation": {
"policies": [
{
"subjects": [
"*.example.com"
],
"issuers": [
{
"challenges": {
"dns": {
"provider": {
"name": "mock"
}
}
},
"module": "acme"
}
]
}
]
}
}
}
}

View File

@ -0,0 +1,157 @@
*.example.com {
tls foo@example.com {
dns mock
}
@foo host foo.example.com
handle @foo {
respond "Foo!"
}
@bar host bar.example.com
handle @bar {
respond "Bar!"
}
# Fallback for otherwise unhandled domains
handle {
abort
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"*.example.com"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"group": "group3",
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "Foo!",
"handler": "static_response"
}
]
}
]
}
],
"match": [
{
"host": [
"foo.example.com"
]
}
]
},
{
"group": "group3",
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "Bar!",
"handler": "static_response"
}
]
}
]
}
],
"match": [
{
"host": [
"bar.example.com"
]
}
]
},
{
"group": "group3",
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"abort": true,
"handler": "static_response"
}
]
}
]
}
]
}
]
}
],
"terminal": true
}
]
}
}
},
"tls": {
"automation": {
"policies": [
{
"subjects": [
"*.example.com"
],
"issuers": [
{
"challenges": {
"dns": {
"provider": {
"name": "mock"
}
}
},
"email": "foo@example.com",
"module": "acme"
},
{
"ca": "https://acme.zerossl.com/v2/DV90",
"challenges": {
"dns": {
"provider": {
"name": "mock"
}
}
},
"email": "foo@example.com",
"module": "acme"
}
]
}
]
}
}
}
}

View File

@ -0,0 +1,61 @@
package integration
import (
"context"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/certmagic"
"github.com/libdns/libdns"
)
func init() {
caddy.RegisterModule(MockDNSProvider{})
}
// MockDNSProvider is a mock DNS provider, for testing config with DNS modules.
type MockDNSProvider struct{}
// CaddyModule returns the Caddy module information.
func (MockDNSProvider) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "dns.providers.mock",
New: func() caddy.Module { return new(MockDNSProvider) },
}
}
// Provision sets up the module.
func (MockDNSProvider) Provision(ctx caddy.Context) error {
return nil
}
// UnmarshalCaddyfile sets up the module from Caddyfile tokens.
func (MockDNSProvider) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
return nil
}
// AppendsRecords appends DNS records to the zone.
func (MockDNSProvider) AppendRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) {
return nil, nil
}
// DeleteRecords deletes DNS records from the zone.
func (MockDNSProvider) DeleteRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) {
return nil, nil
}
// GetRecords gets DNS records from the zone.
func (MockDNSProvider) GetRecords(ctx context.Context, zone string) ([]libdns.Record, error) {
return nil, nil
}
// SetRecords sets DNS records in the zone.
func (MockDNSProvider) SetRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) {
return nil, nil
}
// Interface guard
var _ caddyfile.Unmarshaler = (*MockDNSProvider)(nil)
var _ certmagic.DNSProvider = (*MockDNSProvider)(nil)
var _ caddy.Provisioner = (*MockDNSProvider)(nil)
var _ caddy.Module = (*MockDNSProvider)(nil)

View File

@ -65,6 +65,12 @@ type AutoHTTPSConfig struct {
// enabled. To force automated certificate management // enabled. To force automated certificate management
// regardless of loaded certificates, set this to true. // regardless of loaded certificates, set this to true.
IgnoreLoadedCerts bool `json:"ignore_loaded_certificates,omitempty"` IgnoreLoadedCerts bool `json:"ignore_loaded_certificates,omitempty"`
// If true, automatic HTTPS will prefer wildcard names
// and ignore non-wildcard names if both are available.
// This allows for writing a config with top-level host
// matchers without having those names produce certificates.
PreferWildcard bool `json:"prefer_wildcard,omitempty"`
} }
// automaticHTTPSPhase1 provisions all route matchers, determines // automaticHTTPSPhase1 provisions all route matchers, determines
@ -157,6 +163,27 @@ func (app *App) automaticHTTPSPhase1(ctx caddy.Context, repl *caddy.Replacer) er
} }
} }
if srv.AutoHTTPS.PreferWildcard {
wildcards := make(map[string]struct{})
for d := range serverDomainSet {
if strings.HasPrefix(d, "*.") {
wildcards[d[2:]] = struct{}{}
}
}
for d := range serverDomainSet {
if strings.HasPrefix(d, "*.") {
continue
}
base := d
if idx := strings.Index(d, "."); idx != -1 {
base = d[idx+1:]
}
if _, ok := wildcards[base]; ok {
delete(serverDomainSet, d)
}
}
}
// nothing more to do here if there are no domains that qualify for // nothing more to do here if there are no domains that qualify for
// automatic HTTPS and there are no explicit TLS connection policies: // automatic HTTPS and there are no explicit TLS connection policies:
// if there is at least one domain but no TLS conn policy (F&&T), we'll // if there is at least one domain but no TLS conn policy (F&&T), we'll