From aacb430d87081ee878de16fdc082a9d5f64f3929 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Wed, 15 Apr 2026 09:36:10 +1000 Subject: [PATCH 1/3] tls: add alpn to managed HTTPS records --- modules/caddyhttp/autohttps.go | 48 ++++++++++++++- modules/caddyhttp/autohttps_test.go | 46 ++++++++++++++ modules/caddytls/ech.go | 33 +++++++--- modules/caddytls/ech_dns_test.go | 66 ++++++++++++++++++++ modules/caddytls/tls.go | 95 +++++++++++++++++++++++++++-- 5 files changed, 275 insertions(+), 13 deletions(-) create mode 100644 modules/caddyhttp/autohttps_test.go create mode 100644 modules/caddytls/ech_dns_test.go diff --git a/modules/caddyhttp/autohttps.go b/modules/caddyhttp/autohttps.go index 32e9f106d29..05ef08e1a6e 100644 --- a/modules/caddyhttp/autohttps.go +++ b/modules/caddyhttp/autohttps.go @@ -173,7 +173,7 @@ func (app *App) automaticHTTPSPhase1(ctx caddy.Context, repl *caddy.Replacer) er for d := range serverDomainSet { echDomains = append(echDomains, d) } - app.tlsApp.RegisterServerNames(echDomains) + app.tlsApp.RegisterServerNamesWithALPN(echDomains, httpsRRALPNs(srv)) // nothing more to do here if there are no domains that qualify for // automatic HTTPS and there are no explicit TLS connection policies: @@ -550,6 +550,52 @@ func (app *App) makeRedirRoute(redirToPort uint, matcherSet MatcherSet) Route { } } +func httpsRRALPNs(srv *Server) []string { + // Automatic HTTPS runs before server provisioning fills in the default + // protocols, so derive the effective set directly from the raw config here. + serverProtocols := srv.Protocols + if len(serverProtocols) == 0 { + serverProtocols = []string{"h1", "h2", "h3"} + } + + protocols := make(map[string]struct{}, len(serverProtocols)) + if srv.ListenProtocols == nil { + for _, protocol := range serverProtocols { + protocols[protocol] = struct{}{} + } + } else { + for _, lnProtocols := range srv.ListenProtocols { + if len(lnProtocols) == 0 { + for _, protocol := range serverProtocols { + protocols[protocol] = struct{}{} + } + continue + } + for _, protocol := range lnProtocols { + if protocol == "" { + for _, inherited := range serverProtocols { + protocols[inherited] = struct{}{} + } + continue + } + protocols[protocol] = struct{}{} + } + } + } + + alpn := make([]string, 0, 3) + if _, ok := protocols["h3"]; ok { + alpn = append(alpn, "h3") + } + if _, ok := protocols["h2"]; ok { + alpn = append(alpn, "h2") + } + if _, ok := protocols["h1"]; ok { + alpn = append(alpn, "http/1.1") + } + return alpn +} + // createAutomationPolicies ensures that automated certificates for this // app are managed properly. This adds up to two automation policies: // one for the public names, and one for the internal names. If a catch-all diff --git a/modules/caddyhttp/autohttps_test.go b/modules/caddyhttp/autohttps_test.go new file mode 100644 index 00000000000..71452621405 --- /dev/null +++ b/modules/caddyhttp/autohttps_test.go @@ -0,0 +1,46 @@ +package caddyhttp + +import ( + "reflect" + "testing" +) + +func TestHTTPSRRALPNsDefaultProtocols(t *testing.T) { + srv := &Server{} + + got := httpsRRALPNs(srv) + want := []string{"h3", "h2", "http/1.1"} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected ALPN values: got %v want %v", got, want) + } +} + +func TestHTTPSRRALPNsListenProtocolOverrides(t *testing.T) { + srv := &Server{ + Protocols: []string{"h1", "h2"}, + ListenProtocols: [][]string{ + {"h1"}, + nil, + {"h2c", "h3"}, + }, + } + + got := httpsRRALPNs(srv) + want := []string{"h3", "h2", "http/1.1"} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected ALPN values: got %v want %v", got, want) + } +} + +func TestHTTPSRRALPNsIgnoresH2COnly(t *testing.T) { + srv := &Server{ + Protocols: []string{"h2c"}, + } + + got := httpsRRALPNs(srv) + if len(got) != 0 { + t.Fatalf("unexpected ALPN values: got %v want none", got) + } +} diff --git a/modules/caddytls/ech.go b/modules/caddytls/ech.go index b915fcfbe78..4a48769d85d 100644 --- a/modules/caddytls/ech.go +++ b/modules/caddytls/ech.go @@ -440,6 +440,10 @@ func (t *TLS) publishECHConfigs(logger *zap.Logger) error { zap.Strings("domains", dnsNamesToPublish), zap.Uint8s("config_ids", configIDs)) + if dnsPublisher, ok := publisher.(*ECHDNSPublisher); ok { + dnsPublisher.alpnByDomain = t.alpnValuesForServerNames(dnsNamesToPublish) + } + // publish this ECH config list with this publisher pubTime := time.Now() err := publisher.PublishECHConfigList(t.ctx, dnsNamesToPublish, echCfgListBin) @@ -776,7 +780,8 @@ type ECHDNSPublisher struct { ProviderRaw json.RawMessage `json:"provider,omitempty" caddy:"namespace=dns.providers inline_key=name"` provider ECHDNSProvider - logger *zap.Logger + alpnByDomain map[string][]string + logger *zap.Logger } // CaddyModule returns the Caddy module information. @@ -872,12 +877,7 @@ nextName: continue } params := httpsRec.Params - if params == nil { - params = make(libdns.SvcParams) - } - - // overwrite only the "ech" SvcParamKey - params["ech"] = []string{base64.StdEncoding.EncodeToString(configListBin)} + params = dnsPub.publishedSvcParams(domain, params, configListBin) // publish record _, err = dnsPub.provider.SetRecords(ctx, zone, []libdns.Record{ @@ -903,6 +903,25 @@ nextName: return nil } +func (dnsPub *ECHDNSPublisher) publishedSvcParams(domain string, existing libdns.SvcParams, configListBin []byte) libdns.SvcParams { + params := make(libdns.SvcParams, len(existing)+2) + for key, values := range existing { + params[key] = append([]string(nil), values...) + } + + params["ech"] = []string{base64.StdEncoding.EncodeToString(configListBin)} + + if len(dnsPub.alpnByDomain) == 0 { + return params + } + + if alpn := dnsPub.alpnByDomain[strings.ToLower(domain)]; len(alpn) > 0 { + params["alpn"] = append([]string(nil), alpn...) + } + + return params +} + // echConfig represents an ECHConfig from the specification, // [draft-ietf-tls-esni-22](https://www.ietf.org/archive/id/draft-ietf-tls-esni-22.html). type echConfig struct { diff --git a/modules/caddytls/ech_dns_test.go b/modules/caddytls/ech_dns_test.go new file mode 100644 index 00000000000..6f555acc904 --- /dev/null +++ b/modules/caddytls/ech_dns_test.go @@ -0,0 +1,66 @@ +package caddytls + +import ( + "encoding/base64" + "reflect" + "sync" + "testing" + + "github.com/libdns/libdns" +) + +func TestRegisterServerNamesWithALPN(t *testing.T) { + tlsApp := &TLS{ + serverNames: make(map[string]struct{}), + serverNameALPN: make(map[string]map[string]struct{}), + serverNamesMu: new(sync.Mutex), + } + + tlsApp.RegisterServerNamesWithALPN([]string{ + "Example.com:443", + "example.com", + "127.0.0.1:443", + }, []string{"h2", "http/1.1"}) + tlsApp.RegisterServerNamesWithALPN([]string{"EXAMPLE.COM"}, []string{"h3"}) + + got := tlsApp.alpnValuesForServerNames([]string{"example.com:443", "127.0.0.1:443"}) + want := map[string][]string{ + "example.com": {"h3", "h2", "http/1.1"}, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected ALPN values: got %#v want %#v", got, want) + } +} + +func TestECHDNSPublisherPublishedSvcParams(t *testing.T) { + dnsPub := &ECHDNSPublisher{ + alpnByDomain: map[string][]string{ + "example.com": {"h3", "h2", "http/1.1"}, + }, + } + + existing := libdns.SvcParams{ + "alpn": {"h2"}, + "ipv4hint": {"203.0.113.10"}, + } + + got := dnsPub.publishedSvcParams("Example.com", existing, []byte{0x01, 0x02, 0x03}) + + if !reflect.DeepEqual(existing["alpn"], []string{"h2"}) { + t.Fatalf("existing params mutated: got %v", existing["alpn"]) + } + + if !reflect.DeepEqual(got["alpn"], []string{"h3", "h2", "http/1.1"}) { + t.Fatalf("unexpected ALPN params: got %v", got["alpn"]) + } + + if !reflect.DeepEqual(got["ipv4hint"], []string{"203.0.113.10"}) { + t.Fatalf("unexpected preserved params: got %v", got["ipv4hint"]) + } + + wantECH := base64.StdEncoding.EncodeToString([]byte{0x01, 0x02, 0x03}) + if !reflect.DeepEqual(got["ech"], []string{wantECH}) { + t.Fatalf("unexpected ECH params: got %v want %v", got["ech"], wantECH) + } +} diff --git a/modules/caddytls/tls.go b/modules/caddytls/tls.go index 34ffbf62d27..6a2f8f58773 100644 --- a/modules/caddytls/tls.go +++ b/modules/caddytls/tls.go @@ -23,6 +23,7 @@ import ( "net" "net/http" "runtime/debug" + "slices" "strings" "sync" "time" @@ -140,8 +141,9 @@ type TLS struct { logger *zap.Logger events *caddyevents.App - serverNames map[string]struct{} - serverNamesMu *sync.Mutex + serverNames map[string]struct{} + serverNameALPN map[string]map[string]struct{} + serverNamesMu *sync.Mutex // set of subjects with managed certificates, // and hashes of manually-loaded certificates @@ -169,6 +171,7 @@ func (t *TLS) Provision(ctx caddy.Context) error { repl := caddy.NewReplacer() t.managing, t.loaded = make(map[string]string), make(map[string]string) t.serverNames = make(map[string]struct{}) + t.serverNameALPN = make(map[string]map[string]struct{}) t.serverNamesMu = new(sync.Mutex) // set up default DNS module, if any, and make sure it implements all the @@ -658,17 +661,99 @@ func (t *TLS) managingWildcardFor(subj string, otherSubjsToManage map[string]str // // EXPERIMENTAL: This function and its semantics/behavior are subject to change. func (t *TLS) RegisterServerNames(dnsNames []string) { + t.RegisterServerNamesWithALPN(dnsNames, nil) +} + +// RegisterServerNamesWithALPN registers the provided DNS names with the TLS app +// and associates them with the given HTTPS RR ALPN values, if any. +// +// EXPERIMENTAL: This function and its semantics/behavior are subject to change. +func (t *TLS) RegisterServerNamesWithALPN(dnsNames []string, alpnValues []string) { + t.serverNamesMu.Lock() + defer t.serverNamesMu.Unlock() + + for _, name := range dnsNames { + host, _, err := net.SplitHostPort(name) + if err != nil { + host = name + } + host = strings.ToLower(strings.TrimSpace(host)) + if host == "" || certmagic.SubjectIsIP(host) { + continue + } + t.serverNames[host] = struct{}{} + + if len(alpnValues) == 0 { + continue + } + + if t.serverNameALPN[host] == nil { + t.serverNameALPN[host] = make(map[string]struct{}, len(alpnValues)) + } + for _, alpn := range alpnValues { + if alpn == "" { + continue + } + t.serverNameALPN[host][alpn] = struct{}{} + } + } +} + +func (t *TLS) alpnValuesForServerNames(dnsNames []string) map[string][]string { t.serverNamesMu.Lock() + defer t.serverNamesMu.Unlock() + + result := make(map[string][]string, len(dnsNames)) for _, name := range dnsNames { host, _, err := net.SplitHostPort(name) if err != nil { host = name } - if strings.TrimSpace(host) != "" && !certmagic.SubjectIsIP(host) { - t.serverNames[strings.ToLower(host)] = struct{}{} + host = strings.ToLower(strings.TrimSpace(host)) + if host == "" { + continue + } + + alpnSet := t.serverNameALPN[host] + if len(alpnSet) == 0 { + continue + } + result[host] = orderedHTTPSRRALPN(alpnSet) + } + + return result +} + +func orderedHTTPSRRALPN(alpnSet map[string]struct{}) []string { + if len(alpnSet) == 0 { + return nil + } + + knownOrder := []string{"h3", "h2", "http/1.1"} + ordered := make([]string, 0, len(alpnSet)) + seen := make(map[string]struct{}, len(alpnSet)) + + for _, alpn := range knownOrder { + if _, ok := alpnSet[alpn]; ok { + ordered = append(ordered, alpn) + seen[alpn] = struct{}{} + } + } + + if len(ordered) == len(alpnSet) { + return ordered + } + + var remaining []string + for alpn := range alpnSet { + if _, ok := seen[alpn]; ok { + continue } + remaining = append(remaining, alpn) } - t.serverNamesMu.Unlock() + slices.Sort(remaining) + + return append(ordered, remaining...) } // HandleHTTPChallenge ensures that the ACME HTTP challenge or ZeroSSL HTTP From 904f9fddccf56b646e187dc07afde0075c8514ff Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Fri, 17 Apr 2026 12:25:29 +1000 Subject: [PATCH 2/3] tls: centralise HTTPS RR ALPN defaults and registration Reuse shared protocol defaults instead of repeating the default HTTP protocol list, unify server name registration to carry ALPN in one experimental API and reuse the TLS default ALPN ordering for HTTPS RR publication --- modules/caddyhttp/app.go | 3 +- modules/caddyhttp/autohttps.go | 17 ++++------ modules/caddyhttp/server.go | 14 ++++++-- modules/caddytls/connpolicy.go | 4 +-- modules/caddytls/ech_dns_test.go | 9 +++-- modules/caddytls/tls.go | 58 +++++++++++++++----------------- 6 files changed, 55 insertions(+), 50 deletions(-) diff --git a/modules/caddyhttp/app.go b/modules/caddyhttp/app.go index 74f1466beeb..7b7b02e5db1 100644 --- a/modules/caddyhttp/app.go +++ b/modules/caddyhttp/app.go @@ -23,6 +23,7 @@ import ( "maps" "net" "net/http" + "slices" "strconv" "sync" "time" @@ -235,7 +236,7 @@ func (app *App) Provision(ctx caddy.Context) error { // if no protocols configured explicitly, enable all except h2c if len(srv.Protocols) == 0 { - srv.Protocols = []string{"h1", "h2", "h3"} + srv.Protocols = slices.Clone(srv.protocolsWithDefaults()) } srvProtocolsUnique := map[string]struct{}{} diff --git a/modules/caddyhttp/autohttps.go b/modules/caddyhttp/autohttps.go index 05ef08e1a6e..64240d9f473 100644 --- a/modules/caddyhttp/autohttps.go +++ b/modules/caddyhttp/autohttps.go @@ -173,7 +173,7 @@ func (app *App) automaticHTTPSPhase1(ctx caddy.Context, repl *caddy.Replacer) er for d := range serverDomainSet { echDomains = append(echDomains, d) } - app.tlsApp.RegisterServerNamesWithALPN(echDomains, httpsRRALPNs(srv)) + app.tlsApp.RegisterServerNames(echDomains, httpsRRALPNs(srv)) // nothing more to do here if there are no domains that qualify for // automatic HTTPS and there are no explicit TLS connection policies: @@ -553,10 +553,7 @@ func (app *App) makeRedirRoute(redirToPort uint, matcherSet MatcherSet) Route { func httpsRRALPNs(srv *Server) []string { // Automatic HTTPS runs before server provisioning fills in the default // protocols, so derive the effective set directly from the raw config here. - serverProtocols := srv.Protocols - if len(serverProtocols) == 0 { - serverProtocols = []string{"h1", "h2", "h3"} - } + serverProtocols := srv.protocolsWithDefaults() protocols := make(map[string]struct{}, len(serverProtocols)) if srv.ListenProtocols == nil { @@ -583,17 +580,17 @@ func httpsRRALPNs(srv *Server) []string { } } - alpn := make([]string, 0, 3) + alpn := make(map[string]struct{}, 3) if _, ok := protocols["h3"]; ok { - alpn = append(alpn, "h3") + alpn["h3"] = struct{}{} } if _, ok := protocols["h2"]; ok { - alpn = append(alpn, "h2") + alpn["h2"] = struct{}{} } if _, ok := protocols["h1"]; ok { - alpn = append(alpn, "http/1.1") + alpn["http/1.1"] = struct{}{} } - return alpn + return caddytls.OrderedHTTPSRRALPN(alpn) } // createAutomationPolicies ensures that automated certificates for this diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go index 41a8e55b010..ec9fe18aa8d 100644 --- a/modules/caddyhttp/server.go +++ b/modules/caddyhttp/server.go @@ -301,6 +301,8 @@ type Server struct { onStopFuncs []func(context.Context) error // TODO: Experimental (Nov. 2023) } +var defaultProtocols = []string{"h1", "h2", "h3"} + var ( ServerHeader = "Caddy" serverHeader = []string{ServerHeader} @@ -900,13 +902,14 @@ func (s *Server) logRequest( // protocol returns true if the protocol proto is configured/enabled. func (s *Server) protocol(proto string) bool { if s.ListenProtocols == nil { - if slices.Contains(s.Protocols, proto) { + if slices.Contains(s.protocolsWithDefaults(), proto) { return true } } else { + serverProtocols := s.protocolsWithDefaults() for _, lnProtocols := range s.ListenProtocols { for _, lnProtocol := range lnProtocols { - if lnProtocol == "" && slices.Contains(s.Protocols, proto) || lnProtocol == proto { + if lnProtocol == "" && slices.Contains(serverProtocols, proto) || lnProtocol == proto { return true } } @@ -916,6 +919,13 @@ func (s *Server) protocol(proto string) bool { return false } +func (s *Server) protocolsWithDefaults() []string { + if len(s.Protocols) == 0 { + return defaultProtocols + } + return s.Protocols +} + // Listeners returns the server's listeners. These are active listeners, // so calling Accept() or Close() on them will probably break things. // They are made available here for read-only purposes (e.g. Addr()) diff --git a/modules/caddytls/connpolicy.go b/modules/caddytls/connpolicy.go index c9258da4847..9597af35980 100644 --- a/modules/caddytls/connpolicy.go +++ b/modules/caddytls/connpolicy.go @@ -153,9 +153,9 @@ func (cp ConnectionPolicies) TLSConfig(ctx caddy.Context) *tls.Config { // in its config (remember, TLS connection policies are used by *other* apps to // run TLS servers) -- we skip names with placeholders if tlsApp.EncryptedClientHello.Publication == nil { - var echNames []string repl := caddy.NewReplacer() for _, p := range cp { + var echNames []string for _, m := range p.matchers { if sni, ok := m.(MatchServerName); ok { for _, name := range sni { @@ -164,8 +164,8 @@ func (cp ConnectionPolicies) TLSConfig(ctx caddy.Context) *tls.Config { } } } + tlsApp.RegisterServerNames(echNames, p.ALPN) } - tlsApp.RegisterServerNames(echNames) } tlsCfg.GetEncryptedClientHelloKeys = func(chi *tls.ClientHelloInfo) ([]tls.EncryptedClientHelloKey, error) { diff --git a/modules/caddytls/ech_dns_test.go b/modules/caddytls/ech_dns_test.go index 6f555acc904..7c337366ea9 100644 --- a/modules/caddytls/ech_dns_test.go +++ b/modules/caddytls/ech_dns_test.go @@ -11,17 +11,16 @@ import ( func TestRegisterServerNamesWithALPN(t *testing.T) { tlsApp := &TLS{ - serverNames: make(map[string]struct{}), - serverNameALPN: make(map[string]map[string]struct{}), - serverNamesMu: new(sync.Mutex), + serverNames: make(map[string]serverNameRegistration), + serverNamesMu: new(sync.Mutex), } - tlsApp.RegisterServerNamesWithALPN([]string{ + tlsApp.RegisterServerNames([]string{ "Example.com:443", "example.com", "127.0.0.1:443", }, []string{"h2", "http/1.1"}) - tlsApp.RegisterServerNamesWithALPN([]string{"EXAMPLE.COM"}, []string{"h3"}) + tlsApp.RegisterServerNames([]string{"EXAMPLE.COM"}, []string{"h3"}) got := tlsApp.alpnValuesForServerNames([]string{"example.com:443", "127.0.0.1:443"}) want := map[string][]string{ diff --git a/modules/caddytls/tls.go b/modules/caddytls/tls.go index 6a2f8f58773..e5f6e6fc003 100644 --- a/modules/caddytls/tls.go +++ b/modules/caddytls/tls.go @@ -141,9 +141,8 @@ type TLS struct { logger *zap.Logger events *caddyevents.App - serverNames map[string]struct{} - serverNameALPN map[string]map[string]struct{} - serverNamesMu *sync.Mutex + serverNames map[string]serverNameRegistration + serverNamesMu *sync.Mutex // set of subjects with managed certificates, // and hashes of manually-loaded certificates @@ -170,8 +169,7 @@ func (t *TLS) Provision(ctx caddy.Context) error { t.logger = ctx.Logger() repl := caddy.NewReplacer() t.managing, t.loaded = make(map[string]string), make(map[string]string) - t.serverNames = make(map[string]struct{}) - t.serverNameALPN = make(map[string]map[string]struct{}) + t.serverNames = make(map[string]serverNameRegistration) t.serverNamesMu = new(sync.Mutex) // set up default DNS module, if any, and make sure it implements all the @@ -651,24 +649,16 @@ func (t *TLS) managingWildcardFor(subj string, otherSubjsToManage map[string]str return false } -// RegisterServerNames registers the provided DNS names with the TLS app. -// This is currently used to auto-publish Encrypted ClientHello (ECH) -// configurations, if enabled. Use of this function by apps using the TLS -// app removes the need for the user to redundantly specify domain names -// in their configuration. This function separates hostname and port -// (keeping only the hotsname) and filters IP addresses, which can't be -// used with ECH. +// RegisterServerNames registers the provided DNS names with the TLS app and +// associates them with the given HTTPS RR ALPN values, if any. This is +// currently used to auto-publish Encrypted ClientHello (ECH) configurations, +// if enabled. Use of this function by apps using the TLS app removes the need +// for the user to redundantly specify domain names in their configuration. +// This function separates hostname and port, keeping only the hostname, and +// filters IP addresses which can't be used with ECH. // // EXPERIMENTAL: This function and its semantics/behavior are subject to change. -func (t *TLS) RegisterServerNames(dnsNames []string) { - t.RegisterServerNamesWithALPN(dnsNames, nil) -} - -// RegisterServerNamesWithALPN registers the provided DNS names with the TLS app -// and associates them with the given HTTPS RR ALPN values, if any. -// -// EXPERIMENTAL: This function and its semantics/behavior are subject to change. -func (t *TLS) RegisterServerNamesWithALPN(dnsNames []string, alpnValues []string) { +func (t *TLS) RegisterServerNames(dnsNames, alpnValues []string) { t.serverNamesMu.Lock() defer t.serverNamesMu.Unlock() @@ -681,21 +671,24 @@ func (t *TLS) RegisterServerNamesWithALPN(dnsNames []string, alpnValues []string if host == "" || certmagic.SubjectIsIP(host) { continue } - t.serverNames[host] = struct{}{} + + registration := t.serverNames[host] if len(alpnValues) == 0 { + t.serverNames[host] = registration continue } - if t.serverNameALPN[host] == nil { - t.serverNameALPN[host] = make(map[string]struct{}, len(alpnValues)) + if registration.alpnValues == nil { + registration.alpnValues = make(map[string]struct{}, len(alpnValues)) } for _, alpn := range alpnValues { if alpn == "" { continue } - t.serverNameALPN[host][alpn] = struct{}{} + registration.alpnValues[alpn] = struct{}{} } + t.serverNames[host] = registration } } @@ -714,22 +707,23 @@ func (t *TLS) alpnValuesForServerNames(dnsNames []string) map[string][]string { continue } - alpnSet := t.serverNameALPN[host] - if len(alpnSet) == 0 { + registration, ok := t.serverNames[host] + if !ok || len(registration.alpnValues) == 0 { continue } - result[host] = orderedHTTPSRRALPN(alpnSet) + result[host] = OrderedHTTPSRRALPN(registration.alpnValues) } return result } -func orderedHTTPSRRALPN(alpnSet map[string]struct{}) []string { +// OrderedHTTPSRRALPN returns the HTTPS RR ALPN values in preferred order. +func OrderedHTTPSRRALPN(alpnSet map[string]struct{}) []string { if len(alpnSet) == 0 { return nil } - knownOrder := []string{"h3", "h2", "http/1.1"} + knownOrder := append([]string{"h3"}, defaultALPN...) ordered := make([]string, 0, len(alpnSet)) seen := make(map[string]struct{}, len(alpnSet)) @@ -756,6 +750,10 @@ func orderedHTTPSRRALPN(alpnSet map[string]struct{}) []string { return append(ordered, remaining...) } +type serverNameRegistration struct { + alpnValues map[string]struct{} +} + // HandleHTTPChallenge ensures that the ACME HTTP challenge or ZeroSSL HTTP // validation request is handled for the certificate named by r.Host, if it // is an HTTP challenge request. It requires that the automation policy for From 710902ddc3767a43a70e4e6159001675fbb9604a Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Sat, 18 Apr 2026 11:31:12 +1000 Subject: [PATCH 3/3] http: centralise effective protocol resolution for HTTPS RR ALPN --- modules/caddyhttp/app.go | 35 ++------------------- modules/caddyhttp/autohttps.go | 35 ++------------------- modules/caddyhttp/autohttps_test.go | 3 +- modules/caddyhttp/server.go | 48 +++++++++++++++++++++++------ 4 files changed, 45 insertions(+), 76 deletions(-) diff --git a/modules/caddyhttp/app.go b/modules/caddyhttp/app.go index 7b7b02e5db1..c31f399ba2b 100644 --- a/modules/caddyhttp/app.go +++ b/modules/caddyhttp/app.go @@ -20,10 +20,8 @@ import ( "crypto/tls" "errors" "fmt" - "maps" "net" "net/http" - "slices" "strconv" "sync" "time" @@ -236,12 +234,7 @@ func (app *App) Provision(ctx caddy.Context) error { // if no protocols configured explicitly, enable all except h2c if len(srv.Protocols) == 0 { - srv.Protocols = slices.Clone(srv.protocolsWithDefaults()) - } - - srvProtocolsUnique := map[string]struct{}{} - for _, srvProtocol := range srv.Protocols { - srvProtocolsUnique[srvProtocol] = struct{}{} + srv.Protocols = srv.protocolsWithDefaults() } if srv.ListenProtocols != nil { @@ -252,31 +245,7 @@ func (app *App) Provision(ctx caddy.Context) error { for i, lnProtocols := range srv.ListenProtocols { if lnProtocols != nil { - // populate empty listen protocols with server protocols - lnProtocolsDefault := false - var lnProtocolsInclude []string - srvProtocolsInclude := maps.Clone(srvProtocolsUnique) - - // keep existing listener protocols unless they are empty - for _, lnProtocol := range lnProtocols { - if lnProtocol == "" { - lnProtocolsDefault = true - } else { - lnProtocolsInclude = append(lnProtocolsInclude, lnProtocol) - delete(srvProtocolsInclude, lnProtocol) - } - } - - // append server protocols to listener protocols if any listener protocols were empty - if lnProtocolsDefault { - for _, srvProtocol := range srv.Protocols { - if _, ok := srvProtocolsInclude[srvProtocol]; ok { - lnProtocolsInclude = append(lnProtocolsInclude, srvProtocol) - } - } - } - - srv.ListenProtocols[i] = lnProtocolsInclude + srv.ListenProtocols[i] = srv.listenerProtocolsWithDefaults(lnProtocols) } } } diff --git a/modules/caddyhttp/autohttps.go b/modules/caddyhttp/autohttps.go index 64240d9f473..7ba98ab2fae 100644 --- a/modules/caddyhttp/autohttps.go +++ b/modules/caddyhttp/autohttps.go @@ -551,43 +551,14 @@ func (app *App) makeRedirRoute(redirToPort uint, matcherSet MatcherSet) Route { } func httpsRRALPNs(srv *Server) []string { - // Automatic HTTPS runs before server provisioning fills in the default - // protocols, so derive the effective set directly from the raw config here. - serverProtocols := srv.protocolsWithDefaults() - - protocols := make(map[string]struct{}, len(serverProtocols)) - if srv.ListenProtocols == nil { - for _, protocol := range serverProtocols { - protocols[protocol] = struct{}{} - } - } else { - for _, lnProtocols := range srv.ListenProtocols { - if len(lnProtocols) == 0 { - for _, protocol := range serverProtocols { - protocols[protocol] = struct{}{} - } - continue - } - for _, protocol := range lnProtocols { - if protocol == "" { - for _, inherited := range serverProtocols { - protocols[inherited] = struct{}{} - } - continue - } - protocols[protocol] = struct{}{} - } - } - } - alpn := make(map[string]struct{}, 3) - if _, ok := protocols["h3"]; ok { + if srv.protocol("h3") { alpn["h3"] = struct{}{} } - if _, ok := protocols["h2"]; ok { + if srv.protocol("h2") { alpn["h2"] = struct{}{} } - if _, ok := protocols["h1"]; ok { + if srv.protocol("h1") { alpn["http/1.1"] = struct{}{} } return caddytls.OrderedHTTPSRRALPN(alpn) diff --git a/modules/caddyhttp/autohttps_test.go b/modules/caddyhttp/autohttps_test.go index 71452621405..89843844d9f 100644 --- a/modules/caddyhttp/autohttps_test.go +++ b/modules/caddyhttp/autohttps_test.go @@ -22,7 +22,8 @@ func TestHTTPSRRALPNsListenProtocolOverrides(t *testing.T) { ListenProtocols: [][]string{ {"h1"}, nil, - {"h2c", "h3"}, + {}, + {"h3", ""}, }, } diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go index ec9fe18aa8d..f356c5e1b0f 100644 --- a/modules/caddyhttp/server.go +++ b/modules/caddyhttp/server.go @@ -902,18 +902,13 @@ func (s *Server) logRequest( // protocol returns true if the protocol proto is configured/enabled. func (s *Server) protocol(proto string) bool { if s.ListenProtocols == nil { - if slices.Contains(s.protocolsWithDefaults(), proto) { + return slices.Contains(s.protocolsWithDefaults(), proto) + } + + for _, lnProtocols := range s.ListenProtocols { + if slices.Contains(s.listenerProtocolsWithDefaults(lnProtocols), proto) { return true } - } else { - serverProtocols := s.protocolsWithDefaults() - for _, lnProtocols := range s.ListenProtocols { - for _, lnProtocol := range lnProtocols { - if lnProtocol == "" && slices.Contains(serverProtocols, proto) || lnProtocol == proto { - return true - } - } - } } return false @@ -926,6 +921,39 @@ func (s *Server) protocolsWithDefaults() []string { return s.Protocols } +func (s *Server) listenerProtocolsWithDefaults(lnProtocols []string) []string { + serverProtocols := s.protocolsWithDefaults() + if len(lnProtocols) == 0 { + return serverProtocols + } + + lnProtocolsDefault := false + lnProtocolsInclude := make([]string, 0, len(lnProtocols)+len(serverProtocols)) + srvProtocolsInclude := make(map[string]struct{}, len(serverProtocols)) + for _, srvProtocol := range serverProtocols { + srvProtocolsInclude[srvProtocol] = struct{}{} + } + + for _, lnProtocol := range lnProtocols { + if lnProtocol == "" { + lnProtocolsDefault = true + continue + } + lnProtocolsInclude = append(lnProtocolsInclude, lnProtocol) + delete(srvProtocolsInclude, lnProtocol) + } + + if lnProtocolsDefault { + for _, srvProtocol := range serverProtocols { + if _, ok := srvProtocolsInclude[srvProtocol]; ok { + lnProtocolsInclude = append(lnProtocolsInclude, srvProtocol) + } + } + } + + return lnProtocolsInclude +} + // Listeners returns the server's listeners. These are active listeners, // so calling Accept() or Close() on them will probably break things. // They are made available here for read-only purposes (e.g. Addr())