v3.4.0 593c1c3

traffic_test.go RawHistoryFolder

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}

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 8

Tags 106

↑↓ moveenter openesc close