feat(traffic): add smart tcp entrypoints
- commit
- 3515791ae9810c30792db975d70afcadefd14508
- author
- brice · · 2026-06-30 13:27 UTC
- parent
- cd72440
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
18 files · +2430 -217
M go.mod +1 -1
| @@ -21,6 +21,7 @@ require ( | |||
| 21 | 21 | github.com/mattn/go-runewidth v0.0.23 | |
| 22 | 22 | github.com/muesli/termenv v0.16.0 | |
| 23 | 23 | github.com/rivo/uniseg v0.4.7 | |
| 24 | + | github.com/rs/zerolog v1.35.0 | |
| 24 | 25 | github.com/smallstep/truststore v0.13.0 | |
| 25 | 26 | github.com/spf13/cobra v1.10.2 | |
| 26 | 27 | github.com/spf13/viper v1.21.0 | |
| @@ -77,7 +78,6 @@ require ( | |||
| 77 | 78 | github.com/morikuni/aec v1.1.0 // indirect | |
| 78 | 79 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | |
| 79 | 80 | github.com/muesli/cancelreader v0.2.2 // indirect | |
| 80 | - | github.com/rs/zerolog v1.35.0 // indirect | |
| 81 | 81 | github.com/sirupsen/logrus v1.9.4 // indirect | |
| 82 | 82 | github.com/vbatts/tar-split v0.12.2 // indirect | |
| 83 | 83 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect | |
| @@ -21,6 +21,7 @@ require ( | |||||
| 21 | github.com/mattn/go-runewidth v0.0.23 | 21 | github.com/mattn/go-runewidth v0.0.23 | ||
| 22 | github.com/muesli/termenv v0.16.0 | 22 | github.com/muesli/termenv v0.16.0 | ||
| 23 | github.com/rivo/uniseg v0.4.7 | 23 | github.com/rivo/uniseg v0.4.7 | ||
| 24 | + | github.com/rs/zerolog v1.35.0 | |||
| 24 | github.com/smallstep/truststore v0.13.0 | 25 | github.com/smallstep/truststore v0.13.0 | ||
| 25 | github.com/spf13/cobra v1.10.2 | 26 | github.com/spf13/cobra v1.10.2 | ||
| 26 | github.com/spf13/viper v1.21.0 | 27 | github.com/spf13/viper v1.21.0 | ||
| @@ -77,7 +78,6 @@ require ( | |||||
| 77 | github.com/morikuni/aec v1.1.0 // indirect | 78 | github.com/morikuni/aec v1.1.0 // indirect | ||
| 78 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | 79 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | ||
| 79 | github.com/muesli/cancelreader v0.2.2 // indirect | 80 | 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 // indirect | 81 | github.com/sirupsen/logrus v1.9.4 // indirect | ||
| 82 | github.com/vbatts/tar-split v0.12.2 // indirect | 82 | github.com/vbatts/tar-split v0.12.2 // indirect | ||
| 83 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect | 83 | 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 { | |||
| 55 | 55 | lastReloadStatus string | |
| 56 | 56 | lastReloadError string | |
| 57 | 57 | tlsHTTPServers atomic.Value | |
| 58 | + | smartHTTPServers atomic.Value | |
| 59 | + | smartTLSServers atomic.Value | |
| 58 | 60 | } | |
| 59 | 61 | ||
| 60 | 62 | // NewManager creates a traffic manager. | |
| @@ -65,6 +67,8 @@ func NewManager() *Manager { | |||
| 65 | 67 | lastReloadStatus: reloadStatusOK, | |
| 66 | 68 | } | |
| 67 | 69 | manager.tlsHTTPServers.Store(tlsHTTPServers{}) | |
| 70 | + | manager.smartHTTPServers.Store(smartHTTPServers{}) | |
| 71 | + | manager.smartTLSServers.Store(smartTLSServers{}) | |
| 68 | 72 | 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 | 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 | 110 | for _, update := range udpUpdates { | |
| 107 | 111 | update.runtime.updateEntryPoint(update.entryPoint, update.trusted) | |
| @@ -252,6 +256,7 @@ type tcpRuntimeUpdate struct { | |||
| 252 | 256 | runtime *entryPointRuntime | |
| 253 | 257 | entryPoint domain.EntryPoint | |
| 254 | 258 | trusted []*net.IPNet | |
| 259 | + | rawTrusted []*net.IPNet | |
| 255 | 260 | } | |
| 256 | 261 | ||
| 257 | 262 | type udpRuntimeUpdate struct { | |
| @@ -282,8 +287,13 @@ func (m *Manager) prepareTCPListeners(ctx context.Context, graph *domain.Traffic | |||
| 282 | 287 | stopTCPRuntimes(ctx, created, effectiveTCPOptions(graph.Options.TCP).DrainTimeout) | |
| 283 | 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 | 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 | 297 | next[entryPoint.Name] = runtime | |
| 288 | 298 | delete(current, runtime.entryPointSnapshot().Name) | |
| 289 | 299 | continue | |
| @@ -312,7 +322,7 @@ func stopUDPRuntimes(ctx context.Context, runtimes []*udpEntryPointRuntime, drai | |||
| 312 | 322 | } | |
| 313 | 323 | ||
| 314 | 324 | 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 | |
| 316 | 326 | } | |
| 317 | 327 | ||
| 318 | 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 | 344 | _ = listener.Close() | |
| 335 | 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 | 351 | trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("bound tcp traffic entrypoint") | |
| 338 | 352 | runtime := newEntryPointRuntime(ctx, m, entryPoint, listener, trusted) | |
| 339 | 353 | return runtime, nil | |
| @@ -472,6 +486,7 @@ func entryPointStatuses(entries []domain.EntryPoint, listeners map[string]*entry | |||
| 472 | 486 | status.TotalErrors = counters.TotalErrors | |
| 473 | 487 | status.BytesIn = counters.BytesIn | |
| 474 | 488 | status.BytesOut = counters.BytesOut | |
| 489 | + | status.SmartTCP = counters.SmartTCP | |
| 475 | 490 | } | |
| 476 | 491 | if runtime := udpListeners[entry.Name]; runtime != nil { | |
| 477 | 492 | counters := runtime.counters.snapshot() | |
| @@ -526,6 +541,18 @@ func aggregateCounters(entries []domain.EntryPointStatus) domain.TrafficCounters | |||
| 526 | 541 | counters.TotalErrors += entry.TotalErrors | |
| 527 | 542 | counters.BytesIn += entry.BytesIn | |
| 528 | 543 | 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 | 557 | return counters | |
| 531 | 558 | } | |
| @@ -535,6 +562,7 @@ func cloneTrafficGraph(graph domain.TrafficGraph) domain.TrafficGraph { | |||
| 535 | 562 | clone.EntryPoints = append([]domain.EntryPoint{}, graph.EntryPoints...) | |
| 536 | 563 | for i := range clone.EntryPoints { | |
| 537 | 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 | 567 | clone.Routers = append([]domain.TrafficRouter{}, graph.Routers...) | |
| 540 | 568 | clone.Services = append([]domain.TrafficService{}, graph.Services...) | |
| @@ -566,6 +594,22 @@ type trafficCounters struct { | |||
| 566 | 594 | totalErrors atomic.Int64 | |
| 567 | 595 | bytesIn atomic.Int64 | |
| 568 | 596 | 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 | 615 | func (c *trafficCounters) snapshot() domain.TrafficCounters { | |
| @@ -577,6 +621,20 @@ func (c *trafficCounters) snapshot() domain.TrafficCounters { | |||
| 577 | 621 | TotalErrors: c.totalErrors.Load(), | |
| 578 | 622 | BytesIn: c.bytesIn.Load(), | |
| 579 | 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 | ||
| @@ -55,6 +55,8 @@ type Manager struct { | |||||
| 55 | lastReloadStatus string | 55 | lastReloadStatus string | ||
| 56 | lastReloadError string | 56 | lastReloadError string | ||
| 57 | tlsHTTPServers atomic.Value | 57 | 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 manager | 72 | 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 *entryPointRuntime | 256 | runtime *entryPointRuntime | ||
| 253 | entryPoint domain.EntryPoint | 257 | entryPoint domain.EntryPoint | ||
| 254 | trusted []*net.IPNet | 258 | 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] = runtime | 297 | next[entryPoint.Name] = runtime | ||
| 288 | delete(current, runtime.entryPointSnapshot().Name) | 298 | delete(current, runtime.entryPointSnapshot().Name) | ||
| 289 | continue | 299 | 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.EntryPointProtocolTLSMux | 325 | + | 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, nil | 353 | return runtime, nil | ||
| @@ -472,6 +486,7 @@ func entryPointStatuses(entries []domain.EntryPoint, listeners map[string]*entry | |||||
| 472 | status.TotalErrors = counters.TotalErrors | 486 | status.TotalErrors = counters.TotalErrors | ||
| 473 | status.BytesIn = counters.BytesIn | 487 | status.BytesIn = counters.BytesIn | ||
| 474 | status.BytesOut = counters.BytesOut | 488 | 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.TotalErrors | 541 | counters.TotalErrors += entry.TotalErrors | ||
| 527 | counters.BytesIn += entry.BytesIn | 542 | counters.BytesIn += entry.BytesIn | ||
| 528 | counters.BytesOut += entry.BytesOut | 543 | 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 counters | 557 | 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.Int64 | 594 | totalErrors atomic.Int64 | ||
| 567 | bytesIn atomic.Int64 | 595 | bytesIn atomic.Int64 | ||
| 568 | bytesOut atomic.Int64 | 596 | 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 { | |||
| 39 | 39 | tlsHTTPListener *tlsHTTPListener | |
| 40 | 40 | tlsHTTPServer *http.Server | |
| 41 | 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 | 56 | 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 | |
| 49 | 64 | } | |
| 50 | 65 | ||
| 51 | 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 | 109 | func newEntryPointRuntime(parentCtx context.Context, manager *Manager, entryPoint domain.EntryPoint, listener net.Listener, trusted []*net.IPNet) *entryPointRuntime { | |
| 75 | 110 | ctx, cancel := context.WithCancel(context.WithoutCancel(parentCtx)) | |
| 111 | + | rawTrusted, _ := parseTrustedCIDRs(entryPoint.RawFallbackTrustedCIDRs) | |
| 76 | 112 | 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{}{}, | |
| 85 | 122 | } | |
| 86 | 123 | } | |
| 87 | 124 | ||
| @@ -91,6 +128,7 @@ func (r *entryPointRuntime) start() { | |||
| 91 | 128 | } | |
| 92 | 129 | entryPoint := r.entryPointSnapshot() | |
| 93 | 130 | r.startTLSHTTPServer(entryPoint) | |
| 131 | + | r.startSmartTCPHTTPServers(entryPoint) | |
| 94 | 132 | trafficInfo(r.ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("started tcp traffic entrypoint") | |
| 95 | 133 | go r.acceptLoop() | |
| 96 | 134 | } | |
| @@ -166,14 +204,28 @@ func (r *entryPointRuntime) routeToHTTPS(tracked *trackedTCPConn, conn net.Conn) | |||
| 166 | 204 | r.mu.Lock() | |
| 167 | 205 | listener := r.tlsHTTPListener | |
| 168 | 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 | 225 | if listener == nil { | |
| 170 | 226 | return false | |
| 171 | 227 | } | |
| 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}) | |
| 177 | 229 | } | |
| 178 | 230 | ||
| 179 | 231 | type trackedHTTPConn struct { | |
| @@ -211,6 +263,9 @@ func (r *entryPointRuntime) acceptLoop() { | |||
| 211 | 263 | } | |
| 212 | 264 | if !trustedRemoteAddr(r.trustedSnapshot(), conn.RemoteAddr()) { | |
| 213 | 265 | r.counters.totalRefused.Add(1) | |
| 266 | + | if r.entryPointSnapshot().Protocol == domain.EntryPointProtocolSmartTCP { | |
| 267 | + | r.counters.smartTCP.entrypointCIDRRefused.Add(1) | |
| 268 | + | } | |
| 214 | 269 | _ = conn.Close() | |
| 215 | 270 | continue | |
| 216 | 271 | } | |
| @@ -220,22 +275,25 @@ func (r *entryPointRuntime) acceptLoop() { | |||
| 220 | 275 | } | |
| 221 | 276 | ||
| 222 | 277 | func (r *entryPointRuntime) handleTCPConn(client net.Conn) { | |
| 223 | - | defer r.activeWG.Done() | |
| 224 | - | ||
| 225 | 278 | options := effectiveTCPOptions(snapshotTCPOptions(r.manager.snapshot.Load())) | |
| 226 | 279 | if !r.reserveConnection(options.MaxConnections) { | |
| 227 | 280 | r.counters.totalRefused.Add(1) | |
| 228 | 281 | _ = client.Close() | |
| 282 | + | r.activeWG.Done() | |
| 229 | 283 | return | |
| 230 | 284 | } | |
| 231 | - | defer r.releaseConnection() | |
| 232 | 285 | ||
| 233 | 286 | tracked := &trackedTCPConn{client: client} | |
| 287 | + | tracked.onDone = func() { | |
| 288 | + | r.untrack(tracked) | |
| 289 | + | r.releaseConnection() | |
| 290 | + | r.activeWG.Done() | |
| 291 | + | } | |
| 234 | 292 | r.track(tracked) | |
| 235 | - | untrackOnReturn := true | |
| 293 | + | completeOnReturn := true | |
| 236 | 294 | defer func() { | |
| 237 | - | if untrackOnReturn { | |
| 238 | - | r.untrack(tracked) | |
| 295 | + | if completeOnReturn { | |
| 296 | + | tracked.complete() | |
| 239 | 297 | } | |
| 240 | 298 | }() | |
| 241 | 299 | ||
| @@ -244,7 +302,11 @@ func (r *entryPointRuntime) handleTCPConn(client net.Conn) { | |||
| 244 | 302 | r.handlePlainTCP(tracked, options) | |
| 245 | 303 | case domain.EntryPointProtocolTLSMux: | |
| 246 | 304 | 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 | |
| 248 | 310 | } | |
| 249 | 311 | default: | |
| 250 | 312 | r.counters.totalRefused.Add(1) | |
| @@ -262,6 +324,98 @@ func (r *entryPointRuntime) handlePlainTCP(tracked *trackedTCPConn, options doma | |||
| 262 | 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 | 419 | func (r *entryPointRuntime) handleTLSMux(tracked *trackedTCPConn, options domain.TCPOptions) bool { | |
| 266 | 420 | peeked, err := r.peekTLSClientHello(tracked.client, options) | |
| 267 | 421 | if err != nil { | |
| @@ -296,6 +450,10 @@ func (r *entryPointRuntime) peekTLSClientHello(client net.Conn, options domain.T | |||
| 296 | 450 | 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 | 457 | func clientHelloTimeout(options domain.TCPOptions) time.Duration { | |
| 300 | 458 | const maxTimeout = 5 * time.Second | |
| 301 | 459 | if options.DialTimeout > 0 && options.DialTimeout < maxTimeout { | |
| @@ -309,19 +467,44 @@ type peekedTLSConn struct { | |||
| 309 | 467 | 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 | 471 | dialCtx, cancel := context.WithTimeout(r.ctx, options.DialTimeout) | |
| 314 | 472 | defer cancel() | |
| 315 | 473 | backendConn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", net.JoinHostPort(backend.Host, strconv.Itoa(backend.Port))) | |
| 316 | 474 | if err != nil { | |
| 317 | 475 | r.counters.totalErrors.Add(1) | |
| 318 | 476 | _ = client.Close() | |
| 319 | - | return | |
| 477 | + | return false | |
| 320 | 478 | } | |
| 321 | 479 | ||
| 322 | 480 | r.setBackend(tracked, backendConn) | |
| 323 | 481 | r.counters.totalAccepted.Add(1) | |
| 324 | 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 | 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 | 719 | return | |
| 537 | 720 | } | |
| 538 | 721 | r.stopTLSHTTPServer(ctx, drainTimeout) | |
| 722 | + | r.stopSmartTCPHTTPServer(ctx, drainTimeout) | |
| 723 | + | r.stopSmartTCPTLSServer(ctx, drainTimeout) | |
| 539 | 724 | if r.waitActive(ctx, drainTimeout) { | |
| 540 | 725 | trafficInfo(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Msg("stopped tcp traffic entrypoint") | |
| 541 | 726 | return | |
| @@ -621,33 +806,41 @@ func (r *entryPointRuntime) closeActiveConns() { | |||
| 621 | 806 | ||
| 622 | 807 | func (r *entryPointRuntime) matches(entryPoint domain.EntryPoint) bool { | |
| 623 | 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 | 812 | func (r *entryPointRuntime) sameAddress(entryPoint domain.EntryPoint) bool { | |
| 628 | 813 | 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 | 817 | r.mu.Lock() | |
| 633 | 818 | previousName := r.entryPoint.Name | |
| 634 | 819 | previousProtocol := r.entryPoint.Protocol | |
| 635 | 820 | r.entryPoint = entryPoint | |
| 636 | 821 | r.trusted = trusted | |
| 822 | + | r.rawFallbackTrusted = rawTrusted | |
| 637 | 823 | stale := make([]*trackedTCPConn, 0) | |
| 638 | 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 | 826 | stale = append(stale, conn) | |
| 641 | 827 | } | |
| 642 | 828 | } | |
| 643 | 829 | r.mu.Unlock() | |
| 644 | 830 | for _, conn := range stale { | |
| 645 | 831 | conn.close() | |
| 646 | - | r.untrack(conn) | |
| 647 | 832 | } | |
| 648 | 833 | if previousProtocol == domain.EntryPointProtocolTLSMux && entryPoint.Protocol != domain.EntryPointProtocolTLSMux { | |
| 649 | 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 | 844 | if entryPoint.Protocol == domain.EntryPointProtocolTLSMux { | |
| 652 | 845 | if previousProtocol == domain.EntryPointProtocolTLSMux && previousName != entryPoint.Name { | |
| 653 | 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 | 867 | func (r *entryPointRuntime) entryPointSnapshot() domain.EntryPoint { | |
| 661 | 868 | r.mu.Lock() | |
| 662 | 869 | defer r.mu.Unlock() | |
| @@ -669,6 +876,12 @@ func (r *entryPointRuntime) trustedSnapshot() []*net.IPNet { | |||
| 669 | 876 | 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 | 885 | func (r *entryPointRuntime) isClosed() bool { | |
| 673 | 886 | return r.closed.Load() | |
| 674 | 887 | } | |
| @@ -39,13 +39,28 @@ type entryPointRuntime struct { | |||||
| 39 | tlsHTTPListener *tlsHTTPListener | 39 | tlsHTTPListener *tlsHTTPListener | ||
| 40 | tlsHTTPServer *http.Server | 40 | 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.Mutex | 57 | + | mu sync.Mutex |
| 46 | - | client net.Conn | 58 | + | client net.Conn |
| 47 | - | backend net.Conn | 59 | + | backend net.Conn |
| 48 | - | closing bool | 60 | + | 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.tlsHTTPListener | 205 | 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 false | 226 | 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 | continue | 270 | 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 | return | 283 | 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 := true | 293 | + | 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 = false | 305 | + | 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}, nil | 450 | 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.Second | 458 | 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.Conn | 467 | 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 | - | return | 477 | + | 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 | return | 719 | 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 | return | 726 | 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.Address | 813 | 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.Name | 818 | previousName := r.entryPoint.Name | ||
| 634 | previousProtocol := r.entryPoint.Protocol | 819 | previousProtocol := r.entryPoint.Protocol | ||
| 635 | r.entryPoint = entryPoint | 820 | r.entryPoint = entryPoint | ||
| 636 | r.trusted = trusted | 821 | 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.trusted | 876 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bufio" | |
| 5 | + | "bytes" | |
| 5 | 6 | "context" | |
| 6 | 7 | "crypto/tls" | |
| 7 | 8 | "errors" | |
| @@ -9,6 +10,7 @@ import ( | |||
| 9 | 10 | "io" | |
| 10 | 11 | "net" | |
| 11 | 12 | "net/http" | |
| 13 | + | "sync/atomic" | |
| 12 | 14 | "testing" | |
| 13 | 15 | "time" | |
| 14 | 16 | ||
| @@ -23,6 +25,592 @@ func TestTrafficRuntimeDefaultsApplySafeLimits(t *testing.T) { | |||
| 23 | 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 | 614 | func TestTCPManagerLifecycle(t *testing.T) { | |
| 27 | 615 | backend := startTCPEchoServer(t, 0) | |
| 28 | 616 | graph := tcpGraph(t, freeTCPAddress(t), backend.address) | |
| @@ -451,6 +1039,70 @@ func TestTCPPassthroughStatusCountersTrackAcceptedRefusedErrorsAndBytes(t *testi | |||
| 451 | 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 | 1106 | func tcpGraph(t *testing.T, listenAddress string, backendAddress string) domain.TrafficGraph { | |
| 455 | 1107 | t.Helper() | |
| 456 | 1108 | backend, err := backendFromAddress("echo:tcp", backendAddress) | |
| @@ -498,6 +1150,15 @@ func shutdownManager(t *testing.T, manager *Manager) { | |||
| 498 | 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 | 1162 | type tcpEchoServer struct { | |
| 502 | 1163 | address string | |
| 503 | 1164 | 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 string | 1163 | 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 { | |||
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | 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 | 67 | buf := make([]byte, 0, 4096) | |
| 64 | 68 | tmp := make([]byte, 1024) | |
| 65 | - | for len(buf) < maxClientHelloBytes { | |
| 69 | + | for len(buf) < maxBytes { | |
| 66 | 70 | n, err := conn.Read(tmp) | |
| 67 | 71 | if n > 0 { | |
| 68 | 72 | buf = append(buf, tmp[:n]...) | |
| @@ -78,7 +82,7 @@ func peekClientHelloSNI(conn net.Conn) (string, net.Conn, error) { | |||
| 78 | 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 | 88 | 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 ( | |||
| 11 | 11 | "testing" | |
| 12 | 12 | "time" | |
| 13 | 13 | ||
| 14 | + | "github.com/spf13/viper" | |
| 14 | 15 | "github.com/stretchr/testify/assert" | |
| 15 | 16 | "github.com/stretchr/testify/mock" | |
| 16 | 17 | "github.com/stretchr/testify/require" | |
| @@ -19,10 +20,120 @@ import ( | |||
| 19 | 20 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 20 | 21 | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 21 | 22 | "github.com/bnema/gordon/internal/domain" | |
| 23 | + | config "github.com/bnema/gordon/internal/usecase/config" | |
| 22 | 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 | 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 | 137 | // generateTestCert creates a self-signed certificate for the given hostnames. | |
| 27 | 138 | func generateTestCert(t *testing.T, hosts ...string) tls.Certificate { | |
| 28 | 139 | 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 { | |||
| 261 | 261 | ||
| 262 | 262 | // services holds all the services used by the application. | |
| 263 | 263 | 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 { | |
| 304 | 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 | 579 | // initPKI initialises the internal CA and PKI service when TLS is enabled. | |
| 578 | 580 | 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)") | |
| 581 | 583 | return nil | |
| 582 | 584 | } | |
| 583 | 585 | ||
| @@ -600,8 +602,8 @@ func (si *serviceInit) initPublicTLS() error { | |||
| 600 | 602 | return nil | |
| 601 | 603 | } | |
| 602 | 604 | ||
| 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 | |
| 605 | 607 | } | |
| 606 | 608 | ||
| 607 | 609 | ctx := si.ctx | |
| @@ -616,8 +618,8 @@ func (si *serviceInit) initPublicTLS() error { | |||
| 616 | 618 | Enabled: si.cfg.TLS.ACME.Enabled, | |
| 617 | 619 | Email: si.cfg.TLS.ACME.Email, | |
| 618 | 620 | 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), | |
| 621 | 623 | DataDir: resolveDataDir(si.cfg.Server.DataDir), | |
| 622 | 624 | ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize, | |
| 623 | 625 | DNS: dnsCfg, | |
| @@ -629,6 +631,9 @@ func (si *serviceInit) initPublicTLS() error { | |||
| 629 | 631 | if err != nil { | |
| 630 | 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 | 638 | store, err := acmestore.New(filepath.Join(resolveDataDir(si.cfg.Server.DataDir), "acme")) | |
| 634 | 639 | if err != nil { | |
| @@ -769,7 +774,7 @@ func (si *serviceInit) registerReloadCoordinatorHooks() { | |||
| 769 | 774 | return err | |
| 770 | 775 | } | |
| 771 | 776 | var tlsConfig *tls.Config | |
| 772 | - | if reloadCfg.Server.TLSPort != 0 && si.svc.httpsProxyHandler != nil { | |
| 777 | + | if hasTLSCapableEntrypoint(reloadCfg) && si.svc.httpsProxyHandler != nil { | |
| 773 | 778 | var tlsErr error | |
| 774 | 779 | tlsConfig, tlsErr = proxyTLSConfig(reloadCfg, si.svc.pkiSvc, si.svc.publicTLSSvc, si.log) | |
| 775 | 780 | if tlsErr != nil { | |
| @@ -777,6 +782,7 @@ func (si *serviceInit) registerReloadCoordinatorHooks() { | |||
| 777 | 782 | } | |
| 778 | 783 | } | |
| 779 | 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 | 786 | if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil { | |
| 781 | 787 | return err | |
| 782 | 788 | } | |
| @@ -2814,6 +2820,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||
| 2814 | 2820 | errChan := make(chan error, 3) | |
| 2815 | 2821 | ||
| 2816 | 2822 | registryHandler, httpProxyHandler, httpsProxyHandler := createHTTPHandlers(svc, cfg, log, accessWriter) | |
| 2823 | + | svc.httpProxyHandler = httpProxyHandler | |
| 2824 | + | svc.httpsProxyHandler = httpsProxyHandler | |
| 2817 | 2825 | ||
| 2818 | 2826 | registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort)) | |
| 2819 | 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 | 2844 | if err != nil { | |
| 2837 | 2845 | closeStarted(registrySrv) | |
| 2838 | 2846 | return err | |
| 2839 | 2847 | } | |
| 2840 | 2848 | svc.tlsHTTPEntryPoints = tlsMuxHTTPServerNames(cfg) | |
| 2849 | + | svc.smartHTTPEntryPoints = smartTCPHTTPServerNames(cfg) | |
| 2841 | 2850 | ||
| 2842 | 2851 | // Wait for the registry and HTTP proxy to bind before applying the traffic graph. | |
| 2843 | 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 | 2914 | func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error { | |
| 2915 | + | if ready == nil { | |
| 2916 | + | return nil | |
| 2917 | + | } | |
| 2906 | 2918 | select { | |
| 2907 | 2919 | case <-ready: | |
| 2908 | 2920 | return nil | |
| @@ -3288,44 +3300,127 @@ func matchingStaticCert(certs []tls.Certificate, serverName string) *tls.Certifi | |||
| 3288 | 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) { | |
| 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{} | |
| 3304 | 3306 | ||
| 3305 | - | if cfg.Server.TLSPort == 0 { | |
| 3307 | + | needsTLS := hasTLSCapableEntrypoint(cfg) | |
| 3308 | + | if !hasSmartTCPEntrypoint(cfg) && !needsTLS { | |
| 3306 | 3309 | return httpSrv, httpReady, nil, nil, nil | |
| 3307 | 3310 | } | |
| 3308 | 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 | 3312 | return httpSrv, httpReady, nil, nil, err | |
| 3315 | 3313 | } | |
| 3316 | 3314 | ||
| 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 | + | } | |
| 3320 | 3322 | } | |
| 3321 | 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 | 3326 | registerTLSMuxHTTPServers(trafficManager, cfg, httpsHandler, tlsConfig, nil) | |
| 3327 | + | registerSmartTCPHTTPServers(trafficManager, cfg, httpHandler, httpsHandler, tlsConfig, nil) | |
| 3325 | 3328 | ||
| 3326 | 3329 | 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 | 3424 | func proxyTLSConfig(cfg Config, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, log zerowrap.Logger) (*tls.Config, error) { | |
| 3330 | 3425 | var staticCerts []tls.Certificate | |
| 3331 | 3426 | if cfg.Server.TLSCertFile != "" { | |
| @@ -3351,6 +3446,41 @@ func proxyTLSConfig(cfg Config, pkiSvc *pkiusecase.Service, publicTLS in.PublicT | |||
| 3351 | 3446 | }, 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 | 3484 | func registerTLSMuxHTTPServers(manager *trafficadapter.Manager, cfg Config, httpsHandler http.Handler, tlsConfig *tls.Config, previous map[string]struct{}) map[string]struct{} { | |
| 3355 | 3485 | if manager == nil { | |
| 3356 | 3486 | return previous | |
| @@ -3361,7 +3491,7 @@ func registerTLSMuxHTTPServers(manager *trafficadapter.Manager, cfg Config, http | |||
| 3361 | 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 | 3495 | return next | |
| 3366 | 3496 | } | |
| 3367 | 3497 | for name := range next { | |
| @@ -3372,10 +3502,6 @@ func registerTLSMuxHTTPServers(manager *trafficadapter.Manager, cfg Config, http | |||
| 3372 | 3502 | ||
| 3373 | 3503 | func tlsMuxHTTPServerNames(cfg Config) map[string]struct{} { | |
| 3374 | 3504 | names := map[string]struct{}{} | |
| 3375 | - | if cfg.Server.TLSPort == 0 { | |
| 3376 | - | return names | |
| 3377 | - | } | |
| 3378 | - | names[traffic.DefaultTLSEntryPointName] = struct{}{} | |
| 3379 | 3505 | for name, entryPoint := range cfg.EntryPoints { | |
| 3380 | 3506 | domainEntryPoint := domain.EntryPoint{Name: name, Protocol: entryPoint.Protocol} | |
| 3381 | 3507 | if trafficManagerOwnsEntryPoint(domainEntryPoint) && domainEntryPoint.Protocol == domain.EntryPointProtocolTLSMux { | |
| @@ -3673,10 +3799,8 @@ func isProcessAlive(pid int) bool { | |||
| 3673 | 3799 | ||
| 3674 | 3800 | // loadConfig loads configuration from file and sets defaults. | |
| 3675 | 3801 | func loadConfig(v *viper.Viper, configPath string) error { | |
| 3676 | - | v.SetDefault("server.port", 8088) | |
| 3677 | 3802 | v.SetDefault("server.registry_port", 5000) | |
| 3678 | 3803 | v.SetDefault("server.legacy_registry_domains", []string{}) | |
| 3679 | - | v.SetDefault("server.tls_port", 8443) | |
| 3680 | 3804 | v.SetDefault("server.tls_cert_file", "") | |
| 3681 | 3805 | v.SetDefault("server.tls_key_file", "") | |
| 3682 | 3806 | 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.Runtime | 264 | + | runtime *docker.Runtime |
| 265 | - | eventBus *eventbus.InMemory | 265 | + | eventBus *eventbus.InMemory |
| 266 | - | blobStorage *filesystem.BlobStorage | 266 | + | blobStorage *filesystem.BlobStorage |
| 267 | - | manifestStorage *filesystem.ManifestStorage | 267 | + | manifestStorage *filesystem.ManifestStorage |
| 268 | - | backupStorage *filesystem.BackupStorage | 268 | + | backupStorage *filesystem.BackupStorage |
| 269 | - | volumeBackupStore out.VolumeBackupStorage | 269 | + | volumeBackupStore out.VolumeBackupStorage |
| 270 | - | volumeBackupCfg domain.VolumeBackupConfig | 270 | + | volumeBackupCfg domain.VolumeBackupConfig |
| 271 | - | envLoader out.EnvLoader | 271 | + | envLoader out.EnvLoader |
| 272 | - | logWriter *logwriter.LogWriter | 272 | + | logWriter *logwriter.LogWriter |
| 273 | - | tokenStore out.TokenStore | 273 | + | tokenStore out.TokenStore |
| 274 | - | configSvc *config.Service | 274 | + | configSvc *config.Service |
| 275 | - | secretSvc *secretsSvc.Service | 275 | + | secretSvc *secretsSvc.Service |
| 276 | - | containerSvc *container.Service | 276 | + | containerSvc *container.Service |
| 277 | - | backupSvc *backup.Service | 277 | + | backupSvc *backup.Service |
| 278 | - | volumeBackupSvc *backup.VolumeService | 278 | + | volumeBackupSvc *backup.VolumeService |
| 279 | - | registrySvc *registrySvc.Service | 279 | + | registrySvc *registrySvc.Service |
| 280 | - | healthSvc *health.Service | 280 | + | healthSvc *health.Service |
| 281 | - | logSvc *logs.Service | 281 | + | logSvc *logs.Service |
| 282 | - | imageSvc *images.Service | 282 | + | imageSvc *images.Service |
| 283 | - | volumeSvc *volumesSvc.Service | 283 | + | volumeSvc *volumesSvc.Service |
| 284 | - | proxySvc *proxy.Service | 284 | + | proxySvc *proxy.Service |
| 285 | - | authSvc *auth.Service | 285 | + | authSvc *auth.Service |
| 286 | - | authHandler *authhandler.Handler | 286 | + | authHandler *authhandler.Handler |
| 287 | - | adminHandler *admin.Handler | 287 | + | adminHandler *admin.Handler |
| 288 | - | httpsProxyHandler http.Handler | 288 | + | httpProxyHandler http.Handler |
| 289 | - | internalRegUser string | 289 | + | httpsProxyHandler http.Handler |
| 290 | - | internalRegPass string | 290 | + | internalRegUser string |
| 291 | - | previewStore *filesystem.PreviewStore | 291 | + | internalRegPass string |
| 292 | - | previewService *preview.Service | 292 | + | previewStore *filesystem.PreviewStore |
| 293 | - | envDir string | 293 | + | previewService *preview.Service |
| 294 | - | maxBlobChunkSize int64 | 294 | + | envDir string |
| 295 | - | maxBlobSize int64 | 295 | + | maxBlobChunkSize int64 |
| 296 | - | caAdapter *pkiadapter.CA | 296 | + | maxBlobSize int64 |
| 297 | - | pkiSvc *pkiusecase.Service | 297 | + | caAdapter *pkiadapter.CA |
| 298 | - | reloadCoordinator *reloadCoordinator | 298 | + | pkiSvc *pkiusecase.Service |
| 299 | - | publicTLSSvc in.PublicTLSService | 299 | + | reloadCoordinator *reloadCoordinator |
| 300 | - | publicTLSRuntime publicTLSRuntime | 300 | + | publicTLSSvc in.PublicTLSService |
| 301 | - | trafficManager *trafficadapter.Manager | 301 | + | 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 nil | 583 | return nil | ||
| 582 | } | 584 | } | ||
| 583 | 585 | ||||
| @@ -600,8 +602,8 @@ func (si *serviceInit) initPublicTLS() error { | |||||
| 600 | return nil | 602 | 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.ctx | 609 | 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 err | 774 | return err | ||
| 770 | } | 775 | } | ||
| 771 | var tlsConfig *tls.Config | 776 | 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 error | 778 | 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 err | 787 | 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 err | 2846 | 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 nil | 2920 | 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.Protocols | 3305 | + | 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, nil | 3309 | 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, err | 3312 | 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, nil | 3329 | 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.Certificate | 3425 | 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 | }, nil | 3446 | }, 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 previous | 3486 | 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 next | 3495 | 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 ( | |||
| 21 | 21 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 22 | 22 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 23 | 23 | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 24 | + | "github.com/bnema/gordon/internal/domain" | |
| 24 | 25 | proxyusecase "github.com/bnema/gordon/internal/usecase/proxy" | |
| 26 | + | traffic "github.com/bnema/gordon/internal/usecase/traffic" | |
| 25 | 27 | ) | |
| 26 | 28 | ||
| 27 | 29 | func newNotFoundProxyService(t *testing.T) *proxyusecase.Service { | |
| @@ -473,7 +475,9 @@ func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) { | |||
| 473 | 475 | log: zerowrap.Default(), | |
| 474 | 476 | svc: &services{}, | |
| 475 | 477 | } | |
| 476 | - | si.cfg.Server.TLSPort = 8443 | |
| 478 | + | si.cfg.EntryPoints = map[string]traffic.EntryPointConfig{ | |
| 479 | + | traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}, | |
| 480 | + | } | |
| 477 | 481 | dataDir := t.TempDir() | |
| 478 | 482 | blockingPath := filepath.Join(dataDir, "not-a-directory") | |
| 479 | 483 | 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 = 8443 | 478 | + | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "crypto/tls" | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "io" | |
| 8 | 9 | "net" | |
| @@ -24,15 +25,134 @@ import ( | |||
| 24 | 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 | 54 | cfg := Config{} | |
| 29 | 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 | 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 | 153 | manager := trafficadapter.NewManager() | |
| 33 | 154 | defer func() { require.NoError(t, manager.Shutdown(context.Background())) }() | |
| 34 | 155 | pkiSvc := newTestPKIService(t) | |
| 35 | - | errChan := make(chan error, 4) | |
| 36 | 156 | ||
| 37 | 157 | httpSrv, httpReady, tlsSrv, tlsReady, err := startProxyServers( | |
| 38 | 158 | cfg, | |
| @@ -41,21 +161,24 @@ func TestStartProxyServers_ConfiguresTrafficManagerHTTPSRoute(t *testing.T) { | |||
| 41 | 161 | pkiSvc, | |
| 42 | 162 | nil, | |
| 43 | 163 | manager, | |
| 44 | - | errChan, | |
| 45 | 164 | zerowrap.Default(), | |
| 46 | 165 | ) | |
| 47 | 166 | require.NoError(t, err) | |
| 48 | - | defer shutdownHTTPServer(t, httpSrv) | |
| 167 | + | require.Nil(t, httpSrv) | |
| 168 | + | require.Nil(t, httpReady) | |
| 49 | 169 | require.Nil(t, tlsSrv) | |
| 50 | 170 | require.Nil(t, tlsReady) | |
| 51 | - | waitReadyForTest(t, httpReady, errChan) | |
| 52 | 171 | ||
| 53 | 172 | configSvc := inmocks.NewMockConfigService(t) | |
| 54 | 173 | configSvc.EXPECT().GetRoutes(context.Background()).Return([]domain.Route{{Domain: "app.example.com", HTTPS: true}}) | |
| 55 | 174 | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}) | |
| 56 | 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 | 182 | require.Contains(t, body, "secure") | |
| 60 | 183 | } | |
| 61 | 184 | ||
| @@ -72,7 +195,6 @@ func TestStartProxyServers_ConfiguresCustomTLSMuxHTTPSRoute(t *testing.T) { | |||
| 72 | 195 | manager := trafficadapter.NewManager() | |
| 73 | 196 | defer func() { require.NoError(t, manager.Shutdown(context.Background())) }() | |
| 74 | 197 | pkiSvc := newTestPKIService(t) | |
| 75 | - | errChan := make(chan error, 4) | |
| 76 | 198 | ||
| 77 | 199 | httpSrv, httpReady, tlsSrv, tlsReady, err := startProxyServers( | |
| 78 | 200 | cfg, | |
| @@ -81,14 +203,13 @@ func TestStartProxyServers_ConfiguresCustomTLSMuxHTTPSRoute(t *testing.T) { | |||
| 81 | 203 | pkiSvc, | |
| 82 | 204 | nil, | |
| 83 | 205 | manager, | |
| 84 | - | errChan, | |
| 85 | 206 | zerowrap.Default(), | |
| 86 | 207 | ) | |
| 87 | 208 | require.NoError(t, err) | |
| 88 | - | defer shutdownHTTPServer(t, httpSrv) | |
| 209 | + | require.Nil(t, httpSrv) | |
| 210 | + | require.Nil(t, httpReady) | |
| 89 | 211 | require.Nil(t, tlsSrv) | |
| 90 | 212 | require.Nil(t, tlsReady) | |
| 91 | - | waitReadyForTest(t, httpReady, errChan) | |
| 92 | 213 | ||
| 93 | 214 | configSvc := inmocks.NewMockConfigService(t) | |
| 94 | 215 | configSvc.EXPECT().GetRoutes(context.Background()).Return(nil) | |
| @@ -105,8 +226,9 @@ func TestStartProxyServers_ConfiguresCustomTLSMuxHTTPSRoute(t *testing.T) { | |||
| 105 | 226 | ||
| 106 | 227 | func TestStartProxyServers_TLSSetupFailureStopsHTTPServer(t *testing.T) { | |
| 107 | 228 | 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 | + | } | |
| 110 | 232 | cfg.Server.TLSCertFile = "/path/that/does/not/exist.crt" | |
| 111 | 233 | cfg.Server.TLSKeyFile = "/path/that/does/not/exist.key" | |
| 112 | 234 | ||
| @@ -117,7 +239,6 @@ func TestStartProxyServers_TLSSetupFailureStopsHTTPServer(t *testing.T) { | |||
| 117 | 239 | newTestPKIService(t), | |
| 118 | 240 | nil, | |
| 119 | 241 | trafficadapter.NewManager(), | |
| 120 | - | make(chan error, 4), | |
| 121 | 242 | zerowrap.Default(), | |
| 122 | 243 | ) | |
| 123 | 244 | require.ErrorContains(t, err, "load TLS keypair") | |
| @@ -137,8 +258,9 @@ func TestStartProxyServers_TLSSetupFailureStopsHTTPServer(t *testing.T) { | |||
| 137 | 258 | ||
| 138 | 259 | func TestStartProxyServers_TLSRequiresTrafficManager(t *testing.T) { | |
| 139 | 260 | 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 | + | } | |
| 142 | 264 | ||
| 143 | 265 | httpSrv, _, tlsSrv, _, err := startProxyServers( | |
| 144 | 266 | cfg, | |
| @@ -147,7 +269,6 @@ func TestStartProxyServers_TLSRequiresTrafficManager(t *testing.T) { | |||
| 147 | 269 | newTestPKIService(t), | |
| 148 | 270 | nil, | |
| 149 | 271 | nil, | |
| 150 | - | make(chan error, 2), | |
| 151 | 272 | zerowrap.Default(), | |
| 152 | 273 | ) | |
| 153 | 274 | if httpSrv != nil { | |
| @@ -183,17 +304,6 @@ func freeTCPPort(t *testing.T) int { | |||
| 183 | 304 | 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 | 307 | func shutdownHTTPServer(t *testing.T, srv *http.Server) { | |
| 198 | 308 | t.Helper() | |
| 199 | 309 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |
| @@ -201,6 +311,29 @@ func shutdownHTTPServer(t *testing.T, srv *http.Server) { | |||
| 201 | 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 | 337 | func httpsGetForTest(t *testing.T, port int, serverName string) string { | |
| 205 | 338 | t.Helper() | |
| 206 | 339 | 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 port | 304 | 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 | |||
| 15 | 15 | return nil | |
| 16 | 16 | } | |
| 17 | 17 | graph, err := trafficbuilder.Build(trafficbuilder.Input{ | |
| 18 | - | Server: trafficbuilder.ServerConfig{ | |
| 19 | - | Port: cfg.Server.Port, | |
| 20 | - | TLSPort: cfg.Server.TLSPort, | |
| 21 | - | }, | |
| 22 | 18 | EntryPoints: cfg.EntryPoints, | |
| 23 | 19 | Traffic: cfg.Traffic, | |
| 24 | 20 | Routes: configSvc.GetRoutes(ctx), | |
| @@ -78,7 +74,7 @@ func trafficManagerRouterProtocol(protocol domain.RouterProtocol) bool { | |||
| 78 | 74 | ||
| 79 | 75 | func trafficManagerOwnsEntryPoint(entryPoint domain.EntryPoint) bool { | |
| 80 | 76 | switch entryPoint.Protocol { | |
| 81 | - | case domain.EntryPointProtocolTLSMux, domain.EntryPointProtocolTCP, domain.EntryPointProtocolUDP: | |
| 77 | + | case domain.EntryPointProtocolTLSMux, domain.EntryPointProtocolSmartTCP, domain.EntryPointProtocolTCP, domain.EntryPointProtocolUDP: | |
| 82 | 78 | return true | |
| 83 | 79 | default: | |
| 84 | 80 | return false | |
| @@ -15,10 +15,6 @@ func applyTrafficRuntimeConfig(ctx context.Context, manager *trafficadapter.Mana | |||||
| 15 | return nil | 15 | 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 true | 78 | return true | ||
| 83 | default: | 79 | default: | ||
| 84 | return false | 80 | return false | ||
M internal/app/traffic_test.go +78 -8
| @@ -14,16 +14,55 @@ import ( | |||
| 14 | 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 | 56 | func TestTrafficRuntimeGraphKeepsDefaultTLSEntrypoint(t *testing.T) { | |
| 18 | 57 | graph := domain.TrafficGraph{ | |
| 19 | 58 | 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}, | |
| 22 | 61 | {Name: "postgres", Address: "127.0.0.1:15432", Protocol: domain.EntryPointProtocolTCP}, | |
| 23 | 62 | }, | |
| 24 | 63 | 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"}, | |
| 27 | 66 | {Name: "postgres", EntryPoint: "postgres", Protocol: domain.RouterProtocolTCP, Service: "network_service:postgres:db"}, | |
| 28 | 67 | }, | |
| 29 | 68 | Services: []domain.TrafficService{ | |
| @@ -34,26 +73,57 @@ func TestTrafficRuntimeGraphKeepsDefaultTLSEntrypoint(t *testing.T) { | |||
| 34 | 73 | ||
| 35 | 74 | filtered, err := trafficRuntimeGraph(graph) | |
| 36 | 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 | 77 | assert.ElementsMatch(t, []string{"app-secure", "postgres"}, routerNames(filtered.Routers)) | |
| 39 | 78 | assert.ElementsMatch(t, []string{"route:app.example.com", "network_service:postgres:db"}, serviceNames(filtered.Services)) | |
| 40 | 79 | } | |
| 41 | 80 | ||
| 42 | 81 | func TestTrafficRuntimeGraphAllowsTLSPassthroughOnDefaultTLS(t *testing.T) { | |
| 43 | 82 | 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"}}, | |
| 46 | 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 | 88 | filtered, err := trafficRuntimeGraph(graph) | |
| 50 | 89 | require.NoError(t, err) | |
| 51 | 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 | 92 | require.Len(t, filtered.Routers, 1) | |
| 54 | 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 | 127 | func TestApplyTrafficRuntimeConfigAppliesCustomL4Entrypoint(t *testing.T) { | |
| 58 | 128 | manager := trafficadapter.NewManager() | |
| 59 | 129 | 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 ( | |||
| 11 | 11 | type EntryPointProtocol string | |
| 12 | 12 | ||
| 13 | 13 | 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" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | 21 | type RouterProtocol string | |
| @@ -59,10 +60,13 @@ type UDPOptions struct { | |||
| 59 | 60 | } | |
| 60 | 61 | ||
| 61 | 62 | 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 | |
| 66 | 70 | } | |
| 67 | 71 | ||
| 68 | 72 | type TrafficRouter struct { | |
| @@ -111,6 +115,7 @@ type EntryPointStatus struct { | |||
| 111 | 115 | TotalErrors int64 | |
| 112 | 116 | BytesIn int64 | |
| 113 | 117 | BytesOut int64 | |
| 118 | + | SmartTCP SmartTCPCounters | |
| 114 | 119 | } | |
| 115 | 120 | ||
| 116 | 121 | type TrafficRouterStatus struct { | |
| @@ -144,6 +149,22 @@ type TrafficCounters struct { | |||
| 144 | 149 | TotalErrors int64 | |
| 145 | 150 | BytesIn int64 | |
| 146 | 151 | 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 | 170 | type TrafficServiceRefKind string | |
| @@ -330,26 +351,28 @@ func (g TrafficGraph) validateRouters(entryPoints map[string]EntryPoint, service | |||
| 330 | 351 | return err | |
| 331 | 352 | } | |
| 332 | 353 | } | |
| 333 | - | return nil | |
| 354 | + | return validateRawFallbackRouters(entryPoints, state.routersByName) | |
| 334 | 355 | } | |
| 335 | 356 | ||
| 336 | 357 | 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 | |
| 343 | 365 | } | |
| 344 | 366 | ||
| 345 | 367 | func newRouterValidationState() *routerValidationState { | |
| 346 | 368 | 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{}, | |
| 353 | 376 | } | |
| 354 | 377 | } | |
| 355 | 378 | ||
| @@ -381,6 +404,7 @@ func (s *routerValidationState) validateRouterName(router TrafficRouter) error { | |||
| 381 | 404 | return fmt.Errorf("duplicate traffic router name %q", router.Name) | |
| 382 | 405 | } | |
| 383 | 406 | s.routerNames[router.Name] = struct{}{} | |
| 407 | + | s.routersByName[router.Name] = router | |
| 384 | 408 | return nil | |
| 385 | 409 | } | |
| 386 | 410 | ||
| @@ -492,11 +516,11 @@ func validateRouterEntryPointProtocol(router TrafficRouter, entryPoint EntryPoin | |||
| 492 | 516 | compatible := false | |
| 493 | 517 | switch router.Protocol { | |
| 494 | 518 | case RouterProtocolHTTP: | |
| 495 | - | compatible = entryPoint.Protocol == EntryPointProtocolHTTP || entryPoint.Protocol == EntryPointProtocolTLSMux | |
| 519 | + | compatible = entryPoint.Protocol == EntryPointProtocolHTTP || entryPoint.Protocol == EntryPointProtocolTLSMux || entryPoint.Protocol == EntryPointProtocolSmartTCP | |
| 496 | 520 | case RouterProtocolTLSPassthrough: | |
| 497 | - | compatible = entryPoint.Protocol == EntryPointProtocolTLSMux | |
| 521 | + | compatible = entryPoint.Protocol == EntryPointProtocolTLSMux || entryPoint.Protocol == EntryPointProtocolSmartTCP | |
| 498 | 522 | case RouterProtocolTCP: | |
| 499 | - | compatible = entryPoint.Protocol == EntryPointProtocolTCP | |
| 523 | + | compatible = entryPoint.Protocol == EntryPointProtocolTCP || (entryPoint.Protocol == EntryPointProtocolSmartTCP && entryPoint.RawFallback == router.Name) | |
| 500 | 524 | case RouterProtocolUDP: | |
| 501 | 525 | compatible = entryPoint.Protocol == EntryPointProtocolUDP | |
| 502 | 526 | } | |
| @@ -561,12 +585,39 @@ func validateTrustedCIDRs(entryPoint EntryPoint) error { | |||
| 561 | 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 | 615 | return nil | |
| 565 | 616 | } | |
| 566 | 617 | ||
| 567 | 618 | func validateEntryPointProtocol(protocol EntryPointProtocol) error { | |
| 568 | 619 | switch protocol { | |
| 569 | - | case EntryPointProtocolHTTP, EntryPointProtocolTLSMux, EntryPointProtocolTCP, EntryPointProtocolUDP: | |
| 620 | + | case EntryPointProtocolHTTP, EntryPointProtocolTLSMux, EntryPointProtocolSmartTCP, EntryPointProtocolTCP, EntryPointProtocolUDP: | |
| 570 | 621 | return nil | |
| 571 | 622 | default: | |
| 572 | 623 | return fmt.Errorf("%q", protocol) | |
| @@ -11,10 +11,11 @@ import ( | |||||
| 11 | type EntryPointProtocol string | 11 | 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 string | 21 | type RouterProtocol string | ||
| @@ -59,10 +60,13 @@ type UDPOptions struct { | |||||
| 59 | } | 60 | } | ||
| 60 | 61 | ||||
| 61 | type EntryPoint struct { | 62 | type EntryPoint struct { | ||
| 62 | - | Name string | 63 | + | Name string |
| 63 | - | Address string | 64 | + | Address string |
| 64 | - | Protocol EntryPointProtocol | 65 | + | Protocol EntryPointProtocol |
| 65 | - | TrustedCIDRs []string | 66 | + | 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 int64 | 115 | TotalErrors int64 | ||
| 112 | BytesIn int64 | 116 | BytesIn int64 | ||
| 113 | BytesOut int64 | 117 | 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 int64 | 149 | TotalErrors int64 | ||
| 145 | BytesIn int64 | 150 | BytesIn int64 | ||
| 146 | BytesOut int64 | 151 | 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 string | 170 | type TrafficServiceRefKind string | ||
| @@ -330,26 +351,28 @@ func (g TrafficGraph) validateRouters(entryPoints map[string]EntryPoint, service | |||||
| 330 | return err | 351 | return err | ||
| 331 | } | 352 | } | ||
| 332 | } | 353 | } | ||
| 333 | - | return nil | 354 | + | 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]string | 359 | + | routersByName map[string]TrafficRouter |
| 339 | - | wildSNI map[string]map[string]string | 360 | + | exactSNI map[string]map[string]string |
| 340 | - | httpHosts map[string]map[string]string | 361 | + | wildSNI map[string]map[string]string |
| 341 | - | tcpRouters map[string]string | 362 | + | httpHosts map[string]map[string]string |
| 342 | - | udpRouters map[string]string | 363 | + | 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 nil | 408 | return nil | ||
| 385 | } | 409 | } | ||
| 386 | 410 | ||||
| @@ -492,11 +516,11 @@ func validateRouterEntryPointProtocol(router TrafficRouter, entryPoint EntryPoin | |||||
| 492 | compatible := false | 516 | compatible := false | ||
| 493 | switch router.Protocol { | 517 | switch router.Protocol { | ||
| 494 | case RouterProtocolHTTP: | 518 | case RouterProtocolHTTP: | ||
| 495 | - | compatible = entryPoint.Protocol == EntryPointProtocolHTTP || entryPoint.Protocol == EntryPointProtocolTLSMux | 519 | + | compatible = entryPoint.Protocol == EntryPointProtocolHTTP || entryPoint.Protocol == EntryPointProtocolTLSMux || entryPoint.Protocol == EntryPointProtocolSmartTCP |
| 496 | case RouterProtocolTLSPassthrough: | 520 | case RouterProtocolTLSPassthrough: | ||
| 497 | - | compatible = entryPoint.Protocol == EntryPointProtocolTLSMux | 521 | + | compatible = entryPoint.Protocol == EntryPointProtocolTLSMux || entryPoint.Protocol == EntryPointProtocolSmartTCP |
| 498 | case RouterProtocolTCP: | 522 | case RouterProtocolTCP: | ||
| 499 | - | compatible = entryPoint.Protocol == EntryPointProtocolTCP | 523 | + | compatible = entryPoint.Protocol == EntryPointProtocolTCP || (entryPoint.Protocol == EntryPointProtocolSmartTCP && entryPoint.RawFallback == router.Name) |
| 500 | case RouterProtocolUDP: | 524 | case RouterProtocolUDP: | ||
| 501 | compatible = entryPoint.Protocol == EntryPointProtocolUDP | 525 | 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 nil | 615 | 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 nil | 621 | 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 ( | |||
| 7 | 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 | 96 | func TestTrafficGraphValidateEntryPoints(t *testing.T) { | |
| 11 | 97 | tests := []struct { | |
| 12 | 98 | 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 string | 98 | name string | ||
M internal/usecase/traffic/builder.go +19 -30
| @@ -11,14 +11,10 @@ import ( | |||
| 11 | 11 | "github.com/bnema/gordon/internal/domain" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | - | const ( | |
| 15 | - | DefaultHTTPEntryPointName = "web" | |
| 16 | - | DefaultTLSEntryPointName = "websecure" | |
| 17 | - | ) | |
| 14 | + | const DefaultEdgeEntryPointName = "edge" | |
| 18 | 15 | ||
| 19 | 16 | // Input is the complete, usecase-level input used to build a validated traffic graph. | |
| 20 | 17 | type Input struct { | |
| 21 | - | Server ServerConfig | |
| 22 | 18 | EntryPoints map[string]EntryPointConfig | |
| 23 | 19 | Traffic Config | |
| 24 | 20 | Routes []domain.Route | |
| @@ -26,15 +22,13 @@ type Input struct { | |||
| 26 | 22 | NetworkServices []NetworkServiceConfig | |
| 27 | 23 | } | |
| 28 | 24 | ||
| 29 | - | type ServerConfig struct { | |
| 30 | - | Port int `mapstructure:"port"` | |
| 31 | - | TLSPort int `mapstructure:"tls_port"` | |
| 32 | - | } | |
| 33 | - | ||
| 34 | 25 | 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"` | |
| 38 | 32 | } | |
| 39 | 33 | ||
| 40 | 34 | // Config holds traffic router and option configuration. | |
| @@ -125,16 +119,18 @@ type builder struct { | |||
| 125 | 119 | } | |
| 126 | 120 | ||
| 127 | 121 | 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)) | |
| 135 | 123 | for _, name := range sortedKeys(b.input.EntryPoints) { | |
| 136 | 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 | 135 | return entries | |
| 140 | 136 | } | |
| @@ -143,10 +139,7 @@ func (b *builder) addHTTPRoutes(graph *domain.TrafficGraph) error { | |||
| 143 | 139 | routes := append([]domain.Route{}, b.input.Routes...) | |
| 144 | 140 | sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain }) | |
| 145 | 141 | for _, route := range routes { | |
| 146 | - | entryPoint := DefaultHTTPEntryPointName | |
| 147 | - | if route.HTTPS { | |
| 148 | - | entryPoint = DefaultTLSEntryPointName | |
| 149 | - | } | |
| 142 | + | entryPoint := DefaultEdgeEntryPointName | |
| 150 | 143 | serviceName := string(domain.TrafficServiceRefRoute) + ":" + route.Domain | |
| 151 | 144 | b.addService(domain.TrafficService{Name: serviceName}) | |
| 152 | 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 | 157 | serviceName := string(domain.TrafficServiceRefExternalRoute) + ":" + hostName | |
| 165 | 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 | 161 | return nil | |
| 169 | 162 | } | |
| @@ -355,10 +348,6 @@ func parseBackendAddress(value string) (string, int, error) { | |||
| 355 | 348 | return host, port, nil | |
| 356 | 349 | } | |
| 357 | 350 | ||
| 358 | - | func portAddress(port int) string { | |
| 359 | - | return ":" + strconv.Itoa(port) | |
| 360 | - | } | |
| 361 | - | ||
| 362 | 351 | func sortedKeys[V any](m map[string]V) []string { | |
| 363 | 352 | keys := make([]string, 0, len(m)) | |
| 364 | 353 | 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]EntryPointConfig | 18 | EntryPoints map[string]EntryPointConfig | ||
| 23 | Traffic Config | 19 | Traffic Config | ||
| 24 | Routes []domain.Route | 20 | Routes []domain.Route | ||
| @@ -26,15 +22,13 @@ type Input struct { | |||||
| 26 | NetworkServices []NetworkServiceConfig | 22 | 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 entries | 135 | 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 := DefaultHTTPEntryPointName | 142 | + | entryPoint := DefaultEdgeEntryPointName |
| 147 | - | if route.HTTPS { | |||
| 148 | - | entryPoint = DefaultTLSEntryPointName | |||
| 149 | - | } | |||
| 150 | serviceName := string(domain.TrafficServiceRefRoute) + ":" + route.Domain | 143 | 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) + ":" + hostName | 157 | 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 nil | 161 | return nil | ||
| 169 | } | 162 | } | ||
| @@ -355,10 +348,6 @@ func parseBackendAddress(value string) (string, int, error) { | |||||
| 355 | return host, port, nil | 348 | 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 ( | |||
| 9 | 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 | 49 | func TestBuildDefaultsAndHTTPRoutes(t *testing.T) { | |
| 13 | 50 | graph, err := Build(Input{ | |
| 14 | - | Server: ServerConfig{Port: 80, TLSPort: 443}, | |
| 51 | + | EntryPoints: map[string]EntryPointConfig{"edge": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}}, | |
| 15 | 52 | Routes: []domain.Route{{Domain: "app.example.com"}, {Domain: "secure.example.com", HTTPS: true}}, | |
| 16 | 53 | ExternalRoutes: map[string]string{"ext.example.com": "backend.local:8080"}, | |
| 17 | 54 | }) | |
| 18 | 55 | require.NoError(t, err) | |
| 19 | 56 | ||
| 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"}) | |
| 24 | 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 | 102 | func TestBuildRejectsTLSHTTPRouteConflict(t *testing.T) { | |
| 66 | 103 | _, err := Build(Input{ | |
| 67 | - | Server: ServerConfig{TLSPort: 443}, | |
| 104 | + | EntryPoints: map[string]EntryPointConfig{"edge": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}}, | |
| 68 | 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 | 107 | NetworkServices: []NetworkServiceConfig{{Name: "app", Ports: []PortConfig{{Name: "https", Container: 443, Protocol: domain.NetworkProtocolTCP}}}}, | |
| 71 | 108 | }) | |
| 72 | 109 | 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") | ||