diff --git a/modules/caddyhttp/autohttps.go b/modules/caddyhttp/autohttps.go index 32e9f106d..05ef08e1a 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 000000000..714526214 --- /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 b915fcfbe..4a48769d8 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 000000000..6f555acc9 --- /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 34ffbf62d..6a2f8f587 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 } - 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 + } + 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{}{} } } - 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 + } + + 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) + } + slices.Sort(remaining) + + return append(ordered, remaining...) } // HandleHTTPChallenge ensures that the ACME HTTP challenge or ZeroSSL HTTP