1package traffic
2
3import (
4 "fmt"
5 "net"
6 "net/netip"
7 "sort"
8 "strconv"
9 "strings"
10 "time"
11
12 "github.com/bnema/gordon/internal/domain"
13)
14
15const DefaultEdgeEntryPointName = "edge"
16
17// Input is the complete, usecase-level input used to build a validated traffic graph.
18type Input struct {
19 EntryPoints map[string]EntryPointConfig
20 Traffic Config
21 Routes []domain.Route
22 ExternalRoutes map[string]string
23 NetworkServices []NetworkServiceConfig
24 Services []domain.StandaloneService
25}
26
27type EntryPointConfig struct {
28 Address string `mapstructure:"address"`
29 Protocol domain.EntryPointProtocol `mapstructure:"protocol"`
30 TrustedCIDRs []string `mapstructure:"trusted_cidrs"`
31 RawFallback string `mapstructure:"raw_fallback"`
32 RawFallbackTrustedCIDRs []string `mapstructure:"raw_fallback_trusted_cidrs"`
33 AllowPublicRawFallback bool `mapstructure:"allow_public_raw_fallback"`
34}
35
36// Config holds traffic router and option configuration.
37type Config struct {
38 TCP TCPConfig `mapstructure:"tcp"`
39 UDP UDPConfig `mapstructure:"udp"`
40 TLS TLSConfig `mapstructure:"tls"`
41}
42
43type TCPConfig struct {
44 Routers []RouterConfig `mapstructure:"routers"`
45 DialTimeout string `mapstructure:"dial_timeout"`
46 IdleTimeout string `mapstructure:"idle_timeout"`
47 DrainTimeout string `mapstructure:"drain_timeout"`
48 MaxConnections int `mapstructure:"max_connections"`
49}
50
51type UDPConfig struct {
52 Routers []RouterConfig `mapstructure:"routers"`
53 IdleTimeout string `mapstructure:"idle_timeout"`
54 DrainTimeout string `mapstructure:"drain_timeout"`
55 MaxSessions int `mapstructure:"max_sessions"`
56}
57
58type TLSConfig struct {
59 Routers []RouterConfig `mapstructure:"routers"`
60}
61
62type RouterConfig struct {
63 Name string `mapstructure:"name"`
64 EntryPoint string `mapstructure:"entrypoint"`
65 Host string `mapstructure:"host"`
66 SNI string `mapstructure:"sni"`
67 Service string `mapstructure:"service"`
68}
69
70type NetworkServiceConfig struct {
71 Name string `mapstructure:"name"`
72 Ports []PortConfig `mapstructure:"ports"`
73}
74
75type PortConfig struct {
76 Name string `mapstructure:"name"`
77 Container int `mapstructure:"container"`
78 Protocol domain.NetworkProtocol `mapstructure:"protocol"`
79}
80
81// Build builds and validates a TrafficGraph snapshot. Managed route services are
82// intentionally backend-less because their concrete container endpoints are resolved at runtime.
83func Build(input Input) (domain.TrafficGraph, error) {
84 b := builder{input: input, services: map[string]domain.TrafficService{}}
85 graph := domain.TrafficGraph{}
86
87 options, err := buildOptions(input.Traffic)
88 if err != nil {
89 return domain.TrafficGraph{}, fmt.Errorf("build traffic options: %w", err)
90 }
91 if err := validateNetworkServices(input.NetworkServices); err != nil {
92 return domain.TrafficGraph{}, fmt.Errorf("validate network services: %w", err)
93 }
94 if err := validateStandaloneServices(input.Services); err != nil {
95 return domain.TrafficGraph{}, fmt.Errorf("validate standalone services: %w", err)
96 }
97 graph.Options = options
98 graph.EntryPoints = b.buildEntryPoints()
99
100 if err := b.addHTTPRoutes(&graph); err != nil {
101 return domain.TrafficGraph{}, fmt.Errorf("add HTTP routes: %w", err)
102 }
103 if err := b.addExternalRoutes(&graph); err != nil {
104 return domain.TrafficGraph{}, fmt.Errorf("add external routes: %w", err)
105 }
106 if err := b.addExplicitRouters(&graph); err != nil {
107 return domain.TrafficGraph{}, fmt.Errorf("add explicit traffic routers: %w", err)
108 }
109
110 graph.Services = make([]domain.TrafficService, 0, len(b.services))
111 for _, svc := range b.services {
112 graph.Services = append(graph.Services, svc)
113 }
114 sort.Slice(graph.Services, func(i, j int) bool { return graph.Services[i].Name < graph.Services[j].Name })
115 if err := graph.Validate(); err != nil {
116 return domain.TrafficGraph{}, fmt.Errorf("validate traffic graph: %w", err)
117 }
118 return graph, nil
119}
120
121type builder struct {
122 input Input
123 services map[string]domain.TrafficService
124}
125
126func (b *builder) buildEntryPoints() []domain.EntryPoint {
127 entries := make([]domain.EntryPoint, 0, len(b.input.EntryPoints))
128 for _, name := range sortedKeys(b.input.EntryPoints) {
129 cfg := b.input.EntryPoints[name]
130 entries = append(entries, domain.EntryPoint{
131 Name: name,
132 Address: cfg.Address,
133 Protocol: cfg.Protocol,
134 TrustedCIDRs: append([]string(nil), cfg.TrustedCIDRs...),
135 RawFallback: cfg.RawFallback,
136 RawFallbackTrustedCIDRs: append([]string(nil), cfg.RawFallbackTrustedCIDRs...),
137 AllowPublicRawFallback: cfg.AllowPublicRawFallback,
138 })
139 }
140 return entries
141}
142
143func (b *builder) addHTTPRoutes(graph *domain.TrafficGraph) error {
144 routes := append([]domain.Route{}, b.input.Routes...)
145 if len(routes) == 0 {
146 return nil
147 }
148 entryPoint, err := b.routeEntryPoint()
149 if err != nil {
150 return err
151 }
152 sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain })
153 for _, route := range routes {
154 serviceName := string(domain.TrafficServiceRefRoute) + ":" + route.Domain
155 b.addService(domain.TrafficService{Name: serviceName})
156 graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: "route:" + route.Domain, EntryPoint: entryPoint, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: route.Domain}, Service: serviceName})
157 }
158 return nil
159}
160
161func (b *builder) addExternalRoutes(graph *domain.TrafficGraph) error {
162 hostNames := sortedKeys(b.input.ExternalRoutes)
163 if len(hostNames) == 0 {
164 return nil
165 }
166 entryPoint, err := b.routeEntryPoint()
167 if err != nil {
168 return err
169 }
170 for _, hostName := range hostNames {
171 target := b.input.ExternalRoutes[hostName]
172 host, port, err := parseBackendAddress(target)
173 if err != nil {
174 return fmt.Errorf("external route %q target %q: %w", hostName, target, err)
175 }
176 serviceName := string(domain.TrafficServiceRefExternalRoute) + ":" + hostName
177 b.addService(domain.TrafficService{Name: serviceName, Backends: []domain.TrafficBackend{{Name: hostName, Host: host, Port: port, Protocol: domain.NetworkProtocolTCP}}})
178 graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: "external_route:" + hostName, EntryPoint: entryPoint, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: hostName}, Service: serviceName})
179 }
180 return nil
181}
182
183func (b *builder) routeEntryPoint() (string, error) {
184 if cfg, ok := b.input.EntryPoints[DefaultEdgeEntryPointName]; ok && routeCapableEntryPoint(cfg.Protocol) {
185 return DefaultEdgeEntryPointName, nil
186 }
187
188 candidates := make([]string, 0)
189 for _, name := range sortedKeys(b.input.EntryPoints) {
190 if routeCapableEntryPoint(b.input.EntryPoints[name].Protocol) {
191 candidates = append(candidates, name)
192 }
193 }
194 switch len(candidates) {
195 case 0:
196 return DefaultEdgeEntryPointName, nil
197 case 1:
198 return candidates[0], nil
199 default:
200 return "", fmt.Errorf("multiple route-capable entrypoints configured without %q: %s", DefaultEdgeEntryPointName, strings.Join(candidates, ", "))
201 }
202}
203
204func routeCapableEntryPoint(protocol domain.EntryPointProtocol) bool {
205 switch protocol {
206 case domain.EntryPointProtocolSmartTCP, domain.EntryPointProtocolTLSMux:
207 return true
208 default:
209 return false
210 }
211}
212
213func (b *builder) addExplicitRouters(graph *domain.TrafficGraph) error {
214 if err := b.addL4Routers(graph, b.input.Traffic.TCP.Routers, domain.RouterProtocolTCP, domain.NetworkProtocolTCP); err != nil {
215 return err
216 }
217 if err := b.addL4Routers(graph, b.input.Traffic.UDP.Routers, domain.RouterProtocolUDP, domain.NetworkProtocolUDP); err != nil {
218 return err
219 }
220 return b.addL4Routers(graph, b.input.Traffic.TLS.Routers, domain.RouterProtocolTLSPassthrough, domain.NetworkProtocolTCP)
221}
222
223func (b *builder) addL4Routers(graph *domain.TrafficGraph, routers []RouterConfig, protocol domain.RouterProtocol, backendProtocol domain.NetworkProtocol) error {
224 ordered := append([]RouterConfig{}, routers...)
225 sort.Slice(ordered, func(i, j int) bool { return ordered[i].Name < ordered[j].Name })
226 for _, cfg := range ordered {
227 service, err := b.resolveService(cfg, backendProtocol)
228 if err != nil {
229 return fmt.Errorf("router %q: %w", cfg.Name, err)
230 }
231 b.addService(service)
232 graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: cfg.Name, EntryPoint: cfg.EntryPoint, Protocol: protocol, Rule: domain.TrafficRule{Host: cfg.Host, SNI: cfg.SNI}, Service: cfg.Service})
233 }
234 return nil
235}
236
237func (b *builder) resolveService(router RouterConfig, protocol domain.NetworkProtocol) (domain.TrafficService, error) {
238 ref, err := domain.ParseTrafficServiceRef(router.Service)
239 if err != nil {
240 return domain.TrafficService{}, err
241 }
242 switch ref.Kind {
243 case domain.TrafficServiceRefNetworkService:
244 return b.resolveNetworkService(router.Service, ref, protocol)
245 case domain.TrafficServiceRefService:
246 return b.resolveStandaloneService(router, ref, protocol)
247 case domain.TrafficServiceRefStatic:
248 return domain.TrafficService{}, fmt.Errorf("static service ref %q is unsupported", router.Service)
249 default:
250 return domain.TrafficService{}, fmt.Errorf("service ref %q must be network_service:<name>:<port-name> or service:<name>:<port-name>", router.Service)
251 }
252}
253
254func (b *builder) resolveNetworkService(refValue string, ref domain.TrafficServiceRef, protocol domain.NetworkProtocol) (domain.TrafficService, error) {
255 ns, ok := b.networkService(ref.Name)
256 if !ok {
257 return domain.TrafficService{}, fmt.Errorf("unknown network service %q", ref.Name)
258 }
259 port, ok := networkServicePort(ns, ref.PortName)
260 if !ok {
261 return domain.TrafficService{}, fmt.Errorf("unknown port %q on network service %q", ref.PortName, ref.Name)
262 }
263 if port.Container < 1 || port.Container > 65535 {
264 return domain.TrafficService{}, fmt.Errorf("invalid port %d on network service %q port %q", port.Container, ref.Name, ref.PortName)
265 }
266 if port.Protocol != protocol {
267 return domain.TrafficService{}, fmt.Errorf("network service %q port %q protocol %s does not match router protocol %s", ref.Name, ref.PortName, port.Protocol, protocol)
268 }
269 return domain.TrafficService{Name: refValue, Backends: []domain.TrafficBackend{{Name: ref.Name + ":" + ref.PortName, Host: ref.Name, Port: port.Container, Protocol: port.Protocol}}}, nil
270}
271
272func (b *builder) resolveStandaloneService(router RouterConfig, ref domain.TrafficServiceRef, protocol domain.NetworkProtocol) (domain.TrafficService, error) {
273 svc, ok := b.standaloneService(ref.Name)
274 if !ok || !svc.Enabled {
275 return domain.TrafficService{}, fmt.Errorf("unknown service %q", ref.Name)
276 }
277 port, ok := standaloneServicePort(svc, ref.PortName)
278 if !ok {
279 return domain.TrafficService{}, fmt.Errorf("unknown port %q on service %q", ref.PortName, ref.Name)
280 }
281 if port.Protocol != protocol {
282 return domain.TrafficService{}, fmt.Errorf("service %q port %q protocol %s does not match router protocol %s", ref.Name, ref.PortName, port.Protocol, protocol)
283 }
284 if isPrivateStandalonePort(port) {
285 if err := b.validatePrivateStandalonePortRoute(router, svc, port); err != nil {
286 return domain.TrafficService{}, err
287 }
288 }
289 host, backendPort, err := parseBackendAddress(port.Publish)
290 if err != nil {
291 return domain.TrafficService{}, fmt.Errorf("service %q port %q publish address %q is invalid: %w", ref.Name, ref.PortName, port.Publish, err)
292 }
293 return domain.TrafficService{Name: router.Service, Backends: []domain.TrafficBackend{{Name: ref.Name + ":" + ref.PortName, Host: host, Port: backendPort, Protocol: port.Protocol}}}, nil
294}
295
296func validateStandaloneServices(services []domain.StandaloneService) error {
297 seen := map[string]struct{}{}
298 for _, service := range services {
299 if service.Name == "" {
300 return fmt.Errorf("standalone service name is required")
301 }
302 if _, exists := seen[service.Name]; exists {
303 return fmt.Errorf("duplicate standalone service %q", service.Name)
304 }
305 seen[service.Name] = struct{}{}
306 if err := service.Validate(); err != nil {
307 return fmt.Errorf("standalone service %q: %w", service.Name, err)
308 }
309 }
310 return nil
311}
312
313func validateNetworkServices(services []NetworkServiceConfig) error {
314 seen := map[string]struct{}{}
315 for _, service := range services {
316 if service.Name == "" {
317 return fmt.Errorf("network service name is required")
318 }
319 if _, exists := seen[service.Name]; exists {
320 return fmt.Errorf("duplicate network service %q", service.Name)
321 }
322 seen[service.Name] = struct{}{}
323 if err := validateNetworkServicePorts(service); err != nil {
324 return err
325 }
326 }
327 return nil
328}
329
330func validateNetworkServicePorts(service NetworkServiceConfig) error {
331 seen := map[string]struct{}{}
332 for _, port := range service.Ports {
333 if port.Name == "" {
334 return fmt.Errorf("network service %q port name is required", service.Name)
335 }
336 if _, exists := seen[port.Name]; exists {
337 return fmt.Errorf("duplicate port %q on network service %q", port.Name, service.Name)
338 }
339 if port.Container < 1 || port.Container > 65535 {
340 return fmt.Errorf("invalid container port %d on network service %q port %q", port.Container, service.Name, port.Name)
341 }
342 if err := validateNetworkServicePortProtocol(port.Protocol); err != nil {
343 return fmt.Errorf("invalid protocol for network service %q port %q: %w", service.Name, port.Name, err)
344 }
345 seen[port.Name] = struct{}{}
346 }
347 return nil
348}
349
350func validateNetworkServicePortProtocol(protocol domain.NetworkProtocol) error {
351 switch protocol {
352 case domain.NetworkProtocolTCP, domain.NetworkProtocolUDP:
353 return nil
354 default:
355 return fmt.Errorf("%q", protocol)
356 }
357}
358
359func (b *builder) networkService(name string) (NetworkServiceConfig, bool) {
360 for _, service := range b.input.NetworkServices {
361 if service.Name == name {
362 return service, true
363 }
364 }
365 return NetworkServiceConfig{}, false
366}
367
368func (b *builder) standaloneService(name string) (domain.StandaloneService, bool) {
369 for _, service := range b.input.Services {
370 if service.Name == name {
371 return service, true
372 }
373 }
374 return domain.StandaloneService{}, false
375}
376
377func networkServicePort(service NetworkServiceConfig, name string) (PortConfig, bool) {
378 for _, port := range service.Ports {
379 if port.Name == name {
380 return port, true
381 }
382 }
383 return PortConfig{}, false
384}
385
386func standaloneServicePort(service domain.StandaloneService, name string) (domain.StandaloneServicePort, bool) {
387 for _, port := range service.Ports {
388 if port.Name == name {
389 return port, true
390 }
391 }
392 return domain.StandaloneServicePort{}, false
393}
394
395func isPrivateStandalonePort(port domain.StandaloneServicePort) bool {
396 if port.Public {
397 return false
398 }
399 return port.Private || strings.EqualFold(port.Name, "rcon")
400}
401
402func (b *builder) validatePrivateStandalonePortRoute(router RouterConfig, service domain.StandaloneService, port domain.StandaloneServicePort) error {
403 entryPoint, ok := b.input.EntryPoints[router.EntryPoint]
404 if !ok {
405 return fmt.Errorf("private service %q port %q router %q references unknown entrypoint %q", service.Name, port.Name, router.Name, router.EntryPoint)
406 }
407 if len(port.TrustedCIDRs) == 0 {
408 return fmt.Errorf("private service %q port %q router %q requires non-empty service port trusted_cidrs", service.Name, port.Name, router.Name)
409 }
410 if len(entryPoint.TrustedCIDRs) == 0 {
411 return fmt.Errorf("private service %q port %q router %q entrypoint %q requires non-empty trusted_cidrs", service.Name, port.Name, router.Name, router.EntryPoint)
412 }
413 if !sameCIDRSet(port.TrustedCIDRs, entryPoint.TrustedCIDRs) {
414 return fmt.Errorf("private service %q port %q router %q entrypoint %q trusted_cidrs must exactly match service port trusted_cidrs", service.Name, port.Name, router.Name, router.EntryPoint)
415 }
416 return nil
417}
418
419func sameCIDRSet(a, b []string) bool {
420 if len(a) != len(b) {
421 return false
422 }
423 left, ok := canonicalCIDRs(a)
424 if !ok {
425 return false
426 }
427 right, ok := canonicalCIDRs(b)
428 if !ok {
429 return false
430 }
431 for i := range left {
432 if left[i] != right[i] {
433 return false
434 }
435 }
436 return true
437}
438
439func canonicalCIDRs(values []string) ([]string, bool) {
440 canonical := make([]string, 0, len(values))
441 for _, value := range values {
442 prefix, err := netip.ParsePrefix(value)
443 if err != nil {
444 return nil, false
445 }
446 canonical = append(canonical, prefix.Masked().String())
447 }
448 sort.Strings(canonical)
449 return canonical, true
450}
451
452func (b *builder) addService(service domain.TrafficService) {
453 if _, exists := b.services[service.Name]; !exists {
454 b.services[service.Name] = service
455 }
456}
457
458func buildOptions(cfg Config) (domain.TrafficOptions, error) {
459 tcpDial, err := parsePositiveDurationDefault(cfg.TCP.DialTimeout, 10*time.Second, "traffic.tcp.dial_timeout")
460 if err != nil {
461 return domain.TrafficOptions{}, err
462 }
463 tcpIdle, err := parsePositiveDurationDefault(cfg.TCP.IdleTimeout, 5*time.Minute, "traffic.tcp.idle_timeout")
464 if err != nil {
465 return domain.TrafficOptions{}, err
466 }
467 tcpDrain, err := parsePositiveDurationDefault(cfg.TCP.DrainTimeout, 30*time.Second, "traffic.tcp.drain_timeout")
468 if err != nil {
469 return domain.TrafficOptions{}, err
470 }
471 udpIdle, err := parsePositiveDurationDefault(cfg.UDP.IdleTimeout, 30*time.Second, "traffic.udp.idle_timeout")
472 if err != nil {
473 return domain.TrafficOptions{}, err
474 }
475 udpDrain, err := parsePositiveDurationDefault(cfg.UDP.DrainTimeout, 30*time.Second, "traffic.udp.drain_timeout")
476 if err != nil {
477 return domain.TrafficOptions{}, err
478 }
479 return domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: tcpDial, IdleTimeout: tcpIdle, DrainTimeout: tcpDrain, MaxConnections: cfg.TCP.MaxConnections}, UDP: domain.UDPOptions{IdleTimeout: udpIdle, DrainTimeout: udpDrain, MaxSessions: cfg.UDP.MaxSessions}}, nil
480}
481
482func parsePositiveDurationDefault(value string, fallback time.Duration, field string) (time.Duration, error) {
483 d, err := parseDurationDefault(value, fallback, field)
484 if err != nil {
485 return 0, err
486 }
487 if d <= 0 {
488 return 0, fmt.Errorf("invalid %s: must be positive", field)
489 }
490 return d, nil
491}
492
493func parseDurationDefault(value string, fallback time.Duration, field string) (time.Duration, error) {
494 if strings.TrimSpace(value) == "" {
495 return fallback, nil
496 }
497 d, err := time.ParseDuration(value)
498 if err != nil {
499 return 0, fmt.Errorf("invalid %s: %w", field, err)
500 }
501 return d, nil
502}
503
504func parseBackendAddress(value string) (string, int, error) {
505 host, portText, err := net.SplitHostPort(value)
506 if err != nil {
507 lastColon := strings.LastIndex(value, ":")
508 if lastColon <= 0 || lastColon == len(value)-1 || strings.Count(value, ":") > 1 {
509 return "", 0, err
510 }
511 host, portText = value[:lastColon], value[lastColon+1:]
512 }
513 port, err := strconv.Atoi(portText)
514 if err != nil {
515 return "", 0, fmt.Errorf("invalid port: %w", err)
516 }
517 if host == "" || port < 1 || port > 65535 {
518 return "", 0, fmt.Errorf("host and valid port are required")
519 }
520 return host, port, nil
521}
522
523func sortedKeys[V any](m map[string]V) []string {
524 keys := make([]string, 0, len(m))
525 for k := range m {
526 keys = append(keys, k)
527 }
528 sort.Strings(keys)
529 return keys
530}