@marcel91200 / caddy Mirror · read-only / commits / 5e76b5ee
Fork 0

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