diff --git a/modules/caddyhttp/app.go b/modules/caddyhttp/app.go index 571ac496ea1..bc2b896cd17 100644 --- a/modules/caddyhttp/app.go +++ b/modules/caddyhttp/app.go @@ -20,7 +20,6 @@ import ( "crypto/tls" "errors" "fmt" - "maps" "net" "net/http" "strconv" @@ -241,12 +240,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"} - } - - srvProtocolsUnique := map[string]struct{}{} - for _, srvProtocol := range srv.Protocols { - srvProtocolsUnique[srvProtocol] = struct{}{} + srv.Protocols = srv.protocolsWithDefaults() } if srv.ListenProtocols != nil { @@ -257,31 +251,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 4d975900002..4e5b85f651d 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.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: @@ -574,6 +574,20 @@ func (app *App) makeRedirRoute(redirToPort uint, matcherSet MatcherSet) Route { } } +func httpsRRALPNs(srv *Server) []string { + alpn := make(map[string]struct{}, 3) + if srv.protocol("h3") { + alpn["h3"] = struct{}{} + } + if srv.protocol("h2") { + alpn["h2"] = struct{}{} + } + if srv.protocol("h1") { + alpn["http/1.1"] = struct{}{} + } + return caddytls.OrderedHTTPSRRALPN(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 index b5cc64d9440..89843844d9f 100644 --- a/modules/caddyhttp/autohttps_test.go +++ b/modules/caddyhttp/autohttps_test.go @@ -1,44 +1,47 @@ package caddyhttp import ( + "reflect" "testing" - - "github.com/caddyserver/caddy/v2" ) -func TestRecordAutoHTTPSRedirectAddressPrefersHTTPSPort(t *testing.T) { - app := &App{HTTPSPort: 443} - redirDomains := make(map[string][]caddy.NetworkAddress) +func TestHTTPSRRALPNsDefaultProtocols(t *testing.T) { + srv := &Server{} - app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", StartPort: 2345, EndPort: 2345}) - app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", StartPort: 443, EndPort: 443}) - app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", StartPort: 8443, EndPort: 8443}) + got := httpsRRALPNs(srv) + want := []string{"h3", "h2", "http/1.1"} - got := redirDomains["example.com"] - if len(got) != 1 { - t.Fatalf("expected 1 redirect address, got %d: %#v", len(got), got) - } - if got[0].StartPort != 443 { - t.Fatalf("expected redirect to prefer HTTPS port 443, got %#v", got[0]) + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected ALPN values: got %v want %v", got, want) } } -func TestRecordAutoHTTPSRedirectAddressKeepsAllBindAddressesOnWinningPort(t *testing.T) { - app := &App{HTTPSPort: 443} - redirDomains := make(map[string][]caddy.NetworkAddress) +func TestHTTPSRRALPNsListenProtocolOverrides(t *testing.T) { + srv := &Server{ + Protocols: []string{"h1", "h2"}, + ListenProtocols: [][]string{ + {"h1"}, + nil, + {}, + {"h3", ""}, + }, + } - app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", Host: "10.0.0.189", StartPort: 8443, EndPort: 8443}) - app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", Host: "10.0.0.189", StartPort: 443, EndPort: 443}) - app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", Host: "2603:c024:8002:9500:9eb:e5d3:3975:d056", StartPort: 443, EndPort: 443}) + got := httpsRRALPNs(srv) + want := []string{"h3", "h2", "http/1.1"} - got := redirDomains["example.com"] - if len(got) != 2 { - t.Fatalf("expected 2 redirect addresses for both bind addresses on the winning port, got %d: %#v", len(got), got) + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected ALPN values: got %v want %v", got, want) } - if got[0].StartPort != 443 || got[1].StartPort != 443 { - t.Fatalf("expected both redirect addresses to stay on HTTPS port 443, got %#v", got) +} + +func TestHTTPSRRALPNsIgnoresH2COnly(t *testing.T) { + srv := &Server{ + Protocols: []string{"h2c"}, } - if got[0].Host != "10.0.0.189" || got[1].Host != "2603:c024:8002:9500:9eb:e5d3:3975:d056" { - t.Fatalf("expected both bind addresses to be preserved, got %#v", got) + + got := httpsRRALPNs(srv) + if len(got) != 0 { + t.Fatalf("unexpected ALPN values: got %v want none", got) } } diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go index 3005bc27370..b8f31f936b5 100644 --- a/modules/caddyhttp/server.go +++ b/modules/caddyhttp/server.go @@ -300,6 +300,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} @@ -899,20 +901,56 @@ 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) { + return slices.Contains(s.protocolsWithDefaults(), proto) + } + + for _, lnProtocols := range s.ListenProtocols { + if slices.Contains(s.listenerProtocolsWithDefaults(lnProtocols), proto) { return true } - } else { - for _, lnProtocols := range s.ListenProtocols { - for _, lnProtocol := range lnProtocols { - if lnProtocol == "" && slices.Contains(s.Protocols, proto) || lnProtocol == proto { - return true - } + } + + return false +} + +func (s *Server) protocolsWithDefaults() []string { + if len(s.Protocols) == 0 { + return defaultProtocols + } + 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 false + return lnProtocolsInclude } // Listeners returns the server's listeners. These are active listeners, 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.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..7c337366ea9 --- /dev/null +++ b/modules/caddytls/ech_dns_test.go @@ -0,0 +1,65 @@ +package caddytls + +import ( + "encoding/base64" + "reflect" + "sync" + "testing" + + "github.com/libdns/libdns" +) + +func TestRegisterServerNamesWithALPN(t *testing.T) { + tlsApp := &TLS{ + serverNames: make(map[string]serverNameRegistration), + serverNamesMu: new(sync.Mutex), + } + + tlsApp.RegisterServerNames([]string{ + "Example.com:443", + "example.com", + "127.0.0.1:443", + }, []string{"h2", "http/1.1"}) + tlsApp.RegisterServerNames([]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..e5f6e6fc003 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,7 +141,7 @@ type TLS struct { logger *zap.Logger events *caddyevents.App - serverNames map[string]struct{} + serverNames map[string]serverNameRegistration serverNamesMu *sync.Mutex // set of subjects with managed certificates, @@ -168,7 +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.serverNames = make(map[string]serverNameRegistration) t.serverNamesMu = new(sync.Mutex) // set up default DNS module, if any, and make sure it implements all the @@ -648,27 +649,109 @@ 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) { +func (t *TLS) RegisterServerNames(dnsNames, alpnValues []string) { t.serverNamesMu.Lock() + defer t.serverNamesMu.Unlock() + 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 == "" || certmagic.SubjectIsIP(host) { + continue + } + + registration := t.serverNames[host] + + if len(alpnValues) == 0 { + t.serverNames[host] = registration + continue + } + + if registration.alpnValues == nil { + registration.alpnValues = make(map[string]struct{}, len(alpnValues)) + } + for _, alpn := range alpnValues { + if alpn == "" { + continue + } + registration.alpnValues[alpn] = struct{}{} } + t.serverNames[host] = registration } - t.serverNamesMu.Unlock() +} + +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 + } + host = strings.ToLower(strings.TrimSpace(host)) + if host == "" { + continue + } + + registration, ok := t.serverNames[host] + if !ok || len(registration.alpnValues) == 0 { + continue + } + result[host] = OrderedHTTPSRRALPN(registration.alpnValues) + } + + return result +} + +// OrderedHTTPSRRALPN returns the HTTPS RR ALPN values in preferred order. +func OrderedHTTPSRRALPN(alpnSet map[string]struct{}) []string { + if len(alpnSet) == 0 { + return nil + } + + knownOrder := append([]string{"h3"}, defaultALPN...) + 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) + } + slices.Sort(remaining) + + return append(ordered, remaining...) +} + +type serverNameRegistration struct { + alpnValues map[string]struct{} } // HandleHTTPChallenge ensures that the ACME HTTP challenge or ZeroSSL HTTP