v3.0.0 f19f453

builder.go RawHistoryFolder

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}

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