detached 3515791

feat(traffic): add smart tcp entrypoints

commit
3515791ae9810c30792db975d70afcadefd14508
author
brice · · 2026-06-30 13:27 UTC
parent
cd72440
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
18 files · +2430 -217
M go.mod +1 -1
@@ -21,6 +21,7 @@ require (
2121 github.com/mattn/go-runewidth v0.0.23
2222 github.com/muesli/termenv v0.16.0
2323 github.com/rivo/uniseg v0.4.7
24+ github.com/rs/zerolog v1.35.0
2425 github.com/smallstep/truststore v0.13.0
2526 github.com/spf13/cobra v1.10.2
2627 github.com/spf13/viper v1.21.0
@@ -77,7 +78,6 @@ require (
7778 github.com/morikuni/aec v1.1.0 // indirect
7879 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
7980 github.com/muesli/cancelreader v0.2.2 // indirect
80- github.com/rs/zerolog v1.35.0 // indirect
8181 github.com/sirupsen/logrus v1.9.4 // indirect
8282 github.com/vbatts/tar-split v0.12.2 // indirect
8383 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
@@ -21,6 +21,7 @@ require (
21 github.com/mattn/go-runewidth v0.0.2321 github.com/mattn/go-runewidth v0.0.23
22 github.com/muesli/termenv v0.16.022 github.com/muesli/termenv v0.16.0
23 github.com/rivo/uniseg v0.4.723 github.com/rivo/uniseg v0.4.7
24+ github.com/rs/zerolog v1.35.0
24 github.com/smallstep/truststore v0.13.025 github.com/smallstep/truststore v0.13.0
25 github.com/spf13/cobra v1.10.226 github.com/spf13/cobra v1.10.2
26 github.com/spf13/viper v1.21.027 github.com/spf13/viper v1.21.0
@@ -77,7 +78,6 @@ require (
77 github.com/morikuni/aec v1.1.0 // indirect78 github.com/morikuni/aec v1.1.0 // indirect
78 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect79 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
79 github.com/muesli/cancelreader v0.2.2 // indirect80 github.com/muesli/cancelreader v0.2.2 // indirect
80- github.com/rs/zerolog v1.35.0 // indirect
81 github.com/sirupsen/logrus v1.9.4 // indirect81 github.com/sirupsen/logrus v1.9.4 // indirect
82 github.com/vbatts/tar-split v0.12.2 // indirect82 github.com/vbatts/tar-split v0.12.2 // indirect
83 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect83 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
M internal/adapters/in/traffic/manager.go +61 -3
@@ -55,6 +55,8 @@ type Manager struct {
5555 lastReloadStatus string
5656 lastReloadError string
5757 tlsHTTPServers atomic.Value
58+ smartHTTPServers atomic.Value
59+ smartTLSServers atomic.Value
5860 }
5961
6062 // NewManager creates a traffic manager.
@@ -65,6 +67,8 @@ func NewManager() *Manager {
6567 lastReloadStatus: reloadStatusOK,
6668 }
6769 manager.tlsHTTPServers.Store(tlsHTTPServers{})
70+ manager.smartHTTPServers.Store(smartHTTPServers{})
71+ manager.smartTLSServers.Store(smartTLSServers{})
6872 return manager
6973 }
7074
@@ -101,7 +105,7 @@ func (m *Manager) Apply(ctx context.Context, graph *domain.TrafficGraph) error {
101105 }
102106
103107 for _, update := range tcpUpdates {
104- update.runtime.updateEntryPoint(update.entryPoint, update.trusted)
108+ update.runtime.updateEntryPoint(update.entryPoint, update.trusted, update.rawTrusted)
105109 }
106110 for _, update := range udpUpdates {
107111 update.runtime.updateEntryPoint(update.entryPoint, update.trusted)
@@ -252,6 +256,7 @@ type tcpRuntimeUpdate struct {
252256 runtime *entryPointRuntime
253257 entryPoint domain.EntryPoint
254258 trusted []*net.IPNet
259+ rawTrusted []*net.IPNet
255260 }
256261
257262 type udpRuntimeUpdate struct {
@@ -282,8 +287,13 @@ func (m *Manager) prepareTCPListeners(ctx context.Context, graph *domain.Traffic
282287 stopTCPRuntimes(ctx, created, effectiveTCPOptions(graph.Options.TCP).DrainTimeout)
283288 return nil, nil, nil, fmt.Errorf("parse trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)
284289 }
290+ rawTrusted, err := parseTrustedCIDRs(entryPoint.RawFallbackTrustedCIDRs)
291+ if err != nil {
292+ stopTCPRuntimes(ctx, created, effectiveTCPOptions(graph.Options.TCP).DrainTimeout)
293+ return nil, nil, nil, fmt.Errorf("parse raw fallback trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)
294+ }
285295 trafficDebug(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Msg("reusing tcp traffic listener for same-address entrypoint update")
286- updates = append(updates, tcpRuntimeUpdate{runtime: runtime, entryPoint: entryPoint, trusted: trusted})
296+ updates = append(updates, tcpRuntimeUpdate{runtime: runtime, entryPoint: entryPoint, trusted: trusted, rawTrusted: rawTrusted})
287297 next[entryPoint.Name] = runtime
288298 delete(current, runtime.entryPointSnapshot().Name)
289299 continue
@@ -312,7 +322,7 @@ func stopUDPRuntimes(ctx context.Context, runtimes []*udpEntryPointRuntime, drai
312322 }
313323
314324 func isTCPListenerProtocol(protocol domain.EntryPointProtocol) bool {
315- return protocol == domain.EntryPointProtocolTCP || protocol == domain.EntryPointProtocolTLSMux
325+ return protocol == domain.EntryPointProtocolTCP || protocol == domain.EntryPointProtocolTLSMux || protocol == domain.EntryPointProtocolSmartTCP
316326 }
317327
318328 func conflictingTCPRuntime(current map[string]*entryPointRuntime, entryPoint domain.EntryPoint) *entryPointRuntime {
@@ -334,6 +344,10 @@ func (m *Manager) bindTCPEntryPoint(ctx context.Context, entryPoint domain.Entry
334344 _ = listener.Close()
335345 return nil, fmt.Errorf("parse trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)
336346 }
347+ if _, err := parseTrustedCIDRs(entryPoint.RawFallbackTrustedCIDRs); err != nil {
348+ _ = listener.Close()
349+ return nil, fmt.Errorf("parse raw fallback trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)
350+ }
337351 trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("bound tcp traffic entrypoint")
338352 runtime := newEntryPointRuntime(ctx, m, entryPoint, listener, trusted)
339353 return runtime, nil
@@ -472,6 +486,7 @@ func entryPointStatuses(entries []domain.EntryPoint, listeners map[string]*entry
472486 status.TotalErrors = counters.TotalErrors
473487 status.BytesIn = counters.BytesIn
474488 status.BytesOut = counters.BytesOut
489+ status.SmartTCP = counters.SmartTCP
475490 }
476491 if runtime := udpListeners[entry.Name]; runtime != nil {
477492 counters := runtime.counters.snapshot()
@@ -526,6 +541,18 @@ func aggregateCounters(entries []domain.EntryPointStatus) domain.TrafficCounters
526541 counters.TotalErrors += entry.TotalErrors
527542 counters.BytesIn += entry.BytesIn
528543 counters.BytesOut += entry.BytesOut
544+ counters.SmartTCP.HTTPAccepted += entry.SmartTCP.HTTPAccepted
545+ counters.SmartTCP.H2CAccepted += entry.SmartTCP.H2CAccepted
546+ counters.SmartTCP.HTTPSFallbackAccepted += entry.SmartTCP.HTTPSFallbackAccepted
547+ counters.SmartTCP.TLSPassthroughAccepted += entry.SmartTCP.TLSPassthroughAccepted
548+ counters.SmartTCP.RawFallbackAccepted += entry.SmartTCP.RawFallbackAccepted
549+ counters.SmartTCP.EntrypointCIDRRefused += entry.SmartTCP.EntrypointCIDRRefused
550+ counters.SmartTCP.RawFallbackCIDRRefused += entry.SmartTCP.RawFallbackCIDRRefused
551+ counters.SmartTCP.PROXYRefused += entry.SmartTCP.PROXYRefused
552+ counters.SmartTCP.UnknownNoFallbackRefused += entry.SmartTCP.UnknownNoFallbackRefused
553+ counters.SmartTCP.MalformedRejected += entry.SmartTCP.MalformedRejected
554+ counters.SmartTCP.SniffTimeout += entry.SmartTCP.SniffTimeout
555+ counters.SmartTCP.ClientHelloTooLarge += entry.SmartTCP.ClientHelloTooLarge
529556 }
530557 return counters
531558 }
@@ -535,6 +562,7 @@ func cloneTrafficGraph(graph domain.TrafficGraph) domain.TrafficGraph {
535562 clone.EntryPoints = append([]domain.EntryPoint{}, graph.EntryPoints...)
536563 for i := range clone.EntryPoints {
537564 clone.EntryPoints[i].TrustedCIDRs = append([]string(nil), graph.EntryPoints[i].TrustedCIDRs...)
565+ clone.EntryPoints[i].RawFallbackTrustedCIDRs = append([]string(nil), graph.EntryPoints[i].RawFallbackTrustedCIDRs...)
538566 }
539567 clone.Routers = append([]domain.TrafficRouter{}, graph.Routers...)
540568 clone.Services = append([]domain.TrafficService{}, graph.Services...)
@@ -566,6 +594,22 @@ type trafficCounters struct {
566594 totalErrors atomic.Int64
567595 bytesIn atomic.Int64
568596 bytesOut atomic.Int64
597+ smartTCP smartTCPCounterSet
598+}
599+
600+type smartTCPCounterSet struct {
601+ httpAccepted atomic.Int64
602+ h2cAccepted atomic.Int64
603+ httpsFallbackAccepted atomic.Int64
604+ tlsPassthroughAccepted atomic.Int64
605+ rawFallbackAccepted atomic.Int64
606+ entrypointCIDRRefused atomic.Int64
607+ rawFallbackCIDRRefused atomic.Int64
608+ proxyRefused atomic.Int64
609+ unknownNoFallbackRefused atomic.Int64
610+ malformedRejected atomic.Int64
611+ sniffTimeout atomic.Int64
612+ clientHelloTooLarge atomic.Int64
569613 }
570614
571615 func (c *trafficCounters) snapshot() domain.TrafficCounters {
@@ -577,6 +621,20 @@ func (c *trafficCounters) snapshot() domain.TrafficCounters {
577621 TotalErrors: c.totalErrors.Load(),
578622 BytesIn: c.bytesIn.Load(),
579623 BytesOut: c.bytesOut.Load(),
624+ SmartTCP: domain.SmartTCPCounters{
625+ HTTPAccepted: c.smartTCP.httpAccepted.Load(),
626+ H2CAccepted: c.smartTCP.h2cAccepted.Load(),
627+ HTTPSFallbackAccepted: c.smartTCP.httpsFallbackAccepted.Load(),
628+ TLSPassthroughAccepted: c.smartTCP.tlsPassthroughAccepted.Load(),
629+ RawFallbackAccepted: c.smartTCP.rawFallbackAccepted.Load(),
630+ EntrypointCIDRRefused: c.smartTCP.entrypointCIDRRefused.Load(),
631+ RawFallbackCIDRRefused: c.smartTCP.rawFallbackCIDRRefused.Load(),
632+ PROXYRefused: c.smartTCP.proxyRefused.Load(),
633+ UnknownNoFallbackRefused: c.smartTCP.unknownNoFallbackRefused.Load(),
634+ MalformedRejected: c.smartTCP.malformedRejected.Load(),
635+ SniffTimeout: c.smartTCP.sniffTimeout.Load(),
636+ ClientHelloTooLarge: c.smartTCP.clientHelloTooLarge.Load(),
637+ },
580638 }
581639 }
582640
@@ -55,6 +55,8 @@ type Manager struct {
55 lastReloadStatus string55 lastReloadStatus string
56 lastReloadError string56 lastReloadError string
57 tlsHTTPServers atomic.Value57 tlsHTTPServers atomic.Value
58+ smartHTTPServers atomic.Value
59+ smartTLSServers atomic.Value
58 }60 }
59 61
60 // NewManager creates a traffic manager.62 // NewManager creates a traffic manager.
@@ -65,6 +67,8 @@ func NewManager() *Manager {
65 lastReloadStatus: reloadStatusOK,67 lastReloadStatus: reloadStatusOK,
66 }68 }
67 manager.tlsHTTPServers.Store(tlsHTTPServers{})69 manager.tlsHTTPServers.Store(tlsHTTPServers{})
70+ manager.smartHTTPServers.Store(smartHTTPServers{})
71+ manager.smartTLSServers.Store(smartTLSServers{})
68 return manager72 return manager
69 }73 }
70 74
@@ -101,7 +105,7 @@ func (m *Manager) Apply(ctx context.Context, graph *domain.TrafficGraph) error {
101 }105 }
102 106
103 for _, update := range tcpUpdates {107 for _, update := range tcpUpdates {
104- update.runtime.updateEntryPoint(update.entryPoint, update.trusted)108+ update.runtime.updateEntryPoint(update.entryPoint, update.trusted, update.rawTrusted)
105 }109 }
106 for _, update := range udpUpdates {110 for _, update := range udpUpdates {
107 update.runtime.updateEntryPoint(update.entryPoint, update.trusted)111 update.runtime.updateEntryPoint(update.entryPoint, update.trusted)
@@ -252,6 +256,7 @@ type tcpRuntimeUpdate struct {
252 runtime *entryPointRuntime256 runtime *entryPointRuntime
253 entryPoint domain.EntryPoint257 entryPoint domain.EntryPoint
254 trusted []*net.IPNet258 trusted []*net.IPNet
259+ rawTrusted []*net.IPNet
255 }260 }
256 261
257 type udpRuntimeUpdate struct {262 type udpRuntimeUpdate struct {
@@ -282,8 +287,13 @@ func (m *Manager) prepareTCPListeners(ctx context.Context, graph *domain.Traffic
282 stopTCPRuntimes(ctx, created, effectiveTCPOptions(graph.Options.TCP).DrainTimeout)287 stopTCPRuntimes(ctx, created, effectiveTCPOptions(graph.Options.TCP).DrainTimeout)
283 return nil, nil, nil, fmt.Errorf("parse trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)288 return nil, nil, nil, fmt.Errorf("parse trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)
284 }289 }
290+ rawTrusted, err := parseTrustedCIDRs(entryPoint.RawFallbackTrustedCIDRs)
291+ if err != nil {
292+ stopTCPRuntimes(ctx, created, effectiveTCPOptions(graph.Options.TCP).DrainTimeout)
293+ return nil, nil, nil, fmt.Errorf("parse raw fallback trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)
294+ }
285 trafficDebug(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Msg("reusing tcp traffic listener for same-address entrypoint update")295 trafficDebug(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Msg("reusing tcp traffic listener for same-address entrypoint update")
286- updates = append(updates, tcpRuntimeUpdate{runtime: runtime, entryPoint: entryPoint, trusted: trusted})296+ updates = append(updates, tcpRuntimeUpdate{runtime: runtime, entryPoint: entryPoint, trusted: trusted, rawTrusted: rawTrusted})
287 next[entryPoint.Name] = runtime297 next[entryPoint.Name] = runtime
288 delete(current, runtime.entryPointSnapshot().Name)298 delete(current, runtime.entryPointSnapshot().Name)
289 continue299 continue
@@ -312,7 +322,7 @@ func stopUDPRuntimes(ctx context.Context, runtimes []*udpEntryPointRuntime, drai
312 }322 }
313 323
314 func isTCPListenerProtocol(protocol domain.EntryPointProtocol) bool {324 func isTCPListenerProtocol(protocol domain.EntryPointProtocol) bool {
315- return protocol == domain.EntryPointProtocolTCP || protocol == domain.EntryPointProtocolTLSMux325+ return protocol == domain.EntryPointProtocolTCP || protocol == domain.EntryPointProtocolTLSMux || protocol == domain.EntryPointProtocolSmartTCP
316 }326 }
317 327
318 func conflictingTCPRuntime(current map[string]*entryPointRuntime, entryPoint domain.EntryPoint) *entryPointRuntime {328 func conflictingTCPRuntime(current map[string]*entryPointRuntime, entryPoint domain.EntryPoint) *entryPointRuntime {
@@ -334,6 +344,10 @@ func (m *Manager) bindTCPEntryPoint(ctx context.Context, entryPoint domain.Entry
334 _ = listener.Close()344 _ = listener.Close()
335 return nil, fmt.Errorf("parse trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)345 return nil, fmt.Errorf("parse trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)
336 }346 }
347+ if _, err := parseTrustedCIDRs(entryPoint.RawFallbackTrustedCIDRs); err != nil {
348+ _ = listener.Close()
349+ return nil, fmt.Errorf("parse raw fallback trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)
350+ }
337 trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("bound tcp traffic entrypoint")351 trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("bound tcp traffic entrypoint")
338 runtime := newEntryPointRuntime(ctx, m, entryPoint, listener, trusted)352 runtime := newEntryPointRuntime(ctx, m, entryPoint, listener, trusted)
339 return runtime, nil353 return runtime, nil
@@ -472,6 +486,7 @@ func entryPointStatuses(entries []domain.EntryPoint, listeners map[string]*entry
472 status.TotalErrors = counters.TotalErrors486 status.TotalErrors = counters.TotalErrors
473 status.BytesIn = counters.BytesIn487 status.BytesIn = counters.BytesIn
474 status.BytesOut = counters.BytesOut488 status.BytesOut = counters.BytesOut
489+ status.SmartTCP = counters.SmartTCP
475 }490 }
476 if runtime := udpListeners[entry.Name]; runtime != nil {491 if runtime := udpListeners[entry.Name]; runtime != nil {
477 counters := runtime.counters.snapshot()492 counters := runtime.counters.snapshot()
@@ -526,6 +541,18 @@ func aggregateCounters(entries []domain.EntryPointStatus) domain.TrafficCounters
526 counters.TotalErrors += entry.TotalErrors541 counters.TotalErrors += entry.TotalErrors
527 counters.BytesIn += entry.BytesIn542 counters.BytesIn += entry.BytesIn
528 counters.BytesOut += entry.BytesOut543 counters.BytesOut += entry.BytesOut
544+ counters.SmartTCP.HTTPAccepted += entry.SmartTCP.HTTPAccepted
545+ counters.SmartTCP.H2CAccepted += entry.SmartTCP.H2CAccepted
546+ counters.SmartTCP.HTTPSFallbackAccepted += entry.SmartTCP.HTTPSFallbackAccepted
547+ counters.SmartTCP.TLSPassthroughAccepted += entry.SmartTCP.TLSPassthroughAccepted
548+ counters.SmartTCP.RawFallbackAccepted += entry.SmartTCP.RawFallbackAccepted
549+ counters.SmartTCP.EntrypointCIDRRefused += entry.SmartTCP.EntrypointCIDRRefused
550+ counters.SmartTCP.RawFallbackCIDRRefused += entry.SmartTCP.RawFallbackCIDRRefused
551+ counters.SmartTCP.PROXYRefused += entry.SmartTCP.PROXYRefused
552+ counters.SmartTCP.UnknownNoFallbackRefused += entry.SmartTCP.UnknownNoFallbackRefused
553+ counters.SmartTCP.MalformedRejected += entry.SmartTCP.MalformedRejected
554+ counters.SmartTCP.SniffTimeout += entry.SmartTCP.SniffTimeout
555+ counters.SmartTCP.ClientHelloTooLarge += entry.SmartTCP.ClientHelloTooLarge
529 }556 }
530 return counters557 return counters
531 }558 }
@@ -535,6 +562,7 @@ func cloneTrafficGraph(graph domain.TrafficGraph) domain.TrafficGraph {
535 clone.EntryPoints = append([]domain.EntryPoint{}, graph.EntryPoints...)562 clone.EntryPoints = append([]domain.EntryPoint{}, graph.EntryPoints...)
536 for i := range clone.EntryPoints {563 for i := range clone.EntryPoints {
537 clone.EntryPoints[i].TrustedCIDRs = append([]string(nil), graph.EntryPoints[i].TrustedCIDRs...)564 clone.EntryPoints[i].TrustedCIDRs = append([]string(nil), graph.EntryPoints[i].TrustedCIDRs...)
565+ clone.EntryPoints[i].RawFallbackTrustedCIDRs = append([]string(nil), graph.EntryPoints[i].RawFallbackTrustedCIDRs...)
538 }566 }
539 clone.Routers = append([]domain.TrafficRouter{}, graph.Routers...)567 clone.Routers = append([]domain.TrafficRouter{}, graph.Routers...)
540 clone.Services = append([]domain.TrafficService{}, graph.Services...)568 clone.Services = append([]domain.TrafficService{}, graph.Services...)
@@ -566,6 +594,22 @@ type trafficCounters struct {
566 totalErrors atomic.Int64594 totalErrors atomic.Int64
567 bytesIn atomic.Int64595 bytesIn atomic.Int64
568 bytesOut atomic.Int64596 bytesOut atomic.Int64
597+ smartTCP smartTCPCounterSet
598+}
599+
600+type smartTCPCounterSet struct {
601+ httpAccepted atomic.Int64
602+ h2cAccepted atomic.Int64
603+ httpsFallbackAccepted atomic.Int64
604+ tlsPassthroughAccepted atomic.Int64
605+ rawFallbackAccepted atomic.Int64
606+ entrypointCIDRRefused atomic.Int64
607+ rawFallbackCIDRRefused atomic.Int64
608+ proxyRefused atomic.Int64
609+ unknownNoFallbackRefused atomic.Int64
610+ malformedRejected atomic.Int64
611+ sniffTimeout atomic.Int64
612+ clientHelloTooLarge atomic.Int64
569 }613 }
570 614
571 func (c *trafficCounters) snapshot() domain.TrafficCounters {615 func (c *trafficCounters) snapshot() domain.TrafficCounters {
@@ -577,6 +621,20 @@ func (c *trafficCounters) snapshot() domain.TrafficCounters {
577 TotalErrors: c.totalErrors.Load(),621 TotalErrors: c.totalErrors.Load(),
578 BytesIn: c.bytesIn.Load(),622 BytesIn: c.bytesIn.Load(),
579 BytesOut: c.bytesOut.Load(),623 BytesOut: c.bytesOut.Load(),
624+ SmartTCP: domain.SmartTCPCounters{
625+ HTTPAccepted: c.smartTCP.httpAccepted.Load(),
626+ H2CAccepted: c.smartTCP.h2cAccepted.Load(),
627+ HTTPSFallbackAccepted: c.smartTCP.httpsFallbackAccepted.Load(),
628+ TLSPassthroughAccepted: c.smartTCP.tlsPassthroughAccepted.Load(),
629+ RawFallbackAccepted: c.smartTCP.rawFallbackAccepted.Load(),
630+ EntrypointCIDRRefused: c.smartTCP.entrypointCIDRRefused.Load(),
631+ RawFallbackCIDRRefused: c.smartTCP.rawFallbackCIDRRefused.Load(),
632+ PROXYRefused: c.smartTCP.proxyRefused.Load(),
633+ UnknownNoFallbackRefused: c.smartTCP.unknownNoFallbackRefused.Load(),
634+ MalformedRejected: c.smartTCP.malformedRejected.Load(),
635+ SniffTimeout: c.smartTCP.sniffTimeout.Load(),
636+ ClientHelloTooLarge: c.smartTCP.clientHelloTooLarge.Load(),
637+ },
580 }638 }
581 }639 }
582 640
A internal/adapters/in/traffic/smart_tcp.go +248 -0
@@ -0,0 +1,248 @@
1+package traffic
2+
3+import (
4+ "context"
5+ "crypto/tls"
6+ "errors"
7+ "maps"
8+ "net"
9+ "net/http"
10+ "time"
11+
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+type SmartTCPHTTPServerConfig struct {
16+ Handler http.Handler
17+ Protocols *http.Protocols
18+}
19+
20+type SmartTCPTLSServerConfig struct {
21+ Handler http.Handler
22+ TLSConfig *tls.Config
23+}
24+
25+type smartHTTPServers map[string]SmartTCPHTTPServerConfig
26+type smartTLSServers map[string]SmartTCPTLSServerConfig
27+
28+func (m *Manager) SetSmartTCPHTTPServer(entryPoint string, handler http.Handler, protocols *http.Protocols) {
29+ m.mu.Lock()
30+ current := m.loadSmartHTTPServers()
31+ next := make(smartHTTPServers, len(current)+1)
32+ maps.Copy(next, current)
33+ if handler == nil {
34+ delete(next, entryPoint)
35+ } else {
36+ next[entryPoint] = SmartTCPHTTPServerConfig{Handler: handler, Protocols: protocols}
37+ }
38+ m.smartHTTPServers.Store(next)
39+ runtimes := m.smartTCPRuntimesLocked(entryPoint)
40+ m.mu.Unlock()
41+ for _, runtime := range runtimes {
42+ runtime.refreshSmartTCPHTTPServer(entryPoint)
43+ }
44+}
45+
46+func (m *Manager) SetSmartTCPTLSServer(entryPoint string, handler http.Handler, tlsConfig *tls.Config) {
47+ m.mu.Lock()
48+ current := m.loadSmartTLSServers()
49+ next := make(smartTLSServers, len(current)+1)
50+ maps.Copy(next, current)
51+ if handler == nil || tlsConfig == nil {
52+ delete(next, entryPoint)
53+ } else {
54+ next[entryPoint] = SmartTCPTLSServerConfig{Handler: handler, TLSConfig: tlsConfig.Clone()}
55+ }
56+ m.smartTLSServers.Store(next)
57+ runtimes := m.smartTCPRuntimesLocked(entryPoint)
58+ m.mu.Unlock()
59+ for _, runtime := range runtimes {
60+ runtime.refreshSmartTCPTLSServer(entryPoint)
61+ }
62+}
63+
64+func (m *Manager) smartTCPRuntimesLocked(entryPoint string) []*entryPointRuntime {
65+ runtimes := make([]*entryPointRuntime, 0, len(m.listeners))
66+ for _, runtime := range m.listeners {
67+ snapshot := runtime.entryPointSnapshot()
68+ if snapshot.Name == entryPoint && snapshot.Protocol == domain.EntryPointProtocolSmartTCP {
69+ runtimes = append(runtimes, runtime)
70+ }
71+ }
72+ return runtimes
73+}
74+
75+func (m *Manager) smartHTTPServer(entryPoint string) (SmartTCPHTTPServerConfig, bool) {
76+ config, ok := m.loadSmartHTTPServers()[entryPoint]
77+ return config, ok
78+}
79+
80+func (m *Manager) smartTLSServer(entryPoint string) (SmartTCPTLSServerConfig, bool) {
81+ config, ok := m.loadSmartTLSServers()[entryPoint]
82+ return config, ok
83+}
84+
85+func (m *Manager) loadSmartHTTPServers() smartHTTPServers {
86+ value := m.smartHTTPServers.Load()
87+ if value == nil {
88+ return smartHTTPServers{}
89+ }
90+ return value.(smartHTTPServers)
91+}
92+
93+func (m *Manager) loadSmartTLSServers() smartTLSServers {
94+ value := m.smartTLSServers.Load()
95+ if value == nil {
96+ return smartTLSServers{}
97+ }
98+ return value.(smartTLSServers)
99+}
100+
101+func (r *entryPointRuntime) startSmartTCPHTTPServers(entryPoint domain.EntryPoint) {
102+ if entryPoint.Protocol != domain.EntryPointProtocolSmartTCP {
103+ return
104+ }
105+ if config, ok := r.manager.smartHTTPServer(entryPoint.Name); ok && config.Handler != nil {
106+ r.replaceSmartTCPHTTPServer(entryPoint, config)
107+ }
108+ if config, ok := r.manager.smartTLSServer(entryPoint.Name); ok && config.Handler != nil && config.TLSConfig != nil {
109+ r.replaceSmartTCPTLSServer(entryPoint, config)
110+ }
111+}
112+
113+func (r *entryPointRuntime) refreshSmartTCPHTTPServer(entryPointName string) {
114+ entryPoint := r.entryPointSnapshot()
115+ if entryPoint.Name != entryPointName || entryPoint.Protocol != domain.EntryPointProtocolSmartTCP {
116+ return
117+ }
118+ config, ok := r.manager.smartHTTPServer(entryPointName)
119+ if !ok || config.Handler == nil {
120+ r.stopSmartTCPHTTPServer(r.ctx, 0)
121+ return
122+ }
123+ r.replaceSmartTCPHTTPServer(entryPoint, config)
124+}
125+
126+func (r *entryPointRuntime) refreshSmartTCPTLSServer(entryPointName string) {
127+ entryPoint := r.entryPointSnapshot()
128+ if entryPoint.Name != entryPointName || entryPoint.Protocol != domain.EntryPointProtocolSmartTCP {
129+ return
130+ }
131+ config, ok := r.manager.smartTLSServer(entryPointName)
132+ if !ok || config.Handler == nil || config.TLSConfig == nil {
133+ r.stopSmartTCPTLSServer(r.ctx, 0)
134+ return
135+ }
136+ r.replaceSmartTCPTLSServer(entryPoint, config)
137+}
138+
139+func (r *entryPointRuntime) replaceSmartTCPHTTPServer(entryPoint domain.EntryPoint, config SmartTCPHTTPServerConfig) {
140+ r.smartHTTPReplaceMu.Lock()
141+ defer r.smartHTTPReplaceMu.Unlock()
142+ r.stopSmartTCPHTTPServerLocked(r.ctx, 0)
143+ if r.isClosed() {
144+ return
145+ }
146+ listener := newTLSHTTPListener(r.listener.Addr())
147+ server := &http.Server{Handler: config.Handler, Protocols: config.Protocols, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second}
148+ done := make(chan struct{})
149+ r.mu.Lock()
150+ if r.isClosed() {
151+ r.mu.Unlock()
152+ _ = listener.Close()
153+ return
154+ }
155+ r.smartHTTPListener = listener
156+ r.smartHTTPServer = server
157+ r.smartHTTPDone = done
158+ r.mu.Unlock()
159+ go func() {
160+ defer close(done)
161+ if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
162+ trafficWarn(r.ctx).Err(err).Str("entrypoint", entryPoint.Name).Msg("smart tcp http server stopped with error")
163+ }
164+ }()
165+}
166+
167+func (r *entryPointRuntime) replaceSmartTCPTLSServer(entryPoint domain.EntryPoint, config SmartTCPTLSServerConfig) {
168+ r.smartTLSReplaceMu.Lock()
169+ defer r.smartTLSReplaceMu.Unlock()
170+ r.stopSmartTCPTLSServerLocked(r.ctx, 0)
171+ if r.isClosed() {
172+ return
173+ }
174+ listener := newTLSHTTPListener(r.listener.Addr())
175+ server := &http.Server{Handler: config.Handler, TLSConfig: config.TLSConfig.Clone(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second}
176+ done := make(chan struct{})
177+ r.mu.Lock()
178+ if r.isClosed() {
179+ r.mu.Unlock()
180+ _ = listener.Close()
181+ return
182+ }
183+ r.smartTLSListener = listener
184+ r.smartTLSServer = server
185+ r.smartTLSDone = done
186+ r.mu.Unlock()
187+ go func() {
188+ defer close(done)
189+ if err := server.ServeTLS(listener, "", ""); err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
190+ trafficWarn(r.ctx).Err(err).Str("entrypoint", entryPoint.Name).Msg("smart tcp https server stopped with error")
191+ }
192+ }()
193+}
194+
195+func (r *entryPointRuntime) stopSmartTCPHTTPServer(ctx context.Context, drainTimeout time.Duration) {
196+ r.smartHTTPReplaceMu.Lock()
197+ defer r.smartHTTPReplaceMu.Unlock()
198+ r.stopSmartTCPHTTPServerLocked(ctx, drainTimeout)
199+}
200+
201+func (r *entryPointRuntime) stopSmartTCPHTTPServerLocked(ctx context.Context, drainTimeout time.Duration) {
202+ r.mu.Lock()
203+ listener := r.smartHTTPListener
204+ server := r.smartHTTPServer
205+ done := r.smartHTTPDone
206+ r.smartHTTPListener = nil
207+ r.smartHTTPServer = nil
208+ r.smartHTTPDone = nil
209+ r.mu.Unlock()
210+ stopInternalHTTPServer(ctx, listener, server, done, drainTimeout)
211+}
212+
213+func (r *entryPointRuntime) stopSmartTCPTLSServer(ctx context.Context, drainTimeout time.Duration) {
214+ r.smartTLSReplaceMu.Lock()
215+ defer r.smartTLSReplaceMu.Unlock()
216+ r.stopSmartTCPTLSServerLocked(ctx, drainTimeout)
217+}
218+
219+func (r *entryPointRuntime) stopSmartTCPTLSServerLocked(ctx context.Context, drainTimeout time.Duration) {
220+ r.mu.Lock()
221+ listener := r.smartTLSListener
222+ server := r.smartTLSServer
223+ done := r.smartTLSDone
224+ r.smartTLSListener = nil
225+ r.smartTLSServer = nil
226+ r.smartTLSDone = nil
227+ r.mu.Unlock()
228+ stopInternalHTTPServer(ctx, listener, server, done, drainTimeout)
229+}
230+
231+func stopInternalHTTPServer(ctx context.Context, listener *tlsHTTPListener, server *http.Server, done chan struct{}, drainTimeout time.Duration) {
232+ if listener == nil || server == nil || done == nil {
233+ return
234+ }
235+ _ = listener.Close()
236+ if drainTimeout <= 0 {
237+ drainTimeout = defaultTCPOptions().DrainTimeout
238+ }
239+ shutdownCtx, cancel := context.WithTimeout(ctx, drainTimeout)
240+ defer cancel()
241+ if err := server.Shutdown(shutdownCtx); err != nil {
242+ _ = server.Close()
243+ }
244+ select {
245+ case <-done:
246+ case <-ctx.Done():
247+ }
248+}
@@ -0,0 +1,248 @@
1+package traffic
2+
3+import (
4+ "context"
5+ "crypto/tls"
6+ "errors"
7+ "maps"
8+ "net"
9+ "net/http"
10+ "time"
11+
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+type SmartTCPHTTPServerConfig struct {
16+ Handler http.Handler
17+ Protocols *http.Protocols
18+}
19+
20+type SmartTCPTLSServerConfig struct {
21+ Handler http.Handler
22+ TLSConfig *tls.Config
23+}
24+
25+type smartHTTPServers map[string]SmartTCPHTTPServerConfig
26+type smartTLSServers map[string]SmartTCPTLSServerConfig
27+
28+func (m *Manager) SetSmartTCPHTTPServer(entryPoint string, handler http.Handler, protocols *http.Protocols) {
29+ m.mu.Lock()
30+ current := m.loadSmartHTTPServers()
31+ next := make(smartHTTPServers, len(current)+1)
32+ maps.Copy(next, current)
33+ if handler == nil {
34+ delete(next, entryPoint)
35+ } else {
36+ next[entryPoint] = SmartTCPHTTPServerConfig{Handler: handler, Protocols: protocols}
37+ }
38+ m.smartHTTPServers.Store(next)
39+ runtimes := m.smartTCPRuntimesLocked(entryPoint)
40+ m.mu.Unlock()
41+ for _, runtime := range runtimes {
42+ runtime.refreshSmartTCPHTTPServer(entryPoint)
43+ }
44+}
45+
46+func (m *Manager) SetSmartTCPTLSServer(entryPoint string, handler http.Handler, tlsConfig *tls.Config) {
47+ m.mu.Lock()
48+ current := m.loadSmartTLSServers()
49+ next := make(smartTLSServers, len(current)+1)
50+ maps.Copy(next, current)
51+ if handler == nil || tlsConfig == nil {
52+ delete(next, entryPoint)
53+ } else {
54+ next[entryPoint] = SmartTCPTLSServerConfig{Handler: handler, TLSConfig: tlsConfig.Clone()}
55+ }
56+ m.smartTLSServers.Store(next)
57+ runtimes := m.smartTCPRuntimesLocked(entryPoint)
58+ m.mu.Unlock()
59+ for _, runtime := range runtimes {
60+ runtime.refreshSmartTCPTLSServer(entryPoint)
61+ }
62+}
63+
64+func (m *Manager) smartTCPRuntimesLocked(entryPoint string) []*entryPointRuntime {
65+ runtimes := make([]*entryPointRuntime, 0, len(m.listeners))
66+ for _, runtime := range m.listeners {
67+ snapshot := runtime.entryPointSnapshot()
68+ if snapshot.Name == entryPoint && snapshot.Protocol == domain.EntryPointProtocolSmartTCP {
69+ runtimes = append(runtimes, runtime)
70+ }
71+ }
72+ return runtimes
73+}
74+
75+func (m *Manager) smartHTTPServer(entryPoint string) (SmartTCPHTTPServerConfig, bool) {
76+ config, ok := m.loadSmartHTTPServers()[entryPoint]
77+ return config, ok
78+}
79+
80+func (m *Manager) smartTLSServer(entryPoint string) (SmartTCPTLSServerConfig, bool) {
81+ config, ok := m.loadSmartTLSServers()[entryPoint]
82+ return config, ok
83+}
84+
85+func (m *Manager) loadSmartHTTPServers() smartHTTPServers {
86+ value := m.smartHTTPServers.Load()
87+ if value == nil {
88+ return smartHTTPServers{}
89+ }
90+ return value.(smartHTTPServers)
91+}
92+
93+func (m *Manager) loadSmartTLSServers() smartTLSServers {
94+ value := m.smartTLSServers.Load()
95+ if value == nil {
96+ return smartTLSServers{}
97+ }
98+ return value.(smartTLSServers)
99+}
100+
101+func (r *entryPointRuntime) startSmartTCPHTTPServers(entryPoint domain.EntryPoint) {
102+ if entryPoint.Protocol != domain.EntryPointProtocolSmartTCP {
103+ return
104+ }
105+ if config, ok := r.manager.smartHTTPServer(entryPoint.Name); ok && config.Handler != nil {
106+ r.replaceSmartTCPHTTPServer(entryPoint, config)
107+ }
108+ if config, ok := r.manager.smartTLSServer(entryPoint.Name); ok && config.Handler != nil && config.TLSConfig != nil {
109+ r.replaceSmartTCPTLSServer(entryPoint, config)
110+ }
111+}
112+
113+func (r *entryPointRuntime) refreshSmartTCPHTTPServer(entryPointName string) {
114+ entryPoint := r.entryPointSnapshot()
115+ if entryPoint.Name != entryPointName || entryPoint.Protocol != domain.EntryPointProtocolSmartTCP {
116+ return
117+ }
118+ config, ok := r.manager.smartHTTPServer(entryPointName)
119+ if !ok || config.Handler == nil {
120+ r.stopSmartTCPHTTPServer(r.ctx, 0)
121+ return
122+ }
123+ r.replaceSmartTCPHTTPServer(entryPoint, config)
124+}
125+
126+func (r *entryPointRuntime) refreshSmartTCPTLSServer(entryPointName string) {
127+ entryPoint := r.entryPointSnapshot()
128+ if entryPoint.Name != entryPointName || entryPoint.Protocol != domain.EntryPointProtocolSmartTCP {
129+ return
130+ }
131+ config, ok := r.manager.smartTLSServer(entryPointName)
132+ if !ok || config.Handler == nil || config.TLSConfig == nil {
133+ r.stopSmartTCPTLSServer(r.ctx, 0)
134+ return
135+ }
136+ r.replaceSmartTCPTLSServer(entryPoint, config)
137+}
138+
139+func (r *entryPointRuntime) replaceSmartTCPHTTPServer(entryPoint domain.EntryPoint, config SmartTCPHTTPServerConfig) {
140+ r.smartHTTPReplaceMu.Lock()
141+ defer r.smartHTTPReplaceMu.Unlock()
142+ r.stopSmartTCPHTTPServerLocked(r.ctx, 0)
143+ if r.isClosed() {
144+ return
145+ }
146+ listener := newTLSHTTPListener(r.listener.Addr())
147+ server := &http.Server{Handler: config.Handler, Protocols: config.Protocols, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second}
148+ done := make(chan struct{})
149+ r.mu.Lock()
150+ if r.isClosed() {
151+ r.mu.Unlock()
152+ _ = listener.Close()
153+ return
154+ }
155+ r.smartHTTPListener = listener
156+ r.smartHTTPServer = server
157+ r.smartHTTPDone = done
158+ r.mu.Unlock()
159+ go func() {
160+ defer close(done)
161+ if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
162+ trafficWarn(r.ctx).Err(err).Str("entrypoint", entryPoint.Name).Msg("smart tcp http server stopped with error")
163+ }
164+ }()
165+}
166+
167+func (r *entryPointRuntime) replaceSmartTCPTLSServer(entryPoint domain.EntryPoint, config SmartTCPTLSServerConfig) {
168+ r.smartTLSReplaceMu.Lock()
169+ defer r.smartTLSReplaceMu.Unlock()
170+ r.stopSmartTCPTLSServerLocked(r.ctx, 0)
171+ if r.isClosed() {
172+ return
173+ }
174+ listener := newTLSHTTPListener(r.listener.Addr())
175+ server := &http.Server{Handler: config.Handler, TLSConfig: config.TLSConfig.Clone(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second}
176+ done := make(chan struct{})
177+ r.mu.Lock()
178+ if r.isClosed() {
179+ r.mu.Unlock()
180+ _ = listener.Close()
181+ return
182+ }
183+ r.smartTLSListener = listener
184+ r.smartTLSServer = server
185+ r.smartTLSDone = done
186+ r.mu.Unlock()
187+ go func() {
188+ defer close(done)
189+ if err := server.ServeTLS(listener, "", ""); err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
190+ trafficWarn(r.ctx).Err(err).Str("entrypoint", entryPoint.Name).Msg("smart tcp https server stopped with error")
191+ }
192+ }()
193+}
194+
195+func (r *entryPointRuntime) stopSmartTCPHTTPServer(ctx context.Context, drainTimeout time.Duration) {
196+ r.smartHTTPReplaceMu.Lock()
197+ defer r.smartHTTPReplaceMu.Unlock()
198+ r.stopSmartTCPHTTPServerLocked(ctx, drainTimeout)
199+}
200+
201+func (r *entryPointRuntime) stopSmartTCPHTTPServerLocked(ctx context.Context, drainTimeout time.Duration) {
202+ r.mu.Lock()
203+ listener := r.smartHTTPListener
204+ server := r.smartHTTPServer
205+ done := r.smartHTTPDone
206+ r.smartHTTPListener = nil
207+ r.smartHTTPServer = nil
208+ r.smartHTTPDone = nil
209+ r.mu.Unlock()
210+ stopInternalHTTPServer(ctx, listener, server, done, drainTimeout)
211+}
212+
213+func (r *entryPointRuntime) stopSmartTCPTLSServer(ctx context.Context, drainTimeout time.Duration) {
214+ r.smartTLSReplaceMu.Lock()
215+ defer r.smartTLSReplaceMu.Unlock()
216+ r.stopSmartTCPTLSServerLocked(ctx, drainTimeout)
217+}
218+
219+func (r *entryPointRuntime) stopSmartTCPTLSServerLocked(ctx context.Context, drainTimeout time.Duration) {
220+ r.mu.Lock()
221+ listener := r.smartTLSListener
222+ server := r.smartTLSServer
223+ done := r.smartTLSDone
224+ r.smartTLSListener = nil
225+ r.smartTLSServer = nil
226+ r.smartTLSDone = nil
227+ r.mu.Unlock()
228+ stopInternalHTTPServer(ctx, listener, server, done, drainTimeout)
229+}
230+
231+func stopInternalHTTPServer(ctx context.Context, listener *tlsHTTPListener, server *http.Server, done chan struct{}, drainTimeout time.Duration) {
232+ if listener == nil || server == nil || done == nil {
233+ return
234+ }
235+ _ = listener.Close()
236+ if drainTimeout <= 0 {
237+ drainTimeout = defaultTCPOptions().DrainTimeout
238+ }
239+ shutdownCtx, cancel := context.WithTimeout(ctx, drainTimeout)
240+ defer cancel()
241+ if err := server.Shutdown(shutdownCtx); err != nil {
242+ _ = server.Close()
243+ }
244+ select {
245+ case <-done:
246+ case <-ctx.Done():
247+ }
248+}
A internal/adapters/in/traffic/sniff.go +199 -0
@@ -0,0 +1,199 @@
1+package traffic
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "fmt"
7+ "net"
8+ "strings"
9+ "time"
10+)
11+
12+type smartTCPDispatchKind string
13+
14+const (
15+ dispatchHTTP smartTCPDispatchKind = "http"
16+ dispatchH2C smartTCPDispatchKind = "h2c"
17+ dispatchTLS smartTCPDispatchKind = "tls"
18+ dispatchUnknown smartTCPDispatchKind = "unknown"
19+ dispatchRejectPROXY smartTCPDispatchKind = "reject_proxy"
20+ dispatchSniffTimeout smartTCPDispatchKind = "sniff_timeout"
21+ dispatchReject smartTCPDispatchKind = "reject"
22+ dispatchRejectLarge smartTCPDispatchKind = "reject_large"
23+)
24+
25+type smartTCPSniffResult struct {
26+ kind smartTCPDispatchKind
27+ conn net.Conn
28+}
29+
30+const (
31+ maxSmartTCPSniffBytes = 128
32+ h2cPreface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
33+ proxyV2Signature = "\r\n\r\n\x00\r\nQUIT\n"
34+)
35+
36+func sniffSmartTCP(conn net.Conn, timeout time.Duration) (smartTCPSniffResult, error) {
37+ if timeout > 0 {
38+ if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
39+ return smartTCPSniffResult{}, fmt.Errorf("set sniff read deadline: %w", err)
40+ }
41+ }
42+
43+ buf := make([]byte, 0, 32)
44+ tmp := make([]byte, 1)
45+ for len(buf) < maxSmartTCPSniffBytes {
46+ n, err := conn.Read(tmp)
47+ if n > 0 {
48+ buf = append(buf, tmp[:n]...)
49+ if kind, done := classifySmartTCPPrefix(buf); done {
50+ return sniffResult(conn, buf, kind)
51+ }
52+ }
53+ if err != nil {
54+ if isTimeoutError(err) && len(buf) > 0 {
55+ return sniffResult(conn, buf, dispatchSniffTimeout)
56+ }
57+ return smartTCPSniffResult{}, fmt.Errorf("read smart tcp prefix: %w", err)
58+ }
59+ }
60+ return sniffResult(conn, buf, dispatchRejectLarge)
61+}
62+
63+func sniffResult(conn net.Conn, buf []byte, kind smartTCPDispatchKind) (smartTCPSniffResult, error) {
64+ if err := conn.SetReadDeadline(time.Time{}); err != nil {
65+ return smartTCPSniffResult{}, fmt.Errorf("clear sniff read deadline: %w", err)
66+ }
67+ return smartTCPSniffResult{kind: kind, conn: replayConn{Conn: conn, reader: bytes.NewReader(buf)}}, nil
68+}
69+
70+func classifySmartTCPPrefix(buf []byte) (smartTCPDispatchKind, bool) {
71+ if bytes.Equal(buf, []byte(proxyV2Signature)) || (len(buf) >= len(proxyV2Signature) && bytes.HasPrefix(buf, []byte(proxyV2Signature))) {
72+ return dispatchRejectPROXY, true
73+ }
74+ if bytes.Equal(buf, []byte("PROXY ")) || (len(buf) >= len("PROXY ") && bytes.HasPrefix(buf, []byte("PROXY "))) {
75+ return dispatchRejectPROXY, true
76+ }
77+ if isCompleteH2CPreface(buf) {
78+ return dispatchH2C, true
79+ }
80+ if hasAnyPrefix([]byte(h2cPreface), buf) {
81+ return "", false
82+ }
83+ if isHTTPRequestLine(buf) {
84+ return dispatchHTTP, true
85+ }
86+ if isMalformedHTTPLookingPrefix(buf) {
87+ return dispatchReject, true
88+ }
89+ if isTLSClientHelloPrefix(buf) {
90+ return dispatchTLS, true
91+ }
92+ if isMalformedTLSRecordHeader(buf) {
93+ return dispatchReject, true
94+ }
95+ if isAmbiguousSmartTCPPrefix(buf) {
96+ return "", false
97+ }
98+ return dispatchUnknown, true
99+}
100+
101+func isCompleteH2CPreface(buf []byte) bool {
102+ return len(buf) >= len(h2cPreface) && bytes.HasPrefix(buf, []byte(h2cPreface))
103+}
104+
105+func isHTTPRequestLine(buf []byte) bool {
106+ lineBytes, _, ok := bytes.Cut(buf, []byte("\r\n"))
107+ if !ok {
108+ return false
109+ }
110+ line := string(lineBytes)
111+ parts := strings.Split(line, " ")
112+ return len(parts) == 3 && parts[0] != "" && isHTTPToken(parts[0]) && parts[1] != "" && strings.HasPrefix(parts[2], "HTTP/")
113+}
114+
115+func isHTTPToken(token string) bool {
116+ if token == "" {
117+ return false
118+ }
119+ for _, r := range token {
120+ if r <= 32 || r >= 127 || strings.ContainsRune("()<>@,;:\\\"/[]?={}\t", r) {
121+ return false
122+ }
123+ }
124+ return true
125+}
126+
127+func isTLSClientHelloPrefix(buf []byte) bool {
128+ if len(buf) < 5 || buf[0] != 22 {
129+ return false
130+ }
131+ if buf[1] != 3 || buf[2] > 4 {
132+ return false
133+ }
134+ recordLen := int(buf[3])<<8 | int(buf[4])
135+ return recordLen > 0
136+}
137+
138+func isMalformedTLSRecordHeader(buf []byte) bool {
139+ if len(buf) < 5 || buf[0] != 22 {
140+ return false
141+ }
142+ if buf[1] != 3 || buf[2] > 4 {
143+ return false
144+ }
145+ recordLen := int(buf[3])<<8 | int(buf[4])
146+ return recordLen == 0
147+}
148+
149+func isAmbiguousSmartTCPPrefix(buf []byte) bool {
150+ return hasAnyPrefix([]byte(proxyV2Signature), buf) || hasAnyPrefix([]byte(h2cPreface), buf) || hasAnyPrefix([]byte("PROXY "), buf) || couldBecomeHTTPRequest(buf) || couldBecomeTLS(buf)
151+}
152+
153+func hasAnyPrefix(full []byte, partial []byte) bool {
154+ return len(partial) < len(full) && bytes.HasPrefix(full, partial)
155+}
156+
157+func isMalformedHTTPLookingPrefix(buf []byte) bool {
158+ lineBytes, _, ok := bytes.Cut(buf, []byte("\r\n"))
159+ if !ok || !bytes.Contains(lineBytes, []byte(" ")) {
160+ return false
161+ }
162+ method, _, _ := bytes.Cut(lineBytes, []byte(" "))
163+ return isHTTPToken(string(method))
164+}
165+
166+func couldBecomeHTTPRequest(buf []byte) bool {
167+ if len(buf) == 0 || bytes.Contains(buf, []byte{0}) {
168+ return false
169+ }
170+ if bytes.Contains(buf, []byte("\r\n")) {
171+ return false
172+ }
173+ method, _, _ := bytes.Cut(buf, []byte(" "))
174+ return isHTTPToken(string(method))
175+}
176+
177+func couldBecomeTLS(buf []byte) bool {
178+ if len(buf) == 0 || buf[0] != 22 {
179+ return false
180+ }
181+ if len(buf) == 1 {
182+ return true
183+ }
184+ if buf[1] != 3 {
185+ return false
186+ }
187+ if len(buf) == 2 {
188+ return true
189+ }
190+ if buf[2] > 4 {
191+ return false
192+ }
193+ return len(buf) < 5
194+}
195+
196+func isTimeoutError(err error) bool {
197+ var netErr net.Error
198+ return errors.As(err, &netErr) && netErr.Timeout()
199+}
@@ -0,0 +1,199 @@
1+package traffic
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "fmt"
7+ "net"
8+ "strings"
9+ "time"
10+)
11+
12+type smartTCPDispatchKind string
13+
14+const (
15+ dispatchHTTP smartTCPDispatchKind = "http"
16+ dispatchH2C smartTCPDispatchKind = "h2c"
17+ dispatchTLS smartTCPDispatchKind = "tls"
18+ dispatchUnknown smartTCPDispatchKind = "unknown"
19+ dispatchRejectPROXY smartTCPDispatchKind = "reject_proxy"
20+ dispatchSniffTimeout smartTCPDispatchKind = "sniff_timeout"
21+ dispatchReject smartTCPDispatchKind = "reject"
22+ dispatchRejectLarge smartTCPDispatchKind = "reject_large"
23+)
24+
25+type smartTCPSniffResult struct {
26+ kind smartTCPDispatchKind
27+ conn net.Conn
28+}
29+
30+const (
31+ maxSmartTCPSniffBytes = 128
32+ h2cPreface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
33+ proxyV2Signature = "\r\n\r\n\x00\r\nQUIT\n"
34+)
35+
36+func sniffSmartTCP(conn net.Conn, timeout time.Duration) (smartTCPSniffResult, error) {
37+ if timeout > 0 {
38+ if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
39+ return smartTCPSniffResult{}, fmt.Errorf("set sniff read deadline: %w", err)
40+ }
41+ }
42+
43+ buf := make([]byte, 0, 32)
44+ tmp := make([]byte, 1)
45+ for len(buf) < maxSmartTCPSniffBytes {
46+ n, err := conn.Read(tmp)
47+ if n > 0 {
48+ buf = append(buf, tmp[:n]...)
49+ if kind, done := classifySmartTCPPrefix(buf); done {
50+ return sniffResult(conn, buf, kind)
51+ }
52+ }
53+ if err != nil {
54+ if isTimeoutError(err) && len(buf) > 0 {
55+ return sniffResult(conn, buf, dispatchSniffTimeout)
56+ }
57+ return smartTCPSniffResult{}, fmt.Errorf("read smart tcp prefix: %w", err)
58+ }
59+ }
60+ return sniffResult(conn, buf, dispatchRejectLarge)
61+}
62+
63+func sniffResult(conn net.Conn, buf []byte, kind smartTCPDispatchKind) (smartTCPSniffResult, error) {
64+ if err := conn.SetReadDeadline(time.Time{}); err != nil {
65+ return smartTCPSniffResult{}, fmt.Errorf("clear sniff read deadline: %w", err)
66+ }
67+ return smartTCPSniffResult{kind: kind, conn: replayConn{Conn: conn, reader: bytes.NewReader(buf)}}, nil
68+}
69+
70+func classifySmartTCPPrefix(buf []byte) (smartTCPDispatchKind, bool) {
71+ if bytes.Equal(buf, []byte(proxyV2Signature)) || (len(buf) >= len(proxyV2Signature) && bytes.HasPrefix(buf, []byte(proxyV2Signature))) {
72+ return dispatchRejectPROXY, true
73+ }
74+ if bytes.Equal(buf, []byte("PROXY ")) || (len(buf) >= len("PROXY ") && bytes.HasPrefix(buf, []byte("PROXY "))) {
75+ return dispatchRejectPROXY, true
76+ }
77+ if isCompleteH2CPreface(buf) {
78+ return dispatchH2C, true
79+ }
80+ if hasAnyPrefix([]byte(h2cPreface), buf) {
81+ return "", false
82+ }
83+ if isHTTPRequestLine(buf) {
84+ return dispatchHTTP, true
85+ }
86+ if isMalformedHTTPLookingPrefix(buf) {
87+ return dispatchReject, true
88+ }
89+ if isTLSClientHelloPrefix(buf) {
90+ return dispatchTLS, true
91+ }
92+ if isMalformedTLSRecordHeader(buf) {
93+ return dispatchReject, true
94+ }
95+ if isAmbiguousSmartTCPPrefix(buf) {
96+ return "", false
97+ }
98+ return dispatchUnknown, true
99+}
100+
101+func isCompleteH2CPreface(buf []byte) bool {
102+ return len(buf) >= len(h2cPreface) && bytes.HasPrefix(buf, []byte(h2cPreface))
103+}
104+
105+func isHTTPRequestLine(buf []byte) bool {
106+ lineBytes, _, ok := bytes.Cut(buf, []byte("\r\n"))
107+ if !ok {
108+ return false
109+ }
110+ line := string(lineBytes)
111+ parts := strings.Split(line, " ")
112+ return len(parts) == 3 && parts[0] != "" && isHTTPToken(parts[0]) && parts[1] != "" && strings.HasPrefix(parts[2], "HTTP/")
113+}
114+
115+func isHTTPToken(token string) bool {
116+ if token == "" {
117+ return false
118+ }
119+ for _, r := range token {
120+ if r <= 32 || r >= 127 || strings.ContainsRune("()<>@,;:\\\"/[]?={}\t", r) {
121+ return false
122+ }
123+ }
124+ return true
125+}
126+
127+func isTLSClientHelloPrefix(buf []byte) bool {
128+ if len(buf) < 5 || buf[0] != 22 {
129+ return false
130+ }
131+ if buf[1] != 3 || buf[2] > 4 {
132+ return false
133+ }
134+ recordLen := int(buf[3])<<8 | int(buf[4])
135+ return recordLen > 0
136+}
137+
138+func isMalformedTLSRecordHeader(buf []byte) bool {
139+ if len(buf) < 5 || buf[0] != 22 {
140+ return false
141+ }
142+ if buf[1] != 3 || buf[2] > 4 {
143+ return false
144+ }
145+ recordLen := int(buf[3])<<8 | int(buf[4])
146+ return recordLen == 0
147+}
148+
149+func isAmbiguousSmartTCPPrefix(buf []byte) bool {
150+ return hasAnyPrefix([]byte(proxyV2Signature), buf) || hasAnyPrefix([]byte(h2cPreface), buf) || hasAnyPrefix([]byte("PROXY "), buf) || couldBecomeHTTPRequest(buf) || couldBecomeTLS(buf)
151+}
152+
153+func hasAnyPrefix(full []byte, partial []byte) bool {
154+ return len(partial) < len(full) && bytes.HasPrefix(full, partial)
155+}
156+
157+func isMalformedHTTPLookingPrefix(buf []byte) bool {
158+ lineBytes, _, ok := bytes.Cut(buf, []byte("\r\n"))
159+ if !ok || !bytes.Contains(lineBytes, []byte(" ")) {
160+ return false
161+ }
162+ method, _, _ := bytes.Cut(lineBytes, []byte(" "))
163+ return isHTTPToken(string(method))
164+}
165+
166+func couldBecomeHTTPRequest(buf []byte) bool {
167+ if len(buf) == 0 || bytes.Contains(buf, []byte{0}) {
168+ return false
169+ }
170+ if bytes.Contains(buf, []byte("\r\n")) {
171+ return false
172+ }
173+ method, _, _ := bytes.Cut(buf, []byte(" "))
174+ return isHTTPToken(string(method))
175+}
176+
177+func couldBecomeTLS(buf []byte) bool {
178+ if len(buf) == 0 || buf[0] != 22 {
179+ return false
180+ }
181+ if len(buf) == 1 {
182+ return true
183+ }
184+ if buf[1] != 3 {
185+ return false
186+ }
187+ if len(buf) == 2 {
188+ return true
189+ }
190+ if buf[2] > 4 {
191+ return false
192+ }
193+ return len(buf) < 5
194+}
195+
196+func isTimeoutError(err error) bool {
197+ var netErr net.Error
198+ return errors.As(err, &netErr) && netErr.Timeout()
199+}
A internal/adapters/in/traffic/sniff_test.go +229 -0
@@ -0,0 +1,229 @@
1+package traffic
2+
3+import (
4+ "errors"
5+ "io"
6+ "net"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestSniffSmartTCPClassifiesHTTP(t *testing.T) {
15+ for _, tc := range []struct {
16+ name string
17+ data string
18+ }{
19+ {name: "get", data: "GET / HTTP/1.1\r\n"},
20+ {name: "propfind", data: "PROPFIND / HTTP/1.1\r\n"},
21+ {name: "mkcol", data: "MKCOL / HTTP/1.1\r\n"},
22+ {name: "unknown_method", data: "FOO / HTTP/1.1\r\n"},
23+ {name: "extension_method", data: "BAR / HTTP/1.1\r\n"},
24+ } {
25+ t.Run(tc.name, func(t *testing.T) {
26+ result := sniffBytes(t, []byte(tc.data), time.Second)
27+ assert.Equal(t, dispatchHTTP, result.kind)
28+ assertReplayPrefix(t, result.conn, []byte(tc.data))
29+ })
30+ }
31+}
32+
33+func TestSniffSmartTCPClassifiesH2C(t *testing.T) {
34+ preface := []byte("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n")
35+ result := sniffBytes(t, preface, time.Second)
36+ require.Equal(t, dispatchH2C, result.kind)
37+ assertReplayPrefix(t, result.conn, preface)
38+}
39+
40+func TestSniffSmartTCPClassifiesTLSAndAllowsClientHelloParsing(t *testing.T) {
41+ hello := clientHelloBytes(t, "sniff.example.com")
42+ result := sniffBytes(t, hello, time.Second)
43+ require.Equal(t, dispatchTLS, result.kind)
44+
45+ sni, replayed, err := peekClientHelloSNI(result.conn)
46+ require.NoError(t, err)
47+ assert.Equal(t, "sniff.example.com", sni)
48+ assertReplayPrefix(t, replayed, hello)
49+}
50+
51+func TestSniffSmartTCPRejectsMalformedTLSLookingZeroLengthRecord(t *testing.T) {
52+ result := sniffBytes(t, []byte{22, 3, 3, 0, 0}, time.Second)
53+ assert.Equal(t, dispatchReject, result.kind)
54+ assertReplayPrefix(t, result.conn, []byte{22, 3, 3, 0, 0})
55+}
56+
57+func TestSniffSmartTCPClassifiesSSHBannerAsUnknown(t *testing.T) {
58+ banner := []byte("SSH-2.0-client\r\n")
59+ kind, done := classifySmartTCPPrefix(banner)
60+ require.True(t, done)
61+ assert.Equal(t, dispatchUnknown, kind)
62+
63+ result := sniffBytes(t, banner, time.Second)
64+ assert.Equal(t, dispatchUnknown, result.kind)
65+ assertReplayPrefix(t, result.conn, banner)
66+}
67+
68+func TestSniffSmartTCPRejectsMalformedHTTPLookingRequestLine(t *testing.T) {
69+ data := []byte("GET /broken\r\n")
70+ kind, done := classifySmartTCPPrefix(data)
71+ require.True(t, done)
72+ assert.Equal(t, dispatchReject, kind)
73+
74+ result := sniffBytes(t, data, time.Second)
75+ assert.Equal(t, dispatchReject, result.kind)
76+ assertReplayPrefix(t, result.conn, data)
77+}
78+
79+func TestSniffSmartTCPRejectsPROXY(t *testing.T) {
80+ for _, tc := range []struct {
81+ name string
82+ data []byte
83+ }{
84+ {name: "v1", data: []byte("PROXY TCP4 192.0.2.1 192.0.2.2 12345 443\r\n")},
85+ {name: "v2", data: append([]byte{0x0d, 0x0a, 0x0d, 0x0a, 0x00, 0x0d, 0x0a, 0x51, 0x55, 0x49, 0x54, 0x0a}, []byte{0x21, 0x11, 0x00, 0x0c}...)},
86+ } {
87+ t.Run(tc.name, func(t *testing.T) {
88+ result := sniffBytes(t, tc.data, time.Second)
89+ assert.Equal(t, dispatchRejectPROXY, result.kind)
90+ assertReplayPrefix(t, result.conn, tc.data)
91+ })
92+ }
93+}
94+
95+func TestSniffSmartTCPUnknownIsImmediate(t *testing.T) {
96+ client, server := net.Pipe()
97+ defer client.Close()
98+
99+ start := time.Now()
100+ done := make(chan smartTCPSniffResult, 1)
101+ go func() {
102+ result, err := sniffSmartTCP(server, time.Second)
103+ require.NoError(t, err)
104+ done <- result
105+ }()
106+
107+ _, err := client.Write([]byte{0})
108+ require.NoError(t, err)
109+ select {
110+ case result := <-done:
111+ assert.Equal(t, dispatchUnknown, result.kind)
112+ assert.Less(t, time.Since(start), 500*time.Millisecond)
113+ case <-time.After(500 * time.Millisecond):
114+ t.Fatal("sniff did not classify unknown bytes immediately")
115+ }
116+}
117+
118+func TestSniffSmartTCPAmbiguousPartialsTimeOut(t *testing.T) {
119+ for _, tc := range []struct {
120+ name string
121+ data []byte
122+ }{
123+ {name: "g", data: []byte("G")},
124+ {name: "b", data: []byte("B")},
125+ {name: "bar", data: []byte("BAR")},
126+ {name: "pr", data: []byte("PR")},
127+ {name: "tls_content_type", data: []byte{22}},
128+ {name: "tls_version_major", data: []byte{22, 3}},
129+ {name: "tls_version_minor", data: []byte{22, 3, 3}},
130+ {name: "tls_record_length_partial", data: []byte{22, 3, 3, 0}},
131+ } {
132+ t.Run(tc.name, func(t *testing.T) {
133+ result := sniffBytes(t, tc.data, 20*time.Millisecond)
134+ assert.Equal(t, dispatchSniffTimeout, result.kind)
135+ assertReplayPrefix(t, result.conn, tc.data)
136+ })
137+ }
138+}
139+
140+func TestSniffSmartTCPReplayReturnsSniffedBytesBeforeUnderlyingBytes(t *testing.T) {
141+ client, server := net.Pipe()
142+ defer client.Close()
143+
144+ done := make(chan smartTCPSniffResult, 1)
145+ go func() {
146+ result, err := sniffSmartTCP(server, time.Second)
147+ require.NoError(t, err)
148+ done <- result
149+ }()
150+ _, err := client.Write([]byte("GET / HTTP/1.1\r\n"))
151+ require.NoError(t, err)
152+ result := <-done
153+ writeDone := make(chan error, 1)
154+ go func() {
155+ _, err := client.Write([]byte("Host: example.com\r\n\r\n"))
156+ writeDone <- err
157+ }()
158+
159+ buf := make([]byte, len("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"))
160+ _, err = io.ReadFull(result.conn, buf)
161+ require.NoError(t, err)
162+ assert.Equal(t, "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n", string(buf))
163+ require.NoError(t, <-writeDone)
164+}
165+
166+func TestSniffSmartTCPClearsReadDeadlineBeforeHandoff(t *testing.T) {
167+ conn := &deadlineConn{Conn: pipeConnWithData(t, []byte("GET / HTTP/1.1\r\n"))}
168+ result, err := sniffSmartTCP(conn, time.Second)
169+ require.NoError(t, err)
170+ assert.Equal(t, dispatchHTTP, result.kind)
171+ assert.True(t, conn.sawNonZeroReadDeadline)
172+ assert.True(t, conn.sawClearedReadDeadline)
173+}
174+
175+func sniffBytes(t *testing.T, data []byte, timeout time.Duration) smartTCPSniffResult {
176+ t.Helper()
177+ conn := pipeConnWithData(t, data)
178+ result, err := sniffSmartTCP(conn, timeout)
179+ require.NoError(t, err)
180+ return result
181+}
182+
183+func pipeConnWithData(t *testing.T, data []byte) net.Conn {
184+ t.Helper()
185+ client, server := net.Pipe()
186+ done := make(chan struct{})
187+ go func() {
188+ defer close(done)
189+ _, _ = client.Write(data)
190+ }()
191+ t.Cleanup(func() {
192+ _ = client.Close()
193+ _ = server.Close()
194+ <-done
195+ })
196+ return server
197+}
198+
199+func assertReplayPrefix(t *testing.T, conn net.Conn, want []byte) {
200+ t.Helper()
201+ buf := make([]byte, len(want))
202+ _, err := io.ReadFull(conn, buf)
203+ require.NoError(t, err)
204+ assert.Equal(t, want, buf)
205+}
206+
207+type deadlineConn struct {
208+ net.Conn
209+ sawNonZeroReadDeadline bool
210+ sawClearedReadDeadline bool
211+}
212+
213+func (c *deadlineConn) SetReadDeadline(t time.Time) error {
214+ if t.IsZero() {
215+ c.sawClearedReadDeadline = true
216+ } else {
217+ c.sawNonZeroReadDeadline = true
218+ }
219+ return c.Conn.SetReadDeadline(t)
220+}
221+
222+func TestSniffSmartTCPDoesNotTreatClosedPipeAsSuccess(t *testing.T) {
223+ client, server := net.Pipe()
224+ _ = client.Close()
225+ _, err := sniffSmartTCP(server, time.Second)
226+ if err == nil && !errors.Is(err, net.ErrClosed) {
227+ t.Fatal("expected closed connection to return an error")
228+ }
229+}
@@ -0,0 +1,229 @@
1+package traffic
2+
3+import (
4+ "errors"
5+ "io"
6+ "net"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestSniffSmartTCPClassifiesHTTP(t *testing.T) {
15+ for _, tc := range []struct {
16+ name string
17+ data string
18+ }{
19+ {name: "get", data: "GET / HTTP/1.1\r\n"},
20+ {name: "propfind", data: "PROPFIND / HTTP/1.1\r\n"},
21+ {name: "mkcol", data: "MKCOL / HTTP/1.1\r\n"},
22+ {name: "unknown_method", data: "FOO / HTTP/1.1\r\n"},
23+ {name: "extension_method", data: "BAR / HTTP/1.1\r\n"},
24+ } {
25+ t.Run(tc.name, func(t *testing.T) {
26+ result := sniffBytes(t, []byte(tc.data), time.Second)
27+ assert.Equal(t, dispatchHTTP, result.kind)
28+ assertReplayPrefix(t, result.conn, []byte(tc.data))
29+ })
30+ }
31+}
32+
33+func TestSniffSmartTCPClassifiesH2C(t *testing.T) {
34+ preface := []byte("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n")
35+ result := sniffBytes(t, preface, time.Second)
36+ require.Equal(t, dispatchH2C, result.kind)
37+ assertReplayPrefix(t, result.conn, preface)
38+}
39+
40+func TestSniffSmartTCPClassifiesTLSAndAllowsClientHelloParsing(t *testing.T) {
41+ hello := clientHelloBytes(t, "sniff.example.com")
42+ result := sniffBytes(t, hello, time.Second)
43+ require.Equal(t, dispatchTLS, result.kind)
44+
45+ sni, replayed, err := peekClientHelloSNI(result.conn)
46+ require.NoError(t, err)
47+ assert.Equal(t, "sniff.example.com", sni)
48+ assertReplayPrefix(t, replayed, hello)
49+}
50+
51+func TestSniffSmartTCPRejectsMalformedTLSLookingZeroLengthRecord(t *testing.T) {
52+ result := sniffBytes(t, []byte{22, 3, 3, 0, 0}, time.Second)
53+ assert.Equal(t, dispatchReject, result.kind)
54+ assertReplayPrefix(t, result.conn, []byte{22, 3, 3, 0, 0})
55+}
56+
57+func TestSniffSmartTCPClassifiesSSHBannerAsUnknown(t *testing.T) {
58+ banner := []byte("SSH-2.0-client\r\n")
59+ kind, done := classifySmartTCPPrefix(banner)
60+ require.True(t, done)
61+ assert.Equal(t, dispatchUnknown, kind)
62+
63+ result := sniffBytes(t, banner, time.Second)
64+ assert.Equal(t, dispatchUnknown, result.kind)
65+ assertReplayPrefix(t, result.conn, banner)
66+}
67+
68+func TestSniffSmartTCPRejectsMalformedHTTPLookingRequestLine(t *testing.T) {
69+ data := []byte("GET /broken\r\n")
70+ kind, done := classifySmartTCPPrefix(data)
71+ require.True(t, done)
72+ assert.Equal(t, dispatchReject, kind)
73+
74+ result := sniffBytes(t, data, time.Second)
75+ assert.Equal(t, dispatchReject, result.kind)
76+ assertReplayPrefix(t, result.conn, data)
77+}
78+
79+func TestSniffSmartTCPRejectsPROXY(t *testing.T) {
80+ for _, tc := range []struct {
81+ name string
82+ data []byte
83+ }{
84+ {name: "v1", data: []byte("PROXY TCP4 192.0.2.1 192.0.2.2 12345 443\r\n")},
85+ {name: "v2", data: append([]byte{0x0d, 0x0a, 0x0d, 0x0a, 0x00, 0x0d, 0x0a, 0x51, 0x55, 0x49, 0x54, 0x0a}, []byte{0x21, 0x11, 0x00, 0x0c}...)},
86+ } {
87+ t.Run(tc.name, func(t *testing.T) {
88+ result := sniffBytes(t, tc.data, time.Second)
89+ assert.Equal(t, dispatchRejectPROXY, result.kind)
90+ assertReplayPrefix(t, result.conn, tc.data)
91+ })
92+ }
93+}
94+
95+func TestSniffSmartTCPUnknownIsImmediate(t *testing.T) {
96+ client, server := net.Pipe()
97+ defer client.Close()
98+
99+ start := time.Now()
100+ done := make(chan smartTCPSniffResult, 1)
101+ go func() {
102+ result, err := sniffSmartTCP(server, time.Second)
103+ require.NoError(t, err)
104+ done <- result
105+ }()
106+
107+ _, err := client.Write([]byte{0})
108+ require.NoError(t, err)
109+ select {
110+ case result := <-done:
111+ assert.Equal(t, dispatchUnknown, result.kind)
112+ assert.Less(t, time.Since(start), 500*time.Millisecond)
113+ case <-time.After(500 * time.Millisecond):
114+ t.Fatal("sniff did not classify unknown bytes immediately")
115+ }
116+}
117+
118+func TestSniffSmartTCPAmbiguousPartialsTimeOut(t *testing.T) {
119+ for _, tc := range []struct {
120+ name string
121+ data []byte
122+ }{
123+ {name: "g", data: []byte("G")},
124+ {name: "b", data: []byte("B")},
125+ {name: "bar", data: []byte("BAR")},
126+ {name: "pr", data: []byte("PR")},
127+ {name: "tls_content_type", data: []byte{22}},
128+ {name: "tls_version_major", data: []byte{22, 3}},
129+ {name: "tls_version_minor", data: []byte{22, 3, 3}},
130+ {name: "tls_record_length_partial", data: []byte{22, 3, 3, 0}},
131+ } {
132+ t.Run(tc.name, func(t *testing.T) {
133+ result := sniffBytes(t, tc.data, 20*time.Millisecond)
134+ assert.Equal(t, dispatchSniffTimeout, result.kind)
135+ assertReplayPrefix(t, result.conn, tc.data)
136+ })
137+ }
138+}
139+
140+func TestSniffSmartTCPReplayReturnsSniffedBytesBeforeUnderlyingBytes(t *testing.T) {
141+ client, server := net.Pipe()
142+ defer client.Close()
143+
144+ done := make(chan smartTCPSniffResult, 1)
145+ go func() {
146+ result, err := sniffSmartTCP(server, time.Second)
147+ require.NoError(t, err)
148+ done <- result
149+ }()
150+ _, err := client.Write([]byte("GET / HTTP/1.1\r\n"))
151+ require.NoError(t, err)
152+ result := <-done
153+ writeDone := make(chan error, 1)
154+ go func() {
155+ _, err := client.Write([]byte("Host: example.com\r\n\r\n"))
156+ writeDone <- err
157+ }()
158+
159+ buf := make([]byte, len("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"))
160+ _, err = io.ReadFull(result.conn, buf)
161+ require.NoError(t, err)
162+ assert.Equal(t, "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n", string(buf))
163+ require.NoError(t, <-writeDone)
164+}
165+
166+func TestSniffSmartTCPClearsReadDeadlineBeforeHandoff(t *testing.T) {
167+ conn := &deadlineConn{Conn: pipeConnWithData(t, []byte("GET / HTTP/1.1\r\n"))}
168+ result, err := sniffSmartTCP(conn, time.Second)
169+ require.NoError(t, err)
170+ assert.Equal(t, dispatchHTTP, result.kind)
171+ assert.True(t, conn.sawNonZeroReadDeadline)
172+ assert.True(t, conn.sawClearedReadDeadline)
173+}
174+
175+func sniffBytes(t *testing.T, data []byte, timeout time.Duration) smartTCPSniffResult {
176+ t.Helper()
177+ conn := pipeConnWithData(t, data)
178+ result, err := sniffSmartTCP(conn, timeout)
179+ require.NoError(t, err)
180+ return result
181+}
182+
183+func pipeConnWithData(t *testing.T, data []byte) net.Conn {
184+ t.Helper()
185+ client, server := net.Pipe()
186+ done := make(chan struct{})
187+ go func() {
188+ defer close(done)
189+ _, _ = client.Write(data)
190+ }()
191+ t.Cleanup(func() {
192+ _ = client.Close()
193+ _ = server.Close()
194+ <-done
195+ })
196+ return server
197+}
198+
199+func assertReplayPrefix(t *testing.T, conn net.Conn, want []byte) {
200+ t.Helper()
201+ buf := make([]byte, len(want))
202+ _, err := io.ReadFull(conn, buf)
203+ require.NoError(t, err)
204+ assert.Equal(t, want, buf)
205+}
206+
207+type deadlineConn struct {
208+ net.Conn
209+ sawNonZeroReadDeadline bool
210+ sawClearedReadDeadline bool
211+}
212+
213+func (c *deadlineConn) SetReadDeadline(t time.Time) error {
214+ if t.IsZero() {
215+ c.sawClearedReadDeadline = true
216+ } else {
217+ c.sawNonZeroReadDeadline = true
218+ }
219+ return c.Conn.SetReadDeadline(t)
220+}
221+
222+func TestSniffSmartTCPDoesNotTreatClosedPipeAsSuccess(t *testing.T) {
223+ client, server := net.Pipe()
224+ _ = client.Close()
225+ _, err := sniffSmartTCP(server, time.Second)
226+ if err == nil && !errors.Is(err, net.ErrClosed) {
227+ t.Fatal("expected closed connection to return an error")
228+ }
229+}
M internal/adapters/in/traffic/tcp.go +243 -30
@@ -39,13 +39,28 @@ type entryPointRuntime struct {
3939 tlsHTTPListener *tlsHTTPListener
4040 tlsHTTPServer *http.Server
4141 tlsHTTPDone chan struct{}
42+
43+ smartHTTPReplaceMu sync.Mutex
44+ smartHTTPListener *tlsHTTPListener
45+ smartHTTPServer *http.Server
46+ smartHTTPDone chan struct{}
47+
48+ smartTLSReplaceMu sync.Mutex
49+ smartTLSListener *tlsHTTPListener
50+ smartTLSServer *http.Server
51+ smartTLSDone chan struct{}
52+
53+ rawFallbackTrusted []*net.IPNet
4254 }
4355
4456 type trackedTCPConn struct {
45- mu sync.Mutex
46- client net.Conn
47- backend net.Conn
48- closing bool
57+ mu sync.Mutex
58+ client net.Conn
59+ backend net.Conn
60+ closing bool
61+ onDone func()
62+ doneOnce sync.Once
63+ rawFallbackName string
4964 }
5065
5166 func (c *trackedTCPConn) setBackend(backend net.Conn) {
@@ -71,17 +86,39 @@ func (c *trackedTCPConn) close() {
7186 }
7287 }
7388
89+func (c *trackedTCPConn) complete() {
90+ c.doneOnce.Do(func() {
91+ if c.onDone != nil {
92+ c.onDone()
93+ }
94+ })
95+}
96+
97+func (c *trackedTCPConn) markRawFallback(name string) {
98+ c.mu.Lock()
99+ c.rawFallbackName = name
100+ c.mu.Unlock()
101+}
102+
103+func (c *trackedTCPConn) rawFallback() (string, bool) {
104+ c.mu.Lock()
105+ defer c.mu.Unlock()
106+ return c.rawFallbackName, c.rawFallbackName != ""
107+}
108+
74109 func newEntryPointRuntime(parentCtx context.Context, manager *Manager, entryPoint domain.EntryPoint, listener net.Listener, trusted []*net.IPNet) *entryPointRuntime {
75110 ctx, cancel := context.WithCancel(context.WithoutCancel(parentCtx))
111+ rawTrusted, _ := parseTrustedCIDRs(entryPoint.RawFallbackTrustedCIDRs)
76112 return &entryPointRuntime{
77- manager: manager,
78- entryPoint: entryPoint,
79- listener: listener,
80- trusted: trusted,
81- ctx: ctx,
82- cancel: cancel,
83- acceptDone: make(chan struct{}),
84- activeConns: map[*trackedTCPConn]struct{}{},
113+ manager: manager,
114+ entryPoint: entryPoint,
115+ listener: listener,
116+ trusted: trusted,
117+ rawFallbackTrusted: rawTrusted,
118+ ctx: ctx,
119+ cancel: cancel,
120+ acceptDone: make(chan struct{}),
121+ activeConns: map[*trackedTCPConn]struct{}{},
85122 }
86123 }
87124
@@ -91,6 +128,7 @@ func (r *entryPointRuntime) start() {
91128 }
92129 entryPoint := r.entryPointSnapshot()
93130 r.startTLSHTTPServer(entryPoint)
131+ r.startSmartTCPHTTPServers(entryPoint)
94132 trafficInfo(r.ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("started tcp traffic entrypoint")
95133 go r.acceptLoop()
96134 }
@@ -166,14 +204,28 @@ func (r *entryPointRuntime) routeToHTTPS(tracked *trackedTCPConn, conn net.Conn)
166204 r.mu.Lock()
167205 listener := r.tlsHTTPListener
168206 r.mu.Unlock()
207+ return r.routeToHTTPListener(tracked, conn, listener)
208+}
209+
210+func (r *entryPointRuntime) routeToSmartHTTP(tracked *trackedTCPConn, conn net.Conn) bool {
211+ r.mu.Lock()
212+ listener := r.smartHTTPListener
213+ r.mu.Unlock()
214+ return r.routeToHTTPListener(tracked, conn, listener)
215+}
216+
217+func (r *entryPointRuntime) routeToSmartHTTPS(tracked *trackedTCPConn, conn net.Conn) bool {
218+ r.mu.Lock()
219+ listener := r.smartTLSListener
220+ r.mu.Unlock()
221+ return r.routeToHTTPListener(tracked, conn, listener)
222+}
223+
224+func (r *entryPointRuntime) routeToHTTPListener(tracked *trackedTCPConn, conn net.Conn, listener *tlsHTTPListener) bool {
169225 if listener == nil {
170226 return false
171227 }
172- if listener.serve(&trackedHTTPConn{Conn: conn, onClose: func() { r.untrack(tracked) }}) {
173- return true
174- }
175- _ = conn.Close()
176- return true
228+ return listener.serve(&trackedHTTPConn{Conn: conn, onClose: tracked.complete})
177229 }
178230
179231 type trackedHTTPConn struct {
@@ -211,6 +263,9 @@ func (r *entryPointRuntime) acceptLoop() {
211263 }
212264 if !trustedRemoteAddr(r.trustedSnapshot(), conn.RemoteAddr()) {
213265 r.counters.totalRefused.Add(1)
266+ if r.entryPointSnapshot().Protocol == domain.EntryPointProtocolSmartTCP {
267+ r.counters.smartTCP.entrypointCIDRRefused.Add(1)
268+ }
214269 _ = conn.Close()
215270 continue
216271 }
@@ -220,22 +275,25 @@ func (r *entryPointRuntime) acceptLoop() {
220275 }
221276
222277 func (r *entryPointRuntime) handleTCPConn(client net.Conn) {
223- defer r.activeWG.Done()
224-
225278 options := effectiveTCPOptions(snapshotTCPOptions(r.manager.snapshot.Load()))
226279 if !r.reserveConnection(options.MaxConnections) {
227280 r.counters.totalRefused.Add(1)
228281 _ = client.Close()
282+ r.activeWG.Done()
229283 return
230284 }
231- defer r.releaseConnection()
232285
233286 tracked := &trackedTCPConn{client: client}
287+ tracked.onDone = func() {
288+ r.untrack(tracked)
289+ r.releaseConnection()
290+ r.activeWG.Done()
291+ }
234292 r.track(tracked)
235- untrackOnReturn := true
293+ completeOnReturn := true
236294 defer func() {
237- if untrackOnReturn {
238- r.untrack(tracked)
295+ if completeOnReturn {
296+ tracked.complete()
239297 }
240298 }()
241299
@@ -244,7 +302,11 @@ func (r *entryPointRuntime) handleTCPConn(client net.Conn) {
244302 r.handlePlainTCP(tracked, options)
245303 case domain.EntryPointProtocolTLSMux:
246304 if r.handleTLSMux(tracked, options) {
247- untrackOnReturn = false
305+ completeOnReturn = false
306+ }
307+ case domain.EntryPointProtocolSmartTCP:
308+ if r.handleSmartTCP(tracked, options) {
309+ completeOnReturn = false
248310 }
249311 default:
250312 r.counters.totalRefused.Add(1)
@@ -262,6 +324,98 @@ func (r *entryPointRuntime) handlePlainTCP(tracked *trackedTCPConn, options doma
262324 r.proxyToBackend(tracked, tracked.client, backend, options)
263325 }
264326
327+func (r *entryPointRuntime) handleSmartTCP(tracked *trackedTCPConn, options domain.TCPOptions) bool {
328+ result, err := sniffSmartTCP(tracked.client, clientHelloTimeout(options))
329+ if err != nil {
330+ r.counters.totalErrors.Add(1)
331+ _ = tracked.client.Close()
332+ return false
333+ }
334+ switch result.kind {
335+ case dispatchHTTP, dispatchH2C:
336+ return r.handleSmartTCPHTTP(tracked, result)
337+ case dispatchTLS:
338+ return r.handleSmartTCPTLS(tracked, result.conn, options)
339+ case dispatchUnknown:
340+ return r.handleSmartTCPUnknown(tracked, result.conn, options)
341+ case dispatchRejectPROXY:
342+ return r.refuseSmartTCP(result.conn, r.counters.smartTCP.proxyRefused.Add)
343+ case dispatchSniffTimeout:
344+ return r.refuseSmartTCP(result.conn, r.counters.smartTCP.sniffTimeout.Add)
345+ case dispatchReject:
346+ return r.refuseSmartTCP(result.conn, r.counters.smartTCP.malformedRejected.Add)
347+ case dispatchRejectLarge:
348+ return r.refuseSmartTCP(result.conn, r.counters.smartTCP.clientHelloTooLarge.Add)
349+ default:
350+ return r.refuseSmartTCP(result.conn, nil)
351+ }
352+}
353+
354+func (r *entryPointRuntime) handleSmartTCPHTTP(tracked *trackedTCPConn, result smartTCPSniffResult) bool {
355+ if !r.routeToSmartHTTP(tracked, result.conn) {
356+ return r.refuseSmartTCP(result.conn, nil)
357+ }
358+ r.counters.totalAccepted.Add(1)
359+ if result.kind == dispatchH2C {
360+ r.counters.smartTCP.h2cAccepted.Add(1)
361+ } else {
362+ r.counters.smartTCP.httpAccepted.Add(1)
363+ }
364+ return true
365+}
366+
367+func (r *entryPointRuntime) handleSmartTCPTLS(tracked *trackedTCPConn, conn net.Conn, options domain.TCPOptions) bool {
368+ peeked, err := r.peekTLSClientHello(conn, options)
369+ if err != nil {
370+ return r.handleSmartTCPTLSPeekError(conn, err)
371+ }
372+ if backend, ok := r.resolveTLSBackend(peeked.sni); ok {
373+ r.counters.smartTCP.tlsPassthroughAccepted.Add(1)
374+ r.proxyToBackend(tracked, peeked.conn, backend, options)
375+ return false
376+ }
377+ if r.routeToSmartHTTPS(tracked, peeked.conn) {
378+ r.counters.totalAccepted.Add(1)
379+ r.counters.smartTCP.httpsFallbackAccepted.Add(1)
380+ return true
381+ }
382+ return r.refuseSmartTCP(peeked.conn, nil)
383+}
384+
385+func (r *entryPointRuntime) handleSmartTCPTLSPeekError(conn net.Conn, err error) bool {
386+ if isClientHelloTooLargeError(err) {
387+ r.counters.totalRefused.Add(1)
388+ r.counters.smartTCP.clientHelloTooLarge.Add(1)
389+ } else {
390+ r.counters.totalErrors.Add(1)
391+ }
392+ _ = conn.Close()
393+ return false
394+}
395+
396+func (r *entryPointRuntime) handleSmartTCPUnknown(tracked *trackedTCPConn, conn net.Conn, options domain.TCPOptions) bool {
397+ backend, rawFallbackName, ok, rawCIDRRefused := r.resolveRawFallbackBackendDetailed(conn.RemoteAddr())
398+ if ok {
399+ tracked.markRawFallback(rawFallbackName)
400+ r.counters.smartTCP.rawFallbackAccepted.Add(1)
401+ r.proxyToBackend(tracked, conn, backend, options)
402+ return false
403+ }
404+ if rawCIDRRefused {
405+ return r.refuseSmartTCP(conn, r.counters.smartTCP.rawFallbackCIDRRefused.Add)
406+ }
407+ return r.refuseSmartTCP(conn, r.counters.smartTCP.unknownNoFallbackRefused.Add)
408+}
409+
410+func (r *entryPointRuntime) refuseSmartTCP(conn net.Conn, increment func(int64) int64) bool {
411+ r.counters.totalRefused.Add(1)
412+ if increment != nil {
413+ increment(1)
414+ }
415+ _ = conn.Close()
416+ return false
417+}
418+
265419 func (r *entryPointRuntime) handleTLSMux(tracked *trackedTCPConn, options domain.TCPOptions) bool {
266420 peeked, err := r.peekTLSClientHello(tracked.client, options)
267421 if err != nil {
@@ -296,6 +450,10 @@ func (r *entryPointRuntime) peekTLSClientHello(client net.Conn, options domain.T
296450 return peekedTLSConn{sni: sni, conn: replayed}, nil
297451 }
298452
453+func isClientHelloTooLargeError(err error) bool {
454+ return strings.Contains(err.Error(), "client hello exceeds")
455+}
456+
299457 func clientHelloTimeout(options domain.TCPOptions) time.Duration {
300458 const maxTimeout = 5 * time.Second
301459 if options.DialTimeout > 0 && options.DialTimeout < maxTimeout {
@@ -309,19 +467,44 @@ type peekedTLSConn struct {
309467 conn net.Conn
310468 }
311469
312-func (r *entryPointRuntime) proxyToBackend(tracked *trackedTCPConn, client net.Conn, backend domain.TrafficBackend, options domain.TCPOptions) {
470+func (r *entryPointRuntime) proxyToBackend(tracked *trackedTCPConn, client net.Conn, backend domain.TrafficBackend, options domain.TCPOptions) bool {
313471 dialCtx, cancel := context.WithTimeout(r.ctx, options.DialTimeout)
314472 defer cancel()
315473 backendConn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", net.JoinHostPort(backend.Host, strconv.Itoa(backend.Port)))
316474 if err != nil {
317475 r.counters.totalErrors.Add(1)
318476 _ = client.Close()
319- return
477+ return false
320478 }
321479
322480 r.setBackend(tracked, backendConn)
323481 r.counters.totalAccepted.Add(1)
324482 r.proxyTCP(client, backendConn, options.IdleTimeout)
483+ return true
484+}
485+
486+func (r *entryPointRuntime) resolveRawFallbackBackendDetailed(remote net.Addr) (domain.TrafficBackend, string, bool, bool) {
487+ graph := r.manager.snapshot.Load()
488+ if graph == nil {
489+ return domain.TrafficBackend{}, "", false, false
490+ }
491+ entryPoint := r.entryPointSnapshot()
492+ if entryPoint.RawFallback == "" {
493+ return domain.TrafficBackend{}, "", false, false
494+ }
495+ if !entryPoint.AllowPublicRawFallback {
496+ trusted := r.rawFallbackTrustedSnapshot()
497+ if len(trusted) == 0 || !trustedRemoteAddr(trusted, remote) {
498+ return domain.TrafficBackend{}, "", false, true
499+ }
500+ }
501+ for _, router := range graph.Routers {
502+ if router.Name == entryPoint.RawFallback && router.EntryPoint == entryPoint.Name && router.Protocol == domain.RouterProtocolTCP {
503+ backend, ok := r.backendForRouter(graph, router)
504+ return backend, entryPoint.RawFallback, ok, false
505+ }
506+ }
507+ return domain.TrafficBackend{}, "", false, false
325508 }
326509
327510 func (r *entryPointRuntime) resolveTCPBackend() (domain.TrafficRouter, domain.TrafficBackend, bool) {
@@ -536,6 +719,8 @@ func (r *entryPointRuntime) stop(ctx context.Context, drainTimeout time.Duration
536719 return
537720 }
538721 r.stopTLSHTTPServer(ctx, drainTimeout)
722+ r.stopSmartTCPHTTPServer(ctx, drainTimeout)
723+ r.stopSmartTCPTLSServer(ctx, drainTimeout)
539724 if r.waitActive(ctx, drainTimeout) {
540725 trafficInfo(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Msg("stopped tcp traffic entrypoint")
541726 return
@@ -621,33 +806,41 @@ func (r *entryPointRuntime) closeActiveConns() {
621806
622807 func (r *entryPointRuntime) matches(entryPoint domain.EntryPoint) bool {
623808 current := r.entryPointSnapshot()
624- return current.Name == entryPoint.Name && current.Address == entryPoint.Address && current.Protocol == entryPoint.Protocol && trustedCIDRsEqual(current.TrustedCIDRs, entryPoint.TrustedCIDRs)
809+ return current.Name == entryPoint.Name && current.Address == entryPoint.Address && current.Protocol == entryPoint.Protocol && trustedCIDRsEqual(current.TrustedCIDRs, entryPoint.TrustedCIDRs) && current.RawFallback == entryPoint.RawFallback && trustedCIDRsEqual(current.RawFallbackTrustedCIDRs, entryPoint.RawFallbackTrustedCIDRs) && current.AllowPublicRawFallback == entryPoint.AllowPublicRawFallback
625810 }
626811
627812 func (r *entryPointRuntime) sameAddress(entryPoint domain.EntryPoint) bool {
628813 return r.entryPointSnapshot().Address == entryPoint.Address
629814 }
630815
631-func (r *entryPointRuntime) updateEntryPoint(entryPoint domain.EntryPoint, trusted []*net.IPNet) {
816+func (r *entryPointRuntime) updateEntryPoint(entryPoint domain.EntryPoint, trusted []*net.IPNet, rawTrusted []*net.IPNet) {
632817 r.mu.Lock()
633818 previousName := r.entryPoint.Name
634819 previousProtocol := r.entryPoint.Protocol
635820 r.entryPoint = entryPoint
636821 r.trusted = trusted
822+ r.rawFallbackTrusted = rawTrusted
637823 stale := make([]*trackedTCPConn, 0)
638824 for conn := range r.activeConns {
639- if !trustedRemoteAddr(trusted, conn.client.RemoteAddr()) {
825+ if !trustedRemoteAddr(trusted, conn.client.RemoteAddr()) || rawFallbackConnStale(conn, entryPoint, rawTrusted) {
640826 stale = append(stale, conn)
641827 }
642828 }
643829 r.mu.Unlock()
644830 for _, conn := range stale {
645831 conn.close()
646- r.untrack(conn)
647832 }
648833 if previousProtocol == domain.EntryPointProtocolTLSMux && entryPoint.Protocol != domain.EntryPointProtocolTLSMux {
649834 r.stopTLSHTTPServer(r.ctx, 0)
650835 }
836+ if previousProtocol == domain.EntryPointProtocolSmartTCP && entryPoint.Protocol != domain.EntryPointProtocolSmartTCP {
837+ r.stopSmartTCPHTTPServer(r.ctx, 0)
838+ r.stopSmartTCPTLSServer(r.ctx, 0)
839+ }
840+ if entryPoint.Protocol == domain.EntryPointProtocolSmartTCP {
841+ r.refreshSmartTCPHTTPServer(entryPoint.Name)
842+ r.refreshSmartTCPTLSServer(entryPoint.Name)
843+ }
651844 if entryPoint.Protocol == domain.EntryPointProtocolTLSMux {
652845 if previousProtocol == domain.EntryPointProtocolTLSMux && previousName != entryPoint.Name {
653846 r.refreshTLSHTTPServer(entryPoint.Name)
@@ -657,6 +850,20 @@ func (r *entryPointRuntime) updateEntryPoint(entryPoint domain.EntryPoint, trust
657850 }
658851 }
659852
853+func rawFallbackConnStale(conn *trackedTCPConn, entryPoint domain.EntryPoint, rawTrusted []*net.IPNet) bool {
854+ rawFallbackName, ok := conn.rawFallback()
855+ if !ok {
856+ return false
857+ }
858+ if entryPoint.Protocol != domain.EntryPointProtocolSmartTCP || entryPoint.RawFallback == "" || entryPoint.RawFallback != rawFallbackName {
859+ return true
860+ }
861+ if entryPoint.AllowPublicRawFallback {
862+ return false
863+ }
864+ return len(rawTrusted) == 0 || !trustedRemoteAddr(rawTrusted, conn.client.RemoteAddr())
865+}
866+
660867 func (r *entryPointRuntime) entryPointSnapshot() domain.EntryPoint {
661868 r.mu.Lock()
662869 defer r.mu.Unlock()
@@ -669,6 +876,12 @@ func (r *entryPointRuntime) trustedSnapshot() []*net.IPNet {
669876 return r.trusted
670877 }
671878
879+func (r *entryPointRuntime) rawFallbackTrustedSnapshot() []*net.IPNet {
880+ r.mu.Lock()
881+ defer r.mu.Unlock()
882+ return r.rawFallbackTrusted
883+}
884+
672885 func (r *entryPointRuntime) isClosed() bool {
673886 return r.closed.Load()
674887 }
@@ -39,13 +39,28 @@ type entryPointRuntime struct {
39 tlsHTTPListener *tlsHTTPListener39 tlsHTTPListener *tlsHTTPListener
40 tlsHTTPServer *http.Server40 tlsHTTPServer *http.Server
41 tlsHTTPDone chan struct{}41 tlsHTTPDone chan struct{}
42+
43+ smartHTTPReplaceMu sync.Mutex
44+ smartHTTPListener *tlsHTTPListener
45+ smartHTTPServer *http.Server
46+ smartHTTPDone chan struct{}
47+
48+ smartTLSReplaceMu sync.Mutex
49+ smartTLSListener *tlsHTTPListener
50+ smartTLSServer *http.Server
51+ smartTLSDone chan struct{}
52+
53+ rawFallbackTrusted []*net.IPNet
42 }54 }
43 55
44 type trackedTCPConn struct {56 type trackedTCPConn struct {
45- mu sync.Mutex57+ mu sync.Mutex
46- client net.Conn58+ client net.Conn
47- backend net.Conn59+ backend net.Conn
48- closing bool60+ closing bool
61+ onDone func()
62+ doneOnce sync.Once
63+ rawFallbackName string
49 }64 }
50 65
51 func (c *trackedTCPConn) setBackend(backend net.Conn) {66 func (c *trackedTCPConn) setBackend(backend net.Conn) {
@@ -71,17 +86,39 @@ func (c *trackedTCPConn) close() {
71 }86 }
72 }87 }
73 88
89+func (c *trackedTCPConn) complete() {
90+ c.doneOnce.Do(func() {
91+ if c.onDone != nil {
92+ c.onDone()
93+ }
94+ })
95+}
96+
97+func (c *trackedTCPConn) markRawFallback(name string) {
98+ c.mu.Lock()
99+ c.rawFallbackName = name
100+ c.mu.Unlock()
101+}
102+
103+func (c *trackedTCPConn) rawFallback() (string, bool) {
104+ c.mu.Lock()
105+ defer c.mu.Unlock()
106+ return c.rawFallbackName, c.rawFallbackName != ""
107+}
108+
74 func newEntryPointRuntime(parentCtx context.Context, manager *Manager, entryPoint domain.EntryPoint, listener net.Listener, trusted []*net.IPNet) *entryPointRuntime {109 func newEntryPointRuntime(parentCtx context.Context, manager *Manager, entryPoint domain.EntryPoint, listener net.Listener, trusted []*net.IPNet) *entryPointRuntime {
75 ctx, cancel := context.WithCancel(context.WithoutCancel(parentCtx))110 ctx, cancel := context.WithCancel(context.WithoutCancel(parentCtx))
111+ rawTrusted, _ := parseTrustedCIDRs(entryPoint.RawFallbackTrustedCIDRs)
76 return &entryPointRuntime{112 return &entryPointRuntime{
77- manager: manager,113+ manager: manager,
78- entryPoint: entryPoint,114+ entryPoint: entryPoint,
79- listener: listener,115+ listener: listener,
80- trusted: trusted,116+ trusted: trusted,
81- ctx: ctx,117+ rawFallbackTrusted: rawTrusted,
82- cancel: cancel,118+ ctx: ctx,
83- acceptDone: make(chan struct{}),119+ cancel: cancel,
84- activeConns: map[*trackedTCPConn]struct{}{},120+ acceptDone: make(chan struct{}),
121+ activeConns: map[*trackedTCPConn]struct{}{},
85 }122 }
86 }123 }
87 124
@@ -91,6 +128,7 @@ func (r *entryPointRuntime) start() {
91 }128 }
92 entryPoint := r.entryPointSnapshot()129 entryPoint := r.entryPointSnapshot()
93 r.startTLSHTTPServer(entryPoint)130 r.startTLSHTTPServer(entryPoint)
131+ r.startSmartTCPHTTPServers(entryPoint)
94 trafficInfo(r.ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("started tcp traffic entrypoint")132 trafficInfo(r.ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("started tcp traffic entrypoint")
95 go r.acceptLoop()133 go r.acceptLoop()
96 }134 }
@@ -166,14 +204,28 @@ func (r *entryPointRuntime) routeToHTTPS(tracked *trackedTCPConn, conn net.Conn)
166 r.mu.Lock()204 r.mu.Lock()
167 listener := r.tlsHTTPListener205 listener := r.tlsHTTPListener
168 r.mu.Unlock()206 r.mu.Unlock()
207+ return r.routeToHTTPListener(tracked, conn, listener)
208+}
209+
210+func (r *entryPointRuntime) routeToSmartHTTP(tracked *trackedTCPConn, conn net.Conn) bool {
211+ r.mu.Lock()
212+ listener := r.smartHTTPListener
213+ r.mu.Unlock()
214+ return r.routeToHTTPListener(tracked, conn, listener)
215+}
216+
217+func (r *entryPointRuntime) routeToSmartHTTPS(tracked *trackedTCPConn, conn net.Conn) bool {
218+ r.mu.Lock()
219+ listener := r.smartTLSListener
220+ r.mu.Unlock()
221+ return r.routeToHTTPListener(tracked, conn, listener)
222+}
223+
224+func (r *entryPointRuntime) routeToHTTPListener(tracked *trackedTCPConn, conn net.Conn, listener *tlsHTTPListener) bool {
169 if listener == nil {225 if listener == nil {
170 return false226 return false
171 }227 }
172- if listener.serve(&trackedHTTPConn{Conn: conn, onClose: func() { r.untrack(tracked) }}) {228+ return listener.serve(&trackedHTTPConn{Conn: conn, onClose: tracked.complete})
173- return true
174- }
175- _ = conn.Close()
176- return true
177 }229 }
178 230
179 type trackedHTTPConn struct {231 type trackedHTTPConn struct {
@@ -211,6 +263,9 @@ func (r *entryPointRuntime) acceptLoop() {
211 }263 }
212 if !trustedRemoteAddr(r.trustedSnapshot(), conn.RemoteAddr()) {264 if !trustedRemoteAddr(r.trustedSnapshot(), conn.RemoteAddr()) {
213 r.counters.totalRefused.Add(1)265 r.counters.totalRefused.Add(1)
266+ if r.entryPointSnapshot().Protocol == domain.EntryPointProtocolSmartTCP {
267+ r.counters.smartTCP.entrypointCIDRRefused.Add(1)
268+ }
214 _ = conn.Close()269 _ = conn.Close()
215 continue270 continue
216 }271 }
@@ -220,22 +275,25 @@ func (r *entryPointRuntime) acceptLoop() {
220 }275 }
221 276
222 func (r *entryPointRuntime) handleTCPConn(client net.Conn) {277 func (r *entryPointRuntime) handleTCPConn(client net.Conn) {
223- defer r.activeWG.Done()
224-
225 options := effectiveTCPOptions(snapshotTCPOptions(r.manager.snapshot.Load()))278 options := effectiveTCPOptions(snapshotTCPOptions(r.manager.snapshot.Load()))
226 if !r.reserveConnection(options.MaxConnections) {279 if !r.reserveConnection(options.MaxConnections) {
227 r.counters.totalRefused.Add(1)280 r.counters.totalRefused.Add(1)
228 _ = client.Close()281 _ = client.Close()
282+ r.activeWG.Done()
229 return283 return
230 }284 }
231- defer r.releaseConnection()
232 285
233 tracked := &trackedTCPConn{client: client}286 tracked := &trackedTCPConn{client: client}
287+ tracked.onDone = func() {
288+ r.untrack(tracked)
289+ r.releaseConnection()
290+ r.activeWG.Done()
291+ }
234 r.track(tracked)292 r.track(tracked)
235- untrackOnReturn := true293+ completeOnReturn := true
236 defer func() {294 defer func() {
237- if untrackOnReturn {295+ if completeOnReturn {
238- r.untrack(tracked)296+ tracked.complete()
239 }297 }
240 }()298 }()
241 299
@@ -244,7 +302,11 @@ func (r *entryPointRuntime) handleTCPConn(client net.Conn) {
244 r.handlePlainTCP(tracked, options)302 r.handlePlainTCP(tracked, options)
245 case domain.EntryPointProtocolTLSMux:303 case domain.EntryPointProtocolTLSMux:
246 if r.handleTLSMux(tracked, options) {304 if r.handleTLSMux(tracked, options) {
247- untrackOnReturn = false305+ completeOnReturn = false
306+ }
307+ case domain.EntryPointProtocolSmartTCP:
308+ if r.handleSmartTCP(tracked, options) {
309+ completeOnReturn = false
248 }310 }
249 default:311 default:
250 r.counters.totalRefused.Add(1)312 r.counters.totalRefused.Add(1)
@@ -262,6 +324,98 @@ func (r *entryPointRuntime) handlePlainTCP(tracked *trackedTCPConn, options doma
262 r.proxyToBackend(tracked, tracked.client, backend, options)324 r.proxyToBackend(tracked, tracked.client, backend, options)
263 }325 }
264 326
327+func (r *entryPointRuntime) handleSmartTCP(tracked *trackedTCPConn, options domain.TCPOptions) bool {
328+ result, err := sniffSmartTCP(tracked.client, clientHelloTimeout(options))
329+ if err != nil {
330+ r.counters.totalErrors.Add(1)
331+ _ = tracked.client.Close()
332+ return false
333+ }
334+ switch result.kind {
335+ case dispatchHTTP, dispatchH2C:
336+ return r.handleSmartTCPHTTP(tracked, result)
337+ case dispatchTLS:
338+ return r.handleSmartTCPTLS(tracked, result.conn, options)
339+ case dispatchUnknown:
340+ return r.handleSmartTCPUnknown(tracked, result.conn, options)
341+ case dispatchRejectPROXY:
342+ return r.refuseSmartTCP(result.conn, r.counters.smartTCP.proxyRefused.Add)
343+ case dispatchSniffTimeout:
344+ return r.refuseSmartTCP(result.conn, r.counters.smartTCP.sniffTimeout.Add)
345+ case dispatchReject:
346+ return r.refuseSmartTCP(result.conn, r.counters.smartTCP.malformedRejected.Add)
347+ case dispatchRejectLarge:
348+ return r.refuseSmartTCP(result.conn, r.counters.smartTCP.clientHelloTooLarge.Add)
349+ default:
350+ return r.refuseSmartTCP(result.conn, nil)
351+ }
352+}
353+
354+func (r *entryPointRuntime) handleSmartTCPHTTP(tracked *trackedTCPConn, result smartTCPSniffResult) bool {
355+ if !r.routeToSmartHTTP(tracked, result.conn) {
356+ return r.refuseSmartTCP(result.conn, nil)
357+ }
358+ r.counters.totalAccepted.Add(1)
359+ if result.kind == dispatchH2C {
360+ r.counters.smartTCP.h2cAccepted.Add(1)
361+ } else {
362+ r.counters.smartTCP.httpAccepted.Add(1)
363+ }
364+ return true
365+}
366+
367+func (r *entryPointRuntime) handleSmartTCPTLS(tracked *trackedTCPConn, conn net.Conn, options domain.TCPOptions) bool {
368+ peeked, err := r.peekTLSClientHello(conn, options)
369+ if err != nil {
370+ return r.handleSmartTCPTLSPeekError(conn, err)
371+ }
372+ if backend, ok := r.resolveTLSBackend(peeked.sni); ok {
373+ r.counters.smartTCP.tlsPassthroughAccepted.Add(1)
374+ r.proxyToBackend(tracked, peeked.conn, backend, options)
375+ return false
376+ }
377+ if r.routeToSmartHTTPS(tracked, peeked.conn) {
378+ r.counters.totalAccepted.Add(1)
379+ r.counters.smartTCP.httpsFallbackAccepted.Add(1)
380+ return true
381+ }
382+ return r.refuseSmartTCP(peeked.conn, nil)
383+}
384+
385+func (r *entryPointRuntime) handleSmartTCPTLSPeekError(conn net.Conn, err error) bool {
386+ if isClientHelloTooLargeError(err) {
387+ r.counters.totalRefused.Add(1)
388+ r.counters.smartTCP.clientHelloTooLarge.Add(1)
389+ } else {
390+ r.counters.totalErrors.Add(1)
391+ }
392+ _ = conn.Close()
393+ return false
394+}
395+
396+func (r *entryPointRuntime) handleSmartTCPUnknown(tracked *trackedTCPConn, conn net.Conn, options domain.TCPOptions) bool {
397+ backend, rawFallbackName, ok, rawCIDRRefused := r.resolveRawFallbackBackendDetailed(conn.RemoteAddr())
398+ if ok {
399+ tracked.markRawFallback(rawFallbackName)
400+ r.counters.smartTCP.rawFallbackAccepted.Add(1)
401+ r.proxyToBackend(tracked, conn, backend, options)
402+ return false
403+ }
404+ if rawCIDRRefused {
405+ return r.refuseSmartTCP(conn, r.counters.smartTCP.rawFallbackCIDRRefused.Add)
406+ }
407+ return r.refuseSmartTCP(conn, r.counters.smartTCP.unknownNoFallbackRefused.Add)
408+}
409+
410+func (r *entryPointRuntime) refuseSmartTCP(conn net.Conn, increment func(int64) int64) bool {
411+ r.counters.totalRefused.Add(1)
412+ if increment != nil {
413+ increment(1)
414+ }
415+ _ = conn.Close()
416+ return false
417+}
418+
265 func (r *entryPointRuntime) handleTLSMux(tracked *trackedTCPConn, options domain.TCPOptions) bool {419 func (r *entryPointRuntime) handleTLSMux(tracked *trackedTCPConn, options domain.TCPOptions) bool {
266 peeked, err := r.peekTLSClientHello(tracked.client, options)420 peeked, err := r.peekTLSClientHello(tracked.client, options)
267 if err != nil {421 if err != nil {
@@ -296,6 +450,10 @@ func (r *entryPointRuntime) peekTLSClientHello(client net.Conn, options domain.T
296 return peekedTLSConn{sni: sni, conn: replayed}, nil450 return peekedTLSConn{sni: sni, conn: replayed}, nil
297 }451 }
298 452
453+func isClientHelloTooLargeError(err error) bool {
454+ return strings.Contains(err.Error(), "client hello exceeds")
455+}
456+
299 func clientHelloTimeout(options domain.TCPOptions) time.Duration {457 func clientHelloTimeout(options domain.TCPOptions) time.Duration {
300 const maxTimeout = 5 * time.Second458 const maxTimeout = 5 * time.Second
301 if options.DialTimeout > 0 && options.DialTimeout < maxTimeout {459 if options.DialTimeout > 0 && options.DialTimeout < maxTimeout {
@@ -309,19 +467,44 @@ type peekedTLSConn struct {
309 conn net.Conn467 conn net.Conn
310 }468 }
311 469
312-func (r *entryPointRuntime) proxyToBackend(tracked *trackedTCPConn, client net.Conn, backend domain.TrafficBackend, options domain.TCPOptions) {470+func (r *entryPointRuntime) proxyToBackend(tracked *trackedTCPConn, client net.Conn, backend domain.TrafficBackend, options domain.TCPOptions) bool {
313 dialCtx, cancel := context.WithTimeout(r.ctx, options.DialTimeout)471 dialCtx, cancel := context.WithTimeout(r.ctx, options.DialTimeout)
314 defer cancel()472 defer cancel()
315 backendConn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", net.JoinHostPort(backend.Host, strconv.Itoa(backend.Port)))473 backendConn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", net.JoinHostPort(backend.Host, strconv.Itoa(backend.Port)))
316 if err != nil {474 if err != nil {
317 r.counters.totalErrors.Add(1)475 r.counters.totalErrors.Add(1)
318 _ = client.Close()476 _ = client.Close()
319- return477+ return false
320 }478 }
321 479
322 r.setBackend(tracked, backendConn)480 r.setBackend(tracked, backendConn)
323 r.counters.totalAccepted.Add(1)481 r.counters.totalAccepted.Add(1)
324 r.proxyTCP(client, backendConn, options.IdleTimeout)482 r.proxyTCP(client, backendConn, options.IdleTimeout)
483+ return true
484+}
485+
486+func (r *entryPointRuntime) resolveRawFallbackBackendDetailed(remote net.Addr) (domain.TrafficBackend, string, bool, bool) {
487+ graph := r.manager.snapshot.Load()
488+ if graph == nil {
489+ return domain.TrafficBackend{}, "", false, false
490+ }
491+ entryPoint := r.entryPointSnapshot()
492+ if entryPoint.RawFallback == "" {
493+ return domain.TrafficBackend{}, "", false, false
494+ }
495+ if !entryPoint.AllowPublicRawFallback {
496+ trusted := r.rawFallbackTrustedSnapshot()
497+ if len(trusted) == 0 || !trustedRemoteAddr(trusted, remote) {
498+ return domain.TrafficBackend{}, "", false, true
499+ }
500+ }
501+ for _, router := range graph.Routers {
502+ if router.Name == entryPoint.RawFallback && router.EntryPoint == entryPoint.Name && router.Protocol == domain.RouterProtocolTCP {
503+ backend, ok := r.backendForRouter(graph, router)
504+ return backend, entryPoint.RawFallback, ok, false
505+ }
506+ }
507+ return domain.TrafficBackend{}, "", false, false
325 }508 }
326 509
327 func (r *entryPointRuntime) resolveTCPBackend() (domain.TrafficRouter, domain.TrafficBackend, bool) {510 func (r *entryPointRuntime) resolveTCPBackend() (domain.TrafficRouter, domain.TrafficBackend, bool) {
@@ -536,6 +719,8 @@ func (r *entryPointRuntime) stop(ctx context.Context, drainTimeout time.Duration
536 return719 return
537 }720 }
538 r.stopTLSHTTPServer(ctx, drainTimeout)721 r.stopTLSHTTPServer(ctx, drainTimeout)
722+ r.stopSmartTCPHTTPServer(ctx, drainTimeout)
723+ r.stopSmartTCPTLSServer(ctx, drainTimeout)
539 if r.waitActive(ctx, drainTimeout) {724 if r.waitActive(ctx, drainTimeout) {
540 trafficInfo(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Msg("stopped tcp traffic entrypoint")725 trafficInfo(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Msg("stopped tcp traffic entrypoint")
541 return726 return
@@ -621,33 +806,41 @@ func (r *entryPointRuntime) closeActiveConns() {
621 806
622 func (r *entryPointRuntime) matches(entryPoint domain.EntryPoint) bool {807 func (r *entryPointRuntime) matches(entryPoint domain.EntryPoint) bool {
623 current := r.entryPointSnapshot()808 current := r.entryPointSnapshot()
624- return current.Name == entryPoint.Name && current.Address == entryPoint.Address && current.Protocol == entryPoint.Protocol && trustedCIDRsEqual(current.TrustedCIDRs, entryPoint.TrustedCIDRs)809+ return current.Name == entryPoint.Name && current.Address == entryPoint.Address && current.Protocol == entryPoint.Protocol && trustedCIDRsEqual(current.TrustedCIDRs, entryPoint.TrustedCIDRs) && current.RawFallback == entryPoint.RawFallback && trustedCIDRsEqual(current.RawFallbackTrustedCIDRs, entryPoint.RawFallbackTrustedCIDRs) && current.AllowPublicRawFallback == entryPoint.AllowPublicRawFallback
625 }810 }
626 811
627 func (r *entryPointRuntime) sameAddress(entryPoint domain.EntryPoint) bool {812 func (r *entryPointRuntime) sameAddress(entryPoint domain.EntryPoint) bool {
628 return r.entryPointSnapshot().Address == entryPoint.Address813 return r.entryPointSnapshot().Address == entryPoint.Address
629 }814 }
630 815
631-func (r *entryPointRuntime) updateEntryPoint(entryPoint domain.EntryPoint, trusted []*net.IPNet) {816+func (r *entryPointRuntime) updateEntryPoint(entryPoint domain.EntryPoint, trusted []*net.IPNet, rawTrusted []*net.IPNet) {
632 r.mu.Lock()817 r.mu.Lock()
633 previousName := r.entryPoint.Name818 previousName := r.entryPoint.Name
634 previousProtocol := r.entryPoint.Protocol819 previousProtocol := r.entryPoint.Protocol
635 r.entryPoint = entryPoint820 r.entryPoint = entryPoint
636 r.trusted = trusted821 r.trusted = trusted
822+ r.rawFallbackTrusted = rawTrusted
637 stale := make([]*trackedTCPConn, 0)823 stale := make([]*trackedTCPConn, 0)
638 for conn := range r.activeConns {824 for conn := range r.activeConns {
639- if !trustedRemoteAddr(trusted, conn.client.RemoteAddr()) {825+ if !trustedRemoteAddr(trusted, conn.client.RemoteAddr()) || rawFallbackConnStale(conn, entryPoint, rawTrusted) {
640 stale = append(stale, conn)826 stale = append(stale, conn)
641 }827 }
642 }828 }
643 r.mu.Unlock()829 r.mu.Unlock()
644 for _, conn := range stale {830 for _, conn := range stale {
645 conn.close()831 conn.close()
646- r.untrack(conn)
647 }832 }
648 if previousProtocol == domain.EntryPointProtocolTLSMux && entryPoint.Protocol != domain.EntryPointProtocolTLSMux {833 if previousProtocol == domain.EntryPointProtocolTLSMux && entryPoint.Protocol != domain.EntryPointProtocolTLSMux {
649 r.stopTLSHTTPServer(r.ctx, 0)834 r.stopTLSHTTPServer(r.ctx, 0)
650 }835 }
836+ if previousProtocol == domain.EntryPointProtocolSmartTCP && entryPoint.Protocol != domain.EntryPointProtocolSmartTCP {
837+ r.stopSmartTCPHTTPServer(r.ctx, 0)
838+ r.stopSmartTCPTLSServer(r.ctx, 0)
839+ }
840+ if entryPoint.Protocol == domain.EntryPointProtocolSmartTCP {
841+ r.refreshSmartTCPHTTPServer(entryPoint.Name)
842+ r.refreshSmartTCPTLSServer(entryPoint.Name)
843+ }
651 if entryPoint.Protocol == domain.EntryPointProtocolTLSMux {844 if entryPoint.Protocol == domain.EntryPointProtocolTLSMux {
652 if previousProtocol == domain.EntryPointProtocolTLSMux && previousName != entryPoint.Name {845 if previousProtocol == domain.EntryPointProtocolTLSMux && previousName != entryPoint.Name {
653 r.refreshTLSHTTPServer(entryPoint.Name)846 r.refreshTLSHTTPServer(entryPoint.Name)
@@ -657,6 +850,20 @@ func (r *entryPointRuntime) updateEntryPoint(entryPoint domain.EntryPoint, trust
657 }850 }
658 }851 }
659 852
853+func rawFallbackConnStale(conn *trackedTCPConn, entryPoint domain.EntryPoint, rawTrusted []*net.IPNet) bool {
854+ rawFallbackName, ok := conn.rawFallback()
855+ if !ok {
856+ return false
857+ }
858+ if entryPoint.Protocol != domain.EntryPointProtocolSmartTCP || entryPoint.RawFallback == "" || entryPoint.RawFallback != rawFallbackName {
859+ return true
860+ }
861+ if entryPoint.AllowPublicRawFallback {
862+ return false
863+ }
864+ return len(rawTrusted) == 0 || !trustedRemoteAddr(rawTrusted, conn.client.RemoteAddr())
865+}
866+
660 func (r *entryPointRuntime) entryPointSnapshot() domain.EntryPoint {867 func (r *entryPointRuntime) entryPointSnapshot() domain.EntryPoint {
661 r.mu.Lock()868 r.mu.Lock()
662 defer r.mu.Unlock()869 defer r.mu.Unlock()
@@ -669,6 +876,12 @@ func (r *entryPointRuntime) trustedSnapshot() []*net.IPNet {
669 return r.trusted876 return r.trusted
670 }877 }
671 878
879+func (r *entryPointRuntime) rawFallbackTrustedSnapshot() []*net.IPNet {
880+ r.mu.Lock()
881+ defer r.mu.Unlock()
882+ return r.rawFallbackTrusted
883+}
884+
672 func (r *entryPointRuntime) isClosed() bool {885 func (r *entryPointRuntime) isClosed() bool {
673 return r.closed.Load()886 return r.closed.Load()
674 }887 }
M internal/adapters/in/traffic/tcp_test.go +661 -0
@@ -2,6 +2,7 @@ package traffic
22
33 import (
44 "bufio"
5+ "bytes"
56 "context"
67 "crypto/tls"
78 "errors"
@@ -9,6 +10,7 @@ import (
910 "io"
1011 "net"
1112 "net/http"
13+ "sync/atomic"
1214 "testing"
1315 "time"
1416
@@ -23,6 +25,592 @@ func TestTrafficRuntimeDefaultsApplySafeLimits(t *testing.T) {
2325 assert.Equal(t, 4096, effectiveUDPOptions(domain.UDPOptions{}).MaxSessions)
2426 }
2527
28+func TestSmartTCPDispatch(t *testing.T) {
29+ t.Run("cleartext HTTP request reaches HTTP handler", func(t *testing.T) {
30+ manager, graph, hits := startSmartTCP(t, nil, nil)
31+ defer shutdownManager(t, manager)
32+
33+ conn := dialTCP(t, graph.EntryPoints[0].Address)
34+ defer conn.Close()
35+ _, err := conn.Write([]byte("GET /clear HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
36+ require.NoError(t, err)
37+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
38+ require.NoError(t, err)
39+ body, err := io.ReadAll(resp.Body)
40+ require.NoError(t, err)
41+ require.NoError(t, resp.Body.Close())
42+ assert.Contains(t, string(body), "http:/clear")
43+ assert.Equal(t, int64(1), hits.httpHits.Load())
44+ assert.Equal(t, int64(0), hits.rawHits.Load())
45+ })
46+
47+ t.Run("h2c prior-knowledge reaches HTTP handler", func(t *testing.T) {
48+ manager, graph, hits := startSmartTCP(t, nil, nil)
49+ defer shutdownManager(t, manager)
50+ conn := dialTCP(t, graph.EntryPoints[0].Address)
51+ defer conn.Close()
52+ _, err := conn.Write([]byte(h2cPreface))
53+ require.NoError(t, err)
54+ assert.Eventually(t, func() bool { return hits.httpHits.Load() == 1 }, time.Second, 10*time.Millisecond)
55+ assert.Equal(t, int64(0), hits.rawHits.Load())
56+ })
57+
58+ t.Run("HTTPS request reaches configured HTTPS fallback", func(t *testing.T) {
59+ manager, graph, _ := startSmartTCP(t, nil, nil)
60+ defer shutdownManager(t, manager)
61+ assertHTTPSBody(t, graph.EntryPoints[0].Address, "app.example.com", "smart https")
62+ })
63+
64+ t.Run("TLS passthrough SNI match wins before HTTPS fallback", func(t *testing.T) {
65+ backend := startTLSGreetingServer(t, "raw.example.com", "raw tls\n")
66+ manager, graph, _ := startSmartTCP(t, &smartTCPRoute{tlsSNI: "raw.example.com", backendAddress: backend.address}, nil)
67+ defer shutdownManager(t, manager)
68+ assertTLSGreeting(t, graph.EntryPoints[0].Address, "raw.example.com", "raw tls\n")
69+ assertHTTPSBody(t, graph.EntryPoints[0].Address, "app.example.com", "smart https")
70+ })
71+
72+ t.Run("no-SNI TLS reaches HTTPS fallback", func(t *testing.T) {
73+ manager, graph, _ := startSmartTCP(t, nil, nil)
74+ defer shutdownManager(t, manager)
75+ assertHTTPSBodyNoSNI(t, graph.EntryPoints[0].Address, "smart https")
76+ })
77+
78+ t.Run("unknown bytes reach raw fallback immediately when source policy allows", func(t *testing.T) {
79+ backend := startTCPEchoServer(t, 0)
80+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, trustedCIDRs: []string{"127.0.0.0/8"}})
81+ defer shutdownManager(t, manager)
82+ conn := dialTCP(t, graph.EntryPoints[0].Address)
83+ defer conn.Close()
84+ assertRoundTrip(t, conn, "ssh-raw")
85+ })
86+
87+ t.Run("unknown bytes refused without raw fallback", func(t *testing.T) {
88+ manager, graph, _ := startSmartTCP(t, nil, nil)
89+ defer shutdownManager(t, manager)
90+ assertTCPRejected(t, manager, graph.EntryPoints[0].Address, 1)
91+ })
92+
93+ t.Run("unknown bytes refused when raw fallback source policy denies peer", func(t *testing.T) {
94+ backend := startTCPEchoServer(t, 0)
95+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, trustedCIDRs: []string{"192.0.2.0/24"}})
96+ defer shutdownManager(t, manager)
97+ assertTCPRejected(t, manager, graph.EntryPoints[0].Address, 1)
98+ })
99+
100+ t.Run("malformed HTTP-looking bytes do not reach raw fallback", func(t *testing.T) {
101+ backend := startTCPEchoServer(t, 0)
102+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, allowPublic: true})
103+ defer shutdownManager(t, manager)
104+ conn := dialTCP(t, graph.EntryPoints[0].Address)
105+ defer conn.Close()
106+ _, err := conn.Write([]byte("GET /broken\r\n"))
107+ require.NoError(t, err)
108+ _, err = bufio.NewReader(conn).ReadByte()
109+ require.Error(t, err)
110+ })
111+
112+ t.Run("malformed TLS-looking bytes do not reach raw fallback", func(t *testing.T) {
113+ backend := startTCPEchoServer(t, 0)
114+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, allowPublic: true})
115+ defer shutdownManager(t, manager)
116+ conn := dialTCP(t, graph.EntryPoints[0].Address)
117+ defer conn.Close()
118+ _, err := conn.Write([]byte{22, 3, 3, 0, 8, 2, 0, 0, 4, 0, 0, 0, 0})
119+ require.NoError(t, err)
120+ _, err = bufio.NewReader(conn).ReadByte()
121+ require.Error(t, err)
122+ })
123+
124+ t.Run("PROXY v1 and v2 prefixes rejected", func(t *testing.T) {
125+ manager, graph, _ := startSmartTCP(t, nil, nil)
126+ defer shutdownManager(t, manager)
127+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, []byte("PROXY TCP4 127.0.0.1 127.0.0.1 1 2\r\n"))
128+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, append([]byte(proxyV2Signature), 0x21, 0x11, 0, 0))
129+ })
130+
131+ t.Run("sniff deadline cleared before backend handoff", func(t *testing.T) {
132+ backend := startTCPEchoServer(t, 150*time.Millisecond)
133+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, allowPublic: true})
134+ defer shutdownManager(t, manager)
135+ conn := dialTCP(t, graph.EntryPoints[0].Address)
136+ defer conn.Close()
137+ assertRoundTrip(t, conn, "deadline-clear")
138+ })
139+
140+ t.Run("raw fallback trusted CIDR reload reuses same listener", func(t *testing.T) {
141+ backend := startTCPEchoServer(t, 0)
142+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, trustedCIDRs: []string{"192.0.2.0/24"}})
143+ defer shutdownManager(t, manager)
144+ assertTCPRejected(t, manager, graph.EntryPoints[0].Address, 1)
145+
146+ initialRuntime := tcpRuntimeForTest(t, manager, "edge")
147+ replacement := graph
148+ replacement.EntryPoints = append([]domain.EntryPoint(nil), graph.EntryPoints...)
149+ replacement.EntryPoints[0].RawFallbackTrustedCIDRs = []string{"127.0.0.0/8"}
150+ replacement.Routers = append([]domain.TrafficRouter(nil), graph.Routers...)
151+ replacement.Services = append([]domain.TrafficService(nil), graph.Services...)
152+ require.NoError(t, manager.Apply(context.Background(), &replacement))
153+ assert.Same(t, initialRuntime, tcpRuntimeForTest(t, manager, "edge"))
154+
155+ conn := dialTCP(t, replacement.EntryPoints[0].Address)
156+ defer conn.Close()
157+ assertRoundTrip(t, conn, "raw-reloaded")
158+ })
159+
160+ t.Run("raw fallback connection closes when trusted CIDR no longer allows peer", func(t *testing.T) {
161+ backend := startTCPEchoServer(t, 0)
162+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, trustedCIDRs: []string{"127.0.0.0/8"}})
163+ defer shutdownManager(t, manager)
164+
165+ conn := dialTCP(t, graph.EntryPoints[0].Address)
166+ defer conn.Close()
167+ assertRoundTrip(t, conn, "before")
168+
169+ updated := graph
170+ updated.EntryPoints = append([]domain.EntryPoint(nil), graph.EntryPoints...)
171+ updated.EntryPoints[0].RawFallbackTrustedCIDRs = []string{"192.0.2.0/24"}
172+ require.NoError(t, manager.Apply(context.Background(), &updated))
173+
174+ assert.Eventually(t, func() bool {
175+ _, writeErr := conn.Write([]byte("after\n"))
176+ if writeErr != nil {
177+ return true
178+ }
179+ _ = conn.SetReadDeadline(time.Now().Add(10 * time.Millisecond))
180+ _, readErr := bufio.NewReader(conn).ReadByte()
181+ return readErr != nil
182+ }, time.Second, 10*time.Millisecond)
183+ })
184+
185+ t.Run("TLS ClientHello read after sniff is bounded", func(t *testing.T) {
186+ manager, graph, _ := startSmartTCP(t, nil, nil)
187+ defer shutdownManager(t, manager)
188+ graph.Options.TCP.DialTimeout = 100 * time.Millisecond
189+ require.NoError(t, manager.Apply(context.Background(), &graph))
190+
191+ conn := dialTCP(t, graph.EntryPoints[0].Address)
192+ defer conn.Close()
193+ start := time.Now()
194+ _, err := conn.Write([]byte{22, 3, 3, 0, 42})
195+ require.NoError(t, err)
196+ require.NoError(t, conn.SetReadDeadline(time.Now().Add(750*time.Millisecond)))
197+ _, err = bufio.NewReader(conn).ReadByte()
198+ require.Error(t, err)
199+ assert.Less(t, time.Since(start), 500*time.Millisecond)
200+ })
201+}
202+
203+func TestSmartTCPStatusCounters(t *testing.T) {
204+ t.Run("HTTP accepted", func(t *testing.T) {
205+ manager, graph, _ := startSmartTCP(t, nil, nil)
206+ defer shutdownManager(t, manager)
207+ conn := dialTCP(t, graph.EntryPoints[0].Address)
208+ defer conn.Close()
209+ _, err := conn.Write([]byte("GET /status HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
210+ require.NoError(t, err)
211+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
212+ require.NoError(t, err)
213+ _, _ = io.ReadAll(resp.Body)
214+ _ = resp.Body.Close()
215+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.HTTPAccepted == 1 })
216+ })
217+
218+ t.Run("h2c accepted", func(t *testing.T) {
219+ manager, graph, _ := startSmartTCP(t, nil, nil)
220+ defer shutdownManager(t, manager)
221+ conn := dialTCP(t, graph.EntryPoints[0].Address)
222+ defer conn.Close()
223+ _, err := conn.Write([]byte(h2cPreface))
224+ require.NoError(t, err)
225+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.H2CAccepted == 1 })
226+ })
227+
228+ t.Run("HTTPS fallback accepted", func(t *testing.T) {
229+ manager, graph, _ := startSmartTCP(t, nil, nil)
230+ defer shutdownManager(t, manager)
231+ assertHTTPSBody(t, graph.EntryPoints[0].Address, "app.example.com", "smart https")
232+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.HTTPSFallbackAccepted == 1 })
233+ })
234+
235+ t.Run("TLS passthrough accepted", func(t *testing.T) {
236+ backend := startTLSGreetingServer(t, "raw.example.com", "raw tls\n")
237+ manager, graph, _ := startSmartTCP(t, &smartTCPRoute{tlsSNI: "raw.example.com", backendAddress: backend.address}, nil)
238+ defer shutdownManager(t, manager)
239+ assertTLSGreeting(t, graph.EntryPoints[0].Address, "raw.example.com", "raw tls\n")
240+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.TLSPassthroughAccepted == 1 })
241+ })
242+
243+ t.Run("raw fallback accepted", func(t *testing.T) {
244+ backend := startTCPEchoServer(t, 0)
245+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, allowPublic: true})
246+ defer shutdownManager(t, manager)
247+ conn := dialTCP(t, graph.EntryPoints[0].Address)
248+ defer conn.Close()
249+ assertRoundTrip(t, conn, "raw")
250+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.RawFallbackAccepted == 1 })
251+ })
252+
253+ t.Run("refused and rejected classes", func(t *testing.T) {
254+ backend := startTCPEchoServer(t, 0)
255+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, trustedCIDRs: []string{"192.0.2.0/24"}})
256+ defer shutdownManager(t, manager)
257+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, []byte("PROXY TCP4 127.0.0.1 127.0.0.1 1 2\r\n"))
258+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, []byte{0})
259+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, []byte("GET /broken\r\n"))
260+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, bytes.Repeat([]byte("G"), maxSmartTCPSniffBytes+1))
261+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool {
262+ return c.PROXYRefused == 1 && c.RawFallbackCIDRRefused == 1 && c.MalformedRejected == 1 && c.ClientHelloTooLarge == 1
263+ })
264+ })
265+
266+ t.Run("unknown without fallback refused", func(t *testing.T) {
267+ manager, graph, _ := startSmartTCP(t, nil, nil)
268+ defer shutdownManager(t, manager)
269+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, []byte{0})
270+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.UnknownNoFallbackRefused == 1 })
271+ })
272+
273+ t.Run("sniff timeout", func(t *testing.T) {
274+ manager, graph, _ := startSmartTCP(t, nil, nil)
275+ defer shutdownManager(t, manager)
276+ graph.Options.TCP.DialTimeout = 50 * time.Millisecond
277+ require.NoError(t, manager.Apply(context.Background(), &graph))
278+ conn := dialTCP(t, graph.EntryPoints[0].Address)
279+ defer conn.Close()
280+ _, err := conn.Write([]byte("P"))
281+ require.NoError(t, err)
282+ _, _ = bufio.NewReader(conn).ReadByte()
283+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.SniffTimeout == 1 })
284+ })
285+
286+ t.Run("entrypoint CIDR refused", func(t *testing.T) {
287+ manager, graph, _ := startSmartTCP(t, nil, nil)
288+ defer shutdownManager(t, manager)
289+ graph.EntryPoints[0].TrustedCIDRs = []string{"192.0.2.0/24"}
290+ require.NoError(t, manager.Apply(context.Background(), &graph))
291+ conn := dialTCP(t, graph.EntryPoints[0].Address)
292+ _ = conn.Close()
293+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.EntrypointCIDRRefused == 1 })
294+ })
295+}
296+
297+func TestManagerStatusAggregatesSmartTCP(t *testing.T) {
298+ manager, graph, _ := startSmartTCP(t, nil, nil)
299+ defer shutdownManager(t, manager)
300+ conn := dialTCP(t, graph.EntryPoints[0].Address)
301+ defer conn.Close()
302+ _, err := conn.Write([]byte("GET /aggregate HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
303+ require.NoError(t, err)
304+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
305+ require.NoError(t, err)
306+ _, _ = io.ReadAll(resp.Body)
307+ _ = resp.Body.Close()
308+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.HTTPAccepted == 1 })
309+}
310+
311+func assertSmartTCPStatus(t *testing.T, manager *Manager, match func(domain.SmartTCPCounters) bool) {
312+ t.Helper()
313+ assert.Eventually(t, func() bool {
314+ status := manager.Status()
315+ if len(status.EntryPoints) == 0 || !match(status.EntryPoints[0].SmartTCP) {
316+ return false
317+ }
318+ return match(status.Counters.SmartTCP)
319+ }, time.Second, 10*time.Millisecond)
320+}
321+
322+func TestSmartTCPLifecycleHardening(t *testing.T) {
323+ t.Run("internal HTTP and HTTPS fallback servers stop when entrypoint removed", func(t *testing.T) {
324+ manager, graph, _ := startSmartTCP(t, nil, nil)
325+ initialRuntime := tcpRuntimeForTest(t, manager, "edge")
326+ require.NotNil(t, initialRuntime)
327+
328+ require.NoError(t, manager.Apply(context.Background(), &domain.TrafficGraph{Options: graph.Options}))
329+ assert.Eventually(t, func() bool {
330+ initialRuntime.mu.Lock()
331+ defer initialRuntime.mu.Unlock()
332+ return initialRuntime.smartHTTPServer == nil && initialRuntime.smartTLSServer == nil
333+ }, time.Second, 10*time.Millisecond)
334+ shutdownManager(t, manager)
335+ })
336+
337+ t.Run("HTTP fallback handoff remains counted until hijacked connection closes", func(t *testing.T) {
338+ manager := NewManager()
339+ graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
340+ graph.Options.TCP.MaxConnections = 1
341+ hijacked := make(chan struct{})
342+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
343+ hj, ok := w.(http.Hijacker)
344+ require.True(t, ok)
345+ conn, rw, err := hj.Hijack()
346+ require.NoError(t, err)
347+ defer conn.Close()
348+ _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\nready\n")
349+ _ = rw.Flush()
350+ close(hijacked)
351+ _, _ = rw.ReadString('\n')
352+ }), nil)
353+ manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
354+ require.NoError(t, manager.Apply(context.Background(), &graph))
355+ defer shutdownManager(t, manager)
356+
357+ first := dialTCP(t, graph.EntryPoints[0].Address)
358+ defer first.Close()
359+ firstReader := bufio.NewReader(first)
360+ _, err := first.Write([]byte("GET /ws HTTP/1.1\r\nHost: app.example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"))
361+ require.NoError(t, err)
362+ resp, err := http.ReadResponse(firstReader, nil)
363+ require.NoError(t, err)
364+ assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode)
365+ ready, err := firstReader.ReadString('\n')
366+ require.NoError(t, err)
367+ assert.Equal(t, "ready\n", ready)
368+ <-hijacked
369+ require.Eventually(t, func() bool { return manager.Status().Counters.ActiveTCPConnections == 1 }, time.Second, 10*time.Millisecond)
370+
371+ second := dialTCP(t, graph.EntryPoints[0].Address)
372+ _, err = second.Write([]byte("GET / HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
373+ require.NoError(t, err)
374+ _, err = bufio.NewReader(second).ReadByte()
375+ _ = second.Close()
376+ require.Error(t, err)
377+ assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == 1 }, time.Second, 10*time.Millisecond)
378+
379+ _, err = first.Write([]byte("close\n"))
380+ require.NoError(t, err)
381+ require.Eventually(t, func() bool { return manager.Status().Counters.ActiveTCPConnections == 0 }, time.Second, 10*time.Millisecond)
382+ third := dialTCP(t, graph.EntryPoints[0].Address)
383+ defer third.Close()
384+ _, err = third.Write([]byte("GET /after HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
385+ require.NoError(t, err)
386+ resp, err = http.ReadResponse(bufio.NewReader(third), nil)
387+ require.NoError(t, err)
388+ assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode)
389+ })
390+
391+ t.Run("shutdown force drain closes open HTTP fallback handoff", func(t *testing.T) {
392+ manager := NewManager()
393+ graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
394+ graph.Options.TCP.DrainTimeout = 50 * time.Millisecond
395+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
396+ hj, ok := w.(http.Hijacker)
397+ require.True(t, ok)
398+ conn, rw, err := hj.Hijack()
399+ require.NoError(t, err)
400+ defer conn.Close()
401+ _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\nready\n")
402+ _ = rw.Flush()
403+ _, _ = rw.ReadString('\n')
404+ }), nil)
405+ manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
406+ require.NoError(t, manager.Apply(context.Background(), &graph))
407+
408+ conn := dialTCP(t, graph.EntryPoints[0].Address)
409+ defer conn.Close()
410+ reader := bufio.NewReader(conn)
411+ _, err := conn.Write([]byte("GET /ws HTTP/1.1\r\nHost: app.example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"))
412+ require.NoError(t, err)
413+ resp, err := http.ReadResponse(reader, nil)
414+ require.NoError(t, err)
415+ assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode)
416+ ready, err := reader.ReadString('\n')
417+ require.NoError(t, err)
418+ assert.Equal(t, "ready\n", ready)
419+
420+ started := time.Now()
421+ require.NoError(t, manager.Apply(context.Background(), &domain.TrafficGraph{Options: graph.Options}))
422+ assert.GreaterOrEqual(t, time.Since(started), 40*time.Millisecond)
423+ _, err = conn.Write([]byte("after\n"))
424+ if err == nil {
425+ _, err = reader.ReadByte()
426+ }
427+ require.Error(t, err)
428+ })
429+
430+ t.Run("trusted CIDR change closes active smart TCP connections no longer allowed", func(t *testing.T) {
431+ backend := startTCPEchoServer(t, 0)
432+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, allowPublic: true})
433+ defer shutdownManager(t, manager)
434+ graph.EntryPoints[0].TrustedCIDRs = []string{"127.0.0.0/8"}
435+ require.NoError(t, manager.Apply(context.Background(), &graph))
436+
437+ conn := dialTCP(t, graph.EntryPoints[0].Address)
438+ defer conn.Close()
439+ assertRoundTrip(t, conn, "before")
440+
441+ updated := graph
442+ updated.EntryPoints[0].TrustedCIDRs = []string{"192.0.2.0/24"}
443+ require.NoError(t, manager.Apply(context.Background(), &updated))
444+ assert.Eventually(t, func() bool {
445+ _, writeErr := conn.Write([]byte("after\n"))
446+ if writeErr != nil {
447+ return true
448+ }
449+ _ = conn.SetReadDeadline(time.Now().Add(10 * time.Millisecond))
450+ _, readErr := bufio.NewReader(conn).ReadByte()
451+ return readErr != nil
452+ }, time.Second, 10*time.Millisecond)
453+ })
454+
455+ t.Run("active upgraded connection remains tied to accepted generation", func(t *testing.T) {
456+ manager := NewManager()
457+ graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
458+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
459+ hj, ok := w.(http.Hijacker)
460+ require.True(t, ok)
461+ conn, rw, err := hj.Hijack()
462+ require.NoError(t, err)
463+ _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\nold-ready\n")
464+ _ = rw.Flush()
465+ line, err := rw.ReadString('\n')
466+ if err == nil {
467+ _, _ = conn.Write([]byte("old:" + line))
468+ }
469+ _ = conn.Close()
470+ }), nil)
471+ manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
472+ require.NoError(t, manager.Apply(context.Background(), &graph))
473+ defer shutdownManager(t, manager)
474+
475+ conn := dialTCP(t, graph.EntryPoints[0].Address)
476+ defer conn.Close()
477+ reader := bufio.NewReader(conn)
478+ _, err := conn.Write([]byte("GET /ws HTTP/1.1\r\nHost: app.example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"))
479+ require.NoError(t, err)
480+ resp, err := http.ReadResponse(reader, nil)
481+ require.NoError(t, err)
482+ assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode)
483+ ready, err := reader.ReadString('\n')
484+ require.NoError(t, err)
485+ assert.Equal(t, "old-ready\n", ready)
486+
487+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("new")) }), nil)
488+ _, err = conn.Write([]byte("ping\n"))
489+ require.NoError(t, err)
490+ got, err := reader.ReadString('\n')
491+ require.NoError(t, err)
492+ assert.Equal(t, "old:ping\n", got)
493+ })
494+
495+ t.Run("listener is not reused for incompatible address", func(t *testing.T) {
496+ manager, graph, _ := startSmartTCP(t, nil, nil)
497+ defer shutdownManager(t, manager)
498+ initialRuntime := tcpRuntimeForTest(t, manager, "edge")
499+
500+ updated := graph
501+ updated.EntryPoints[0].Address = freeTCPAddress(t)
502+ require.NoError(t, manager.Apply(context.Background(), &updated))
503+ assert.NotSame(t, initialRuntime, tcpRuntimeForTest(t, manager, "edge"))
504+ })
505+}
506+
507+func TestSmartTCPHTTPHandoff(t *testing.T) {
508+ t.Run("HTTP Upgrade h2c reaches handler and not raw fallback", func(t *testing.T) {
509+ manager, graph, hits := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: startTCPEchoServer(t, 0).address, allowPublic: true})
510+ defer shutdownManager(t, manager)
511+ conn := dialTCP(t, graph.EntryPoints[0].Address)
512+ defer conn.Close()
513+ _, err := conn.Write([]byte("GET /upgrade HTTP/1.1\r\nHost: app.example.com\r\nConnection: Upgrade, HTTP2-Settings\r\nUpgrade: h2c\r\nHTTP2-Settings: AAMAAABkAAQAAP__\r\n\r\n"))
514+ require.NoError(t, err)
515+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
516+ require.NoError(t, err)
517+ _, _ = io.ReadAll(resp.Body)
518+ _ = resp.Body.Close()
519+ assert.Equal(t, int64(1), hits.httpHits.Load())
520+ assert.Equal(t, int64(0), hits.rawHits.Load())
521+ })
522+
523+ t.Run("websocket upgrade supports hijacker", func(t *testing.T) {
524+ manager := NewManager()
525+ graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
526+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
527+ hj, ok := w.(http.Hijacker)
528+ require.True(t, ok)
529+ conn, rw, err := hj.Hijack()
530+ require.NoError(t, err)
531+ defer conn.Close()
532+ _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\nws-ok")
533+ _ = rw.Flush()
534+ }), nil)
535+ manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
536+ require.NoError(t, manager.Apply(context.Background(), &graph))
537+ defer shutdownManager(t, manager)
538+ conn := dialTCP(t, graph.EntryPoints[0].Address)
539+ defer conn.Close()
540+ _, err := conn.Write([]byte("GET /ws HTTP/1.1\r\nHost: app.example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"))
541+ require.NoError(t, err)
542+ body, err := io.ReadAll(conn)
543+ require.NoError(t, err)
544+ assert.Contains(t, string(body), "ws-ok")
545+ })
546+
547+ t.Run("CONNECT reaches HTTP handler and not raw fallback", func(t *testing.T) {
548+ manager, graph, hits := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: startTCPEchoServer(t, 0).address, allowPublic: true})
549+ defer shutdownManager(t, manager)
550+ conn := dialTCP(t, graph.EntryPoints[0].Address)
551+ defer conn.Close()
552+ _, err := conn.Write([]byte("CONNECT db.example.com:443 HTTP/1.1\r\nHost: db.example.com:443\r\nConnection: close\r\n\r\n"))
553+ require.NoError(t, err)
554+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
555+ require.NoError(t, err)
556+ _, _ = io.ReadAll(resp.Body)
557+ _ = resp.Body.Close()
558+ assert.Equal(t, int64(1), hits.httpHits.Load())
559+ assert.Equal(t, int64(0), hits.rawHits.Load())
560+ })
561+
562+ t.Run("handoff connection has cleared deadlines and useful addresses", func(t *testing.T) {
563+ seen := make(chan string, 1)
564+ manager := NewManager()
565+ graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
566+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
567+ time.Sleep(150 * time.Millisecond)
568+ seen <- r.RemoteAddr + "|" + r.Context().Value(http.LocalAddrContextKey).(net.Addr).String()
569+ _, _ = w.Write([]byte("addr-ok"))
570+ }), nil)
571+ manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
572+ require.NoError(t, manager.Apply(context.Background(), &graph))
573+ defer shutdownManager(t, manager)
574+ conn := dialTCP(t, graph.EntryPoints[0].Address)
575+ defer conn.Close()
576+ _, err := conn.Write([]byte("GET /addr HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
577+ require.NoError(t, err)
578+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
579+ require.NoError(t, err)
580+ _, _ = io.ReadAll(resp.Body)
581+ _ = resp.Body.Close()
582+ got := <-seen
583+ assert.Contains(t, got, "127.0.0.1")
584+ assert.Contains(t, got, graph.EntryPoints[0].Address)
585+ })
586+}
587+
588+func TestRouteToHTTPListenerServeFailureReturnsFalseAndLeavesTrackedForCaller(t *testing.T) {
589+ listener, err := net.Listen("tcp", "127.0.0.1:0")
590+ require.NoError(t, err)
591+ defer listener.Close()
592+
593+ runtime := newEntryPointRuntime(context.Background(), NewManager(), domain.EntryPoint{Name: "edge"}, listener, nil)
594+ httpListener := newTLSHTTPListener(listener.Addr())
595+ require.NoError(t, httpListener.Close())
596+
597+ clientConn, serverConn := net.Pipe()
598+ defer clientConn.Close()
599+ defer serverConn.Close()
600+ tracked := &trackedTCPConn{client: clientConn}
601+ runtime.track(tracked)
602+
603+ accepted := runtime.routeToHTTPListener(tracked, serverConn, httpListener)
604+ require.False(t, accepted)
605+
606+ runtime.mu.Lock()
607+ _, stillTracked := runtime.activeConns[tracked]
608+ runtime.mu.Unlock()
609+ assert.True(t, stillTracked)
610+
611+ runtime.untrack(tracked)
612+}
613+
26614 func TestTCPManagerLifecycle(t *testing.T) {
27615 backend := startTCPEchoServer(t, 0)
28616 graph := tcpGraph(t, freeTCPAddress(t), backend.address)
@@ -451,6 +1039,70 @@ func TestTCPPassthroughStatusCountersTrackAcceptedRefusedErrorsAndBytes(t *testi
4511039 assert.Eventually(t, func() bool { return manager.Status().Counters.TotalErrors == 1 }, time.Second, 10*time.Millisecond)
4521040 }
4531041
1042+type smartTCPRoute struct {
1043+ tlsSNI string
1044+ backendAddress string
1045+ trustedCIDRs []string
1046+ allowPublic bool
1047+}
1048+
1049+type smartTCPHits struct {
1050+ httpHits *atomic.Int64
1051+ rawHits *atomic.Int64
1052+}
1053+
1054+func startSmartTCP(t *testing.T, tlsRoute *smartTCPRoute, rawRoute *smartTCPRoute) (*Manager, domain.TrafficGraph, smartTCPHits) {
1055+ t.Helper()
1056+ manager := NewManager()
1057+ graph := smartTCPGraph(t, freeTCPAddress(t), tlsRoute, rawRoute)
1058+ httpHits := &atomic.Int64{}
1059+ rawHits := &atomic.Int64{}
1060+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1061+ httpHits.Add(1)
1062+ _, _ = w.Write([]byte("http:" + r.URL.Path))
1063+ }), nil)
1064+ manager.SetSmartTCPTLSServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1065+ _, _ = w.Write([]byte("smart https"))
1066+ }), testTLSConfig(t, "app.example.com"))
1067+ require.NoError(t, manager.Apply(context.Background(), &graph))
1068+ return manager, graph, smartTCPHits{httpHits: httpHits, rawHits: rawHits}
1069+}
1070+
1071+func smartTCPGraph(t *testing.T, listenAddress string, tlsRoute *smartTCPRoute, rawRoute *smartTCPRoute) domain.TrafficGraph {
1072+ t.Helper()
1073+ entry := domain.EntryPoint{Name: "edge", Address: listenAddress, Protocol: domain.EntryPointProtocolSmartTCP}
1074+ routers := []domain.TrafficRouter{{Name: "route:app", EntryPoint: "edge", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app"}}
1075+ services := []domain.TrafficService{{Name: "route:app"}}
1076+ if tlsRoute != nil {
1077+ backend, err := backendFromAddress("tls:raw", tlsRoute.backendAddress)
1078+ require.NoError(t, err)
1079+ routers = append(routers, domain.TrafficRouter{Name: "raw-tls", EntryPoint: "edge", Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: tlsRoute.tlsSNI}, Service: "network_service:tls:raw"})
1080+ services = append(services, domain.TrafficService{Name: "network_service:tls:raw", Backends: []domain.TrafficBackend{backend}})
1081+ }
1082+ if rawRoute != nil {
1083+ backend, err := backendFromAddress("raw:tcp", rawRoute.backendAddress)
1084+ require.NoError(t, err)
1085+ entry.RawFallback = "raw-fallback"
1086+ entry.RawFallbackTrustedCIDRs = rawRoute.trustedCIDRs
1087+ entry.AllowPublicRawFallback = rawRoute.allowPublic
1088+ routers = append(routers, domain.TrafficRouter{Name: "raw-fallback", EntryPoint: "edge", Protocol: domain.RouterProtocolTCP, Service: "network_service:raw:tcp"})
1089+ services = append(services, domain.TrafficService{Name: "network_service:raw:tcp", Backends: []domain.TrafficBackend{backend}})
1090+ }
1091+ graph := domain.TrafficGraph{Options: domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: time.Second, IdleTimeout: time.Minute, DrainTimeout: time.Second}}, EntryPoints: []domain.EntryPoint{entry}, Routers: routers, Services: services}
1092+ require.NoError(t, graph.Validate())
1093+ return graph
1094+}
1095+
1096+func assertSmartTCPWriteRejected(t *testing.T, address string, payload []byte) {
1097+ t.Helper()
1098+ conn := dialTCP(t, address)
1099+ defer conn.Close()
1100+ _, err := conn.Write(payload)
1101+ require.NoError(t, err)
1102+ _, err = bufio.NewReader(conn).ReadByte()
1103+ require.Error(t, err)
1104+}
1105+
4541106 func tcpGraph(t *testing.T, listenAddress string, backendAddress string) domain.TrafficGraph {
4551107 t.Helper()
4561108 backend, err := backendFromAddress("echo:tcp", backendAddress)
@@ -498,6 +1150,15 @@ func shutdownManager(t *testing.T, manager *Manager) {
4981150 require.NoError(t, manager.Shutdown(ctx))
4991151 }
5001152
1153+func tcpRuntimeForTest(t *testing.T, manager *Manager, name string) *entryPointRuntime {
1154+ t.Helper()
1155+ manager.mu.Lock()
1156+ defer manager.mu.Unlock()
1157+ runtime := manager.listeners[name]
1158+ require.NotNil(t, runtime)
1159+ return runtime
1160+}
1161+
5011162 type tcpEchoServer struct {
5021163 address string
5031164 close func()
@@ -2,6 +2,7 @@ package traffic
2 2
3 import (3 import (
4 "bufio"4 "bufio"
5+ "bytes"
5 "context"6 "context"
6 "crypto/tls"7 "crypto/tls"
7 "errors"8 "errors"
@@ -9,6 +10,7 @@ import (
9 "io"10 "io"
10 "net"11 "net"
11 "net/http"12 "net/http"
13+ "sync/atomic"
12 "testing"14 "testing"
13 "time"15 "time"
14 16
@@ -23,6 +25,592 @@ func TestTrafficRuntimeDefaultsApplySafeLimits(t *testing.T) {
23 assert.Equal(t, 4096, effectiveUDPOptions(domain.UDPOptions{}).MaxSessions)25 assert.Equal(t, 4096, effectiveUDPOptions(domain.UDPOptions{}).MaxSessions)
24 }26 }
25 27
28+func TestSmartTCPDispatch(t *testing.T) {
29+ t.Run("cleartext HTTP request reaches HTTP handler", func(t *testing.T) {
30+ manager, graph, hits := startSmartTCP(t, nil, nil)
31+ defer shutdownManager(t, manager)
32+
33+ conn := dialTCP(t, graph.EntryPoints[0].Address)
34+ defer conn.Close()
35+ _, err := conn.Write([]byte("GET /clear HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
36+ require.NoError(t, err)
37+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
38+ require.NoError(t, err)
39+ body, err := io.ReadAll(resp.Body)
40+ require.NoError(t, err)
41+ require.NoError(t, resp.Body.Close())
42+ assert.Contains(t, string(body), "http:/clear")
43+ assert.Equal(t, int64(1), hits.httpHits.Load())
44+ assert.Equal(t, int64(0), hits.rawHits.Load())
45+ })
46+
47+ t.Run("h2c prior-knowledge reaches HTTP handler", func(t *testing.T) {
48+ manager, graph, hits := startSmartTCP(t, nil, nil)
49+ defer shutdownManager(t, manager)
50+ conn := dialTCP(t, graph.EntryPoints[0].Address)
51+ defer conn.Close()
52+ _, err := conn.Write([]byte(h2cPreface))
53+ require.NoError(t, err)
54+ assert.Eventually(t, func() bool { return hits.httpHits.Load() == 1 }, time.Second, 10*time.Millisecond)
55+ assert.Equal(t, int64(0), hits.rawHits.Load())
56+ })
57+
58+ t.Run("HTTPS request reaches configured HTTPS fallback", func(t *testing.T) {
59+ manager, graph, _ := startSmartTCP(t, nil, nil)
60+ defer shutdownManager(t, manager)
61+ assertHTTPSBody(t, graph.EntryPoints[0].Address, "app.example.com", "smart https")
62+ })
63+
64+ t.Run("TLS passthrough SNI match wins before HTTPS fallback", func(t *testing.T) {
65+ backend := startTLSGreetingServer(t, "raw.example.com", "raw tls\n")
66+ manager, graph, _ := startSmartTCP(t, &smartTCPRoute{tlsSNI: "raw.example.com", backendAddress: backend.address}, nil)
67+ defer shutdownManager(t, manager)
68+ assertTLSGreeting(t, graph.EntryPoints[0].Address, "raw.example.com", "raw tls\n")
69+ assertHTTPSBody(t, graph.EntryPoints[0].Address, "app.example.com", "smart https")
70+ })
71+
72+ t.Run("no-SNI TLS reaches HTTPS fallback", func(t *testing.T) {
73+ manager, graph, _ := startSmartTCP(t, nil, nil)
74+ defer shutdownManager(t, manager)
75+ assertHTTPSBodyNoSNI(t, graph.EntryPoints[0].Address, "smart https")
76+ })
77+
78+ t.Run("unknown bytes reach raw fallback immediately when source policy allows", func(t *testing.T) {
79+ backend := startTCPEchoServer(t, 0)
80+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, trustedCIDRs: []string{"127.0.0.0/8"}})
81+ defer shutdownManager(t, manager)
82+ conn := dialTCP(t, graph.EntryPoints[0].Address)
83+ defer conn.Close()
84+ assertRoundTrip(t, conn, "ssh-raw")
85+ })
86+
87+ t.Run("unknown bytes refused without raw fallback", func(t *testing.T) {
88+ manager, graph, _ := startSmartTCP(t, nil, nil)
89+ defer shutdownManager(t, manager)
90+ assertTCPRejected(t, manager, graph.EntryPoints[0].Address, 1)
91+ })
92+
93+ t.Run("unknown bytes refused when raw fallback source policy denies peer", func(t *testing.T) {
94+ backend := startTCPEchoServer(t, 0)
95+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, trustedCIDRs: []string{"192.0.2.0/24"}})
96+ defer shutdownManager(t, manager)
97+ assertTCPRejected(t, manager, graph.EntryPoints[0].Address, 1)
98+ })
99+
100+ t.Run("malformed HTTP-looking bytes do not reach raw fallback", func(t *testing.T) {
101+ backend := startTCPEchoServer(t, 0)
102+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, allowPublic: true})
103+ defer shutdownManager(t, manager)
104+ conn := dialTCP(t, graph.EntryPoints[0].Address)
105+ defer conn.Close()
106+ _, err := conn.Write([]byte("GET /broken\r\n"))
107+ require.NoError(t, err)
108+ _, err = bufio.NewReader(conn).ReadByte()
109+ require.Error(t, err)
110+ })
111+
112+ t.Run("malformed TLS-looking bytes do not reach raw fallback", func(t *testing.T) {
113+ backend := startTCPEchoServer(t, 0)
114+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, allowPublic: true})
115+ defer shutdownManager(t, manager)
116+ conn := dialTCP(t, graph.EntryPoints[0].Address)
117+ defer conn.Close()
118+ _, err := conn.Write([]byte{22, 3, 3, 0, 8, 2, 0, 0, 4, 0, 0, 0, 0})
119+ require.NoError(t, err)
120+ _, err = bufio.NewReader(conn).ReadByte()
121+ require.Error(t, err)
122+ })
123+
124+ t.Run("PROXY v1 and v2 prefixes rejected", func(t *testing.T) {
125+ manager, graph, _ := startSmartTCP(t, nil, nil)
126+ defer shutdownManager(t, manager)
127+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, []byte("PROXY TCP4 127.0.0.1 127.0.0.1 1 2\r\n"))
128+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, append([]byte(proxyV2Signature), 0x21, 0x11, 0, 0))
129+ })
130+
131+ t.Run("sniff deadline cleared before backend handoff", func(t *testing.T) {
132+ backend := startTCPEchoServer(t, 150*time.Millisecond)
133+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, allowPublic: true})
134+ defer shutdownManager(t, manager)
135+ conn := dialTCP(t, graph.EntryPoints[0].Address)
136+ defer conn.Close()
137+ assertRoundTrip(t, conn, "deadline-clear")
138+ })
139+
140+ t.Run("raw fallback trusted CIDR reload reuses same listener", func(t *testing.T) {
141+ backend := startTCPEchoServer(t, 0)
142+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, trustedCIDRs: []string{"192.0.2.0/24"}})
143+ defer shutdownManager(t, manager)
144+ assertTCPRejected(t, manager, graph.EntryPoints[0].Address, 1)
145+
146+ initialRuntime := tcpRuntimeForTest(t, manager, "edge")
147+ replacement := graph
148+ replacement.EntryPoints = append([]domain.EntryPoint(nil), graph.EntryPoints...)
149+ replacement.EntryPoints[0].RawFallbackTrustedCIDRs = []string{"127.0.0.0/8"}
150+ replacement.Routers = append([]domain.TrafficRouter(nil), graph.Routers...)
151+ replacement.Services = append([]domain.TrafficService(nil), graph.Services...)
152+ require.NoError(t, manager.Apply(context.Background(), &replacement))
153+ assert.Same(t, initialRuntime, tcpRuntimeForTest(t, manager, "edge"))
154+
155+ conn := dialTCP(t, replacement.EntryPoints[0].Address)
156+ defer conn.Close()
157+ assertRoundTrip(t, conn, "raw-reloaded")
158+ })
159+
160+ t.Run("raw fallback connection closes when trusted CIDR no longer allows peer", func(t *testing.T) {
161+ backend := startTCPEchoServer(t, 0)
162+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, trustedCIDRs: []string{"127.0.0.0/8"}})
163+ defer shutdownManager(t, manager)
164+
165+ conn := dialTCP(t, graph.EntryPoints[0].Address)
166+ defer conn.Close()
167+ assertRoundTrip(t, conn, "before")
168+
169+ updated := graph
170+ updated.EntryPoints = append([]domain.EntryPoint(nil), graph.EntryPoints...)
171+ updated.EntryPoints[0].RawFallbackTrustedCIDRs = []string{"192.0.2.0/24"}
172+ require.NoError(t, manager.Apply(context.Background(), &updated))
173+
174+ assert.Eventually(t, func() bool {
175+ _, writeErr := conn.Write([]byte("after\n"))
176+ if writeErr != nil {
177+ return true
178+ }
179+ _ = conn.SetReadDeadline(time.Now().Add(10 * time.Millisecond))
180+ _, readErr := bufio.NewReader(conn).ReadByte()
181+ return readErr != nil
182+ }, time.Second, 10*time.Millisecond)
183+ })
184+
185+ t.Run("TLS ClientHello read after sniff is bounded", func(t *testing.T) {
186+ manager, graph, _ := startSmartTCP(t, nil, nil)
187+ defer shutdownManager(t, manager)
188+ graph.Options.TCP.DialTimeout = 100 * time.Millisecond
189+ require.NoError(t, manager.Apply(context.Background(), &graph))
190+
191+ conn := dialTCP(t, graph.EntryPoints[0].Address)
192+ defer conn.Close()
193+ start := time.Now()
194+ _, err := conn.Write([]byte{22, 3, 3, 0, 42})
195+ require.NoError(t, err)
196+ require.NoError(t, conn.SetReadDeadline(time.Now().Add(750*time.Millisecond)))
197+ _, err = bufio.NewReader(conn).ReadByte()
198+ require.Error(t, err)
199+ assert.Less(t, time.Since(start), 500*time.Millisecond)
200+ })
201+}
202+
203+func TestSmartTCPStatusCounters(t *testing.T) {
204+ t.Run("HTTP accepted", func(t *testing.T) {
205+ manager, graph, _ := startSmartTCP(t, nil, nil)
206+ defer shutdownManager(t, manager)
207+ conn := dialTCP(t, graph.EntryPoints[0].Address)
208+ defer conn.Close()
209+ _, err := conn.Write([]byte("GET /status HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
210+ require.NoError(t, err)
211+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
212+ require.NoError(t, err)
213+ _, _ = io.ReadAll(resp.Body)
214+ _ = resp.Body.Close()
215+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.HTTPAccepted == 1 })
216+ })
217+
218+ t.Run("h2c accepted", func(t *testing.T) {
219+ manager, graph, _ := startSmartTCP(t, nil, nil)
220+ defer shutdownManager(t, manager)
221+ conn := dialTCP(t, graph.EntryPoints[0].Address)
222+ defer conn.Close()
223+ _, err := conn.Write([]byte(h2cPreface))
224+ require.NoError(t, err)
225+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.H2CAccepted == 1 })
226+ })
227+
228+ t.Run("HTTPS fallback accepted", func(t *testing.T) {
229+ manager, graph, _ := startSmartTCP(t, nil, nil)
230+ defer shutdownManager(t, manager)
231+ assertHTTPSBody(t, graph.EntryPoints[0].Address, "app.example.com", "smart https")
232+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.HTTPSFallbackAccepted == 1 })
233+ })
234+
235+ t.Run("TLS passthrough accepted", func(t *testing.T) {
236+ backend := startTLSGreetingServer(t, "raw.example.com", "raw tls\n")
237+ manager, graph, _ := startSmartTCP(t, &smartTCPRoute{tlsSNI: "raw.example.com", backendAddress: backend.address}, nil)
238+ defer shutdownManager(t, manager)
239+ assertTLSGreeting(t, graph.EntryPoints[0].Address, "raw.example.com", "raw tls\n")
240+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.TLSPassthroughAccepted == 1 })
241+ })
242+
243+ t.Run("raw fallback accepted", func(t *testing.T) {
244+ backend := startTCPEchoServer(t, 0)
245+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, allowPublic: true})
246+ defer shutdownManager(t, manager)
247+ conn := dialTCP(t, graph.EntryPoints[0].Address)
248+ defer conn.Close()
249+ assertRoundTrip(t, conn, "raw")
250+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.RawFallbackAccepted == 1 })
251+ })
252+
253+ t.Run("refused and rejected classes", func(t *testing.T) {
254+ backend := startTCPEchoServer(t, 0)
255+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, trustedCIDRs: []string{"192.0.2.0/24"}})
256+ defer shutdownManager(t, manager)
257+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, []byte("PROXY TCP4 127.0.0.1 127.0.0.1 1 2\r\n"))
258+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, []byte{0})
259+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, []byte("GET /broken\r\n"))
260+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, bytes.Repeat([]byte("G"), maxSmartTCPSniffBytes+1))
261+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool {
262+ return c.PROXYRefused == 1 && c.RawFallbackCIDRRefused == 1 && c.MalformedRejected == 1 && c.ClientHelloTooLarge == 1
263+ })
264+ })
265+
266+ t.Run("unknown without fallback refused", func(t *testing.T) {
267+ manager, graph, _ := startSmartTCP(t, nil, nil)
268+ defer shutdownManager(t, manager)
269+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, []byte{0})
270+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.UnknownNoFallbackRefused == 1 })
271+ })
272+
273+ t.Run("sniff timeout", func(t *testing.T) {
274+ manager, graph, _ := startSmartTCP(t, nil, nil)
275+ defer shutdownManager(t, manager)
276+ graph.Options.TCP.DialTimeout = 50 * time.Millisecond
277+ require.NoError(t, manager.Apply(context.Background(), &graph))
278+ conn := dialTCP(t, graph.EntryPoints[0].Address)
279+ defer conn.Close()
280+ _, err := conn.Write([]byte("P"))
281+ require.NoError(t, err)
282+ _, _ = bufio.NewReader(conn).ReadByte()
283+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.SniffTimeout == 1 })
284+ })
285+
286+ t.Run("entrypoint CIDR refused", func(t *testing.T) {
287+ manager, graph, _ := startSmartTCP(t, nil, nil)
288+ defer shutdownManager(t, manager)
289+ graph.EntryPoints[0].TrustedCIDRs = []string{"192.0.2.0/24"}
290+ require.NoError(t, manager.Apply(context.Background(), &graph))
291+ conn := dialTCP(t, graph.EntryPoints[0].Address)
292+ _ = conn.Close()
293+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.EntrypointCIDRRefused == 1 })
294+ })
295+}
296+
297+func TestManagerStatusAggregatesSmartTCP(t *testing.T) {
298+ manager, graph, _ := startSmartTCP(t, nil, nil)
299+ defer shutdownManager(t, manager)
300+ conn := dialTCP(t, graph.EntryPoints[0].Address)
301+ defer conn.Close()
302+ _, err := conn.Write([]byte("GET /aggregate HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
303+ require.NoError(t, err)
304+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
305+ require.NoError(t, err)
306+ _, _ = io.ReadAll(resp.Body)
307+ _ = resp.Body.Close()
308+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.HTTPAccepted == 1 })
309+}
310+
311+func assertSmartTCPStatus(t *testing.T, manager *Manager, match func(domain.SmartTCPCounters) bool) {
312+ t.Helper()
313+ assert.Eventually(t, func() bool {
314+ status := manager.Status()
315+ if len(status.EntryPoints) == 0 || !match(status.EntryPoints[0].SmartTCP) {
316+ return false
317+ }
318+ return match(status.Counters.SmartTCP)
319+ }, time.Second, 10*time.Millisecond)
320+}
321+
322+func TestSmartTCPLifecycleHardening(t *testing.T) {
323+ t.Run("internal HTTP and HTTPS fallback servers stop when entrypoint removed", func(t *testing.T) {
324+ manager, graph, _ := startSmartTCP(t, nil, nil)
325+ initialRuntime := tcpRuntimeForTest(t, manager, "edge")
326+ require.NotNil(t, initialRuntime)
327+
328+ require.NoError(t, manager.Apply(context.Background(), &domain.TrafficGraph{Options: graph.Options}))
329+ assert.Eventually(t, func() bool {
330+ initialRuntime.mu.Lock()
331+ defer initialRuntime.mu.Unlock()
332+ return initialRuntime.smartHTTPServer == nil && initialRuntime.smartTLSServer == nil
333+ }, time.Second, 10*time.Millisecond)
334+ shutdownManager(t, manager)
335+ })
336+
337+ t.Run("HTTP fallback handoff remains counted until hijacked connection closes", func(t *testing.T) {
338+ manager := NewManager()
339+ graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
340+ graph.Options.TCP.MaxConnections = 1
341+ hijacked := make(chan struct{})
342+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
343+ hj, ok := w.(http.Hijacker)
344+ require.True(t, ok)
345+ conn, rw, err := hj.Hijack()
346+ require.NoError(t, err)
347+ defer conn.Close()
348+ _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\nready\n")
349+ _ = rw.Flush()
350+ close(hijacked)
351+ _, _ = rw.ReadString('\n')
352+ }), nil)
353+ manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
354+ require.NoError(t, manager.Apply(context.Background(), &graph))
355+ defer shutdownManager(t, manager)
356+
357+ first := dialTCP(t, graph.EntryPoints[0].Address)
358+ defer first.Close()
359+ firstReader := bufio.NewReader(first)
360+ _, err := first.Write([]byte("GET /ws HTTP/1.1\r\nHost: app.example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"))
361+ require.NoError(t, err)
362+ resp, err := http.ReadResponse(firstReader, nil)
363+ require.NoError(t, err)
364+ assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode)
365+ ready, err := firstReader.ReadString('\n')
366+ require.NoError(t, err)
367+ assert.Equal(t, "ready\n", ready)
368+ <-hijacked
369+ require.Eventually(t, func() bool { return manager.Status().Counters.ActiveTCPConnections == 1 }, time.Second, 10*time.Millisecond)
370+
371+ second := dialTCP(t, graph.EntryPoints[0].Address)
372+ _, err = second.Write([]byte("GET / HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
373+ require.NoError(t, err)
374+ _, err = bufio.NewReader(second).ReadByte()
375+ _ = second.Close()
376+ require.Error(t, err)
377+ assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == 1 }, time.Second, 10*time.Millisecond)
378+
379+ _, err = first.Write([]byte("close\n"))
380+ require.NoError(t, err)
381+ require.Eventually(t, func() bool { return manager.Status().Counters.ActiveTCPConnections == 0 }, time.Second, 10*time.Millisecond)
382+ third := dialTCP(t, graph.EntryPoints[0].Address)
383+ defer third.Close()
384+ _, err = third.Write([]byte("GET /after HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
385+ require.NoError(t, err)
386+ resp, err = http.ReadResponse(bufio.NewReader(third), nil)
387+ require.NoError(t, err)
388+ assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode)
389+ })
390+
391+ t.Run("shutdown force drain closes open HTTP fallback handoff", func(t *testing.T) {
392+ manager := NewManager()
393+ graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
394+ graph.Options.TCP.DrainTimeout = 50 * time.Millisecond
395+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
396+ hj, ok := w.(http.Hijacker)
397+ require.True(t, ok)
398+ conn, rw, err := hj.Hijack()
399+ require.NoError(t, err)
400+ defer conn.Close()
401+ _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\nready\n")
402+ _ = rw.Flush()
403+ _, _ = rw.ReadString('\n')
404+ }), nil)
405+ manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
406+ require.NoError(t, manager.Apply(context.Background(), &graph))
407+
408+ conn := dialTCP(t, graph.EntryPoints[0].Address)
409+ defer conn.Close()
410+ reader := bufio.NewReader(conn)
411+ _, err := conn.Write([]byte("GET /ws HTTP/1.1\r\nHost: app.example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"))
412+ require.NoError(t, err)
413+ resp, err := http.ReadResponse(reader, nil)
414+ require.NoError(t, err)
415+ assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode)
416+ ready, err := reader.ReadString('\n')
417+ require.NoError(t, err)
418+ assert.Equal(t, "ready\n", ready)
419+
420+ started := time.Now()
421+ require.NoError(t, manager.Apply(context.Background(), &domain.TrafficGraph{Options: graph.Options}))
422+ assert.GreaterOrEqual(t, time.Since(started), 40*time.Millisecond)
423+ _, err = conn.Write([]byte("after\n"))
424+ if err == nil {
425+ _, err = reader.ReadByte()
426+ }
427+ require.Error(t, err)
428+ })
429+
430+ t.Run("trusted CIDR change closes active smart TCP connections no longer allowed", func(t *testing.T) {
431+ backend := startTCPEchoServer(t, 0)
432+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, allowPublic: true})
433+ defer shutdownManager(t, manager)
434+ graph.EntryPoints[0].TrustedCIDRs = []string{"127.0.0.0/8"}
435+ require.NoError(t, manager.Apply(context.Background(), &graph))
436+
437+ conn := dialTCP(t, graph.EntryPoints[0].Address)
438+ defer conn.Close()
439+ assertRoundTrip(t, conn, "before")
440+
441+ updated := graph
442+ updated.EntryPoints[0].TrustedCIDRs = []string{"192.0.2.0/24"}
443+ require.NoError(t, manager.Apply(context.Background(), &updated))
444+ assert.Eventually(t, func() bool {
445+ _, writeErr := conn.Write([]byte("after\n"))
446+ if writeErr != nil {
447+ return true
448+ }
449+ _ = conn.SetReadDeadline(time.Now().Add(10 * time.Millisecond))
450+ _, readErr := bufio.NewReader(conn).ReadByte()
451+ return readErr != nil
452+ }, time.Second, 10*time.Millisecond)
453+ })
454+
455+ t.Run("active upgraded connection remains tied to accepted generation", func(t *testing.T) {
456+ manager := NewManager()
457+ graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
458+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
459+ hj, ok := w.(http.Hijacker)
460+ require.True(t, ok)
461+ conn, rw, err := hj.Hijack()
462+ require.NoError(t, err)
463+ _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\nold-ready\n")
464+ _ = rw.Flush()
465+ line, err := rw.ReadString('\n')
466+ if err == nil {
467+ _, _ = conn.Write([]byte("old:" + line))
468+ }
469+ _ = conn.Close()
470+ }), nil)
471+ manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
472+ require.NoError(t, manager.Apply(context.Background(), &graph))
473+ defer shutdownManager(t, manager)
474+
475+ conn := dialTCP(t, graph.EntryPoints[0].Address)
476+ defer conn.Close()
477+ reader := bufio.NewReader(conn)
478+ _, err := conn.Write([]byte("GET /ws HTTP/1.1\r\nHost: app.example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"))
479+ require.NoError(t, err)
480+ resp, err := http.ReadResponse(reader, nil)
481+ require.NoError(t, err)
482+ assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode)
483+ ready, err := reader.ReadString('\n')
484+ require.NoError(t, err)
485+ assert.Equal(t, "old-ready\n", ready)
486+
487+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("new")) }), nil)
488+ _, err = conn.Write([]byte("ping\n"))
489+ require.NoError(t, err)
490+ got, err := reader.ReadString('\n')
491+ require.NoError(t, err)
492+ assert.Equal(t, "old:ping\n", got)
493+ })
494+
495+ t.Run("listener is not reused for incompatible address", func(t *testing.T) {
496+ manager, graph, _ := startSmartTCP(t, nil, nil)
497+ defer shutdownManager(t, manager)
498+ initialRuntime := tcpRuntimeForTest(t, manager, "edge")
499+
500+ updated := graph
501+ updated.EntryPoints[0].Address = freeTCPAddress(t)
502+ require.NoError(t, manager.Apply(context.Background(), &updated))
503+ assert.NotSame(t, initialRuntime, tcpRuntimeForTest(t, manager, "edge"))
504+ })
505+}
506+
507+func TestSmartTCPHTTPHandoff(t *testing.T) {
508+ t.Run("HTTP Upgrade h2c reaches handler and not raw fallback", func(t *testing.T) {
509+ manager, graph, hits := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: startTCPEchoServer(t, 0).address, allowPublic: true})
510+ defer shutdownManager(t, manager)
511+ conn := dialTCP(t, graph.EntryPoints[0].Address)
512+ defer conn.Close()
513+ _, err := conn.Write([]byte("GET /upgrade HTTP/1.1\r\nHost: app.example.com\r\nConnection: Upgrade, HTTP2-Settings\r\nUpgrade: h2c\r\nHTTP2-Settings: AAMAAABkAAQAAP__\r\n\r\n"))
514+ require.NoError(t, err)
515+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
516+ require.NoError(t, err)
517+ _, _ = io.ReadAll(resp.Body)
518+ _ = resp.Body.Close()
519+ assert.Equal(t, int64(1), hits.httpHits.Load())
520+ assert.Equal(t, int64(0), hits.rawHits.Load())
521+ })
522+
523+ t.Run("websocket upgrade supports hijacker", func(t *testing.T) {
524+ manager := NewManager()
525+ graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
526+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
527+ hj, ok := w.(http.Hijacker)
528+ require.True(t, ok)
529+ conn, rw, err := hj.Hijack()
530+ require.NoError(t, err)
531+ defer conn.Close()
532+ _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\nws-ok")
533+ _ = rw.Flush()
534+ }), nil)
535+ manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
536+ require.NoError(t, manager.Apply(context.Background(), &graph))
537+ defer shutdownManager(t, manager)
538+ conn := dialTCP(t, graph.EntryPoints[0].Address)
539+ defer conn.Close()
540+ _, err := conn.Write([]byte("GET /ws HTTP/1.1\r\nHost: app.example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"))
541+ require.NoError(t, err)
542+ body, err := io.ReadAll(conn)
543+ require.NoError(t, err)
544+ assert.Contains(t, string(body), "ws-ok")
545+ })
546+
547+ t.Run("CONNECT reaches HTTP handler and not raw fallback", func(t *testing.T) {
548+ manager, graph, hits := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: startTCPEchoServer(t, 0).address, allowPublic: true})
549+ defer shutdownManager(t, manager)
550+ conn := dialTCP(t, graph.EntryPoints[0].Address)
551+ defer conn.Close()
552+ _, err := conn.Write([]byte("CONNECT db.example.com:443 HTTP/1.1\r\nHost: db.example.com:443\r\nConnection: close\r\n\r\n"))
553+ require.NoError(t, err)
554+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
555+ require.NoError(t, err)
556+ _, _ = io.ReadAll(resp.Body)
557+ _ = resp.Body.Close()
558+ assert.Equal(t, int64(1), hits.httpHits.Load())
559+ assert.Equal(t, int64(0), hits.rawHits.Load())
560+ })
561+
562+ t.Run("handoff connection has cleared deadlines and useful addresses", func(t *testing.T) {
563+ seen := make(chan string, 1)
564+ manager := NewManager()
565+ graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
566+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
567+ time.Sleep(150 * time.Millisecond)
568+ seen <- r.RemoteAddr + "|" + r.Context().Value(http.LocalAddrContextKey).(net.Addr).String()
569+ _, _ = w.Write([]byte("addr-ok"))
570+ }), nil)
571+ manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
572+ require.NoError(t, manager.Apply(context.Background(), &graph))
573+ defer shutdownManager(t, manager)
574+ conn := dialTCP(t, graph.EntryPoints[0].Address)
575+ defer conn.Close()
576+ _, err := conn.Write([]byte("GET /addr HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
577+ require.NoError(t, err)
578+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
579+ require.NoError(t, err)
580+ _, _ = io.ReadAll(resp.Body)
581+ _ = resp.Body.Close()
582+ got := <-seen
583+ assert.Contains(t, got, "127.0.0.1")
584+ assert.Contains(t, got, graph.EntryPoints[0].Address)
585+ })
586+}
587+
588+func TestRouteToHTTPListenerServeFailureReturnsFalseAndLeavesTrackedForCaller(t *testing.T) {
589+ listener, err := net.Listen("tcp", "127.0.0.1:0")
590+ require.NoError(t, err)
591+ defer listener.Close()
592+
593+ runtime := newEntryPointRuntime(context.Background(), NewManager(), domain.EntryPoint{Name: "edge"}, listener, nil)
594+ httpListener := newTLSHTTPListener(listener.Addr())
595+ require.NoError(t, httpListener.Close())
596+
597+ clientConn, serverConn := net.Pipe()
598+ defer clientConn.Close()
599+ defer serverConn.Close()
600+ tracked := &trackedTCPConn{client: clientConn}
601+ runtime.track(tracked)
602+
603+ accepted := runtime.routeToHTTPListener(tracked, serverConn, httpListener)
604+ require.False(t, accepted)
605+
606+ runtime.mu.Lock()
607+ _, stillTracked := runtime.activeConns[tracked]
608+ runtime.mu.Unlock()
609+ assert.True(t, stillTracked)
610+
611+ runtime.untrack(tracked)
612+}
613+
26 func TestTCPManagerLifecycle(t *testing.T) {614 func TestTCPManagerLifecycle(t *testing.T) {
27 backend := startTCPEchoServer(t, 0)615 backend := startTCPEchoServer(t, 0)
28 graph := tcpGraph(t, freeTCPAddress(t), backend.address)616 graph := tcpGraph(t, freeTCPAddress(t), backend.address)
@@ -451,6 +1039,70 @@ func TestTCPPassthroughStatusCountersTrackAcceptedRefusedErrorsAndBytes(t *testi
451 assert.Eventually(t, func() bool { return manager.Status().Counters.TotalErrors == 1 }, time.Second, 10*time.Millisecond)1039 assert.Eventually(t, func() bool { return manager.Status().Counters.TotalErrors == 1 }, time.Second, 10*time.Millisecond)
452 }1040 }
453 1041
1042+type smartTCPRoute struct {
1043+ tlsSNI string
1044+ backendAddress string
1045+ trustedCIDRs []string
1046+ allowPublic bool
1047+}
1048+
1049+type smartTCPHits struct {
1050+ httpHits *atomic.Int64
1051+ rawHits *atomic.Int64
1052+}
1053+
1054+func startSmartTCP(t *testing.T, tlsRoute *smartTCPRoute, rawRoute *smartTCPRoute) (*Manager, domain.TrafficGraph, smartTCPHits) {
1055+ t.Helper()
1056+ manager := NewManager()
1057+ graph := smartTCPGraph(t, freeTCPAddress(t), tlsRoute, rawRoute)
1058+ httpHits := &atomic.Int64{}
1059+ rawHits := &atomic.Int64{}
1060+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1061+ httpHits.Add(1)
1062+ _, _ = w.Write([]byte("http:" + r.URL.Path))
1063+ }), nil)
1064+ manager.SetSmartTCPTLSServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1065+ _, _ = w.Write([]byte("smart https"))
1066+ }), testTLSConfig(t, "app.example.com"))
1067+ require.NoError(t, manager.Apply(context.Background(), &graph))
1068+ return manager, graph, smartTCPHits{httpHits: httpHits, rawHits: rawHits}
1069+}
1070+
1071+func smartTCPGraph(t *testing.T, listenAddress string, tlsRoute *smartTCPRoute, rawRoute *smartTCPRoute) domain.TrafficGraph {
1072+ t.Helper()
1073+ entry := domain.EntryPoint{Name: "edge", Address: listenAddress, Protocol: domain.EntryPointProtocolSmartTCP}
1074+ routers := []domain.TrafficRouter{{Name: "route:app", EntryPoint: "edge", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app"}}
1075+ services := []domain.TrafficService{{Name: "route:app"}}
1076+ if tlsRoute != nil {
1077+ backend, err := backendFromAddress("tls:raw", tlsRoute.backendAddress)
1078+ require.NoError(t, err)
1079+ routers = append(routers, domain.TrafficRouter{Name: "raw-tls", EntryPoint: "edge", Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: tlsRoute.tlsSNI}, Service: "network_service:tls:raw"})
1080+ services = append(services, domain.TrafficService{Name: "network_service:tls:raw", Backends: []domain.TrafficBackend{backend}})
1081+ }
1082+ if rawRoute != nil {
1083+ backend, err := backendFromAddress("raw:tcp", rawRoute.backendAddress)
1084+ require.NoError(t, err)
1085+ entry.RawFallback = "raw-fallback"
1086+ entry.RawFallbackTrustedCIDRs = rawRoute.trustedCIDRs
1087+ entry.AllowPublicRawFallback = rawRoute.allowPublic
1088+ routers = append(routers, domain.TrafficRouter{Name: "raw-fallback", EntryPoint: "edge", Protocol: domain.RouterProtocolTCP, Service: "network_service:raw:tcp"})
1089+ services = append(services, domain.TrafficService{Name: "network_service:raw:tcp", Backends: []domain.TrafficBackend{backend}})
1090+ }
1091+ graph := domain.TrafficGraph{Options: domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: time.Second, IdleTimeout: time.Minute, DrainTimeout: time.Second}}, EntryPoints: []domain.EntryPoint{entry}, Routers: routers, Services: services}
1092+ require.NoError(t, graph.Validate())
1093+ return graph
1094+}
1095+
1096+func assertSmartTCPWriteRejected(t *testing.T, address string, payload []byte) {
1097+ t.Helper()
1098+ conn := dialTCP(t, address)
1099+ defer conn.Close()
1100+ _, err := conn.Write(payload)
1101+ require.NoError(t, err)
1102+ _, err = bufio.NewReader(conn).ReadByte()
1103+ require.Error(t, err)
1104+}
1105+
454 func tcpGraph(t *testing.T, listenAddress string, backendAddress string) domain.TrafficGraph {1106 func tcpGraph(t *testing.T, listenAddress string, backendAddress string) domain.TrafficGraph {
455 t.Helper()1107 t.Helper()
456 backend, err := backendFromAddress("echo:tcp", backendAddress)1108 backend, err := backendFromAddress("echo:tcp", backendAddress)
@@ -498,6 +1150,15 @@ func shutdownManager(t *testing.T, manager *Manager) {
498 require.NoError(t, manager.Shutdown(ctx))1150 require.NoError(t, manager.Shutdown(ctx))
499 }1151 }
500 1152
1153+func tcpRuntimeForTest(t *testing.T, manager *Manager, name string) *entryPointRuntime {
1154+ t.Helper()
1155+ manager.mu.Lock()
1156+ defer manager.mu.Unlock()
1157+ runtime := manager.listeners[name]
1158+ require.NotNil(t, runtime)
1159+ return runtime
1160+}
1161+
501 type tcpEchoServer struct {1162 type tcpEchoServer struct {
502 address string1163 address string
503 close func()1164 close func()
M internal/adapters/in/traffic/tls_mux.go +6 -2
@@ -60,9 +60,13 @@ func (m *Manager) loadTLSHTTPServers() tlsHTTPServers {
6060 }
6161
6262 func peekClientHelloSNI(conn net.Conn) (string, net.Conn, error) {
63+ return peekClientHelloSNIWithLimit(conn, maxClientHelloBytes)
64+}
65+
66+func peekClientHelloSNIWithLimit(conn net.Conn, maxBytes int) (string, net.Conn, error) {
6367 buf := make([]byte, 0, 4096)
6468 tmp := make([]byte, 1024)
65- for len(buf) < maxClientHelloBytes {
69+ for len(buf) < maxBytes {
6670 n, err := conn.Read(tmp)
6771 if n > 0 {
6872 buf = append(buf, tmp[:n]...)
@@ -78,7 +82,7 @@ func peekClientHelloSNI(conn net.Conn) (string, net.Conn, error) {
7882 return "", nil, fmt.Errorf("read client hello: %w", err)
7983 }
8084 }
81- return "", nil, fmt.Errorf("client hello exceeds %d bytes", maxClientHelloBytes)
85+ return "", nil, fmt.Errorf("client hello exceeds %d bytes", maxBytes)
8286 }
8387
8488 func parseClientHelloSNI(data []byte) (string, bool, error) {
@@ -60,9 +60,13 @@ func (m *Manager) loadTLSHTTPServers() tlsHTTPServers {
60 }60 }
61 61
62 func peekClientHelloSNI(conn net.Conn) (string, net.Conn, error) {62 func peekClientHelloSNI(conn net.Conn) (string, net.Conn, error) {
63+ return peekClientHelloSNIWithLimit(conn, maxClientHelloBytes)
64+}
65+
66+func peekClientHelloSNIWithLimit(conn net.Conn, maxBytes int) (string, net.Conn, error) {
63 buf := make([]byte, 0, 4096)67 buf := make([]byte, 0, 4096)
64 tmp := make([]byte, 1024)68 tmp := make([]byte, 1024)
65- for len(buf) < maxClientHelloBytes {69+ for len(buf) < maxBytes {
66 n, err := conn.Read(tmp)70 n, err := conn.Read(tmp)
67 if n > 0 {71 if n > 0 {
68 buf = append(buf, tmp[:n]...)72 buf = append(buf, tmp[:n]...)
@@ -78,7 +82,7 @@ func peekClientHelloSNI(conn net.Conn) (string, net.Conn, error) {
78 return "", nil, fmt.Errorf("read client hello: %w", err)82 return "", nil, fmt.Errorf("read client hello: %w", err)
79 }83 }
80 }84 }
81- return "", nil, fmt.Errorf("client hello exceeds %d bytes", maxClientHelloBytes)85+ return "", nil, fmt.Errorf("client hello exceeds %d bytes", maxBytes)
82 }86 }
83 87
84 func parseClientHelloSNI(data []byte) (string, bool, error) {88 func parseClientHelloSNI(data []byte) (string, bool, error) {
M internal/app/public_tls_wiring_test.go +111 -0
@@ -11,6 +11,7 @@ import (
1111 "testing"
1212 "time"
1313
14+ "github.com/spf13/viper"
1415 "github.com/stretchr/testify/assert"
1516 "github.com/stretchr/testify/mock"
1617 "github.com/stretchr/testify/require"
@@ -19,10 +20,120 @@ import (
1920 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
2021 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
2122 "github.com/bnema/gordon/internal/domain"
23+ config "github.com/bnema/gordon/internal/usecase/config"
2224 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
25+ "github.com/bnema/gordon/internal/usecase/publictls"
26+ traffic "github.com/bnema/gordon/internal/usecase/traffic"
2327 "github.com/bnema/zerowrap"
2428 )
2529
30+func TestPublicTLSReadinessAllowsDNS01WithoutEdgePort(t *testing.T) {
31+ cfg := Config{}
32+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeCloudflareDNS01)
33+
34+ require.NoError(t, validatePublicTLSReadiness(cfg))
35+ assert.Equal(t, 443, effectivePublicTLSPort(cfg))
36+}
37+
38+func TestPublicTLSReadinessRejectsAutoResolvedHTTP01WithoutBoundExternalPort80(t *testing.T) {
39+ cfg := Config{}
40+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeAuto)
41+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
42+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
43+ }
44+ effective := publictls.EffectiveChallenge{
45+ ConfiguredMode: domain.ACMEChallengeAuto,
46+ Mode: domain.ACMEChallengeHTTP01,
47+ TokenSource: domain.ACMETokenSourceNone,
48+ Reason: "auto selected http-01 (no cloudflare token)",
49+ }
50+
51+ err := validateEffectivePublicTLSReadiness(cfg, effective)
52+ require.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
53+}
54+
55+func TestEffectivePublicTLSPortPrefersTLSCapableEntrypointOverLegacyTLSPort(t *testing.T) {
56+ cfg := Config{}
57+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeCloudflareDNS01)
58+ cfg.Server.TLSPort = 8443
59+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
60+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
61+ }
62+
63+ assert.Equal(t, 443, effectivePublicTLSPort(cfg))
64+}
65+
66+func TestPublicTLSReadinessRequiresHTTP01ActuallyBoundExternalPort80(t *testing.T) {
67+ t.Run("rejects http entrypoint when smart tcp suppresses standalone http", func(t *testing.T) {
68+ cfg := Config{}
69+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeHTTP01)
70+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
71+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
72+ "web": {Address: ":80", Protocol: domain.EntryPointProtocolHTTP},
73+ }
74+
75+ err := validatePublicTLSReadiness(cfg)
76+ require.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
77+ })
78+
79+ t.Run("allows smart tcp entrypoint on port 80", func(t *testing.T) {
80+ cfg := Config{}
81+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeHTTP01)
82+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
83+ traffic.DefaultEdgeEntryPointName: {Address: ":80", Protocol: domain.EntryPointProtocolSmartTCP},
84+ }
85+
86+ require.NoError(t, validatePublicTLSReadiness(cfg))
87+ assert.Equal(t, 80, effectiveHTTP01Port(cfg))
88+ })
89+
90+ t.Run("rejects legacy standalone http port 80 without explicit entrypoint", func(t *testing.T) {
91+ cfg := Config{}
92+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeHTTP01)
93+ cfg.Server.Port = 80
94+
95+ err := validatePublicTLSReadiness(cfg)
96+ require.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
97+ assert.Equal(t, 0, effectiveHTTP01Port(cfg))
98+ })
99+
100+ t.Run("rejects legacy port 80 when smart tcp suppresses standalone http", func(t *testing.T) {
101+ cfg := Config{}
102+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeHTTP01)
103+ cfg.Server.Port = 80
104+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
105+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
106+ }
107+
108+ err := validatePublicTLSReadiness(cfg)
109+ require.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
110+ })
111+}
112+
113+func TestPKIInitializesForSmartTCPEvenWithoutLegacyTLSPort(t *testing.T) {
114+ si := &serviceInit{
115+ ctx: context.Background(),
116+ log: zerowrap.Default(),
117+ svc: &services{configSvc: cfgServiceForPKITest(t)},
118+ }
119+ si.cfg.Server.DataDir = t.TempDir()
120+ si.cfg.EntryPoints = map[string]traffic.EntryPointConfig{
121+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
122+ }
123+
124+ require.NoError(t, si.initPKI())
125+ require.NotNil(t, si.svc.pkiSvc)
126+ si.svc.pkiSvc.Stop()
127+}
128+
129+func cfgServiceForPKITest(t *testing.T) *config.Service {
130+ t.Helper()
131+ v := viper.New()
132+ svc := config.NewService(v, nil)
133+ require.NoError(t, svc.Load(context.Background()))
134+ return svc
135+}
136+
26137 // generateTestCert creates a self-signed certificate for the given hostnames.
27138 func generateTestCert(t *testing.T, hosts ...string) tls.Certificate {
28139 t.Helper()
@@ -11,6 +11,7 @@ import (
11 "testing"11 "testing"
12 "time"12 "time"
13 13
14+ "github.com/spf13/viper"
14 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/mock"16 "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"17 "github.com/stretchr/testify/require"
@@ -19,10 +20,120 @@ import (
19 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"20 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
20 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"21 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
21 "github.com/bnema/gordon/internal/domain"22 "github.com/bnema/gordon/internal/domain"
23+ config "github.com/bnema/gordon/internal/usecase/config"
22 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"24 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
25+ "github.com/bnema/gordon/internal/usecase/publictls"
26+ traffic "github.com/bnema/gordon/internal/usecase/traffic"
23 "github.com/bnema/zerowrap"27 "github.com/bnema/zerowrap"
24 )28 )
25 29
30+func TestPublicTLSReadinessAllowsDNS01WithoutEdgePort(t *testing.T) {
31+ cfg := Config{}
32+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeCloudflareDNS01)
33+
34+ require.NoError(t, validatePublicTLSReadiness(cfg))
35+ assert.Equal(t, 443, effectivePublicTLSPort(cfg))
36+}
37+
38+func TestPublicTLSReadinessRejectsAutoResolvedHTTP01WithoutBoundExternalPort80(t *testing.T) {
39+ cfg := Config{}
40+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeAuto)
41+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
42+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
43+ }
44+ effective := publictls.EffectiveChallenge{
45+ ConfiguredMode: domain.ACMEChallengeAuto,
46+ Mode: domain.ACMEChallengeHTTP01,
47+ TokenSource: domain.ACMETokenSourceNone,
48+ Reason: "auto selected http-01 (no cloudflare token)",
49+ }
50+
51+ err := validateEffectivePublicTLSReadiness(cfg, effective)
52+ require.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
53+}
54+
55+func TestEffectivePublicTLSPortPrefersTLSCapableEntrypointOverLegacyTLSPort(t *testing.T) {
56+ cfg := Config{}
57+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeCloudflareDNS01)
58+ cfg.Server.TLSPort = 8443
59+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
60+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
61+ }
62+
63+ assert.Equal(t, 443, effectivePublicTLSPort(cfg))
64+}
65+
66+func TestPublicTLSReadinessRequiresHTTP01ActuallyBoundExternalPort80(t *testing.T) {
67+ t.Run("rejects http entrypoint when smart tcp suppresses standalone http", func(t *testing.T) {
68+ cfg := Config{}
69+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeHTTP01)
70+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
71+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
72+ "web": {Address: ":80", Protocol: domain.EntryPointProtocolHTTP},
73+ }
74+
75+ err := validatePublicTLSReadiness(cfg)
76+ require.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
77+ })
78+
79+ t.Run("allows smart tcp entrypoint on port 80", func(t *testing.T) {
80+ cfg := Config{}
81+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeHTTP01)
82+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
83+ traffic.DefaultEdgeEntryPointName: {Address: ":80", Protocol: domain.EntryPointProtocolSmartTCP},
84+ }
85+
86+ require.NoError(t, validatePublicTLSReadiness(cfg))
87+ assert.Equal(t, 80, effectiveHTTP01Port(cfg))
88+ })
89+
90+ t.Run("rejects legacy standalone http port 80 without explicit entrypoint", func(t *testing.T) {
91+ cfg := Config{}
92+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeHTTP01)
93+ cfg.Server.Port = 80
94+
95+ err := validatePublicTLSReadiness(cfg)
96+ require.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
97+ assert.Equal(t, 0, effectiveHTTP01Port(cfg))
98+ })
99+
100+ t.Run("rejects legacy port 80 when smart tcp suppresses standalone http", func(t *testing.T) {
101+ cfg := Config{}
102+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeHTTP01)
103+ cfg.Server.Port = 80
104+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
105+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
106+ }
107+
108+ err := validatePublicTLSReadiness(cfg)
109+ require.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
110+ })
111+}
112+
113+func TestPKIInitializesForSmartTCPEvenWithoutLegacyTLSPort(t *testing.T) {
114+ si := &serviceInit{
115+ ctx: context.Background(),
116+ log: zerowrap.Default(),
117+ svc: &services{configSvc: cfgServiceForPKITest(t)},
118+ }
119+ si.cfg.Server.DataDir = t.TempDir()
120+ si.cfg.EntryPoints = map[string]traffic.EntryPointConfig{
121+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
122+ }
123+
124+ require.NoError(t, si.initPKI())
125+ require.NotNil(t, si.svc.pkiSvc)
126+ si.svc.pkiSvc.Stop()
127+}
128+
129+func cfgServiceForPKITest(t *testing.T) *config.Service {
130+ t.Helper()
131+ v := viper.New()
132+ svc := config.NewService(v, nil)
133+ require.NoError(t, svc.Load(context.Background()))
134+ return svc
135+}
136+
26 // generateTestCert creates a self-signed certificate for the given hostnames.137 // generateTestCert creates a self-signed certificate for the given hostnames.
27 func generateTestCert(t *testing.T, hosts ...string) tls.Certificate {138 func generateTestCert(t *testing.T, hosts ...string) tls.Certificate {
28 t.Helper()139 t.Helper()
M internal/app/run.go +202 -78
@@ -261,46 +261,48 @@ type Config struct {
261261
262262 // services holds all the services used by the application.
263263 type services struct {
264- runtime *docker.Runtime
265- eventBus *eventbus.InMemory
266- blobStorage *filesystem.BlobStorage
267- manifestStorage *filesystem.ManifestStorage
268- backupStorage *filesystem.BackupStorage
269- volumeBackupStore out.VolumeBackupStorage
270- volumeBackupCfg domain.VolumeBackupConfig
271- envLoader out.EnvLoader
272- logWriter *logwriter.LogWriter
273- tokenStore out.TokenStore
274- configSvc *config.Service
275- secretSvc *secretsSvc.Service
276- containerSvc *container.Service
277- backupSvc *backup.Service
278- volumeBackupSvc *backup.VolumeService
279- registrySvc *registrySvc.Service
280- healthSvc *health.Service
281- logSvc *logs.Service
282- imageSvc *images.Service
283- volumeSvc *volumesSvc.Service
284- proxySvc *proxy.Service
285- authSvc *auth.Service
286- authHandler *authhandler.Handler
287- adminHandler *admin.Handler
288- httpsProxyHandler http.Handler
289- internalRegUser string
290- internalRegPass string
291- previewStore *filesystem.PreviewStore
292- previewService *preview.Service
293- envDir string
294- maxBlobChunkSize int64
295- maxBlobSize int64
296- caAdapter *pkiadapter.CA
297- pkiSvc *pkiusecase.Service
298- reloadCoordinator *reloadCoordinator
299- publicTLSSvc in.PublicTLSService
300- publicTLSRuntime publicTLSRuntime
301- trafficManager *trafficadapter.Manager
302- tlsHTTPEntryPoints map[string]struct{}
303- registryHandler interface {
264+ runtime *docker.Runtime
265+ eventBus *eventbus.InMemory
266+ blobStorage *filesystem.BlobStorage
267+ manifestStorage *filesystem.ManifestStorage
268+ backupStorage *filesystem.BackupStorage
269+ volumeBackupStore out.VolumeBackupStorage
270+ volumeBackupCfg domain.VolumeBackupConfig
271+ envLoader out.EnvLoader
272+ logWriter *logwriter.LogWriter
273+ tokenStore out.TokenStore
274+ configSvc *config.Service
275+ secretSvc *secretsSvc.Service
276+ containerSvc *container.Service
277+ backupSvc *backup.Service
278+ volumeBackupSvc *backup.VolumeService
279+ registrySvc *registrySvc.Service
280+ healthSvc *health.Service
281+ logSvc *logs.Service
282+ imageSvc *images.Service
283+ volumeSvc *volumesSvc.Service
284+ proxySvc *proxy.Service
285+ authSvc *auth.Service
286+ authHandler *authhandler.Handler
287+ adminHandler *admin.Handler
288+ httpProxyHandler http.Handler
289+ httpsProxyHandler http.Handler
290+ internalRegUser string
291+ internalRegPass string
292+ previewStore *filesystem.PreviewStore
293+ previewService *preview.Service
294+ envDir string
295+ maxBlobChunkSize int64
296+ maxBlobSize int64
297+ caAdapter *pkiadapter.CA
298+ pkiSvc *pkiusecase.Service
299+ reloadCoordinator *reloadCoordinator
300+ publicTLSSvc in.PublicTLSService
301+ publicTLSRuntime publicTLSRuntime
302+ trafficManager *trafficadapter.Manager
303+ tlsHTTPEntryPoints map[string]struct{}
304+ smartHTTPEntryPoints map[string]struct{}
305+ registryHandler interface {
304306 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
305307 }
306308 }
@@ -576,8 +578,8 @@ func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config,
576578
577579 // initPKI initialises the internal CA and PKI service when TLS is enabled.
578580 func (si *serviceInit) initPKI() error {
579- if si.cfg.Server.TLSPort == 0 {
580- si.log.Info().Msg("internal CA disabled (server.tls_port=0)")
581+ if !hasTLSCapableEntrypoint(si.cfg) {
582+ si.log.Info().Msg("internal CA disabled (no TLS-capable entrypoint configured)")
581583 return nil
582584 }
583585
@@ -600,8 +602,8 @@ func (si *serviceInit) initPublicTLS() error {
600602 return nil
601603 }
602604
603- if si.cfg.Server.TLSPort == 0 {
604- return fmt.Errorf("%w: tls.acme.enabled requires server.tls_port > 0", domain.ErrACMEChallengeInvalid)
605+ if err := validatePublicTLSReadiness(si.cfg); err != nil {
606+ return err
605607 }
606608
607609 ctx := si.ctx
@@ -616,8 +618,8 @@ func (si *serviceInit) initPublicTLS() error {
616618 Enabled: si.cfg.TLS.ACME.Enabled,
617619 Email: si.cfg.TLS.ACME.Email,
618620 Challenge: si.cfg.TLS.ACME.Challenge,
619- HTTPPort: si.cfg.Server.Port,
620- TLSPort: si.cfg.Server.TLSPort,
621+ HTTPPort: effectiveHTTP01Port(si.cfg),
622+ TLSPort: effectivePublicTLSPort(si.cfg),
621623 DataDir: resolveDataDir(si.cfg.Server.DataDir),
622624 ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize,
623625 DNS: dnsCfg,
@@ -629,6 +631,9 @@ func (si *serviceInit) initPublicTLS() error {
629631 if err != nil {
630632 return log.WrapErr(err, "resolve ACME challenge")
631633 }
634+ if err := validateEffectivePublicTLSReadiness(si.cfg, effective); err != nil {
635+ return err
636+ }
632637
633638 store, err := acmestore.New(filepath.Join(resolveDataDir(si.cfg.Server.DataDir), "acme"))
634639 if err != nil {
@@ -769,7 +774,7 @@ func (si *serviceInit) registerReloadCoordinatorHooks() {
769774 return err
770775 }
771776 var tlsConfig *tls.Config
772- if reloadCfg.Server.TLSPort != 0 && si.svc.httpsProxyHandler != nil {
777+ if hasTLSCapableEntrypoint(reloadCfg) && si.svc.httpsProxyHandler != nil {
773778 var tlsErr error
774779 tlsConfig, tlsErr = proxyTLSConfig(reloadCfg, si.svc.pkiSvc, si.svc.publicTLSSvc, si.log)
775780 if tlsErr != nil {
@@ -777,6 +782,7 @@ func (si *serviceInit) registerReloadCoordinatorHooks() {
777782 }
778783 }
779784 si.svc.tlsHTTPEntryPoints = registerTLSMuxHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpsProxyHandler, tlsConfig, si.svc.tlsHTTPEntryPoints)
785+ si.svc.smartHTTPEntryPoints = registerSmartTCPHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpProxyHandler, si.svc.httpsProxyHandler, tlsConfig, si.svc.smartHTTPEntryPoints)
780786 if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil {
781787 return err
782788 }
@@ -2814,6 +2820,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
28142820 errChan := make(chan error, 3)
28152821
28162822 registryHandler, httpProxyHandler, httpsProxyHandler := createHTTPHandlers(svc, cfg, log, accessWriter)
2823+ svc.httpProxyHandler = httpProxyHandler
2824+ svc.httpsProxyHandler = httpsProxyHandler
28172825
28182826 registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort))
28192827 registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log)
@@ -2832,12 +2840,13 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
28322840 }
28332841 }
28342842
2835- proxySrv, proxyReady, tlsSrv, _, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, errChan, log)
2843+ proxySrv, proxyReady, tlsSrv, _, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, log)
28362844 if err != nil {
28372845 closeStarted(registrySrv)
28382846 return err
28392847 }
28402848 svc.tlsHTTPEntryPoints = tlsMuxHTTPServerNames(cfg)
2849+ svc.smartHTTPEntryPoints = smartTCPHTTPServerNames(cfg)
28412850
28422851 // Wait for the registry and HTTP proxy to bind before applying the traffic graph.
28432852 // This prevents auto-start races while keeping the TLS mux under one owner.
@@ -2903,6 +2912,9 @@ func shutdownTrafficManagerForStartupCleanup(manager *trafficadapter.Manager, lo
29032912 }
29042913
29052914 func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
2915+ if ready == nil {
2916+ return nil
2917+ }
29062918 select {
29072919 case <-ready:
29082920 return nil
@@ -3288,44 +3300,127 @@ func matchingStaticCert(certs []tls.Certificate, serverName string) *tls.Certifi
32883300 return matchingPreparedStaticCert(prepareStaticTLSCertificates(certs), serverName)
32893301 }
32903302
3291-func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, trafficManager *trafficadapter.Manager, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {
3292- // HTTP listener (Cloudflare proxy + onboarding for direct clients)
3293- var httpProtos http.Protocols
3294- httpProtos.SetHTTP1(true)
3295- httpProtos.SetUnencryptedHTTP2(true)
3296- httpSrv, httpReady := startServer(
3297- fmt.Sprintf(":%d", cfg.Server.Port),
3298- httpHandler,
3299- "proxy-http",
3300- &httpProtos,
3301- errChan,
3302- log,
3303- )
3303+func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, trafficManager *trafficadapter.Manager, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {
3304+ var httpSrv *http.Server
3305+ var httpReady <-chan struct{}
33043306
3305- if cfg.Server.TLSPort == 0 {
3307+ needsTLS := hasTLSCapableEntrypoint(cfg)
3308+ if !hasSmartTCPEntrypoint(cfg) && !needsTLS {
33063309 return httpSrv, httpReady, nil, nil, nil
33073310 }
33083311 cleanupHTTP := func(err error) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {
3309- shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
3310- defer cancel()
3311- if shutdownErr := httpSrv.Shutdown(shutdownCtx); shutdownErr != nil {
3312- log.Error().Err(shutdownErr).Msg("failed to shut down proxy-http server during startup cleanup")
3313- }
33143312 return httpSrv, httpReady, nil, nil, err
33153313 }
33163314
3317- tlsConfig, err := proxyTLSConfig(cfg, pkiSvc, publicTLS, log)
3318- if err != nil {
3319- return cleanupHTTP(err)
3315+ var tlsConfig *tls.Config
3316+ if needsTLS {
3317+ var err error
3318+ tlsConfig, err = proxyTLSConfig(cfg, pkiSvc, publicTLS, log)
3319+ if err != nil {
3320+ return cleanupHTTP(err)
3321+ }
33203322 }
33213323 if trafficManager == nil {
3322- return cleanupHTTP(fmt.Errorf("traffic manager is required when server.tls_port is enabled"))
3324+ return cleanupHTTP(fmt.Errorf("traffic manager is required when traffic entrypoints are enabled"))
33233325 }
33243326 registerTLSMuxHTTPServers(trafficManager, cfg, httpsHandler, tlsConfig, nil)
3327+ registerSmartTCPHTTPServers(trafficManager, cfg, httpHandler, httpsHandler, tlsConfig, nil)
33253328
33263329 return httpSrv, httpReady, nil, nil, nil
33273330 }
33283331
3332+func hasSmartTCPEntrypoint(cfg Config) bool {
3333+ for _, entryPoint := range cfg.EntryPoints {
3334+ if entryPoint.Protocol == domain.EntryPointProtocolSmartTCP {
3335+ return true
3336+ }
3337+ }
3338+ return false
3339+}
3340+
3341+func hasTLSCapableEntrypoint(cfg Config) bool {
3342+ for _, entryPoint := range cfg.EntryPoints {
3343+ switch entryPoint.Protocol {
3344+ case domain.EntryPointProtocolSmartTCP, domain.EntryPointProtocolTLSMux:
3345+ return true
3346+ }
3347+ }
3348+ return false
3349+}
3350+
3351+func effectivePublicTLSPort(cfg Config) int {
3352+ for _, entryPoint := range cfg.EntryPoints {
3353+ switch entryPoint.Protocol {
3354+ case domain.EntryPointProtocolSmartTCP, domain.EntryPointProtocolTLSMux:
3355+ if port := portFromAddress(entryPoint.Address); port > 0 {
3356+ return port
3357+ }
3358+ }
3359+ }
3360+ return 443
3361+}
3362+
3363+func effectiveHTTP01Port(cfg Config) int {
3364+ if hasSmartTCPHTTP01Entrypoint(cfg) {
3365+ return 80
3366+ }
3367+ return 0
3368+}
3369+
3370+func validatePublicTLSReadiness(cfg Config) error {
3371+ mode, err := domain.ParseACMEChallengeMode(cfg.TLS.ACME.Challenge)
3372+ if err != nil {
3373+ return err
3374+ }
3375+ switch mode {
3376+ case domain.ACMEChallengeCloudflareDNS01, domain.ACMEChallengeAuto:
3377+ return nil
3378+ case domain.ACMEChallengeHTTP01:
3379+ return validateHTTP01ChallengeReadiness(cfg)
3380+ default:
3381+ return fmt.Errorf("%w: %q", domain.ErrACMEChallengeInvalid, mode)
3382+ }
3383+}
3384+
3385+func validateEffectivePublicTLSReadiness(cfg Config, effective publictls.EffectiveChallenge) error {
3386+ if effective.Mode != domain.ACMEChallengeHTTP01 {
3387+ return nil
3388+ }
3389+ return validateHTTP01ChallengeReadiness(cfg)
3390+}
3391+
3392+func validateHTTP01ChallengeReadiness(cfg Config) error {
3393+ if hasBoundHTTP01ChallengeListener(cfg) {
3394+ return nil
3395+ }
3396+ return fmt.Errorf("%w: http-01 requires an actually bound HTTP-01 challenge listener on external :80", domain.ErrACMEChallengeInvalid)
3397+}
3398+
3399+func hasBoundHTTP01ChallengeListener(cfg Config) bool {
3400+ return hasSmartTCPHTTP01Entrypoint(cfg)
3401+}
3402+
3403+func hasSmartTCPHTTP01Entrypoint(cfg Config) bool {
3404+ for _, entryPoint := range cfg.EntryPoints {
3405+ if entryPoint.Protocol == domain.EntryPointProtocolSmartTCP && portFromAddress(entryPoint.Address) == 80 {
3406+ return true
3407+ }
3408+ }
3409+ return false
3410+}
3411+
3412+func portFromAddress(address string) int {
3413+ _, portText, err := net.SplitHostPort(address)
3414+ if err != nil {
3415+ return 0
3416+ }
3417+ port, err := strconv.Atoi(portText)
3418+ if err != nil {
3419+ return 0
3420+ }
3421+ return port
3422+}
3423+
33293424 func proxyTLSConfig(cfg Config, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, log zerowrap.Logger) (*tls.Config, error) {
33303425 var staticCerts []tls.Certificate
33313426 if cfg.Server.TLSCertFile != "" {
@@ -3351,6 +3446,41 @@ func proxyTLSConfig(cfg Config, pkiSvc *pkiusecase.Service, publicTLS in.PublicT
33513446 }, nil
33523447 }
33533448
3449+func registerSmartTCPHTTPServers(manager *trafficadapter.Manager, cfg Config, httpHandler, httpsHandler http.Handler, tlsConfig *tls.Config, previous map[string]struct{}) map[string]struct{} {
3450+ if manager == nil {
3451+ return previous
3452+ }
3453+ next := smartTCPHTTPServerNames(cfg)
3454+ for name := range previous {
3455+ if _, ok := next[name]; !ok {
3456+ manager.SetSmartTCPHTTPServer(name, nil, nil)
3457+ manager.SetSmartTCPTLSServer(name, nil, nil)
3458+ }
3459+ }
3460+ var httpProtos http.Protocols
3461+ httpProtos.SetHTTP1(true)
3462+ httpProtos.SetUnencryptedHTTP2(true)
3463+ for name := range next {
3464+ if httpHandler != nil {
3465+ manager.SetSmartTCPHTTPServer(name, httpHandler, &httpProtos)
3466+ }
3467+ if httpsHandler != nil && tlsConfig != nil {
3468+ manager.SetSmartTCPTLSServer(name, httpsHandler, tlsConfig)
3469+ }
3470+ }
3471+ return next
3472+}
3473+
3474+func smartTCPHTTPServerNames(cfg Config) map[string]struct{} {
3475+ names := map[string]struct{}{}
3476+ for name, entryPoint := range cfg.EntryPoints {
3477+ if entryPoint.Protocol == domain.EntryPointProtocolSmartTCP {
3478+ names[name] = struct{}{}
3479+ }
3480+ }
3481+ return names
3482+}
3483+
33543484 func registerTLSMuxHTTPServers(manager *trafficadapter.Manager, cfg Config, httpsHandler http.Handler, tlsConfig *tls.Config, previous map[string]struct{}) map[string]struct{} {
33553485 if manager == nil {
33563486 return previous
@@ -3361,7 +3491,7 @@ func registerTLSMuxHTTPServers(manager *trafficadapter.Manager, cfg Config, http
33613491 manager.SetTLSHTTPServer(name, nil, nil)
33623492 }
33633493 }
3364- if cfg.Server.TLSPort == 0 || httpsHandler == nil || tlsConfig == nil {
3494+ if httpsHandler == nil || tlsConfig == nil {
33653495 return next
33663496 }
33673497 for name := range next {
@@ -3372,10 +3502,6 @@ func registerTLSMuxHTTPServers(manager *trafficadapter.Manager, cfg Config, http
33723502
33733503 func tlsMuxHTTPServerNames(cfg Config) map[string]struct{} {
33743504 names := map[string]struct{}{}
3375- if cfg.Server.TLSPort == 0 {
3376- return names
3377- }
3378- names[traffic.DefaultTLSEntryPointName] = struct{}{}
33793505 for name, entryPoint := range cfg.EntryPoints {
33803506 domainEntryPoint := domain.EntryPoint{Name: name, Protocol: entryPoint.Protocol}
33813507 if trafficManagerOwnsEntryPoint(domainEntryPoint) && domainEntryPoint.Protocol == domain.EntryPointProtocolTLSMux {
@@ -3673,10 +3799,8 @@ func isProcessAlive(pid int) bool {
36733799
36743800 // loadConfig loads configuration from file and sets defaults.
36753801 func loadConfig(v *viper.Viper, configPath string) error {
3676- v.SetDefault("server.port", 8088)
36773802 v.SetDefault("server.registry_port", 5000)
36783803 v.SetDefault("server.legacy_registry_domains", []string{})
3679- v.SetDefault("server.tls_port", 8443)
36803804 v.SetDefault("server.tls_cert_file", "")
36813805 v.SetDefault("server.tls_key_file", "")
36823806 v.SetDefault("tls.acme.enabled", false)
@@ -261,46 +261,48 @@ type Config struct {
261 261
262 // services holds all the services used by the application.262 // services holds all the services used by the application.
263 type services struct {263 type services struct {
264- runtime *docker.Runtime264+ runtime *docker.Runtime
265- eventBus *eventbus.InMemory265+ eventBus *eventbus.InMemory
266- blobStorage *filesystem.BlobStorage266+ blobStorage *filesystem.BlobStorage
267- manifestStorage *filesystem.ManifestStorage267+ manifestStorage *filesystem.ManifestStorage
268- backupStorage *filesystem.BackupStorage268+ backupStorage *filesystem.BackupStorage
269- volumeBackupStore out.VolumeBackupStorage269+ volumeBackupStore out.VolumeBackupStorage
270- volumeBackupCfg domain.VolumeBackupConfig270+ volumeBackupCfg domain.VolumeBackupConfig
271- envLoader out.EnvLoader271+ envLoader out.EnvLoader
272- logWriter *logwriter.LogWriter272+ logWriter *logwriter.LogWriter
273- tokenStore out.TokenStore273+ tokenStore out.TokenStore
274- configSvc *config.Service274+ configSvc *config.Service
275- secretSvc *secretsSvc.Service275+ secretSvc *secretsSvc.Service
276- containerSvc *container.Service276+ containerSvc *container.Service
277- backupSvc *backup.Service277+ backupSvc *backup.Service
278- volumeBackupSvc *backup.VolumeService278+ volumeBackupSvc *backup.VolumeService
279- registrySvc *registrySvc.Service279+ registrySvc *registrySvc.Service
280- healthSvc *health.Service280+ healthSvc *health.Service
281- logSvc *logs.Service281+ logSvc *logs.Service
282- imageSvc *images.Service282+ imageSvc *images.Service
283- volumeSvc *volumesSvc.Service283+ volumeSvc *volumesSvc.Service
284- proxySvc *proxy.Service284+ proxySvc *proxy.Service
285- authSvc *auth.Service285+ authSvc *auth.Service
286- authHandler *authhandler.Handler286+ authHandler *authhandler.Handler
287- adminHandler *admin.Handler287+ adminHandler *admin.Handler
288- httpsProxyHandler http.Handler288+ httpProxyHandler http.Handler
289- internalRegUser string289+ httpsProxyHandler http.Handler
290- internalRegPass string290+ internalRegUser string
291- previewStore *filesystem.PreviewStore291+ internalRegPass string
292- previewService *preview.Service292+ previewStore *filesystem.PreviewStore
293- envDir string293+ previewService *preview.Service
294- maxBlobChunkSize int64294+ envDir string
295- maxBlobSize int64295+ maxBlobChunkSize int64
296- caAdapter *pkiadapter.CA296+ maxBlobSize int64
297- pkiSvc *pkiusecase.Service297+ caAdapter *pkiadapter.CA
298- reloadCoordinator *reloadCoordinator298+ pkiSvc *pkiusecase.Service
299- publicTLSSvc in.PublicTLSService299+ reloadCoordinator *reloadCoordinator
300- publicTLSRuntime publicTLSRuntime300+ publicTLSSvc in.PublicTLSService
301- trafficManager *trafficadapter.Manager301+ publicTLSRuntime publicTLSRuntime
302- tlsHTTPEntryPoints map[string]struct{}302+ trafficManager *trafficadapter.Manager
303- registryHandler interface {303+ tlsHTTPEntryPoints map[string]struct{}
304+ smartHTTPEntryPoints map[string]struct{}
305+ registryHandler interface {
304 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)306 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
305 }307 }
306 }308 }
@@ -576,8 +578,8 @@ func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config,
576 578
577 // initPKI initialises the internal CA and PKI service when TLS is enabled.579 // initPKI initialises the internal CA and PKI service when TLS is enabled.
578 func (si *serviceInit) initPKI() error {580 func (si *serviceInit) initPKI() error {
579- if si.cfg.Server.TLSPort == 0 {581+ if !hasTLSCapableEntrypoint(si.cfg) {
580- si.log.Info().Msg("internal CA disabled (server.tls_port=0)")582+ si.log.Info().Msg("internal CA disabled (no TLS-capable entrypoint configured)")
581 return nil583 return nil
582 }584 }
583 585
@@ -600,8 +602,8 @@ func (si *serviceInit) initPublicTLS() error {
600 return nil602 return nil
601 }603 }
602 604
603- if si.cfg.Server.TLSPort == 0 {605+ if err := validatePublicTLSReadiness(si.cfg); err != nil {
604- return fmt.Errorf("%w: tls.acme.enabled requires server.tls_port > 0", domain.ErrACMEChallengeInvalid)606+ return err
605 }607 }
606 608
607 ctx := si.ctx609 ctx := si.ctx
@@ -616,8 +618,8 @@ func (si *serviceInit) initPublicTLS() error {
616 Enabled: si.cfg.TLS.ACME.Enabled,618 Enabled: si.cfg.TLS.ACME.Enabled,
617 Email: si.cfg.TLS.ACME.Email,619 Email: si.cfg.TLS.ACME.Email,
618 Challenge: si.cfg.TLS.ACME.Challenge,620 Challenge: si.cfg.TLS.ACME.Challenge,
619- HTTPPort: si.cfg.Server.Port,621+ HTTPPort: effectiveHTTP01Port(si.cfg),
620- TLSPort: si.cfg.Server.TLSPort,622+ TLSPort: effectivePublicTLSPort(si.cfg),
621 DataDir: resolveDataDir(si.cfg.Server.DataDir),623 DataDir: resolveDataDir(si.cfg.Server.DataDir),
622 ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize,624 ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize,
623 DNS: dnsCfg,625 DNS: dnsCfg,
@@ -629,6 +631,9 @@ func (si *serviceInit) initPublicTLS() error {
629 if err != nil {631 if err != nil {
630 return log.WrapErr(err, "resolve ACME challenge")632 return log.WrapErr(err, "resolve ACME challenge")
631 }633 }
634+ if err := validateEffectivePublicTLSReadiness(si.cfg, effective); err != nil {
635+ return err
636+ }
632 637
633 store, err := acmestore.New(filepath.Join(resolveDataDir(si.cfg.Server.DataDir), "acme"))638 store, err := acmestore.New(filepath.Join(resolveDataDir(si.cfg.Server.DataDir), "acme"))
634 if err != nil {639 if err != nil {
@@ -769,7 +774,7 @@ func (si *serviceInit) registerReloadCoordinatorHooks() {
769 return err774 return err
770 }775 }
771 var tlsConfig *tls.Config776 var tlsConfig *tls.Config
772- if reloadCfg.Server.TLSPort != 0 && si.svc.httpsProxyHandler != nil {777+ if hasTLSCapableEntrypoint(reloadCfg) && si.svc.httpsProxyHandler != nil {
773 var tlsErr error778 var tlsErr error
774 tlsConfig, tlsErr = proxyTLSConfig(reloadCfg, si.svc.pkiSvc, si.svc.publicTLSSvc, si.log)779 tlsConfig, tlsErr = proxyTLSConfig(reloadCfg, si.svc.pkiSvc, si.svc.publicTLSSvc, si.log)
775 if tlsErr != nil {780 if tlsErr != nil {
@@ -777,6 +782,7 @@ func (si *serviceInit) registerReloadCoordinatorHooks() {
777 }782 }
778 }783 }
779 si.svc.tlsHTTPEntryPoints = registerTLSMuxHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpsProxyHandler, tlsConfig, si.svc.tlsHTTPEntryPoints)784 si.svc.tlsHTTPEntryPoints = registerTLSMuxHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpsProxyHandler, tlsConfig, si.svc.tlsHTTPEntryPoints)
785+ si.svc.smartHTTPEntryPoints = registerSmartTCPHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpProxyHandler, si.svc.httpsProxyHandler, tlsConfig, si.svc.smartHTTPEntryPoints)
780 if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil {786 if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil {
781 return err787 return err
782 }788 }
@@ -2814,6 +2820,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
2814 errChan := make(chan error, 3)2820 errChan := make(chan error, 3)
2815 2821
2816 registryHandler, httpProxyHandler, httpsProxyHandler := createHTTPHandlers(svc, cfg, log, accessWriter)2822 registryHandler, httpProxyHandler, httpsProxyHandler := createHTTPHandlers(svc, cfg, log, accessWriter)
2823+ svc.httpProxyHandler = httpProxyHandler
2824+ svc.httpsProxyHandler = httpsProxyHandler
2817 2825
2818 registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort))2826 registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort))
2819 registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log)2827 registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log)
@@ -2832,12 +2840,13 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
2832 }2840 }
2833 }2841 }
2834 2842
2835- proxySrv, proxyReady, tlsSrv, _, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, errChan, log)2843+ proxySrv, proxyReady, tlsSrv, _, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, log)
2836 if err != nil {2844 if err != nil {
2837 closeStarted(registrySrv)2845 closeStarted(registrySrv)
2838 return err2846 return err
2839 }2847 }
2840 svc.tlsHTTPEntryPoints = tlsMuxHTTPServerNames(cfg)2848 svc.tlsHTTPEntryPoints = tlsMuxHTTPServerNames(cfg)
2849+ svc.smartHTTPEntryPoints = smartTCPHTTPServerNames(cfg)
2841 2850
2842 // Wait for the registry and HTTP proxy to bind before applying the traffic graph.2851 // Wait for the registry and HTTP proxy to bind before applying the traffic graph.
2843 // This prevents auto-start races while keeping the TLS mux under one owner.2852 // This prevents auto-start races while keeping the TLS mux under one owner.
@@ -2903,6 +2912,9 @@ func shutdownTrafficManagerForStartupCleanup(manager *trafficadapter.Manager, lo
2903 }2912 }
2904 2913
2905 func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {2914 func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
2915+ if ready == nil {
2916+ return nil
2917+ }
2906 select {2918 select {
2907 case <-ready:2919 case <-ready:
2908 return nil2920 return nil
@@ -3288,44 +3300,127 @@ func matchingStaticCert(certs []tls.Certificate, serverName string) *tls.Certifi
3288 return matchingPreparedStaticCert(prepareStaticTLSCertificates(certs), serverName)3300 return matchingPreparedStaticCert(prepareStaticTLSCertificates(certs), serverName)
3289 }3301 }
3290 3302
3291-func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, trafficManager *trafficadapter.Manager, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {3303+func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, trafficManager *trafficadapter.Manager, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {
3292- // HTTP listener (Cloudflare proxy + onboarding for direct clients)3304+ var httpSrv *http.Server
3293- var httpProtos http.Protocols3305+ var httpReady <-chan struct{}
3294- httpProtos.SetHTTP1(true)
3295- httpProtos.SetUnencryptedHTTP2(true)
3296- httpSrv, httpReady := startServer(
3297- fmt.Sprintf(":%d", cfg.Server.Port),
3298- httpHandler,
3299- "proxy-http",
3300- &httpProtos,
3301- errChan,
3302- log,
3303- )
3304 3306
3305- if cfg.Server.TLSPort == 0 {3307+ needsTLS := hasTLSCapableEntrypoint(cfg)
3308+ if !hasSmartTCPEntrypoint(cfg) && !needsTLS {
3306 return httpSrv, httpReady, nil, nil, nil3309 return httpSrv, httpReady, nil, nil, nil
3307 }3310 }
3308 cleanupHTTP := func(err error) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {3311 cleanupHTTP := func(err error) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {
3309- shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
3310- defer cancel()
3311- if shutdownErr := httpSrv.Shutdown(shutdownCtx); shutdownErr != nil {
3312- log.Error().Err(shutdownErr).Msg("failed to shut down proxy-http server during startup cleanup")
3313- }
3314 return httpSrv, httpReady, nil, nil, err3312 return httpSrv, httpReady, nil, nil, err
3315 }3313 }
3316 3314
3317- tlsConfig, err := proxyTLSConfig(cfg, pkiSvc, publicTLS, log)3315+ var tlsConfig *tls.Config
3318- if err != nil {3316+ if needsTLS {
3319- return cleanupHTTP(err)3317+ var err error
3318+ tlsConfig, err = proxyTLSConfig(cfg, pkiSvc, publicTLS, log)
3319+ if err != nil {
3320+ return cleanupHTTP(err)
3321+ }
3320 }3322 }
3321 if trafficManager == nil {3323 if trafficManager == nil {
3322- return cleanupHTTP(fmt.Errorf("traffic manager is required when server.tls_port is enabled"))3324+ return cleanupHTTP(fmt.Errorf("traffic manager is required when traffic entrypoints are enabled"))
3323 }3325 }
3324 registerTLSMuxHTTPServers(trafficManager, cfg, httpsHandler, tlsConfig, nil)3326 registerTLSMuxHTTPServers(trafficManager, cfg, httpsHandler, tlsConfig, nil)
3327+ registerSmartTCPHTTPServers(trafficManager, cfg, httpHandler, httpsHandler, tlsConfig, nil)
3325 3328
3326 return httpSrv, httpReady, nil, nil, nil3329 return httpSrv, httpReady, nil, nil, nil
3327 }3330 }
3328 3331
3332+func hasSmartTCPEntrypoint(cfg Config) bool {
3333+ for _, entryPoint := range cfg.EntryPoints {
3334+ if entryPoint.Protocol == domain.EntryPointProtocolSmartTCP {
3335+ return true
3336+ }
3337+ }
3338+ return false
3339+}
3340+
3341+func hasTLSCapableEntrypoint(cfg Config) bool {
3342+ for _, entryPoint := range cfg.EntryPoints {
3343+ switch entryPoint.Protocol {
3344+ case domain.EntryPointProtocolSmartTCP, domain.EntryPointProtocolTLSMux:
3345+ return true
3346+ }
3347+ }
3348+ return false
3349+}
3350+
3351+func effectivePublicTLSPort(cfg Config) int {
3352+ for _, entryPoint := range cfg.EntryPoints {
3353+ switch entryPoint.Protocol {
3354+ case domain.EntryPointProtocolSmartTCP, domain.EntryPointProtocolTLSMux:
3355+ if port := portFromAddress(entryPoint.Address); port > 0 {
3356+ return port
3357+ }
3358+ }
3359+ }
3360+ return 443
3361+}
3362+
3363+func effectiveHTTP01Port(cfg Config) int {
3364+ if hasSmartTCPHTTP01Entrypoint(cfg) {
3365+ return 80
3366+ }
3367+ return 0
3368+}
3369+
3370+func validatePublicTLSReadiness(cfg Config) error {
3371+ mode, err := domain.ParseACMEChallengeMode(cfg.TLS.ACME.Challenge)
3372+ if err != nil {
3373+ return err
3374+ }
3375+ switch mode {
3376+ case domain.ACMEChallengeCloudflareDNS01, domain.ACMEChallengeAuto:
3377+ return nil
3378+ case domain.ACMEChallengeHTTP01:
3379+ return validateHTTP01ChallengeReadiness(cfg)
3380+ default:
3381+ return fmt.Errorf("%w: %q", domain.ErrACMEChallengeInvalid, mode)
3382+ }
3383+}
3384+
3385+func validateEffectivePublicTLSReadiness(cfg Config, effective publictls.EffectiveChallenge) error {
3386+ if effective.Mode != domain.ACMEChallengeHTTP01 {
3387+ return nil
3388+ }
3389+ return validateHTTP01ChallengeReadiness(cfg)
3390+}
3391+
3392+func validateHTTP01ChallengeReadiness(cfg Config) error {
3393+ if hasBoundHTTP01ChallengeListener(cfg) {
3394+ return nil
3395+ }
3396+ return fmt.Errorf("%w: http-01 requires an actually bound HTTP-01 challenge listener on external :80", domain.ErrACMEChallengeInvalid)
3397+}
3398+
3399+func hasBoundHTTP01ChallengeListener(cfg Config) bool {
3400+ return hasSmartTCPHTTP01Entrypoint(cfg)
3401+}
3402+
3403+func hasSmartTCPHTTP01Entrypoint(cfg Config) bool {
3404+ for _, entryPoint := range cfg.EntryPoints {
3405+ if entryPoint.Protocol == domain.EntryPointProtocolSmartTCP && portFromAddress(entryPoint.Address) == 80 {
3406+ return true
3407+ }
3408+ }
3409+ return false
3410+}
3411+
3412+func portFromAddress(address string) int {
3413+ _, portText, err := net.SplitHostPort(address)
3414+ if err != nil {
3415+ return 0
3416+ }
3417+ port, err := strconv.Atoi(portText)
3418+ if err != nil {
3419+ return 0
3420+ }
3421+ return port
3422+}
3423+
3329 func proxyTLSConfig(cfg Config, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, log zerowrap.Logger) (*tls.Config, error) {3424 func proxyTLSConfig(cfg Config, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, log zerowrap.Logger) (*tls.Config, error) {
3330 var staticCerts []tls.Certificate3425 var staticCerts []tls.Certificate
3331 if cfg.Server.TLSCertFile != "" {3426 if cfg.Server.TLSCertFile != "" {
@@ -3351,6 +3446,41 @@ func proxyTLSConfig(cfg Config, pkiSvc *pkiusecase.Service, publicTLS in.PublicT
3351 }, nil3446 }, nil
3352 }3447 }
3353 3448
3449+func registerSmartTCPHTTPServers(manager *trafficadapter.Manager, cfg Config, httpHandler, httpsHandler http.Handler, tlsConfig *tls.Config, previous map[string]struct{}) map[string]struct{} {
3450+ if manager == nil {
3451+ return previous
3452+ }
3453+ next := smartTCPHTTPServerNames(cfg)
3454+ for name := range previous {
3455+ if _, ok := next[name]; !ok {
3456+ manager.SetSmartTCPHTTPServer(name, nil, nil)
3457+ manager.SetSmartTCPTLSServer(name, nil, nil)
3458+ }
3459+ }
3460+ var httpProtos http.Protocols
3461+ httpProtos.SetHTTP1(true)
3462+ httpProtos.SetUnencryptedHTTP2(true)
3463+ for name := range next {
3464+ if httpHandler != nil {
3465+ manager.SetSmartTCPHTTPServer(name, httpHandler, &httpProtos)
3466+ }
3467+ if httpsHandler != nil && tlsConfig != nil {
3468+ manager.SetSmartTCPTLSServer(name, httpsHandler, tlsConfig)
3469+ }
3470+ }
3471+ return next
3472+}
3473+
3474+func smartTCPHTTPServerNames(cfg Config) map[string]struct{} {
3475+ names := map[string]struct{}{}
3476+ for name, entryPoint := range cfg.EntryPoints {
3477+ if entryPoint.Protocol == domain.EntryPointProtocolSmartTCP {
3478+ names[name] = struct{}{}
3479+ }
3480+ }
3481+ return names
3482+}
3483+
3354 func registerTLSMuxHTTPServers(manager *trafficadapter.Manager, cfg Config, httpsHandler http.Handler, tlsConfig *tls.Config, previous map[string]struct{}) map[string]struct{} {3484 func registerTLSMuxHTTPServers(manager *trafficadapter.Manager, cfg Config, httpsHandler http.Handler, tlsConfig *tls.Config, previous map[string]struct{}) map[string]struct{} {
3355 if manager == nil {3485 if manager == nil {
3356 return previous3486 return previous
@@ -3361,7 +3491,7 @@ func registerTLSMuxHTTPServers(manager *trafficadapter.Manager, cfg Config, http
3361 manager.SetTLSHTTPServer(name, nil, nil)3491 manager.SetTLSHTTPServer(name, nil, nil)
3362 }3492 }
3363 }3493 }
3364- if cfg.Server.TLSPort == 0 || httpsHandler == nil || tlsConfig == nil {3494+ if httpsHandler == nil || tlsConfig == nil {
3365 return next3495 return next
3366 }3496 }
3367 for name := range next {3497 for name := range next {
@@ -3372,10 +3502,6 @@ func registerTLSMuxHTTPServers(manager *trafficadapter.Manager, cfg Config, http
3372 3502
3373 func tlsMuxHTTPServerNames(cfg Config) map[string]struct{} {3503 func tlsMuxHTTPServerNames(cfg Config) map[string]struct{} {
3374 names := map[string]struct{}{}3504 names := map[string]struct{}{}
3375- if cfg.Server.TLSPort == 0 {
3376- return names
3377- }
3378- names[traffic.DefaultTLSEntryPointName] = struct{}{}
3379 for name, entryPoint := range cfg.EntryPoints {3505 for name, entryPoint := range cfg.EntryPoints {
3380 domainEntryPoint := domain.EntryPoint{Name: name, Protocol: entryPoint.Protocol}3506 domainEntryPoint := domain.EntryPoint{Name: name, Protocol: entryPoint.Protocol}
3381 if trafficManagerOwnsEntryPoint(domainEntryPoint) && domainEntryPoint.Protocol == domain.EntryPointProtocolTLSMux {3507 if trafficManagerOwnsEntryPoint(domainEntryPoint) && domainEntryPoint.Protocol == domain.EntryPointProtocolTLSMux {
@@ -3673,10 +3799,8 @@ func isProcessAlive(pid int) bool {
3673 3799
3674 // loadConfig loads configuration from file and sets defaults.3800 // loadConfig loads configuration from file and sets defaults.
3675 func loadConfig(v *viper.Viper, configPath string) error {3801 func loadConfig(v *viper.Viper, configPath string) error {
3676- v.SetDefault("server.port", 8088)
3677 v.SetDefault("server.registry_port", 5000)3802 v.SetDefault("server.registry_port", 5000)
3678 v.SetDefault("server.legacy_registry_domains", []string{})3803 v.SetDefault("server.legacy_registry_domains", []string{})
3679- v.SetDefault("server.tls_port", 8443)
3680 v.SetDefault("server.tls_cert_file", "")3804 v.SetDefault("server.tls_cert_file", "")
3681 v.SetDefault("server.tls_key_file", "")3805 v.SetDefault("server.tls_key_file", "")
3682 v.SetDefault("tls.acme.enabled", false)3806 v.SetDefault("tls.acme.enabled", false)
M internal/app/run_http_handlers_test.go +5 -1
@@ -21,7 +21,9 @@ import (
2121 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
2222 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
2323 out "github.com/bnema/gordon/internal/boundaries/out"
24+ "github.com/bnema/gordon/internal/domain"
2425 proxyusecase "github.com/bnema/gordon/internal/usecase/proxy"
26+ traffic "github.com/bnema/gordon/internal/usecase/traffic"
2527 )
2628
2729 func newNotFoundProxyService(t *testing.T) *proxyusecase.Service {
@@ -473,7 +475,9 @@ func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) {
473475 log: zerowrap.Default(),
474476 svc: &services{},
475477 }
476- si.cfg.Server.TLSPort = 8443
478+ si.cfg.EntryPoints = map[string]traffic.EntryPointConfig{
479+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
480+ }
477481 dataDir := t.TempDir()
478482 blockingPath := filepath.Join(dataDir, "not-a-directory")
479483 require.NoError(t, os.WriteFile(blockingPath, []byte("blocking file"), 0600))
@@ -21,7 +21,9 @@ import (
21 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"21 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
22 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"22 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
23 out "github.com/bnema/gordon/internal/boundaries/out"23 out "github.com/bnema/gordon/internal/boundaries/out"
24+ "github.com/bnema/gordon/internal/domain"
24 proxyusecase "github.com/bnema/gordon/internal/usecase/proxy"25 proxyusecase "github.com/bnema/gordon/internal/usecase/proxy"
26+ traffic "github.com/bnema/gordon/internal/usecase/traffic"
25 )27 )
26 28
27 func newNotFoundProxyService(t *testing.T) *proxyusecase.Service {29 func newNotFoundProxyService(t *testing.T) *proxyusecase.Service {
@@ -473,7 +475,9 @@ func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) {
473 log: zerowrap.Default(),475 log: zerowrap.Default(),
474 svc: &services{},476 svc: &services{},
475 }477 }
476- si.cfg.Server.TLSPort = 8443478+ si.cfg.EntryPoints = map[string]traffic.EntryPointConfig{
479+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
480+ }
477 dataDir := t.TempDir()481 dataDir := t.TempDir()
478 blockingPath := filepath.Join(dataDir, "not-a-directory")482 blockingPath := filepath.Join(dataDir, "not-a-directory")
479 require.NoError(t, os.WriteFile(blockingPath, []byte("blocking file"), 0600))483 require.NoError(t, os.WriteFile(blockingPath, []byte("blocking file"), 0600))
M internal/app/start_proxy_servers_test.go +160 -27
@@ -3,6 +3,7 @@ package app
33 import (
44 "context"
55 "crypto/tls"
6+ "errors"
67 "fmt"
78 "io"
89 "net"
@@ -24,15 +25,134 @@ import (
2425 traffic "github.com/bnema/gordon/internal/usecase/traffic"
2526 )
2627
27-func TestStartProxyServers_ConfiguresTrafficManagerHTTPSRoute(t *testing.T) {
28+func TestWaitForServerReady_NilReadyReturnsImmediately(t *testing.T) {
29+ errChan := make(chan error)
30+ done := make(chan error, 1)
31+
32+ go func() {
33+ done <- waitForServerReady(nil, errChan)
34+ }()
35+
36+ select {
37+ case err := <-done:
38+ require.NoError(t, err)
39+ case <-time.After(100 * time.Millisecond):
40+ t.Fatal("nil ready channel blocked startup readiness")
41+ }
42+}
43+
44+func TestWaitForServerReady_NonNilReadyPreservesErrorBehavior(t *testing.T) {
45+ expected := errors.New("listen failed")
46+ errChan := make(chan error, 1)
47+ errChan <- expected
48+
49+ err := waitForServerReady(make(chan struct{}), errChan)
50+ require.ErrorIs(t, err, expected)
51+}
52+
53+func TestStartProxyServers_DoesNotStartLegacyHTTPListenerFromServerPort(t *testing.T) {
2854 cfg := Config{}
2955 cfg.Server.Port = freeTCPPort(t)
56+
57+ manager := trafficadapter.NewManager()
58+ defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
59+
60+ httpSrv, httpReady, tlsSrv, tlsReady, err := startProxyServers(
61+ cfg,
62+ http.NotFoundHandler(),
63+ http.NotFoundHandler(),
64+ newTestPKIService(t),
65+ nil,
66+ manager,
67+ zerowrap.Default(),
68+ )
69+ require.NoError(t, err)
70+ require.Nil(t, httpSrv)
71+ require.Nil(t, httpReady)
72+ require.Nil(t, tlsSrv)
73+ require.Nil(t, tlsReady)
74+ assertTCPPortClosed(t, fmt.Sprintf("127.0.0.1:%d", cfg.Server.Port))
75+}
76+
77+func TestStartProxyServers_DoesNotTreatLegacyTLSPortAsTLSCapable(t *testing.T) {
78+ cfg := Config{}
3079 cfg.Server.TLSPort = freeTCPPort(t)
3180
81+ manager := trafficadapter.NewManager()
82+ defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
83+
84+ httpSrv, httpReady, tlsSrv, tlsReady, err := startProxyServers(
85+ cfg,
86+ http.NotFoundHandler(),
87+ http.NotFoundHandler(),
88+ newTestPKIService(t),
89+ nil,
90+ manager,
91+ zerowrap.Default(),
92+ )
93+ require.NoError(t, err)
94+ require.Nil(t, httpSrv)
95+ require.Nil(t, httpReady)
96+ require.Nil(t, tlsSrv)
97+ require.Nil(t, tlsReady)
98+ assertTCPPortClosed(t, fmt.Sprintf("127.0.0.1:%d", cfg.Server.TLSPort))
99+}
100+
101+func TestStartProxyServers_ConfiguresSmartTCPHTTPAndHTTPSRoutesWithoutPublicHTTPListener(t *testing.T) {
102+ cfg := Config{}
103+ cfg.Server.Port = freeTCPPort(t)
104+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
105+ traffic.DefaultEdgeEntryPointName: {
106+ Address: freeTCPAddress(t),
107+ Protocol: domain.EntryPointProtocolSmartTCP,
108+ },
109+ }
110+
111+ manager := trafficadapter.NewManager()
112+ defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
113+ pkiSvc := newTestPKIService(t)
114+
115+ httpSrv, httpReady, tlsSrv, tlsReady, err := startProxyServers(
116+ cfg,
117+ http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("cleartext " + r.Proto)) }),
118+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("secure")) }),
119+ pkiSvc,
120+ nil,
121+ manager,
122+ zerowrap.Default(),
123+ )
124+ require.NoError(t, err)
125+ require.Nil(t, httpSrv)
126+ require.Nil(t, httpReady)
127+ require.Nil(t, tlsSrv)
128+ require.Nil(t, tlsReady)
129+ assertTCPPortClosed(t, fmt.Sprintf("127.0.0.1:%d", cfg.Server.Port))
130+
131+ configSvc := inmocks.NewMockConfigService(t)
132+ configSvc.EXPECT().GetRoutes(context.Background()).Return([]domain.Route{{Domain: "app.example.com", HTTPS: true}})
133+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
134+ require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc))
135+
136+ _, smartPortText, err := net.SplitHostPort(cfg.EntryPoints[traffic.DefaultEdgeEntryPointName].Address)
137+ require.NoError(t, err)
138+ smartPort, err := strconv.Atoi(smartPortText)
139+ require.NoError(t, err)
140+ require.Contains(t, httpGetForTest(t, smartPort, "app.example.com"), "cleartext HTTP/1.1")
141+ require.Contains(t, httpsGetForTest(t, smartPort, "app.example.com"), "secure")
142+}
143+
144+func TestStartProxyServers_ConfiguresTrafficManagerHTTPSRoute(t *testing.T) {
145+ cfg := Config{}
146+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
147+ traffic.DefaultEdgeEntryPointName: {
148+ Address: freeTCPAddress(t),
149+ Protocol: domain.EntryPointProtocolTLSMux,
150+ },
151+ }
152+
32153 manager := trafficadapter.NewManager()
33154 defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
34155 pkiSvc := newTestPKIService(t)
35- errChan := make(chan error, 4)
36156
37157 httpSrv, httpReady, tlsSrv, tlsReady, err := startProxyServers(
38158 cfg,
@@ -41,21 +161,24 @@ func TestStartProxyServers_ConfiguresTrafficManagerHTTPSRoute(t *testing.T) {
41161 pkiSvc,
42162 nil,
43163 manager,
44- errChan,
45164 zerowrap.Default(),
46165 )
47166 require.NoError(t, err)
48- defer shutdownHTTPServer(t, httpSrv)
167+ require.Nil(t, httpSrv)
168+ require.Nil(t, httpReady)
49169 require.Nil(t, tlsSrv)
50170 require.Nil(t, tlsReady)
51- waitReadyForTest(t, httpReady, errChan)
52171
53172 configSvc := inmocks.NewMockConfigService(t)
54173 configSvc.EXPECT().GetRoutes(context.Background()).Return([]domain.Route{{Domain: "app.example.com", HTTPS: true}})
55174 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
56175 require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc))
57176
58- body := httpsGetForTest(t, cfg.Server.TLSPort, "app.example.com")
177+ _, portText, err := net.SplitHostPort(cfg.EntryPoints[traffic.DefaultEdgeEntryPointName].Address)
178+ require.NoError(t, err)
179+ port, err := strconv.Atoi(portText)
180+ require.NoError(t, err)
181+ body := httpsGetForTest(t, port, "app.example.com")
59182 require.Contains(t, body, "secure")
60183 }
61184
@@ -72,7 +195,6 @@ func TestStartProxyServers_ConfiguresCustomTLSMuxHTTPSRoute(t *testing.T) {
72195 manager := trafficadapter.NewManager()
73196 defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
74197 pkiSvc := newTestPKIService(t)
75- errChan := make(chan error, 4)
76198
77199 httpSrv, httpReady, tlsSrv, tlsReady, err := startProxyServers(
78200 cfg,
@@ -81,14 +203,13 @@ func TestStartProxyServers_ConfiguresCustomTLSMuxHTTPSRoute(t *testing.T) {
81203 pkiSvc,
82204 nil,
83205 manager,
84- errChan,
85206 zerowrap.Default(),
86207 )
87208 require.NoError(t, err)
88- defer shutdownHTTPServer(t, httpSrv)
209+ require.Nil(t, httpSrv)
210+ require.Nil(t, httpReady)
89211 require.Nil(t, tlsSrv)
90212 require.Nil(t, tlsReady)
91- waitReadyForTest(t, httpReady, errChan)
92213
93214 configSvc := inmocks.NewMockConfigService(t)
94215 configSvc.EXPECT().GetRoutes(context.Background()).Return(nil)
@@ -105,8 +226,9 @@ func TestStartProxyServers_ConfiguresCustomTLSMuxHTTPSRoute(t *testing.T) {
105226
106227 func TestStartProxyServers_TLSSetupFailureStopsHTTPServer(t *testing.T) {
107228 cfg := Config{}
108- cfg.Server.Port = freeTCPPort(t)
109- cfg.Server.TLSPort = freeTCPPort(t)
229+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
230+ traffic.DefaultEdgeEntryPointName: {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolSmartTCP},
231+ }
110232 cfg.Server.TLSCertFile = "/path/that/does/not/exist.crt"
111233 cfg.Server.TLSKeyFile = "/path/that/does/not/exist.key"
112234
@@ -117,7 +239,6 @@ func TestStartProxyServers_TLSSetupFailureStopsHTTPServer(t *testing.T) {
117239 newTestPKIService(t),
118240 nil,
119241 trafficadapter.NewManager(),
120- make(chan error, 4),
121242 zerowrap.Default(),
122243 )
123244 require.ErrorContains(t, err, "load TLS keypair")
@@ -137,8 +258,9 @@ func TestStartProxyServers_TLSSetupFailureStopsHTTPServer(t *testing.T) {
137258
138259 func TestStartProxyServers_TLSRequiresTrafficManager(t *testing.T) {
139260 cfg := Config{}
140- cfg.Server.Port = freeTCPPort(t)
141- cfg.Server.TLSPort = freeTCPPort(t)
261+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
262+ traffic.DefaultEdgeEntryPointName: {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolSmartTCP},
263+ }
142264
143265 httpSrv, _, tlsSrv, _, err := startProxyServers(
144266 cfg,
@@ -147,7 +269,6 @@ func TestStartProxyServers_TLSRequiresTrafficManager(t *testing.T) {
147269 newTestPKIService(t),
148270 nil,
149271 nil,
150- make(chan error, 2),
151272 zerowrap.Default(),
152273 )
153274 if httpSrv != nil {
@@ -183,17 +304,6 @@ func freeTCPPort(t *testing.T) int {
183304 return port
184305 }
185306
186-func waitReadyForTest(t *testing.T, ready <-chan struct{}, errChan <-chan error) {
187- t.Helper()
188- select {
189- case <-ready:
190- case err := <-errChan:
191- require.NoError(t, err)
192- case <-time.After(2 * time.Second):
193- t.Fatal("server did not become ready")
194- }
195-}
196-
197307 func shutdownHTTPServer(t *testing.T, srv *http.Server) {
198308 t.Helper()
199309 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
@@ -201,6 +311,29 @@ func shutdownHTTPServer(t *testing.T, srv *http.Server) {
201311 require.NoError(t, srv.Shutdown(ctx))
202312 }
203313
314+func httpGetForTest(t *testing.T, port int, host string) string {
315+ t.Helper()
316+ client := &http.Client{Timeout: 2 * time.Second}
317+ req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/", port), nil)
318+ require.NoError(t, err)
319+ req.Host = host
320+ resp, err := client.Do(req)
321+ require.NoError(t, err)
322+ defer resp.Body.Close()
323+ body, err := io.ReadAll(resp.Body)
324+ require.NoError(t, err)
325+ return string(body)
326+}
327+
328+func assertTCPPortClosed(t *testing.T, address string) {
329+ t.Helper()
330+ conn, err := net.DialTimeout("tcp", address, 100*time.Millisecond)
331+ if err == nil {
332+ _ = conn.Close()
333+ t.Fatalf("expected %s to be closed", address)
334+ }
335+}
336+
204337 func httpsGetForTest(t *testing.T, port int, serverName string) string {
205338 t.Helper()
206339 transport := &http.Transport{
@@ -3,6 +3,7 @@ package app
3 import (3 import (
4 "context"4 "context"
5 "crypto/tls"5 "crypto/tls"
6+ "errors"
6 "fmt"7 "fmt"
7 "io"8 "io"
8 "net"9 "net"
@@ -24,15 +25,134 @@ import (
24 traffic "github.com/bnema/gordon/internal/usecase/traffic"25 traffic "github.com/bnema/gordon/internal/usecase/traffic"
25 )26 )
26 27
27-func TestStartProxyServers_ConfiguresTrafficManagerHTTPSRoute(t *testing.T) {28+func TestWaitForServerReady_NilReadyReturnsImmediately(t *testing.T) {
29+ errChan := make(chan error)
30+ done := make(chan error, 1)
31+
32+ go func() {
33+ done <- waitForServerReady(nil, errChan)
34+ }()
35+
36+ select {
37+ case err := <-done:
38+ require.NoError(t, err)
39+ case <-time.After(100 * time.Millisecond):
40+ t.Fatal("nil ready channel blocked startup readiness")
41+ }
42+}
43+
44+func TestWaitForServerReady_NonNilReadyPreservesErrorBehavior(t *testing.T) {
45+ expected := errors.New("listen failed")
46+ errChan := make(chan error, 1)
47+ errChan <- expected
48+
49+ err := waitForServerReady(make(chan struct{}), errChan)
50+ require.ErrorIs(t, err, expected)
51+}
52+
53+func TestStartProxyServers_DoesNotStartLegacyHTTPListenerFromServerPort(t *testing.T) {
28 cfg := Config{}54 cfg := Config{}
29 cfg.Server.Port = freeTCPPort(t)55 cfg.Server.Port = freeTCPPort(t)
56+
57+ manager := trafficadapter.NewManager()
58+ defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
59+
60+ httpSrv, httpReady, tlsSrv, tlsReady, err := startProxyServers(
61+ cfg,
62+ http.NotFoundHandler(),
63+ http.NotFoundHandler(),
64+ newTestPKIService(t),
65+ nil,
66+ manager,
67+ zerowrap.Default(),
68+ )
69+ require.NoError(t, err)
70+ require.Nil(t, httpSrv)
71+ require.Nil(t, httpReady)
72+ require.Nil(t, tlsSrv)
73+ require.Nil(t, tlsReady)
74+ assertTCPPortClosed(t, fmt.Sprintf("127.0.0.1:%d", cfg.Server.Port))
75+}
76+
77+func TestStartProxyServers_DoesNotTreatLegacyTLSPortAsTLSCapable(t *testing.T) {
78+ cfg := Config{}
30 cfg.Server.TLSPort = freeTCPPort(t)79 cfg.Server.TLSPort = freeTCPPort(t)
31 80
81+ manager := trafficadapter.NewManager()
82+ defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
83+
84+ httpSrv, httpReady, tlsSrv, tlsReady, err := startProxyServers(
85+ cfg,
86+ http.NotFoundHandler(),
87+ http.NotFoundHandler(),
88+ newTestPKIService(t),
89+ nil,
90+ manager,
91+ zerowrap.Default(),
92+ )
93+ require.NoError(t, err)
94+ require.Nil(t, httpSrv)
95+ require.Nil(t, httpReady)
96+ require.Nil(t, tlsSrv)
97+ require.Nil(t, tlsReady)
98+ assertTCPPortClosed(t, fmt.Sprintf("127.0.0.1:%d", cfg.Server.TLSPort))
99+}
100+
101+func TestStartProxyServers_ConfiguresSmartTCPHTTPAndHTTPSRoutesWithoutPublicHTTPListener(t *testing.T) {
102+ cfg := Config{}
103+ cfg.Server.Port = freeTCPPort(t)
104+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
105+ traffic.DefaultEdgeEntryPointName: {
106+ Address: freeTCPAddress(t),
107+ Protocol: domain.EntryPointProtocolSmartTCP,
108+ },
109+ }
110+
111+ manager := trafficadapter.NewManager()
112+ defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
113+ pkiSvc := newTestPKIService(t)
114+
115+ httpSrv, httpReady, tlsSrv, tlsReady, err := startProxyServers(
116+ cfg,
117+ http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("cleartext " + r.Proto)) }),
118+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("secure")) }),
119+ pkiSvc,
120+ nil,
121+ manager,
122+ zerowrap.Default(),
123+ )
124+ require.NoError(t, err)
125+ require.Nil(t, httpSrv)
126+ require.Nil(t, httpReady)
127+ require.Nil(t, tlsSrv)
128+ require.Nil(t, tlsReady)
129+ assertTCPPortClosed(t, fmt.Sprintf("127.0.0.1:%d", cfg.Server.Port))
130+
131+ configSvc := inmocks.NewMockConfigService(t)
132+ configSvc.EXPECT().GetRoutes(context.Background()).Return([]domain.Route{{Domain: "app.example.com", HTTPS: true}})
133+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
134+ require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc))
135+
136+ _, smartPortText, err := net.SplitHostPort(cfg.EntryPoints[traffic.DefaultEdgeEntryPointName].Address)
137+ require.NoError(t, err)
138+ smartPort, err := strconv.Atoi(smartPortText)
139+ require.NoError(t, err)
140+ require.Contains(t, httpGetForTest(t, smartPort, "app.example.com"), "cleartext HTTP/1.1")
141+ require.Contains(t, httpsGetForTest(t, smartPort, "app.example.com"), "secure")
142+}
143+
144+func TestStartProxyServers_ConfiguresTrafficManagerHTTPSRoute(t *testing.T) {
145+ cfg := Config{}
146+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
147+ traffic.DefaultEdgeEntryPointName: {
148+ Address: freeTCPAddress(t),
149+ Protocol: domain.EntryPointProtocolTLSMux,
150+ },
151+ }
152+
32 manager := trafficadapter.NewManager()153 manager := trafficadapter.NewManager()
33 defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()154 defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
34 pkiSvc := newTestPKIService(t)155 pkiSvc := newTestPKIService(t)
35- errChan := make(chan error, 4)
36 156
37 httpSrv, httpReady, tlsSrv, tlsReady, err := startProxyServers(157 httpSrv, httpReady, tlsSrv, tlsReady, err := startProxyServers(
38 cfg,158 cfg,
@@ -41,21 +161,24 @@ func TestStartProxyServers_ConfiguresTrafficManagerHTTPSRoute(t *testing.T) {
41 pkiSvc,161 pkiSvc,
42 nil,162 nil,
43 manager,163 manager,
44- errChan,
45 zerowrap.Default(),164 zerowrap.Default(),
46 )165 )
47 require.NoError(t, err)166 require.NoError(t, err)
48- defer shutdownHTTPServer(t, httpSrv)167+ require.Nil(t, httpSrv)
168+ require.Nil(t, httpReady)
49 require.Nil(t, tlsSrv)169 require.Nil(t, tlsSrv)
50 require.Nil(t, tlsReady)170 require.Nil(t, tlsReady)
51- waitReadyForTest(t, httpReady, errChan)
52 171
53 configSvc := inmocks.NewMockConfigService(t)172 configSvc := inmocks.NewMockConfigService(t)
54 configSvc.EXPECT().GetRoutes(context.Background()).Return([]domain.Route{{Domain: "app.example.com", HTTPS: true}})173 configSvc.EXPECT().GetRoutes(context.Background()).Return([]domain.Route{{Domain: "app.example.com", HTTPS: true}})
55 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})174 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
56 require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc))175 require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc))
57 176
58- body := httpsGetForTest(t, cfg.Server.TLSPort, "app.example.com")177+ _, portText, err := net.SplitHostPort(cfg.EntryPoints[traffic.DefaultEdgeEntryPointName].Address)
178+ require.NoError(t, err)
179+ port, err := strconv.Atoi(portText)
180+ require.NoError(t, err)
181+ body := httpsGetForTest(t, port, "app.example.com")
59 require.Contains(t, body, "secure")182 require.Contains(t, body, "secure")
60 }183 }
61 184
@@ -72,7 +195,6 @@ func TestStartProxyServers_ConfiguresCustomTLSMuxHTTPSRoute(t *testing.T) {
72 manager := trafficadapter.NewManager()195 manager := trafficadapter.NewManager()
73 defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()196 defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
74 pkiSvc := newTestPKIService(t)197 pkiSvc := newTestPKIService(t)
75- errChan := make(chan error, 4)
76 198
77 httpSrv, httpReady, tlsSrv, tlsReady, err := startProxyServers(199 httpSrv, httpReady, tlsSrv, tlsReady, err := startProxyServers(
78 cfg,200 cfg,
@@ -81,14 +203,13 @@ func TestStartProxyServers_ConfiguresCustomTLSMuxHTTPSRoute(t *testing.T) {
81 pkiSvc,203 pkiSvc,
82 nil,204 nil,
83 manager,205 manager,
84- errChan,
85 zerowrap.Default(),206 zerowrap.Default(),
86 )207 )
87 require.NoError(t, err)208 require.NoError(t, err)
88- defer shutdownHTTPServer(t, httpSrv)209+ require.Nil(t, httpSrv)
210+ require.Nil(t, httpReady)
89 require.Nil(t, tlsSrv)211 require.Nil(t, tlsSrv)
90 require.Nil(t, tlsReady)212 require.Nil(t, tlsReady)
91- waitReadyForTest(t, httpReady, errChan)
92 213
93 configSvc := inmocks.NewMockConfigService(t)214 configSvc := inmocks.NewMockConfigService(t)
94 configSvc.EXPECT().GetRoutes(context.Background()).Return(nil)215 configSvc.EXPECT().GetRoutes(context.Background()).Return(nil)
@@ -105,8 +226,9 @@ func TestStartProxyServers_ConfiguresCustomTLSMuxHTTPSRoute(t *testing.T) {
105 226
106 func TestStartProxyServers_TLSSetupFailureStopsHTTPServer(t *testing.T) {227 func TestStartProxyServers_TLSSetupFailureStopsHTTPServer(t *testing.T) {
107 cfg := Config{}228 cfg := Config{}
108- cfg.Server.Port = freeTCPPort(t)229+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
109- cfg.Server.TLSPort = freeTCPPort(t)230+ traffic.DefaultEdgeEntryPointName: {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolSmartTCP},
231+ }
110 cfg.Server.TLSCertFile = "/path/that/does/not/exist.crt"232 cfg.Server.TLSCertFile = "/path/that/does/not/exist.crt"
111 cfg.Server.TLSKeyFile = "/path/that/does/not/exist.key"233 cfg.Server.TLSKeyFile = "/path/that/does/not/exist.key"
112 234
@@ -117,7 +239,6 @@ func TestStartProxyServers_TLSSetupFailureStopsHTTPServer(t *testing.T) {
117 newTestPKIService(t),239 newTestPKIService(t),
118 nil,240 nil,
119 trafficadapter.NewManager(),241 trafficadapter.NewManager(),
120- make(chan error, 4),
121 zerowrap.Default(),242 zerowrap.Default(),
122 )243 )
123 require.ErrorContains(t, err, "load TLS keypair")244 require.ErrorContains(t, err, "load TLS keypair")
@@ -137,8 +258,9 @@ func TestStartProxyServers_TLSSetupFailureStopsHTTPServer(t *testing.T) {
137 258
138 func TestStartProxyServers_TLSRequiresTrafficManager(t *testing.T) {259 func TestStartProxyServers_TLSRequiresTrafficManager(t *testing.T) {
139 cfg := Config{}260 cfg := Config{}
140- cfg.Server.Port = freeTCPPort(t)261+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
141- cfg.Server.TLSPort = freeTCPPort(t)262+ traffic.DefaultEdgeEntryPointName: {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolSmartTCP},
263+ }
142 264
143 httpSrv, _, tlsSrv, _, err := startProxyServers(265 httpSrv, _, tlsSrv, _, err := startProxyServers(
144 cfg,266 cfg,
@@ -147,7 +269,6 @@ func TestStartProxyServers_TLSRequiresTrafficManager(t *testing.T) {
147 newTestPKIService(t),269 newTestPKIService(t),
148 nil,270 nil,
149 nil,271 nil,
150- make(chan error, 2),
151 zerowrap.Default(),272 zerowrap.Default(),
152 )273 )
153 if httpSrv != nil {274 if httpSrv != nil {
@@ -183,17 +304,6 @@ func freeTCPPort(t *testing.T) int {
183 return port304 return port
184 }305 }
185 306
186-func waitReadyForTest(t *testing.T, ready <-chan struct{}, errChan <-chan error) {
187- t.Helper()
188- select {
189- case <-ready:
190- case err := <-errChan:
191- require.NoError(t, err)
192- case <-time.After(2 * time.Second):
193- t.Fatal("server did not become ready")
194- }
195-}
196-
197 func shutdownHTTPServer(t *testing.T, srv *http.Server) {307 func shutdownHTTPServer(t *testing.T, srv *http.Server) {
198 t.Helper()308 t.Helper()
199 ctx, cancel := context.WithTimeout(context.Background(), time.Second)309 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
@@ -201,6 +311,29 @@ func shutdownHTTPServer(t *testing.T, srv *http.Server) {
201 require.NoError(t, srv.Shutdown(ctx))311 require.NoError(t, srv.Shutdown(ctx))
202 }312 }
203 313
314+func httpGetForTest(t *testing.T, port int, host string) string {
315+ t.Helper()
316+ client := &http.Client{Timeout: 2 * time.Second}
317+ req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/", port), nil)
318+ require.NoError(t, err)
319+ req.Host = host
320+ resp, err := client.Do(req)
321+ require.NoError(t, err)
322+ defer resp.Body.Close()
323+ body, err := io.ReadAll(resp.Body)
324+ require.NoError(t, err)
325+ return string(body)
326+}
327+
328+func assertTCPPortClosed(t *testing.T, address string) {
329+ t.Helper()
330+ conn, err := net.DialTimeout("tcp", address, 100*time.Millisecond)
331+ if err == nil {
332+ _ = conn.Close()
333+ t.Fatalf("expected %s to be closed", address)
334+ }
335+}
336+
204 func httpsGetForTest(t *testing.T, port int, serverName string) string {337 func httpsGetForTest(t *testing.T, port int, serverName string) string {
205 t.Helper()338 t.Helper()
206 transport := &http.Transport{339 transport := &http.Transport{
M internal/app/traffic.go +1 -5
@@ -15,10 +15,6 @@ func applyTrafficRuntimeConfig(ctx context.Context, manager *trafficadapter.Mana
1515 return nil
1616 }
1717 graph, err := trafficbuilder.Build(trafficbuilder.Input{
18- Server: trafficbuilder.ServerConfig{
19- Port: cfg.Server.Port,
20- TLSPort: cfg.Server.TLSPort,
21- },
2218 EntryPoints: cfg.EntryPoints,
2319 Traffic: cfg.Traffic,
2420 Routes: configSvc.GetRoutes(ctx),
@@ -78,7 +74,7 @@ func trafficManagerRouterProtocol(protocol domain.RouterProtocol) bool {
7874
7975 func trafficManagerOwnsEntryPoint(entryPoint domain.EntryPoint) bool {
8076 switch entryPoint.Protocol {
81- case domain.EntryPointProtocolTLSMux, domain.EntryPointProtocolTCP, domain.EntryPointProtocolUDP:
77+ case domain.EntryPointProtocolTLSMux, domain.EntryPointProtocolSmartTCP, domain.EntryPointProtocolTCP, domain.EntryPointProtocolUDP:
8278 return true
8379 default:
8480 return false
@@ -15,10 +15,6 @@ func applyTrafficRuntimeConfig(ctx context.Context, manager *trafficadapter.Mana
15 return nil15 return nil
16 }16 }
17 graph, err := trafficbuilder.Build(trafficbuilder.Input{17 graph, err := trafficbuilder.Build(trafficbuilder.Input{
18- Server: trafficbuilder.ServerConfig{
19- Port: cfg.Server.Port,
20- TLSPort: cfg.Server.TLSPort,
21- },
22 EntryPoints: cfg.EntryPoints,18 EntryPoints: cfg.EntryPoints,
23 Traffic: cfg.Traffic,19 Traffic: cfg.Traffic,
24 Routes: configSvc.GetRoutes(ctx),20 Routes: configSvc.GetRoutes(ctx),
@@ -78,7 +74,7 @@ func trafficManagerRouterProtocol(protocol domain.RouterProtocol) bool {
78 74
79 func trafficManagerOwnsEntryPoint(entryPoint domain.EntryPoint) bool {75 func trafficManagerOwnsEntryPoint(entryPoint domain.EntryPoint) bool {
80 switch entryPoint.Protocol {76 switch entryPoint.Protocol {
81- case domain.EntryPointProtocolTLSMux, domain.EntryPointProtocolTCP, domain.EntryPointProtocolUDP:77+ case domain.EntryPointProtocolTLSMux, domain.EntryPointProtocolSmartTCP, domain.EntryPointProtocolTCP, domain.EntryPointProtocolUDP:
82 return true78 return true
83 default:79 default:
84 return false80 return false
M internal/app/traffic_test.go +78 -8
@@ -14,16 +14,55 @@ import (
1414 "github.com/bnema/gordon/internal/usecase/traffic"
1515 )
1616
17+func TestTrafficRuntimeGraphKeepsSmartTCPHTTPAndTLSPassthroughRouters(t *testing.T) {
18+ graph := domain.TrafficGraph{
19+ EntryPoints: []domain.EntryPoint{
20+ {Name: "web", Address: "127.0.0.1:8080", Protocol: domain.EntryPointProtocolHTTP},
21+ {Name: traffic.DefaultEdgeEntryPointName, Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolSmartTCP, RawFallback: "ssh", RawFallbackTrustedCIDRs: []string{"127.0.0.0/8"}},
22+ },
23+ Routers: []domain.TrafficRouter{
24+ {Name: "legacy-http", EntryPoint: "web", Protocol: domain.RouterProtocolHTTP, Service: "route:legacy.example.com"},
25+ {Name: "app", EntryPoint: traffic.DefaultEdgeEntryPointName, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
26+ {Name: "tls-raw", EntryPoint: traffic.DefaultEdgeEntryPointName, Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: "raw.example.com"}, Service: "network_service:raw:tls"},
27+ {Name: "ssh", EntryPoint: traffic.DefaultEdgeEntryPointName, Protocol: domain.RouterProtocolTCP, Service: "network_service:ssh:ssh"},
28+ },
29+ Services: []domain.TrafficService{
30+ {Name: "route:legacy.example.com"},
31+ {Name: "route:app.example.com"},
32+ {Name: "network_service:raw:tls", Backends: []domain.TrafficBackend{{Name: "raw:tls", Host: "127.0.0.1", Port: 443, Protocol: domain.NetworkProtocolTCP}}},
33+ {Name: "network_service:ssh:ssh", Backends: []domain.TrafficBackend{{Name: "ssh:ssh", Host: "127.0.0.1", Port: 22, Protocol: domain.NetworkProtocolTCP}}},
34+ },
35+ }
36+
37+ filtered, err := trafficRuntimeGraph(graph)
38+ require.NoError(t, err)
39+ assert.ElementsMatch(t, []string{traffic.DefaultEdgeEntryPointName}, entryPointNames(filtered.EntryPoints))
40+ assert.ElementsMatch(t, []string{"app", "tls-raw", "ssh"}, routerNames(filtered.Routers))
41+ assert.ElementsMatch(t, []string{"route:app.example.com", "network_service:raw:tls", "network_service:ssh:ssh"}, serviceNames(filtered.Services))
42+}
43+
44+func TestTrafficRuntimeGraphRejectsHTTPRouterOnlyWhenItTargetsManagerProtocolEntryPointThatIsNotOwned(t *testing.T) {
45+ graph := domain.TrafficGraph{
46+ EntryPoints: []domain.EntryPoint{{Name: "edge", Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolHTTP}},
47+ Routers: []domain.TrafficRouter{{Name: "app", EntryPoint: "edge", Protocol: domain.RouterProtocolHTTP, Service: "route:app.example.com"}},
48+ Services: []domain.TrafficService{{Name: "route:app.example.com"}},
49+ }
50+
51+ filtered, err := trafficRuntimeGraph(graph)
52+ require.NoError(t, err)
53+ assert.Empty(t, filtered.Routers)
54+}
55+
1756 func TestTrafficRuntimeGraphKeepsDefaultTLSEntrypoint(t *testing.T) {
1857 graph := domain.TrafficGraph{
1958 EntryPoints: []domain.EntryPoint{
20- {Name: traffic.DefaultHTTPEntryPointName, Address: "127.0.0.1:8080", Protocol: domain.EntryPointProtocolHTTP},
21- {Name: traffic.DefaultTLSEntryPointName, Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolTLSMux},
59+ {Name: "web", Address: "127.0.0.1:8080", Protocol: domain.EntryPointProtocolHTTP},
60+ {Name: "websecure", Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolTLSMux},
2261 {Name: "postgres", Address: "127.0.0.1:15432", Protocol: domain.EntryPointProtocolTCP},
2362 },
2463 Routers: []domain.TrafficRouter{
25- {Name: "app", EntryPoint: traffic.DefaultHTTPEntryPointName, Protocol: domain.RouterProtocolHTTP, Service: "route:app.example.com"},
26- {Name: "app-secure", EntryPoint: traffic.DefaultTLSEntryPointName, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
64+ {Name: "app", EntryPoint: "web", Protocol: domain.RouterProtocolHTTP, Service: "route:app.example.com"},
65+ {Name: "app-secure", EntryPoint: "websecure", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
2766 {Name: "postgres", EntryPoint: "postgres", Protocol: domain.RouterProtocolTCP, Service: "network_service:postgres:db"},
2867 },
2968 Services: []domain.TrafficService{
@@ -34,26 +73,57 @@ func TestTrafficRuntimeGraphKeepsDefaultTLSEntrypoint(t *testing.T) {
3473
3574 filtered, err := trafficRuntimeGraph(graph)
3675 require.NoError(t, err)
37- assert.ElementsMatch(t, []string{traffic.DefaultTLSEntryPointName, "postgres"}, entryPointNames(filtered.EntryPoints))
76+ assert.ElementsMatch(t, []string{"websecure", "postgres"}, entryPointNames(filtered.EntryPoints))
3877 assert.ElementsMatch(t, []string{"app-secure", "postgres"}, routerNames(filtered.Routers))
3978 assert.ElementsMatch(t, []string{"route:app.example.com", "network_service:postgres:db"}, serviceNames(filtered.Services))
4079 }
4180
4281 func TestTrafficRuntimeGraphAllowsTLSPassthroughOnDefaultTLS(t *testing.T) {
4382 graph := domain.TrafficGraph{
44- EntryPoints: []domain.EntryPoint{{Name: traffic.DefaultTLSEntryPointName, Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolTLSMux}},
45- Routers: []domain.TrafficRouter{{Name: "raw", EntryPoint: traffic.DefaultTLSEntryPointName, Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: "raw.example.com"}, Service: "network_service:raw:tls"}},
83+ EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolTLSMux}},
84+ Routers: []domain.TrafficRouter{{Name: "raw", EntryPoint: "websecure", Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: "raw.example.com"}, Service: "network_service:raw:tls"}},
4685 Services: []domain.TrafficService{{Name: "network_service:raw:tls", Backends: []domain.TrafficBackend{{Name: "raw:tls", Host: "127.0.0.1", Port: 443, Protocol: domain.NetworkProtocolTCP}}}},
4786 }
4887
4988 filtered, err := trafficRuntimeGraph(graph)
5089 require.NoError(t, err)
5190 require.Len(t, filtered.EntryPoints, 1)
52- assert.Equal(t, traffic.DefaultTLSEntryPointName, filtered.EntryPoints[0].Name)
91+ assert.Equal(t, "websecure", filtered.EntryPoints[0].Name)
5392 require.Len(t, filtered.Routers, 1)
5493 assert.Equal(t, "raw", filtered.Routers[0].Name)
5594 }
5695
96+func TestApplyTrafficRuntimeConfigAppliesSmartTCPEntrypointWithRawFallbackPolicy(t *testing.T) {
97+ manager := trafficadapter.NewManager()
98+ defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
99+ cfg := Config{
100+ EntryPoints: map[string]traffic.EntryPointConfig{
101+ traffic.DefaultEdgeEntryPointName: {
102+ Address: freeTCPAddress(t),
103+ Protocol: domain.EntryPointProtocolSmartTCP,
104+ RawFallback: "ssh",
105+ RawFallbackTrustedCIDRs: []string{"127.0.0.0/8"},
106+ },
107+ },
108+ NetworkServices: []traffic.NetworkServiceConfig{{
109+ Name: "ssh",
110+ Ports: []traffic.PortConfig{{Name: "ssh", Container: 22, Protocol: domain.NetworkProtocolTCP}},
111+ }},
112+ }
113+ cfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "ssh", EntryPoint: traffic.DefaultEdgeEntryPointName, Service: "network_service:ssh:ssh"}}
114+
115+ configSvc := inmocks.NewMockConfigService(t)
116+ configSvc.EXPECT().GetRoutes(context.Background()).Return([]domain.Route{{Domain: "app.example.com", HTTPS: true}})
117+ configSvc.EXPECT().GetExternalRoutes().Return(nil)
118+
119+ require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc))
120+ status := manager.Status()
121+ require.Len(t, status.EntryPoints, 1)
122+ assert.Equal(t, traffic.DefaultEdgeEntryPointName, status.EntryPoints[0].Name)
123+ assert.True(t, status.EntryPoints[0].Active)
124+ assert.Equal(t, domain.EntryPointProtocolSmartTCP, status.EntryPoints[0].Protocol)
125+}
126+
57127 func TestApplyTrafficRuntimeConfigAppliesCustomL4Entrypoint(t *testing.T) {
58128 manager := trafficadapter.NewManager()
59129 defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
@@ -14,16 +14,55 @@ import (
14 "github.com/bnema/gordon/internal/usecase/traffic"14 "github.com/bnema/gordon/internal/usecase/traffic"
15 )15 )
16 16
17+func TestTrafficRuntimeGraphKeepsSmartTCPHTTPAndTLSPassthroughRouters(t *testing.T) {
18+ graph := domain.TrafficGraph{
19+ EntryPoints: []domain.EntryPoint{
20+ {Name: "web", Address: "127.0.0.1:8080", Protocol: domain.EntryPointProtocolHTTP},
21+ {Name: traffic.DefaultEdgeEntryPointName, Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolSmartTCP, RawFallback: "ssh", RawFallbackTrustedCIDRs: []string{"127.0.0.0/8"}},
22+ },
23+ Routers: []domain.TrafficRouter{
24+ {Name: "legacy-http", EntryPoint: "web", Protocol: domain.RouterProtocolHTTP, Service: "route:legacy.example.com"},
25+ {Name: "app", EntryPoint: traffic.DefaultEdgeEntryPointName, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
26+ {Name: "tls-raw", EntryPoint: traffic.DefaultEdgeEntryPointName, Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: "raw.example.com"}, Service: "network_service:raw:tls"},
27+ {Name: "ssh", EntryPoint: traffic.DefaultEdgeEntryPointName, Protocol: domain.RouterProtocolTCP, Service: "network_service:ssh:ssh"},
28+ },
29+ Services: []domain.TrafficService{
30+ {Name: "route:legacy.example.com"},
31+ {Name: "route:app.example.com"},
32+ {Name: "network_service:raw:tls", Backends: []domain.TrafficBackend{{Name: "raw:tls", Host: "127.0.0.1", Port: 443, Protocol: domain.NetworkProtocolTCP}}},
33+ {Name: "network_service:ssh:ssh", Backends: []domain.TrafficBackend{{Name: "ssh:ssh", Host: "127.0.0.1", Port: 22, Protocol: domain.NetworkProtocolTCP}}},
34+ },
35+ }
36+
37+ filtered, err := trafficRuntimeGraph(graph)
38+ require.NoError(t, err)
39+ assert.ElementsMatch(t, []string{traffic.DefaultEdgeEntryPointName}, entryPointNames(filtered.EntryPoints))
40+ assert.ElementsMatch(t, []string{"app", "tls-raw", "ssh"}, routerNames(filtered.Routers))
41+ assert.ElementsMatch(t, []string{"route:app.example.com", "network_service:raw:tls", "network_service:ssh:ssh"}, serviceNames(filtered.Services))
42+}
43+
44+func TestTrafficRuntimeGraphRejectsHTTPRouterOnlyWhenItTargetsManagerProtocolEntryPointThatIsNotOwned(t *testing.T) {
45+ graph := domain.TrafficGraph{
46+ EntryPoints: []domain.EntryPoint{{Name: "edge", Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolHTTP}},
47+ Routers: []domain.TrafficRouter{{Name: "app", EntryPoint: "edge", Protocol: domain.RouterProtocolHTTP, Service: "route:app.example.com"}},
48+ Services: []domain.TrafficService{{Name: "route:app.example.com"}},
49+ }
50+
51+ filtered, err := trafficRuntimeGraph(graph)
52+ require.NoError(t, err)
53+ assert.Empty(t, filtered.Routers)
54+}
55+
17 func TestTrafficRuntimeGraphKeepsDefaultTLSEntrypoint(t *testing.T) {56 func TestTrafficRuntimeGraphKeepsDefaultTLSEntrypoint(t *testing.T) {
18 graph := domain.TrafficGraph{57 graph := domain.TrafficGraph{
19 EntryPoints: []domain.EntryPoint{58 EntryPoints: []domain.EntryPoint{
20- {Name: traffic.DefaultHTTPEntryPointName, Address: "127.0.0.1:8080", Protocol: domain.EntryPointProtocolHTTP},59+ {Name: "web", Address: "127.0.0.1:8080", Protocol: domain.EntryPointProtocolHTTP},
21- {Name: traffic.DefaultTLSEntryPointName, Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolTLSMux},60+ {Name: "websecure", Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolTLSMux},
22 {Name: "postgres", Address: "127.0.0.1:15432", Protocol: domain.EntryPointProtocolTCP},61 {Name: "postgres", Address: "127.0.0.1:15432", Protocol: domain.EntryPointProtocolTCP},
23 },62 },
24 Routers: []domain.TrafficRouter{63 Routers: []domain.TrafficRouter{
25- {Name: "app", EntryPoint: traffic.DefaultHTTPEntryPointName, Protocol: domain.RouterProtocolHTTP, Service: "route:app.example.com"},64+ {Name: "app", EntryPoint: "web", Protocol: domain.RouterProtocolHTTP, Service: "route:app.example.com"},
26- {Name: "app-secure", EntryPoint: traffic.DefaultTLSEntryPointName, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},65+ {Name: "app-secure", EntryPoint: "websecure", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
27 {Name: "postgres", EntryPoint: "postgres", Protocol: domain.RouterProtocolTCP, Service: "network_service:postgres:db"},66 {Name: "postgres", EntryPoint: "postgres", Protocol: domain.RouterProtocolTCP, Service: "network_service:postgres:db"},
28 },67 },
29 Services: []domain.TrafficService{68 Services: []domain.TrafficService{
@@ -34,26 +73,57 @@ func TestTrafficRuntimeGraphKeepsDefaultTLSEntrypoint(t *testing.T) {
34 73
35 filtered, err := trafficRuntimeGraph(graph)74 filtered, err := trafficRuntimeGraph(graph)
36 require.NoError(t, err)75 require.NoError(t, err)
37- assert.ElementsMatch(t, []string{traffic.DefaultTLSEntryPointName, "postgres"}, entryPointNames(filtered.EntryPoints))76+ assert.ElementsMatch(t, []string{"websecure", "postgres"}, entryPointNames(filtered.EntryPoints))
38 assert.ElementsMatch(t, []string{"app-secure", "postgres"}, routerNames(filtered.Routers))77 assert.ElementsMatch(t, []string{"app-secure", "postgres"}, routerNames(filtered.Routers))
39 assert.ElementsMatch(t, []string{"route:app.example.com", "network_service:postgres:db"}, serviceNames(filtered.Services))78 assert.ElementsMatch(t, []string{"route:app.example.com", "network_service:postgres:db"}, serviceNames(filtered.Services))
40 }79 }
41 80
42 func TestTrafficRuntimeGraphAllowsTLSPassthroughOnDefaultTLS(t *testing.T) {81 func TestTrafficRuntimeGraphAllowsTLSPassthroughOnDefaultTLS(t *testing.T) {
43 graph := domain.TrafficGraph{82 graph := domain.TrafficGraph{
44- EntryPoints: []domain.EntryPoint{{Name: traffic.DefaultTLSEntryPointName, Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolTLSMux}},83+ EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolTLSMux}},
45- Routers: []domain.TrafficRouter{{Name: "raw", EntryPoint: traffic.DefaultTLSEntryPointName, Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: "raw.example.com"}, Service: "network_service:raw:tls"}},84+ Routers: []domain.TrafficRouter{{Name: "raw", EntryPoint: "websecure", Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: "raw.example.com"}, Service: "network_service:raw:tls"}},
46 Services: []domain.TrafficService{{Name: "network_service:raw:tls", Backends: []domain.TrafficBackend{{Name: "raw:tls", Host: "127.0.0.1", Port: 443, Protocol: domain.NetworkProtocolTCP}}}},85 Services: []domain.TrafficService{{Name: "network_service:raw:tls", Backends: []domain.TrafficBackend{{Name: "raw:tls", Host: "127.0.0.1", Port: 443, Protocol: domain.NetworkProtocolTCP}}}},
47 }86 }
48 87
49 filtered, err := trafficRuntimeGraph(graph)88 filtered, err := trafficRuntimeGraph(graph)
50 require.NoError(t, err)89 require.NoError(t, err)
51 require.Len(t, filtered.EntryPoints, 1)90 require.Len(t, filtered.EntryPoints, 1)
52- assert.Equal(t, traffic.DefaultTLSEntryPointName, filtered.EntryPoints[0].Name)91+ assert.Equal(t, "websecure", filtered.EntryPoints[0].Name)
53 require.Len(t, filtered.Routers, 1)92 require.Len(t, filtered.Routers, 1)
54 assert.Equal(t, "raw", filtered.Routers[0].Name)93 assert.Equal(t, "raw", filtered.Routers[0].Name)
55 }94 }
56 95
96+func TestApplyTrafficRuntimeConfigAppliesSmartTCPEntrypointWithRawFallbackPolicy(t *testing.T) {
97+ manager := trafficadapter.NewManager()
98+ defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
99+ cfg := Config{
100+ EntryPoints: map[string]traffic.EntryPointConfig{
101+ traffic.DefaultEdgeEntryPointName: {
102+ Address: freeTCPAddress(t),
103+ Protocol: domain.EntryPointProtocolSmartTCP,
104+ RawFallback: "ssh",
105+ RawFallbackTrustedCIDRs: []string{"127.0.0.0/8"},
106+ },
107+ },
108+ NetworkServices: []traffic.NetworkServiceConfig{{
109+ Name: "ssh",
110+ Ports: []traffic.PortConfig{{Name: "ssh", Container: 22, Protocol: domain.NetworkProtocolTCP}},
111+ }},
112+ }
113+ cfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "ssh", EntryPoint: traffic.DefaultEdgeEntryPointName, Service: "network_service:ssh:ssh"}}
114+
115+ configSvc := inmocks.NewMockConfigService(t)
116+ configSvc.EXPECT().GetRoutes(context.Background()).Return([]domain.Route{{Domain: "app.example.com", HTTPS: true}})
117+ configSvc.EXPECT().GetExternalRoutes().Return(nil)
118+
119+ require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc))
120+ status := manager.Status()
121+ require.Len(t, status.EntryPoints, 1)
122+ assert.Equal(t, traffic.DefaultEdgeEntryPointName, status.EntryPoints[0].Name)
123+ assert.True(t, status.EntryPoints[0].Active)
124+ assert.Equal(t, domain.EntryPointProtocolSmartTCP, status.EntryPoints[0].Protocol)
125+}
126+
57 func TestApplyTrafficRuntimeConfigAppliesCustomL4Entrypoint(t *testing.T) {127 func TestApplyTrafficRuntimeConfigAppliesCustomL4Entrypoint(t *testing.T) {
58 manager := trafficadapter.NewManager()128 manager := trafficadapter.NewManager()
59 defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()129 defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
M internal/domain/traffic.go +76 -25
@@ -11,10 +11,11 @@ import (
1111 type EntryPointProtocol string
1212
1313 const (
14- EntryPointProtocolHTTP EntryPointProtocol = "http"
15- EntryPointProtocolTLSMux EntryPointProtocol = "tls_mux"
16- EntryPointProtocolTCP EntryPointProtocol = "tcp"
17- EntryPointProtocolUDP EntryPointProtocol = "udp"
14+ EntryPointProtocolHTTP EntryPointProtocol = "http"
15+ EntryPointProtocolTLSMux EntryPointProtocol = "tls_mux"
16+ EntryPointProtocolSmartTCP EntryPointProtocol = "smart_tcp"
17+ EntryPointProtocolTCP EntryPointProtocol = "tcp"
18+ EntryPointProtocolUDP EntryPointProtocol = "udp"
1819 )
1920
2021 type RouterProtocol string
@@ -59,10 +60,13 @@ type UDPOptions struct {
5960 }
6061
6162 type EntryPoint struct {
62- Name string
63- Address string
64- Protocol EntryPointProtocol
65- TrustedCIDRs []string
63+ Name string
64+ Address string
65+ Protocol EntryPointProtocol
66+ TrustedCIDRs []string
67+ RawFallback string
68+ RawFallbackTrustedCIDRs []string
69+ AllowPublicRawFallback bool
6670 }
6771
6872 type TrafficRouter struct {
@@ -111,6 +115,7 @@ type EntryPointStatus struct {
111115 TotalErrors int64
112116 BytesIn int64
113117 BytesOut int64
118+ SmartTCP SmartTCPCounters
114119 }
115120
116121 type TrafficRouterStatus struct {
@@ -144,6 +149,22 @@ type TrafficCounters struct {
144149 TotalErrors int64
145150 BytesIn int64
146151 BytesOut int64
152+ SmartTCP SmartTCPCounters
153+}
154+
155+type SmartTCPCounters struct {
156+ HTTPAccepted int64
157+ H2CAccepted int64
158+ HTTPSFallbackAccepted int64
159+ TLSPassthroughAccepted int64
160+ RawFallbackAccepted int64
161+ EntrypointCIDRRefused int64
162+ RawFallbackCIDRRefused int64
163+ PROXYRefused int64
164+ UnknownNoFallbackRefused int64
165+ MalformedRejected int64
166+ SniffTimeout int64
167+ ClientHelloTooLarge int64
147168 }
148169
149170 type TrafficServiceRefKind string
@@ -330,26 +351,28 @@ func (g TrafficGraph) validateRouters(entryPoints map[string]EntryPoint, service
330351 return err
331352 }
332353 }
333- return nil
354+ return validateRawFallbackRouters(entryPoints, state.routersByName)
334355 }
335356
336357 type routerValidationState struct {
337- routerNames map[string]struct{}
338- exactSNI map[string]map[string]string
339- wildSNI map[string]map[string]string
340- httpHosts map[string]map[string]string
341- tcpRouters map[string]string
342- udpRouters map[string]string
358+ routerNames map[string]struct{}
359+ routersByName map[string]TrafficRouter
360+ exactSNI map[string]map[string]string
361+ wildSNI map[string]map[string]string
362+ httpHosts map[string]map[string]string
363+ tcpRouters map[string]string
364+ udpRouters map[string]string
343365 }
344366
345367 func newRouterValidationState() *routerValidationState {
346368 return &routerValidationState{
347- routerNames: map[string]struct{}{},
348- exactSNI: map[string]map[string]string{},
349- wildSNI: map[string]map[string]string{},
350- httpHosts: map[string]map[string]string{},
351- tcpRouters: map[string]string{},
352- udpRouters: map[string]string{},
369+ routerNames: map[string]struct{}{},
370+ routersByName: map[string]TrafficRouter{},
371+ exactSNI: map[string]map[string]string{},
372+ wildSNI: map[string]map[string]string{},
373+ httpHosts: map[string]map[string]string{},
374+ tcpRouters: map[string]string{},
375+ udpRouters: map[string]string{},
353376 }
354377 }
355378
@@ -381,6 +404,7 @@ func (s *routerValidationState) validateRouterName(router TrafficRouter) error {
381404 return fmt.Errorf("duplicate traffic router name %q", router.Name)
382405 }
383406 s.routerNames[router.Name] = struct{}{}
407+ s.routersByName[router.Name] = router
384408 return nil
385409 }
386410
@@ -492,11 +516,11 @@ func validateRouterEntryPointProtocol(router TrafficRouter, entryPoint EntryPoin
492516 compatible := false
493517 switch router.Protocol {
494518 case RouterProtocolHTTP:
495- compatible = entryPoint.Protocol == EntryPointProtocolHTTP || entryPoint.Protocol == EntryPointProtocolTLSMux
519+ compatible = entryPoint.Protocol == EntryPointProtocolHTTP || entryPoint.Protocol == EntryPointProtocolTLSMux || entryPoint.Protocol == EntryPointProtocolSmartTCP
496520 case RouterProtocolTLSPassthrough:
497- compatible = entryPoint.Protocol == EntryPointProtocolTLSMux
521+ compatible = entryPoint.Protocol == EntryPointProtocolTLSMux || entryPoint.Protocol == EntryPointProtocolSmartTCP
498522 case RouterProtocolTCP:
499- compatible = entryPoint.Protocol == EntryPointProtocolTCP
523+ compatible = entryPoint.Protocol == EntryPointProtocolTCP || (entryPoint.Protocol == EntryPointProtocolSmartTCP && entryPoint.RawFallback == router.Name)
500524 case RouterProtocolUDP:
501525 compatible = entryPoint.Protocol == EntryPointProtocolUDP
502526 }
@@ -561,12 +585,39 @@ func validateTrustedCIDRs(entryPoint EntryPoint) error {
561585 return fmt.Errorf("invalid trusted_cidrs entry %q for entrypoint %q: %w", cidr, entryPoint.Name, err)
562586 }
563587 }
588+ for _, cidr := range entryPoint.RawFallbackTrustedCIDRs {
589+ if _, _, err := net.ParseCIDR(strings.TrimSpace(cidr)); err != nil {
590+ return fmt.Errorf("invalid raw_fallback_trusted_cidrs entry %q for entrypoint %q: %w", cidr, entryPoint.Name, err)
591+ }
592+ }
593+ if entryPoint.RawFallback != "" && len(entryPoint.RawFallbackTrustedCIDRs) == 0 && !entryPoint.AllowPublicRawFallback {
594+ return fmt.Errorf("raw fallback %q on entrypoint %q requires raw_fallback_trusted_cidrs or allow_public_raw_fallback", entryPoint.RawFallback, entryPoint.Name)
595+ }
596+ return nil
597+}
598+
599+func validateRawFallbackRouters(entryPoints map[string]EntryPoint, routers map[string]TrafficRouter) error {
600+ for _, entryPoint := range entryPoints {
601+ if entryPoint.RawFallback == "" {
602+ continue
603+ }
604+ router, ok := routers[entryPoint.RawFallback]
605+ if !ok {
606+ return fmt.Errorf("raw fallback %q on entrypoint %q references unknown router", entryPoint.RawFallback, entryPoint.Name)
607+ }
608+ if router.EntryPoint != entryPoint.Name {
609+ return fmt.Errorf("raw fallback %q on entrypoint %q references router on entrypoint %q", entryPoint.RawFallback, entryPoint.Name, router.EntryPoint)
610+ }
611+ if router.Protocol != RouterProtocolTCP {
612+ return fmt.Errorf("raw fallback %q on entrypoint %q must reference tcp router", entryPoint.RawFallback, entryPoint.Name)
613+ }
614+ }
564615 return nil
565616 }
566617
567618 func validateEntryPointProtocol(protocol EntryPointProtocol) error {
568619 switch protocol {
569- case EntryPointProtocolHTTP, EntryPointProtocolTLSMux, EntryPointProtocolTCP, EntryPointProtocolUDP:
620+ case EntryPointProtocolHTTP, EntryPointProtocolTLSMux, EntryPointProtocolSmartTCP, EntryPointProtocolTCP, EntryPointProtocolUDP:
570621 return nil
571622 default:
572623 return fmt.Errorf("%q", protocol)
@@ -11,10 +11,11 @@ import (
11 type EntryPointProtocol string11 type EntryPointProtocol string
12 12
13 const (13 const (
14- EntryPointProtocolHTTP EntryPointProtocol = "http"14+ EntryPointProtocolHTTP EntryPointProtocol = "http"
15- EntryPointProtocolTLSMux EntryPointProtocol = "tls_mux"15+ EntryPointProtocolTLSMux EntryPointProtocol = "tls_mux"
16- EntryPointProtocolTCP EntryPointProtocol = "tcp"16+ EntryPointProtocolSmartTCP EntryPointProtocol = "smart_tcp"
17- EntryPointProtocolUDP EntryPointProtocol = "udp"17+ EntryPointProtocolTCP EntryPointProtocol = "tcp"
18+ EntryPointProtocolUDP EntryPointProtocol = "udp"
18 )19 )
19 20
20 type RouterProtocol string21 type RouterProtocol string
@@ -59,10 +60,13 @@ type UDPOptions struct {
59 }60 }
60 61
61 type EntryPoint struct {62 type EntryPoint struct {
62- Name string63+ Name string
63- Address string64+ Address string
64- Protocol EntryPointProtocol65+ Protocol EntryPointProtocol
65- TrustedCIDRs []string66+ TrustedCIDRs []string
67+ RawFallback string
68+ RawFallbackTrustedCIDRs []string
69+ AllowPublicRawFallback bool
66 }70 }
67 71
68 type TrafficRouter struct {72 type TrafficRouter struct {
@@ -111,6 +115,7 @@ type EntryPointStatus struct {
111 TotalErrors int64115 TotalErrors int64
112 BytesIn int64116 BytesIn int64
113 BytesOut int64117 BytesOut int64
118+ SmartTCP SmartTCPCounters
114 }119 }
115 120
116 type TrafficRouterStatus struct {121 type TrafficRouterStatus struct {
@@ -144,6 +149,22 @@ type TrafficCounters struct {
144 TotalErrors int64149 TotalErrors int64
145 BytesIn int64150 BytesIn int64
146 BytesOut int64151 BytesOut int64
152+ SmartTCP SmartTCPCounters
153+}
154+
155+type SmartTCPCounters struct {
156+ HTTPAccepted int64
157+ H2CAccepted int64
158+ HTTPSFallbackAccepted int64
159+ TLSPassthroughAccepted int64
160+ RawFallbackAccepted int64
161+ EntrypointCIDRRefused int64
162+ RawFallbackCIDRRefused int64
163+ PROXYRefused int64
164+ UnknownNoFallbackRefused int64
165+ MalformedRejected int64
166+ SniffTimeout int64
167+ ClientHelloTooLarge int64
147 }168 }
148 169
149 type TrafficServiceRefKind string170 type TrafficServiceRefKind string
@@ -330,26 +351,28 @@ func (g TrafficGraph) validateRouters(entryPoints map[string]EntryPoint, service
330 return err351 return err
331 }352 }
332 }353 }
333- return nil354+ return validateRawFallbackRouters(entryPoints, state.routersByName)
334 }355 }
335 356
336 type routerValidationState struct {357 type routerValidationState struct {
337- routerNames map[string]struct{}358+ routerNames map[string]struct{}
338- exactSNI map[string]map[string]string359+ routersByName map[string]TrafficRouter
339- wildSNI map[string]map[string]string360+ exactSNI map[string]map[string]string
340- httpHosts map[string]map[string]string361+ wildSNI map[string]map[string]string
341- tcpRouters map[string]string362+ httpHosts map[string]map[string]string
342- udpRouters map[string]string363+ tcpRouters map[string]string
364+ udpRouters map[string]string
343 }365 }
344 366
345 func newRouterValidationState() *routerValidationState {367 func newRouterValidationState() *routerValidationState {
346 return &routerValidationState{368 return &routerValidationState{
347- routerNames: map[string]struct{}{},369+ routerNames: map[string]struct{}{},
348- exactSNI: map[string]map[string]string{},370+ routersByName: map[string]TrafficRouter{},
349- wildSNI: map[string]map[string]string{},371+ exactSNI: map[string]map[string]string{},
350- httpHosts: map[string]map[string]string{},372+ wildSNI: map[string]map[string]string{},
351- tcpRouters: map[string]string{},373+ httpHosts: map[string]map[string]string{},
352- udpRouters: map[string]string{},374+ tcpRouters: map[string]string{},
375+ udpRouters: map[string]string{},
353 }376 }
354 }377 }
355 378
@@ -381,6 +404,7 @@ func (s *routerValidationState) validateRouterName(router TrafficRouter) error {
381 return fmt.Errorf("duplicate traffic router name %q", router.Name)404 return fmt.Errorf("duplicate traffic router name %q", router.Name)
382 }405 }
383 s.routerNames[router.Name] = struct{}{}406 s.routerNames[router.Name] = struct{}{}
407+ s.routersByName[router.Name] = router
384 return nil408 return nil
385 }409 }
386 410
@@ -492,11 +516,11 @@ func validateRouterEntryPointProtocol(router TrafficRouter, entryPoint EntryPoin
492 compatible := false516 compatible := false
493 switch router.Protocol {517 switch router.Protocol {
494 case RouterProtocolHTTP:518 case RouterProtocolHTTP:
495- compatible = entryPoint.Protocol == EntryPointProtocolHTTP || entryPoint.Protocol == EntryPointProtocolTLSMux519+ compatible = entryPoint.Protocol == EntryPointProtocolHTTP || entryPoint.Protocol == EntryPointProtocolTLSMux || entryPoint.Protocol == EntryPointProtocolSmartTCP
496 case RouterProtocolTLSPassthrough:520 case RouterProtocolTLSPassthrough:
497- compatible = entryPoint.Protocol == EntryPointProtocolTLSMux521+ compatible = entryPoint.Protocol == EntryPointProtocolTLSMux || entryPoint.Protocol == EntryPointProtocolSmartTCP
498 case RouterProtocolTCP:522 case RouterProtocolTCP:
499- compatible = entryPoint.Protocol == EntryPointProtocolTCP523+ compatible = entryPoint.Protocol == EntryPointProtocolTCP || (entryPoint.Protocol == EntryPointProtocolSmartTCP && entryPoint.RawFallback == router.Name)
500 case RouterProtocolUDP:524 case RouterProtocolUDP:
501 compatible = entryPoint.Protocol == EntryPointProtocolUDP525 compatible = entryPoint.Protocol == EntryPointProtocolUDP
502 }526 }
@@ -561,12 +585,39 @@ func validateTrustedCIDRs(entryPoint EntryPoint) error {
561 return fmt.Errorf("invalid trusted_cidrs entry %q for entrypoint %q: %w", cidr, entryPoint.Name, err)585 return fmt.Errorf("invalid trusted_cidrs entry %q for entrypoint %q: %w", cidr, entryPoint.Name, err)
562 }586 }
563 }587 }
588+ for _, cidr := range entryPoint.RawFallbackTrustedCIDRs {
589+ if _, _, err := net.ParseCIDR(strings.TrimSpace(cidr)); err != nil {
590+ return fmt.Errorf("invalid raw_fallback_trusted_cidrs entry %q for entrypoint %q: %w", cidr, entryPoint.Name, err)
591+ }
592+ }
593+ if entryPoint.RawFallback != "" && len(entryPoint.RawFallbackTrustedCIDRs) == 0 && !entryPoint.AllowPublicRawFallback {
594+ return fmt.Errorf("raw fallback %q on entrypoint %q requires raw_fallback_trusted_cidrs or allow_public_raw_fallback", entryPoint.RawFallback, entryPoint.Name)
595+ }
596+ return nil
597+}
598+
599+func validateRawFallbackRouters(entryPoints map[string]EntryPoint, routers map[string]TrafficRouter) error {
600+ for _, entryPoint := range entryPoints {
601+ if entryPoint.RawFallback == "" {
602+ continue
603+ }
604+ router, ok := routers[entryPoint.RawFallback]
605+ if !ok {
606+ return fmt.Errorf("raw fallback %q on entrypoint %q references unknown router", entryPoint.RawFallback, entryPoint.Name)
607+ }
608+ if router.EntryPoint != entryPoint.Name {
609+ return fmt.Errorf("raw fallback %q on entrypoint %q references router on entrypoint %q", entryPoint.RawFallback, entryPoint.Name, router.EntryPoint)
610+ }
611+ if router.Protocol != RouterProtocolTCP {
612+ return fmt.Errorf("raw fallback %q on entrypoint %q must reference tcp router", entryPoint.RawFallback, entryPoint.Name)
613+ }
614+ }
564 return nil615 return nil
565 }616 }
566 617
567 func validateEntryPointProtocol(protocol EntryPointProtocol) error {618 func validateEntryPointProtocol(protocol EntryPointProtocol) error {
568 switch protocol {619 switch protocol {
569- case EntryPointProtocolHTTP, EntryPointProtocolTLSMux, EntryPointProtocolTCP, EntryPointProtocolUDP:620+ case EntryPointProtocolHTTP, EntryPointProtocolTLSMux, EntryPointProtocolSmartTCP, EntryPointProtocolTCP, EntryPointProtocolUDP:
570 return nil621 return nil
571 default:622 default:
572 return fmt.Errorf("%q", protocol)623 return fmt.Errorf("%q", protocol)
M internal/domain/traffic_test.go +86 -0
@@ -7,6 +7,92 @@ import (
77 "github.com/stretchr/testify/require"
88 )
99
10+func TestTrafficGraphSmartTCPAllowsHTTPTLSPassthroughAndExplicitRawFallback(t *testing.T) {
11+ graph := TrafficGraph{
12+ EntryPoints: []EntryPoint{{
13+ Name: "edge",
14+ Address: ":443",
15+ Protocol: EntryPointProtocolSmartTCP,
16+ RawFallback: "ssh-fallback",
17+ RawFallbackTrustedCIDRs: []string{"100.64.0.0/10"},
18+ }},
19+ Routers: []TrafficRouter{
20+ {Name: "route:app.example.com", EntryPoint: "edge", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
21+ {Name: "raw-tls", EntryPoint: "edge", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "raw.example.com"}, Service: "network_service:raw:tls"},
22+ {Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"},
23+ },
24+ Services: []TrafficService{
25+ {Name: "route:app.example.com"},
26+ {Name: "network_service:raw:tls", Backends: []TrafficBackend{{Name: "raw:tls", Host: "raw", Port: 443, Protocol: NetworkProtocolTCP}}},
27+ {Name: "network_service:ssh:ssh", Backends: []TrafficBackend{{Name: "ssh:ssh", Host: "ssh", Port: 22, Protocol: NetworkProtocolTCP}}},
28+ },
29+ }
30+ require.NoError(t, graph.Validate())
31+}
32+
33+func TestTrafficGraphSmartTCPRawFallbackValidation(t *testing.T) {
34+ baseServices := []TrafficService{
35+ {Name: "route:app.example.com"},
36+ {Name: "network_service:ssh:ssh", Backends: []TrafficBackend{{Name: "ssh:ssh", Host: "ssh", Port: 22, Protocol: NetworkProtocolTCP}}},
37+ }
38+
39+ tests := []struct {
40+ name string
41+ entryPoint EntryPoint
42+ routers []TrafficRouter
43+ wantErr string
44+ }{
45+ {
46+ name: "public raw fallback rejected by default",
47+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallback: "ssh-fallback"},
48+ routers: []TrafficRouter{{Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"}},
49+ wantErr: "raw fallback",
50+ },
51+ {
52+ name: "missing raw fallback router rejected",
53+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallback: "missing", RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
54+ routers: nil,
55+ wantErr: "raw fallback",
56+ },
57+ {
58+ name: "raw fallback must reference tcp router",
59+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallback: "route:app.example.com", RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
60+ routers: []TrafficRouter{
61+ {Name: "route:app.example.com", EntryPoint: "edge", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
62+ },
63+ wantErr: "raw fallback",
64+ },
65+ {
66+ name: "invalid raw fallback trusted cidr rejected",
67+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallback: "ssh-fallback", RawFallbackTrustedCIDRs: []string{"not-a-cidr"}},
68+ routers: []TrafficRouter{{Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"}},
69+ wantErr: "invalid raw_fallback_trusted_cidrs",
70+ },
71+ }
72+
73+ for _, tt := range tests {
74+ t.Run(tt.name, func(t *testing.T) {
75+ graph := TrafficGraph{EntryPoints: []EntryPoint{tt.entryPoint}, Routers: tt.routers, Services: baseServices}
76+ require.ErrorContains(t, graph.Validate(), tt.wantErr)
77+ })
78+ }
79+}
80+
81+func TestTrafficGraphSmartTCPRejectsHTTPHostAndTLSSNIConflict(t *testing.T) {
82+ graph := TrafficGraph{
83+ EntryPoints: []EntryPoint{{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP}},
84+ Routers: []TrafficRouter{
85+ {Name: "route:app.example.com", EntryPoint: "edge", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
86+ {Name: "raw-tls", EntryPoint: "edge", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "app.example.com"}, Service: "network_service:raw:tls"},
87+ },
88+ Services: []TrafficService{
89+ {Name: "route:app.example.com"},
90+ {Name: "network_service:raw:tls", Backends: []TrafficBackend{{Name: "raw:tls", Host: "raw", Port: 443, Protocol: NetworkProtocolTCP}}},
91+ },
92+ }
93+ require.ErrorContains(t, graph.Validate(), "http host conflicts with tls passthrough sni")
94+}
95+
1096 func TestTrafficGraphValidateEntryPoints(t *testing.T) {
1197 tests := []struct {
1298 name string
@@ -7,6 +7,92 @@ import (
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 )8 )
9 9
10+func TestTrafficGraphSmartTCPAllowsHTTPTLSPassthroughAndExplicitRawFallback(t *testing.T) {
11+ graph := TrafficGraph{
12+ EntryPoints: []EntryPoint{{
13+ Name: "edge",
14+ Address: ":443",
15+ Protocol: EntryPointProtocolSmartTCP,
16+ RawFallback: "ssh-fallback",
17+ RawFallbackTrustedCIDRs: []string{"100.64.0.0/10"},
18+ }},
19+ Routers: []TrafficRouter{
20+ {Name: "route:app.example.com", EntryPoint: "edge", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
21+ {Name: "raw-tls", EntryPoint: "edge", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "raw.example.com"}, Service: "network_service:raw:tls"},
22+ {Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"},
23+ },
24+ Services: []TrafficService{
25+ {Name: "route:app.example.com"},
26+ {Name: "network_service:raw:tls", Backends: []TrafficBackend{{Name: "raw:tls", Host: "raw", Port: 443, Protocol: NetworkProtocolTCP}}},
27+ {Name: "network_service:ssh:ssh", Backends: []TrafficBackend{{Name: "ssh:ssh", Host: "ssh", Port: 22, Protocol: NetworkProtocolTCP}}},
28+ },
29+ }
30+ require.NoError(t, graph.Validate())
31+}
32+
33+func TestTrafficGraphSmartTCPRawFallbackValidation(t *testing.T) {
34+ baseServices := []TrafficService{
35+ {Name: "route:app.example.com"},
36+ {Name: "network_service:ssh:ssh", Backends: []TrafficBackend{{Name: "ssh:ssh", Host: "ssh", Port: 22, Protocol: NetworkProtocolTCP}}},
37+ }
38+
39+ tests := []struct {
40+ name string
41+ entryPoint EntryPoint
42+ routers []TrafficRouter
43+ wantErr string
44+ }{
45+ {
46+ name: "public raw fallback rejected by default",
47+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallback: "ssh-fallback"},
48+ routers: []TrafficRouter{{Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"}},
49+ wantErr: "raw fallback",
50+ },
51+ {
52+ name: "missing raw fallback router rejected",
53+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallback: "missing", RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
54+ routers: nil,
55+ wantErr: "raw fallback",
56+ },
57+ {
58+ name: "raw fallback must reference tcp router",
59+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallback: "route:app.example.com", RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
60+ routers: []TrafficRouter{
61+ {Name: "route:app.example.com", EntryPoint: "edge", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
62+ },
63+ wantErr: "raw fallback",
64+ },
65+ {
66+ name: "invalid raw fallback trusted cidr rejected",
67+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallback: "ssh-fallback", RawFallbackTrustedCIDRs: []string{"not-a-cidr"}},
68+ routers: []TrafficRouter{{Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"}},
69+ wantErr: "invalid raw_fallback_trusted_cidrs",
70+ },
71+ }
72+
73+ for _, tt := range tests {
74+ t.Run(tt.name, func(t *testing.T) {
75+ graph := TrafficGraph{EntryPoints: []EntryPoint{tt.entryPoint}, Routers: tt.routers, Services: baseServices}
76+ require.ErrorContains(t, graph.Validate(), tt.wantErr)
77+ })
78+ }
79+}
80+
81+func TestTrafficGraphSmartTCPRejectsHTTPHostAndTLSSNIConflict(t *testing.T) {
82+ graph := TrafficGraph{
83+ EntryPoints: []EntryPoint{{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP}},
84+ Routers: []TrafficRouter{
85+ {Name: "route:app.example.com", EntryPoint: "edge", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
86+ {Name: "raw-tls", EntryPoint: "edge", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "app.example.com"}, Service: "network_service:raw:tls"},
87+ },
88+ Services: []TrafficService{
89+ {Name: "route:app.example.com"},
90+ {Name: "network_service:raw:tls", Backends: []TrafficBackend{{Name: "raw:tls", Host: "raw", Port: 443, Protocol: NetworkProtocolTCP}}},
91+ },
92+ }
93+ require.ErrorContains(t, graph.Validate(), "http host conflicts with tls passthrough sni")
94+}
95+
10 func TestTrafficGraphValidateEntryPoints(t *testing.T) {96 func TestTrafficGraphValidateEntryPoints(t *testing.T) {
11 tests := []struct {97 tests := []struct {
12 name string98 name string
M internal/usecase/traffic/builder.go +19 -30
@@ -11,14 +11,10 @@ import (
1111 "github.com/bnema/gordon/internal/domain"
1212 )
1313
14-const (
15- DefaultHTTPEntryPointName = "web"
16- DefaultTLSEntryPointName = "websecure"
17-)
14+const DefaultEdgeEntryPointName = "edge"
1815
1916 // Input is the complete, usecase-level input used to build a validated traffic graph.
2017 type Input struct {
21- Server ServerConfig
2218 EntryPoints map[string]EntryPointConfig
2319 Traffic Config
2420 Routes []domain.Route
@@ -26,15 +22,13 @@ type Input struct {
2622 NetworkServices []NetworkServiceConfig
2723 }
2824
29-type ServerConfig struct {
30- Port int `mapstructure:"port"`
31- TLSPort int `mapstructure:"tls_port"`
32-}
33-
3425 type EntryPointConfig struct {
35- Address string `mapstructure:"address"`
36- Protocol domain.EntryPointProtocol `mapstructure:"protocol"`
37- TrustedCIDRs []string `mapstructure:"trusted_cidrs"`
26+ Address string `mapstructure:"address"`
27+ Protocol domain.EntryPointProtocol `mapstructure:"protocol"`
28+ TrustedCIDRs []string `mapstructure:"trusted_cidrs"`
29+ RawFallback string `mapstructure:"raw_fallback"`
30+ RawFallbackTrustedCIDRs []string `mapstructure:"raw_fallback_trusted_cidrs"`
31+ AllowPublicRawFallback bool `mapstructure:"allow_public_raw_fallback"`
3832 }
3933
4034 // Config holds traffic router and option configuration.
@@ -125,16 +119,18 @@ type builder struct {
125119 }
126120
127121 func (b *builder) buildEntryPoints() []domain.EntryPoint {
128- entries := make([]domain.EntryPoint, 0, len(b.input.EntryPoints)+2)
129- if b.input.Server.Port != 0 {
130- entries = append(entries, domain.EntryPoint{Name: DefaultHTTPEntryPointName, Address: portAddress(b.input.Server.Port), Protocol: domain.EntryPointProtocolHTTP})
131- }
132- if b.input.Server.TLSPort != 0 {
133- entries = append(entries, domain.EntryPoint{Name: DefaultTLSEntryPointName, Address: portAddress(b.input.Server.TLSPort), Protocol: domain.EntryPointProtocolTLSMux})
134- }
122+ entries := make([]domain.EntryPoint, 0, len(b.input.EntryPoints))
135123 for _, name := range sortedKeys(b.input.EntryPoints) {
136124 cfg := b.input.EntryPoints[name]
137- entries = append(entries, domain.EntryPoint{Name: name, Address: cfg.Address, Protocol: cfg.Protocol, TrustedCIDRs: append([]string{}, cfg.TrustedCIDRs...)})
125+ entries = append(entries, domain.EntryPoint{
126+ Name: name,
127+ Address: cfg.Address,
128+ Protocol: cfg.Protocol,
129+ TrustedCIDRs: append([]string(nil), cfg.TrustedCIDRs...),
130+ RawFallback: cfg.RawFallback,
131+ RawFallbackTrustedCIDRs: append([]string(nil), cfg.RawFallbackTrustedCIDRs...),
132+ AllowPublicRawFallback: cfg.AllowPublicRawFallback,
133+ })
138134 }
139135 return entries
140136 }
@@ -143,10 +139,7 @@ func (b *builder) addHTTPRoutes(graph *domain.TrafficGraph) error {
143139 routes := append([]domain.Route{}, b.input.Routes...)
144140 sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain })
145141 for _, route := range routes {
146- entryPoint := DefaultHTTPEntryPointName
147- if route.HTTPS {
148- entryPoint = DefaultTLSEntryPointName
149- }
142+ entryPoint := DefaultEdgeEntryPointName
150143 serviceName := string(domain.TrafficServiceRefRoute) + ":" + route.Domain
151144 b.addService(domain.TrafficService{Name: serviceName})
152145 graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: "route:" + route.Domain, EntryPoint: entryPoint, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: route.Domain}, Service: serviceName})
@@ -163,7 +156,7 @@ func (b *builder) addExternalRoutes(graph *domain.TrafficGraph) error {
163156 }
164157 serviceName := string(domain.TrafficServiceRefExternalRoute) + ":" + hostName
165158 b.addService(domain.TrafficService{Name: serviceName, Backends: []domain.TrafficBackend{{Name: hostName, Host: host, Port: port, Protocol: domain.NetworkProtocolTCP}}})
166- graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: "external_route:" + hostName, EntryPoint: DefaultHTTPEntryPointName, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: hostName}, Service: serviceName})
159+ graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: "external_route:" + hostName, EntryPoint: DefaultEdgeEntryPointName, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: hostName}, Service: serviceName})
167160 }
168161 return nil
169162 }
@@ -355,10 +348,6 @@ func parseBackendAddress(value string) (string, int, error) {
355348 return host, port, nil
356349 }
357350
358-func portAddress(port int) string {
359- return ":" + strconv.Itoa(port)
360-}
361-
362351 func sortedKeys[V any](m map[string]V) []string {
363352 keys := make([]string, 0, len(m))
364353 for k := range m {
@@ -11,14 +11,10 @@ import (
11 "github.com/bnema/gordon/internal/domain"11 "github.com/bnema/gordon/internal/domain"
12 )12 )
13 13
14-const (14+const DefaultEdgeEntryPointName = "edge"
15- DefaultHTTPEntryPointName = "web"
16- DefaultTLSEntryPointName = "websecure"
17-)
18 15
19 // Input is the complete, usecase-level input used to build a validated traffic graph.16 // Input is the complete, usecase-level input used to build a validated traffic graph.
20 type Input struct {17 type Input struct {
21- Server ServerConfig
22 EntryPoints map[string]EntryPointConfig18 EntryPoints map[string]EntryPointConfig
23 Traffic Config19 Traffic Config
24 Routes []domain.Route20 Routes []domain.Route
@@ -26,15 +22,13 @@ type Input struct {
26 NetworkServices []NetworkServiceConfig22 NetworkServices []NetworkServiceConfig
27 }23 }
28 24
29-type ServerConfig struct {
30- Port int `mapstructure:"port"`
31- TLSPort int `mapstructure:"tls_port"`
32-}
33-
34 type EntryPointConfig struct {25 type EntryPointConfig struct {
35- Address string `mapstructure:"address"`26+ Address string `mapstructure:"address"`
36- Protocol domain.EntryPointProtocol `mapstructure:"protocol"`27+ Protocol domain.EntryPointProtocol `mapstructure:"protocol"`
37- TrustedCIDRs []string `mapstructure:"trusted_cidrs"`28+ TrustedCIDRs []string `mapstructure:"trusted_cidrs"`
29+ RawFallback string `mapstructure:"raw_fallback"`
30+ RawFallbackTrustedCIDRs []string `mapstructure:"raw_fallback_trusted_cidrs"`
31+ AllowPublicRawFallback bool `mapstructure:"allow_public_raw_fallback"`
38 }32 }
39 33
40 // Config holds traffic router and option configuration.34 // Config holds traffic router and option configuration.
@@ -125,16 +119,18 @@ type builder struct {
125 }119 }
126 120
127 func (b *builder) buildEntryPoints() []domain.EntryPoint {121 func (b *builder) buildEntryPoints() []domain.EntryPoint {
128- entries := make([]domain.EntryPoint, 0, len(b.input.EntryPoints)+2)122+ entries := make([]domain.EntryPoint, 0, len(b.input.EntryPoints))
129- if b.input.Server.Port != 0 {
130- entries = append(entries, domain.EntryPoint{Name: DefaultHTTPEntryPointName, Address: portAddress(b.input.Server.Port), Protocol: domain.EntryPointProtocolHTTP})
131- }
132- if b.input.Server.TLSPort != 0 {
133- entries = append(entries, domain.EntryPoint{Name: DefaultTLSEntryPointName, Address: portAddress(b.input.Server.TLSPort), Protocol: domain.EntryPointProtocolTLSMux})
134- }
135 for _, name := range sortedKeys(b.input.EntryPoints) {123 for _, name := range sortedKeys(b.input.EntryPoints) {
136 cfg := b.input.EntryPoints[name]124 cfg := b.input.EntryPoints[name]
137- entries = append(entries, domain.EntryPoint{Name: name, Address: cfg.Address, Protocol: cfg.Protocol, TrustedCIDRs: append([]string{}, cfg.TrustedCIDRs...)})125+ entries = append(entries, domain.EntryPoint{
126+ Name: name,
127+ Address: cfg.Address,
128+ Protocol: cfg.Protocol,
129+ TrustedCIDRs: append([]string(nil), cfg.TrustedCIDRs...),
130+ RawFallback: cfg.RawFallback,
131+ RawFallbackTrustedCIDRs: append([]string(nil), cfg.RawFallbackTrustedCIDRs...),
132+ AllowPublicRawFallback: cfg.AllowPublicRawFallback,
133+ })
138 }134 }
139 return entries135 return entries
140 }136 }
@@ -143,10 +139,7 @@ func (b *builder) addHTTPRoutes(graph *domain.TrafficGraph) error {
143 routes := append([]domain.Route{}, b.input.Routes...)139 routes := append([]domain.Route{}, b.input.Routes...)
144 sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain })140 sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain })
145 for _, route := range routes {141 for _, route := range routes {
146- entryPoint := DefaultHTTPEntryPointName142+ entryPoint := DefaultEdgeEntryPointName
147- if route.HTTPS {
148- entryPoint = DefaultTLSEntryPointName
149- }
150 serviceName := string(domain.TrafficServiceRefRoute) + ":" + route.Domain143 serviceName := string(domain.TrafficServiceRefRoute) + ":" + route.Domain
151 b.addService(domain.TrafficService{Name: serviceName})144 b.addService(domain.TrafficService{Name: serviceName})
152 graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: "route:" + route.Domain, EntryPoint: entryPoint, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: route.Domain}, Service: serviceName})145 graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: "route:" + route.Domain, EntryPoint: entryPoint, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: route.Domain}, Service: serviceName})
@@ -163,7 +156,7 @@ func (b *builder) addExternalRoutes(graph *domain.TrafficGraph) error {
163 }156 }
164 serviceName := string(domain.TrafficServiceRefExternalRoute) + ":" + hostName157 serviceName := string(domain.TrafficServiceRefExternalRoute) + ":" + hostName
165 b.addService(domain.TrafficService{Name: serviceName, Backends: []domain.TrafficBackend{{Name: hostName, Host: host, Port: port, Protocol: domain.NetworkProtocolTCP}}})158 b.addService(domain.TrafficService{Name: serviceName, Backends: []domain.TrafficBackend{{Name: hostName, Host: host, Port: port, Protocol: domain.NetworkProtocolTCP}}})
166- graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: "external_route:" + hostName, EntryPoint: DefaultHTTPEntryPointName, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: hostName}, Service: serviceName})159+ graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: "external_route:" + hostName, EntryPoint: DefaultEdgeEntryPointName, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: hostName}, Service: serviceName})
167 }160 }
168 return nil161 return nil
169 }162 }
@@ -355,10 +348,6 @@ func parseBackendAddress(value string) (string, int, error) {
355 return host, port, nil348 return host, port, nil
356 }349 }
357 350
358-func portAddress(port int) string {
359- return ":" + strconv.Itoa(port)
360-}
361-
362 func sortedKeys[V any](m map[string]V) []string {351 func sortedKeys[V any](m map[string]V) []string {
363 keys := make([]string, 0, len(m))352 keys := make([]string, 0, len(m))
364 for k := range m {353 for k := range m {
M internal/usecase/traffic/builder_test.go +44 -7
@@ -9,18 +9,55 @@ import (
99 "github.com/bnema/gordon/internal/domain"
1010 )
1111
12+func TestBuildSmartTCPRoutesDefaultToEdge(t *testing.T) {
13+ graph, err := Build(Input{
14+ EntryPoints: map[string]EntryPointConfig{"edge": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}},
15+ Routes: []domain.Route{{Domain: "app.example.com"}},
16+ })
17+ require.NoError(t, err)
18+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "route:app.example.com", EntryPoint: "edge", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"})
19+}
20+
21+func TestBuildRawFallbackConfigMapsToEntryPoint(t *testing.T) {
22+ graph, err := Build(Input{
23+ EntryPoints: map[string]EntryPointConfig{"edge": {
24+ Address: ":443",
25+ Protocol: domain.EntryPointProtocolSmartTCP,
26+ RawFallback: "ssh-fallback",
27+ RawFallbackTrustedCIDRs: []string{"100.64.0.0/10"},
28+ AllowPublicRawFallback: true,
29+ }},
30+ Traffic: Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "ssh-fallback", EntryPoint: "edge", Service: "network_service:ssh:ssh"}}}},
31+ NetworkServices: []NetworkServiceConfig{{Name: "ssh", Ports: []PortConfig{{Name: "ssh", Container: 22, Protocol: domain.NetworkProtocolTCP}}}},
32+ })
33+ require.NoError(t, err)
34+ require.Equal(t, []domain.EntryPoint{{
35+ Name: "edge",
36+ Address: ":443",
37+ Protocol: domain.EntryPointProtocolSmartTCP,
38+ RawFallback: "ssh-fallback",
39+ RawFallbackTrustedCIDRs: []string{"100.64.0.0/10"},
40+ AllowPublicRawFallback: true,
41+ }}, graph.EntryPoints)
42+}
43+
44+func TestBuildSmartTCPRoutesRequireCompatibleEdge(t *testing.T) {
45+ _, err := Build(Input{Routes: []domain.Route{{Domain: "app.example.com"}}})
46+ require.ErrorContains(t, err, "entrypoint")
47+}
48+
1249 func TestBuildDefaultsAndHTTPRoutes(t *testing.T) {
1350 graph, err := Build(Input{
14- Server: ServerConfig{Port: 80, TLSPort: 443},
51+ EntryPoints: map[string]EntryPointConfig{"edge": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}},
1552 Routes: []domain.Route{{Domain: "app.example.com"}, {Domain: "secure.example.com", HTTPS: true}},
1653 ExternalRoutes: map[string]string{"ext.example.com": "backend.local:8080"},
1754 })
1855 require.NoError(t, err)
1956
20- require.Contains(t, graph.EntryPoints, domain.EntryPoint{Name: "web", Address: ":80", Protocol: domain.EntryPointProtocolHTTP})
21- require.Contains(t, graph.EntryPoints, domain.EntryPoint{Name: "websecure", Address: ":443", Protocol: domain.EntryPointProtocolTLSMux})
22- require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "route:app.example.com", EntryPoint: "web", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"})
23- require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "route:secure.example.com", EntryPoint: "websecure", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "secure.example.com"}, Service: "route:secure.example.com"})
57+ require.Equal(t, []domain.EntryPoint{{Name: "edge", Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}}, graph.EntryPoints)
58+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "route:app.example.com", EntryPoint: "edge", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"})
59+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "route:secure.example.com", EntryPoint: "edge", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "secure.example.com"}, Service: "route:secure.example.com"})
60+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "external_route:ext.example.com", EntryPoint: "edge", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "ext.example.com"}, Service: "external_route:ext.example.com"})
2461 require.Contains(t, graph.Services, domain.TrafficService{Name: "external_route:ext.example.com", Backends: []domain.TrafficBackend{{Name: "ext.example.com", Host: "backend.local", Port: 8080, Protocol: domain.NetworkProtocolTCP}}})
2562 }
2663
@@ -64,9 +101,9 @@ func TestBuildRejectsStaticServiceRef(t *testing.T) {
64101
65102 func TestBuildRejectsTLSHTTPRouteConflict(t *testing.T) {
66103 _, err := Build(Input{
67- Server: ServerConfig{TLSPort: 443},
104+ EntryPoints: map[string]EntryPointConfig{"edge": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}},
68105 Routes: []domain.Route{{Domain: "app.example.com", HTTPS: true}},
69- Traffic: Config{TLS: TLSConfig{Routers: []RouterConfig{{Name: "passthrough", EntryPoint: "websecure", SNI: "app.example.com", Service: "network_service:app:https"}}}},
106+ Traffic: Config{TLS: TLSConfig{Routers: []RouterConfig{{Name: "passthrough", EntryPoint: "edge", SNI: "app.example.com", Service: "network_service:app:https"}}}},
70107 NetworkServices: []NetworkServiceConfig{{Name: "app", Ports: []PortConfig{{Name: "https", Container: 443, Protocol: domain.NetworkProtocolTCP}}}},
71108 })
72109 require.ErrorContains(t, err, "http host conflicts")
@@ -9,18 +9,55 @@ import (
9 "github.com/bnema/gordon/internal/domain"9 "github.com/bnema/gordon/internal/domain"
10 )10 )
11 11
12+func TestBuildSmartTCPRoutesDefaultToEdge(t *testing.T) {
13+ graph, err := Build(Input{
14+ EntryPoints: map[string]EntryPointConfig{"edge": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}},
15+ Routes: []domain.Route{{Domain: "app.example.com"}},
16+ })
17+ require.NoError(t, err)
18+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "route:app.example.com", EntryPoint: "edge", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"})
19+}
20+
21+func TestBuildRawFallbackConfigMapsToEntryPoint(t *testing.T) {
22+ graph, err := Build(Input{
23+ EntryPoints: map[string]EntryPointConfig{"edge": {
24+ Address: ":443",
25+ Protocol: domain.EntryPointProtocolSmartTCP,
26+ RawFallback: "ssh-fallback",
27+ RawFallbackTrustedCIDRs: []string{"100.64.0.0/10"},
28+ AllowPublicRawFallback: true,
29+ }},
30+ Traffic: Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "ssh-fallback", EntryPoint: "edge", Service: "network_service:ssh:ssh"}}}},
31+ NetworkServices: []NetworkServiceConfig{{Name: "ssh", Ports: []PortConfig{{Name: "ssh", Container: 22, Protocol: domain.NetworkProtocolTCP}}}},
32+ })
33+ require.NoError(t, err)
34+ require.Equal(t, []domain.EntryPoint{{
35+ Name: "edge",
36+ Address: ":443",
37+ Protocol: domain.EntryPointProtocolSmartTCP,
38+ RawFallback: "ssh-fallback",
39+ RawFallbackTrustedCIDRs: []string{"100.64.0.0/10"},
40+ AllowPublicRawFallback: true,
41+ }}, graph.EntryPoints)
42+}
43+
44+func TestBuildSmartTCPRoutesRequireCompatibleEdge(t *testing.T) {
45+ _, err := Build(Input{Routes: []domain.Route{{Domain: "app.example.com"}}})
46+ require.ErrorContains(t, err, "entrypoint")
47+}
48+
12 func TestBuildDefaultsAndHTTPRoutes(t *testing.T) {49 func TestBuildDefaultsAndHTTPRoutes(t *testing.T) {
13 graph, err := Build(Input{50 graph, err := Build(Input{
14- Server: ServerConfig{Port: 80, TLSPort: 443},51+ EntryPoints: map[string]EntryPointConfig{"edge": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}},
15 Routes: []domain.Route{{Domain: "app.example.com"}, {Domain: "secure.example.com", HTTPS: true}},52 Routes: []domain.Route{{Domain: "app.example.com"}, {Domain: "secure.example.com", HTTPS: true}},
16 ExternalRoutes: map[string]string{"ext.example.com": "backend.local:8080"},53 ExternalRoutes: map[string]string{"ext.example.com": "backend.local:8080"},
17 })54 })
18 require.NoError(t, err)55 require.NoError(t, err)
19 56
20- require.Contains(t, graph.EntryPoints, domain.EntryPoint{Name: "web", Address: ":80", Protocol: domain.EntryPointProtocolHTTP})57+ require.Equal(t, []domain.EntryPoint{{Name: "edge", Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}}, graph.EntryPoints)
21- require.Contains(t, graph.EntryPoints, domain.EntryPoint{Name: "websecure", Address: ":443", Protocol: domain.EntryPointProtocolTLSMux})58+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "route:app.example.com", EntryPoint: "edge", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"})
22- require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "route:app.example.com", EntryPoint: "web", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"})59+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "route:secure.example.com", EntryPoint: "edge", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "secure.example.com"}, Service: "route:secure.example.com"})
23- require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "route:secure.example.com", EntryPoint: "websecure", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "secure.example.com"}, Service: "route:secure.example.com"})60+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "external_route:ext.example.com", EntryPoint: "edge", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "ext.example.com"}, Service: "external_route:ext.example.com"})
24 require.Contains(t, graph.Services, domain.TrafficService{Name: "external_route:ext.example.com", Backends: []domain.TrafficBackend{{Name: "ext.example.com", Host: "backend.local", Port: 8080, Protocol: domain.NetworkProtocolTCP}}})61 require.Contains(t, graph.Services, domain.TrafficService{Name: "external_route:ext.example.com", Backends: []domain.TrafficBackend{{Name: "ext.example.com", Host: "backend.local", Port: 8080, Protocol: domain.NetworkProtocolTCP}}})
25 }62 }
26 63
@@ -64,9 +101,9 @@ func TestBuildRejectsStaticServiceRef(t *testing.T) {
64 101
65 func TestBuildRejectsTLSHTTPRouteConflict(t *testing.T) {102 func TestBuildRejectsTLSHTTPRouteConflict(t *testing.T) {
66 _, err := Build(Input{103 _, err := Build(Input{
67- Server: ServerConfig{TLSPort: 443},104+ EntryPoints: map[string]EntryPointConfig{"edge": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}},
68 Routes: []domain.Route{{Domain: "app.example.com", HTTPS: true}},105 Routes: []domain.Route{{Domain: "app.example.com", HTTPS: true}},
69- Traffic: Config{TLS: TLSConfig{Routers: []RouterConfig{{Name: "passthrough", EntryPoint: "websecure", SNI: "app.example.com", Service: "network_service:app:https"}}}},106+ Traffic: Config{TLS: TLSConfig{Routers: []RouterConfig{{Name: "passthrough", EntryPoint: "edge", SNI: "app.example.com", Service: "network_service:app:https"}}}},
70 NetworkServices: []NetworkServiceConfig{{Name: "app", Ports: []PortConfig{{Name: "https", Container: 443, Protocol: domain.NetworkProtocolTCP}}}},107 NetworkServices: []NetworkServiceConfig{{Name: "app", Ports: []PortConfig{{Name: "https", Container: 443, Protocol: domain.NetworkProtocolTCP}}}},
71 })108 })
72 require.ErrorContains(t, err, "http host conflicts")109 require.ErrorContains(t, err, "http host conflicts")

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