Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 11, 12:15 UTC
tls: add alpn to managed HTTPS records (#7653)
* 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
Zen Dodd committed
May 10, 2026 at 13:10 UTC
5e76b5ee43e8ec9e78d07665cdd75515b4c07acd
8 files changed
+282
-90
modules/caddyhttp/app.go
+2
-32
@@ -20,7 +20,6 @@ import (
20
"crypto/tls"
21
"errors"
22
"fmt"
23
- "maps"
23
"net"
24
"net/http"
25
"strconv"
@@ -241,12 +240,7 @@ func (app *App) Provision(ctx caddy.Context) error {
240
241
// if no protocols configured explicitly, enable all except h2c
242
if len(srv.Protocols) == 0 {
244
- srv.Protocols = []string{"h1", "h2", "h3"}
245
- }
246
-
247
- srvProtocolsUnique := map[string]struct{}{}
248
- for _, srvProtocol := range srv.Protocols {
249
- srvProtocolsUnique[srvProtocol] = struct{}{}
243
+ srv.Protocols = srv.protocolsWithDefaults()
244
}
245
246
if srv.ListenProtocols != nil {
@@ -257,31 +251,7 @@ func (app *App) Provision(ctx caddy.Context) error {
251
252
for i, lnProtocols := range srv.ListenProtocols {
253
if lnProtocols != nil {
260
- // populate empty listen protocols with server protocols
261
- lnProtocolsDefault := false
262
- var lnProtocolsInclude []string
263
- srvProtocolsInclude := maps.Clone(srvProtocolsUnique)
264
-
265
- // keep existing listener protocols unless they are empty
266
- for _, lnProtocol := range lnProtocols {
267
- if lnProtocol == "" {
268
- lnProtocolsDefault = true
269
- } else {
270
- lnProtocolsInclude = append(lnProtocolsInclude, lnProtocol)
271
- delete(srvProtocolsInclude, lnProtocol)
272
- }
273
- }
274
-
275
- // append server protocols to listener protocols if any listener protocols were empty
276
- if lnProtocolsDefault {
277
- for _, srvProtocol := range srv.Protocols {
278
- if _, ok := srvProtocolsInclude[srvProtocol]; ok {
279
- lnProtocolsInclude = append(lnProtocolsInclude, srvProtocol)
280
- }
281
- }
282
- }
283
-
284
- srv.ListenProtocols[i] = lnProtocolsInclude
254
+ srv.ListenProtocols[i] = srv.listenerProtocolsWithDefaults(lnProtocols)
255
}
256
}
257
}
modules/caddyhttp/autohttps.go
+15
-1
@@ -173,7 +173,7 @@ func (app *App) automaticHTTPSPhase1(ctx caddy.Context, repl *caddy.Replacer) er
173
for d := range serverDomainSet {
174
echDomains = append(echDomains, d)
175
}
176
- app.tlsApp.RegisterServerNames(echDomains)
176
+ app.tlsApp.RegisterServerNames(echDomains, httpsRRALPNs(srv))
177
178
// nothing more to do here if there are no domains that qualify for
179
// automatic HTTPS and there are no explicit TLS connection policies:
@@ -574,6 +574,20 @@ func (app *App) makeRedirRoute(redirToPort uint, matcherSet MatcherSet) Route {
574
}
575
}
576
577
+func httpsRRALPNs(srv *Server) []string {
578
+ alpn := make(map[string]struct{}, 3)
579
+ if srv.protocol("h3") {
580
+ alpn["h3"] = struct{}{}
581
+ }
582
+ if srv.protocol("h2") {
583
+ alpn["h2"] = struct{}{}
584
+ }
585
+ if srv.protocol("h1") {
586
+ alpn["http/1.1"] = struct{}{}
587
+ }
588
+ return caddytls.OrderedHTTPSRRALPN(alpn)
589
+}
590
+
591
// createAutomationPolicies ensures that automated certificates for this
592
// app are managed properly. This adds up to two automation policies:
593
// one for the public names, and one for the internal names. If a catch-all
modules/caddyhttp/autohttps_test.go
+30
-27
@@ -1,44 +1,47 @@
1
package caddyhttp
2
3
import (
4
+ "reflect"
5
"testing"
5
-
6
- "github.com/caddyserver/caddy/v2"
6
)
7
9
-func TestRecordAutoHTTPSRedirectAddressPrefersHTTPSPort(t *testing.T) {
10
- app := &App{HTTPSPort: 443}
11
- redirDomains := make(map[string][]caddy.NetworkAddress)
8
+func TestHTTPSRRALPNsDefaultProtocols(t *testing.T) {
9
+ srv := &Server{}
10
13
- app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", StartPort: 2345, EndPort: 2345})
14
- app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", StartPort: 443, EndPort: 443})
15
- app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", StartPort: 8443, EndPort: 8443})
11
+ got := httpsRRALPNs(srv)
12
+ want := []string{"h3", "h2", "http/1.1"}
13
17
- got := redirDomains["example.com"]
18
- if len(got) != 1 {
19
- t.Fatalf("expected 1 redirect address, got %d: %#v", len(got), got)
20
- }
21
- if got[0].StartPort != 443 {
22
- t.Fatalf("expected redirect to prefer HTTPS port 443, got %#v", got[0])
14
+ if !reflect.DeepEqual(got, want) {
15
+ t.Fatalf("unexpected ALPN values: got %v want %v", got, want)
16
}
17
}
18
26
-func TestRecordAutoHTTPSRedirectAddressKeepsAllBindAddressesOnWinningPort(t *testing.T) {
27
- app := &App{HTTPSPort: 443}
28
- redirDomains := make(map[string][]caddy.NetworkAddress)
19
+func TestHTTPSRRALPNsListenProtocolOverrides(t *testing.T) {
20
+ srv := &Server{
21
+ Protocols: []string{"h1", "h2"},
22
+ ListenProtocols: [][]string{
23
+ {"h1"},
24
+ nil,
25
+ {},
26
+ {"h3", ""},
27
+ },
28
+ }
29
30
- app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", Host: "10.0.0.189", StartPort: 8443, EndPort: 8443})
31
- app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", Host: "10.0.0.189", StartPort: 443, EndPort: 443})
32
- app.recordAutoHTTPSRedirectAddress(redirDomains, "example.com", caddy.NetworkAddress{Network: "tcp", Host: "2603:c024:8002:9500:9eb:e5d3:3975:d056", StartPort: 443, EndPort: 443})
30
+ got := httpsRRALPNs(srv)
31
+ want := []string{"h3", "h2", "http/1.1"}
32
34
- got := redirDomains["example.com"]
35
- if len(got) != 2 {
36
- t.Fatalf("expected 2 redirect addresses for both bind addresses on the winning port, got %d: %#v", len(got), got)
33
+ if !reflect.DeepEqual(got, want) {
34
+ t.Fatalf("unexpected ALPN values: got %v want %v", got, want)
35
}
38
- if got[0].StartPort != 443 || got[1].StartPort != 443 {
39
- t.Fatalf("expected both redirect addresses to stay on HTTPS port 443, got %#v", got)
36
+}
37
+
38
+func TestHTTPSRRALPNsIgnoresH2COnly(t *testing.T) {
39
+ srv := &Server{
40
+ Protocols: []string{"h2c"},
41
}
41
- if got[0].Host != "10.0.0.189" || got[1].Host != "2603:c024:8002:9500:9eb:e5d3:3975:d056" {
42
- t.Fatalf("expected both bind addresses to be preserved, got %#v", got)
42
+
43
+ got := httpsRRALPNs(srv)
44
+ if len(got) != 0 {
45
+ t.Fatalf("unexpected ALPN values: got %v want none", got)
46
}
47
}
modules/caddyhttp/server.go
+46
-8
@@ -300,6 +300,8 @@ type Server struct {
300
onStopFuncs []func(context.Context) error // TODO: Experimental (Nov. 2023)
301
}
302
303
+var defaultProtocols = []string{"h1", "h2", "h3"}
304
+
305
var (
306
ServerHeader = "Caddy"
307
serverHeader = []string{ServerHeader}
@@ -899,20 +901,56 @@ func (s *Server) logRequest(
901
// protocol returns true if the protocol proto is configured/enabled.
902
func (s *Server) protocol(proto string) bool {
903
if s.ListenProtocols == nil {
902
- if slices.Contains(s.Protocols, proto) {
904
+ return slices.Contains(s.protocolsWithDefaults(), proto)
905
+ }
906
+
907
+ for _, lnProtocols := range s.ListenProtocols {
908
+ if slices.Contains(s.listenerProtocolsWithDefaults(lnProtocols), proto) {
909
return true
910
}
905
- } else {
906
- for _, lnProtocols := range s.ListenProtocols {
907
- for _, lnProtocol := range lnProtocols {
908
- if lnProtocol == "" && slices.Contains(s.Protocols, proto) || lnProtocol == proto {
909
- return true
910
- }
911
+ }
912
+
913
+ return false
914
+}
915
+
916
+func (s *Server) protocolsWithDefaults() []string {
917
+ if len(s.Protocols) == 0 {
918
+ return defaultProtocols
919
+ }
920
+ return s.Protocols
921
+}
922
+
923
+func (s *Server) listenerProtocolsWithDefaults(lnProtocols []string) []string {
924
+ serverProtocols := s.protocolsWithDefaults()
925
+ if len(lnProtocols) == 0 {
926
+ return serverProtocols
927
+ }
928
+
929
+ lnProtocolsDefault := false
930
+ lnProtocolsInclude := make([]string, 0, len(lnProtocols)+len(serverProtocols))
931
+ srvProtocolsInclude := make(map[string]struct{}, len(serverProtocols))
932
+ for _, srvProtocol := range serverProtocols {
933
+ srvProtocolsInclude[srvProtocol] = struct{}{}
934
+ }
935
+
936
+ for _, lnProtocol := range lnProtocols {
937
+ if lnProtocol == "" {
938
+ lnProtocolsDefault = true
939
+ continue
940
+ }
941
+ lnProtocolsInclude = append(lnProtocolsInclude, lnProtocol)
942
+ delete(srvProtocolsInclude, lnProtocol)
943
+ }
944
+
945
+ if lnProtocolsDefault {
946
+ for _, srvProtocol := range serverProtocols {
947
+ if _, ok := srvProtocolsInclude[srvProtocol]; ok {
948
+ lnProtocolsInclude = append(lnProtocolsInclude, srvProtocol)
949
}
950
}
951
}
952
915
- return false
953
+ return lnProtocolsInclude
954
}
955
956
// Listeners returns the server's listeners. These are active listeners,
modules/caddytls/connpolicy.go
+2
-2
@@ -153,9 +153,9 @@ func (cp ConnectionPolicies) TLSConfig(ctx caddy.Context) *tls.Config {
153
// in its config (remember, TLS connection policies are used by *other* apps to
154
// run TLS servers) -- we skip names with placeholders
155
if tlsApp.EncryptedClientHello.Publication == nil {
156
- var echNames []string
156
repl := caddy.NewReplacer()
157
for _, p := range cp {
158
+ var echNames []string
159
for _, m := range p.matchers {
160
if sni, ok := m.(MatchServerName); ok {
161
for _, name := range sni {
@@ -164,8 +164,8 @@ func (cp ConnectionPolicies) TLSConfig(ctx caddy.Context) *tls.Config {
164
}
165
}
166
}
167
+ tlsApp.RegisterServerNames(echNames, p.ALPN)
168
}
168
- tlsApp.RegisterServerNames(echNames)
169
}
170
171
tlsCfg.GetEncryptedClientHelloKeys = func(chi *tls.ClientHelloInfo) ([]tls.EncryptedClientHelloKey, error) {
modules/caddytls/ech.go
+26
-7
@@ -440,6 +440,10 @@ func (t *TLS) publishECHConfigs(logger *zap.Logger) error {
440
zap.Strings("domains", dnsNamesToPublish),
441
zap.Uint8s("config_ids", configIDs))
442
443
+ if dnsPublisher, ok := publisher.(*ECHDNSPublisher); ok {
444
+ dnsPublisher.alpnByDomain = t.alpnValuesForServerNames(dnsNamesToPublish)
445
+ }
446
+
447
// publish this ECH config list with this publisher
448
pubTime := time.Now()
449
err := publisher.PublishECHConfigList(t.ctx, dnsNamesToPublish, echCfgListBin)
@@ -776,7 +780,8 @@ type ECHDNSPublisher struct {
780
ProviderRaw json.RawMessage `json:"provider,omitempty" caddy:"namespace=dns.providers inline_key=name"`
781
provider ECHDNSProvider
782
779
- logger *zap.Logger
783
+ alpnByDomain map[string][]string
784
+ logger *zap.Logger
785
}
786
787
// CaddyModule returns the Caddy module information.
@@ -872,12 +877,7 @@ nextName:
877
continue
878
}
879
params := httpsRec.Params
875
- if params == nil {
876
- params = make(libdns.SvcParams)
877
- }
878
-
879
- // overwrite only the "ech" SvcParamKey
880
- params["ech"] = []string{base64.StdEncoding.EncodeToString(configListBin)}
880
+ params = dnsPub.publishedSvcParams(domain, params, configListBin)
881
882
// publish record
883
_, err = dnsPub.provider.SetRecords(ctx, zone, []libdns.Record{
@@ -903,6 +903,25 @@ nextName:
903
return nil
904
}
905
906
+func (dnsPub *ECHDNSPublisher) publishedSvcParams(domain string, existing libdns.SvcParams, configListBin []byte) libdns.SvcParams {
907
+ params := make(libdns.SvcParams, len(existing)+2)
908
+ for key, values := range existing {
909
+ params[key] = append([]string(nil), values...)
910
+ }
911
+
912
+ params["ech"] = []string{base64.StdEncoding.EncodeToString(configListBin)}
913
+
914
+ if len(dnsPub.alpnByDomain) == 0 {
915
+ return params
916
+ }
917
+
918
+ if alpn := dnsPub.alpnByDomain[strings.ToLower(domain)]; len(alpn) > 0 {
919
+ params["alpn"] = append([]string(nil), alpn...)
920
+ }
921
+
922
+ return params
923
+}
924
+
925
// echConfig represents an ECHConfig from the specification,
926
// [draft-ietf-tls-esni-22](https://www.ietf.org/archive/id/draft-ietf-tls-esni-22.html).
927
type echConfig struct {
modules/caddytls/ech_dns_test.go
new
+65
@@ -0,0 +1,65 @@
1
+package caddytls
2
+
3
+import (
4
+ "encoding/base64"
5
+ "reflect"
6
+ "sync"
7
+ "testing"
8
+
9
+ "github.com/libdns/libdns"
10
+)
11
+
12
+func TestRegisterServerNamesWithALPN(t *testing.T) {
13
+ tlsApp := &TLS{
14
+ serverNames: make(map[string]serverNameRegistration),
15
+ serverNamesMu: new(sync.Mutex),
16
+ }
17
+
18
+ tlsApp.RegisterServerNames([]string{
19
+ "Example.com:443",
20
+ "example.com",
21
+ "127.0.0.1:443",
22
+ }, []string{"h2", "http/1.1"})
23
+ tlsApp.RegisterServerNames([]string{"EXAMPLE.COM"}, []string{"h3"})
24
+
25
+ got := tlsApp.alpnValuesForServerNames([]string{"example.com:443", "127.0.0.1:443"})
26
+ want := map[string][]string{
27
+ "example.com": {"h3", "h2", "http/1.1"},
28
+ }
29
+
30
+ if !reflect.DeepEqual(got, want) {
31
+ t.Fatalf("unexpected ALPN values: got %#v want %#v", got, want)
32
+ }
33
+}
34
+
35
+func TestECHDNSPublisherPublishedSvcParams(t *testing.T) {
36
+ dnsPub := &ECHDNSPublisher{
37
+ alpnByDomain: map[string][]string{
38
+ "example.com": {"h3", "h2", "http/1.1"},
39
+ },
40
+ }
41
+
42
+ existing := libdns.SvcParams{
43
+ "alpn": {"h2"},
44
+ "ipv4hint": {"203.0.113.10"},
45
+ }
46
+
47
+ got := dnsPub.publishedSvcParams("Example.com", existing, []byte{0x01, 0x02, 0x03})
48
+
49
+ if !reflect.DeepEqual(existing["alpn"], []string{"h2"}) {
50
+ t.Fatalf("existing params mutated: got %v", existing["alpn"])
51
+ }
52
+
53
+ if !reflect.DeepEqual(got["alpn"], []string{"h3", "h2", "http/1.1"}) {
54
+ t.Fatalf("unexpected ALPN params: got %v", got["alpn"])
55
+ }
56
+
57
+ if !reflect.DeepEqual(got["ipv4hint"], []string{"203.0.113.10"}) {
58
+ t.Fatalf("unexpected preserved params: got %v", got["ipv4hint"])
59
+ }
60
+
61
+ wantECH := base64.StdEncoding.EncodeToString([]byte{0x01, 0x02, 0x03})
62
+ if !reflect.DeepEqual(got["ech"], []string{wantECH}) {
63
+ t.Fatalf("unexpected ECH params: got %v want %v", got["ech"], wantECH)
64
+ }
65
+}
modules/caddytls/tls.go
+96
-13
@@ -23,6 +23,7 @@ import (
23
"net"
24
"net/http"
25
"runtime/debug"
26
+ "slices"
27
"strings"
28
"sync"
29
"time"
@@ -140,7 +141,7 @@ type TLS struct {
141
logger *zap.Logger
142
events *caddyevents.App
143
143
- serverNames map[string]struct{}
144
+ serverNames map[string]serverNameRegistration
145
serverNamesMu *sync.Mutex
146
147
// set of subjects with managed certificates,
@@ -168,7 +169,7 @@ func (t *TLS) Provision(ctx caddy.Context) error {
169
t.logger = ctx.Logger()
170
repl := caddy.NewReplacer()
171
t.managing, t.loaded = make(map[string]string), make(map[string]string)
171
- t.serverNames = make(map[string]struct{})
172
+ t.serverNames = make(map[string]serverNameRegistration)
173
t.serverNamesMu = new(sync.Mutex)
174
175
// 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
649
return false
650
}
651
651
-// RegisterServerNames registers the provided DNS names with the TLS app.
652
-// This is currently used to auto-publish Encrypted ClientHello (ECH)
653
-// configurations, if enabled. Use of this function by apps using the TLS
654
-// app removes the need for the user to redundantly specify domain names
655
-// in their configuration. This function separates hostname and port
656
-// (keeping only the hotsname) and filters IP addresses, which can't be
657
-// used with ECH.
652
+// RegisterServerNames registers the provided DNS names with the TLS app and
653
+// associates them with the given HTTPS RR ALPN values, if any. This is
654
+// currently used to auto-publish Encrypted ClientHello (ECH) configurations,
655
+// if enabled. Use of this function by apps using the TLS app removes the need
656
+// for the user to redundantly specify domain names in their configuration.
657
+// This function separates hostname and port, keeping only the hostname, and
658
+// filters IP addresses which can't be used with ECH.
659
//
660
// EXPERIMENTAL: This function and its semantics/behavior are subject to change.
660
-func (t *TLS) RegisterServerNames(dnsNames []string) {
661
+func (t *TLS) RegisterServerNames(dnsNames, alpnValues []string) {
662
t.serverNamesMu.Lock()
663
+ defer t.serverNamesMu.Unlock()
664
+
665
for _, name := range dnsNames {
666
host, _, err := net.SplitHostPort(name)
667
if err != nil {
668
host = name
669
}
667
- if strings.TrimSpace(host) != "" && !certmagic.SubjectIsIP(host) {
668
- t.serverNames[strings.ToLower(host)] = struct{}{}
670
+ host = strings.ToLower(strings.TrimSpace(host))
671
+ if host == "" || certmagic.SubjectIsIP(host) {
672
+ continue
673
+ }
674
+
675
+ registration := t.serverNames[host]
676
+
677
+ if len(alpnValues) == 0 {
678
+ t.serverNames[host] = registration
679
+ continue
680
+ }
681
+
682
+ if registration.alpnValues == nil {
683
+ registration.alpnValues = make(map[string]struct{}, len(alpnValues))
684
+ }
685
+ for _, alpn := range alpnValues {
686
+ if alpn == "" {
687
+ continue
688
+ }
689
+ registration.alpnValues[alpn] = struct{}{}
690
}
691
+ t.serverNames[host] = registration
692
}
671
- t.serverNamesMu.Unlock()
693
+}
694
+
695
+func (t *TLS) alpnValuesForServerNames(dnsNames []string) map[string][]string {
696
+ t.serverNamesMu.Lock()
697
+ defer t.serverNamesMu.Unlock()
698
+
699
+ result := make(map[string][]string, len(dnsNames))
700
+ for _, name := range dnsNames {
701
+ host, _, err := net.SplitHostPort(name)
702
+ if err != nil {
703
+ host = name
704
+ }
705
+ host = strings.ToLower(strings.TrimSpace(host))
706
+ if host == "" {
707
+ continue
708
+ }
709
+
710
+ registration, ok := t.serverNames[host]
711
+ if !ok || len(registration.alpnValues) == 0 {
712
+ continue
713
+ }
714
+ result[host] = OrderedHTTPSRRALPN(registration.alpnValues)
715
+ }
716
+
717
+ return result
718
+}
719
+
720
+// OrderedHTTPSRRALPN returns the HTTPS RR ALPN values in preferred order.
721
+func OrderedHTTPSRRALPN(alpnSet map[string]struct{}) []string {
722
+ if len(alpnSet) == 0 {
723
+ return nil
724
+ }
725
+
726
+ knownOrder := append([]string{"h3"}, defaultALPN...)
727
+ ordered := make([]string, 0, len(alpnSet))
728
+ seen := make(map[string]struct{}, len(alpnSet))
729
+
730
+ for _, alpn := range knownOrder {
731
+ if _, ok := alpnSet[alpn]; ok {
732
+ ordered = append(ordered, alpn)
733
+ seen[alpn] = struct{}{}
734
+ }
735
+ }
736
+
737
+ if len(ordered) == len(alpnSet) {
738
+ return ordered
739
+ }
740
+
741
+ var remaining []string
742
+ for alpn := range alpnSet {
743
+ if _, ok := seen[alpn]; ok {
744
+ continue
745
+ }
746
+ remaining = append(remaining, alpn)
747
+ }
748
+ slices.Sort(remaining)
749
+
750
+ return append(ordered, remaining...)
751
+}
752
+
753
+type serverNameRegistration struct {
754
+ alpnValues map[string]struct{}
755
}
756
757
// HandleHTTPChallenge ensures that the ACME HTTP challenge or ZeroSSL HTTP