detached 7924808

feat(traffic): add traffic graph domain model

commit
7924808985266003c12ac5777ea74decb09756f5
author
brice · · 2026-06-29 20:59 UTC
parent
1db619a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +1144 -0
A internal/domain/traffic.go +636 -0
@@ -0,0 +1,636 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "net"
6+ "strconv"
7+ "strings"
8+ "time"
9+)
10+
11+type EntryPointProtocol string
12+
13+const (
14+ EntryPointProtocolHTTP EntryPointProtocol = "http"
15+ EntryPointProtocolTLSMux EntryPointProtocol = "tls_mux"
16+ EntryPointProtocolTCP EntryPointProtocol = "tcp"
17+ EntryPointProtocolUDP EntryPointProtocol = "udp"
18+)
19+
20+type RouterProtocol string
21+
22+const (
23+ RouterProtocolHTTP RouterProtocol = "http"
24+ RouterProtocolTCP RouterProtocol = "tcp"
25+ RouterProtocolUDP RouterProtocol = "udp"
26+ RouterProtocolTLSPassthrough RouterProtocol = "tls_passthrough"
27+)
28+
29+type NetworkProtocol string
30+
31+const (
32+ NetworkProtocolTCP NetworkProtocol = "tcp"
33+ NetworkProtocolUDP NetworkProtocol = "udp"
34+)
35+
36+type TrafficGraph struct {
37+ Options TrafficOptions
38+ EntryPoints []EntryPoint
39+ Routers []TrafficRouter
40+ Services []TrafficService
41+}
42+
43+type TrafficOptions struct {
44+ TCP TCPOptions
45+ UDP UDPOptions
46+}
47+
48+type TCPOptions struct {
49+ DialTimeout time.Duration
50+ IdleTimeout time.Duration
51+ DrainTimeout time.Duration
52+ MaxConnections int
53+}
54+
55+type UDPOptions struct {
56+ IdleTimeout time.Duration
57+ DrainTimeout time.Duration
58+ MaxSessions int
59+}
60+
61+type EntryPoint struct {
62+ Name string
63+ Address string
64+ Protocol EntryPointProtocol
65+ TrustedCIDRs []string
66+}
67+
68+type TrafficRouter struct {
69+ Name string
70+ EntryPoint string
71+ Protocol RouterProtocol
72+ Rule TrafficRule
73+ Service string
74+}
75+
76+type TrafficRule struct {
77+ Host string
78+ SNI string
79+}
80+
81+type TrafficService struct {
82+ Name string
83+ Backends []TrafficBackend
84+}
85+
86+type TrafficBackend struct {
87+ Name string
88+ Host string
89+ Port int
90+ Protocol NetworkProtocol
91+}
92+
93+type TrafficStatus struct {
94+ LastReloadStatus string
95+ LastReloadError string
96+ EntryPoints []EntryPointStatus
97+ Routers []TrafficRouterStatus
98+ Services []TrafficServiceStatus
99+ Counters TrafficCounters
100+}
101+
102+type EntryPointStatus struct {
103+ Name string
104+ Address string
105+ Protocol EntryPointProtocol
106+ Active bool
107+ ActiveTCPConnections int64
108+ ActiveUDPSessions int64
109+ TotalAccepted int64
110+ TotalRefused int64
111+ TotalErrors int64
112+ BytesIn int64
113+ BytesOut int64
114+}
115+
116+type TrafficRouterStatus struct {
117+ Name string
118+ EntryPoint string
119+ Protocol RouterProtocol
120+ Active bool
121+ ActiveTCPConnections int64
122+ ActiveUDPSessions int64
123+ TotalAccepted int64
124+ TotalRefused int64
125+ TotalErrors int64
126+ BytesIn int64
127+ BytesOut int64
128+}
129+
130+type TrafficServiceStatus struct {
131+ Name string
132+ Active bool
133+ Backends []TrafficBackendStatus
134+}
135+
136+type TrafficBackendStatus struct {
137+ Name string
138+ Active bool
139+ ActiveTCPConnections int64
140+ ActiveUDPSessions int64
141+ TotalAccepted int64
142+ TotalRefused int64
143+ TotalErrors int64
144+ BytesIn int64
145+ BytesOut int64
146+}
147+
148+type TrafficCounters struct {
149+ ActiveTCPConnections int64
150+ ActiveUDPSessions int64
151+ TotalAccepted int64
152+ TotalRefused int64
153+ TotalErrors int64
154+ BytesIn int64
155+ BytesOut int64
156+}
157+
158+type TrafficServiceRefKind string
159+
160+const (
161+ TrafficServiceRefRoute TrafficServiceRefKind = "route"
162+ TrafficServiceRefExternalRoute TrafficServiceRefKind = "external_route"
163+ TrafficServiceRefNetworkService TrafficServiceRefKind = "network_service"
164+ TrafficServiceRefStatic TrafficServiceRefKind = "static"
165+)
166+
167+type TrafficServiceRef struct {
168+ Kind TrafficServiceRefKind
169+ Domain string
170+ Name string
171+ PortName string
172+ Reserved bool
173+}
174+
175+func ParseTrafficServiceRef(value string) (TrafficServiceRef, error) {
176+ kind, rest, ok := strings.Cut(strings.TrimSpace(value), ":")
177+ if !ok || rest == "" {
178+ return TrafficServiceRef{}, fmt.Errorf("invalid traffic service ref %q", value)
179+ }
180+ switch TrafficServiceRefKind(kind) {
181+ case TrafficServiceRefRoute:
182+ return TrafficServiceRef{Kind: TrafficServiceRefRoute, Domain: rest}, nil
183+ case TrafficServiceRefExternalRoute:
184+ return TrafficServiceRef{Kind: TrafficServiceRefExternalRoute, Domain: rest}, nil
185+ case TrafficServiceRefNetworkService:
186+ name, portName, ok := strings.Cut(rest, ":")
187+ if !ok || name == "" || portName == "" || strings.Contains(portName, ":") {
188+ return TrafficServiceRef{}, fmt.Errorf("invalid network service ref %q", value)
189+ }
190+ return TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: name, PortName: portName}, nil
191+ case TrafficServiceRefStatic:
192+ return TrafficServiceRef{Kind: TrafficServiceRefStatic, Name: rest, Reserved: true}, nil
193+ default:
194+ return TrafficServiceRef{}, fmt.Errorf("invalid traffic service ref kind %q", kind)
195+ }
196+}
197+
198+func (g TrafficGraph) Validate() error {
199+ entryPoints, err := validateTrafficEntryPoints(g.EntryPoints)
200+ if err != nil {
201+ return err
202+ }
203+ services, err := validateTrafficServices(g.Services)
204+ if err != nil {
205+ return err
206+ }
207+ return g.validateRouters(entryPoints, services)
208+}
209+
210+func validateTrafficEntryPoints(entries []EntryPoint) (map[string]EntryPoint, error) {
211+ entryPoints := make(map[string]EntryPoint, len(entries))
212+ listenAddresses := newProtocolListenAddressSet()
213+ for _, entryPoint := range entries {
214+ if err := validateTrafficEntryPoint(entryPoint, entryPoints, listenAddresses); err != nil {
215+ return nil, err
216+ }
217+ entryPoints[entryPoint.Name] = entryPoint
218+ }
219+ return entryPoints, nil
220+}
221+
222+type protocolListenAddressSet struct {
223+ tcp listenAddressSet
224+ udp listenAddressSet
225+}
226+
227+func newProtocolListenAddressSet() *protocolListenAddressSet {
228+ return &protocolListenAddressSet{tcp: newListenAddressSet(), udp: newListenAddressSet()}
229+}
230+
231+func (s *protocolListenAddressSet) add(entryPoint EntryPoint, addr listenAddress) error {
232+ if entryPoint.Protocol == EntryPointProtocolUDP {
233+ return s.udp.add(addr, entryPoint.Name, "udp")
234+ }
235+ return s.tcp.add(addr, entryPoint.Name, "tcp")
236+}
237+
238+func validateTrafficEntryPoint(entryPoint EntryPoint, existing map[string]EntryPoint, listenAddresses *protocolListenAddressSet) error {
239+ if entryPoint.Name == "" {
240+ return fmt.Errorf("entrypoint name is required")
241+ }
242+ if _, exists := existing[entryPoint.Name]; exists {
243+ return fmt.Errorf("duplicate entrypoint name %q", entryPoint.Name)
244+ }
245+ if err := validateEntryPointProtocol(entryPoint.Protocol); err != nil {
246+ return fmt.Errorf("invalid entrypoint protocol for %q: %w", entryPoint.Name, err)
247+ }
248+ addr, err := parseListenAddress(entryPoint.Address)
249+ if err != nil {
250+ return fmt.Errorf("invalid entrypoint address for %q: %w", entryPoint.Name, err)
251+ }
252+ return listenAddresses.add(entryPoint, addr)
253+}
254+
255+func validateTrafficServices(values []TrafficService) (map[string]TrafficService, error) {
256+ services := make(map[string]TrafficService, len(values))
257+ for _, service := range values {
258+ if err := validateTrafficService(service, services); err != nil {
259+ return nil, err
260+ }
261+ services[service.Name] = service
262+ }
263+ return services, nil
264+}
265+
266+func validateTrafficService(service TrafficService, existing map[string]TrafficService) error {
267+ if service.Name == "" {
268+ return fmt.Errorf("traffic service name is required")
269+ }
270+ if _, exists := existing[service.Name]; exists {
271+ return fmt.Errorf("duplicate traffic service name %q", service.Name)
272+ }
273+ if _, err := ParseTrafficServiceRef(service.Name); err != nil {
274+ return fmt.Errorf("invalid traffic service ref %q: %w", service.Name, err)
275+ }
276+ for _, backend := range service.Backends {
277+ if err := validateTrafficBackend(service.Name, backend); err != nil {
278+ return err
279+ }
280+ }
281+ return nil
282+}
283+
284+func validateTrafficBackend(serviceName string, backend TrafficBackend) error {
285+ if backend.Port < 1 || backend.Port > 65535 {
286+ return fmt.Errorf("invalid backend port %d for service %q", backend.Port, serviceName)
287+ }
288+ if err := validateNetworkProtocol(backend.Protocol); err != nil {
289+ return fmt.Errorf("invalid backend protocol for service %q: %w", serviceName, err)
290+ }
291+ return nil
292+}
293+
294+func (g TrafficGraph) validateRouters(entryPoints map[string]EntryPoint, services map[string]TrafficService) error {
295+ state := newRouterValidationState()
296+ for _, router := range g.Routers {
297+ if err := state.validateRouter(router, entryPoints, services); err != nil {
298+ return err
299+ }
300+ }
301+ return nil
302+}
303+
304+type routerValidationState struct {
305+ routerNames map[string]struct{}
306+ exactSNI map[string]map[string]string
307+ wildSNI map[string]map[string]string
308+ httpHosts map[string]map[string]string
309+ tcpRouters map[string]string
310+ udpRouters map[string]string
311+}
312+
313+func newRouterValidationState() *routerValidationState {
314+ return &routerValidationState{
315+ routerNames: map[string]struct{}{},
316+ exactSNI: map[string]map[string]string{},
317+ wildSNI: map[string]map[string]string{},
318+ httpHosts: map[string]map[string]string{},
319+ tcpRouters: map[string]string{},
320+ udpRouters: map[string]string{},
321+ }
322+}
323+
324+func (s *routerValidationState) validateRouter(router TrafficRouter, entryPoints map[string]EntryPoint, services map[string]TrafficService) error {
325+ if err := s.validateRouterName(router); err != nil {
326+ return err
327+ }
328+ entryPoint, serviceRef, service, err := resolveRouterTargets(router, entryPoints, services)
329+ if err != nil {
330+ return err
331+ }
332+ if err := validateRouterEntryPointProtocol(router, entryPoint); err != nil {
333+ return err
334+ }
335+ if err := validateRouterServiceRef(router, serviceRef, service); err != nil {
336+ return err
337+ }
338+ if err := s.validateRouterRule(router, entryPoint); err != nil {
339+ return err
340+ }
341+ return s.validateTLSPassthroughRule(router, entryPoint)
342+}
343+
344+func (s *routerValidationState) validateRouterName(router TrafficRouter) error {
345+ if router.Name == "" {
346+ return fmt.Errorf("traffic router name is required")
347+ }
348+ if _, exists := s.routerNames[router.Name]; exists {
349+ return fmt.Errorf("duplicate traffic router name %q", router.Name)
350+ }
351+ s.routerNames[router.Name] = struct{}{}
352+ return nil
353+}
354+
355+func resolveRouterTargets(router TrafficRouter, entryPoints map[string]EntryPoint, services map[string]TrafficService) (EntryPoint, TrafficServiceRef, TrafficService, error) {
356+ entryPoint, ok := entryPoints[router.EntryPoint]
357+ if !ok {
358+ return EntryPoint{}, TrafficServiceRef{}, TrafficService{}, fmt.Errorf("router %q references unknown entrypoint %q", router.Name, router.EntryPoint)
359+ }
360+ serviceRef, err := ParseTrafficServiceRef(router.Service)
361+ if err != nil {
362+ return EntryPoint{}, TrafficServiceRef{}, TrafficService{}, fmt.Errorf("router %q has invalid service ref: %w", router.Name, err)
363+ }
364+ service, ok := services[router.Service]
365+ if !ok {
366+ return EntryPoint{}, TrafficServiceRef{}, TrafficService{}, fmt.Errorf("router %q references unknown service %q", router.Name, router.Service)
367+ }
368+ if err := validateRouterProtocol(router.Protocol); err != nil {
369+ return EntryPoint{}, TrafficServiceRef{}, TrafficService{}, fmt.Errorf("invalid router protocol for %q: %w", router.Name, err)
370+ }
371+ return entryPoint, serviceRef, service, nil
372+}
373+
374+func (s *routerValidationState) validateRouterRule(router TrafficRouter, entryPoint EntryPoint) error {
375+ switch router.Protocol {
376+ case RouterProtocolTCP:
377+ return validateSingleL4Router(s.tcpRouters, "tcp", entryPoint.Name, router.Name)
378+ case RouterProtocolUDP:
379+ return validateSingleL4Router(s.udpRouters, "udp", entryPoint.Name, router.Name)
380+ case RouterProtocolHTTP:
381+ return s.validateHTTPRule(router, entryPoint)
382+ default:
383+ return nil
384+ }
385+}
386+
387+func validateSingleL4Router(routers map[string]string, protocol string, entryPoint string, routerName string) error {
388+ if existing := routers[entryPoint]; existing != "" {
389+ return fmt.Errorf("ambiguous %s routers on entrypoint %q: %q and %q", protocol, entryPoint, existing, routerName)
390+ }
391+ routers[entryPoint] = routerName
392+ return nil
393+}
394+
395+func (s *routerValidationState) validateHTTPRule(router TrafficRouter, entryPoint EntryPoint) error {
396+ if router.Rule.Host == "" {
397+ return nil
398+ }
399+ host := normalizeHost(router.Rule.Host)
400+ if existing := s.httpHosts[entryPoint.Name][host]; existing != "" {
401+ return fmt.Errorf("duplicate http host %q on entrypoint %q used by routers %q and %q", host, entryPoint.Name, existing, router.Name)
402+ }
403+ if err := s.validateHTTPRuleTLSConflicts(entryPoint.Name, host); err != nil {
404+ return err
405+ }
406+ putNested(s.httpHosts, entryPoint.Name, host, router.Name)
407+ return nil
408+}
409+
410+func (s *routerValidationState) validateHTTPRuleTLSConflicts(entryPoint string, host string) error {
411+ if s.exactSNI[entryPoint][host] != "" {
412+ return fmt.Errorf("http host conflicts with tls passthrough sni %q on entrypoint %q", host, entryPoint)
413+ }
414+ if wildcard := matchingWildcardSNI(host, s.wildSNI[entryPoint]); wildcard != "" {
415+ return fmt.Errorf("http host conflicts with wildcard tls passthrough sni %q on entrypoint %q", wildcard, entryPoint)
416+ }
417+ return nil
418+}
419+
420+func (s *routerValidationState) validateTLSPassthroughRule(router TrafficRouter, entryPoint EntryPoint) error {
421+ if router.Protocol != RouterProtocolTLSPassthrough || router.Rule.SNI == "" {
422+ return nil
423+ }
424+ sni := normalizeHost(router.Rule.SNI)
425+ if strings.HasPrefix(sni, "*.") {
426+ return s.validateWildcardSNI(entryPoint.Name, sni, router.Name)
427+ }
428+ return s.validateExactSNI(entryPoint.Name, sni, router.Name)
429+}
430+
431+func (s *routerValidationState) validateWildcardSNI(entryPoint string, sni string, routerName string) error {
432+ if s.wildSNI[entryPoint][sni] != "" {
433+ return fmt.Errorf("ambiguous wildcard tls sni %q on entrypoint %q", sni, entryPoint)
434+ }
435+ if host := matchingHTTPHost(sni, s.httpHosts[entryPoint]); host != "" {
436+ return fmt.Errorf("http host conflicts with wildcard tls passthrough sni %q on entrypoint %q for host %q", sni, entryPoint, host)
437+ }
438+ putNested(s.wildSNI, entryPoint, sni, routerName)
439+ return nil
440+}
441+
442+func (s *routerValidationState) validateExactSNI(entryPoint string, sni string, routerName string) error {
443+ if s.exactSNI[entryPoint][sni] != "" {
444+ return fmt.Errorf("duplicate exact tls sni %q on entrypoint %q", sni, entryPoint)
445+ }
446+ if s.httpHosts[entryPoint][sni] != "" {
447+ return fmt.Errorf("http host conflicts with tls passthrough sni %q on entrypoint %q", sni, entryPoint)
448+ }
449+ putNested(s.exactSNI, entryPoint, sni, routerName)
450+ return nil
451+}
452+
453+func validateRouterEntryPointProtocol(router TrafficRouter, entryPoint EntryPoint) error {
454+ compatible := false
455+ switch router.Protocol {
456+ case RouterProtocolHTTP:
457+ compatible = entryPoint.Protocol == EntryPointProtocolHTTP || entryPoint.Protocol == EntryPointProtocolTLSMux
458+ case RouterProtocolTLSPassthrough:
459+ compatible = entryPoint.Protocol == EntryPointProtocolTLSMux
460+ case RouterProtocolTCP:
461+ compatible = entryPoint.Protocol == EntryPointProtocolTCP
462+ case RouterProtocolUDP:
463+ compatible = entryPoint.Protocol == EntryPointProtocolUDP
464+ }
465+ if compatible {
466+ return nil
467+ }
468+ return fmt.Errorf("router %q protocol %s is incompatible with entrypoint %q protocol %s", router.Name, router.Protocol, entryPoint.Name, entryPoint.Protocol)
469+}
470+
471+func validateRouterServiceRef(router TrafficRouter, ref TrafficServiceRef, service TrafficService) error {
472+ switch router.Protocol {
473+ case RouterProtocolHTTP:
474+ if ref.Kind == TrafficServiceRefRoute || ref.Kind == TrafficServiceRefExternalRoute {
475+ return nil
476+ }
477+ case RouterProtocolTCP, RouterProtocolUDP, RouterProtocolTLSPassthrough:
478+ return validateL4RouterService(router, ref, service)
479+ }
480+ if ref.Kind == TrafficServiceRefStatic {
481+ return fmt.Errorf("static traffic service ref %q is unsupported in traffic graph validation", router.Service)
482+ }
483+ return fmt.Errorf("router %q protocol %s does not support service ref kind %s", router.Name, router.Protocol, ref.Kind)
484+}
485+
486+func validateL4RouterService(router TrafficRouter, ref TrafficServiceRef, service TrafficService) error {
487+ want, ok := l4BackendProtocol(router.Protocol)
488+ if !ok {
489+ return nil
490+ }
491+ if ref.Kind != TrafficServiceRefNetworkService {
492+ if ref.Kind == TrafficServiceRefStatic {
493+ return fmt.Errorf("static traffic service ref %q is unsupported in traffic graph validation", router.Service)
494+ }
495+ return fmt.Errorf("l4 router %q requires network_service service ref", router.Name)
496+ }
497+ if len(service.Backends) != 1 {
498+ return fmt.Errorf("l4 router service must have exactly one backend for router %q", router.Name)
499+ }
500+ backend := service.Backends[0]
501+ if backend.Protocol != want {
502+ return fmt.Errorf("backend protocol %s does not match router protocol %s for router %q", backend.Protocol, want, router.Name)
503+ }
504+ return nil
505+}
506+
507+func l4BackendProtocol(protocol RouterProtocol) (NetworkProtocol, bool) {
508+ switch protocol {
509+ case RouterProtocolTCP:
510+ return NetworkProtocolTCP, true
511+ case RouterProtocolUDP:
512+ return NetworkProtocolUDP, true
513+ case RouterProtocolTLSPassthrough:
514+ return NetworkProtocolTCP, true
515+ default:
516+ return "", false
517+ }
518+}
519+
520+func validateEntryPointProtocol(protocol EntryPointProtocol) error {
521+ switch protocol {
522+ case EntryPointProtocolHTTP, EntryPointProtocolTLSMux, EntryPointProtocolTCP, EntryPointProtocolUDP:
523+ return nil
524+ default:
525+ return fmt.Errorf("%q", protocol)
526+ }
527+}
528+
529+func validateRouterProtocol(protocol RouterProtocol) error {
530+ switch protocol {
531+ case RouterProtocolHTTP, RouterProtocolTCP, RouterProtocolUDP, RouterProtocolTLSPassthrough:
532+ return nil
533+ default:
534+ return fmt.Errorf("%q", protocol)
535+ }
536+}
537+
538+func validateNetworkProtocol(protocol NetworkProtocol) error {
539+ switch protocol {
540+ case NetworkProtocolTCP, NetworkProtocolUDP:
541+ return nil
542+ default:
543+ return fmt.Errorf("%q", protocol)
544+ }
545+}
546+
547+type listenAddress struct {
548+ key string
549+ port int
550+ wildcard bool
551+}
552+
553+type listenAddressSet struct {
554+ byKey map[string]string
555+ all []registeredListenAddress
556+}
557+
558+type registeredListenAddress struct {
559+ addr listenAddress
560+ name string
561+}
562+
563+func newListenAddressSet() listenAddressSet {
564+ return listenAddressSet{byKey: map[string]string{}}
565+}
566+
567+func (s *listenAddressSet) add(addr listenAddress, name string, protocol string) error {
568+ if other, exists := s.byKey[addr.key]; exists {
569+ return fmt.Errorf("duplicate %s entrypoint address %q used by %q and %q", protocol, addr.key, other, name)
570+ }
571+ for _, existing := range s.all {
572+ if existing.addr.port == addr.port && (existing.addr.wildcard || addr.wildcard) {
573+ return fmt.Errorf("conflicting %s entrypoint address %q used by %q and %q", protocol, addr.key, existing.name, name)
574+ }
575+ }
576+ s.byKey[addr.key] = name
577+ s.all = append(s.all, registeredListenAddress{addr: addr, name: name})
578+ return nil
579+}
580+
581+func parseListenAddress(address string) (listenAddress, error) {
582+ host, portValue, err := net.SplitHostPort(address)
583+ if err != nil {
584+ return listenAddress{}, err
585+ }
586+ port, err := strconv.Atoi(portValue)
587+ if err != nil || port < 1 || port > 65535 {
588+ return listenAddress{}, fmt.Errorf("invalid port %q", portValue)
589+ }
590+ return listenAddress{
591+ key: net.JoinHostPort(host, strconv.Itoa(port)),
592+ port: port,
593+ wildcard: isWildcardListenHost(host),
594+ }, nil
595+}
596+
597+func isWildcardListenHost(host string) bool {
598+ return host == "" || host == "0.0.0.0" || host == "::"
599+}
600+
601+func normalizeHost(host string) string {
602+ return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
603+}
604+
605+func matchingWildcardSNI(host string, wildcards map[string]string) string {
606+ for wildcard := range wildcards {
607+ if hostMatchesWildcard(host, wildcard) {
608+ return wildcard
609+ }
610+ }
611+ return ""
612+}
613+
614+func matchingHTTPHost(wildcard string, hosts map[string]string) string {
615+ for host := range hosts {
616+ if hostMatchesWildcard(host, wildcard) {
617+ return host
618+ }
619+ }
620+ return ""
621+}
622+
623+func hostMatchesWildcard(host string, wildcard string) bool {
624+ suffix, ok := strings.CutPrefix(wildcard, "*.")
625+ if !ok || host == suffix {
626+ return false
627+ }
628+ return strings.HasSuffix(host, "."+suffix)
629+}
630+
631+func putNested(values map[string]map[string]string, key string, nestedKey string, value string) {
632+ if values[key] == nil {
633+ values[key] = map[string]string{}
634+ }
635+ values[key][nestedKey] = value
636+}
@@ -0,0 +1,636 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "net"
6+ "strconv"
7+ "strings"
8+ "time"
9+)
10+
11+type EntryPointProtocol string
12+
13+const (
14+ EntryPointProtocolHTTP EntryPointProtocol = "http"
15+ EntryPointProtocolTLSMux EntryPointProtocol = "tls_mux"
16+ EntryPointProtocolTCP EntryPointProtocol = "tcp"
17+ EntryPointProtocolUDP EntryPointProtocol = "udp"
18+)
19+
20+type RouterProtocol string
21+
22+const (
23+ RouterProtocolHTTP RouterProtocol = "http"
24+ RouterProtocolTCP RouterProtocol = "tcp"
25+ RouterProtocolUDP RouterProtocol = "udp"
26+ RouterProtocolTLSPassthrough RouterProtocol = "tls_passthrough"
27+)
28+
29+type NetworkProtocol string
30+
31+const (
32+ NetworkProtocolTCP NetworkProtocol = "tcp"
33+ NetworkProtocolUDP NetworkProtocol = "udp"
34+)
35+
36+type TrafficGraph struct {
37+ Options TrafficOptions
38+ EntryPoints []EntryPoint
39+ Routers []TrafficRouter
40+ Services []TrafficService
41+}
42+
43+type TrafficOptions struct {
44+ TCP TCPOptions
45+ UDP UDPOptions
46+}
47+
48+type TCPOptions struct {
49+ DialTimeout time.Duration
50+ IdleTimeout time.Duration
51+ DrainTimeout time.Duration
52+ MaxConnections int
53+}
54+
55+type UDPOptions struct {
56+ IdleTimeout time.Duration
57+ DrainTimeout time.Duration
58+ MaxSessions int
59+}
60+
61+type EntryPoint struct {
62+ Name string
63+ Address string
64+ Protocol EntryPointProtocol
65+ TrustedCIDRs []string
66+}
67+
68+type TrafficRouter struct {
69+ Name string
70+ EntryPoint string
71+ Protocol RouterProtocol
72+ Rule TrafficRule
73+ Service string
74+}
75+
76+type TrafficRule struct {
77+ Host string
78+ SNI string
79+}
80+
81+type TrafficService struct {
82+ Name string
83+ Backends []TrafficBackend
84+}
85+
86+type TrafficBackend struct {
87+ Name string
88+ Host string
89+ Port int
90+ Protocol NetworkProtocol
91+}
92+
93+type TrafficStatus struct {
94+ LastReloadStatus string
95+ LastReloadError string
96+ EntryPoints []EntryPointStatus
97+ Routers []TrafficRouterStatus
98+ Services []TrafficServiceStatus
99+ Counters TrafficCounters
100+}
101+
102+type EntryPointStatus struct {
103+ Name string
104+ Address string
105+ Protocol EntryPointProtocol
106+ Active bool
107+ ActiveTCPConnections int64
108+ ActiveUDPSessions int64
109+ TotalAccepted int64
110+ TotalRefused int64
111+ TotalErrors int64
112+ BytesIn int64
113+ BytesOut int64
114+}
115+
116+type TrafficRouterStatus struct {
117+ Name string
118+ EntryPoint string
119+ Protocol RouterProtocol
120+ Active bool
121+ ActiveTCPConnections int64
122+ ActiveUDPSessions int64
123+ TotalAccepted int64
124+ TotalRefused int64
125+ TotalErrors int64
126+ BytesIn int64
127+ BytesOut int64
128+}
129+
130+type TrafficServiceStatus struct {
131+ Name string
132+ Active bool
133+ Backends []TrafficBackendStatus
134+}
135+
136+type TrafficBackendStatus struct {
137+ Name string
138+ Active bool
139+ ActiveTCPConnections int64
140+ ActiveUDPSessions int64
141+ TotalAccepted int64
142+ TotalRefused int64
143+ TotalErrors int64
144+ BytesIn int64
145+ BytesOut int64
146+}
147+
148+type TrafficCounters struct {
149+ ActiveTCPConnections int64
150+ ActiveUDPSessions int64
151+ TotalAccepted int64
152+ TotalRefused int64
153+ TotalErrors int64
154+ BytesIn int64
155+ BytesOut int64
156+}
157+
158+type TrafficServiceRefKind string
159+
160+const (
161+ TrafficServiceRefRoute TrafficServiceRefKind = "route"
162+ TrafficServiceRefExternalRoute TrafficServiceRefKind = "external_route"
163+ TrafficServiceRefNetworkService TrafficServiceRefKind = "network_service"
164+ TrafficServiceRefStatic TrafficServiceRefKind = "static"
165+)
166+
167+type TrafficServiceRef struct {
168+ Kind TrafficServiceRefKind
169+ Domain string
170+ Name string
171+ PortName string
172+ Reserved bool
173+}
174+
175+func ParseTrafficServiceRef(value string) (TrafficServiceRef, error) {
176+ kind, rest, ok := strings.Cut(strings.TrimSpace(value), ":")
177+ if !ok || rest == "" {
178+ return TrafficServiceRef{}, fmt.Errorf("invalid traffic service ref %q", value)
179+ }
180+ switch TrafficServiceRefKind(kind) {
181+ case TrafficServiceRefRoute:
182+ return TrafficServiceRef{Kind: TrafficServiceRefRoute, Domain: rest}, nil
183+ case TrafficServiceRefExternalRoute:
184+ return TrafficServiceRef{Kind: TrafficServiceRefExternalRoute, Domain: rest}, nil
185+ case TrafficServiceRefNetworkService:
186+ name, portName, ok := strings.Cut(rest, ":")
187+ if !ok || name == "" || portName == "" || strings.Contains(portName, ":") {
188+ return TrafficServiceRef{}, fmt.Errorf("invalid network service ref %q", value)
189+ }
190+ return TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: name, PortName: portName}, nil
191+ case TrafficServiceRefStatic:
192+ return TrafficServiceRef{Kind: TrafficServiceRefStatic, Name: rest, Reserved: true}, nil
193+ default:
194+ return TrafficServiceRef{}, fmt.Errorf("invalid traffic service ref kind %q", kind)
195+ }
196+}
197+
198+func (g TrafficGraph) Validate() error {
199+ entryPoints, err := validateTrafficEntryPoints(g.EntryPoints)
200+ if err != nil {
201+ return err
202+ }
203+ services, err := validateTrafficServices(g.Services)
204+ if err != nil {
205+ return err
206+ }
207+ return g.validateRouters(entryPoints, services)
208+}
209+
210+func validateTrafficEntryPoints(entries []EntryPoint) (map[string]EntryPoint, error) {
211+ entryPoints := make(map[string]EntryPoint, len(entries))
212+ listenAddresses := newProtocolListenAddressSet()
213+ for _, entryPoint := range entries {
214+ if err := validateTrafficEntryPoint(entryPoint, entryPoints, listenAddresses); err != nil {
215+ return nil, err
216+ }
217+ entryPoints[entryPoint.Name] = entryPoint
218+ }
219+ return entryPoints, nil
220+}
221+
222+type protocolListenAddressSet struct {
223+ tcp listenAddressSet
224+ udp listenAddressSet
225+}
226+
227+func newProtocolListenAddressSet() *protocolListenAddressSet {
228+ return &protocolListenAddressSet{tcp: newListenAddressSet(), udp: newListenAddressSet()}
229+}
230+
231+func (s *protocolListenAddressSet) add(entryPoint EntryPoint, addr listenAddress) error {
232+ if entryPoint.Protocol == EntryPointProtocolUDP {
233+ return s.udp.add(addr, entryPoint.Name, "udp")
234+ }
235+ return s.tcp.add(addr, entryPoint.Name, "tcp")
236+}
237+
238+func validateTrafficEntryPoint(entryPoint EntryPoint, existing map[string]EntryPoint, listenAddresses *protocolListenAddressSet) error {
239+ if entryPoint.Name == "" {
240+ return fmt.Errorf("entrypoint name is required")
241+ }
242+ if _, exists := existing[entryPoint.Name]; exists {
243+ return fmt.Errorf("duplicate entrypoint name %q", entryPoint.Name)
244+ }
245+ if err := validateEntryPointProtocol(entryPoint.Protocol); err != nil {
246+ return fmt.Errorf("invalid entrypoint protocol for %q: %w", entryPoint.Name, err)
247+ }
248+ addr, err := parseListenAddress(entryPoint.Address)
249+ if err != nil {
250+ return fmt.Errorf("invalid entrypoint address for %q: %w", entryPoint.Name, err)
251+ }
252+ return listenAddresses.add(entryPoint, addr)
253+}
254+
255+func validateTrafficServices(values []TrafficService) (map[string]TrafficService, error) {
256+ services := make(map[string]TrafficService, len(values))
257+ for _, service := range values {
258+ if err := validateTrafficService(service, services); err != nil {
259+ return nil, err
260+ }
261+ services[service.Name] = service
262+ }
263+ return services, nil
264+}
265+
266+func validateTrafficService(service TrafficService, existing map[string]TrafficService) error {
267+ if service.Name == "" {
268+ return fmt.Errorf("traffic service name is required")
269+ }
270+ if _, exists := existing[service.Name]; exists {
271+ return fmt.Errorf("duplicate traffic service name %q", service.Name)
272+ }
273+ if _, err := ParseTrafficServiceRef(service.Name); err != nil {
274+ return fmt.Errorf("invalid traffic service ref %q: %w", service.Name, err)
275+ }
276+ for _, backend := range service.Backends {
277+ if err := validateTrafficBackend(service.Name, backend); err != nil {
278+ return err
279+ }
280+ }
281+ return nil
282+}
283+
284+func validateTrafficBackend(serviceName string, backend TrafficBackend) error {
285+ if backend.Port < 1 || backend.Port > 65535 {
286+ return fmt.Errorf("invalid backend port %d for service %q", backend.Port, serviceName)
287+ }
288+ if err := validateNetworkProtocol(backend.Protocol); err != nil {
289+ return fmt.Errorf("invalid backend protocol for service %q: %w", serviceName, err)
290+ }
291+ return nil
292+}
293+
294+func (g TrafficGraph) validateRouters(entryPoints map[string]EntryPoint, services map[string]TrafficService) error {
295+ state := newRouterValidationState()
296+ for _, router := range g.Routers {
297+ if err := state.validateRouter(router, entryPoints, services); err != nil {
298+ return err
299+ }
300+ }
301+ return nil
302+}
303+
304+type routerValidationState struct {
305+ routerNames map[string]struct{}
306+ exactSNI map[string]map[string]string
307+ wildSNI map[string]map[string]string
308+ httpHosts map[string]map[string]string
309+ tcpRouters map[string]string
310+ udpRouters map[string]string
311+}
312+
313+func newRouterValidationState() *routerValidationState {
314+ return &routerValidationState{
315+ routerNames: map[string]struct{}{},
316+ exactSNI: map[string]map[string]string{},
317+ wildSNI: map[string]map[string]string{},
318+ httpHosts: map[string]map[string]string{},
319+ tcpRouters: map[string]string{},
320+ udpRouters: map[string]string{},
321+ }
322+}
323+
324+func (s *routerValidationState) validateRouter(router TrafficRouter, entryPoints map[string]EntryPoint, services map[string]TrafficService) error {
325+ if err := s.validateRouterName(router); err != nil {
326+ return err
327+ }
328+ entryPoint, serviceRef, service, err := resolveRouterTargets(router, entryPoints, services)
329+ if err != nil {
330+ return err
331+ }
332+ if err := validateRouterEntryPointProtocol(router, entryPoint); err != nil {
333+ return err
334+ }
335+ if err := validateRouterServiceRef(router, serviceRef, service); err != nil {
336+ return err
337+ }
338+ if err := s.validateRouterRule(router, entryPoint); err != nil {
339+ return err
340+ }
341+ return s.validateTLSPassthroughRule(router, entryPoint)
342+}
343+
344+func (s *routerValidationState) validateRouterName(router TrafficRouter) error {
345+ if router.Name == "" {
346+ return fmt.Errorf("traffic router name is required")
347+ }
348+ if _, exists := s.routerNames[router.Name]; exists {
349+ return fmt.Errorf("duplicate traffic router name %q", router.Name)
350+ }
351+ s.routerNames[router.Name] = struct{}{}
352+ return nil
353+}
354+
355+func resolveRouterTargets(router TrafficRouter, entryPoints map[string]EntryPoint, services map[string]TrafficService) (EntryPoint, TrafficServiceRef, TrafficService, error) {
356+ entryPoint, ok := entryPoints[router.EntryPoint]
357+ if !ok {
358+ return EntryPoint{}, TrafficServiceRef{}, TrafficService{}, fmt.Errorf("router %q references unknown entrypoint %q", router.Name, router.EntryPoint)
359+ }
360+ serviceRef, err := ParseTrafficServiceRef(router.Service)
361+ if err != nil {
362+ return EntryPoint{}, TrafficServiceRef{}, TrafficService{}, fmt.Errorf("router %q has invalid service ref: %w", router.Name, err)
363+ }
364+ service, ok := services[router.Service]
365+ if !ok {
366+ return EntryPoint{}, TrafficServiceRef{}, TrafficService{}, fmt.Errorf("router %q references unknown service %q", router.Name, router.Service)
367+ }
368+ if err := validateRouterProtocol(router.Protocol); err != nil {
369+ return EntryPoint{}, TrafficServiceRef{}, TrafficService{}, fmt.Errorf("invalid router protocol for %q: %w", router.Name, err)
370+ }
371+ return entryPoint, serviceRef, service, nil
372+}
373+
374+func (s *routerValidationState) validateRouterRule(router TrafficRouter, entryPoint EntryPoint) error {
375+ switch router.Protocol {
376+ case RouterProtocolTCP:
377+ return validateSingleL4Router(s.tcpRouters, "tcp", entryPoint.Name, router.Name)
378+ case RouterProtocolUDP:
379+ return validateSingleL4Router(s.udpRouters, "udp", entryPoint.Name, router.Name)
380+ case RouterProtocolHTTP:
381+ return s.validateHTTPRule(router, entryPoint)
382+ default:
383+ return nil
384+ }
385+}
386+
387+func validateSingleL4Router(routers map[string]string, protocol string, entryPoint string, routerName string) error {
388+ if existing := routers[entryPoint]; existing != "" {
389+ return fmt.Errorf("ambiguous %s routers on entrypoint %q: %q and %q", protocol, entryPoint, existing, routerName)
390+ }
391+ routers[entryPoint] = routerName
392+ return nil
393+}
394+
395+func (s *routerValidationState) validateHTTPRule(router TrafficRouter, entryPoint EntryPoint) error {
396+ if router.Rule.Host == "" {
397+ return nil
398+ }
399+ host := normalizeHost(router.Rule.Host)
400+ if existing := s.httpHosts[entryPoint.Name][host]; existing != "" {
401+ return fmt.Errorf("duplicate http host %q on entrypoint %q used by routers %q and %q", host, entryPoint.Name, existing, router.Name)
402+ }
403+ if err := s.validateHTTPRuleTLSConflicts(entryPoint.Name, host); err != nil {
404+ return err
405+ }
406+ putNested(s.httpHosts, entryPoint.Name, host, router.Name)
407+ return nil
408+}
409+
410+func (s *routerValidationState) validateHTTPRuleTLSConflicts(entryPoint string, host string) error {
411+ if s.exactSNI[entryPoint][host] != "" {
412+ return fmt.Errorf("http host conflicts with tls passthrough sni %q on entrypoint %q", host, entryPoint)
413+ }
414+ if wildcard := matchingWildcardSNI(host, s.wildSNI[entryPoint]); wildcard != "" {
415+ return fmt.Errorf("http host conflicts with wildcard tls passthrough sni %q on entrypoint %q", wildcard, entryPoint)
416+ }
417+ return nil
418+}
419+
420+func (s *routerValidationState) validateTLSPassthroughRule(router TrafficRouter, entryPoint EntryPoint) error {
421+ if router.Protocol != RouterProtocolTLSPassthrough || router.Rule.SNI == "" {
422+ return nil
423+ }
424+ sni := normalizeHost(router.Rule.SNI)
425+ if strings.HasPrefix(sni, "*.") {
426+ return s.validateWildcardSNI(entryPoint.Name, sni, router.Name)
427+ }
428+ return s.validateExactSNI(entryPoint.Name, sni, router.Name)
429+}
430+
431+func (s *routerValidationState) validateWildcardSNI(entryPoint string, sni string, routerName string) error {
432+ if s.wildSNI[entryPoint][sni] != "" {
433+ return fmt.Errorf("ambiguous wildcard tls sni %q on entrypoint %q", sni, entryPoint)
434+ }
435+ if host := matchingHTTPHost(sni, s.httpHosts[entryPoint]); host != "" {
436+ return fmt.Errorf("http host conflicts with wildcard tls passthrough sni %q on entrypoint %q for host %q", sni, entryPoint, host)
437+ }
438+ putNested(s.wildSNI, entryPoint, sni, routerName)
439+ return nil
440+}
441+
442+func (s *routerValidationState) validateExactSNI(entryPoint string, sni string, routerName string) error {
443+ if s.exactSNI[entryPoint][sni] != "" {
444+ return fmt.Errorf("duplicate exact tls sni %q on entrypoint %q", sni, entryPoint)
445+ }
446+ if s.httpHosts[entryPoint][sni] != "" {
447+ return fmt.Errorf("http host conflicts with tls passthrough sni %q on entrypoint %q", sni, entryPoint)
448+ }
449+ putNested(s.exactSNI, entryPoint, sni, routerName)
450+ return nil
451+}
452+
453+func validateRouterEntryPointProtocol(router TrafficRouter, entryPoint EntryPoint) error {
454+ compatible := false
455+ switch router.Protocol {
456+ case RouterProtocolHTTP:
457+ compatible = entryPoint.Protocol == EntryPointProtocolHTTP || entryPoint.Protocol == EntryPointProtocolTLSMux
458+ case RouterProtocolTLSPassthrough:
459+ compatible = entryPoint.Protocol == EntryPointProtocolTLSMux
460+ case RouterProtocolTCP:
461+ compatible = entryPoint.Protocol == EntryPointProtocolTCP
462+ case RouterProtocolUDP:
463+ compatible = entryPoint.Protocol == EntryPointProtocolUDP
464+ }
465+ if compatible {
466+ return nil
467+ }
468+ return fmt.Errorf("router %q protocol %s is incompatible with entrypoint %q protocol %s", router.Name, router.Protocol, entryPoint.Name, entryPoint.Protocol)
469+}
470+
471+func validateRouterServiceRef(router TrafficRouter, ref TrafficServiceRef, service TrafficService) error {
472+ switch router.Protocol {
473+ case RouterProtocolHTTP:
474+ if ref.Kind == TrafficServiceRefRoute || ref.Kind == TrafficServiceRefExternalRoute {
475+ return nil
476+ }
477+ case RouterProtocolTCP, RouterProtocolUDP, RouterProtocolTLSPassthrough:
478+ return validateL4RouterService(router, ref, service)
479+ }
480+ if ref.Kind == TrafficServiceRefStatic {
481+ return fmt.Errorf("static traffic service ref %q is unsupported in traffic graph validation", router.Service)
482+ }
483+ return fmt.Errorf("router %q protocol %s does not support service ref kind %s", router.Name, router.Protocol, ref.Kind)
484+}
485+
486+func validateL4RouterService(router TrafficRouter, ref TrafficServiceRef, service TrafficService) error {
487+ want, ok := l4BackendProtocol(router.Protocol)
488+ if !ok {
489+ return nil
490+ }
491+ if ref.Kind != TrafficServiceRefNetworkService {
492+ if ref.Kind == TrafficServiceRefStatic {
493+ return fmt.Errorf("static traffic service ref %q is unsupported in traffic graph validation", router.Service)
494+ }
495+ return fmt.Errorf("l4 router %q requires network_service service ref", router.Name)
496+ }
497+ if len(service.Backends) != 1 {
498+ return fmt.Errorf("l4 router service must have exactly one backend for router %q", router.Name)
499+ }
500+ backend := service.Backends[0]
501+ if backend.Protocol != want {
502+ return fmt.Errorf("backend protocol %s does not match router protocol %s for router %q", backend.Protocol, want, router.Name)
503+ }
504+ return nil
505+}
506+
507+func l4BackendProtocol(protocol RouterProtocol) (NetworkProtocol, bool) {
508+ switch protocol {
509+ case RouterProtocolTCP:
510+ return NetworkProtocolTCP, true
511+ case RouterProtocolUDP:
512+ return NetworkProtocolUDP, true
513+ case RouterProtocolTLSPassthrough:
514+ return NetworkProtocolTCP, true
515+ default:
516+ return "", false
517+ }
518+}
519+
520+func validateEntryPointProtocol(protocol EntryPointProtocol) error {
521+ switch protocol {
522+ case EntryPointProtocolHTTP, EntryPointProtocolTLSMux, EntryPointProtocolTCP, EntryPointProtocolUDP:
523+ return nil
524+ default:
525+ return fmt.Errorf("%q", protocol)
526+ }
527+}
528+
529+func validateRouterProtocol(protocol RouterProtocol) error {
530+ switch protocol {
531+ case RouterProtocolHTTP, RouterProtocolTCP, RouterProtocolUDP, RouterProtocolTLSPassthrough:
532+ return nil
533+ default:
534+ return fmt.Errorf("%q", protocol)
535+ }
536+}
537+
538+func validateNetworkProtocol(protocol NetworkProtocol) error {
539+ switch protocol {
540+ case NetworkProtocolTCP, NetworkProtocolUDP:
541+ return nil
542+ default:
543+ return fmt.Errorf("%q", protocol)
544+ }
545+}
546+
547+type listenAddress struct {
548+ key string
549+ port int
550+ wildcard bool
551+}
552+
553+type listenAddressSet struct {
554+ byKey map[string]string
555+ all []registeredListenAddress
556+}
557+
558+type registeredListenAddress struct {
559+ addr listenAddress
560+ name string
561+}
562+
563+func newListenAddressSet() listenAddressSet {
564+ return listenAddressSet{byKey: map[string]string{}}
565+}
566+
567+func (s *listenAddressSet) add(addr listenAddress, name string, protocol string) error {
568+ if other, exists := s.byKey[addr.key]; exists {
569+ return fmt.Errorf("duplicate %s entrypoint address %q used by %q and %q", protocol, addr.key, other, name)
570+ }
571+ for _, existing := range s.all {
572+ if existing.addr.port == addr.port && (existing.addr.wildcard || addr.wildcard) {
573+ return fmt.Errorf("conflicting %s entrypoint address %q used by %q and %q", protocol, addr.key, existing.name, name)
574+ }
575+ }
576+ s.byKey[addr.key] = name
577+ s.all = append(s.all, registeredListenAddress{addr: addr, name: name})
578+ return nil
579+}
580+
581+func parseListenAddress(address string) (listenAddress, error) {
582+ host, portValue, err := net.SplitHostPort(address)
583+ if err != nil {
584+ return listenAddress{}, err
585+ }
586+ port, err := strconv.Atoi(portValue)
587+ if err != nil || port < 1 || port > 65535 {
588+ return listenAddress{}, fmt.Errorf("invalid port %q", portValue)
589+ }
590+ return listenAddress{
591+ key: net.JoinHostPort(host, strconv.Itoa(port)),
592+ port: port,
593+ wildcard: isWildcardListenHost(host),
594+ }, nil
595+}
596+
597+func isWildcardListenHost(host string) bool {
598+ return host == "" || host == "0.0.0.0" || host == "::"
599+}
600+
601+func normalizeHost(host string) string {
602+ return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
603+}
604+
605+func matchingWildcardSNI(host string, wildcards map[string]string) string {
606+ for wildcard := range wildcards {
607+ if hostMatchesWildcard(host, wildcard) {
608+ return wildcard
609+ }
610+ }
611+ return ""
612+}
613+
614+func matchingHTTPHost(wildcard string, hosts map[string]string) string {
615+ for host := range hosts {
616+ if hostMatchesWildcard(host, wildcard) {
617+ return host
618+ }
619+ }
620+ return ""
621+}
622+
623+func hostMatchesWildcard(host string, wildcard string) bool {
624+ suffix, ok := strings.CutPrefix(wildcard, "*.")
625+ if !ok || host == suffix {
626+ return false
627+ }
628+ return strings.HasSuffix(host, "."+suffix)
629+}
630+
631+func putNested(values map[string]map[string]string, key string, nestedKey string, value string) {
632+ if values[key] == nil {
633+ values[key] = map[string]string{}
634+ }
635+ values[key][nestedKey] = value
636+}
A internal/domain/traffic_test.go +508 -0
@@ -0,0 +1,508 @@
1+package domain
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestTrafficGraphValidateEntryPoints(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ graph TrafficGraph
14+ wantErr string
15+ }{
16+ {
17+ name: "duplicate entrypoint names",
18+ graph: TrafficGraph{EntryPoints: []EntryPoint{
19+ {Name: "web", Address: ":80", Protocol: EntryPointProtocolHTTP},
20+ {Name: "web", Address: ":81", Protocol: EntryPointProtocolHTTP},
21+ }},
22+ wantErr: "duplicate entrypoint name",
23+ },
24+ {
25+ name: "invalid entrypoint address",
26+ graph: TrafficGraph{EntryPoints: []EntryPoint{
27+ {Name: "web", Address: "not-a-listen-address", Protocol: EntryPointProtocolHTTP},
28+ }},
29+ wantErr: "invalid entrypoint address",
30+ },
31+ {
32+ name: "invalid entrypoint protocol",
33+ graph: TrafficGraph{EntryPoints: []EntryPoint{
34+ {Name: "web", Address: ":80", Protocol: EntryPointProtocol("smtp")},
35+ }},
36+ wantErr: "invalid entrypoint protocol",
37+ },
38+ {
39+ name: "duplicate tcp address rejected",
40+ graph: TrafficGraph{EntryPoints: []EntryPoint{
41+ {Name: "web", Address: ":443", Protocol: EntryPointProtocolTLSMux},
42+ {Name: "tcp", Address: ":443", Protocol: EntryPointProtocolTCP},
43+ }},
44+ wantErr: "duplicate tcp entrypoint address",
45+ },
46+ {
47+ name: "duplicate udp address rejected",
48+ graph: TrafficGraph{EntryPoints: []EntryPoint{
49+ {Name: "dns1", Address: ":53", Protocol: EntryPointProtocolUDP},
50+ {Name: "dns2", Address: ":53", Protocol: EntryPointProtocolUDP},
51+ }},
52+ wantErr: "duplicate udp entrypoint address",
53+ },
54+ {
55+ name: "tcp wildcard address conflicts with ipv4 specific bind",
56+ graph: TrafficGraph{EntryPoints: []EntryPoint{
57+ {Name: "wild", Address: ":443", Protocol: EntryPointProtocolTCP},
58+ {Name: "specific", Address: "127.0.0.1:443", Protocol: EntryPointProtocolTCP},
59+ }},
60+ wantErr: "conflicting tcp entrypoint address",
61+ },
62+ {
63+ name: "tcp wildcard address conflicts with ipv6 wildcard bind",
64+ graph: TrafficGraph{EntryPoints: []EntryPoint{
65+ {Name: "wild", Address: ":443", Protocol: EntryPointProtocolTCP},
66+ {Name: "specific", Address: "[::]:443", Protocol: EntryPointProtocolTCP},
67+ }},
68+ wantErr: "conflicting tcp entrypoint address",
69+ },
70+ {
71+ name: "tcp and udp same numeric port allowed",
72+ graph: TrafficGraph{EntryPoints: []EntryPoint{
73+ {Name: "tcp", Address: ":53", Protocol: EntryPointProtocolTCP},
74+ {Name: "udp", Address: ":53", Protocol: EntryPointProtocolUDP},
75+ }},
76+ },
77+ }
78+
79+ for _, tt := range tests {
80+ t.Run(tt.name, func(t *testing.T) {
81+ err := tt.graph.Validate()
82+ if tt.wantErr == "" {
83+ require.NoError(t, err)
84+ return
85+ }
86+ require.ErrorContains(t, err, tt.wantErr)
87+ })
88+ }
89+}
90+
91+func TestTrafficGraphValidateRouterEntryPointProtocolCompatibility(t *testing.T) {
92+ tests := []struct {
93+ name string
94+ routerProtocol RouterProtocol
95+ entryProtocol EntryPointProtocol
96+ wantErr bool
97+ }{
98+ {name: "http router accepts http entrypoint", routerProtocol: RouterProtocolHTTP, entryProtocol: EntryPointProtocolHTTP},
99+ {name: "http router accepts tls mux entrypoint", routerProtocol: RouterProtocolHTTP, entryProtocol: EntryPointProtocolTLSMux},
100+ {name: "tls passthrough router accepts tls mux entrypoint", routerProtocol: RouterProtocolTLSPassthrough, entryProtocol: EntryPointProtocolTLSMux},
101+ {name: "tcp router accepts tcp entrypoint", routerProtocol: RouterProtocolTCP, entryProtocol: EntryPointProtocolTCP},
102+ {name: "udp router accepts udp entrypoint", routerProtocol: RouterProtocolUDP, entryProtocol: EntryPointProtocolUDP},
103+ {name: "http router rejects udp entrypoint", routerProtocol: RouterProtocolHTTP, entryProtocol: EntryPointProtocolUDP, wantErr: true},
104+ {name: "http router rejects tcp entrypoint", routerProtocol: RouterProtocolHTTP, entryProtocol: EntryPointProtocolTCP, wantErr: true},
105+ {name: "udp router rejects tcp entrypoint", routerProtocol: RouterProtocolUDP, entryProtocol: EntryPointProtocolTCP, wantErr: true},
106+ {name: "udp router rejects http entrypoint", routerProtocol: RouterProtocolUDP, entryProtocol: EntryPointProtocolHTTP, wantErr: true},
107+ {name: "udp router rejects tls mux entrypoint", routerProtocol: RouterProtocolUDP, entryProtocol: EntryPointProtocolTLSMux, wantErr: true},
108+ {name: "tcp router rejects udp entrypoint", routerProtocol: RouterProtocolTCP, entryProtocol: EntryPointProtocolUDP, wantErr: true},
109+ {name: "tcp router rejects http entrypoint", routerProtocol: RouterProtocolTCP, entryProtocol: EntryPointProtocolHTTP, wantErr: true},
110+ {name: "tcp router rejects tls mux entrypoint", routerProtocol: RouterProtocolTCP, entryProtocol: EntryPointProtocolTLSMux, wantErr: true},
111+ {name: "tls passthrough router rejects http entrypoint", routerProtocol: RouterProtocolTLSPassthrough, entryProtocol: EntryPointProtocolHTTP, wantErr: true},
112+ {name: "tls passthrough router rejects udp entrypoint", routerProtocol: RouterProtocolTLSPassthrough, entryProtocol: EntryPointProtocolUDP, wantErr: true},
113+ {name: "tls passthrough router rejects tcp entrypoint per tls mux spec", routerProtocol: RouterProtocolTLSPassthrough, entryProtocol: EntryPointProtocolTCP, wantErr: true},
114+ }
115+
116+ for _, tt := range tests {
117+ t.Run(tt.name, func(t *testing.T) {
118+ serviceName := "route:app.example.com"
119+ backendProtocol := NetworkProtocolTCP
120+ rule := TrafficRule{Host: "app.example.com"}
121+ switch tt.routerProtocol {
122+ case RouterProtocolUDP:
123+ serviceName = "network_service:app:game"
124+ backendProtocol = NetworkProtocolUDP
125+ rule = TrafficRule{}
126+ case RouterProtocolTCP, RouterProtocolTLSPassthrough:
127+ serviceName = "network_service:app:tcp"
128+ rule = TrafficRule{SNI: "app.example.com"}
129+ }
130+
131+ graph := TrafficGraph{
132+ EntryPoints: []EntryPoint{{Name: "entry", Address: ":12345", Protocol: tt.entryProtocol}},
133+ Routers: []TrafficRouter{{Name: "router", EntryPoint: "entry", Protocol: tt.routerProtocol, Rule: rule, Service: serviceName}},
134+ Services: []TrafficService{{Name: serviceName, Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 8080, Protocol: backendProtocol}}}},
135+ }
136+ err := graph.Validate()
137+ if !tt.wantErr {
138+ require.NoError(t, err)
139+ return
140+ }
141+ require.ErrorContains(t, err, "router \"router\" protocol "+string(tt.routerProtocol)+" is incompatible with entrypoint \"entry\" protocol "+string(tt.entryProtocol))
142+ })
143+ }
144+}
145+
146+func TestTrafficGraphValidateRouterNames(t *testing.T) {
147+ service := TrafficService{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 8080, Protocol: NetworkProtocolTCP}}}
148+ tests := []struct {
149+ name string
150+ routers []TrafficRouter
151+ wantErr string
152+ }{
153+ {
154+ name: "router name is required",
155+ routers: []TrafficRouter{{EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: service.Name}},
156+ wantErr: "traffic router name is required",
157+ },
158+ {
159+ name: "duplicate router names rejected",
160+ routers: []TrafficRouter{
161+ {Name: "web", EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: service.Name},
162+ {Name: "web", EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "api.example.com"}, Service: service.Name},
163+ },
164+ wantErr: "duplicate traffic router name \"web\"",
165+ },
166+ }
167+
168+ for _, tt := range tests {
169+ t.Run(tt.name, func(t *testing.T) {
170+ graph := TrafficGraph{
171+ EntryPoints: []EntryPoint{{Name: "web", Address: ":80", Protocol: EntryPointProtocolHTTP}},
172+ Routers: tt.routers,
173+ Services: []TrafficService{service},
174+ }
175+ require.ErrorContains(t, graph.Validate(), tt.wantErr)
176+ })
177+ }
178+}
179+
180+func TestTrafficGraphValidateDuplicateHTTPHosts(t *testing.T) {
181+ service := TrafficService{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 8080, Protocol: NetworkProtocolTCP}}}
182+ tests := []struct {
183+ name string
184+ entryProtocol EntryPointProtocol
185+ entryAddress string
186+ }{
187+ {name: "http entrypoint", entryProtocol: EntryPointProtocolHTTP, entryAddress: ":80"},
188+ {name: "tls mux entrypoint", entryProtocol: EntryPointProtocolTLSMux, entryAddress: ":443"},
189+ }
190+
191+ for _, tt := range tests {
192+ t.Run(tt.name, func(t *testing.T) {
193+ graph := TrafficGraph{
194+ EntryPoints: []EntryPoint{{Name: "web", Address: tt.entryAddress, Protocol: tt.entryProtocol}},
195+ Routers: []TrafficRouter{
196+ {Name: "one", EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "App.Example.Com."}, Service: service.Name},
197+ {Name: "two", EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: service.Name},
198+ },
199+ Services: []TrafficService{service},
200+ }
201+ require.ErrorContains(t, graph.Validate(), "duplicate http host \"app.example.com\" on entrypoint \"web\"")
202+ })
203+ }
204+}
205+
206+func TestTrafficGraphValidateAmbiguousL4Routers(t *testing.T) {
207+ tests := []struct {
208+ name string
209+ entryProtocol EntryPointProtocol
210+ routerProtocol RouterProtocol
211+ service TrafficService
212+ wantErr string
213+ }{
214+ {
215+ name: "multiple tcp routers on same entrypoint rejected",
216+ entryProtocol: EntryPointProtocolTCP,
217+ routerProtocol: RouterProtocolTCP,
218+ service: TrafficService{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 5432, Protocol: NetworkProtocolTCP}}},
219+ wantErr: "ambiguous tcp routers on entrypoint \"l4\"",
220+ },
221+ {
222+ name: "multiple udp routers on same entrypoint rejected",
223+ entryProtocol: EntryPointProtocolUDP,
224+ routerProtocol: RouterProtocolUDP,
225+ service: TrafficService{Name: "network_service:app:game", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 25565, Protocol: NetworkProtocolUDP}}},
226+ wantErr: "ambiguous udp routers on entrypoint \"l4\"",
227+ },
228+ }
229+
230+ for _, tt := range tests {
231+ t.Run(tt.name, func(t *testing.T) {
232+ graph := TrafficGraph{
233+ EntryPoints: []EntryPoint{{Name: "l4", Address: ":12345", Protocol: tt.entryProtocol}},
234+ Routers: []TrafficRouter{
235+ {Name: "one", EntryPoint: "l4", Protocol: tt.routerProtocol, Service: tt.service.Name},
236+ {Name: "two", EntryPoint: "l4", Protocol: tt.routerProtocol, Service: tt.service.Name},
237+ },
238+ Services: []TrafficService{tt.service},
239+ }
240+ require.ErrorContains(t, graph.Validate(), tt.wantErr)
241+ })
242+ }
243+}
244+
245+func TestTrafficGraphValidateTLSRules(t *testing.T) {
246+ tests := []struct {
247+ name string
248+ routers []TrafficRouter
249+ wantErr string
250+ }{
251+ {
252+ name: "duplicate exact tls sni on same entrypoint rejected",
253+ routers: []TrafficRouter{
254+ {Name: "one", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "db.example.com"}, Service: "network_service:app:https"},
255+ {Name: "two", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "db.example.com"}, Service: "network_service:app:https"},
256+ },
257+ wantErr: "duplicate exact tls sni",
258+ },
259+ {
260+ name: "exact sni plus wildcard deterministic allowed",
261+ routers: []TrafficRouter{
262+ {Name: "exact", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "db.example.com"}, Service: "network_service:app:https"},
263+ {Name: "wild", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
264+ },
265+ },
266+ {
267+ name: "ambiguous wildcard sni overlaps rejected",
268+ routers: []TrafficRouter{
269+ {Name: "wild1", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
270+ {Name: "wild2", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
271+ },
272+ wantErr: "ambiguous wildcard tls sni",
273+ },
274+ {
275+ name: "http host and tls passthrough sni conflict rejected",
276+ routers: []TrafficRouter{
277+ {Name: "http", EntryPoint: "tls", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
278+ {Name: "pass", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "app.example.com"}, Service: "network_service:app:https"},
279+ },
280+ wantErr: "http host conflicts with tls passthrough sni",
281+ },
282+ {
283+ name: "http host conflicts with later wildcard tls passthrough sni",
284+ routers: []TrafficRouter{
285+ {Name: "http", EntryPoint: "tls", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
286+ {Name: "pass", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
287+ },
288+ wantErr: "http host conflicts with wildcard tls passthrough sni",
289+ },
290+ {
291+ name: "http host conflicts with earlier wildcard tls passthrough sni",
292+ routers: []TrafficRouter{
293+ {Name: "pass", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
294+ {Name: "http", EntryPoint: "tls", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
295+ },
296+ wantErr: "http host conflicts with wildcard tls passthrough sni",
297+ },
298+ }
299+
300+ for _, tt := range tests {
301+ t.Run(tt.name, func(t *testing.T) {
302+ graph := TrafficGraph{
303+ EntryPoints: []EntryPoint{{Name: "tls", Address: ":443", Protocol: EntryPointProtocolTLSMux}},
304+ Routers: tt.routers,
305+ Services: []TrafficService{
306+ {Name: "network_service:app:https", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP}}},
307+ {Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.3", Port: 8080, Protocol: NetworkProtocolTCP}}},
308+ },
309+ }
310+ err := graph.Validate()
311+ if tt.wantErr == "" {
312+ require.NoError(t, err)
313+ return
314+ }
315+ require.ErrorContains(t, err, tt.wantErr)
316+ })
317+ }
318+}
319+
320+func TestTrafficGraphValidateBackendsAndL4Routers(t *testing.T) {
321+ tests := []struct {
322+ name string
323+ router TrafficRouter
324+ services []TrafficService
325+ wantErr string
326+ }{
327+ {
328+ name: "backend ports outside range rejected low",
329+ router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"},
330+ services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Port: 0, Protocol: NetworkProtocolTCP}}}},
331+ wantErr: "invalid backend port",
332+ },
333+ {
334+ name: "backend ports outside range rejected high",
335+ router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"},
336+ services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Port: 65536, Protocol: NetworkProtocolTCP}}}},
337+ wantErr: "invalid backend port",
338+ },
339+ {
340+ name: "l4 routers reject services with more than one backend",
341+ router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"},
342+ services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{
343+ {Name: "app", Port: 5432, Protocol: NetworkProtocolTCP},
344+ {Name: "app2", Port: 5432, Protocol: NetworkProtocolTCP},
345+ }}},
346+ wantErr: "l4 router service must have exactly one backend",
347+ },
348+ {
349+ name: "tcp router requires tcp network backend",
350+ router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"},
351+ services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Port: 53, Protocol: NetworkProtocolUDP}}}},
352+ wantErr: "backend protocol udp does not match router protocol tcp",
353+ },
354+ {
355+ name: "udp router requires udp network backend",
356+ router: TrafficRouter{Name: "udp", EntryPoint: "udp", Protocol: RouterProtocolUDP, Service: "network_service:app:game"},
357+ services: []TrafficService{{Name: "network_service:app:game", Backends: []TrafficBackend{{Name: "app", Port: 53, Protocol: NetworkProtocolTCP}}}},
358+ wantErr: "backend protocol tcp does not match router protocol udp",
359+ },
360+ {
361+ name: "tls passthrough requires tcp network backend",
362+ router: TrafficRouter{Name: "tls", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "db.example.com"}, Service: "network_service:app:https"},
363+ services: []TrafficService{{Name: "network_service:app:https", Backends: []TrafficBackend{{Name: "app", Port: 443, Protocol: NetworkProtocolUDP}}}},
364+ wantErr: "backend protocol udp does not match router protocol tcp",
365+ },
366+ {
367+ name: "l4 router rejects route ref",
368+ router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "route:app.example.com"},
369+ services: []TrafficService{{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Port: 443, Protocol: NetworkProtocolTCP}}}},
370+ wantErr: "requires network_service service ref",
371+ },
372+ {
373+ name: "static ref parses but graph validation rejects unsupported",
374+ router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "static:maintenance"},
375+ services: []TrafficService{{Name: "static:maintenance", Backends: []TrafficBackend{{Name: "maintenance", Port: 443, Protocol: NetworkProtocolTCP}}}},
376+ wantErr: "static traffic service ref \"static:maintenance\" is unsupported",
377+ },
378+ }
379+
380+ for _, tt := range tests {
381+ t.Run(tt.name, func(t *testing.T) {
382+ graph := TrafficGraph{
383+ EntryPoints: []EntryPoint{
384+ {Name: "tcp", Address: ":443", Protocol: EntryPointProtocolTCP},
385+ {Name: "udp", Address: ":443", Protocol: EntryPointProtocolUDP},
386+ {Name: "tls", Address: ":8443", Protocol: EntryPointProtocolTLSMux},
387+ },
388+ Routers: []TrafficRouter{tt.router},
389+ Services: tt.services,
390+ }
391+ require.ErrorContains(t, graph.Validate(), tt.wantErr)
392+ })
393+ }
394+}
395+
396+func TestTrafficGraphValidateHTTPServiceRefs(t *testing.T) {
397+ tests := []struct {
398+ name string
399+ service string
400+ wantErr string
401+ }{
402+ {name: "http allows route", service: "route:app.example.com"},
403+ {name: "http allows external_route", service: "external_route:api.example.com"},
404+ {name: "http rejects network_service", service: "network_service:app:http", wantErr: "does not support service ref kind network_service"},
405+ {name: "http rejects static", service: "static:maintenance", wantErr: "static traffic service ref \"static:maintenance\" is unsupported"},
406+ }
407+
408+ for _, tt := range tests {
409+ t.Run(tt.name, func(t *testing.T) {
410+ graph := TrafficGraph{
411+ EntryPoints: []EntryPoint{{Name: "web", Address: ":80", Protocol: EntryPointProtocolHTTP}},
412+ Routers: []TrafficRouter{{Name: "web", EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: tt.service}},
413+ Services: []TrafficService{{Name: tt.service, Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 8080, Protocol: NetworkProtocolTCP}}}},
414+ }
415+ err := graph.Validate()
416+ if tt.wantErr == "" {
417+ require.NoError(t, err)
418+ return
419+ }
420+ require.ErrorContains(t, err, tt.wantErr)
421+ })
422+ }
423+}
424+
425+func TestTrafficGraphValidateRouterServiceRefs(t *testing.T) {
426+ t.Run("router service ref is parsed", func(t *testing.T) {
427+ graph := TrafficGraph{
428+ EntryPoints: []EntryPoint{{Name: "tcp", Address: ":5432", Protocol: EntryPointProtocolTCP}},
429+ Routers: []TrafficRouter{{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:postgres:"}},
430+ Services: []TrafficService{{Name: "network_service:postgres:", Backends: []TrafficBackend{{Name: "postgres", Host: "10.0.0.4", Port: 5432, Protocol: NetworkProtocolTCP}}}},
431+ }
432+ require.ErrorContains(t, graph.Validate(), "invalid traffic service ref")
433+ })
434+
435+ t.Run("router service ref must exist as service name", func(t *testing.T) {
436+ graph := TrafficGraph{
437+ EntryPoints: []EntryPoint{{Name: "tcp", Address: ":5432", Protocol: EntryPointProtocolTCP}},
438+ Routers: []TrafficRouter{{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:postgres:db"}},
439+ }
440+ require.ErrorContains(t, graph.Validate(), "references unknown service")
441+ })
442+}
443+
444+func TestTrafficServiceRef(t *testing.T) {
445+ ref, err := ParseTrafficServiceRef("static:maintenance")
446+ require.NoError(t, err)
447+ require.Equal(t, TrafficServiceRefStatic, ref.Kind)
448+ require.True(t, ref.Reserved)
449+ require.Equal(t, "maintenance", ref.Name)
450+}
451+
452+func TestParseTrafficServiceRef(t *testing.T) {
453+ tests := []struct {
454+ input string
455+ want TrafficServiceRef
456+ }{
457+ {input: "route:app.example.com", want: TrafficServiceRef{Kind: TrafficServiceRefRoute, Domain: "app.example.com"}},
458+ {input: "external_route:api.example.com", want: TrafficServiceRef{Kind: TrafficServiceRefExternalRoute, Domain: "api.example.com"}},
459+ {input: "network_service:postgres:db", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "postgres", PortName: "db"}},
460+ {input: "network_service:web:http", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "web", PortName: "http"}},
461+ {input: "network_service:game:game", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "game", PortName: "game"}},
462+ {input: "static:maintenance", want: TrafficServiceRef{Kind: TrafficServiceRefStatic, Name: "maintenance", Reserved: true}},
463+ }
464+
465+ for _, tt := range tests {
466+ t.Run(tt.input, func(t *testing.T) {
467+ got, err := ParseTrafficServiceRef(tt.input)
468+ require.NoError(t, err)
469+ require.Equal(t, tt.want, got)
470+ })
471+ }
472+
473+ invalid := []string{"", "route:", "external_route:", "network_service:", "network_service:postgres", "network_service:postgres:", "network_service::db", "network_service:postgres:db:extra", "static:", "unknown:value"}
474+ for _, input := range invalid {
475+ t.Run("invalid "+input, func(t *testing.T) {
476+ _, err := ParseTrafficServiceRef(input)
477+ require.Error(t, err)
478+ })
479+ }
480+}
481+
482+func TestTrafficDomainTypesMatchSpecFields(t *testing.T) {
483+ graph := TrafficGraph{
484+ Options: TrafficOptions{
485+ TCP: TCPOptions{DialTimeout: time.Second, IdleTimeout: time.Minute, DrainTimeout: 2 * time.Second, MaxConnections: 100},
486+ UDP: UDPOptions{IdleTimeout: time.Minute, DrainTimeout: 2 * time.Second, MaxSessions: 50},
487+ },
488+ EntryPoints: []EntryPoint{{Name: "tcp", Address: ":1234", Protocol: EntryPointProtocolTCP, TrustedCIDRs: []string{"10.0.0.0/8"}}},
489+ Routers: []TrafficRouter{{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:game"}},
490+ Services: []TrafficService{{Name: "network_service:app:game", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 1234, Protocol: NetworkProtocolTCP}}}},
491+ }
492+ require.NoError(t, graph.Validate())
493+
494+ status := TrafficStatus{
495+ LastReloadStatus: "ok",
496+ LastReloadError: "",
497+ EntryPoints: []EntryPointStatus{{Name: "tcp", ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7}},
498+ Routers: []TrafficRouterStatus{{Name: "tcp", ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7}},
499+ Services: []TrafficServiceStatus{{Name: "network_service:app:game", Backends: []TrafficBackendStatus{{Name: "app", ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7}}}},
500+ Counters: TrafficCounters{ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7},
501+ }
502+ require.Equal(t, "ok", status.LastReloadStatus)
503+ require.Empty(t, status.LastReloadError)
504+ require.Equal(t, int64(7), status.EntryPoints[0].BytesOut)
505+ require.Equal(t, int64(7), status.Routers[0].BytesOut)
506+ require.Equal(t, int64(7), status.Services[0].Backends[0].BytesOut)
507+ require.Equal(t, int64(7), status.Counters.BytesOut)
508+}
@@ -0,0 +1,508 @@
1+package domain
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestTrafficGraphValidateEntryPoints(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ graph TrafficGraph
14+ wantErr string
15+ }{
16+ {
17+ name: "duplicate entrypoint names",
18+ graph: TrafficGraph{EntryPoints: []EntryPoint{
19+ {Name: "web", Address: ":80", Protocol: EntryPointProtocolHTTP},
20+ {Name: "web", Address: ":81", Protocol: EntryPointProtocolHTTP},
21+ }},
22+ wantErr: "duplicate entrypoint name",
23+ },
24+ {
25+ name: "invalid entrypoint address",
26+ graph: TrafficGraph{EntryPoints: []EntryPoint{
27+ {Name: "web", Address: "not-a-listen-address", Protocol: EntryPointProtocolHTTP},
28+ }},
29+ wantErr: "invalid entrypoint address",
30+ },
31+ {
32+ name: "invalid entrypoint protocol",
33+ graph: TrafficGraph{EntryPoints: []EntryPoint{
34+ {Name: "web", Address: ":80", Protocol: EntryPointProtocol("smtp")},
35+ }},
36+ wantErr: "invalid entrypoint protocol",
37+ },
38+ {
39+ name: "duplicate tcp address rejected",
40+ graph: TrafficGraph{EntryPoints: []EntryPoint{
41+ {Name: "web", Address: ":443", Protocol: EntryPointProtocolTLSMux},
42+ {Name: "tcp", Address: ":443", Protocol: EntryPointProtocolTCP},
43+ }},
44+ wantErr: "duplicate tcp entrypoint address",
45+ },
46+ {
47+ name: "duplicate udp address rejected",
48+ graph: TrafficGraph{EntryPoints: []EntryPoint{
49+ {Name: "dns1", Address: ":53", Protocol: EntryPointProtocolUDP},
50+ {Name: "dns2", Address: ":53", Protocol: EntryPointProtocolUDP},
51+ }},
52+ wantErr: "duplicate udp entrypoint address",
53+ },
54+ {
55+ name: "tcp wildcard address conflicts with ipv4 specific bind",
56+ graph: TrafficGraph{EntryPoints: []EntryPoint{
57+ {Name: "wild", Address: ":443", Protocol: EntryPointProtocolTCP},
58+ {Name: "specific", Address: "127.0.0.1:443", Protocol: EntryPointProtocolTCP},
59+ }},
60+ wantErr: "conflicting tcp entrypoint address",
61+ },
62+ {
63+ name: "tcp wildcard address conflicts with ipv6 wildcard bind",
64+ graph: TrafficGraph{EntryPoints: []EntryPoint{
65+ {Name: "wild", Address: ":443", Protocol: EntryPointProtocolTCP},
66+ {Name: "specific", Address: "[::]:443", Protocol: EntryPointProtocolTCP},
67+ }},
68+ wantErr: "conflicting tcp entrypoint address",
69+ },
70+ {
71+ name: "tcp and udp same numeric port allowed",
72+ graph: TrafficGraph{EntryPoints: []EntryPoint{
73+ {Name: "tcp", Address: ":53", Protocol: EntryPointProtocolTCP},
74+ {Name: "udp", Address: ":53", Protocol: EntryPointProtocolUDP},
75+ }},
76+ },
77+ }
78+
79+ for _, tt := range tests {
80+ t.Run(tt.name, func(t *testing.T) {
81+ err := tt.graph.Validate()
82+ if tt.wantErr == "" {
83+ require.NoError(t, err)
84+ return
85+ }
86+ require.ErrorContains(t, err, tt.wantErr)
87+ })
88+ }
89+}
90+
91+func TestTrafficGraphValidateRouterEntryPointProtocolCompatibility(t *testing.T) {
92+ tests := []struct {
93+ name string
94+ routerProtocol RouterProtocol
95+ entryProtocol EntryPointProtocol
96+ wantErr bool
97+ }{
98+ {name: "http router accepts http entrypoint", routerProtocol: RouterProtocolHTTP, entryProtocol: EntryPointProtocolHTTP},
99+ {name: "http router accepts tls mux entrypoint", routerProtocol: RouterProtocolHTTP, entryProtocol: EntryPointProtocolTLSMux},
100+ {name: "tls passthrough router accepts tls mux entrypoint", routerProtocol: RouterProtocolTLSPassthrough, entryProtocol: EntryPointProtocolTLSMux},
101+ {name: "tcp router accepts tcp entrypoint", routerProtocol: RouterProtocolTCP, entryProtocol: EntryPointProtocolTCP},
102+ {name: "udp router accepts udp entrypoint", routerProtocol: RouterProtocolUDP, entryProtocol: EntryPointProtocolUDP},
103+ {name: "http router rejects udp entrypoint", routerProtocol: RouterProtocolHTTP, entryProtocol: EntryPointProtocolUDP, wantErr: true},
104+ {name: "http router rejects tcp entrypoint", routerProtocol: RouterProtocolHTTP, entryProtocol: EntryPointProtocolTCP, wantErr: true},
105+ {name: "udp router rejects tcp entrypoint", routerProtocol: RouterProtocolUDP, entryProtocol: EntryPointProtocolTCP, wantErr: true},
106+ {name: "udp router rejects http entrypoint", routerProtocol: RouterProtocolUDP, entryProtocol: EntryPointProtocolHTTP, wantErr: true},
107+ {name: "udp router rejects tls mux entrypoint", routerProtocol: RouterProtocolUDP, entryProtocol: EntryPointProtocolTLSMux, wantErr: true},
108+ {name: "tcp router rejects udp entrypoint", routerProtocol: RouterProtocolTCP, entryProtocol: EntryPointProtocolUDP, wantErr: true},
109+ {name: "tcp router rejects http entrypoint", routerProtocol: RouterProtocolTCP, entryProtocol: EntryPointProtocolHTTP, wantErr: true},
110+ {name: "tcp router rejects tls mux entrypoint", routerProtocol: RouterProtocolTCP, entryProtocol: EntryPointProtocolTLSMux, wantErr: true},
111+ {name: "tls passthrough router rejects http entrypoint", routerProtocol: RouterProtocolTLSPassthrough, entryProtocol: EntryPointProtocolHTTP, wantErr: true},
112+ {name: "tls passthrough router rejects udp entrypoint", routerProtocol: RouterProtocolTLSPassthrough, entryProtocol: EntryPointProtocolUDP, wantErr: true},
113+ {name: "tls passthrough router rejects tcp entrypoint per tls mux spec", routerProtocol: RouterProtocolTLSPassthrough, entryProtocol: EntryPointProtocolTCP, wantErr: true},
114+ }
115+
116+ for _, tt := range tests {
117+ t.Run(tt.name, func(t *testing.T) {
118+ serviceName := "route:app.example.com"
119+ backendProtocol := NetworkProtocolTCP
120+ rule := TrafficRule{Host: "app.example.com"}
121+ switch tt.routerProtocol {
122+ case RouterProtocolUDP:
123+ serviceName = "network_service:app:game"
124+ backendProtocol = NetworkProtocolUDP
125+ rule = TrafficRule{}
126+ case RouterProtocolTCP, RouterProtocolTLSPassthrough:
127+ serviceName = "network_service:app:tcp"
128+ rule = TrafficRule{SNI: "app.example.com"}
129+ }
130+
131+ graph := TrafficGraph{
132+ EntryPoints: []EntryPoint{{Name: "entry", Address: ":12345", Protocol: tt.entryProtocol}},
133+ Routers: []TrafficRouter{{Name: "router", EntryPoint: "entry", Protocol: tt.routerProtocol, Rule: rule, Service: serviceName}},
134+ Services: []TrafficService{{Name: serviceName, Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 8080, Protocol: backendProtocol}}}},
135+ }
136+ err := graph.Validate()
137+ if !tt.wantErr {
138+ require.NoError(t, err)
139+ return
140+ }
141+ require.ErrorContains(t, err, "router \"router\" protocol "+string(tt.routerProtocol)+" is incompatible with entrypoint \"entry\" protocol "+string(tt.entryProtocol))
142+ })
143+ }
144+}
145+
146+func TestTrafficGraphValidateRouterNames(t *testing.T) {
147+ service := TrafficService{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 8080, Protocol: NetworkProtocolTCP}}}
148+ tests := []struct {
149+ name string
150+ routers []TrafficRouter
151+ wantErr string
152+ }{
153+ {
154+ name: "router name is required",
155+ routers: []TrafficRouter{{EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: service.Name}},
156+ wantErr: "traffic router name is required",
157+ },
158+ {
159+ name: "duplicate router names rejected",
160+ routers: []TrafficRouter{
161+ {Name: "web", EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: service.Name},
162+ {Name: "web", EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "api.example.com"}, Service: service.Name},
163+ },
164+ wantErr: "duplicate traffic router name \"web\"",
165+ },
166+ }
167+
168+ for _, tt := range tests {
169+ t.Run(tt.name, func(t *testing.T) {
170+ graph := TrafficGraph{
171+ EntryPoints: []EntryPoint{{Name: "web", Address: ":80", Protocol: EntryPointProtocolHTTP}},
172+ Routers: tt.routers,
173+ Services: []TrafficService{service},
174+ }
175+ require.ErrorContains(t, graph.Validate(), tt.wantErr)
176+ })
177+ }
178+}
179+
180+func TestTrafficGraphValidateDuplicateHTTPHosts(t *testing.T) {
181+ service := TrafficService{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 8080, Protocol: NetworkProtocolTCP}}}
182+ tests := []struct {
183+ name string
184+ entryProtocol EntryPointProtocol
185+ entryAddress string
186+ }{
187+ {name: "http entrypoint", entryProtocol: EntryPointProtocolHTTP, entryAddress: ":80"},
188+ {name: "tls mux entrypoint", entryProtocol: EntryPointProtocolTLSMux, entryAddress: ":443"},
189+ }
190+
191+ for _, tt := range tests {
192+ t.Run(tt.name, func(t *testing.T) {
193+ graph := TrafficGraph{
194+ EntryPoints: []EntryPoint{{Name: "web", Address: tt.entryAddress, Protocol: tt.entryProtocol}},
195+ Routers: []TrafficRouter{
196+ {Name: "one", EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "App.Example.Com."}, Service: service.Name},
197+ {Name: "two", EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: service.Name},
198+ },
199+ Services: []TrafficService{service},
200+ }
201+ require.ErrorContains(t, graph.Validate(), "duplicate http host \"app.example.com\" on entrypoint \"web\"")
202+ })
203+ }
204+}
205+
206+func TestTrafficGraphValidateAmbiguousL4Routers(t *testing.T) {
207+ tests := []struct {
208+ name string
209+ entryProtocol EntryPointProtocol
210+ routerProtocol RouterProtocol
211+ service TrafficService
212+ wantErr string
213+ }{
214+ {
215+ name: "multiple tcp routers on same entrypoint rejected",
216+ entryProtocol: EntryPointProtocolTCP,
217+ routerProtocol: RouterProtocolTCP,
218+ service: TrafficService{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 5432, Protocol: NetworkProtocolTCP}}},
219+ wantErr: "ambiguous tcp routers on entrypoint \"l4\"",
220+ },
221+ {
222+ name: "multiple udp routers on same entrypoint rejected",
223+ entryProtocol: EntryPointProtocolUDP,
224+ routerProtocol: RouterProtocolUDP,
225+ service: TrafficService{Name: "network_service:app:game", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 25565, Protocol: NetworkProtocolUDP}}},
226+ wantErr: "ambiguous udp routers on entrypoint \"l4\"",
227+ },
228+ }
229+
230+ for _, tt := range tests {
231+ t.Run(tt.name, func(t *testing.T) {
232+ graph := TrafficGraph{
233+ EntryPoints: []EntryPoint{{Name: "l4", Address: ":12345", Protocol: tt.entryProtocol}},
234+ Routers: []TrafficRouter{
235+ {Name: "one", EntryPoint: "l4", Protocol: tt.routerProtocol, Service: tt.service.Name},
236+ {Name: "two", EntryPoint: "l4", Protocol: tt.routerProtocol, Service: tt.service.Name},
237+ },
238+ Services: []TrafficService{tt.service},
239+ }
240+ require.ErrorContains(t, graph.Validate(), tt.wantErr)
241+ })
242+ }
243+}
244+
245+func TestTrafficGraphValidateTLSRules(t *testing.T) {
246+ tests := []struct {
247+ name string
248+ routers []TrafficRouter
249+ wantErr string
250+ }{
251+ {
252+ name: "duplicate exact tls sni on same entrypoint rejected",
253+ routers: []TrafficRouter{
254+ {Name: "one", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "db.example.com"}, Service: "network_service:app:https"},
255+ {Name: "two", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "db.example.com"}, Service: "network_service:app:https"},
256+ },
257+ wantErr: "duplicate exact tls sni",
258+ },
259+ {
260+ name: "exact sni plus wildcard deterministic allowed",
261+ routers: []TrafficRouter{
262+ {Name: "exact", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "db.example.com"}, Service: "network_service:app:https"},
263+ {Name: "wild", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
264+ },
265+ },
266+ {
267+ name: "ambiguous wildcard sni overlaps rejected",
268+ routers: []TrafficRouter{
269+ {Name: "wild1", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
270+ {Name: "wild2", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
271+ },
272+ wantErr: "ambiguous wildcard tls sni",
273+ },
274+ {
275+ name: "http host and tls passthrough sni conflict rejected",
276+ routers: []TrafficRouter{
277+ {Name: "http", EntryPoint: "tls", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
278+ {Name: "pass", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "app.example.com"}, Service: "network_service:app:https"},
279+ },
280+ wantErr: "http host conflicts with tls passthrough sni",
281+ },
282+ {
283+ name: "http host conflicts with later wildcard tls passthrough sni",
284+ routers: []TrafficRouter{
285+ {Name: "http", EntryPoint: "tls", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
286+ {Name: "pass", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
287+ },
288+ wantErr: "http host conflicts with wildcard tls passthrough sni",
289+ },
290+ {
291+ name: "http host conflicts with earlier wildcard tls passthrough sni",
292+ routers: []TrafficRouter{
293+ {Name: "pass", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
294+ {Name: "http", EntryPoint: "tls", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
295+ },
296+ wantErr: "http host conflicts with wildcard tls passthrough sni",
297+ },
298+ }
299+
300+ for _, tt := range tests {
301+ t.Run(tt.name, func(t *testing.T) {
302+ graph := TrafficGraph{
303+ EntryPoints: []EntryPoint{{Name: "tls", Address: ":443", Protocol: EntryPointProtocolTLSMux}},
304+ Routers: tt.routers,
305+ Services: []TrafficService{
306+ {Name: "network_service:app:https", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP}}},
307+ {Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.3", Port: 8080, Protocol: NetworkProtocolTCP}}},
308+ },
309+ }
310+ err := graph.Validate()
311+ if tt.wantErr == "" {
312+ require.NoError(t, err)
313+ return
314+ }
315+ require.ErrorContains(t, err, tt.wantErr)
316+ })
317+ }
318+}
319+
320+func TestTrafficGraphValidateBackendsAndL4Routers(t *testing.T) {
321+ tests := []struct {
322+ name string
323+ router TrafficRouter
324+ services []TrafficService
325+ wantErr string
326+ }{
327+ {
328+ name: "backend ports outside range rejected low",
329+ router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"},
330+ services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Port: 0, Protocol: NetworkProtocolTCP}}}},
331+ wantErr: "invalid backend port",
332+ },
333+ {
334+ name: "backend ports outside range rejected high",
335+ router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"},
336+ services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Port: 65536, Protocol: NetworkProtocolTCP}}}},
337+ wantErr: "invalid backend port",
338+ },
339+ {
340+ name: "l4 routers reject services with more than one backend",
341+ router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"},
342+ services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{
343+ {Name: "app", Port: 5432, Protocol: NetworkProtocolTCP},
344+ {Name: "app2", Port: 5432, Protocol: NetworkProtocolTCP},
345+ }}},
346+ wantErr: "l4 router service must have exactly one backend",
347+ },
348+ {
349+ name: "tcp router requires tcp network backend",
350+ router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"},
351+ services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Port: 53, Protocol: NetworkProtocolUDP}}}},
352+ wantErr: "backend protocol udp does not match router protocol tcp",
353+ },
354+ {
355+ name: "udp router requires udp network backend",
356+ router: TrafficRouter{Name: "udp", EntryPoint: "udp", Protocol: RouterProtocolUDP, Service: "network_service:app:game"},
357+ services: []TrafficService{{Name: "network_service:app:game", Backends: []TrafficBackend{{Name: "app", Port: 53, Protocol: NetworkProtocolTCP}}}},
358+ wantErr: "backend protocol tcp does not match router protocol udp",
359+ },
360+ {
361+ name: "tls passthrough requires tcp network backend",
362+ router: TrafficRouter{Name: "tls", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "db.example.com"}, Service: "network_service:app:https"},
363+ services: []TrafficService{{Name: "network_service:app:https", Backends: []TrafficBackend{{Name: "app", Port: 443, Protocol: NetworkProtocolUDP}}}},
364+ wantErr: "backend protocol udp does not match router protocol tcp",
365+ },
366+ {
367+ name: "l4 router rejects route ref",
368+ router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "route:app.example.com"},
369+ services: []TrafficService{{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Port: 443, Protocol: NetworkProtocolTCP}}}},
370+ wantErr: "requires network_service service ref",
371+ },
372+ {
373+ name: "static ref parses but graph validation rejects unsupported",
374+ router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "static:maintenance"},
375+ services: []TrafficService{{Name: "static:maintenance", Backends: []TrafficBackend{{Name: "maintenance", Port: 443, Protocol: NetworkProtocolTCP}}}},
376+ wantErr: "static traffic service ref \"static:maintenance\" is unsupported",
377+ },
378+ }
379+
380+ for _, tt := range tests {
381+ t.Run(tt.name, func(t *testing.T) {
382+ graph := TrafficGraph{
383+ EntryPoints: []EntryPoint{
384+ {Name: "tcp", Address: ":443", Protocol: EntryPointProtocolTCP},
385+ {Name: "udp", Address: ":443", Protocol: EntryPointProtocolUDP},
386+ {Name: "tls", Address: ":8443", Protocol: EntryPointProtocolTLSMux},
387+ },
388+ Routers: []TrafficRouter{tt.router},
389+ Services: tt.services,
390+ }
391+ require.ErrorContains(t, graph.Validate(), tt.wantErr)
392+ })
393+ }
394+}
395+
396+func TestTrafficGraphValidateHTTPServiceRefs(t *testing.T) {
397+ tests := []struct {
398+ name string
399+ service string
400+ wantErr string
401+ }{
402+ {name: "http allows route", service: "route:app.example.com"},
403+ {name: "http allows external_route", service: "external_route:api.example.com"},
404+ {name: "http rejects network_service", service: "network_service:app:http", wantErr: "does not support service ref kind network_service"},
405+ {name: "http rejects static", service: "static:maintenance", wantErr: "static traffic service ref \"static:maintenance\" is unsupported"},
406+ }
407+
408+ for _, tt := range tests {
409+ t.Run(tt.name, func(t *testing.T) {
410+ graph := TrafficGraph{
411+ EntryPoints: []EntryPoint{{Name: "web", Address: ":80", Protocol: EntryPointProtocolHTTP}},
412+ Routers: []TrafficRouter{{Name: "web", EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: tt.service}},
413+ Services: []TrafficService{{Name: tt.service, Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 8080, Protocol: NetworkProtocolTCP}}}},
414+ }
415+ err := graph.Validate()
416+ if tt.wantErr == "" {
417+ require.NoError(t, err)
418+ return
419+ }
420+ require.ErrorContains(t, err, tt.wantErr)
421+ })
422+ }
423+}
424+
425+func TestTrafficGraphValidateRouterServiceRefs(t *testing.T) {
426+ t.Run("router service ref is parsed", func(t *testing.T) {
427+ graph := TrafficGraph{
428+ EntryPoints: []EntryPoint{{Name: "tcp", Address: ":5432", Protocol: EntryPointProtocolTCP}},
429+ Routers: []TrafficRouter{{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:postgres:"}},
430+ Services: []TrafficService{{Name: "network_service:postgres:", Backends: []TrafficBackend{{Name: "postgres", Host: "10.0.0.4", Port: 5432, Protocol: NetworkProtocolTCP}}}},
431+ }
432+ require.ErrorContains(t, graph.Validate(), "invalid traffic service ref")
433+ })
434+
435+ t.Run("router service ref must exist as service name", func(t *testing.T) {
436+ graph := TrafficGraph{
437+ EntryPoints: []EntryPoint{{Name: "tcp", Address: ":5432", Protocol: EntryPointProtocolTCP}},
438+ Routers: []TrafficRouter{{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:postgres:db"}},
439+ }
440+ require.ErrorContains(t, graph.Validate(), "references unknown service")
441+ })
442+}
443+
444+func TestTrafficServiceRef(t *testing.T) {
445+ ref, err := ParseTrafficServiceRef("static:maintenance")
446+ require.NoError(t, err)
447+ require.Equal(t, TrafficServiceRefStatic, ref.Kind)
448+ require.True(t, ref.Reserved)
449+ require.Equal(t, "maintenance", ref.Name)
450+}
451+
452+func TestParseTrafficServiceRef(t *testing.T) {
453+ tests := []struct {
454+ input string
455+ want TrafficServiceRef
456+ }{
457+ {input: "route:app.example.com", want: TrafficServiceRef{Kind: TrafficServiceRefRoute, Domain: "app.example.com"}},
458+ {input: "external_route:api.example.com", want: TrafficServiceRef{Kind: TrafficServiceRefExternalRoute, Domain: "api.example.com"}},
459+ {input: "network_service:postgres:db", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "postgres", PortName: "db"}},
460+ {input: "network_service:web:http", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "web", PortName: "http"}},
461+ {input: "network_service:game:game", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "game", PortName: "game"}},
462+ {input: "static:maintenance", want: TrafficServiceRef{Kind: TrafficServiceRefStatic, Name: "maintenance", Reserved: true}},
463+ }
464+
465+ for _, tt := range tests {
466+ t.Run(tt.input, func(t *testing.T) {
467+ got, err := ParseTrafficServiceRef(tt.input)
468+ require.NoError(t, err)
469+ require.Equal(t, tt.want, got)
470+ })
471+ }
472+
473+ invalid := []string{"", "route:", "external_route:", "network_service:", "network_service:postgres", "network_service:postgres:", "network_service::db", "network_service:postgres:db:extra", "static:", "unknown:value"}
474+ for _, input := range invalid {
475+ t.Run("invalid "+input, func(t *testing.T) {
476+ _, err := ParseTrafficServiceRef(input)
477+ require.Error(t, err)
478+ })
479+ }
480+}
481+
482+func TestTrafficDomainTypesMatchSpecFields(t *testing.T) {
483+ graph := TrafficGraph{
484+ Options: TrafficOptions{
485+ TCP: TCPOptions{DialTimeout: time.Second, IdleTimeout: time.Minute, DrainTimeout: 2 * time.Second, MaxConnections: 100},
486+ UDP: UDPOptions{IdleTimeout: time.Minute, DrainTimeout: 2 * time.Second, MaxSessions: 50},
487+ },
488+ EntryPoints: []EntryPoint{{Name: "tcp", Address: ":1234", Protocol: EntryPointProtocolTCP, TrustedCIDRs: []string{"10.0.0.0/8"}}},
489+ Routers: []TrafficRouter{{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:game"}},
490+ Services: []TrafficService{{Name: "network_service:app:game", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 1234, Protocol: NetworkProtocolTCP}}}},
491+ }
492+ require.NoError(t, graph.Validate())
493+
494+ status := TrafficStatus{
495+ LastReloadStatus: "ok",
496+ LastReloadError: "",
497+ EntryPoints: []EntryPointStatus{{Name: "tcp", ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7}},
498+ Routers: []TrafficRouterStatus{{Name: "tcp", ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7}},
499+ Services: []TrafficServiceStatus{{Name: "network_service:app:game", Backends: []TrafficBackendStatus{{Name: "app", ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7}}}},
500+ Counters: TrafficCounters{ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7},
501+ }
502+ require.Equal(t, "ok", status.LastReloadStatus)
503+ require.Empty(t, status.LastReloadError)
504+ require.Equal(t, int64(7), status.EntryPoints[0].BytesOut)
505+ require.Equal(t, int64(7), status.Routers[0].BytesOut)
506+ require.Equal(t, int64(7), status.Services[0].Backends[0].BytesOut)
507+ require.Equal(t, int64(7), status.Counters.BytesOut)
508+}

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