tls: add alpn to managed HTTPS records

This commit is contained in:
Zen Dodd 2026-04-15 09:36:10 +10:00
parent 7586e68e27
commit aacb430d87
No known key found for this signature in database
GPG Key ID: 6909546B2C52EC2D
5 changed files with 275 additions and 13 deletions

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

View File

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

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

View File

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