detached f46f30c

fix(traffic): enforce listener trust boundaries

commit
f46f30cd67b4ac7ff0f3d6a53900795599218b19
author
brice · · 2026-06-30 02:41 UTC
parent
1955dc9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +260 -23
M docs/config/traffic.md +4 -4
@@ -50,7 +50,7 @@ Service references use:
5050 dial_timeout = "10s"
5151 idle_timeout = "5m"
5252 drain_timeout = "30s"
53-max_connections = 0
53+max_connections = 1024
5454
5555 [[traffic.tcp.routers]]
5656 name = "postgres"
@@ -58,7 +58,7 @@ entrypoint = "postgres"
5858 service = "network_service:postgres:db"
5959 ```
6060
61-A plain TCP entrypoint supports one TCP router.
61+A plain TCP entrypoint supports one TCP router. If `max_connections` is omitted or set to `0`, Gordon applies the safe runtime default of `1024` active connections per entrypoint.
6262
6363 ## UDP Routers
6464
@@ -66,7 +66,7 @@ A plain TCP entrypoint supports one TCP router.
6666 [traffic.udp]
6767 idle_timeout = "30s"
6868 drain_timeout = "30s"
69-max_sessions = 0
69+max_sessions = 4096
7070
7171 [[traffic.udp.routers]]
7272 name = "game"
@@ -74,7 +74,7 @@ entrypoint = "game"
7474 service = "network_service:game:game"
7575 ```
7676
77-UDP sessions are keyed by client address and expire after `idle_timeout`.
77+UDP sessions are keyed by client address and expire after `idle_timeout`. If `max_sessions` is omitted or set to `0`, Gordon applies the safe runtime default of `4096` active sessions per entrypoint.
7878
7979 ## TLS Passthrough
8080
@@ -50,7 +50,7 @@ Service references use:
50 dial_timeout = "10s"50 dial_timeout = "10s"
51 idle_timeout = "5m"51 idle_timeout = "5m"
52 drain_timeout = "30s"52 drain_timeout = "30s"
53-max_connections = 053+max_connections = 1024
54 54
55 [[traffic.tcp.routers]]55 [[traffic.tcp.routers]]
56 name = "postgres"56 name = "postgres"
@@ -58,7 +58,7 @@ entrypoint = "postgres"
58 service = "network_service:postgres:db"58 service = "network_service:postgres:db"
59 ```59 ```
60 60
61-A plain TCP entrypoint supports one TCP router.61+A plain TCP entrypoint supports one TCP router. If `max_connections` is omitted or set to `0`, Gordon applies the safe runtime default of `1024` active connections per entrypoint.
62 62
63 ## UDP Routers63 ## UDP Routers
64 64
@@ -66,7 +66,7 @@ A plain TCP entrypoint supports one TCP router.
66 [traffic.udp]66 [traffic.udp]
67 idle_timeout = "30s"67 idle_timeout = "30s"
68 drain_timeout = "30s"68 drain_timeout = "30s"
69-max_sessions = 069+max_sessions = 4096
70 70
71 [[traffic.udp.routers]]71 [[traffic.udp.routers]]
72 name = "game"72 name = "game"
@@ -74,7 +74,7 @@ entrypoint = "game"
74 service = "network_service:game:game"74 service = "network_service:game:game"
75 ```75 ```
76 76
77-UDP sessions are keyed by client address and expire after `idle_timeout`.77+UDP sessions are keyed by client address and expire after `idle_timeout`. If `max_sessions` is omitted or set to `0`, Gordon applies the safe runtime default of `4096` active sessions per entrypoint.
78 78
79 ## TLS Passthrough79 ## TLS Passthrough
80 80
M internal/adapters/in/traffic/manager.go +87 -6
@@ -7,6 +7,7 @@ import (
77 "fmt"
88 "net"
99 "sort"
10+ "strings"
1011 "sync"
1112 "sync/atomic"
1213 "time"
@@ -243,6 +244,11 @@ func (m *Manager) prepareTCPListeners(ctx context.Context, graph *domain.Traffic
243244 next[entryPoint.Name] = runtime
244245 continue
245246 }
247+ if runtime := conflictingTCPRuntime(current, entryPoint); runtime != nil {
248+ trafficInfo(ctx).Str("entrypoint", runtime.entryPoint.Name).Str("address", runtime.entryPoint.Address).Msg("stopping tcp traffic entrypoint before same-address replacement")
249+ runtime.stop(ctx, effectiveTCPOptions(graph.Options.TCP).DrainTimeout)
250+ delete(current, runtime.entryPoint.Name)
251+ }
246252 runtime, err := m.bindTCPEntryPoint(ctx, entryPoint)
247253 if err != nil {
248254 for _, createdRuntime := range created {
@@ -260,13 +266,27 @@ func isTCPListenerProtocol(protocol domain.EntryPointProtocol) bool {
260266 return protocol == domain.EntryPointProtocolTCP || protocol == domain.EntryPointProtocolTLSMux
261267 }
262268
269+func conflictingTCPRuntime(current map[string]*entryPointRuntime, entryPoint domain.EntryPoint) *entryPointRuntime {
270+ for _, runtime := range current {
271+ if runtime.entryPoint.Address == entryPoint.Address {
272+ return runtime
273+ }
274+ }
275+ return nil
276+}
277+
263278 func (m *Manager) bindTCPEntryPoint(ctx context.Context, entryPoint domain.EntryPoint) (*entryPointRuntime, error) {
264279 listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", entryPoint.Address)
265280 if err != nil {
266281 return nil, fmt.Errorf("bind tcp entrypoint %q on %s: %w", entryPoint.Name, entryPoint.Address, err)
267282 }
283+ trusted, err := parseTrustedCIDRs(entryPoint.TrustedCIDRs)
284+ if err != nil {
285+ _ = listener.Close()
286+ return nil, fmt.Errorf("parse trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)
287+ }
268288 trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("bound tcp traffic entrypoint")
269- runtime := newEntryPointRuntime(ctx, m, entryPoint, listener)
289+ runtime := newEntryPointRuntime(ctx, m, entryPoint, listener, trusted)
270290 return runtime, nil
271291 }
272292
@@ -286,6 +306,11 @@ func (m *Manager) prepareUDPListeners(ctx context.Context, graph *domain.Traffic
286306 next[entryPoint.Name] = runtime
287307 continue
288308 }
309+ if runtime := conflictingUDPRuntime(current, entryPoint); runtime != nil {
310+ trafficInfo(ctx).Str("entrypoint", runtime.entryPoint.Name).Str("address", runtime.entryPoint.Address).Msg("stopping udp traffic entrypoint before same-address replacement")
311+ runtime.stop(ctx, effectiveUDPOptions(graph.Options.UDP).DrainTimeout)
312+ delete(current, runtime.entryPoint.Name)
313+ }
289314 runtime, err := m.bindUDPEntryPoint(ctx, entryPoint)
290315 if err != nil {
291316 for _, createdRuntime := range created {
@@ -299,13 +324,62 @@ func (m *Manager) prepareUDPListeners(ctx context.Context, graph *domain.Traffic
299324 return next, created, nil
300325 }
301326
327+func conflictingUDPRuntime(current map[string]*udpEntryPointRuntime, entryPoint domain.EntryPoint) *udpEntryPointRuntime {
328+ for _, runtime := range current {
329+ if runtime.entryPoint.Address == entryPoint.Address {
330+ return runtime
331+ }
332+ }
333+ return nil
334+}
335+
302336 func (m *Manager) bindUDPEntryPoint(ctx context.Context, entryPoint domain.EntryPoint) (*udpEntryPointRuntime, error) {
303337 packetConn, err := (&net.ListenConfig{}).ListenPacket(ctx, "udp", entryPoint.Address)
304338 if err != nil {
305339 return nil, fmt.Errorf("bind udp entrypoint %q on %s: %w", entryPoint.Name, entryPoint.Address, err)
306340 }
341+ trusted, err := parseTrustedCIDRs(entryPoint.TrustedCIDRs)
342+ if err != nil {
343+ _ = packetConn.Close()
344+ return nil, fmt.Errorf("parse trusted cidrs for udp entrypoint %q: %w", entryPoint.Name, err)
345+ }
307346 trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("bound udp traffic entrypoint")
308- return newUDPEntryPointRuntime(ctx, m, entryPoint, packetConn), nil
347+ return newUDPEntryPointRuntime(ctx, m, entryPoint, packetConn, trusted), nil
348+}
349+
350+func parseTrustedCIDRs(values []string) ([]*net.IPNet, error) {
351+ if len(values) == 0 {
352+ return nil, nil
353+ }
354+ trusted := make([]*net.IPNet, 0, len(values))
355+ for _, value := range values {
356+ _, network, err := net.ParseCIDR(strings.TrimSpace(value))
357+ if err != nil {
358+ return nil, err
359+ }
360+ trusted = append(trusted, network)
361+ }
362+ return trusted, nil
363+}
364+
365+func trustedRemoteAddr(trusted []*net.IPNet, addr net.Addr) bool {
366+ if len(trusted) == 0 {
367+ return true
368+ }
369+ host, _, err := net.SplitHostPort(addr.String())
370+ if err != nil {
371+ return false
372+ }
373+ ip := net.ParseIP(host)
374+ if ip == nil {
375+ return false
376+ }
377+ for _, network := range trusted {
378+ if network.Contains(ip) {
379+ return true
380+ }
381+ }
382+ return false
309383 }
310384
311385 func entryPointStatuses(entries []domain.EntryPoint, listeners map[string]*entryPointRuntime, udpListeners map[string]*udpEntryPointRuntime) []domain.EntryPointStatus {
@@ -442,9 +516,10 @@ func (c *trafficCounters) snapshot() domain.TrafficCounters {
442516
443517 func defaultTCPOptions() domain.TCPOptions {
444518 return domain.TCPOptions{
445- DialTimeout: 10 * time.Second,
446- IdleTimeout: 5 * time.Minute,
447- DrainTimeout: 30 * time.Second,
519+ DialTimeout: 10 * time.Second,
520+ IdleTimeout: 5 * time.Minute,
521+ DrainTimeout: 30 * time.Second,
522+ MaxConnections: 1024,
448523 }
449524 }
450525
@@ -459,11 +534,14 @@ func effectiveTCPOptions(options domain.TCPOptions) domain.TCPOptions {
459534 if options.DrainTimeout == 0 {
460535 options.DrainTimeout = defaults.DrainTimeout
461536 }
537+ if options.MaxConnections <= 0 {
538+ options.MaxConnections = defaults.MaxConnections
539+ }
462540 return options
463541 }
464542
465543 func defaultUDPOptions() domain.UDPOptions {
466- return domain.UDPOptions{IdleTimeout: 30 * time.Second, DrainTimeout: 30 * time.Second}
544+ return domain.UDPOptions{IdleTimeout: 30 * time.Second, DrainTimeout: 30 * time.Second, MaxSessions: 4096}
467545 }
468546
469547 func effectiveUDPOptions(options domain.UDPOptions) domain.UDPOptions {
@@ -474,5 +552,8 @@ func effectiveUDPOptions(options domain.UDPOptions) domain.UDPOptions {
474552 if options.DrainTimeout == 0 {
475553 options.DrainTimeout = defaults.DrainTimeout
476554 }
555+ if options.MaxSessions <= 0 {
556+ options.MaxSessions = defaults.MaxSessions
557+ }
477558 return options
478559 }
@@ -7,6 +7,7 @@ import (
7 "fmt"7 "fmt"
8 "net"8 "net"
9 "sort"9 "sort"
10+ "strings"
10 "sync"11 "sync"
11 "sync/atomic"12 "sync/atomic"
12 "time"13 "time"
@@ -243,6 +244,11 @@ func (m *Manager) prepareTCPListeners(ctx context.Context, graph *domain.Traffic
243 next[entryPoint.Name] = runtime244 next[entryPoint.Name] = runtime
244 continue245 continue
245 }246 }
247+ if runtime := conflictingTCPRuntime(current, entryPoint); runtime != nil {
248+ trafficInfo(ctx).Str("entrypoint", runtime.entryPoint.Name).Str("address", runtime.entryPoint.Address).Msg("stopping tcp traffic entrypoint before same-address replacement")
249+ runtime.stop(ctx, effectiveTCPOptions(graph.Options.TCP).DrainTimeout)
250+ delete(current, runtime.entryPoint.Name)
251+ }
246 runtime, err := m.bindTCPEntryPoint(ctx, entryPoint)252 runtime, err := m.bindTCPEntryPoint(ctx, entryPoint)
247 if err != nil {253 if err != nil {
248 for _, createdRuntime := range created {254 for _, createdRuntime := range created {
@@ -260,13 +266,27 @@ func isTCPListenerProtocol(protocol domain.EntryPointProtocol) bool {
260 return protocol == domain.EntryPointProtocolTCP || protocol == domain.EntryPointProtocolTLSMux266 return protocol == domain.EntryPointProtocolTCP || protocol == domain.EntryPointProtocolTLSMux
261 }267 }
262 268
269+func conflictingTCPRuntime(current map[string]*entryPointRuntime, entryPoint domain.EntryPoint) *entryPointRuntime {
270+ for _, runtime := range current {
271+ if runtime.entryPoint.Address == entryPoint.Address {
272+ return runtime
273+ }
274+ }
275+ return nil
276+}
277+
263 func (m *Manager) bindTCPEntryPoint(ctx context.Context, entryPoint domain.EntryPoint) (*entryPointRuntime, error) {278 func (m *Manager) bindTCPEntryPoint(ctx context.Context, entryPoint domain.EntryPoint) (*entryPointRuntime, error) {
264 listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", entryPoint.Address)279 listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", entryPoint.Address)
265 if err != nil {280 if err != nil {
266 return nil, fmt.Errorf("bind tcp entrypoint %q on %s: %w", entryPoint.Name, entryPoint.Address, err)281 return nil, fmt.Errorf("bind tcp entrypoint %q on %s: %w", entryPoint.Name, entryPoint.Address, err)
267 }282 }
283+ trusted, err := parseTrustedCIDRs(entryPoint.TrustedCIDRs)
284+ if err != nil {
285+ _ = listener.Close()
286+ return nil, fmt.Errorf("parse trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)
287+ }
268 trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("bound tcp traffic entrypoint")288 trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("bound tcp traffic entrypoint")
269- runtime := newEntryPointRuntime(ctx, m, entryPoint, listener)289+ runtime := newEntryPointRuntime(ctx, m, entryPoint, listener, trusted)
270 return runtime, nil290 return runtime, nil
271 }291 }
272 292
@@ -286,6 +306,11 @@ func (m *Manager) prepareUDPListeners(ctx context.Context, graph *domain.Traffic
286 next[entryPoint.Name] = runtime306 next[entryPoint.Name] = runtime
287 continue307 continue
288 }308 }
309+ if runtime := conflictingUDPRuntime(current, entryPoint); runtime != nil {
310+ trafficInfo(ctx).Str("entrypoint", runtime.entryPoint.Name).Str("address", runtime.entryPoint.Address).Msg("stopping udp traffic entrypoint before same-address replacement")
311+ runtime.stop(ctx, effectiveUDPOptions(graph.Options.UDP).DrainTimeout)
312+ delete(current, runtime.entryPoint.Name)
313+ }
289 runtime, err := m.bindUDPEntryPoint(ctx, entryPoint)314 runtime, err := m.bindUDPEntryPoint(ctx, entryPoint)
290 if err != nil {315 if err != nil {
291 for _, createdRuntime := range created {316 for _, createdRuntime := range created {
@@ -299,13 +324,62 @@ func (m *Manager) prepareUDPListeners(ctx context.Context, graph *domain.Traffic
299 return next, created, nil324 return next, created, nil
300 }325 }
301 326
327+func conflictingUDPRuntime(current map[string]*udpEntryPointRuntime, entryPoint domain.EntryPoint) *udpEntryPointRuntime {
328+ for _, runtime := range current {
329+ if runtime.entryPoint.Address == entryPoint.Address {
330+ return runtime
331+ }
332+ }
333+ return nil
334+}
335+
302 func (m *Manager) bindUDPEntryPoint(ctx context.Context, entryPoint domain.EntryPoint) (*udpEntryPointRuntime, error) {336 func (m *Manager) bindUDPEntryPoint(ctx context.Context, entryPoint domain.EntryPoint) (*udpEntryPointRuntime, error) {
303 packetConn, err := (&net.ListenConfig{}).ListenPacket(ctx, "udp", entryPoint.Address)337 packetConn, err := (&net.ListenConfig{}).ListenPacket(ctx, "udp", entryPoint.Address)
304 if err != nil {338 if err != nil {
305 return nil, fmt.Errorf("bind udp entrypoint %q on %s: %w", entryPoint.Name, entryPoint.Address, err)339 return nil, fmt.Errorf("bind udp entrypoint %q on %s: %w", entryPoint.Name, entryPoint.Address, err)
306 }340 }
341+ trusted, err := parseTrustedCIDRs(entryPoint.TrustedCIDRs)
342+ if err != nil {
343+ _ = packetConn.Close()
344+ return nil, fmt.Errorf("parse trusted cidrs for udp entrypoint %q: %w", entryPoint.Name, err)
345+ }
307 trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("bound udp traffic entrypoint")346 trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("bound udp traffic entrypoint")
308- return newUDPEntryPointRuntime(ctx, m, entryPoint, packetConn), nil347+ return newUDPEntryPointRuntime(ctx, m, entryPoint, packetConn, trusted), nil
348+}
349+
350+func parseTrustedCIDRs(values []string) ([]*net.IPNet, error) {
351+ if len(values) == 0 {
352+ return nil, nil
353+ }
354+ trusted := make([]*net.IPNet, 0, len(values))
355+ for _, value := range values {
356+ _, network, err := net.ParseCIDR(strings.TrimSpace(value))
357+ if err != nil {
358+ return nil, err
359+ }
360+ trusted = append(trusted, network)
361+ }
362+ return trusted, nil
363+}
364+
365+func trustedRemoteAddr(trusted []*net.IPNet, addr net.Addr) bool {
366+ if len(trusted) == 0 {
367+ return true
368+ }
369+ host, _, err := net.SplitHostPort(addr.String())
370+ if err != nil {
371+ return false
372+ }
373+ ip := net.ParseIP(host)
374+ if ip == nil {
375+ return false
376+ }
377+ for _, network := range trusted {
378+ if network.Contains(ip) {
379+ return true
380+ }
381+ }
382+ return false
309 }383 }
310 384
311 func entryPointStatuses(entries []domain.EntryPoint, listeners map[string]*entryPointRuntime, udpListeners map[string]*udpEntryPointRuntime) []domain.EntryPointStatus {385 func entryPointStatuses(entries []domain.EntryPoint, listeners map[string]*entryPointRuntime, udpListeners map[string]*udpEntryPointRuntime) []domain.EntryPointStatus {
@@ -442,9 +516,10 @@ func (c *trafficCounters) snapshot() domain.TrafficCounters {
442 516
443 func defaultTCPOptions() domain.TCPOptions {517 func defaultTCPOptions() domain.TCPOptions {
444 return domain.TCPOptions{518 return domain.TCPOptions{
445- DialTimeout: 10 * time.Second,519+ DialTimeout: 10 * time.Second,
446- IdleTimeout: 5 * time.Minute,520+ IdleTimeout: 5 * time.Minute,
447- DrainTimeout: 30 * time.Second,521+ DrainTimeout: 30 * time.Second,
522+ MaxConnections: 1024,
448 }523 }
449 }524 }
450 525
@@ -459,11 +534,14 @@ func effectiveTCPOptions(options domain.TCPOptions) domain.TCPOptions {
459 if options.DrainTimeout == 0 {534 if options.DrainTimeout == 0 {
460 options.DrainTimeout = defaults.DrainTimeout535 options.DrainTimeout = defaults.DrainTimeout
461 }536 }
537+ if options.MaxConnections <= 0 {
538+ options.MaxConnections = defaults.MaxConnections
539+ }
462 return options540 return options
463 }541 }
464 542
465 func defaultUDPOptions() domain.UDPOptions {543 func defaultUDPOptions() domain.UDPOptions {
466- return domain.UDPOptions{IdleTimeout: 30 * time.Second, DrainTimeout: 30 * time.Second}544+ return domain.UDPOptions{IdleTimeout: 30 * time.Second, DrainTimeout: 30 * time.Second, MaxSessions: 4096}
467 }545 }
468 546
469 func effectiveUDPOptions(options domain.UDPOptions) domain.UDPOptions {547 func effectiveUDPOptions(options domain.UDPOptions) domain.UDPOptions {
@@ -474,5 +552,8 @@ func effectiveUDPOptions(options domain.UDPOptions) domain.UDPOptions {
474 if options.DrainTimeout == 0 {552 if options.DrainTimeout == 0 {
475 options.DrainTimeout = defaults.DrainTimeout553 options.DrainTimeout = defaults.DrainTimeout
476 }554 }
555+ if options.MaxSessions <= 0 {
556+ options.MaxSessions = defaults.MaxSessions
557+ }
477 return options558 return options
478 }559 }
M internal/adapters/in/traffic/tcp.go +8 -1
@@ -20,6 +20,7 @@ type entryPointRuntime struct {
2020 entryPoint domain.EntryPoint
2121 listener net.Listener
2222 counters trafficCounters
23+ trusted []*net.IPNet
2324
2425 started atomic.Bool
2526 closed atomic.Bool
@@ -39,12 +40,13 @@ type trackedTCPConn struct {
3940 backend net.Conn
4041 }
4142
42-func newEntryPointRuntime(parentCtx context.Context, manager *Manager, entryPoint domain.EntryPoint, listener net.Listener) *entryPointRuntime {
43+func newEntryPointRuntime(parentCtx context.Context, manager *Manager, entryPoint domain.EntryPoint, listener net.Listener, trusted []*net.IPNet) *entryPointRuntime {
4344 ctx, cancel := context.WithCancel(context.WithoutCancel(parentCtx))
4445 return &entryPointRuntime{
4546 manager: manager,
4647 entryPoint: entryPoint,
4748 listener: listener,
49+ trusted: trusted,
4850 ctx: ctx,
4951 cancel: cancel,
5052 acceptDone: make(chan struct{}),
@@ -75,6 +77,11 @@ func (r *entryPointRuntime) acceptLoop() {
7577 trafficWarn(r.ctx).Err(err).Str("entrypoint", r.entryPoint.Name).Msg("tcp traffic accept loop stopped")
7678 return
7779 }
80+ if !trustedRemoteAddr(r.trusted, conn.RemoteAddr()) {
81+ r.counters.totalRefused.Add(1)
82+ _ = conn.Close()
83+ continue
84+ }
7885 r.activeWG.Add(1)
7986 go r.handleTCPConn(conn)
8087 }
@@ -20,6 +20,7 @@ type entryPointRuntime struct {
20 entryPoint domain.EntryPoint20 entryPoint domain.EntryPoint
21 listener net.Listener21 listener net.Listener
22 counters trafficCounters22 counters trafficCounters
23+ trusted []*net.IPNet
23 24
24 started atomic.Bool25 started atomic.Bool
25 closed atomic.Bool26 closed atomic.Bool
@@ -39,12 +40,13 @@ type trackedTCPConn struct {
39 backend net.Conn40 backend net.Conn
40 }41 }
41 42
42-func newEntryPointRuntime(parentCtx context.Context, manager *Manager, entryPoint domain.EntryPoint, listener net.Listener) *entryPointRuntime {43+func newEntryPointRuntime(parentCtx context.Context, manager *Manager, entryPoint domain.EntryPoint, listener net.Listener, trusted []*net.IPNet) *entryPointRuntime {
43 ctx, cancel := context.WithCancel(context.WithoutCancel(parentCtx))44 ctx, cancel := context.WithCancel(context.WithoutCancel(parentCtx))
44 return &entryPointRuntime{45 return &entryPointRuntime{
45 manager: manager,46 manager: manager,
46 entryPoint: entryPoint,47 entryPoint: entryPoint,
47 listener: listener,48 listener: listener,
49+ trusted: trusted,
48 ctx: ctx,50 ctx: ctx,
49 cancel: cancel,51 cancel: cancel,
50 acceptDone: make(chan struct{}),52 acceptDone: make(chan struct{}),
@@ -75,6 +77,11 @@ func (r *entryPointRuntime) acceptLoop() {
75 trafficWarn(r.ctx).Err(err).Str("entrypoint", r.entryPoint.Name).Msg("tcp traffic accept loop stopped")77 trafficWarn(r.ctx).Err(err).Str("entrypoint", r.entryPoint.Name).Msg("tcp traffic accept loop stopped")
76 return78 return
77 }79 }
80+ if !trustedRemoteAddr(r.trusted, conn.RemoteAddr()) {
81+ r.counters.totalRefused.Add(1)
82+ _ = conn.Close()
83+ continue
84+ }
78 r.activeWG.Add(1)85 r.activeWG.Add(1)
79 go r.handleTCPConn(conn)86 go r.handleTCPConn(conn)
80 }87 }
M internal/adapters/in/traffic/tcp_test.go +41 -0
@@ -14,6 +14,11 @@ import (
1414 "github.com/bnema/gordon/internal/domain"
1515 )
1616
17+func TestTrafficRuntimeDefaultsApplySafeLimits(t *testing.T) {
18+ assert.Equal(t, 1024, effectiveTCPOptions(domain.TCPOptions{}).MaxConnections)
19+ assert.Equal(t, 4096, effectiveUDPOptions(domain.UDPOptions{}).MaxSessions)
20+}
21+
1722 func TestTCPManagerLifecycle(t *testing.T) {
1823 backend := startTCPEchoServer(t, 0)
1924 graph := tcpGraph(t, freeTCPAddress(t), backend.address)
@@ -56,6 +61,42 @@ func TestTCPManagerRejectsInvalidGraphWithoutReplacingSnapshot(t *testing.T) {
5661 assertRoundTrip(t, conn, "still works")
5762 }
5863
64+func TestTCPManagerReplacesSameAddressEntrypoint(t *testing.T) {
65+ backend := startTCPEchoServer(t, 0)
66+ graph := tcpGraph(t, freeTCPAddress(t), backend.address)
67+ manager := NewManager()
68+ require.NoError(t, manager.Apply(context.Background(), &graph))
69+ defer shutdownManager(t, manager)
70+
71+ replacement := graph
72+ replacement.EntryPoints = []domain.EntryPoint{{Name: "postgres-tls", Address: graph.EntryPoints[0].Address, Protocol: domain.EntryPointProtocolTLSMux}}
73+ replacement.Routers = nil
74+ replacement.Services = nil
75+ require.NoError(t, manager.Apply(context.Background(), &replacement))
76+
77+ status := manager.Status()
78+ require.Len(t, status.EntryPoints, 1)
79+ assert.Equal(t, "postgres-tls", status.EntryPoints[0].Name)
80+ assert.Equal(t, domain.EntryPointProtocolTLSMux, status.EntryPoints[0].Protocol)
81+}
82+
83+func TestTCPPassthroughRejectsUntrustedCIDR(t *testing.T) {
84+ backend := startTCPEchoServer(t, 0)
85+ graph := tcpGraph(t, freeTCPAddress(t), backend.address)
86+ graph.EntryPoints[0].TrustedCIDRs = []string{"192.0.2.0/24"}
87+ manager := NewManager()
88+ require.NoError(t, manager.Apply(context.Background(), &graph))
89+ defer shutdownManager(t, manager)
90+
91+ conn := dialTCP(t, graph.EntryPoints[0].Address)
92+ defer conn.Close()
93+ _, err := conn.Write([]byte("blocked"))
94+ require.NoError(t, err)
95+ _, err = bufio.NewReader(conn).ReadByte()
96+ require.Error(t, err)
97+ assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == 1 }, time.Second, 10*time.Millisecond)
98+}
99+
59100 func TestTCPPassthroughEcho(t *testing.T) {
60101 backend := startTCPEchoServer(t, 0)
61102 graph := tcpGraph(t, freeTCPAddress(t), backend.address)
@@ -14,6 +14,11 @@ import (
14 "github.com/bnema/gordon/internal/domain"14 "github.com/bnema/gordon/internal/domain"
15 )15 )
16 16
17+func TestTrafficRuntimeDefaultsApplySafeLimits(t *testing.T) {
18+ assert.Equal(t, 1024, effectiveTCPOptions(domain.TCPOptions{}).MaxConnections)
19+ assert.Equal(t, 4096, effectiveUDPOptions(domain.UDPOptions{}).MaxSessions)
20+}
21+
17 func TestTCPManagerLifecycle(t *testing.T) {22 func TestTCPManagerLifecycle(t *testing.T) {
18 backend := startTCPEchoServer(t, 0)23 backend := startTCPEchoServer(t, 0)
19 graph := tcpGraph(t, freeTCPAddress(t), backend.address)24 graph := tcpGraph(t, freeTCPAddress(t), backend.address)
@@ -56,6 +61,42 @@ func TestTCPManagerRejectsInvalidGraphWithoutReplacingSnapshot(t *testing.T) {
56 assertRoundTrip(t, conn, "still works")61 assertRoundTrip(t, conn, "still works")
57 }62 }
58 63
64+func TestTCPManagerReplacesSameAddressEntrypoint(t *testing.T) {
65+ backend := startTCPEchoServer(t, 0)
66+ graph := tcpGraph(t, freeTCPAddress(t), backend.address)
67+ manager := NewManager()
68+ require.NoError(t, manager.Apply(context.Background(), &graph))
69+ defer shutdownManager(t, manager)
70+
71+ replacement := graph
72+ replacement.EntryPoints = []domain.EntryPoint{{Name: "postgres-tls", Address: graph.EntryPoints[0].Address, Protocol: domain.EntryPointProtocolTLSMux}}
73+ replacement.Routers = nil
74+ replacement.Services = nil
75+ require.NoError(t, manager.Apply(context.Background(), &replacement))
76+
77+ status := manager.Status()
78+ require.Len(t, status.EntryPoints, 1)
79+ assert.Equal(t, "postgres-tls", status.EntryPoints[0].Name)
80+ assert.Equal(t, domain.EntryPointProtocolTLSMux, status.EntryPoints[0].Protocol)
81+}
82+
83+func TestTCPPassthroughRejectsUntrustedCIDR(t *testing.T) {
84+ backend := startTCPEchoServer(t, 0)
85+ graph := tcpGraph(t, freeTCPAddress(t), backend.address)
86+ graph.EntryPoints[0].TrustedCIDRs = []string{"192.0.2.0/24"}
87+ manager := NewManager()
88+ require.NoError(t, manager.Apply(context.Background(), &graph))
89+ defer shutdownManager(t, manager)
90+
91+ conn := dialTCP(t, graph.EntryPoints[0].Address)
92+ defer conn.Close()
93+ _, err := conn.Write([]byte("blocked"))
94+ require.NoError(t, err)
95+ _, err = bufio.NewReader(conn).ReadByte()
96+ require.Error(t, err)
97+ assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == 1 }, time.Second, 10*time.Millisecond)
98+}
99+
59 func TestTCPPassthroughEcho(t *testing.T) {100 func TestTCPPassthroughEcho(t *testing.T) {
60 backend := startTCPEchoServer(t, 0)101 backend := startTCPEchoServer(t, 0)
61 graph := tcpGraph(t, freeTCPAddress(t), backend.address)102 graph := tcpGraph(t, freeTCPAddress(t), backend.address)
M internal/adapters/in/traffic/tlsmux.go +30 -10
@@ -79,14 +79,10 @@ func parseClientHelloSNI(data []byte) (string, bool, error) {
7979 if data[0] != 22 {
8080 return "", false, fmt.Errorf("tls record is not a handshake")
8181 }
82- recordLen := int(data[3])<<8 | int(data[4])
83- if recordLen <= 0 {
84- return "", false, fmt.Errorf("tls handshake record is empty")
85- }
86- if len(data) < 5+recordLen {
87- return "", false, nil
82+ record, consumed, complete, err := tlsRecordPayload(data, 0)
83+ if err != nil || !complete {
84+ return "", complete, err
8885 }
89- record := data[5 : 5+recordLen]
9086 if len(record) < 4 {
9187 return "", true, fmt.Errorf("tls handshake is truncated")
9288 }
@@ -94,13 +90,37 @@ func parseClientHelloSNI(data []byte) (string, bool, error) {
9490 return "", true, fmt.Errorf("tls handshake is not client hello")
9591 }
9692 handshakeLen := int(record[1])<<16 | int(record[2])<<8 | int(record[3])
97- if handshakeLen > len(record)-4 {
98- return "", false, nil
93+ handshake := append([]byte(nil), record[4:]...)
94+ for len(handshake) < handshakeLen {
95+ if len(data) < consumed+5 {
96+ return "", false, nil
97+ }
98+ next, nextConsumed, nextComplete, err := tlsRecordPayload(data, consumed)
99+ if err != nil || !nextComplete {
100+ return "", nextComplete, err
101+ }
102+ handshake = append(handshake, next...)
103+ consumed = nextConsumed
99104 }
100- sni, err := parseClientHelloExtensions(record[4 : 4+handshakeLen])
105+ sni, err := parseClientHelloExtensions(handshake[:handshakeLen])
101106 return sni, true, err
102107 }
103108
109+func tlsRecordPayload(data []byte, offset int) ([]byte, int, bool, error) {
110+ if data[offset] != 22 {
111+ return nil, offset, false, fmt.Errorf("tls record is not a handshake")
112+ }
113+ recordLen := int(data[offset+3])<<8 | int(data[offset+4])
114+ if recordLen <= 0 {
115+ return nil, offset, false, fmt.Errorf("tls handshake record is empty")
116+ }
117+ end := offset + 5 + recordLen
118+ if len(data) < end {
119+ return nil, offset, false, nil
120+ }
121+ return data[offset+5 : end], end, true, nil
122+}
123+
104124 func parseClientHelloExtensions(body []byte) (string, error) {
105125 reader := bytes.NewReader(body)
106126 if reader.Len() < 34 {
@@ -79,14 +79,10 @@ func parseClientHelloSNI(data []byte) (string, bool, error) {
79 if data[0] != 22 {79 if data[0] != 22 {
80 return "", false, fmt.Errorf("tls record is not a handshake")80 return "", false, fmt.Errorf("tls record is not a handshake")
81 }81 }
82- recordLen := int(data[3])<<8 | int(data[4])82+ record, consumed, complete, err := tlsRecordPayload(data, 0)
83- if recordLen <= 0 {83+ if err != nil || !complete {
84- return "", false, fmt.Errorf("tls handshake record is empty")84+ return "", complete, err
85- }
86- if len(data) < 5+recordLen {
87- return "", false, nil
88 }85 }
89- record := data[5 : 5+recordLen]
90 if len(record) < 4 {86 if len(record) < 4 {
91 return "", true, fmt.Errorf("tls handshake is truncated")87 return "", true, fmt.Errorf("tls handshake is truncated")
92 }88 }
@@ -94,13 +90,37 @@ func parseClientHelloSNI(data []byte) (string, bool, error) {
94 return "", true, fmt.Errorf("tls handshake is not client hello")90 return "", true, fmt.Errorf("tls handshake is not client hello")
95 }91 }
96 handshakeLen := int(record[1])<<16 | int(record[2])<<8 | int(record[3])92 handshakeLen := int(record[1])<<16 | int(record[2])<<8 | int(record[3])
97- if handshakeLen > len(record)-4 {93+ handshake := append([]byte(nil), record[4:]...)
98- return "", false, nil94+ for len(handshake) < handshakeLen {
95+ if len(data) < consumed+5 {
96+ return "", false, nil
97+ }
98+ next, nextConsumed, nextComplete, err := tlsRecordPayload(data, consumed)
99+ if err != nil || !nextComplete {
100+ return "", nextComplete, err
101+ }
102+ handshake = append(handshake, next...)
103+ consumed = nextConsumed
99 }104 }
100- sni, err := parseClientHelloExtensions(record[4 : 4+handshakeLen])105+ sni, err := parseClientHelloExtensions(handshake[:handshakeLen])
101 return sni, true, err106 return sni, true, err
102 }107 }
103 108
109+func tlsRecordPayload(data []byte, offset int) ([]byte, int, bool, error) {
110+ if data[offset] != 22 {
111+ return nil, offset, false, fmt.Errorf("tls record is not a handshake")
112+ }
113+ recordLen := int(data[offset+3])<<8 | int(data[offset+4])
114+ if recordLen <= 0 {
115+ return nil, offset, false, fmt.Errorf("tls handshake record is empty")
116+ }
117+ end := offset + 5 + recordLen
118+ if len(data) < end {
119+ return nil, offset, false, nil
120+ }
121+ return data[offset+5 : end], end, true, nil
122+}
123+
104 func parseClientHelloExtensions(body []byte) (string, error) {124 func parseClientHelloExtensions(body []byte) (string, error) {
105 reader := bytes.NewReader(body)125 reader := bytes.NewReader(body)
106 if reader.Len() < 34 {126 if reader.Len() < 34 {
M internal/adapters/in/traffic/tlsmux_test.go +34 -0
@@ -67,6 +67,40 @@ func TestClientHelloPeek(t *testing.T) {
6767 require.NoError(t, err)
6868 assert.Equal(t, hello, buf)
6969 })
70+
71+ t.Run("client_hello_across_multiple_tls_records", func(t *testing.T) {
72+ hello := splitClientHelloAcrossRecords(t, clientHelloBytes(t, "multi-record.example.com"))
73+ client, server := net.Pipe()
74+ go func() {
75+ _, _ = client.Write(hello)
76+ _ = client.Close()
77+ }()
78+ sni, replayed, err := peekClientHelloSNI(server)
79+ require.NoError(t, err)
80+ assert.Equal(t, "multi-record.example.com", sni)
81+ buf := make([]byte, len(hello))
82+ _, err = io.ReadFull(replayed, buf)
83+ require.NoError(t, err)
84+ assert.Equal(t, hello, buf)
85+ })
86+}
87+
88+func splitClientHelloAcrossRecords(t *testing.T, hello []byte) []byte {
89+ t.Helper()
90+ require.GreaterOrEqual(t, len(hello), 15)
91+ require.Equal(t, byte(22), hello[0])
92+ recordLen := int(hello[3])<<8 | int(hello[4])
93+ require.Greater(t, recordLen, 10)
94+ firstPayloadLen := 10
95+ first := append([]byte{}, hello[:5]...)
96+ first[3] = byte(firstPayloadLen >> 8)
97+ first[4] = byte(firstPayloadLen)
98+ first = append(first, hello[5:5+firstPayloadLen]...)
99+ remainingPayloadLen := recordLen - firstPayloadLen
100+ second := append([]byte{22, hello[1], hello[2], byte(remainingPayloadLen >> 8), byte(remainingPayloadLen)}, hello[5+firstPayloadLen:5+recordLen]...)
101+ out := append(first, second...)
102+ out = append(out, hello[5+recordLen:]...)
103+ return out
70104 }
71105
72106 func TestTLSPassthrough(t *testing.T) {
@@ -67,6 +67,40 @@ func TestClientHelloPeek(t *testing.T) {
67 require.NoError(t, err)67 require.NoError(t, err)
68 assert.Equal(t, hello, buf)68 assert.Equal(t, hello, buf)
69 })69 })
70+
71+ t.Run("client_hello_across_multiple_tls_records", func(t *testing.T) {
72+ hello := splitClientHelloAcrossRecords(t, clientHelloBytes(t, "multi-record.example.com"))
73+ client, server := net.Pipe()
74+ go func() {
75+ _, _ = client.Write(hello)
76+ _ = client.Close()
77+ }()
78+ sni, replayed, err := peekClientHelloSNI(server)
79+ require.NoError(t, err)
80+ assert.Equal(t, "multi-record.example.com", sni)
81+ buf := make([]byte, len(hello))
82+ _, err = io.ReadFull(replayed, buf)
83+ require.NoError(t, err)
84+ assert.Equal(t, hello, buf)
85+ })
86+}
87+
88+func splitClientHelloAcrossRecords(t *testing.T, hello []byte) []byte {
89+ t.Helper()
90+ require.GreaterOrEqual(t, len(hello), 15)
91+ require.Equal(t, byte(22), hello[0])
92+ recordLen := int(hello[3])<<8 | int(hello[4])
93+ require.Greater(t, recordLen, 10)
94+ firstPayloadLen := 10
95+ first := append([]byte{}, hello[:5]...)
96+ first[3] = byte(firstPayloadLen >> 8)
97+ first[4] = byte(firstPayloadLen)
98+ first = append(first, hello[5:5+firstPayloadLen]...)
99+ remainingPayloadLen := recordLen - firstPayloadLen
100+ second := append([]byte{22, hello[1], hello[2], byte(remainingPayloadLen >> 8), byte(remainingPayloadLen)}, hello[5+firstPayloadLen:5+recordLen]...)
101+ out := append(first, second...)
102+ out = append(out, hello[5+recordLen:]...)
103+ return out
70 }104 }
71 105
72 func TestTLSPassthrough(t *testing.T) {106 func TestTLSPassthrough(t *testing.T) {
M internal/adapters/in/traffic/udp.go +7 -1
@@ -19,6 +19,7 @@ type udpEntryPointRuntime struct {
1919 entryPoint domain.EntryPoint
2020 packetConn net.PacketConn
2121 counters trafficCounters
22+ trusted []*net.IPNet
2223
2324 started atomic.Bool
2425 closed atomic.Bool
@@ -40,12 +41,13 @@ type udpSession struct {
4041 once sync.Once
4142 }
4243
43-func newUDPEntryPointRuntime(parentCtx context.Context, manager *Manager, entryPoint domain.EntryPoint, packetConn net.PacketConn) *udpEntryPointRuntime {
44+func newUDPEntryPointRuntime(parentCtx context.Context, manager *Manager, entryPoint domain.EntryPoint, packetConn net.PacketConn, trusted []*net.IPNet) *udpEntryPointRuntime {
4445 ctx, cancel := context.WithCancel(context.WithoutCancel(parentCtx))
4546 return &udpEntryPointRuntime{
4647 manager: manager,
4748 entryPoint: entryPoint,
4849 packetConn: packetConn,
50+ trusted: trusted,
4951 ctx: ctx,
5052 cancel: cancel,
5153 done: make(chan struct{}),
@@ -74,6 +76,10 @@ func (r *udpEntryPointRuntime) readLoop() {
7476 r.counters.totalErrors.Add(1)
7577 continue
7678 }
79+ if !trustedRemoteAddr(r.trusted, clientAddr) {
80+ r.counters.totalRefused.Add(1)
81+ continue
82+ }
7783 packet := append([]byte(nil), buf[:n]...)
7884 r.handleDatagram(clientAddr, packet)
7985 }
@@ -19,6 +19,7 @@ type udpEntryPointRuntime struct {
19 entryPoint domain.EntryPoint19 entryPoint domain.EntryPoint
20 packetConn net.PacketConn20 packetConn net.PacketConn
21 counters trafficCounters21 counters trafficCounters
22+ trusted []*net.IPNet
22 23
23 started atomic.Bool24 started atomic.Bool
24 closed atomic.Bool25 closed atomic.Bool
@@ -40,12 +41,13 @@ type udpSession struct {
40 once sync.Once41 once sync.Once
41 }42 }
42 43
43-func newUDPEntryPointRuntime(parentCtx context.Context, manager *Manager, entryPoint domain.EntryPoint, packetConn net.PacketConn) *udpEntryPointRuntime {44+func newUDPEntryPointRuntime(parentCtx context.Context, manager *Manager, entryPoint domain.EntryPoint, packetConn net.PacketConn, trusted []*net.IPNet) *udpEntryPointRuntime {
44 ctx, cancel := context.WithCancel(context.WithoutCancel(parentCtx))45 ctx, cancel := context.WithCancel(context.WithoutCancel(parentCtx))
45 return &udpEntryPointRuntime{46 return &udpEntryPointRuntime{
46 manager: manager,47 manager: manager,
47 entryPoint: entryPoint,48 entryPoint: entryPoint,
48 packetConn: packetConn,49 packetConn: packetConn,
50+ trusted: trusted,
49 ctx: ctx,51 ctx: ctx,
50 cancel: cancel,52 cancel: cancel,
51 done: make(chan struct{}),53 done: make(chan struct{}),
@@ -74,6 +76,10 @@ func (r *udpEntryPointRuntime) readLoop() {
74 r.counters.totalErrors.Add(1)76 r.counters.totalErrors.Add(1)
75 continue77 continue
76 }78 }
79+ if !trustedRemoteAddr(r.trusted, clientAddr) {
80+ r.counters.totalRefused.Add(1)
81+ continue
82+ }
77 packet := append([]byte(nil), buf[:n]...)83 packet := append([]byte(nil), buf[:n]...)
78 r.handleDatagram(clientAddr, packet)84 r.handleDatagram(clientAddr, packet)
79 }85 }
M internal/adapters/in/traffic/udp_test.go +19 -0
@@ -24,6 +24,25 @@ func TestUDPPassthroughEcho(t *testing.T) {
2424 assertUDPRoundTrip(t, conn, "hello udp")
2525 }
2626
27+func TestUDPRejectsUntrustedCIDR(t *testing.T) {
28+ backend := startUDPEchoServer(t)
29+ graph := udpGraph(t, freeUDPAddress(t), backend.address)
30+ graph.EntryPoints[0].TrustedCIDRs = []string{"192.0.2.0/24"}
31+ manager := NewManager()
32+ require.NoError(t, manager.Apply(context.Background(), &graph))
33+ defer shutdownManager(t, manager)
34+
35+ conn := dialUDP(t, graph.EntryPoints[0].Address)
36+ defer conn.Close()
37+ _, err := conn.Write([]byte("blocked"))
38+ require.NoError(t, err)
39+ _ = conn.SetReadDeadline(time.Now().Add(80 * time.Millisecond))
40+ buf := make([]byte, 32)
41+ _, err = conn.Read(buf)
42+ require.Error(t, err)
43+ assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == 1 }, time.Second, 10*time.Millisecond)
44+}
45+
2746 func TestUDPTwoClientsGetIsolatedSessions(t *testing.T) {
2847 backend := startUDPEchoServer(t)
2948 graph := udpGraph(t, freeUDPAddress(t), backend.address)
@@ -24,6 +24,25 @@ func TestUDPPassthroughEcho(t *testing.T) {
24 assertUDPRoundTrip(t, conn, "hello udp")24 assertUDPRoundTrip(t, conn, "hello udp")
25 }25 }
26 26
27+func TestUDPRejectsUntrustedCIDR(t *testing.T) {
28+ backend := startUDPEchoServer(t)
29+ graph := udpGraph(t, freeUDPAddress(t), backend.address)
30+ graph.EntryPoints[0].TrustedCIDRs = []string{"192.0.2.0/24"}
31+ manager := NewManager()
32+ require.NoError(t, manager.Apply(context.Background(), &graph))
33+ defer shutdownManager(t, manager)
34+
35+ conn := dialUDP(t, graph.EntryPoints[0].Address)
36+ defer conn.Close()
37+ _, err := conn.Write([]byte("blocked"))
38+ require.NoError(t, err)
39+ _ = conn.SetReadDeadline(time.Now().Add(80 * time.Millisecond))
40+ buf := make([]byte, 32)
41+ _, err = conn.Read(buf)
42+ require.Error(t, err)
43+ assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == 1 }, time.Second, 10*time.Millisecond)
44+}
45+
27 func TestUDPTwoClientsGetIsolatedSessions(t *testing.T) {46 func TestUDPTwoClientsGetIsolatedSessions(t *testing.T) {
28 backend := startUDPEchoServer(t)47 backend := startUDPEchoServer(t)
29 graph := udpGraph(t, freeUDPAddress(t), backend.address)48 graph := udpGraph(t, freeUDPAddress(t), backend.address)
M internal/domain/traffic.go +16 -1
@@ -250,6 +250,9 @@ func validateTrafficEntryPoint(entryPoint EntryPoint, existing map[string]EntryP
250250 if err := validateEntryPointProtocol(entryPoint.Protocol); err != nil {
251251 return fmt.Errorf("invalid entrypoint protocol for %q: %w", entryPoint.Name, err)
252252 }
253+ if err := validateTrustedCIDRs(entryPoint); err != nil {
254+ return err
255+ }
253256 addr, err := parseListenAddress(entryPoint.Address)
254257 if err != nil {
255258 return fmt.Errorf("invalid entrypoint address for %q: %w", entryPoint.Name, err)
@@ -423,9 +426,12 @@ func (s *routerValidationState) validateHTTPRuleTLSConflicts(entryPoint string,
423426 }
424427
425428 func (s *routerValidationState) validateTLSPassthroughRule(router TrafficRouter, entryPoint EntryPoint) error {
426- if router.Protocol != RouterProtocolTLSPassthrough || router.Rule.SNI == "" {
429+ if router.Protocol != RouterProtocolTLSPassthrough {
427430 return nil
428431 }
432+ if strings.TrimSpace(router.Rule.SNI) == "" {
433+ return fmt.Errorf("tls passthrough router %q requires sni", router.Name)
434+ }
429435 sni := normalizeHost(router.Rule.SNI)
430436 if strings.HasPrefix(sni, "*.") {
431437 return s.validateWildcardSNI(entryPoint.Name, sni, router.Name)
@@ -525,6 +531,15 @@ func l4BackendProtocol(protocol RouterProtocol) (NetworkProtocol, bool) {
525531 }
526532 }
527533
534+func validateTrustedCIDRs(entryPoint EntryPoint) error {
535+ for _, cidr := range entryPoint.TrustedCIDRs {
536+ if _, _, err := net.ParseCIDR(strings.TrimSpace(cidr)); err != nil {
537+ return fmt.Errorf("invalid trusted_cidrs entry %q for entrypoint %q: %w", cidr, entryPoint.Name, err)
538+ }
539+ }
540+ return nil
541+}
542+
528543 func validateEntryPointProtocol(protocol EntryPointProtocol) error {
529544 switch protocol {
530545 case EntryPointProtocolHTTP, EntryPointProtocolTLSMux, EntryPointProtocolTCP, EntryPointProtocolUDP:
@@ -250,6 +250,9 @@ func validateTrafficEntryPoint(entryPoint EntryPoint, existing map[string]EntryP
250 if err := validateEntryPointProtocol(entryPoint.Protocol); err != nil {250 if err := validateEntryPointProtocol(entryPoint.Protocol); err != nil {
251 return fmt.Errorf("invalid entrypoint protocol for %q: %w", entryPoint.Name, err)251 return fmt.Errorf("invalid entrypoint protocol for %q: %w", entryPoint.Name, err)
252 }252 }
253+ if err := validateTrustedCIDRs(entryPoint); err != nil {
254+ return err
255+ }
253 addr, err := parseListenAddress(entryPoint.Address)256 addr, err := parseListenAddress(entryPoint.Address)
254 if err != nil {257 if err != nil {
255 return fmt.Errorf("invalid entrypoint address for %q: %w", entryPoint.Name, err)258 return fmt.Errorf("invalid entrypoint address for %q: %w", entryPoint.Name, err)
@@ -423,9 +426,12 @@ func (s *routerValidationState) validateHTTPRuleTLSConflicts(entryPoint string,
423 }426 }
424 427
425 func (s *routerValidationState) validateTLSPassthroughRule(router TrafficRouter, entryPoint EntryPoint) error {428 func (s *routerValidationState) validateTLSPassthroughRule(router TrafficRouter, entryPoint EntryPoint) error {
426- if router.Protocol != RouterProtocolTLSPassthrough || router.Rule.SNI == "" {429+ if router.Protocol != RouterProtocolTLSPassthrough {
427 return nil430 return nil
428 }431 }
432+ if strings.TrimSpace(router.Rule.SNI) == "" {
433+ return fmt.Errorf("tls passthrough router %q requires sni", router.Name)
434+ }
429 sni := normalizeHost(router.Rule.SNI)435 sni := normalizeHost(router.Rule.SNI)
430 if strings.HasPrefix(sni, "*.") {436 if strings.HasPrefix(sni, "*.") {
431 return s.validateWildcardSNI(entryPoint.Name, sni, router.Name)437 return s.validateWildcardSNI(entryPoint.Name, sni, router.Name)
@@ -525,6 +531,15 @@ func l4BackendProtocol(protocol RouterProtocol) (NetworkProtocol, bool) {
525 }531 }
526 }532 }
527 533
534+func validateTrustedCIDRs(entryPoint EntryPoint) error {
535+ for _, cidr := range entryPoint.TrustedCIDRs {
536+ if _, _, err := net.ParseCIDR(strings.TrimSpace(cidr)); err != nil {
537+ return fmt.Errorf("invalid trusted_cidrs entry %q for entrypoint %q: %w", cidr, entryPoint.Name, err)
538+ }
539+ }
540+ return nil
541+}
542+
528 func validateEntryPointProtocol(protocol EntryPointProtocol) error {543 func validateEntryPointProtocol(protocol EntryPointProtocol) error {
529 switch protocol {544 switch protocol {
530 case EntryPointProtocolHTTP, EntryPointProtocolTLSMux, EntryPointProtocolTCP, EntryPointProtocolUDP:545 case EntryPointProtocolHTTP, EntryPointProtocolTLSMux, EntryPointProtocolTCP, EntryPointProtocolUDP:
M internal/domain/traffic_test.go +14 -0
@@ -74,6 +74,13 @@ func TestTrafficGraphValidateEntryPoints(t *testing.T) {
7474 {Name: "udp", Address: ":53", Protocol: EntryPointProtocolUDP},
7575 }},
7676 },
77+ {
78+ name: "invalid trusted cidr rejected",
79+ graph: TrafficGraph{EntryPoints: []EntryPoint{
80+ {Name: "tcp", Address: ":5432", Protocol: EntryPointProtocolTCP, TrustedCIDRs: []string{"not-a-cidr"}},
81+ }},
82+ wantErr: "invalid trusted_cidrs",
83+ },
7784 }
7885
7986 for _, tt := range tests {
@@ -279,6 +286,13 @@ func TestTrafficGraphValidateTLSRules(t *testing.T) {
279286 },
280287 wantErr: "ambiguous wildcard tls sni",
281288 },
289+ {
290+ name: "tls passthrough requires sni",
291+ routers: []TrafficRouter{
292+ {Name: "pass", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Service: "network_service:app:https"},
293+ },
294+ wantErr: "requires sni",
295+ },
282296 {
283297 name: "http host and tls passthrough sni conflict rejected",
284298 routers: []TrafficRouter{
@@ -74,6 +74,13 @@ func TestTrafficGraphValidateEntryPoints(t *testing.T) {
74 {Name: "udp", Address: ":53", Protocol: EntryPointProtocolUDP},74 {Name: "udp", Address: ":53", Protocol: EntryPointProtocolUDP},
75 }},75 }},
76 },76 },
77+ {
78+ name: "invalid trusted cidr rejected",
79+ graph: TrafficGraph{EntryPoints: []EntryPoint{
80+ {Name: "tcp", Address: ":5432", Protocol: EntryPointProtocolTCP, TrustedCIDRs: []string{"not-a-cidr"}},
81+ }},
82+ wantErr: "invalid trusted_cidrs",
83+ },
77 }84 }
78 85
79 for _, tt := range tests {86 for _, tt := range tests {
@@ -279,6 +286,13 @@ func TestTrafficGraphValidateTLSRules(t *testing.T) {
279 },286 },
280 wantErr: "ambiguous wildcard tls sni",287 wantErr: "ambiguous wildcard tls sni",
281 },288 },
289+ {
290+ name: "tls passthrough requires sni",
291+ routers: []TrafficRouter{
292+ {Name: "pass", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Service: "network_service:app:https"},
293+ },
294+ wantErr: "requires sni",
295+ },
282 {296 {
283 name: "http host and tls passthrough sni conflict rejected",297 name: "http host and tls passthrough sni conflict rejected",
284 routers: []TrafficRouter{298 routers: []TrafficRouter{

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close