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