1package domain
2
3import (
4 "testing"
5 "time"
6
7 "github.com/stretchr/testify/require"
8)
9
10func 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
33func 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: "raw fallback cidrs require raw fallback on smart tcp",
53 entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
54 wantErr: "requires raw_fallback",
55 },
56 {
57 name: "public raw fallback flag requires raw fallback on smart tcp",
58 entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, AllowPublicRawFallback: true},
59 wantErr: "requires raw_fallback",
60 },
61 {
62 name: "missing raw fallback router rejected",
63 entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallback: "missing", RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
64 routers: nil,
65 wantErr: "raw fallback",
66 },
67 {
68 name: "raw fallback must reference tcp router",
69 entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallback: "route:app.example.com", RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
70 routers: []TrafficRouter{
71 {Name: "route:app.example.com", EntryPoint: "edge", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
72 },
73 wantErr: "raw fallback",
74 },
75 {
76 name: "invalid raw fallback trusted cidr rejected",
77 entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallback: "ssh-fallback", RawFallbackTrustedCIDRs: []string{"not-a-cidr"}},
78 routers: []TrafficRouter{{Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"}},
79 wantErr: "invalid raw_fallback_trusted_cidrs",
80 },
81 {
82 name: "raw fallback rejected on tcp entrypoint",
83 entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolTCP, RawFallback: "ssh-fallback", RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
84 routers: []TrafficRouter{{Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"}},
85 wantErr: "raw_fallback is only supported on smart_tcp",
86 },
87 {
88 name: "raw fallback cidrs rejected on tls mux entrypoint",
89 entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolTLSMux, RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
90 wantErr: "raw_fallback_trusted_cidrs is only supported on smart_tcp",
91 },
92 {
93 name: "public raw fallback flag rejected on udp entrypoint",
94 entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolUDP, AllowPublicRawFallback: true},
95 wantErr: "allow_public_raw_fallback is only supported on smart_tcp",
96 },
97 }
98
99 for _, tt := range tests {
100 t.Run(tt.name, func(t *testing.T) {
101 graph := TrafficGraph{EntryPoints: []EntryPoint{tt.entryPoint}, Routers: tt.routers, Services: baseServices}
102 require.ErrorContains(t, graph.Validate(), tt.wantErr)
103 })
104 }
105}
106
107func TestTrafficGraphSmartTCPRejectsHTTPHostAndTLSSNIConflict(t *testing.T) {
108 graph := TrafficGraph{
109 EntryPoints: []EntryPoint{{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP}},
110 Routers: []TrafficRouter{
111 {Name: "route:app.example.com", EntryPoint: "edge", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
112 {Name: "raw-tls", EntryPoint: "edge", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "app.example.com"}, Service: "network_service:raw:tls"},
113 },
114 Services: []TrafficService{
115 {Name: "route:app.example.com"},
116 {Name: "network_service:raw:tls", Backends: []TrafficBackend{{Name: "raw:tls", Host: "raw", Port: 443, Protocol: NetworkProtocolTCP}}},
117 },
118 }
119 require.ErrorContains(t, graph.Validate(), "http host conflicts with tls passthrough sni")
120}
121
122func TestTrafficGraphValidateEntryPoints(t *testing.T) {
123 tests := []struct {
124 name string
125 graph TrafficGraph
126 wantErr string
127 }{
128 {
129 name: "duplicate entrypoint names",
130 graph: TrafficGraph{EntryPoints: []EntryPoint{
131 {Name: "web", Address: ":80", Protocol: EntryPointProtocolTLSMux},
132 {Name: "web", Address: ":81", Protocol: EntryPointProtocolTLSMux},
133 }},
134 wantErr: "duplicate entrypoint name",
135 },
136 {
137 name: "invalid entrypoint address",
138 graph: TrafficGraph{EntryPoints: []EntryPoint{
139 {Name: "web", Address: "not-a-listen-address", Protocol: EntryPointProtocolTLSMux},
140 }},
141 wantErr: "invalid entrypoint address",
142 },
143 {
144 name: "http entrypoint protocol is unsupported",
145 graph: TrafficGraph{EntryPoints: []EntryPoint{
146 {Name: "web", Address: ":80", Protocol: EntryPointProtocolHTTP},
147 }},
148 wantErr: "unsupported traffic entrypoint protocol \"http\"",
149 },
150 {
151 name: "invalid entrypoint protocol",
152 graph: TrafficGraph{EntryPoints: []EntryPoint{
153 {Name: "web", Address: ":80", Protocol: EntryPointProtocol("smtp")},
154 }},
155 wantErr: "unsupported traffic entrypoint protocol \"smtp\"",
156 },
157 {
158 name: "duplicate tcp address rejected",
159 graph: TrafficGraph{EntryPoints: []EntryPoint{
160 {Name: "web", Address: ":443", Protocol: EntryPointProtocolTLSMux},
161 {Name: "tcp", Address: ":443", Protocol: EntryPointProtocolTCP},
162 }},
163 wantErr: "duplicate tcp entrypoint address",
164 },
165 {
166 name: "duplicate udp address rejected",
167 graph: TrafficGraph{EntryPoints: []EntryPoint{
168 {Name: "dns1", Address: ":53", Protocol: EntryPointProtocolUDP},
169 {Name: "dns2", Address: ":53", Protocol: EntryPointProtocolUDP},
170 }},
171 wantErr: "duplicate udp entrypoint address",
172 },
173 {
174 name: "tcp wildcard address conflicts with ipv4 specific bind",
175 graph: TrafficGraph{EntryPoints: []EntryPoint{
176 {Name: "wild", Address: ":443", Protocol: EntryPointProtocolTCP},
177 {Name: "specific", Address: "127.0.0.1:443", Protocol: EntryPointProtocolTCP},
178 }},
179 wantErr: "conflicting tcp entrypoint address",
180 },
181 {
182 name: "tcp wildcard address conflicts with ipv6 wildcard bind",
183 graph: TrafficGraph{EntryPoints: []EntryPoint{
184 {Name: "wild", Address: ":443", Protocol: EntryPointProtocolTCP},
185 {Name: "specific", Address: "[::]:443", Protocol: EntryPointProtocolTCP},
186 }},
187 wantErr: "conflicting tcp entrypoint address",
188 },
189 {
190 name: "tcp and udp same numeric port allowed",
191 graph: TrafficGraph{EntryPoints: []EntryPoint{
192 {Name: "tcp", Address: ":53", Protocol: EntryPointProtocolTCP},
193 {Name: "udp", Address: ":53", Protocol: EntryPointProtocolUDP},
194 }},
195 },
196 {
197 name: "invalid trusted cidr rejected",
198 graph: TrafficGraph{EntryPoints: []EntryPoint{
199 {Name: "tcp", Address: ":5432", Protocol: EntryPointProtocolTCP, TrustedCIDRs: []string{"not-a-cidr"}},
200 }},
201 wantErr: "invalid trusted_cidrs",
202 },
203 }
204
205 for _, tt := range tests {
206 t.Run(tt.name, func(t *testing.T) {
207 err := tt.graph.Validate()
208 if tt.wantErr == "" {
209 require.NoError(t, err)
210 return
211 }
212 require.ErrorContains(t, err, tt.wantErr)
213 })
214 }
215}
216
217func TestTrafficGraphValidateRouterEntryPointProtocolCompatibility(t *testing.T) {
218 tests := []struct {
219 name string
220 routerProtocol RouterProtocol
221 entryProtocol EntryPointProtocol
222 wantErr bool
223 }{
224 {name: "http router accepts tls mux entrypoint", routerProtocol: RouterProtocolHTTP, entryProtocol: EntryPointProtocolTLSMux},
225 {name: "tls passthrough router accepts tls mux entrypoint", routerProtocol: RouterProtocolTLSPassthrough, entryProtocol: EntryPointProtocolTLSMux},
226 {name: "tcp router accepts tcp entrypoint", routerProtocol: RouterProtocolTCP, entryProtocol: EntryPointProtocolTCP},
227 {name: "udp router accepts udp entrypoint", routerProtocol: RouterProtocolUDP, entryProtocol: EntryPointProtocolUDP},
228 {name: "http router rejects udp entrypoint", routerProtocol: RouterProtocolHTTP, entryProtocol: EntryPointProtocolUDP, wantErr: true},
229 {name: "http router rejects tcp entrypoint", routerProtocol: RouterProtocolHTTP, entryProtocol: EntryPointProtocolTCP, wantErr: true},
230 {name: "udp router rejects tcp entrypoint", routerProtocol: RouterProtocolUDP, entryProtocol: EntryPointProtocolTCP, wantErr: true},
231 {name: "udp router rejects tls mux entrypoint", routerProtocol: RouterProtocolUDP, entryProtocol: EntryPointProtocolTLSMux, wantErr: true},
232 {name: "tcp router rejects udp entrypoint", routerProtocol: RouterProtocolTCP, entryProtocol: EntryPointProtocolUDP, wantErr: true},
233 {name: "tcp router rejects tls mux entrypoint", routerProtocol: RouterProtocolTCP, entryProtocol: EntryPointProtocolTLSMux, wantErr: true},
234 {name: "tls passthrough router rejects udp entrypoint", routerProtocol: RouterProtocolTLSPassthrough, entryProtocol: EntryPointProtocolUDP, wantErr: true},
235 {name: "tls passthrough router rejects tcp entrypoint per tls mux spec", routerProtocol: RouterProtocolTLSPassthrough, entryProtocol: EntryPointProtocolTCP, wantErr: true},
236 }
237
238 for _, tt := range tests {
239 t.Run(tt.name, func(t *testing.T) {
240 serviceName := "route:app.example.com"
241 backendProtocol := NetworkProtocolTCP
242 rule := TrafficRule{Host: "app.example.com"}
243 switch tt.routerProtocol {
244 case RouterProtocolUDP:
245 serviceName = "network_service:app:game"
246 backendProtocol = NetworkProtocolUDP
247 rule = TrafficRule{}
248 case RouterProtocolTCP, RouterProtocolTLSPassthrough:
249 serviceName = "network_service:app:tcp"
250 rule = TrafficRule{SNI: "app.example.com"}
251 }
252
253 graph := TrafficGraph{
254 EntryPoints: []EntryPoint{{Name: "entry", Address: ":12345", Protocol: tt.entryProtocol}},
255 Routers: []TrafficRouter{{Name: "router", EntryPoint: "entry", Protocol: tt.routerProtocol, Rule: rule, Service: serviceName}},
256 Services: []TrafficService{{Name: serviceName, Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 8080, Protocol: backendProtocol}}}},
257 }
258 err := graph.Validate()
259 if !tt.wantErr {
260 require.NoError(t, err)
261 return
262 }
263 require.ErrorContains(t, err, "router \"router\" protocol "+string(tt.routerProtocol)+" is incompatible with entrypoint \"entry\" protocol "+string(tt.entryProtocol))
264 })
265 }
266}
267
268func TestTrafficGraphValidateRouterNames(t *testing.T) {
269 service := TrafficService{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 8080, Protocol: NetworkProtocolTCP}}}
270 tests := []struct {
271 name string
272 routers []TrafficRouter
273 wantErr string
274 }{
275 {
276 name: "router name is required",
277 routers: []TrafficRouter{{EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: service.Name}},
278 wantErr: "traffic router name is required",
279 },
280 {
281 name: "duplicate router names rejected",
282 routers: []TrafficRouter{
283 {Name: "web", EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: service.Name},
284 {Name: "web", EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "api.example.com"}, Service: service.Name},
285 },
286 wantErr: "duplicate traffic router name \"web\"",
287 },
288 }
289
290 for _, tt := range tests {
291 t.Run(tt.name, func(t *testing.T) {
292 graph := TrafficGraph{
293 EntryPoints: []EntryPoint{{Name: "web", Address: ":443", Protocol: EntryPointProtocolTLSMux}},
294 Routers: tt.routers,
295 Services: []TrafficService{service},
296 }
297 require.ErrorContains(t, graph.Validate(), tt.wantErr)
298 })
299 }
300}
301
302func TestTrafficGraphValidateDuplicateHTTPHosts(t *testing.T) {
303 service := TrafficService{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 8080, Protocol: NetworkProtocolTCP}}}
304 tests := []struct {
305 name string
306 entryProtocol EntryPointProtocol
307 entryAddress string
308 }{
309 {name: "tls mux entrypoint", entryProtocol: EntryPointProtocolTLSMux, entryAddress: ":443"},
310 }
311
312 for _, tt := range tests {
313 t.Run(tt.name, func(t *testing.T) {
314 graph := TrafficGraph{
315 EntryPoints: []EntryPoint{{Name: "web", Address: tt.entryAddress, Protocol: tt.entryProtocol}},
316 Routers: []TrafficRouter{
317 {Name: "one", EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "App.Example.Com."}, Service: service.Name},
318 {Name: "two", EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: service.Name},
319 },
320 Services: []TrafficService{service},
321 }
322 require.ErrorContains(t, graph.Validate(), "duplicate http host \"app.example.com\" on entrypoint \"web\"")
323 })
324 }
325}
326
327func TestTrafficGraphValidateAmbiguousL4Routers(t *testing.T) {
328 tests := []struct {
329 name string
330 entryProtocol EntryPointProtocol
331 routerProtocol RouterProtocol
332 service TrafficService
333 wantErr string
334 }{
335 {
336 name: "multiple tcp routers on same entrypoint rejected",
337 entryProtocol: EntryPointProtocolTCP,
338 routerProtocol: RouterProtocolTCP,
339 service: TrafficService{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 5432, Protocol: NetworkProtocolTCP}}},
340 wantErr: "ambiguous tcp routers on entrypoint \"l4\"",
341 },
342 {
343 name: "multiple udp routers on same entrypoint rejected",
344 entryProtocol: EntryPointProtocolUDP,
345 routerProtocol: RouterProtocolUDP,
346 service: TrafficService{Name: "network_service:app:game", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 25565, Protocol: NetworkProtocolUDP}}},
347 wantErr: "ambiguous udp routers on entrypoint \"l4\"",
348 },
349 }
350
351 for _, tt := range tests {
352 t.Run(tt.name, func(t *testing.T) {
353 graph := TrafficGraph{
354 EntryPoints: []EntryPoint{{Name: "l4", Address: ":12345", Protocol: tt.entryProtocol}},
355 Routers: []TrafficRouter{
356 {Name: "one", EntryPoint: "l4", Protocol: tt.routerProtocol, Service: tt.service.Name},
357 {Name: "two", EntryPoint: "l4", Protocol: tt.routerProtocol, Service: tt.service.Name},
358 },
359 Services: []TrafficService{tt.service},
360 }
361 require.ErrorContains(t, graph.Validate(), tt.wantErr)
362 })
363 }
364}
365
366func TestTrafficGraphValidateTLSRules(t *testing.T) {
367 tests := []struct {
368 name string
369 routers []TrafficRouter
370 wantErr string
371 }{
372 {
373 name: "duplicate exact tls sni on same entrypoint rejected",
374 routers: []TrafficRouter{
375 {Name: "one", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "db.example.com"}, Service: "network_service:app:https"},
376 {Name: "two", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "db.example.com"}, Service: "network_service:app:https"},
377 },
378 wantErr: "duplicate exact tls sni",
379 },
380 {
381 name: "exact sni plus wildcard deterministic allowed",
382 routers: []TrafficRouter{
383 {Name: "exact", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "db.example.com"}, Service: "network_service:app:https"},
384 {Name: "wild", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
385 },
386 },
387 {
388 name: "duplicate wildcard sni rejected",
389 routers: []TrafficRouter{
390 {Name: "wild1", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
391 {Name: "wild2", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
392 },
393 wantErr: "ambiguous wildcard tls sni",
394 },
395 {
396 name: "nested wildcard sni overlap rejected",
397 routers: []TrafficRouter{
398 {Name: "wild1", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
399 {Name: "wild2", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.sub.example.com"}, Service: "network_service:app:https"},
400 },
401 wantErr: "ambiguous wildcard tls sni",
402 },
403 {
404 name: "tls passthrough requires sni",
405 routers: []TrafficRouter{
406 {Name: "pass", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Service: "network_service:app:https"},
407 },
408 wantErr: "requires sni",
409 },
410 {
411 name: "http host and tls passthrough sni conflict rejected",
412 routers: []TrafficRouter{
413 {Name: "http", EntryPoint: "tls", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
414 {Name: "pass", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "app.example.com"}, Service: "network_service:app:https"},
415 },
416 wantErr: "http host conflicts with tls passthrough sni",
417 },
418 {
419 name: "http host conflicts with later wildcard tls passthrough sni",
420 routers: []TrafficRouter{
421 {Name: "http", EntryPoint: "tls", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
422 {Name: "pass", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
423 },
424 wantErr: "http host conflicts with wildcard tls passthrough sni",
425 },
426 {
427 name: "http host conflicts with earlier wildcard tls passthrough sni",
428 routers: []TrafficRouter{
429 {Name: "pass", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
430 {Name: "http", EntryPoint: "tls", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
431 },
432 wantErr: "http host conflicts with wildcard tls passthrough sni",
433 },
434 }
435
436 for _, tt := range tests {
437 t.Run(tt.name, func(t *testing.T) {
438 graph := TrafficGraph{
439 EntryPoints: []EntryPoint{{Name: "tls", Address: ":443", Protocol: EntryPointProtocolTLSMux}},
440 Routers: tt.routers,
441 Services: []TrafficService{
442 {Name: "network_service:app:https", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP}}},
443 {Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.3", Port: 8080, Protocol: NetworkProtocolTCP}}},
444 },
445 }
446 err := graph.Validate()
447 if tt.wantErr == "" {
448 require.NoError(t, err)
449 return
450 }
451 require.ErrorContains(t, err, tt.wantErr)
452 })
453 }
454}
455
456func TestTrafficGraphValidateBackendsAndL4Routers(t *testing.T) {
457 tests := []struct {
458 name string
459 router TrafficRouter
460 services []TrafficService
461 wantErr string
462 }{
463 {
464 name: "backend host required",
465 router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"},
466 services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Port: 5432, Protocol: NetworkProtocolTCP}}}},
467 wantErr: "backend host is required",
468 },
469 {
470 name: "backend ports outside range rejected low",
471 router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"},
472 services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 0, Protocol: NetworkProtocolTCP}}}},
473 wantErr: "invalid backend port",
474 },
475 {
476 name: "backend ports outside range rejected high",
477 router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"},
478 services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 65536, Protocol: NetworkProtocolTCP}}}},
479 wantErr: "invalid backend port",
480 },
481 {
482 name: "l4 routers reject services with more than one backend",
483 router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"},
484 services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{
485 {Name: "app", Host: "10.0.0.2", Port: 5432, Protocol: NetworkProtocolTCP},
486 {Name: "app2", Host: "10.0.0.3", Port: 5432, Protocol: NetworkProtocolTCP},
487 }}},
488 wantErr: "l4 router service must have exactly one backend",
489 },
490 {
491 name: "tcp router requires tcp network backend",
492 router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"},
493 services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 53, Protocol: NetworkProtocolUDP}}}},
494 wantErr: "backend protocol udp does not match router protocol tcp",
495 },
496 {
497 name: "udp router requires udp network backend",
498 router: TrafficRouter{Name: "udp", EntryPoint: "udp", Protocol: RouterProtocolUDP, Service: "network_service:app:game"},
499 services: []TrafficService{{Name: "network_service:app:game", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 53, Protocol: NetworkProtocolTCP}}}},
500 wantErr: "backend protocol tcp does not match router protocol udp",
501 },
502 {
503 name: "tls passthrough requires tcp network backend",
504 router: TrafficRouter{Name: "tls", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "db.example.com"}, Service: "network_service:app:https"},
505 services: []TrafficService{{Name: "network_service:app:https", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolUDP}}}},
506 wantErr: "backend protocol udp does not match router protocol tls_passthrough",
507 },
508 {
509 name: "l4 router rejects route ref",
510 router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "route:app.example.com"},
511 services: []TrafficService{{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP}}}},
512 wantErr: "requires network_service or service service ref",
513 },
514 {
515 name: "static ref parses but graph validation rejects unsupported",
516 router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "static:maintenance"},
517 services: []TrafficService{{Name: "static:maintenance", Backends: []TrafficBackend{{Name: "maintenance", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP}}}},
518 wantErr: "static traffic service ref \"static:maintenance\" is unsupported",
519 },
520 }
521
522 for _, tt := range tests {
523 t.Run(tt.name, func(t *testing.T) {
524 graph := TrafficGraph{
525 EntryPoints: []EntryPoint{
526 {Name: "tcp", Address: ":443", Protocol: EntryPointProtocolTCP},
527 {Name: "udp", Address: ":443", Protocol: EntryPointProtocolUDP},
528 {Name: "tls", Address: ":8443", Protocol: EntryPointProtocolTLSMux},
529 },
530 Routers: []TrafficRouter{tt.router},
531 Services: tt.services,
532 }
533 require.ErrorContains(t, graph.Validate(), tt.wantErr)
534 })
535 }
536}
537
538func TestTrafficGraphValidateL4ServiceRefs(t *testing.T) {
539 tests := []struct {
540 name string
541 routerProtocol RouterProtocol
542 entryPoint EntryPoint
543 backend TrafficBackend
544 }{
545 {
546 name: "tcp allows service ref with tcp backend",
547 routerProtocol: RouterProtocolTCP,
548 entryPoint: EntryPoint{Name: "tcp", Address: ":25565", Protocol: EntryPointProtocolTCP},
549 backend: TrafficBackend{Name: "rust", Host: "10.0.0.2", Port: 25565, Protocol: NetworkProtocolTCP},
550 },
551 {
552 name: "udp allows service ref with udp backend",
553 routerProtocol: RouterProtocolUDP,
554 entryPoint: EntryPoint{Name: "udp", Address: ":25565", Protocol: EntryPointProtocolUDP},
555 backend: TrafficBackend{Name: "rust", Host: "10.0.0.2", Port: 25565, Protocol: NetworkProtocolUDP},
556 },
557 {
558 name: "tls passthrough allows service ref with tcp backend",
559 routerProtocol: RouterProtocolTLSPassthrough,
560 entryPoint: EntryPoint{Name: "tls", Address: ":443", Protocol: EntryPointProtocolTLSMux},
561 backend: TrafficBackend{Name: "rust", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP},
562 },
563 }
564
565 for _, tt := range tests {
566 t.Run(tt.name, func(t *testing.T) {
567 router := TrafficRouter{Name: "router", EntryPoint: tt.entryPoint.Name, Protocol: tt.routerProtocol, Service: "service:rust:game"}
568 if tt.routerProtocol == RouterProtocolTLSPassthrough {
569 router.Rule = TrafficRule{SNI: "rust.example.com"}
570 }
571 graph := TrafficGraph{
572 EntryPoints: []EntryPoint{tt.entryPoint},
573 Routers: []TrafficRouter{router},
574 Services: []TrafficService{{Name: "service:rust:game", Backends: []TrafficBackend{tt.backend}}},
575 }
576 require.NoError(t, graph.Validate())
577 })
578 }
579}
580
581func TestTrafficGraphValidateHTTPServiceRefs(t *testing.T) {
582 tests := []struct {
583 name string
584 service string
585 wantErr string
586 }{
587 {name: "http allows route", service: "route:app.example.com"},
588 {name: "http allows external_route", service: "external_route:api.example.com"},
589 {name: "http rejects network_service", service: "network_service:app:http", wantErr: "does not support service ref kind network_service"},
590 {name: "http rejects service", service: "service:app:http", wantErr: "does not support service ref kind service"},
591 {name: "http rejects static", service: "static:maintenance", wantErr: "static traffic service ref \"static:maintenance\" is unsupported"},
592 }
593
594 for _, tt := range tests {
595 t.Run(tt.name, func(t *testing.T) {
596 graph := TrafficGraph{
597 EntryPoints: []EntryPoint{{Name: "web", Address: ":443", Protocol: EntryPointProtocolTLSMux}},
598 Routers: []TrafficRouter{{Name: "web", EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: tt.service}},
599 Services: []TrafficService{{Name: tt.service, Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 8080, Protocol: NetworkProtocolTCP}}}},
600 }
601 err := graph.Validate()
602 if tt.wantErr == "" {
603 require.NoError(t, err)
604 return
605 }
606 require.ErrorContains(t, err, tt.wantErr)
607 })
608 }
609}
610
611func TestTrafficGraphValidateRouterServiceRefs(t *testing.T) {
612 t.Run("router service ref is parsed", func(t *testing.T) {
613 graph := TrafficGraph{
614 EntryPoints: []EntryPoint{{Name: "tcp", Address: ":5432", Protocol: EntryPointProtocolTCP}},
615 Routers: []TrafficRouter{{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:postgres:"}},
616 Services: []TrafficService{{Name: "network_service:postgres:", Backends: []TrafficBackend{{Name: "postgres", Host: "10.0.0.4", Port: 5432, Protocol: NetworkProtocolTCP}}}},
617 }
618 require.ErrorContains(t, graph.Validate(), "invalid traffic service ref")
619 })
620
621 t.Run("router service ref must exist as service name", func(t *testing.T) {
622 graph := TrafficGraph{
623 EntryPoints: []EntryPoint{{Name: "tcp", Address: ":5432", Protocol: EntryPointProtocolTCP}},
624 Routers: []TrafficRouter{{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:postgres:db"}},
625 }
626 require.ErrorContains(t, graph.Validate(), "references unknown service")
627 })
628}
629
630func TestTrafficServiceRef(t *testing.T) {
631 ref, err := ParseTrafficServiceRef("static:maintenance")
632 require.NoError(t, err)
633 require.Equal(t, TrafficServiceRefStatic, ref.Kind)
634 require.True(t, ref.Reserved)
635 require.Equal(t, "maintenance", ref.Name)
636}
637
638func TestParseTrafficServiceRef(t *testing.T) {
639 tests := []struct {
640 input string
641 want TrafficServiceRef
642 }{
643 {input: "route:app.example.com", want: TrafficServiceRef{Kind: TrafficServiceRefRoute, Domain: "app.example.com"}},
644 {input: "external_route:api.example.com", want: TrafficServiceRef{Kind: TrafficServiceRefExternalRoute, Domain: "api.example.com"}},
645 {input: "network_service:postgres:db", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "postgres", PortName: "db"}},
646 {input: "network_service:web:http", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "web", PortName: "http"}},
647 {input: "network_service:game:game", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "game", PortName: "game"}},
648 {input: "service:rust:game", want: TrafficServiceRef{Kind: TrafficServiceRefService, Name: "rust", PortName: "game"}},
649 {input: "static:maintenance", want: TrafficServiceRef{Kind: TrafficServiceRefStatic, Name: "maintenance", Reserved: true}},
650 }
651
652 for _, tt := range tests {
653 t.Run(tt.input, func(t *testing.T) {
654 got, err := ParseTrafficServiceRef(tt.input)
655 require.NoError(t, err)
656 require.Equal(t, tt.want, got)
657 })
658 }
659
660 invalid := []string{"", "route:", "external_route:", "network_service:", "network_service:postgres", "network_service:postgres:", "network_service::db", "network_service:postgres:db:extra", "service:", "service:rust", "service:rust:", "service::game", "service:rust:game:extra", "static:", "unknown:value"}
661 for _, input := range invalid {
662 t.Run("invalid "+input, func(t *testing.T) {
663 _, err := ParseTrafficServiceRef(input)
664 require.Error(t, err)
665 })
666 }
667}
668
669func TestTrafficGraphValidateRejectsInvalidOptions(t *testing.T) {
670 base := TrafficGraph{}
671 tests := []struct {
672 name string
673 mutate func(*TrafficGraph)
674 wantErr string
675 }{
676 {name: "negative tcp dial timeout", mutate: func(g *TrafficGraph) { g.Options.TCP.DialTimeout = -time.Second }, wantErr: "traffic.tcp.dial_timeout must be positive"},
677 {name: "negative tcp idle timeout", mutate: func(g *TrafficGraph) { g.Options.TCP.IdleTimeout = -time.Second }, wantErr: "traffic.tcp.idle_timeout must be positive"},
678 {name: "negative tcp drain timeout", mutate: func(g *TrafficGraph) { g.Options.TCP.DrainTimeout = -time.Second }, wantErr: "traffic.tcp.drain_timeout must be positive"},
679 {name: "negative tcp max connections", mutate: func(g *TrafficGraph) { g.Options.TCP.MaxConnections = -1 }, wantErr: "traffic.tcp.max_connections must be non-negative"},
680 {name: "negative udp idle timeout", mutate: func(g *TrafficGraph) { g.Options.UDP.IdleTimeout = -time.Second }, wantErr: "traffic.udp.idle_timeout must be positive"},
681 {name: "negative udp drain timeout", mutate: func(g *TrafficGraph) { g.Options.UDP.DrainTimeout = -time.Second }, wantErr: "traffic.udp.drain_timeout must be positive"},
682 {name: "negative udp max sessions", mutate: func(g *TrafficGraph) { g.Options.UDP.MaxSessions = -1 }, wantErr: "traffic.udp.max_sessions must be non-negative"},
683 }
684 for _, tt := range tests {
685 t.Run(tt.name, func(t *testing.T) {
686 graph := base
687 tt.mutate(&graph)
688 require.ErrorContains(t, graph.Validate(), tt.wantErr)
689 })
690 }
691}
692
693func TestTrafficDomainTypesMatchSpecFields(t *testing.T) {
694 graph := TrafficGraph{
695 Options: TrafficOptions{
696 TCP: TCPOptions{DialTimeout: time.Second, IdleTimeout: time.Minute, DrainTimeout: 2 * time.Second, MaxConnections: 100},
697 UDP: UDPOptions{IdleTimeout: time.Minute, DrainTimeout: 2 * time.Second, MaxSessions: 50},
698 },
699 EntryPoints: []EntryPoint{{Name: "tcp", Address: ":1234", Protocol: EntryPointProtocolTCP, TrustedCIDRs: []string{"10.0.0.0/8"}}},
700 Routers: []TrafficRouter{{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:game"}},
701 Services: []TrafficService{{Name: "network_service:app:game", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 1234, Protocol: NetworkProtocolTCP}}}},
702 }
703 require.NoError(t, graph.Validate())
704
705 status := TrafficStatus{
706 LastReloadStatus: "ok",
707 LastReloadError: "",
708 EntryPoints: []EntryPointStatus{{Name: "tcp", ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7}},
709 Routers: []TrafficRouterStatus{{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:game", Active: true}},
710 Services: []TrafficServiceStatus{{Name: "network_service:app:game", Backends: []TrafficBackendStatus{{Name: "app", Host: "10.0.0.2", Port: 1234, Protocol: NetworkProtocolTCP, Active: true}}}},
711 Counters: TrafficCounters{ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7},
712 }
713 require.Equal(t, "ok", status.LastReloadStatus)
714 require.Empty(t, status.LastReloadError)
715 require.Equal(t, int64(7), status.EntryPoints[0].BytesOut)
716 require.True(t, status.Routers[0].Active)
717 require.True(t, status.Services[0].Backends[0].Active)
718 require.Equal(t, int64(7), status.Counters.BytesOut)
719}