tls: add alpn to managed HTTPS records (#7653)
Some checks are pending
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Waiting to run
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Waiting to run
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Waiting to run
Tests / test (s390x on IBM Z) (push) Waiting to run
Tests / goreleaser-check (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, aix) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, linux) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, windows) (push) Waiting to run
Lint / lint (push) Waiting to run
Lint / lint-1 (push) Waiting to run
Lint / lint-2 (push) Waiting to run
Lint / govulncheck (push) Waiting to run
Lint / dependency-review (push) Waiting to run
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Waiting to run

* tls: add alpn to managed HTTPS records

* 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

* http: centralise effective protocol resolution for HTTPS RR ALPN
This commit is contained in:
Zen Dodd 2026-05-10 13:10:29 +10:00 committed by GitHub
parent 9c78b97f9e
commit 5e76b5ee43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 286 additions and 94 deletions

View file

@ -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)
}
}
}

View file

@ -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

View file

@ -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)
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 := 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)
func TestHTTPSRRALPNsListenProtocolOverrides(t *testing.T) {
srv := &Server{
Protocols: []string{"h1", "h2"},
ListenProtocols: [][]string{
{"h1"},
nil,
{},
{"h3", ""},
},
}
if got[0].StartPort != 443 || got[1].StartPort != 443 {
t.Fatalf("expected both redirect addresses to stay on HTTPS port 443, got %#v", got)
}
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)
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)
}
}

View file

@ -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,22 +901,58 @@ 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 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())

View file

@ -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) {

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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
}
}
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{}{}
}
}
t.serverNamesMu.Unlock()
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