detached 771cd59

feat(traffic): add tls passthrough mux

commit
771cd59e7465fa9b1c42f1f7b0f313b7bd31881a
author
brice · · 2026-06-29 21:43 UTC
parent
2eed61d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +728 -10
M internal/adapters/in/traffic/manager.go +9 -2
@@ -31,15 +31,18 @@ type Manager struct {
3131
3232 lastReloadStatus string
3333 lastReloadError string
34+ tlsFallbacks atomic.Value
3435 }
3536
3637 // NewManager creates a traffic manager.
3738 func NewManager(log zerowrap.Logger) *Manager {
38- return &Manager{
39+ manager := &Manager{
3940 log: log,
4041 listeners: map[string]*entryPointRuntime{},
4142 lastReloadStatus: reloadStatusOK,
4243 }
44+ manager.tlsFallbacks.Store(tlsFallbacks{})
45+ return manager
4346 }
4447
4548 // Apply validates and applies a new traffic graph snapshot.
@@ -142,7 +145,7 @@ func (m *Manager) prepareTCPListeners(ctx context.Context, graph *domain.Traffic
142145 next := make(map[string]*entryPointRuntime, len(current))
143146 created := []*entryPointRuntime{}
144147 for _, entryPoint := range graph.EntryPoints {
145- if entryPoint.Protocol != domain.EntryPointProtocolTCP {
148+ if !isTCPListenerProtocol(entryPoint.Protocol) {
146149 continue
147150 }
148151 if runtime := current[entryPoint.Name]; runtime != nil && runtime.matches(entryPoint) {
@@ -162,6 +165,10 @@ func (m *Manager) prepareTCPListeners(ctx context.Context, graph *domain.Traffic
162165 return next, created, nil
163166 }
164167
168+func isTCPListenerProtocol(protocol domain.EntryPointProtocol) bool {
169+ return protocol == domain.EntryPointProtocolTCP || protocol == domain.EntryPointProtocolTLSMux
170+}
171+
165172 func (m *Manager) bindTCPEntryPoint(ctx context.Context, entryPoint domain.EntryPoint) (*entryPointRuntime, error) {
166173 listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", entryPoint.Address)
167174 if err != nil {
@@ -31,15 +31,18 @@ type Manager struct {
31 31
32 lastReloadStatus string32 lastReloadStatus string
33 lastReloadError string33 lastReloadError string
34+ tlsFallbacks atomic.Value
34 }35 }
35 36
36 // NewManager creates a traffic manager.37 // NewManager creates a traffic manager.
37 func NewManager(log zerowrap.Logger) *Manager {38 func NewManager(log zerowrap.Logger) *Manager {
38- return &Manager{39+ manager := &Manager{
39 log: log,40 log: log,
40 listeners: map[string]*entryPointRuntime{},41 listeners: map[string]*entryPointRuntime{},
41 lastReloadStatus: reloadStatusOK,42 lastReloadStatus: reloadStatusOK,
42 }43 }
44+ manager.tlsFallbacks.Store(tlsFallbacks{})
45+ return manager
43 }46 }
44 47
45 // Apply validates and applies a new traffic graph snapshot.48 // Apply validates and applies a new traffic graph snapshot.
@@ -142,7 +145,7 @@ func (m *Manager) prepareTCPListeners(ctx context.Context, graph *domain.Traffic
142 next := make(map[string]*entryPointRuntime, len(current))145 next := make(map[string]*entryPointRuntime, len(current))
143 created := []*entryPointRuntime{}146 created := []*entryPointRuntime{}
144 for _, entryPoint := range graph.EntryPoints {147 for _, entryPoint := range graph.EntryPoints {
145- if entryPoint.Protocol != domain.EntryPointProtocolTCP {148+ if !isTCPListenerProtocol(entryPoint.Protocol) {
146 continue149 continue
147 }150 }
148 if runtime := current[entryPoint.Name]; runtime != nil && runtime.matches(entryPoint) {151 if runtime := current[entryPoint.Name]; runtime != nil && runtime.matches(entryPoint) {
@@ -162,6 +165,10 @@ func (m *Manager) prepareTCPListeners(ctx context.Context, graph *domain.Traffic
162 return next, created, nil165 return next, created, nil
163 }166 }
164 167
168+func isTCPListenerProtocol(protocol domain.EntryPointProtocol) bool {
169+ return protocol == domain.EntryPointProtocolTCP || protocol == domain.EntryPointProtocolTLSMux
170+}
171+
165 func (m *Manager) bindTCPEntryPoint(ctx context.Context, entryPoint domain.EntryPoint) (*entryPointRuntime, error) {172 func (m *Manager) bindTCPEntryPoint(ctx context.Context, entryPoint domain.EntryPoint) (*entryPointRuntime, error) {
166 listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", entryPoint.Address)173 listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", entryPoint.Address)
167 if err != nil {174 if err != nil {
M internal/adapters/in/traffic/tcp.go +123 -8
@@ -7,6 +7,7 @@ import (
77 "io"
88 "net"
99 "strconv"
10+ "strings"
1011 "sync"
1112 "sync/atomic"
1213 "time"
@@ -88,13 +89,79 @@ func (r *entryPointRuntime) handleTCPConn(client net.Conn) {
8889 }
8990 defer r.releaseConnection()
9091
91- router, backend, ok := r.resolveTCPBackend()
92+ tracked := &trackedTCPConn{client: client}
93+ r.track(tracked)
94+ defer r.untrack(tracked)
95+
96+ switch r.entryPoint.Protocol {
97+ case domain.EntryPointProtocolTCP:
98+ r.handlePlainTCP(tracked, options)
99+ case domain.EntryPointProtocolTLSMux:
100+ r.handleTLSMux(tracked, options)
101+ default:
102+ r.counters.totalRefused.Add(1)
103+ _ = tracked.client.Close()
104+ }
105+}
106+
107+func (r *entryPointRuntime) handlePlainTCP(tracked *trackedTCPConn, options domain.TCPOptions) {
108+ _, backend, ok := r.resolveTCPBackend()
92109 if !ok {
93110 r.counters.totalRefused.Add(1)
94- _ = client.Close()
111+ _ = tracked.client.Close()
95112 return
96113 }
114+ r.proxyToBackend(tracked, tracked.client, backend, options)
115+}
97116
117+func (r *entryPointRuntime) handleTLSMux(tracked *trackedTCPConn, options domain.TCPOptions) {
118+ peeked, err := r.peekTLSClientHello(tracked.client, options)
119+ if err != nil {
120+ r.counters.totalErrors.Add(1)
121+ _ = tracked.client.Close()
122+ return
123+ }
124+ if backend, ok := r.resolveTLSBackend(peeked.sni); ok {
125+ r.proxyToBackend(tracked, peeked.conn, backend, options)
126+ return
127+ }
128+ if fallback := r.manager.tlsFallback(r.entryPoint.Name); fallback != nil {
129+ r.counters.totalAccepted.Add(1)
130+ fallback(r.ctx, peeked.conn)
131+ return
132+ }
133+ r.counters.totalRefused.Add(1)
134+ _ = peeked.conn.Close()
135+}
136+
137+func (r *entryPointRuntime) peekTLSClientHello(client net.Conn, options domain.TCPOptions) (peekedTLSConn, error) {
138+ if err := client.SetReadDeadline(time.Now().Add(clientHelloTimeout(options))); err != nil {
139+ return peekedTLSConn{}, err
140+ }
141+ sni, replayed, err := peekClientHelloSNI(client)
142+ if clearErr := client.SetReadDeadline(time.Time{}); err == nil && clearErr != nil {
143+ return peekedTLSConn{}, clearErr
144+ }
145+ if err != nil {
146+ return peekedTLSConn{}, err
147+ }
148+ return peekedTLSConn{sni: sni, conn: replayed}, nil
149+}
150+
151+func clientHelloTimeout(options domain.TCPOptions) time.Duration {
152+ const maxTimeout = 5 * time.Second
153+ if options.DialTimeout > 0 && options.DialTimeout < maxTimeout {
154+ return options.DialTimeout
155+ }
156+ return maxTimeout
157+}
158+
159+type peekedTLSConn struct {
160+ sni string
161+ conn net.Conn
162+}
163+
164+func (r *entryPointRuntime) proxyToBackend(tracked *trackedTCPConn, client net.Conn, backend domain.TrafficBackend, options domain.TCPOptions) {
98165 dialCtx, cancel := context.WithTimeout(r.ctx, options.DialTimeout)
99166 defer cancel()
100167 backendConn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", net.JoinHostPort(backend.Host, strconv.Itoa(backend.Port)))
@@ -104,12 +171,8 @@ func (r *entryPointRuntime) handleTCPConn(client net.Conn) {
104171 return
105172 }
106173
107- tracked := &trackedTCPConn{client: client, backend: backendConn}
108- r.track(tracked)
109- defer r.untrack(tracked)
110-
174+ tracked.backend = backendConn
111175 r.counters.totalAccepted.Add(1)
112- _ = router
113176 r.proxyTCP(client, backendConn, options.IdleTimeout)
114177 }
115178
@@ -143,6 +206,56 @@ func (r *entryPointRuntime) resolveTCPBackend() (domain.TrafficRouter, domain.Tr
143206 return domain.TrafficRouter{}, domain.TrafficBackend{}, false
144207 }
145208
209+func (r *entryPointRuntime) resolveTLSBackend(sni string) (domain.TrafficBackend, bool) {
210+ graph := r.manager.snapshot.Load()
211+ if graph == nil || sni == "" {
212+ return domain.TrafficBackend{}, false
213+ }
214+ if router, ok := r.findExactTLSRouter(graph, sni); ok {
215+ return r.backendForRouter(graph, router)
216+ }
217+ if router, ok := r.findWildcardTLSRouter(graph, sni); ok {
218+ return r.backendForRouter(graph, router)
219+ }
220+ return domain.TrafficBackend{}, false
221+}
222+
223+func (r *entryPointRuntime) findExactTLSRouter(graph *domain.TrafficGraph, sni string) (domain.TrafficRouter, bool) {
224+ for _, router := range graph.Routers {
225+ if router.EntryPoint == r.entryPoint.Name && router.Protocol == domain.RouterProtocolTLSPassthrough && normalizeTLSName(router.Rule.SNI) == sni {
226+ return router, true
227+ }
228+ }
229+ return domain.TrafficRouter{}, false
230+}
231+
232+func (r *entryPointRuntime) findWildcardTLSRouter(graph *domain.TrafficGraph, sni string) (domain.TrafficRouter, bool) {
233+ for _, router := range graph.Routers {
234+ wildcard := normalizeTLSName(router.Rule.SNI)
235+ if router.EntryPoint == r.entryPoint.Name && router.Protocol == domain.RouterProtocolTLSPassthrough && hostMatchesTLSWildcard(sni, wildcard) {
236+ return router, true
237+ }
238+ }
239+ return domain.TrafficRouter{}, false
240+}
241+
242+func (r *entryPointRuntime) backendForRouter(graph *domain.TrafficGraph, router domain.TrafficRouter) (domain.TrafficBackend, bool) {
243+ for _, service := range graph.Services {
244+ if service.Name == router.Service && len(service.Backends) == 1 && service.Backends[0].Protocol == domain.NetworkProtocolTCP {
245+ return service.Backends[0], true
246+ }
247+ }
248+ return domain.TrafficBackend{}, false
249+}
250+
251+func hostMatchesTLSWildcard(host string, wildcard string) bool {
252+ suffix, ok := strings.CutPrefix(wildcard, "*.")
253+ if !ok || host == suffix {
254+ return false
255+ }
256+ return strings.HasSuffix(host, "."+suffix)
257+}
258+
146259 func (r *entryPointRuntime) proxyTCP(client net.Conn, backend net.Conn, idleTimeout time.Duration) {
147260 defer client.Close()
148261 defer backend.Close()
@@ -286,7 +399,9 @@ func (r *entryPointRuntime) closeActiveConns() {
286399 defer r.mu.Unlock()
287400 for conn := range r.activeConns {
288401 _ = conn.client.Close()
289- _ = conn.backend.Close()
402+ if conn.backend != nil {
403+ _ = conn.backend.Close()
404+ }
290405 }
291406 }
292407
@@ -7,6 +7,7 @@ import (
7 "io"7 "io"
8 "net"8 "net"
9 "strconv"9 "strconv"
10+ "strings"
10 "sync"11 "sync"
11 "sync/atomic"12 "sync/atomic"
12 "time"13 "time"
@@ -88,13 +89,79 @@ func (r *entryPointRuntime) handleTCPConn(client net.Conn) {
88 }89 }
89 defer r.releaseConnection()90 defer r.releaseConnection()
90 91
91- router, backend, ok := r.resolveTCPBackend()92+ tracked := &trackedTCPConn{client: client}
93+ r.track(tracked)
94+ defer r.untrack(tracked)
95+
96+ switch r.entryPoint.Protocol {
97+ case domain.EntryPointProtocolTCP:
98+ r.handlePlainTCP(tracked, options)
99+ case domain.EntryPointProtocolTLSMux:
100+ r.handleTLSMux(tracked, options)
101+ default:
102+ r.counters.totalRefused.Add(1)
103+ _ = tracked.client.Close()
104+ }
105+}
106+
107+func (r *entryPointRuntime) handlePlainTCP(tracked *trackedTCPConn, options domain.TCPOptions) {
108+ _, backend, ok := r.resolveTCPBackend()
92 if !ok {109 if !ok {
93 r.counters.totalRefused.Add(1)110 r.counters.totalRefused.Add(1)
94- _ = client.Close()111+ _ = tracked.client.Close()
95 return112 return
96 }113 }
114+ r.proxyToBackend(tracked, tracked.client, backend, options)
115+}
97 116
117+func (r *entryPointRuntime) handleTLSMux(tracked *trackedTCPConn, options domain.TCPOptions) {
118+ peeked, err := r.peekTLSClientHello(tracked.client, options)
119+ if err != nil {
120+ r.counters.totalErrors.Add(1)
121+ _ = tracked.client.Close()
122+ return
123+ }
124+ if backend, ok := r.resolveTLSBackend(peeked.sni); ok {
125+ r.proxyToBackend(tracked, peeked.conn, backend, options)
126+ return
127+ }
128+ if fallback := r.manager.tlsFallback(r.entryPoint.Name); fallback != nil {
129+ r.counters.totalAccepted.Add(1)
130+ fallback(r.ctx, peeked.conn)
131+ return
132+ }
133+ r.counters.totalRefused.Add(1)
134+ _ = peeked.conn.Close()
135+}
136+
137+func (r *entryPointRuntime) peekTLSClientHello(client net.Conn, options domain.TCPOptions) (peekedTLSConn, error) {
138+ if err := client.SetReadDeadline(time.Now().Add(clientHelloTimeout(options))); err != nil {
139+ return peekedTLSConn{}, err
140+ }
141+ sni, replayed, err := peekClientHelloSNI(client)
142+ if clearErr := client.SetReadDeadline(time.Time{}); err == nil && clearErr != nil {
143+ return peekedTLSConn{}, clearErr
144+ }
145+ if err != nil {
146+ return peekedTLSConn{}, err
147+ }
148+ return peekedTLSConn{sni: sni, conn: replayed}, nil
149+}
150+
151+func clientHelloTimeout(options domain.TCPOptions) time.Duration {
152+ const maxTimeout = 5 * time.Second
153+ if options.DialTimeout > 0 && options.DialTimeout < maxTimeout {
154+ return options.DialTimeout
155+ }
156+ return maxTimeout
157+}
158+
159+type peekedTLSConn struct {
160+ sni string
161+ conn net.Conn
162+}
163+
164+func (r *entryPointRuntime) proxyToBackend(tracked *trackedTCPConn, client net.Conn, backend domain.TrafficBackend, options domain.TCPOptions) {
98 dialCtx, cancel := context.WithTimeout(r.ctx, options.DialTimeout)165 dialCtx, cancel := context.WithTimeout(r.ctx, options.DialTimeout)
99 defer cancel()166 defer cancel()
100 backendConn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", net.JoinHostPort(backend.Host, strconv.Itoa(backend.Port)))167 backendConn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", net.JoinHostPort(backend.Host, strconv.Itoa(backend.Port)))
@@ -104,12 +171,8 @@ func (r *entryPointRuntime) handleTCPConn(client net.Conn) {
104 return171 return
105 }172 }
106 173
107- tracked := &trackedTCPConn{client: client, backend: backendConn}174+ tracked.backend = backendConn
108- r.track(tracked)
109- defer r.untrack(tracked)
110-
111 r.counters.totalAccepted.Add(1)175 r.counters.totalAccepted.Add(1)
112- _ = router
113 r.proxyTCP(client, backendConn, options.IdleTimeout)176 r.proxyTCP(client, backendConn, options.IdleTimeout)
114 }177 }
115 178
@@ -143,6 +206,56 @@ func (r *entryPointRuntime) resolveTCPBackend() (domain.TrafficRouter, domain.Tr
143 return domain.TrafficRouter{}, domain.TrafficBackend{}, false206 return domain.TrafficRouter{}, domain.TrafficBackend{}, false
144 }207 }
145 208
209+func (r *entryPointRuntime) resolveTLSBackend(sni string) (domain.TrafficBackend, bool) {
210+ graph := r.manager.snapshot.Load()
211+ if graph == nil || sni == "" {
212+ return domain.TrafficBackend{}, false
213+ }
214+ if router, ok := r.findExactTLSRouter(graph, sni); ok {
215+ return r.backendForRouter(graph, router)
216+ }
217+ if router, ok := r.findWildcardTLSRouter(graph, sni); ok {
218+ return r.backendForRouter(graph, router)
219+ }
220+ return domain.TrafficBackend{}, false
221+}
222+
223+func (r *entryPointRuntime) findExactTLSRouter(graph *domain.TrafficGraph, sni string) (domain.TrafficRouter, bool) {
224+ for _, router := range graph.Routers {
225+ if router.EntryPoint == r.entryPoint.Name && router.Protocol == domain.RouterProtocolTLSPassthrough && normalizeTLSName(router.Rule.SNI) == sni {
226+ return router, true
227+ }
228+ }
229+ return domain.TrafficRouter{}, false
230+}
231+
232+func (r *entryPointRuntime) findWildcardTLSRouter(graph *domain.TrafficGraph, sni string) (domain.TrafficRouter, bool) {
233+ for _, router := range graph.Routers {
234+ wildcard := normalizeTLSName(router.Rule.SNI)
235+ if router.EntryPoint == r.entryPoint.Name && router.Protocol == domain.RouterProtocolTLSPassthrough && hostMatchesTLSWildcard(sni, wildcard) {
236+ return router, true
237+ }
238+ }
239+ return domain.TrafficRouter{}, false
240+}
241+
242+func (r *entryPointRuntime) backendForRouter(graph *domain.TrafficGraph, router domain.TrafficRouter) (domain.TrafficBackend, bool) {
243+ for _, service := range graph.Services {
244+ if service.Name == router.Service && len(service.Backends) == 1 && service.Backends[0].Protocol == domain.NetworkProtocolTCP {
245+ return service.Backends[0], true
246+ }
247+ }
248+ return domain.TrafficBackend{}, false
249+}
250+
251+func hostMatchesTLSWildcard(host string, wildcard string) bool {
252+ suffix, ok := strings.CutPrefix(wildcard, "*.")
253+ if !ok || host == suffix {
254+ return false
255+ }
256+ return strings.HasSuffix(host, "."+suffix)
257+}
258+
146 func (r *entryPointRuntime) proxyTCP(client net.Conn, backend net.Conn, idleTimeout time.Duration) {259 func (r *entryPointRuntime) proxyTCP(client net.Conn, backend net.Conn, idleTimeout time.Duration) {
147 defer client.Close()260 defer client.Close()
148 defer backend.Close()261 defer backend.Close()
@@ -286,7 +399,9 @@ func (r *entryPointRuntime) closeActiveConns() {
286 defer r.mu.Unlock()399 defer r.mu.Unlock()
287 for conn := range r.activeConns {400 for conn := range r.activeConns {
288 _ = conn.client.Close()401 _ = conn.client.Close()
289- _ = conn.backend.Close()402+ if conn.backend != nil {
403+ _ = conn.backend.Close()
404+ }
290 }405 }
291 }406 }
292 407
A internal/adapters/in/traffic/tlsmux.go +305 -0
@@ -0,0 +1,305 @@
1+package traffic
2+
3+import (
4+ "bytes"
5+ "context"
6+ "crypto/tls"
7+ "errors"
8+ "fmt"
9+ "net"
10+ "net/http"
11+ "strings"
12+ "sync"
13+ "time"
14+)
15+
16+const maxClientHelloBytes = 64 << 10
17+
18+// TLSFallback handles tls_mux connections that do not match a passthrough router.
19+type TLSFallback func(context.Context, net.Conn)
20+
21+type tlsFallbacks map[string]TLSFallback
22+
23+// SetTLSFallback installs or removes an HTTPS fallback for a tls_mux entrypoint.
24+func (m *Manager) SetTLSFallback(entryPoint string, fallback TLSFallback) {
25+ m.mu.Lock()
26+ defer m.mu.Unlock()
27+
28+ current := m.loadTLSFallbacks()
29+ next := make(tlsFallbacks, len(current)+1)
30+ for name, value := range current {
31+ next[name] = value
32+ }
33+ if fallback == nil {
34+ delete(next, entryPoint)
35+ } else {
36+ next[entryPoint] = fallback
37+ }
38+ m.tlsFallbacks.Store(next)
39+}
40+
41+func (m *Manager) tlsFallback(entryPoint string) TLSFallback {
42+ return m.loadTLSFallbacks()[entryPoint]
43+}
44+
45+func (m *Manager) loadTLSFallbacks() tlsFallbacks {
46+ value := m.tlsFallbacks.Load()
47+ if value == nil {
48+ return tlsFallbacks{}
49+ }
50+ return value.(tlsFallbacks)
51+}
52+
53+func peekClientHelloSNI(conn net.Conn) (string, net.Conn, error) {
54+ buf := make([]byte, 0, 4096)
55+ tmp := make([]byte, 1024)
56+ for len(buf) < maxClientHelloBytes {
57+ n, err := conn.Read(tmp)
58+ if n > 0 {
59+ buf = append(buf, tmp[:n]...)
60+ sni, complete, parseErr := parseClientHelloSNI(buf)
61+ if parseErr != nil {
62+ return "", nil, parseErr
63+ }
64+ if complete {
65+ return sni, replayConn{Conn: conn, reader: bytes.NewReader(buf)}, nil
66+ }
67+ }
68+ if err != nil {
69+ return "", nil, err
70+ }
71+ }
72+ return "", nil, fmt.Errorf("client hello exceeds %d bytes", maxClientHelloBytes)
73+}
74+
75+func parseClientHelloSNI(data []byte) (string, bool, error) {
76+ if len(data) < 5 {
77+ return "", false, nil
78+ }
79+ if data[0] != 22 {
80+ return "", false, fmt.Errorf("tls record is not a handshake")
81+ }
82+ recordLen := int(data[3])<<8 | int(data[4])
83+ if recordLen <= 0 {
84+ return "", false, fmt.Errorf("tls handshake record is empty")
85+ }
86+ if len(data) < 5+recordLen {
87+ return "", false, nil
88+ }
89+ record := data[5 : 5+recordLen]
90+ if len(record) < 4 {
91+ return "", true, fmt.Errorf("tls handshake is truncated")
92+ }
93+ if record[0] != 1 {
94+ return "", true, fmt.Errorf("tls handshake is not client hello")
95+ }
96+ handshakeLen := int(record[1])<<16 | int(record[2])<<8 | int(record[3])
97+ if handshakeLen > len(record)-4 {
98+ return "", false, nil
99+ }
100+ sni, err := parseClientHelloExtensions(record[4 : 4+handshakeLen])
101+ return sni, true, err
102+}
103+
104+func parseClientHelloExtensions(body []byte) (string, error) {
105+ reader := bytes.NewReader(body)
106+ if reader.Len() < 34 {
107+ return "", fmt.Errorf("client hello is truncated")
108+ }
109+ _, _ = readBytes(reader, 34) // legacy_version + random
110+ if err := skipLengthPrefixed(reader, 1); err != nil {
111+ return "", err
112+ }
113+ if err := skipLengthPrefixed(reader, 2); err != nil {
114+ return "", err
115+ }
116+ if err := skipLengthPrefixed(reader, 1); err != nil {
117+ return "", err
118+ }
119+ if reader.Len() == 0 {
120+ return "", nil
121+ }
122+ extLen, err := readUint16(reader)
123+ if err != nil {
124+ return "", err
125+ }
126+ if extLen > reader.Len() {
127+ return "", fmt.Errorf("client hello extensions are truncated")
128+ }
129+ extBytes, _ := readBytes(reader, extLen)
130+ extensions := bytes.NewReader(extBytes)
131+ for extensions.Len() > 0 {
132+ extType, err := readUint16(extensions)
133+ if err != nil {
134+ return "", err
135+ }
136+ extDataLen, err := readUint16(extensions)
137+ if err != nil {
138+ return "", err
139+ }
140+ if extDataLen > extensions.Len() {
141+ return "", fmt.Errorf("client hello extension is truncated")
142+ }
143+ extData, _ := readBytes(extensions, extDataLen)
144+ if extType == 0 {
145+ return parseSNIExtension(extData)
146+ }
147+ }
148+ return "", nil
149+}
150+
151+func parseSNIExtension(data []byte) (string, error) {
152+ reader := bytes.NewReader(data)
153+ listLen, err := readUint16(reader)
154+ if err != nil {
155+ return "", err
156+ }
157+ if listLen > reader.Len() {
158+ return "", fmt.Errorf("sni extension is truncated")
159+ }
160+ listBytes, _ := readBytes(reader, listLen)
161+ list := bytes.NewReader(listBytes)
162+ for list.Len() > 0 {
163+ nameType, err := list.ReadByte()
164+ if err != nil {
165+ return "", err
166+ }
167+ nameLen, err := readUint16(list)
168+ if err != nil {
169+ return "", err
170+ }
171+ if nameLen > list.Len() {
172+ return "", fmt.Errorf("sni name is truncated")
173+ }
174+ nameBytes, _ := readBytes(list, nameLen)
175+ name := string(nameBytes)
176+ if nameType == 0 {
177+ return normalizeTLSName(name), nil
178+ }
179+ }
180+ return "", nil
181+}
182+
183+func skipLengthPrefixed(reader *bytes.Reader, size int) error {
184+ length, err := readLength(reader, size)
185+ if err != nil {
186+ return err
187+ }
188+ if length > reader.Len() {
189+ return fmt.Errorf("client hello field is truncated")
190+ }
191+ _, _ = readBytes(reader, length)
192+ return nil
193+}
194+
195+func readBytes(reader *bytes.Reader, size int) ([]byte, error) {
196+ if reader.Len() < size {
197+ return nil, fmt.Errorf("client hello field is truncated")
198+ }
199+ value := make([]byte, size)
200+ _, _ = reader.Read(value)
201+ return value, nil
202+}
203+
204+func readLength(reader *bytes.Reader, size int) (int, error) {
205+ if reader.Len() < size {
206+ return 0, fmt.Errorf("client hello length is truncated")
207+ }
208+ value := 0
209+ for i := 0; i < size; i++ {
210+ b, _ := reader.ReadByte()
211+ value = value<<8 | int(b)
212+ }
213+ return value, nil
214+}
215+
216+func readUint16(reader *bytes.Reader) (int, error) {
217+ return readLength(reader, 2)
218+}
219+
220+func normalizeTLSName(value string) string {
221+ return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(value)), ".")
222+}
223+
224+type replayConn struct {
225+ net.Conn
226+ reader *bytes.Reader
227+}
228+
229+func (c replayConn) Read(p []byte) (int, error) {
230+ if c.reader != nil && c.reader.Len() > 0 {
231+ return c.reader.Read(p)
232+ }
233+ return c.Conn.Read(p)
234+}
235+
236+// ServeHTTPSFallback serves a single TLS connection with the provided HTTP handler.
237+func ServeHTTPSFallback(ctx context.Context, conn net.Conn, handler http.Handler, tlsConfig *tls.Config) error {
238+ tracked := newTrackedCloseConn(tls.Server(conn, tlsConfig))
239+ listener := newSingleConnListener(tracked)
240+ server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
241+ errCh := make(chan error, 1)
242+ go func() { errCh <- server.Serve(listener) }()
243+
244+ select {
245+ case <-tracked.done:
246+ _ = server.Close()
247+ err := <-errCh
248+ if errors.Is(err, net.ErrClosed) || errors.Is(err, http.ErrServerClosed) {
249+ return nil
250+ }
251+ return err
252+ case <-ctx.Done():
253+ _ = server.Close()
254+ _ = tracked.Close()
255+ <-errCh
256+ return ctx.Err()
257+ }
258+}
259+
260+type singleConnListener struct {
261+ conn net.Conn
262+ once sync.Once
263+ done chan struct{}
264+}
265+
266+func newSingleConnListener(conn net.Conn) *singleConnListener {
267+ return &singleConnListener{conn: conn, done: make(chan struct{})}
268+}
269+
270+func (l *singleConnListener) Accept() (net.Conn, error) {
271+ var conn net.Conn
272+ l.once.Do(func() { conn = l.conn })
273+ if conn != nil {
274+ return conn, nil
275+ }
276+ <-l.done
277+ return nil, net.ErrClosed
278+}
279+
280+func (l *singleConnListener) Close() error {
281+ select {
282+ case <-l.done:
283+ default:
284+ close(l.done)
285+ }
286+ return nil
287+}
288+
289+func (l *singleConnListener) Addr() net.Addr { return l.conn.LocalAddr() }
290+
291+type trackedCloseConn struct {
292+ net.Conn
293+ done chan struct{}
294+ once sync.Once
295+}
296+
297+func newTrackedCloseConn(conn net.Conn) *trackedCloseConn {
298+ return &trackedCloseConn{Conn: conn, done: make(chan struct{})}
299+}
300+
301+func (c *trackedCloseConn) Close() error {
302+ err := c.Conn.Close()
303+ c.once.Do(func() { close(c.done) })
304+ return err
305+}
@@ -0,0 +1,305 @@
1+package traffic
2+
3+import (
4+ "bytes"
5+ "context"
6+ "crypto/tls"
7+ "errors"
8+ "fmt"
9+ "net"
10+ "net/http"
11+ "strings"
12+ "sync"
13+ "time"
14+)
15+
16+const maxClientHelloBytes = 64 << 10
17+
18+// TLSFallback handles tls_mux connections that do not match a passthrough router.
19+type TLSFallback func(context.Context, net.Conn)
20+
21+type tlsFallbacks map[string]TLSFallback
22+
23+// SetTLSFallback installs or removes an HTTPS fallback for a tls_mux entrypoint.
24+func (m *Manager) SetTLSFallback(entryPoint string, fallback TLSFallback) {
25+ m.mu.Lock()
26+ defer m.mu.Unlock()
27+
28+ current := m.loadTLSFallbacks()
29+ next := make(tlsFallbacks, len(current)+1)
30+ for name, value := range current {
31+ next[name] = value
32+ }
33+ if fallback == nil {
34+ delete(next, entryPoint)
35+ } else {
36+ next[entryPoint] = fallback
37+ }
38+ m.tlsFallbacks.Store(next)
39+}
40+
41+func (m *Manager) tlsFallback(entryPoint string) TLSFallback {
42+ return m.loadTLSFallbacks()[entryPoint]
43+}
44+
45+func (m *Manager) loadTLSFallbacks() tlsFallbacks {
46+ value := m.tlsFallbacks.Load()
47+ if value == nil {
48+ return tlsFallbacks{}
49+ }
50+ return value.(tlsFallbacks)
51+}
52+
53+func peekClientHelloSNI(conn net.Conn) (string, net.Conn, error) {
54+ buf := make([]byte, 0, 4096)
55+ tmp := make([]byte, 1024)
56+ for len(buf) < maxClientHelloBytes {
57+ n, err := conn.Read(tmp)
58+ if n > 0 {
59+ buf = append(buf, tmp[:n]...)
60+ sni, complete, parseErr := parseClientHelloSNI(buf)
61+ if parseErr != nil {
62+ return "", nil, parseErr
63+ }
64+ if complete {
65+ return sni, replayConn{Conn: conn, reader: bytes.NewReader(buf)}, nil
66+ }
67+ }
68+ if err != nil {
69+ return "", nil, err
70+ }
71+ }
72+ return "", nil, fmt.Errorf("client hello exceeds %d bytes", maxClientHelloBytes)
73+}
74+
75+func parseClientHelloSNI(data []byte) (string, bool, error) {
76+ if len(data) < 5 {
77+ return "", false, nil
78+ }
79+ if data[0] != 22 {
80+ return "", false, fmt.Errorf("tls record is not a handshake")
81+ }
82+ recordLen := int(data[3])<<8 | int(data[4])
83+ if recordLen <= 0 {
84+ return "", false, fmt.Errorf("tls handshake record is empty")
85+ }
86+ if len(data) < 5+recordLen {
87+ return "", false, nil
88+ }
89+ record := data[5 : 5+recordLen]
90+ if len(record) < 4 {
91+ return "", true, fmt.Errorf("tls handshake is truncated")
92+ }
93+ if record[0] != 1 {
94+ return "", true, fmt.Errorf("tls handshake is not client hello")
95+ }
96+ handshakeLen := int(record[1])<<16 | int(record[2])<<8 | int(record[3])
97+ if handshakeLen > len(record)-4 {
98+ return "", false, nil
99+ }
100+ sni, err := parseClientHelloExtensions(record[4 : 4+handshakeLen])
101+ return sni, true, err
102+}
103+
104+func parseClientHelloExtensions(body []byte) (string, error) {
105+ reader := bytes.NewReader(body)
106+ if reader.Len() < 34 {
107+ return "", fmt.Errorf("client hello is truncated")
108+ }
109+ _, _ = readBytes(reader, 34) // legacy_version + random
110+ if err := skipLengthPrefixed(reader, 1); err != nil {
111+ return "", err
112+ }
113+ if err := skipLengthPrefixed(reader, 2); err != nil {
114+ return "", err
115+ }
116+ if err := skipLengthPrefixed(reader, 1); err != nil {
117+ return "", err
118+ }
119+ if reader.Len() == 0 {
120+ return "", nil
121+ }
122+ extLen, err := readUint16(reader)
123+ if err != nil {
124+ return "", err
125+ }
126+ if extLen > reader.Len() {
127+ return "", fmt.Errorf("client hello extensions are truncated")
128+ }
129+ extBytes, _ := readBytes(reader, extLen)
130+ extensions := bytes.NewReader(extBytes)
131+ for extensions.Len() > 0 {
132+ extType, err := readUint16(extensions)
133+ if err != nil {
134+ return "", err
135+ }
136+ extDataLen, err := readUint16(extensions)
137+ if err != nil {
138+ return "", err
139+ }
140+ if extDataLen > extensions.Len() {
141+ return "", fmt.Errorf("client hello extension is truncated")
142+ }
143+ extData, _ := readBytes(extensions, extDataLen)
144+ if extType == 0 {
145+ return parseSNIExtension(extData)
146+ }
147+ }
148+ return "", nil
149+}
150+
151+func parseSNIExtension(data []byte) (string, error) {
152+ reader := bytes.NewReader(data)
153+ listLen, err := readUint16(reader)
154+ if err != nil {
155+ return "", err
156+ }
157+ if listLen > reader.Len() {
158+ return "", fmt.Errorf("sni extension is truncated")
159+ }
160+ listBytes, _ := readBytes(reader, listLen)
161+ list := bytes.NewReader(listBytes)
162+ for list.Len() > 0 {
163+ nameType, err := list.ReadByte()
164+ if err != nil {
165+ return "", err
166+ }
167+ nameLen, err := readUint16(list)
168+ if err != nil {
169+ return "", err
170+ }
171+ if nameLen > list.Len() {
172+ return "", fmt.Errorf("sni name is truncated")
173+ }
174+ nameBytes, _ := readBytes(list, nameLen)
175+ name := string(nameBytes)
176+ if nameType == 0 {
177+ return normalizeTLSName(name), nil
178+ }
179+ }
180+ return "", nil
181+}
182+
183+func skipLengthPrefixed(reader *bytes.Reader, size int) error {
184+ length, err := readLength(reader, size)
185+ if err != nil {
186+ return err
187+ }
188+ if length > reader.Len() {
189+ return fmt.Errorf("client hello field is truncated")
190+ }
191+ _, _ = readBytes(reader, length)
192+ return nil
193+}
194+
195+func readBytes(reader *bytes.Reader, size int) ([]byte, error) {
196+ if reader.Len() < size {
197+ return nil, fmt.Errorf("client hello field is truncated")
198+ }
199+ value := make([]byte, size)
200+ _, _ = reader.Read(value)
201+ return value, nil
202+}
203+
204+func readLength(reader *bytes.Reader, size int) (int, error) {
205+ if reader.Len() < size {
206+ return 0, fmt.Errorf("client hello length is truncated")
207+ }
208+ value := 0
209+ for i := 0; i < size; i++ {
210+ b, _ := reader.ReadByte()
211+ value = value<<8 | int(b)
212+ }
213+ return value, nil
214+}
215+
216+func readUint16(reader *bytes.Reader) (int, error) {
217+ return readLength(reader, 2)
218+}
219+
220+func normalizeTLSName(value string) string {
221+ return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(value)), ".")
222+}
223+
224+type replayConn struct {
225+ net.Conn
226+ reader *bytes.Reader
227+}
228+
229+func (c replayConn) Read(p []byte) (int, error) {
230+ if c.reader != nil && c.reader.Len() > 0 {
231+ return c.reader.Read(p)
232+ }
233+ return c.Conn.Read(p)
234+}
235+
236+// ServeHTTPSFallback serves a single TLS connection with the provided HTTP handler.
237+func ServeHTTPSFallback(ctx context.Context, conn net.Conn, handler http.Handler, tlsConfig *tls.Config) error {
238+ tracked := newTrackedCloseConn(tls.Server(conn, tlsConfig))
239+ listener := newSingleConnListener(tracked)
240+ server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
241+ errCh := make(chan error, 1)
242+ go func() { errCh <- server.Serve(listener) }()
243+
244+ select {
245+ case <-tracked.done:
246+ _ = server.Close()
247+ err := <-errCh
248+ if errors.Is(err, net.ErrClosed) || errors.Is(err, http.ErrServerClosed) {
249+ return nil
250+ }
251+ return err
252+ case <-ctx.Done():
253+ _ = server.Close()
254+ _ = tracked.Close()
255+ <-errCh
256+ return ctx.Err()
257+ }
258+}
259+
260+type singleConnListener struct {
261+ conn net.Conn
262+ once sync.Once
263+ done chan struct{}
264+}
265+
266+func newSingleConnListener(conn net.Conn) *singleConnListener {
267+ return &singleConnListener{conn: conn, done: make(chan struct{})}
268+}
269+
270+func (l *singleConnListener) Accept() (net.Conn, error) {
271+ var conn net.Conn
272+ l.once.Do(func() { conn = l.conn })
273+ if conn != nil {
274+ return conn, nil
275+ }
276+ <-l.done
277+ return nil, net.ErrClosed
278+}
279+
280+func (l *singleConnListener) Close() error {
281+ select {
282+ case <-l.done:
283+ default:
284+ close(l.done)
285+ }
286+ return nil
287+}
288+
289+func (l *singleConnListener) Addr() net.Addr { return l.conn.LocalAddr() }
290+
291+type trackedCloseConn struct {
292+ net.Conn
293+ done chan struct{}
294+ once sync.Once
295+}
296+
297+func newTrackedCloseConn(conn net.Conn) *trackedCloseConn {
298+ return &trackedCloseConn{Conn: conn, done: make(chan struct{})}
299+}
300+
301+func (c *trackedCloseConn) Close() error {
302+ err := c.Conn.Close()
303+ c.once.Do(func() { close(c.done) })
304+ return err
305+}
A internal/adapters/in/traffic/tlsmux_test.go +291 -0
@@ -0,0 +1,291 @@
1+package traffic
2+
3+import (
4+ "bufio"
5+ "context"
6+ "crypto/ecdsa"
7+ "crypto/elliptic"
8+ "crypto/rand"
9+ "crypto/tls"
10+ "crypto/x509"
11+ "crypto/x509/pkix"
12+ "encoding/pem"
13+ "io"
14+ "math/big"
15+ "net"
16+ "net/http"
17+ "strings"
18+ "testing"
19+ "time"
20+
21+ "github.com/bnema/zerowrap"
22+ "github.com/stretchr/testify/assert"
23+ "github.com/stretchr/testify/require"
24+
25+ "github.com/bnema/gordon/internal/domain"
26+)
27+
28+func TestClientHelloPeek(t *testing.T) {
29+ t.Run("valid sni returns replayable bytes", func(t *testing.T) {
30+ sni, replayed := peekHelloFromTLSClient(t, "raw.example.com")
31+ assert.Equal(t, "raw.example.com", sni)
32+ buf := make([]byte, 5)
33+ n, err := replayed.Read(buf)
34+ require.NoError(t, err)
35+ assert.Equal(t, 5, n)
36+ assert.Equal(t, byte(22), buf[0])
37+ })
38+
39+ t.Run("no sni", func(t *testing.T) {
40+ sni, _ := peekHelloFromTLSClient(t, "")
41+ assert.Empty(t, sni)
42+ })
43+
44+ t.Run("malformed", func(t *testing.T) {
45+ client, server := net.Pipe()
46+ go func() {
47+ _, _ = client.Write([]byte("not tls"))
48+ _ = client.Close()
49+ }()
50+ _, _, err := peekClientHelloSNI(server)
51+ require.Error(t, err)
52+ })
53+
54+ t.Run("fragmented", func(t *testing.T) {
55+ hello := clientHelloBytes(t, "fragmented.example.com")
56+ client, server := net.Pipe()
57+ go func() {
58+ for _, b := range hello {
59+ _, _ = client.Write([]byte{b})
60+ }
61+ _ = client.Close()
62+ }()
63+ sni, replayed, err := peekClientHelloSNI(server)
64+ require.NoError(t, err)
65+ assert.Equal(t, "fragmented.example.com", sni)
66+ buf := make([]byte, len(hello))
67+ _, err = io.ReadFull(replayed, buf)
68+ require.NoError(t, err)
69+ assert.Equal(t, hello, buf)
70+ })
71+}
72+
73+func TestTLSPassthrough(t *testing.T) {
74+ backendA := startTLSGreetingServer(t, "backend-a", "backend-a\n")
75+ backendB := startTLSGreetingServer(t, "backend-b", "backend-b\n")
76+ backendExact := startTLSGreetingServer(t, "backend-exact", "backend-exact\n")
77+
78+ graph := tlsGraph(t, freeTCPAddress(t), []tlsRoute{
79+ {name: "raw", sni: "raw.example.com", service: "raw", backend: backendA.address},
80+ {name: "wild", sni: "*.example.com", service: "wild", backend: backendB.address},
81+ {name: "exact", sni: "api.example.com", service: "exact", backend: backendExact.address},
82+ })
83+ manager := NewManager(zerowrap.Default())
84+ require.NoError(t, manager.Apply(context.Background(), &graph))
85+ defer shutdownManager(t, manager)
86+
87+ assertTLSGreeting(t, graph.EntryPoints[0].Address, "raw.example.com", "backend-a\n")
88+ assertTLSGreeting(t, graph.EntryPoints[0].Address, "shop.example.com", "backend-b\n")
89+ assertTLSGreeting(t, graph.EntryPoints[0].Address, "api.example.com", "backend-exact\n")
90+}
91+
92+func TestTLSPassthroughUnknownCloses(t *testing.T) {
93+ backend := startTLSGreetingServer(t, "backend", "backend\n")
94+ graph := tlsGraph(t, freeTCPAddress(t), []tlsRoute{{name: "raw", sni: "raw.example.com", service: "raw", backend: backend.address}})
95+ manager := NewManager(zerowrap.Default())
96+ require.NoError(t, manager.Apply(context.Background(), &graph))
97+ defer shutdownManager(t, manager)
98+
99+ _, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", graph.EntryPoints[0].Address, &tls.Config{ServerName: "unknown.example.com", InsecureSkipVerify: true})
100+ require.Error(t, err)
101+ assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == 1 }, time.Second, 10*time.Millisecond)
102+}
103+
104+func TestTLSMuxIncompleteClientHelloDoesNotHangShutdown(t *testing.T) {
105+ graph := domain.TrafficGraph{
106+ Options: domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: time.Second, IdleTimeout: time.Minute, DrainTimeout: 50 * time.Millisecond}},
107+ EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTLSMux}},
108+ }
109+ manager := NewManager(zerowrap.Default())
110+ require.NoError(t, manager.Apply(context.Background(), &graph))
111+
112+ conn := dialTCP(t, graph.EntryPoints[0].Address)
113+ _, err := conn.Write([]byte{22})
114+ require.NoError(t, err)
115+
116+ ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
117+ defer cancel()
118+ started := time.Now()
119+ require.NoError(t, manager.Shutdown(ctx))
120+ assert.Less(t, time.Since(started), 250*time.Millisecond)
121+ _ = conn.Close()
122+}
123+
124+func TestTLSMuxHTTPSFallback(t *testing.T) {
125+ backend := startTLSGreetingServer(t, "raw-backend", "raw\n")
126+ graph := tlsGraph(t, freeTCPAddress(t), []tlsRoute{{name: "raw", sni: "raw.example.com", service: "raw", backend: backend.address}})
127+ manager := NewManager(zerowrap.Default())
128+ fallbackCert := testTLSConfig(t, "fallback.example.com")
129+ manager.SetTLSFallback("websecure", func(ctx context.Context, conn net.Conn) {
130+ _ = ServeHTTPSFallback(ctx, conn, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
131+ _, _ = w.Write([]byte("fallback"))
132+ }), fallbackCert)
133+ })
134+ require.NoError(t, manager.Apply(context.Background(), &graph))
135+ defer shutdownManager(t, manager)
136+
137+ assertTLSGreeting(t, graph.EntryPoints[0].Address, "raw.example.com", "raw\n")
138+ assertHTTPSFallback(t, graph.EntryPoints[0].Address, "app.example.com", "fallback")
139+ assertHTTPSFallback(t, graph.EntryPoints[0].Address, "", "fallback")
140+
141+ conn := dialTCP(t, graph.EntryPoints[0].Address)
142+ _, err := conn.Write([]byte("malformed"))
143+ require.NoError(t, err)
144+ _ = conn.Close()
145+ assert.Eventually(t, func() bool { return manager.Status().Counters.TotalErrors >= 1 }, time.Second, 10*time.Millisecond)
146+}
147+
148+func peekHelloFromTLSClient(t *testing.T, serverName string) (string, net.Conn) {
149+ t.Helper()
150+ client, server := net.Pipe()
151+ done := make(chan struct{})
152+ go func() {
153+ defer close(done)
154+ _ = tls.Client(client, &tls.Config{ServerName: serverName, InsecureSkipVerify: true}).Handshake()
155+ _ = client.Close()
156+ }()
157+ sni, replayed, err := peekClientHelloSNI(server)
158+ require.NoError(t, err)
159+ t.Cleanup(func() {
160+ _ = replayed.Close()
161+ <-done
162+ })
163+ return sni, replayed
164+}
165+
166+func clientHelloBytes(t *testing.T, serverName string) []byte {
167+ t.Helper()
168+ _, replayed := peekHelloFromTLSClient(t, serverName)
169+ buf := make([]byte, maxClientHelloBytes)
170+ n, err := replayed.Read(buf)
171+ require.NoError(t, err)
172+ return append([]byte{}, buf[:n]...)
173+}
174+
175+type tlsRoute struct {
176+ name string
177+ sni string
178+ service string
179+ backend string
180+}
181+
182+func tlsGraph(t *testing.T, listenAddress string, routes []tlsRoute) domain.TrafficGraph {
183+ t.Helper()
184+ graph := domain.TrafficGraph{
185+ Options: domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: time.Second, IdleTimeout: time.Minute, DrainTimeout: time.Second}},
186+ EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: listenAddress, Protocol: domain.EntryPointProtocolTLSMux}},
187+ }
188+ for _, route := range routes {
189+ backend, err := backendFromAddress(route.service+":tls", route.backend)
190+ require.NoError(t, err)
191+ ref := serviceRef(route.service, "tls")
192+ graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: route.name, EntryPoint: "websecure", Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: route.sni}, Service: ref})
193+ graph.Services = append(graph.Services, domain.TrafficService{Name: ref, Backends: []domain.TrafficBackend{backend}})
194+ }
195+ require.NoError(t, graph.Validate())
196+ return graph
197+}
198+
199+func assertTLSGreeting(t *testing.T, address string, serverName string, want string) {
200+ t.Helper()
201+ conn, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", address, &tls.Config{ServerName: serverName, InsecureSkipVerify: true})
202+ require.NoError(t, err)
203+ defer conn.Close()
204+ line, err := bufio.NewReader(conn).ReadString('\n')
205+ require.NoError(t, err)
206+ assert.Equal(t, want, line)
207+}
208+
209+func assertHTTPSFallback(t *testing.T, address string, serverName string, wantBody string) {
210+ t.Helper()
211+ conn, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", address, &tls.Config{ServerName: serverName, InsecureSkipVerify: true})
212+ require.NoError(t, err)
213+ defer conn.Close()
214+ _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
215+ require.NoError(t, err)
216+ body, err := io.ReadAll(conn)
217+ require.NoError(t, err)
218+ assert.Contains(t, string(body), wantBody)
219+}
220+
221+type tlsGreetingServer struct{ address string }
222+
223+func startTLSGreetingServer(t *testing.T, commonName string, greeting string) tlsGreetingServer {
224+ t.Helper()
225+ listener, err := tls.Listen("tcp", "127.0.0.1:0", testTLSConfig(t, commonName))
226+ require.NoError(t, err)
227+ done := make(chan struct{})
228+ go func() {
229+ defer close(done)
230+ for {
231+ conn, err := listener.Accept()
232+ if err != nil {
233+ return
234+ }
235+ go func() {
236+ defer conn.Close()
237+ _, _ = conn.Write([]byte(greeting))
238+ }()
239+ }
240+ }()
241+ t.Cleanup(func() {
242+ _ = listener.Close()
243+ <-done
244+ })
245+ return tlsGreetingServer{address: listener.Addr().String()}
246+}
247+
248+func testTLSConfig(t *testing.T, commonName string) *tls.Config {
249+ t.Helper()
250+ cert, err := testCertificate(commonName)
251+ require.NoError(t, err)
252+ return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}
253+}
254+
255+func testCertificate(commonName string) (tls.Certificate, error) {
256+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
257+ if err != nil {
258+ return tls.Certificate{}, err
259+ }
260+ serial, err := rand.Int(rand.Reader, big.NewInt(1<<62))
261+ if err != nil {
262+ return tls.Certificate{}, err
263+ }
264+ template := x509.Certificate{
265+ SerialNumber: serial,
266+ Subject: pkix.Name{CommonName: commonName},
267+ DNSNames: []string{commonName},
268+ NotBefore: time.Now().Add(-time.Hour),
269+ NotAfter: time.Now().Add(time.Hour),
270+ KeyUsage: x509.KeyUsageDigitalSignature,
271+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
272+ }
273+ der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
274+ if err != nil {
275+ return tls.Certificate{}, err
276+ }
277+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
278+ keyBytes, err := x509.MarshalECPrivateKey(key)
279+ if err != nil {
280+ return tls.Certificate{}, err
281+ }
282+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
283+ return tls.X509KeyPair(certPEM, keyPEM)
284+}
285+
286+func TestHostMatchesTLSWildcard(t *testing.T) {
287+ assert.True(t, hostMatchesTLSWildcard("api.example.com", "*.example.com"))
288+ assert.False(t, hostMatchesTLSWildcard("example.com", "*.example.com"))
289+ assert.False(t, hostMatchesTLSWildcard("api.other.com", "*.example.com"))
290+ assert.True(t, strings.EqualFold(normalizeTLSName("API.EXAMPLE.COM."), "api.example.com"))
291+}
@@ -0,0 +1,291 @@
1+package traffic
2+
3+import (
4+ "bufio"
5+ "context"
6+ "crypto/ecdsa"
7+ "crypto/elliptic"
8+ "crypto/rand"
9+ "crypto/tls"
10+ "crypto/x509"
11+ "crypto/x509/pkix"
12+ "encoding/pem"
13+ "io"
14+ "math/big"
15+ "net"
16+ "net/http"
17+ "strings"
18+ "testing"
19+ "time"
20+
21+ "github.com/bnema/zerowrap"
22+ "github.com/stretchr/testify/assert"
23+ "github.com/stretchr/testify/require"
24+
25+ "github.com/bnema/gordon/internal/domain"
26+)
27+
28+func TestClientHelloPeek(t *testing.T) {
29+ t.Run("valid sni returns replayable bytes", func(t *testing.T) {
30+ sni, replayed := peekHelloFromTLSClient(t, "raw.example.com")
31+ assert.Equal(t, "raw.example.com", sni)
32+ buf := make([]byte, 5)
33+ n, err := replayed.Read(buf)
34+ require.NoError(t, err)
35+ assert.Equal(t, 5, n)
36+ assert.Equal(t, byte(22), buf[0])
37+ })
38+
39+ t.Run("no sni", func(t *testing.T) {
40+ sni, _ := peekHelloFromTLSClient(t, "")
41+ assert.Empty(t, sni)
42+ })
43+
44+ t.Run("malformed", func(t *testing.T) {
45+ client, server := net.Pipe()
46+ go func() {
47+ _, _ = client.Write([]byte("not tls"))
48+ _ = client.Close()
49+ }()
50+ _, _, err := peekClientHelloSNI(server)
51+ require.Error(t, err)
52+ })
53+
54+ t.Run("fragmented", func(t *testing.T) {
55+ hello := clientHelloBytes(t, "fragmented.example.com")
56+ client, server := net.Pipe()
57+ go func() {
58+ for _, b := range hello {
59+ _, _ = client.Write([]byte{b})
60+ }
61+ _ = client.Close()
62+ }()
63+ sni, replayed, err := peekClientHelloSNI(server)
64+ require.NoError(t, err)
65+ assert.Equal(t, "fragmented.example.com", sni)
66+ buf := make([]byte, len(hello))
67+ _, err = io.ReadFull(replayed, buf)
68+ require.NoError(t, err)
69+ assert.Equal(t, hello, buf)
70+ })
71+}
72+
73+func TestTLSPassthrough(t *testing.T) {
74+ backendA := startTLSGreetingServer(t, "backend-a", "backend-a\n")
75+ backendB := startTLSGreetingServer(t, "backend-b", "backend-b\n")
76+ backendExact := startTLSGreetingServer(t, "backend-exact", "backend-exact\n")
77+
78+ graph := tlsGraph(t, freeTCPAddress(t), []tlsRoute{
79+ {name: "raw", sni: "raw.example.com", service: "raw", backend: backendA.address},
80+ {name: "wild", sni: "*.example.com", service: "wild", backend: backendB.address},
81+ {name: "exact", sni: "api.example.com", service: "exact", backend: backendExact.address},
82+ })
83+ manager := NewManager(zerowrap.Default())
84+ require.NoError(t, manager.Apply(context.Background(), &graph))
85+ defer shutdownManager(t, manager)
86+
87+ assertTLSGreeting(t, graph.EntryPoints[0].Address, "raw.example.com", "backend-a\n")
88+ assertTLSGreeting(t, graph.EntryPoints[0].Address, "shop.example.com", "backend-b\n")
89+ assertTLSGreeting(t, graph.EntryPoints[0].Address, "api.example.com", "backend-exact\n")
90+}
91+
92+func TestTLSPassthroughUnknownCloses(t *testing.T) {
93+ backend := startTLSGreetingServer(t, "backend", "backend\n")
94+ graph := tlsGraph(t, freeTCPAddress(t), []tlsRoute{{name: "raw", sni: "raw.example.com", service: "raw", backend: backend.address}})
95+ manager := NewManager(zerowrap.Default())
96+ require.NoError(t, manager.Apply(context.Background(), &graph))
97+ defer shutdownManager(t, manager)
98+
99+ _, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", graph.EntryPoints[0].Address, &tls.Config{ServerName: "unknown.example.com", InsecureSkipVerify: true})
100+ require.Error(t, err)
101+ assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == 1 }, time.Second, 10*time.Millisecond)
102+}
103+
104+func TestTLSMuxIncompleteClientHelloDoesNotHangShutdown(t *testing.T) {
105+ graph := domain.TrafficGraph{
106+ Options: domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: time.Second, IdleTimeout: time.Minute, DrainTimeout: 50 * time.Millisecond}},
107+ EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTLSMux}},
108+ }
109+ manager := NewManager(zerowrap.Default())
110+ require.NoError(t, manager.Apply(context.Background(), &graph))
111+
112+ conn := dialTCP(t, graph.EntryPoints[0].Address)
113+ _, err := conn.Write([]byte{22})
114+ require.NoError(t, err)
115+
116+ ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
117+ defer cancel()
118+ started := time.Now()
119+ require.NoError(t, manager.Shutdown(ctx))
120+ assert.Less(t, time.Since(started), 250*time.Millisecond)
121+ _ = conn.Close()
122+}
123+
124+func TestTLSMuxHTTPSFallback(t *testing.T) {
125+ backend := startTLSGreetingServer(t, "raw-backend", "raw\n")
126+ graph := tlsGraph(t, freeTCPAddress(t), []tlsRoute{{name: "raw", sni: "raw.example.com", service: "raw", backend: backend.address}})
127+ manager := NewManager(zerowrap.Default())
128+ fallbackCert := testTLSConfig(t, "fallback.example.com")
129+ manager.SetTLSFallback("websecure", func(ctx context.Context, conn net.Conn) {
130+ _ = ServeHTTPSFallback(ctx, conn, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
131+ _, _ = w.Write([]byte("fallback"))
132+ }), fallbackCert)
133+ })
134+ require.NoError(t, manager.Apply(context.Background(), &graph))
135+ defer shutdownManager(t, manager)
136+
137+ assertTLSGreeting(t, graph.EntryPoints[0].Address, "raw.example.com", "raw\n")
138+ assertHTTPSFallback(t, graph.EntryPoints[0].Address, "app.example.com", "fallback")
139+ assertHTTPSFallback(t, graph.EntryPoints[0].Address, "", "fallback")
140+
141+ conn := dialTCP(t, graph.EntryPoints[0].Address)
142+ _, err := conn.Write([]byte("malformed"))
143+ require.NoError(t, err)
144+ _ = conn.Close()
145+ assert.Eventually(t, func() bool { return manager.Status().Counters.TotalErrors >= 1 }, time.Second, 10*time.Millisecond)
146+}
147+
148+func peekHelloFromTLSClient(t *testing.T, serverName string) (string, net.Conn) {
149+ t.Helper()
150+ client, server := net.Pipe()
151+ done := make(chan struct{})
152+ go func() {
153+ defer close(done)
154+ _ = tls.Client(client, &tls.Config{ServerName: serverName, InsecureSkipVerify: true}).Handshake()
155+ _ = client.Close()
156+ }()
157+ sni, replayed, err := peekClientHelloSNI(server)
158+ require.NoError(t, err)
159+ t.Cleanup(func() {
160+ _ = replayed.Close()
161+ <-done
162+ })
163+ return sni, replayed
164+}
165+
166+func clientHelloBytes(t *testing.T, serverName string) []byte {
167+ t.Helper()
168+ _, replayed := peekHelloFromTLSClient(t, serverName)
169+ buf := make([]byte, maxClientHelloBytes)
170+ n, err := replayed.Read(buf)
171+ require.NoError(t, err)
172+ return append([]byte{}, buf[:n]...)
173+}
174+
175+type tlsRoute struct {
176+ name string
177+ sni string
178+ service string
179+ backend string
180+}
181+
182+func tlsGraph(t *testing.T, listenAddress string, routes []tlsRoute) domain.TrafficGraph {
183+ t.Helper()
184+ graph := domain.TrafficGraph{
185+ Options: domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: time.Second, IdleTimeout: time.Minute, DrainTimeout: time.Second}},
186+ EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: listenAddress, Protocol: domain.EntryPointProtocolTLSMux}},
187+ }
188+ for _, route := range routes {
189+ backend, err := backendFromAddress(route.service+":tls", route.backend)
190+ require.NoError(t, err)
191+ ref := serviceRef(route.service, "tls")
192+ graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: route.name, EntryPoint: "websecure", Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: route.sni}, Service: ref})
193+ graph.Services = append(graph.Services, domain.TrafficService{Name: ref, Backends: []domain.TrafficBackend{backend}})
194+ }
195+ require.NoError(t, graph.Validate())
196+ return graph
197+}
198+
199+func assertTLSGreeting(t *testing.T, address string, serverName string, want string) {
200+ t.Helper()
201+ conn, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", address, &tls.Config{ServerName: serverName, InsecureSkipVerify: true})
202+ require.NoError(t, err)
203+ defer conn.Close()
204+ line, err := bufio.NewReader(conn).ReadString('\n')
205+ require.NoError(t, err)
206+ assert.Equal(t, want, line)
207+}
208+
209+func assertHTTPSFallback(t *testing.T, address string, serverName string, wantBody string) {
210+ t.Helper()
211+ conn, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", address, &tls.Config{ServerName: serverName, InsecureSkipVerify: true})
212+ require.NoError(t, err)
213+ defer conn.Close()
214+ _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
215+ require.NoError(t, err)
216+ body, err := io.ReadAll(conn)
217+ require.NoError(t, err)
218+ assert.Contains(t, string(body), wantBody)
219+}
220+
221+type tlsGreetingServer struct{ address string }
222+
223+func startTLSGreetingServer(t *testing.T, commonName string, greeting string) tlsGreetingServer {
224+ t.Helper()
225+ listener, err := tls.Listen("tcp", "127.0.0.1:0", testTLSConfig(t, commonName))
226+ require.NoError(t, err)
227+ done := make(chan struct{})
228+ go func() {
229+ defer close(done)
230+ for {
231+ conn, err := listener.Accept()
232+ if err != nil {
233+ return
234+ }
235+ go func() {
236+ defer conn.Close()
237+ _, _ = conn.Write([]byte(greeting))
238+ }()
239+ }
240+ }()
241+ t.Cleanup(func() {
242+ _ = listener.Close()
243+ <-done
244+ })
245+ return tlsGreetingServer{address: listener.Addr().String()}
246+}
247+
248+func testTLSConfig(t *testing.T, commonName string) *tls.Config {
249+ t.Helper()
250+ cert, err := testCertificate(commonName)
251+ require.NoError(t, err)
252+ return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}
253+}
254+
255+func testCertificate(commonName string) (tls.Certificate, error) {
256+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
257+ if err != nil {
258+ return tls.Certificate{}, err
259+ }
260+ serial, err := rand.Int(rand.Reader, big.NewInt(1<<62))
261+ if err != nil {
262+ return tls.Certificate{}, err
263+ }
264+ template := x509.Certificate{
265+ SerialNumber: serial,
266+ Subject: pkix.Name{CommonName: commonName},
267+ DNSNames: []string{commonName},
268+ NotBefore: time.Now().Add(-time.Hour),
269+ NotAfter: time.Now().Add(time.Hour),
270+ KeyUsage: x509.KeyUsageDigitalSignature,
271+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
272+ }
273+ der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
274+ if err != nil {
275+ return tls.Certificate{}, err
276+ }
277+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
278+ keyBytes, err := x509.MarshalECPrivateKey(key)
279+ if err != nil {
280+ return tls.Certificate{}, err
281+ }
282+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
283+ return tls.X509KeyPair(certPEM, keyPEM)
284+}
285+
286+func TestHostMatchesTLSWildcard(t *testing.T) {
287+ assert.True(t, hostMatchesTLSWildcard("api.example.com", "*.example.com"))
288+ assert.False(t, hostMatchesTLSWildcard("example.com", "*.example.com"))
289+ assert.False(t, hostMatchesTLSWildcard("api.other.com", "*.example.com"))
290+ assert.True(t, strings.EqualFold(normalizeTLSName("API.EXAMPLE.COM."), "api.example.com"))
291+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close