feat(traffic): add tls passthrough mux
- commit
- 771cd59e7465fa9b1c42f1f7b0f313b7bd31881a
- author
- brice · · 2026-06-29 21:43 UTC
- parent
- 2eed61d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 31 | 31 | ||
| 32 | 32 | lastReloadStatus string | |
| 33 | 33 | lastReloadError string | |
| 34 | + | tlsFallbacks atomic.Value | |
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | 37 | // NewManager creates a traffic manager. | |
| 37 | 38 | func NewManager(log zerowrap.Logger) *Manager { | |
| 38 | - | return &Manager{ | |
| 39 | + | manager := &Manager{ | |
| 39 | 40 | log: log, | |
| 40 | 41 | listeners: map[string]*entryPointRuntime{}, | |
| 41 | 42 | lastReloadStatus: reloadStatusOK, | |
| 42 | 43 | } | |
| 44 | + | manager.tlsFallbacks.Store(tlsFallbacks{}) | |
| 45 | + | return manager | |
| 43 | 46 | } | |
| 44 | 47 | ||
| 45 | 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 | 145 | next := make(map[string]*entryPointRuntime, len(current)) | |
| 143 | 146 | created := []*entryPointRuntime{} | |
| 144 | 147 | for _, entryPoint := range graph.EntryPoints { | |
| 145 | - | if entryPoint.Protocol != domain.EntryPointProtocolTCP { | |
| 148 | + | if !isTCPListenerProtocol(entryPoint.Protocol) { | |
| 146 | 149 | continue | |
| 147 | 150 | } | |
| 148 | 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 | 165 | 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 | 172 | func (m *Manager) bindTCPEntryPoint(ctx context.Context, entryPoint domain.EntryPoint) (*entryPointRuntime, error) { | |
| 166 | 173 | listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", entryPoint.Address) | |
| 167 | 174 | if err != nil { | |
| @@ -31,15 +31,18 @@ type Manager struct { | |||||
| 31 | 31 | ||||
| 32 | lastReloadStatus string | 32 | lastReloadStatus string | ||
| 33 | lastReloadError string | 33 | 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 | continue | 149 | 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, nil | 165 | 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 ( | |||
| 7 | 7 | "io" | |
| 8 | 8 | "net" | |
| 9 | 9 | "strconv" | |
| 10 | + | "strings" | |
| 10 | 11 | "sync" | |
| 11 | 12 | "sync/atomic" | |
| 12 | 13 | "time" | |
| @@ -88,13 +89,79 @@ func (r *entryPointRuntime) handleTCPConn(client net.Conn) { | |||
| 88 | 89 | } | |
| 89 | 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 | 109 | if !ok { | |
| 93 | 110 | r.counters.totalRefused.Add(1) | |
| 94 | - | _ = client.Close() | |
| 111 | + | _ = tracked.client.Close() | |
| 95 | 112 | 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 | 165 | dialCtx, cancel := context.WithTimeout(r.ctx, options.DialTimeout) | |
| 99 | 166 | defer cancel() | |
| 100 | 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 | 171 | return | |
| 105 | 172 | } | |
| 106 | 173 | ||
| 107 | - | tracked := &trackedTCPConn{client: client, backend: backendConn} | |
| 108 | - | r.track(tracked) | |
| 109 | - | defer r.untrack(tracked) | |
| 110 | - | ||
| 174 | + | tracked.backend = backendConn | |
| 111 | 175 | r.counters.totalAccepted.Add(1) | |
| 112 | - | _ = router | |
| 113 | 176 | r.proxyTCP(client, backendConn, options.IdleTimeout) | |
| 114 | 177 | } | |
| 115 | 178 | ||
| @@ -143,6 +206,56 @@ func (r *entryPointRuntime) resolveTCPBackend() (domain.TrafficRouter, domain.Tr | |||
| 143 | 206 | 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 | 259 | func (r *entryPointRuntime) proxyTCP(client net.Conn, backend net.Conn, idleTimeout time.Duration) { | |
| 147 | 260 | defer client.Close() | |
| 148 | 261 | defer backend.Close() | |
| @@ -286,7 +399,9 @@ func (r *entryPointRuntime) closeActiveConns() { | |||
| 286 | 399 | defer r.mu.Unlock() | |
| 287 | 400 | for conn := range r.activeConns { | |
| 288 | 401 | _ = conn.client.Close() | |
| 289 | - | _ = conn.backend.Close() | |
| 402 | + | if conn.backend != nil { | |
| 403 | + | _ = conn.backend.Close() | |
| 404 | + | } | |
| 290 | 405 | } | |
| 291 | 406 | } | |
| 292 | 407 | ||
| @@ -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 | return | 112 | 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 | return | 171 | 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{}, false | 206 | 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 | + | } | |||