detached 39cada7

feat(traffic): expose traffic runtime status

commit
39cada732d96494a81809bd2f5806cda6807bcdf
author
brice · · 2026-06-29 22:20 UTC
parent
02269b2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
19 files · +864 -12
M docs/cli/index.md +5 -0
@@ -42,6 +42,7 @@ Management commands run locally through in-process services by default. Add `--r
4242 | `gordon routes` | Manage routes | [routes](./routes.md) |
4343 | `gordon secrets` | Manage secrets | [secrets](./secrets.md) |
4444 | `gordon status` | Show Gordon server status | [status](./status.md) |
45+| `gordon traffic` | Inspect traffic plane status | [traffic](./traffic.md) |
4546 | `gordon volumes` | Manage volumes | - |
4647
4748 ## Client Commands
@@ -93,6 +94,10 @@ gordon logs myapp.example.com -f # Follow container logs
9394 # Check version
9495 gordon version
9596
97+# Traffic plane
98+gordon traffic status --remote prod
99+gordon traffic status --remote prod --json
100+
96101 # Backups
97102 gordon backups list
98103 gordon backups run app.example.com
@@ -42,6 +42,7 @@ Management commands run locally through in-process services by default. Add `--r
42 | `gordon routes` | Manage routes | [routes](./routes.md) |42 | `gordon routes` | Manage routes | [routes](./routes.md) |
43 | `gordon secrets` | Manage secrets | [secrets](./secrets.md) |43 | `gordon secrets` | Manage secrets | [secrets](./secrets.md) |
44 | `gordon status` | Show Gordon server status | [status](./status.md) |44 | `gordon status` | Show Gordon server status | [status](./status.md) |
45+| `gordon traffic` | Inspect traffic plane status | [traffic](./traffic.md) |
45 | `gordon volumes` | Manage volumes | - |46 | `gordon volumes` | Manage volumes | - |
46 47
47 ## Client Commands48 ## Client Commands
@@ -93,6 +94,10 @@ gordon logs myapp.example.com -f # Follow container logs
93 # Check version94 # Check version
94 gordon version95 gordon version
95 96
97+# Traffic plane
98+gordon traffic status --remote prod
99+gordon traffic status --remote prod --json
100+
96 # Backups101 # Backups
97 gordon backups list102 gordon backups list
98 gordon backups run app.example.com103 gordon backups run app.example.com
A docs/cli/traffic.md +62 -0
@@ -0,0 +1,62 @@
1+# gordon traffic
2+
3+Inspect Gordon traffic plane status.
4+
5+## Subcommands
6+
7+| Command | Description |
8+|---------|-------------|
9+| `gordon traffic status` | Show traffic entrypoints, counters, and reload status |
10+
11+## Usage
12+
13+```bash
14+gordon traffic status --remote prod
15+gordon traffic status --remote https://gordon.example.com --json
16+```
17+
18+## Flags
19+
20+| Flag | Description |
21+|------|-------------|
22+| `--json` | Output machine-readable JSON |
23+| `--remote`, `-r` | Remote Gordon instance or saved remote name |
24+
25+## JSON Output
26+
27+```json
28+{
29+ "last_reload_status": "ok",
30+ "entrypoints": [
31+ {
32+ "name": "postgres",
33+ "address": "0.0.0.0:5432",
34+ "protocol": "tcp",
35+ "active": true,
36+ "active_tcp_connections": 1,
37+ "active_udp_sessions": 0,
38+ "total_accepted": 12,
39+ "total_refused": 0,
40+ "total_errors": 0,
41+ "bytes_in": 4096,
42+ "bytes_out": 8192
43+ }
44+ ],
45+ "routers": [],
46+ "services": [],
47+ "counters": {
48+ "active_tcp_connections": 1,
49+ "active_udp_sessions": 0,
50+ "total_accepted": 12,
51+ "total_refused": 0,
52+ "total_errors": 0,
53+ "bytes_in": 4096,
54+ "bytes_out": 8192
55+ }
56+}
57+```
58+
59+## Related
60+
61+- [Traffic configuration](../config/traffic.md)
62+- [Server status](./status.md)
@@ -0,0 +1,62 @@
1+# gordon traffic
2+
3+Inspect Gordon traffic plane status.
4+
5+## Subcommands
6+
7+| Command | Description |
8+|---------|-------------|
9+| `gordon traffic status` | Show traffic entrypoints, counters, and reload status |
10+
11+## Usage
12+
13+```bash
14+gordon traffic status --remote prod
15+gordon traffic status --remote https://gordon.example.com --json
16+```
17+
18+## Flags
19+
20+| Flag | Description |
21+|------|-------------|
22+| `--json` | Output machine-readable JSON |
23+| `--remote`, `-r` | Remote Gordon instance or saved remote name |
24+
25+## JSON Output
26+
27+```json
28+{
29+ "last_reload_status": "ok",
30+ "entrypoints": [
31+ {
32+ "name": "postgres",
33+ "address": "0.0.0.0:5432",
34+ "protocol": "tcp",
35+ "active": true,
36+ "active_tcp_connections": 1,
37+ "active_udp_sessions": 0,
38+ "total_accepted": 12,
39+ "total_refused": 0,
40+ "total_errors": 0,
41+ "bytes_in": 4096,
42+ "bytes_out": 8192
43+ }
44+ ],
45+ "routers": [],
46+ "services": [],
47+ "counters": {
48+ "active_tcp_connections": 1,
49+ "active_udp_sessions": 0,
50+ "total_accepted": 12,
51+ "total_refused": 0,
52+ "total_errors": 0,
53+ "bytes_in": 4096,
54+ "bytes_out": 8192
55+ }
56+}
57+```
58+
59+## Related
60+
61+- [Traffic configuration](../config/traffic.md)
62+- [Server status](./status.md)
M docs/config/index.md +2 -0
@@ -172,6 +172,7 @@ keep_last = 3
172172 | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) |
173173 | `[routes]` | Domain to image mapping | [Routes](./routes.md) |
174174 | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |
175+| `[entrypoints]`, `[traffic]`, `[[network_services]]` | L4 and TLS passthrough traffic plane | [Traffic](./traffic.md) |
175176 | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |
176177 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |
177178 | `[backups]` | Database backups | [Backups](./backups.md) |
@@ -284,6 +285,7 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
284285 - [Server Configuration](./server.md)
285286 - [Routes Configuration](./routes.md)
286287 - [External Routes](./external-routes.md)
288+- [Traffic Plane](./traffic.md)
287289 - [Authentication](./auth.md)
288290 - [Telemetry](./telemetry.md)
289291 - [Backups](./backups.md)
@@ -172,6 +172,7 @@ keep_last = 3
172 | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) |172 | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) |
173 | `[routes]` | Domain to image mapping | [Routes](./routes.md) |173 | `[routes]` | Domain to image mapping | [Routes](./routes.md) |
174 | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |174 | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |
175+| `[entrypoints]`, `[traffic]`, `[[network_services]]` | L4 and TLS passthrough traffic plane | [Traffic](./traffic.md) |
175 | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |176 | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |
176 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |177 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |
177 | `[backups]` | Database backups | [Backups](./backups.md) |178 | `[backups]` | Database backups | [Backups](./backups.md) |
@@ -284,6 +285,7 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
284 - [Server Configuration](./server.md)285 - [Server Configuration](./server.md)
285 - [Routes Configuration](./routes.md)286 - [Routes Configuration](./routes.md)
286 - [External Routes](./external-routes.md)287 - [External Routes](./external-routes.md)
288+- [Traffic Plane](./traffic.md)
287 - [Authentication](./auth.md)289 - [Authentication](./auth.md)
288 - [Telemetry](./telemetry.md)290 - [Telemetry](./telemetry.md)
289 - [Backups](./backups.md)291 - [Backups](./backups.md)
A docs/config/traffic.md +101 -0
@@ -0,0 +1,101 @@
1+# Traffic Plane Configuration
2+
3+Gordon models network listeners as a traffic graph: entrypoints receive connections, routers select a service, and services resolve to backends.
4+
5+## Entrypoints
6+
7+`server.port` creates the default HTTP entrypoint `web`. `server.tls_port` creates the default TLS multiplexer entrypoint `websecure` when non-zero.
8+
9+Custom entrypoints use the top-level `entrypoints` table:
10+
11+```toml
12+[entrypoints.postgres]
13+address = "0.0.0.0:5432"
14+protocol = "tcp"
15+trusted_cidrs = ["100.64.0.0/10"]
16+
17+[entrypoints.game]
18+address = "0.0.0.0:7777"
19+protocol = "udp"
20+```
21+
22+Protocols are `http`, `tls_mux`, `tcp`, and `udp`.
23+
24+## Network Services
25+
26+Network services describe non-HTTP backends that L4 routers can target.
27+
28+```toml
29+[[network_services]]
30+name = "postgres"
31+
32+[[network_services.ports]]
33+name = "db"
34+container = 5432
35+protocol = "tcp"
36+```
37+
38+Service references use:
39+
40+- `route:<domain>` for configured HTTP routes
41+- `external_route:<domain>` for configured external HTTP routes
42+- `network_service:<service>:<port-name>` for TCP, UDP, and TLS passthrough
43+
44+`static:<name>` is reserved and currently unsupported.
45+
46+## TCP Routers
47+
48+```toml
49+[traffic.tcp]
50+dial_timeout = "10s"
51+idle_timeout = "5m"
52+drain_timeout = "30s"
53+max_connections = 0
54+
55+[[traffic.tcp.routers]]
56+name = "postgres"
57+entrypoint = "postgres"
58+service = "network_service:postgres:db"
59+```
60+
61+A plain TCP entrypoint supports one TCP router.
62+
63+## UDP Routers
64+
65+```toml
66+[traffic.udp]
67+idle_timeout = "30s"
68+drain_timeout = "30s"
69+max_sessions = 0
70+
71+[[traffic.udp.routers]]
72+name = "game"
73+entrypoint = "game"
74+service = "network_service:game:game"
75+```
76+
77+UDP sessions are keyed by client address and expire after `idle_timeout`.
78+
79+## TLS Passthrough
80+
81+TLS passthrough routers run on traffic-manager-owned `tls_mux` entrypoints and route by ClientHello SNI without terminating TLS. The default `websecure` entrypoint is reserved for Gordon's legacy HTTPS listener until full HTTP/HTTPS traffic-manager ownership is enabled; use a custom `tls_mux` entrypoint for passthrough.
82+
83+```toml
84+[entrypoints.rawtls]
85+address = "0.0.0.0:9443"
86+protocol = "tls_mux"
87+
88+[[traffic.tls.routers]]
89+name = "raw-tls"
90+entrypoint = "rawtls"
91+sni = "raw.example.com"
92+service = "network_service:raw:tls"
93+```
94+
95+Exact SNI matches win over wildcard matches. Ambiguous wildcard overlaps and HTTP-host/TLS-passthrough conflicts are rejected at validation time.
96+
97+## Related
98+
99+- [Server Settings](./server.md)
100+- [Routes](./routes.md)
101+- [CLI traffic status](../cli/traffic.md)
@@ -0,0 +1,101 @@
1+# Traffic Plane Configuration
2+
3+Gordon models network listeners as a traffic graph: entrypoints receive connections, routers select a service, and services resolve to backends.
4+
5+## Entrypoints
6+
7+`server.port` creates the default HTTP entrypoint `web`. `server.tls_port` creates the default TLS multiplexer entrypoint `websecure` when non-zero.
8+
9+Custom entrypoints use the top-level `entrypoints` table:
10+
11+```toml
12+[entrypoints.postgres]
13+address = "0.0.0.0:5432"
14+protocol = "tcp"
15+trusted_cidrs = ["100.64.0.0/10"]
16+
17+[entrypoints.game]
18+address = "0.0.0.0:7777"
19+protocol = "udp"
20+```
21+
22+Protocols are `http`, `tls_mux`, `tcp`, and `udp`.
23+
24+## Network Services
25+
26+Network services describe non-HTTP backends that L4 routers can target.
27+
28+```toml
29+[[network_services]]
30+name = "postgres"
31+
32+[[network_services.ports]]
33+name = "db"
34+container = 5432
35+protocol = "tcp"
36+```
37+
38+Service references use:
39+
40+- `route:<domain>` for configured HTTP routes
41+- `external_route:<domain>` for configured external HTTP routes
42+- `network_service:<service>:<port-name>` for TCP, UDP, and TLS passthrough
43+
44+`static:<name>` is reserved and currently unsupported.
45+
46+## TCP Routers
47+
48+```toml
49+[traffic.tcp]
50+dial_timeout = "10s"
51+idle_timeout = "5m"
52+drain_timeout = "30s"
53+max_connections = 0
54+
55+[[traffic.tcp.routers]]
56+name = "postgres"
57+entrypoint = "postgres"
58+service = "network_service:postgres:db"
59+```
60+
61+A plain TCP entrypoint supports one TCP router.
62+
63+## UDP Routers
64+
65+```toml
66+[traffic.udp]
67+idle_timeout = "30s"
68+drain_timeout = "30s"
69+max_sessions = 0
70+
71+[[traffic.udp.routers]]
72+name = "game"
73+entrypoint = "game"
74+service = "network_service:game:game"
75+```
76+
77+UDP sessions are keyed by client address and expire after `idle_timeout`.
78+
79+## TLS Passthrough
80+
81+TLS passthrough routers run on traffic-manager-owned `tls_mux` entrypoints and route by ClientHello SNI without terminating TLS. The default `websecure` entrypoint is reserved for Gordon's legacy HTTPS listener until full HTTP/HTTPS traffic-manager ownership is enabled; use a custom `tls_mux` entrypoint for passthrough.
82+
83+```toml
84+[entrypoints.rawtls]
85+address = "0.0.0.0:9443"
86+protocol = "tls_mux"
87+
88+[[traffic.tls.routers]]
89+name = "raw-tls"
90+entrypoint = "rawtls"
91+sni = "raw.example.com"
92+service = "network_service:raw:tls"
93+```
94+
95+Exact SNI matches win over wildcard matches. Ambiguous wildcard overlaps and HTTP-host/TLS-passthrough conflicts are rejected at validation time.
96+
97+## Related
98+
99+- [Server Settings](./server.md)
100+- [Routes](./routes.md)
101+- [CLI traffic status](../cli/traffic.md)
M docs/index.md +1 -0
@@ -47,6 +47,7 @@ Gordon runs on your VPS and provides:
4747 - [Configuration Overview](./config/index.md) - All configuration options
4848 - [Server Settings](./config/server.md) - Ports, domains, and runtime
4949 - [Routes](./config/routes.md) - Domain to container mapping
50+- [Traffic Plane](./config/traffic.md) - TCP, UDP, and TLS passthrough entrypoints
5051 - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows
5152 - [Secrets](./config/secrets.md) - Secure credential storage
5253 - [Network Isolation](./config/network-isolation.md) - Per-app network isolation
@@ -47,6 +47,7 @@ Gordon runs on your VPS and provides:
47 - [Configuration Overview](./config/index.md) - All configuration options47 - [Configuration Overview](./config/index.md) - All configuration options
48 - [Server Settings](./config/server.md) - Ports, domains, and runtime48 - [Server Settings](./config/server.md) - Ports, domains, and runtime
49 - [Routes](./config/routes.md) - Domain to container mapping49 - [Routes](./config/routes.md) - Domain to container mapping
50+- [Traffic Plane](./config/traffic.md) - TCP, UDP, and TLS passthrough entrypoints
50 - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows51 - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows
51 - [Secrets](./config/secrets.md) - Secure credential storage52 - [Secrets](./config/secrets.md) - Secure credential storage
52 - [Network Isolation](./config/network-isolation.md) - Per-app network isolation53 - [Network Isolation](./config/network-isolation.md) - Per-app network isolation
A internal/adapters/dto/traffic.go +148 -0
@@ -0,0 +1,148 @@
1+package dto
2+
3+import "github.com/bnema/gordon/internal/domain"
4+
5+type TrafficStatusResponse struct {
6+ LastReloadStatus string `json:"last_reload_status"`
7+ LastReloadError string `json:"last_reload_error,omitempty"`
8+ EntryPoints []TrafficEntryPointStatus `json:"entrypoints"`
9+ Routers []TrafficRouterStatus `json:"routers"`
10+ Services []TrafficServiceStatus `json:"services"`
11+ Counters TrafficCounters `json:"counters"`
12+}
13+
14+type TrafficEntryPointStatus struct {
15+ Name string `json:"name"`
16+ Address string `json:"address"`
17+ Protocol domain.EntryPointProtocol `json:"protocol"`
18+ Active bool `json:"active"`
19+ ActiveTCPConnections int64 `json:"active_tcp_connections"`
20+ ActiveUDPSessions int64 `json:"active_udp_sessions"`
21+ TotalAccepted int64 `json:"total_accepted"`
22+ TotalRefused int64 `json:"total_refused"`
23+ TotalErrors int64 `json:"total_errors"`
24+ BytesIn int64 `json:"bytes_in"`
25+ BytesOut int64 `json:"bytes_out"`
26+}
27+
28+type TrafficRouterStatus struct {
29+ Name string `json:"name"`
30+ EntryPoint string `json:"entrypoint"`
31+ Protocol domain.RouterProtocol `json:"protocol"`
32+ Rule TrafficRule `json:"rule"`
33+ Service string `json:"service"`
34+ Active bool `json:"active"`
35+ ActiveTCPConnections int64 `json:"active_tcp_connections"`
36+ ActiveUDPSessions int64 `json:"active_udp_sessions"`
37+ TotalAccepted int64 `json:"total_accepted"`
38+ TotalRefused int64 `json:"total_refused"`
39+ TotalErrors int64 `json:"total_errors"`
40+ BytesIn int64 `json:"bytes_in"`
41+ BytesOut int64 `json:"bytes_out"`
42+}
43+
44+type TrafficServiceStatus struct {
45+ Name string `json:"name"`
46+ Active bool `json:"active"`
47+ Backends []TrafficBackendStatus `json:"backends"`
48+}
49+
50+type TrafficRule struct {
51+ Host string `json:"host,omitempty"`
52+ SNI string `json:"sni,omitempty"`
53+}
54+
55+type TrafficBackendStatus struct {
56+ Name string `json:"name"`
57+ Host string `json:"host"`
58+ Port int `json:"port"`
59+ Protocol domain.NetworkProtocol `json:"protocol"`
60+ Active bool `json:"active"`
61+ ActiveTCPConnections int64 `json:"active_tcp_connections"`
62+ ActiveUDPSessions int64 `json:"active_udp_sessions"`
63+ TotalAccepted int64 `json:"total_accepted"`
64+ TotalRefused int64 `json:"total_refused"`
65+ TotalErrors int64 `json:"total_errors"`
66+ BytesIn int64 `json:"bytes_in"`
67+ BytesOut int64 `json:"bytes_out"`
68+}
69+
70+type TrafficCounters struct {
71+ ActiveTCPConnections int64 `json:"active_tcp_connections"`
72+ ActiveUDPSessions int64 `json:"active_udp_sessions"`
73+ TotalAccepted int64 `json:"total_accepted"`
74+ TotalRefused int64 `json:"total_refused"`
75+ TotalErrors int64 `json:"total_errors"`
76+ BytesIn int64 `json:"bytes_in"`
77+ BytesOut int64 `json:"bytes_out"`
78+}
79+
80+func TrafficStatusFromDomain(status domain.TrafficStatus) TrafficStatusResponse {
81+ return TrafficStatusResponse{
82+ LastReloadStatus: status.LastReloadStatus,
83+ LastReloadError: status.LastReloadError,
84+ EntryPoints: trafficEntryPointsFromDomain(status.EntryPoints),
85+ Routers: trafficRoutersFromDomain(status.Routers),
86+ Services: trafficServicesFromDomain(status.Services),
87+ Counters: trafficCountersFromDomain(status.Counters),
88+ }
89+}
90+
91+func trafficEntryPointsFromDomain(values []domain.EntryPointStatus) []TrafficEntryPointStatus {
92+ out := make([]TrafficEntryPointStatus, 0, len(values))
93+ for _, value := range values {
94+ out = append(out, TrafficEntryPointStatus{
95+ Name: value.Name, Address: value.Address, Protocol: value.Protocol, Active: value.Active,
96+ ActiveTCPConnections: value.ActiveTCPConnections, ActiveUDPSessions: value.ActiveUDPSessions,
97+ TotalAccepted: value.TotalAccepted, TotalRefused: value.TotalRefused, TotalErrors: value.TotalErrors,
98+ BytesIn: value.BytesIn, BytesOut: value.BytesOut,
99+ })
100+ }
101+ return out
102+}
103+
104+func trafficRoutersFromDomain(values []domain.TrafficRouterStatus) []TrafficRouterStatus {
105+ out := make([]TrafficRouterStatus, 0, len(values))
106+ for _, value := range values {
107+ out = append(out, TrafficRouterStatus{
108+ Name: value.Name, EntryPoint: value.EntryPoint, Protocol: value.Protocol, Rule: TrafficRule{Host: value.Rule.Host, SNI: value.Rule.SNI}, Service: value.Service, Active: value.Active,
109+ ActiveTCPConnections: value.ActiveTCPConnections, ActiveUDPSessions: value.ActiveUDPSessions,
110+ TotalAccepted: value.TotalAccepted, TotalRefused: value.TotalRefused, TotalErrors: value.TotalErrors,
111+ BytesIn: value.BytesIn, BytesOut: value.BytesOut,
112+ })
113+ }
114+ return out
115+}
116+
117+func trafficServicesFromDomain(values []domain.TrafficServiceStatus) []TrafficServiceStatus {
118+ out := make([]TrafficServiceStatus, 0, len(values))
119+ for _, value := range values {
120+ out = append(out, TrafficServiceStatus{Name: value.Name, Active: value.Active, Backends: trafficBackendsFromDomain(value.Backends)})
121+ }
122+ return out
123+}
124+
125+func trafficBackendsFromDomain(values []domain.TrafficBackendStatus) []TrafficBackendStatus {
126+ out := make([]TrafficBackendStatus, 0, len(values))
127+ for _, value := range values {
128+ out = append(out, TrafficBackendStatus{
129+ Name: value.Name, Host: value.Host, Port: value.Port, Protocol: value.Protocol, Active: value.Active,
130+ ActiveTCPConnections: value.ActiveTCPConnections, ActiveUDPSessions: value.ActiveUDPSessions,
131+ TotalAccepted: value.TotalAccepted, TotalRefused: value.TotalRefused, TotalErrors: value.TotalErrors,
132+ BytesIn: value.BytesIn, BytesOut: value.BytesOut,
133+ })
134+ }
135+ return out
136+}
137+
138+func trafficCountersFromDomain(value domain.TrafficCounters) TrafficCounters {
139+ return TrafficCounters{
140+ ActiveTCPConnections: value.ActiveTCPConnections,
141+ ActiveUDPSessions: value.ActiveUDPSessions,
142+ TotalAccepted: value.TotalAccepted,
143+ TotalRefused: value.TotalRefused,
144+ TotalErrors: value.TotalErrors,
145+ BytesIn: value.BytesIn,
146+ BytesOut: value.BytesOut,
147+ }
148+}
@@ -0,0 +1,148 @@
1+package dto
2+
3+import "github.com/bnema/gordon/internal/domain"
4+
5+type TrafficStatusResponse struct {
6+ LastReloadStatus string `json:"last_reload_status"`
7+ LastReloadError string `json:"last_reload_error,omitempty"`
8+ EntryPoints []TrafficEntryPointStatus `json:"entrypoints"`
9+ Routers []TrafficRouterStatus `json:"routers"`
10+ Services []TrafficServiceStatus `json:"services"`
11+ Counters TrafficCounters `json:"counters"`
12+}
13+
14+type TrafficEntryPointStatus struct {
15+ Name string `json:"name"`
16+ Address string `json:"address"`
17+ Protocol domain.EntryPointProtocol `json:"protocol"`
18+ Active bool `json:"active"`
19+ ActiveTCPConnections int64 `json:"active_tcp_connections"`
20+ ActiveUDPSessions int64 `json:"active_udp_sessions"`
21+ TotalAccepted int64 `json:"total_accepted"`
22+ TotalRefused int64 `json:"total_refused"`
23+ TotalErrors int64 `json:"total_errors"`
24+ BytesIn int64 `json:"bytes_in"`
25+ BytesOut int64 `json:"bytes_out"`
26+}
27+
28+type TrafficRouterStatus struct {
29+ Name string `json:"name"`
30+ EntryPoint string `json:"entrypoint"`
31+ Protocol domain.RouterProtocol `json:"protocol"`
32+ Rule TrafficRule `json:"rule"`
33+ Service string `json:"service"`
34+ Active bool `json:"active"`
35+ ActiveTCPConnections int64 `json:"active_tcp_connections"`
36+ ActiveUDPSessions int64 `json:"active_udp_sessions"`
37+ TotalAccepted int64 `json:"total_accepted"`
38+ TotalRefused int64 `json:"total_refused"`
39+ TotalErrors int64 `json:"total_errors"`
40+ BytesIn int64 `json:"bytes_in"`
41+ BytesOut int64 `json:"bytes_out"`
42+}
43+
44+type TrafficServiceStatus struct {
45+ Name string `json:"name"`
46+ Active bool `json:"active"`
47+ Backends []TrafficBackendStatus `json:"backends"`
48+}
49+
50+type TrafficRule struct {
51+ Host string `json:"host,omitempty"`
52+ SNI string `json:"sni,omitempty"`
53+}
54+
55+type TrafficBackendStatus struct {
56+ Name string `json:"name"`
57+ Host string `json:"host"`
58+ Port int `json:"port"`
59+ Protocol domain.NetworkProtocol `json:"protocol"`
60+ Active bool `json:"active"`
61+ ActiveTCPConnections int64 `json:"active_tcp_connections"`
62+ ActiveUDPSessions int64 `json:"active_udp_sessions"`
63+ TotalAccepted int64 `json:"total_accepted"`
64+ TotalRefused int64 `json:"total_refused"`
65+ TotalErrors int64 `json:"total_errors"`
66+ BytesIn int64 `json:"bytes_in"`
67+ BytesOut int64 `json:"bytes_out"`
68+}
69+
70+type TrafficCounters struct {
71+ ActiveTCPConnections int64 `json:"active_tcp_connections"`
72+ ActiveUDPSessions int64 `json:"active_udp_sessions"`
73+ TotalAccepted int64 `json:"total_accepted"`
74+ TotalRefused int64 `json:"total_refused"`
75+ TotalErrors int64 `json:"total_errors"`
76+ BytesIn int64 `json:"bytes_in"`
77+ BytesOut int64 `json:"bytes_out"`
78+}
79+
80+func TrafficStatusFromDomain(status domain.TrafficStatus) TrafficStatusResponse {
81+ return TrafficStatusResponse{
82+ LastReloadStatus: status.LastReloadStatus,
83+ LastReloadError: status.LastReloadError,
84+ EntryPoints: trafficEntryPointsFromDomain(status.EntryPoints),
85+ Routers: trafficRoutersFromDomain(status.Routers),
86+ Services: trafficServicesFromDomain(status.Services),
87+ Counters: trafficCountersFromDomain(status.Counters),
88+ }
89+}
90+
91+func trafficEntryPointsFromDomain(values []domain.EntryPointStatus) []TrafficEntryPointStatus {
92+ out := make([]TrafficEntryPointStatus, 0, len(values))
93+ for _, value := range values {
94+ out = append(out, TrafficEntryPointStatus{
95+ Name: value.Name, Address: value.Address, Protocol: value.Protocol, Active: value.Active,
96+ ActiveTCPConnections: value.ActiveTCPConnections, ActiveUDPSessions: value.ActiveUDPSessions,
97+ TotalAccepted: value.TotalAccepted, TotalRefused: value.TotalRefused, TotalErrors: value.TotalErrors,
98+ BytesIn: value.BytesIn, BytesOut: value.BytesOut,
99+ })
100+ }
101+ return out
102+}
103+
104+func trafficRoutersFromDomain(values []domain.TrafficRouterStatus) []TrafficRouterStatus {
105+ out := make([]TrafficRouterStatus, 0, len(values))
106+ for _, value := range values {
107+ out = append(out, TrafficRouterStatus{
108+ Name: value.Name, EntryPoint: value.EntryPoint, Protocol: value.Protocol, Rule: TrafficRule{Host: value.Rule.Host, SNI: value.Rule.SNI}, Service: value.Service, Active: value.Active,
109+ ActiveTCPConnections: value.ActiveTCPConnections, ActiveUDPSessions: value.ActiveUDPSessions,
110+ TotalAccepted: value.TotalAccepted, TotalRefused: value.TotalRefused, TotalErrors: value.TotalErrors,
111+ BytesIn: value.BytesIn, BytesOut: value.BytesOut,
112+ })
113+ }
114+ return out
115+}
116+
117+func trafficServicesFromDomain(values []domain.TrafficServiceStatus) []TrafficServiceStatus {
118+ out := make([]TrafficServiceStatus, 0, len(values))
119+ for _, value := range values {
120+ out = append(out, TrafficServiceStatus{Name: value.Name, Active: value.Active, Backends: trafficBackendsFromDomain(value.Backends)})
121+ }
122+ return out
123+}
124+
125+func trafficBackendsFromDomain(values []domain.TrafficBackendStatus) []TrafficBackendStatus {
126+ out := make([]TrafficBackendStatus, 0, len(values))
127+ for _, value := range values {
128+ out = append(out, TrafficBackendStatus{
129+ Name: value.Name, Host: value.Host, Port: value.Port, Protocol: value.Protocol, Active: value.Active,
130+ ActiveTCPConnections: value.ActiveTCPConnections, ActiveUDPSessions: value.ActiveUDPSessions,
131+ TotalAccepted: value.TotalAccepted, TotalRefused: value.TotalRefused, TotalErrors: value.TotalErrors,
132+ BytesIn: value.BytesIn, BytesOut: value.BytesOut,
133+ })
134+ }
135+ return out
136+}
137+
138+func trafficCountersFromDomain(value domain.TrafficCounters) TrafficCounters {
139+ return TrafficCounters{
140+ ActiveTCPConnections: value.ActiveTCPConnections,
141+ ActiveUDPSessions: value.ActiveUDPSessions,
142+ TotalAccepted: value.TotalAccepted,
143+ TotalRefused: value.TotalRefused,
144+ TotalErrors: value.TotalErrors,
145+ BytesIn: value.BytesIn,
146+ BytesOut: value.BytesOut,
147+ }
148+}
M internal/adapters/in/cli/remote/client.go +21 -7
@@ -701,6 +701,21 @@ func (c *Client) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, erro
701701 return &status, nil
702702 }
703703
704+// GetTrafficStatus returns the traffic plane status.
705+func (c *Client) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) {
706+ resp, err := c.request(ctx, http.MethodGet, "/traffic/status", nil)
707+ if err != nil {
708+ return nil, err
709+ }
710+
711+ var status dto.TrafficStatusResponse
712+ if err := parseResponse(resp, &status); err != nil {
713+ return nil, err
714+ }
715+
716+ return &status, nil
717+}
718+
704719 // GetStatus returns the Gordon server status.
705720 func (c *Client) GetStatus(ctx context.Context) (*Status, error) {
706721 resp, err := c.request(ctx, http.MethodGet, "/status", nil)
@@ -1351,19 +1366,18 @@ func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, er
13511366 // Process complete SSE events
13521367 for {
13531368 data := lineBuffer.String()
1354- idx := strings.Index(data, "\n\n")
1355- if idx == -1 {
1369+ event, remaining, ok := strings.Cut(data, "\n\n")
1370+ if !ok {
13561371 break
13571372 }
13581373
1359- event := data[:idx]
13601374 lineBuffer.Reset()
1361- lineBuffer.WriteString(data[idx+2:])
1375+ lineBuffer.WriteString(remaining)
13621376
13631377 // Parse SSE data lines
1364- for _, line := range strings.Split(event, "\n") {
1365- if strings.HasPrefix(line, "data: ") {
1366- logLine := strings.TrimPrefix(line, "data: ")
1378+ for line := range strings.SplitSeq(event, "\n") {
1379+ logLine, ok := strings.CutPrefix(line, "data: ")
1380+ if ok {
13671381 select {
13681382 case ch <- logLine:
13691383 case <-ctx.Done():
@@ -701,6 +701,21 @@ func (c *Client) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, erro
701 return &status, nil701 return &status, nil
702 }702 }
703 703
704+// GetTrafficStatus returns the traffic plane status.
705+func (c *Client) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) {
706+ resp, err := c.request(ctx, http.MethodGet, "/traffic/status", nil)
707+ if err != nil {
708+ return nil, err
709+ }
710+
711+ var status dto.TrafficStatusResponse
712+ if err := parseResponse(resp, &status); err != nil {
713+ return nil, err
714+ }
715+
716+ return &status, nil
717+}
718+
704 // GetStatus returns the Gordon server status.719 // GetStatus returns the Gordon server status.
705 func (c *Client) GetStatus(ctx context.Context) (*Status, error) {720 func (c *Client) GetStatus(ctx context.Context) (*Status, error) {
706 resp, err := c.request(ctx, http.MethodGet, "/status", nil)721 resp, err := c.request(ctx, http.MethodGet, "/status", nil)
@@ -1351,19 +1366,18 @@ func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, er
1351 // Process complete SSE events1366 // Process complete SSE events
1352 for {1367 for {
1353 data := lineBuffer.String()1368 data := lineBuffer.String()
1354- idx := strings.Index(data, "\n\n")1369+ event, remaining, ok := strings.Cut(data, "\n\n")
1355- if idx == -1 {1370+ if !ok {
1356 break1371 break
1357 }1372 }
1358 1373
1359- event := data[:idx]
1360 lineBuffer.Reset()1374 lineBuffer.Reset()
1361- lineBuffer.WriteString(data[idx+2:])1375+ lineBuffer.WriteString(remaining)
1362 1376
1363 // Parse SSE data lines1377 // Parse SSE data lines
1364- for _, line := range strings.Split(event, "\n") {1378+ for line := range strings.SplitSeq(event, "\n") {
1365- if strings.HasPrefix(line, "data: ") {1379+ logLine, ok := strings.CutPrefix(line, "data: ")
1366- logLine := strings.TrimPrefix(line, "data: ")1380+ if ok {
1367 select {1381 select {
1368 case ch <- logLine:1382 case ch <- logLine:
1369 case <-ctx.Done():1383 case <-ctx.Done():
M internal/adapters/in/cli/remote/client_test.go +17 -0
@@ -117,6 +117,23 @@ func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
117117 require.NoError(t, err)
118118 }
119119
120+func TestClientGetTrafficStatus(t *testing.T) {
121+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
122+ require.Equal(t, "/admin/traffic/status", r.URL.Path)
123+ require.Equal(t, http.MethodGet, r.Method)
124+ w.Header().Set("Content-Type", "application/json")
125+ _, _ = w.Write([]byte(`{"last_reload_status":"ok","counters":{"active_tcp_connections":1}}`))
126+ }))
127+ defer srv.Close()
128+
129+ client := NewClient(srv.URL)
130+ status, err := client.GetTrafficStatus(context.Background())
131+ require.NoError(t, err)
132+ require.NotNil(t, status)
133+ assert.Equal(t, "ok", status.LastReloadStatus)
134+ assert.Equal(t, int64(1), status.Counters.ActiveTCPConnections)
135+}
136+
120137 func TestClientGetTLSStatus(t *testing.T) {
121138 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
122139 require.Equal(t, "/admin/tls/status", r.URL.Path)
@@ -117,6 +117,23 @@ func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
117 require.NoError(t, err)117 require.NoError(t, err)
118 }118 }
119 119
120+func TestClientGetTrafficStatus(t *testing.T) {
121+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
122+ require.Equal(t, "/admin/traffic/status", r.URL.Path)
123+ require.Equal(t, http.MethodGet, r.Method)
124+ w.Header().Set("Content-Type", "application/json")
125+ _, _ = w.Write([]byte(`{"last_reload_status":"ok","counters":{"active_tcp_connections":1}}`))
126+ }))
127+ defer srv.Close()
128+
129+ client := NewClient(srv.URL)
130+ status, err := client.GetTrafficStatus(context.Background())
131+ require.NoError(t, err)
132+ require.NotNil(t, status)
133+ assert.Equal(t, "ok", status.LastReloadStatus)
134+ assert.Equal(t, int64(1), status.Counters.ActiveTCPConnections)
135+}
136+
120 func TestClientGetTLSStatus(t *testing.T) {137 func TestClientGetTLSStatus(t *testing.T) {
121 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {138 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
122 require.Equal(t, "/admin/tls/status", r.URL.Path)139 require.Equal(t, "/admin/tls/status", r.URL.Path)
M internal/adapters/in/cli/root.go +4 -0
@@ -154,6 +154,10 @@ Commands are organized by where they run:
154154 tlsCmd.GroupID = groupManage
155155 rootCmd.AddCommand(tlsCmd)
156156
157+ trafficCmd := newTrafficCmd()
158+ trafficCmd.GroupID = groupManage
159+ rootCmd.AddCommand(trafficCmd)
160+
157161 // Client-only commands (no server needed)
158162 remotesCmd := newRemotesCmd()
159163 remotesCmd.GroupID = groupClient
@@ -154,6 +154,10 @@ Commands are organized by where they run:
154 tlsCmd.GroupID = groupManage154 tlsCmd.GroupID = groupManage
155 rootCmd.AddCommand(tlsCmd)155 rootCmd.AddCommand(tlsCmd)
156 156
157+ trafficCmd := newTrafficCmd()
158+ trafficCmd.GroupID = groupManage
159+ rootCmd.AddCommand(trafficCmd)
160+
157 // Client-only commands (no server needed)161 // Client-only commands (no server needed)
158 remotesCmd := newRemotesCmd()162 remotesCmd := newRemotesCmd()
159 remotesCmd.GroupID = groupClient163 remotesCmd.GroupID = groupClient
A internal/adapters/in/cli/traffic.go +140 -0
@@ -0,0 +1,140 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "io"
6+
7+ "github.com/spf13/cobra"
8+
9+ "github.com/bnema/gordon/internal/adapters/dto"
10+)
11+
12+func newTrafficCmd() *cobra.Command {
13+ cmd := &cobra.Command{
14+ Use: "traffic",
15+ Short: "Inspect traffic plane status",
16+ }
17+ cmd.AddCommand(newTrafficStatusCmd())
18+ return cmd
19+}
20+
21+func newTrafficStatusCmd() *cobra.Command {
22+ var jsonOut bool
23+ cmd := &cobra.Command{
24+ Use: "status",
25+ Short: "Show traffic entrypoint, router, and counter status",
26+ RunE: func(cmd *cobra.Command, args []string) error {
27+ client, isRemote, err := GetRemoteClient()
28+ if err != nil {
29+ return err
30+ }
31+ if !isRemote {
32+ return fmt.Errorf("traffic status currently requires --remote")
33+ }
34+ status, err := client.GetTrafficStatus(cmd.Context())
35+ if err != nil {
36+ return err
37+ }
38+ return renderTrafficStatus(cmd.OutOrStdout(), status, jsonOut)
39+ },
40+ }
41+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output JSON")
42+ return cmd
43+}
44+
45+func renderTrafficStatus(out io.Writer, status *dto.TrafficStatusResponse, jsonOut bool) error {
46+ if jsonOut {
47+ return writeJSON(out, status)
48+ }
49+ if err := cliWriteLine(out, cliRenderTitle("Traffic Status")); err != nil {
50+ return err
51+ }
52+ if err := cliWriteLine(out, cliRenderMeta("Reload", status.LastReloadStatus)); err != nil {
53+ return err
54+ }
55+ if status.LastReloadError != "" {
56+ if err := cliWriteLine(out, cliRenderWarning("Last reload error: "+status.LastReloadError)); err != nil {
57+ return err
58+ }
59+ }
60+ if err := cliWriteLine(out, ""); err != nil {
61+ return err
62+ }
63+ if err := cliWriteLine(out, "Entrypoints:"); err != nil {
64+ return err
65+ }
66+ if len(status.EntryPoints) == 0 {
67+ if err := cliWriteLine(out, cliRenderMuted(" none")); err != nil {
68+ return err
69+ }
70+ } else {
71+ for _, entry := range status.EntryPoints {
72+ if err := cliWritef(out, " %s %s %s active=%t tcp=%d udp=%d accepted=%d refused=%d errors=%d\n",
73+ entry.Name, entry.Protocol, entry.Address, entry.Active, entry.ActiveTCPConnections, entry.ActiveUDPSessions,
74+ entry.TotalAccepted, entry.TotalRefused, entry.TotalErrors); err != nil {
75+ return err
76+ }
77+ }
78+ }
79+ if err := renderTrafficRouters(out, status.Routers); err != nil {
80+ return err
81+ }
82+ if err := renderTrafficServices(out, status.Services); err != nil {
83+ return err
84+ }
85+ if err := cliWriteLine(out, ""); err != nil {
86+ return err
87+ }
88+ return cliWritef(out, "Totals: tcp=%d udp=%d accepted=%d refused=%d errors=%d bytes_in=%d bytes_out=%d\n",
89+ status.Counters.ActiveTCPConnections, status.Counters.ActiveUDPSessions, status.Counters.TotalAccepted,
90+ status.Counters.TotalRefused, status.Counters.TotalErrors, status.Counters.BytesIn, status.Counters.BytesOut)
91+}
92+
93+func renderTrafficRouters(out io.Writer, routers []dto.TrafficRouterStatus) error {
94+ if err := cliWriteLine(out, ""); err != nil {
95+ return err
96+ }
97+ if err := cliWriteLine(out, "Routers:"); err != nil {
98+ return err
99+ }
100+ if len(routers) == 0 {
101+ return cliWriteLine(out, cliRenderMuted(" none"))
102+ }
103+ for _, router := range routers {
104+ rule := router.Rule.Host
105+ if rule == "" {
106+ rule = router.Rule.SNI
107+ }
108+ if err := cliWritef(out, " %s %s entrypoint=%s rule=%s service=%s active=%t accepted=%d refused=%d errors=%d\n",
109+ router.Name, router.Protocol, router.EntryPoint, rule, router.Service, router.Active,
110+ router.TotalAccepted, router.TotalRefused, router.TotalErrors); err != nil {
111+ return err
112+ }
113+ }
114+ return nil
115+}
116+
117+func renderTrafficServices(out io.Writer, services []dto.TrafficServiceStatus) error {
118+ if err := cliWriteLine(out, ""); err != nil {
119+ return err
120+ }
121+ if err := cliWriteLine(out, "Services:"); err != nil {
122+ return err
123+ }
124+ if len(services) == 0 {
125+ return cliWriteLine(out, cliRenderMuted(" none"))
126+ }
127+ for _, service := range services {
128+ if err := cliWritef(out, " %s active=%t\n", service.Name, service.Active); err != nil {
129+ return err
130+ }
131+ for _, backend := range service.Backends {
132+ if err := cliWritef(out, " %s %s://%s:%d active=%t accepted=%d refused=%d errors=%d\n",
133+ backend.Name, backend.Protocol, backend.Host, backend.Port, backend.Active,
134+ backend.TotalAccepted, backend.TotalRefused, backend.TotalErrors); err != nil {
135+ return err
136+ }
137+ }
138+ }
139+ return nil
140+}
@@ -0,0 +1,140 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "io"
6+
7+ "github.com/spf13/cobra"
8+
9+ "github.com/bnema/gordon/internal/adapters/dto"
10+)
11+
12+func newTrafficCmd() *cobra.Command {
13+ cmd := &cobra.Command{
14+ Use: "traffic",
15+ Short: "Inspect traffic plane status",
16+ }
17+ cmd.AddCommand(newTrafficStatusCmd())
18+ return cmd
19+}
20+
21+func newTrafficStatusCmd() *cobra.Command {
22+ var jsonOut bool
23+ cmd := &cobra.Command{
24+ Use: "status",
25+ Short: "Show traffic entrypoint, router, and counter status",
26+ RunE: func(cmd *cobra.Command, args []string) error {
27+ client, isRemote, err := GetRemoteClient()
28+ if err != nil {
29+ return err
30+ }
31+ if !isRemote {
32+ return fmt.Errorf("traffic status currently requires --remote")
33+ }
34+ status, err := client.GetTrafficStatus(cmd.Context())
35+ if err != nil {
36+ return err
37+ }
38+ return renderTrafficStatus(cmd.OutOrStdout(), status, jsonOut)
39+ },
40+ }
41+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output JSON")
42+ return cmd
43+}
44+
45+func renderTrafficStatus(out io.Writer, status *dto.TrafficStatusResponse, jsonOut bool) error {
46+ if jsonOut {
47+ return writeJSON(out, status)
48+ }
49+ if err := cliWriteLine(out, cliRenderTitle("Traffic Status")); err != nil {
50+ return err
51+ }
52+ if err := cliWriteLine(out, cliRenderMeta("Reload", status.LastReloadStatus)); err != nil {
53+ return err
54+ }
55+ if status.LastReloadError != "" {
56+ if err := cliWriteLine(out, cliRenderWarning("Last reload error: "+status.LastReloadError)); err != nil {
57+ return err
58+ }
59+ }
60+ if err := cliWriteLine(out, ""); err != nil {
61+ return err
62+ }
63+ if err := cliWriteLine(out, "Entrypoints:"); err != nil {
64+ return err
65+ }
66+ if len(status.EntryPoints) == 0 {
67+ if err := cliWriteLine(out, cliRenderMuted(" none")); err != nil {
68+ return err
69+ }
70+ } else {
71+ for _, entry := range status.EntryPoints {
72+ if err := cliWritef(out, " %s %s %s active=%t tcp=%d udp=%d accepted=%d refused=%d errors=%d\n",
73+ entry.Name, entry.Protocol, entry.Address, entry.Active, entry.ActiveTCPConnections, entry.ActiveUDPSessions,
74+ entry.TotalAccepted, entry.TotalRefused, entry.TotalErrors); err != nil {
75+ return err
76+ }
77+ }
78+ }
79+ if err := renderTrafficRouters(out, status.Routers); err != nil {
80+ return err
81+ }
82+ if err := renderTrafficServices(out, status.Services); err != nil {
83+ return err
84+ }
85+ if err := cliWriteLine(out, ""); err != nil {
86+ return err
87+ }
88+ return cliWritef(out, "Totals: tcp=%d udp=%d accepted=%d refused=%d errors=%d bytes_in=%d bytes_out=%d\n",
89+ status.Counters.ActiveTCPConnections, status.Counters.ActiveUDPSessions, status.Counters.TotalAccepted,
90+ status.Counters.TotalRefused, status.Counters.TotalErrors, status.Counters.BytesIn, status.Counters.BytesOut)
91+}
92+
93+func renderTrafficRouters(out io.Writer, routers []dto.TrafficRouterStatus) error {
94+ if err := cliWriteLine(out, ""); err != nil {
95+ return err
96+ }
97+ if err := cliWriteLine(out, "Routers:"); err != nil {
98+ return err
99+ }
100+ if len(routers) == 0 {
101+ return cliWriteLine(out, cliRenderMuted(" none"))
102+ }
103+ for _, router := range routers {
104+ rule := router.Rule.Host
105+ if rule == "" {
106+ rule = router.Rule.SNI
107+ }
108+ if err := cliWritef(out, " %s %s entrypoint=%s rule=%s service=%s active=%t accepted=%d refused=%d errors=%d\n",
109+ router.Name, router.Protocol, router.EntryPoint, rule, router.Service, router.Active,
110+ router.TotalAccepted, router.TotalRefused, router.TotalErrors); err != nil {
111+ return err
112+ }
113+ }
114+ return nil
115+}
116+
117+func renderTrafficServices(out io.Writer, services []dto.TrafficServiceStatus) error {
118+ if err := cliWriteLine(out, ""); err != nil {
119+ return err
120+ }
121+ if err := cliWriteLine(out, "Services:"); err != nil {
122+ return err
123+ }
124+ if len(services) == 0 {
125+ return cliWriteLine(out, cliRenderMuted(" none"))
126+ }
127+ for _, service := range services {
128+ if err := cliWritef(out, " %s active=%t\n", service.Name, service.Active); err != nil {
129+ return err
130+ }
131+ for _, backend := range service.Backends {
132+ if err := cliWritef(out, " %s %s://%s:%d active=%t accepted=%d refused=%d errors=%d\n",
133+ backend.Name, backend.Protocol, backend.Host, backend.Port, backend.Active,
134+ backend.TotalAccepted, backend.TotalRefused, backend.TotalErrors); err != nil {
135+ return err
136+ }
137+ }
138+ }
139+ return nil
140+}
A internal/adapters/in/cli/traffic_test.go +65 -0
@@ -0,0 +1,65 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/adapters/dto"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+func TestTrafficCommandExists(t *testing.T) {
16+ cmd := NewRootCmd()
17+ traffic, _, err := cmd.Find([]string{"traffic"})
18+ require.NoError(t, err)
19+ require.NotNil(t, traffic)
20+ assert.Equal(t, "traffic", traffic.Name())
21+
22+ status, _, err := cmd.Find([]string{"traffic", "status"})
23+ require.NoError(t, err)
24+ require.NotNil(t, status)
25+ assert.Equal(t, "status", status.Name())
26+}
27+
28+func TestTrafficStatusHumanOutput(t *testing.T) {
29+ status := &dto.TrafficStatusResponse{
30+ LastReloadStatus: "ok",
31+ EntryPoints: []dto.TrafficEntryPointStatus{{
32+ Name: "postgres", Address: "127.0.0.1:5432", Protocol: domain.EntryPointProtocolTCP,
33+ Active: true, ActiveTCPConnections: 1, TotalAccepted: 2, BytesIn: 10, BytesOut: 20,
34+ }},
35+ Routers: []dto.TrafficRouterStatus{{
36+ Name: "pg-router", EntryPoint: "postgres", Protocol: domain.RouterProtocolTCP,
37+ Service: "network_service:postgres:db", Active: true, TotalAccepted: 2,
38+ }},
39+ Services: []dto.TrafficServiceStatus{{
40+ Name: "network_service:postgres:db", Active: true,
41+ Backends: []dto.TrafficBackendStatus{{Name: "postgres:db", Host: "127.0.0.1", Port: 5432, Protocol: domain.NetworkProtocolTCP, Active: true}},
42+ }},
43+ Counters: dto.TrafficCounters{ActiveTCPConnections: 1, TotalAccepted: 2, BytesIn: 10, BytesOut: 20},
44+ }
45+ var buf bytes.Buffer
46+ require.NoError(t, renderTrafficStatus(&buf, status, false))
47+ output := buf.String()
48+ assert.Contains(t, output, "Traffic Status")
49+ assert.Contains(t, output, "postgres")
50+ assert.Contains(t, output, "127.0.0.1:5432")
51+ assert.Contains(t, output, "pg-router")
52+ assert.Contains(t, output, "network_service:postgres:db")
53+ assert.Contains(t, output, "tcp://127.0.0.1:5432")
54+ assert.Contains(t, output, "accepted=2")
55+}
56+
57+func TestTrafficStatusJSONOutput(t *testing.T) {
58+ status := &dto.TrafficStatusResponse{LastReloadStatus: "ok", Counters: dto.TrafficCounters{ActiveUDPSessions: 3}}
59+ var buf bytes.Buffer
60+ require.NoError(t, renderTrafficStatus(&buf, status, true))
61+ var got dto.TrafficStatusResponse
62+ require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
63+ assert.Equal(t, "ok", got.LastReloadStatus)
64+ assert.Equal(t, int64(3), got.Counters.ActiveUDPSessions)
65+}
@@ -0,0 +1,65 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/adapters/dto"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+func TestTrafficCommandExists(t *testing.T) {
16+ cmd := NewRootCmd()
17+ traffic, _, err := cmd.Find([]string{"traffic"})
18+ require.NoError(t, err)
19+ require.NotNil(t, traffic)
20+ assert.Equal(t, "traffic", traffic.Name())
21+
22+ status, _, err := cmd.Find([]string{"traffic", "status"})
23+ require.NoError(t, err)
24+ require.NotNil(t, status)
25+ assert.Equal(t, "status", status.Name())
26+}
27+
28+func TestTrafficStatusHumanOutput(t *testing.T) {
29+ status := &dto.TrafficStatusResponse{
30+ LastReloadStatus: "ok",
31+ EntryPoints: []dto.TrafficEntryPointStatus{{
32+ Name: "postgres", Address: "127.0.0.1:5432", Protocol: domain.EntryPointProtocolTCP,
33+ Active: true, ActiveTCPConnections: 1, TotalAccepted: 2, BytesIn: 10, BytesOut: 20,
34+ }},
35+ Routers: []dto.TrafficRouterStatus{{
36+ Name: "pg-router", EntryPoint: "postgres", Protocol: domain.RouterProtocolTCP,
37+ Service: "network_service:postgres:db", Active: true, TotalAccepted: 2,
38+ }},
39+ Services: []dto.TrafficServiceStatus{{
40+ Name: "network_service:postgres:db", Active: true,
41+ Backends: []dto.TrafficBackendStatus{{Name: "postgres:db", Host: "127.0.0.1", Port: 5432, Protocol: domain.NetworkProtocolTCP, Active: true}},
42+ }},
43+ Counters: dto.TrafficCounters{ActiveTCPConnections: 1, TotalAccepted: 2, BytesIn: 10, BytesOut: 20},
44+ }
45+ var buf bytes.Buffer
46+ require.NoError(t, renderTrafficStatus(&buf, status, false))
47+ output := buf.String()
48+ assert.Contains(t, output, "Traffic Status")
49+ assert.Contains(t, output, "postgres")
50+ assert.Contains(t, output, "127.0.0.1:5432")
51+ assert.Contains(t, output, "pg-router")
52+ assert.Contains(t, output, "network_service:postgres:db")
53+ assert.Contains(t, output, "tcp://127.0.0.1:5432")
54+ assert.Contains(t, output, "accepted=2")
55+}
56+
57+func TestTrafficStatusJSONOutput(t *testing.T) {
58+ status := &dto.TrafficStatusResponse{LastReloadStatus: "ok", Counters: dto.TrafficCounters{ActiveUDPSessions: 3}}
59+ var buf bytes.Buffer
60+ require.NoError(t, renderTrafficStatus(&buf, status, true))
61+ var got dto.TrafficStatusResponse
62+ require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
63+ assert.Equal(t, "ok", got.LastReloadStatus)
64+ assert.Equal(t, int64(3), got.Counters.ActiveUDPSessions)
65+}
M internal/adapters/in/http/admin/handler.go +8 -0
@@ -38,6 +38,10 @@ type reloadTrigger interface {
3838 Trigger(ctx context.Context) error
3939 }
4040
41+type trafficStatusService interface {
42+ Status() domain.TrafficStatus
43+}
44+
4145 // Handler implements the HTTP handler for the admin API.
4246 type Handler struct {
4347 configSvc in.ConfigService
@@ -54,6 +58,7 @@ type Handler struct {
5458 previewSvc previewService
5559 reloadTrigger reloadTrigger
5660 publicTLSSvc in.PublicTLSService
61+ trafficSvc trafficStatusService
5762 log zerowrap.Logger
5863 }
5964
@@ -202,6 +207,7 @@ type HandlerDeps struct {
202207 VolumeSvc in.VolumeService
203208 ReloadTrigger reloadTrigger
204209 PublicTLSSvc in.PublicTLSService
210+ TrafficSvc trafficStatusService
205211 }
206212
207213 // NewHandler creates a new admin HTTP handler.
@@ -221,6 +227,7 @@ func NewHandler(deps HandlerDeps) *Handler {
221227 previewSvc: deps.PreviewSvc,
222228 reloadTrigger: deps.ReloadTrigger,
223229 publicTLSSvc: deps.PublicTLSSvc,
230+ trafficSvc: deps.TrafficSvc,
224231 log: deps.Log,
225232 }
226233 }
@@ -274,6 +281,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
274281 "/attachments/orphans": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentOrphans(w, r) },
275282 "/attachments/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentPrune(w, r) },
276283 "/tls/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTLSStatus(w, r) },
284+ "/traffic/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTrafficStatus(w, r) },
277285 }
278286 if handler, ok := exactRoutes[path]; ok {
279287 return handler, true
@@ -38,6 +38,10 @@ type reloadTrigger interface {
38 Trigger(ctx context.Context) error38 Trigger(ctx context.Context) error
39 }39 }
40 40
41+type trafficStatusService interface {
42+ Status() domain.TrafficStatus
43+}
44+
41 // Handler implements the HTTP handler for the admin API.45 // Handler implements the HTTP handler for the admin API.
42 type Handler struct {46 type Handler struct {
43 configSvc in.ConfigService47 configSvc in.ConfigService
@@ -54,6 +58,7 @@ type Handler struct {
54 previewSvc previewService58 previewSvc previewService
55 reloadTrigger reloadTrigger59 reloadTrigger reloadTrigger
56 publicTLSSvc in.PublicTLSService60 publicTLSSvc in.PublicTLSService
61+ trafficSvc trafficStatusService
57 log zerowrap.Logger62 log zerowrap.Logger
58 }63 }
59 64
@@ -202,6 +207,7 @@ type HandlerDeps struct {
202 VolumeSvc in.VolumeService207 VolumeSvc in.VolumeService
203 ReloadTrigger reloadTrigger208 ReloadTrigger reloadTrigger
204 PublicTLSSvc in.PublicTLSService209 PublicTLSSvc in.PublicTLSService
210+ TrafficSvc trafficStatusService
205 }211 }
206 212
207 // NewHandler creates a new admin HTTP handler.213 // NewHandler creates a new admin HTTP handler.
@@ -221,6 +227,7 @@ func NewHandler(deps HandlerDeps) *Handler {
221 previewSvc: deps.PreviewSvc,227 previewSvc: deps.PreviewSvc,
222 reloadTrigger: deps.ReloadTrigger,228 reloadTrigger: deps.ReloadTrigger,
223 publicTLSSvc: deps.PublicTLSSvc,229 publicTLSSvc: deps.PublicTLSSvc,
230+ trafficSvc: deps.TrafficSvc,
224 log: deps.Log,231 log: deps.Log,
225 }232 }
226 }233 }
@@ -274,6 +281,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
274 "/attachments/orphans": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentOrphans(w, r) },281 "/attachments/orphans": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentOrphans(w, r) },
275 "/attachments/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentPrune(w, r) },282 "/attachments/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentPrune(w, r) },
276 "/tls/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTLSStatus(w, r) },283 "/tls/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTLSStatus(w, r) },
284+ "/traffic/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTrafficStatus(w, r) },
277 }285 }
278 if handler, ok := exactRoutes[path]; ok {286 if handler, ok := exactRoutes[path]; ok {
279 return handler, true287 return handler, true
A internal/adapters/in/http/admin/handler_traffic.go +25 -0
@@ -0,0 +1,25 @@
1+package admin
2+
3+import (
4+ "net/http"
5+
6+ "github.com/bnema/gordon/internal/adapters/dto"
7+ "github.com/bnema/gordon/internal/domain"
8+)
9+
10+func (h *Handler) handleTrafficStatus(w http.ResponseWriter, r *http.Request) {
11+ ctx := r.Context()
12+ if r.Method != http.MethodGet {
13+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
14+ return
15+ }
16+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
17+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
18+ return
19+ }
20+ if h.trafficSvc == nil {
21+ h.sendJSON(w, http.StatusOK, dto.TrafficStatusResponse{LastReloadStatus: "unavailable"})
22+ return
23+ }
24+ h.sendJSON(w, http.StatusOK, dto.TrafficStatusFromDomain(h.trafficSvc.Status()))
25+}
@@ -0,0 +1,25 @@
1+package admin
2+
3+import (
4+ "net/http"
5+
6+ "github.com/bnema/gordon/internal/adapters/dto"
7+ "github.com/bnema/gordon/internal/domain"
8+)
9+
10+func (h *Handler) handleTrafficStatus(w http.ResponseWriter, r *http.Request) {
11+ ctx := r.Context()
12+ if r.Method != http.MethodGet {
13+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
14+ return
15+ }
16+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
17+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
18+ return
19+ }
20+ if h.trafficSvc == nil {
21+ h.sendJSON(w, http.StatusOK, dto.TrafficStatusResponse{LastReloadStatus: "unavailable"})
22+ return
23+ }
24+ h.sendJSON(w, http.StatusOK, dto.TrafficStatusFromDomain(h.trafficSvc.Status()))
25+}
A internal/adapters/in/http/admin/handler_traffic_test.go +54 -0
@@ -0,0 +1,54 @@
1+package admin
2+
3+import (
4+ "encoding/json"
5+ "net/http"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/adapters/dto"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+type fakeTrafficStatusService struct{ status domain.TrafficStatus }
16+
17+func (f fakeTrafficStatusService) Status() domain.TrafficStatus { return f.status }
18+
19+func TestHandler_TrafficStatus(t *testing.T) {
20+ handler := newTestHandler(t, func(d *HandlerDeps) {
21+ d.TrafficSvc = fakeTrafficStatusService{status: domain.TrafficStatus{
22+ LastReloadStatus: "ok",
23+ EntryPoints: []domain.EntryPointStatus{{
24+ Name: "postgres", Address: "127.0.0.1:5432", Protocol: domain.EntryPointProtocolTCP,
25+ Active: true, ActiveTCPConnections: 2, TotalAccepted: 3, BytesIn: 11, BytesOut: 22,
26+ }},
27+ Counters: domain.TrafficCounters{ActiveTCPConnections: 2, TotalAccepted: 3, BytesIn: 11, BytesOut: 22},
28+ }}
29+ })
30+ server := newScopedTestServer(t, handler, "admin:status:read")
31+
32+ resp, err := http.Get(server.URL + "/admin/traffic/status")
33+ require.NoError(t, err)
34+ defer resp.Body.Close()
35+ require.Equal(t, http.StatusOK, resp.StatusCode)
36+
37+ var body dto.TrafficStatusResponse
38+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
39+ assert.Equal(t, "ok", body.LastReloadStatus)
40+ require.Len(t, body.EntryPoints, 1)
41+ assert.Equal(t, "postgres", body.EntryPoints[0].Name)
42+ assert.Equal(t, int64(2), body.Counters.ActiveTCPConnections)
43+ assert.Equal(t, int64(22), body.Counters.BytesOut)
44+}
45+
46+func TestHandler_TrafficStatusRequiresStatusRead(t *testing.T) {
47+ handler := newTestHandler(t, func(d *HandlerDeps) { d.TrafficSvc = fakeTrafficStatusService{} })
48+ server := newScopedTestServer(t, handler, "admin:config:read")
49+
50+ resp, err := http.Get(server.URL + "/admin/traffic/status")
51+ require.NoError(t, err)
52+ defer resp.Body.Close()
53+ assert.Equal(t, http.StatusForbidden, resp.StatusCode)
54+}
@@ -0,0 +1,54 @@
1+package admin
2+
3+import (
4+ "encoding/json"
5+ "net/http"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/adapters/dto"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+type fakeTrafficStatusService struct{ status domain.TrafficStatus }
16+
17+func (f fakeTrafficStatusService) Status() domain.TrafficStatus { return f.status }
18+
19+func TestHandler_TrafficStatus(t *testing.T) {
20+ handler := newTestHandler(t, func(d *HandlerDeps) {
21+ d.TrafficSvc = fakeTrafficStatusService{status: domain.TrafficStatus{
22+ LastReloadStatus: "ok",
23+ EntryPoints: []domain.EntryPointStatus{{
24+ Name: "postgres", Address: "127.0.0.1:5432", Protocol: domain.EntryPointProtocolTCP,
25+ Active: true, ActiveTCPConnections: 2, TotalAccepted: 3, BytesIn: 11, BytesOut: 22,
26+ }},
27+ Counters: domain.TrafficCounters{ActiveTCPConnections: 2, TotalAccepted: 3, BytesIn: 11, BytesOut: 22},
28+ }}
29+ })
30+ server := newScopedTestServer(t, handler, "admin:status:read")
31+
32+ resp, err := http.Get(server.URL + "/admin/traffic/status")
33+ require.NoError(t, err)
34+ defer resp.Body.Close()
35+ require.Equal(t, http.StatusOK, resp.StatusCode)
36+
37+ var body dto.TrafficStatusResponse
38+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
39+ assert.Equal(t, "ok", body.LastReloadStatus)
40+ require.Len(t, body.EntryPoints, 1)
41+ assert.Equal(t, "postgres", body.EntryPoints[0].Name)
42+ assert.Equal(t, int64(2), body.Counters.ActiveTCPConnections)
43+ assert.Equal(t, int64(22), body.Counters.BytesOut)
44+}
45+
46+func TestHandler_TrafficStatusRequiresStatusRead(t *testing.T) {
47+ handler := newTestHandler(t, func(d *HandlerDeps) { d.TrafficSvc = fakeTrafficStatusService{} })
48+ server := newScopedTestServer(t, handler, "admin:config:read")
49+
50+ resp, err := http.Get(server.URL + "/admin/traffic/status")
51+ require.NoError(t, err)
52+ defer resp.Body.Close()
53+ assert.Equal(t, http.StatusForbidden, resp.StatusCode)
54+}
M internal/adapters/in/traffic/manager.go +2 -2
@@ -285,7 +285,7 @@ func entryPointStatuses(entries []domain.EntryPoint, listeners map[string]*entry
285285 func routerStatuses(routers []domain.TrafficRouter, listeners map[string]*entryPointRuntime, udpListeners map[string]*udpEntryPointRuntime) []domain.TrafficRouterStatus {
286286 statuses := make([]domain.TrafficRouterStatus, 0, len(routers))
287287 for _, router := range routers {
288- status := domain.TrafficRouterStatus{Name: router.Name, EntryPoint: router.EntryPoint, Protocol: router.Protocol, Active: true}
288+ status := domain.TrafficRouterStatus{Name: router.Name, EntryPoint: router.EntryPoint, Protocol: router.Protocol, Rule: router.Rule, Service: router.Service, Active: true}
289289 if runtime := listeners[router.EntryPoint]; runtime != nil {
290290 counters := runtime.counters.snapshot()
291291 status.ActiveTCPConnections = counters.ActiveTCPConnections
@@ -315,7 +315,7 @@ func serviceStatuses(services []domain.TrafficService) []domain.TrafficServiceSt
315315 for _, service := range services {
316316 status := domain.TrafficServiceStatus{Name: service.Name, Active: true, Backends: make([]domain.TrafficBackendStatus, 0, len(service.Backends))}
317317 for _, backend := range service.Backends {
318- status.Backends = append(status.Backends, domain.TrafficBackendStatus{Name: backend.Name, Active: true})
318+ status.Backends = append(status.Backends, domain.TrafficBackendStatus{Name: backend.Name, Host: backend.Host, Port: backend.Port, Protocol: backend.Protocol, Active: true})
319319 }
320320 statuses = append(statuses, status)
321321 }
@@ -285,7 +285,7 @@ func entryPointStatuses(entries []domain.EntryPoint, listeners map[string]*entry
285 func routerStatuses(routers []domain.TrafficRouter, listeners map[string]*entryPointRuntime, udpListeners map[string]*udpEntryPointRuntime) []domain.TrafficRouterStatus {285 func routerStatuses(routers []domain.TrafficRouter, listeners map[string]*entryPointRuntime, udpListeners map[string]*udpEntryPointRuntime) []domain.TrafficRouterStatus {
286 statuses := make([]domain.TrafficRouterStatus, 0, len(routers))286 statuses := make([]domain.TrafficRouterStatus, 0, len(routers))
287 for _, router := range routers {287 for _, router := range routers {
288- status := domain.TrafficRouterStatus{Name: router.Name, EntryPoint: router.EntryPoint, Protocol: router.Protocol, Active: true}288+ status := domain.TrafficRouterStatus{Name: router.Name, EntryPoint: router.EntryPoint, Protocol: router.Protocol, Rule: router.Rule, Service: router.Service, Active: true}
289 if runtime := listeners[router.EntryPoint]; runtime != nil {289 if runtime := listeners[router.EntryPoint]; runtime != nil {
290 counters := runtime.counters.snapshot()290 counters := runtime.counters.snapshot()
291 status.ActiveTCPConnections = counters.ActiveTCPConnections291 status.ActiveTCPConnections = counters.ActiveTCPConnections
@@ -315,7 +315,7 @@ func serviceStatuses(services []domain.TrafficService) []domain.TrafficServiceSt
315 for _, service := range services {315 for _, service := range services {
316 status := domain.TrafficServiceStatus{Name: service.Name, Active: true, Backends: make([]domain.TrafficBackendStatus, 0, len(service.Backends))}316 status := domain.TrafficServiceStatus{Name: service.Name, Active: true, Backends: make([]domain.TrafficBackendStatus, 0, len(service.Backends))}
317 for _, backend := range service.Backends {317 for _, backend := range service.Backends {
318- status.Backends = append(status.Backends, domain.TrafficBackendStatus{Name: backend.Name, Active: true})318+ status.Backends = append(status.Backends, domain.TrafficBackendStatus{Name: backend.Name, Host: backend.Host, Port: backend.Port, Protocol: backend.Protocol, Active: true})
319 }319 }
320 statuses = append(statuses, status)320 statuses = append(statuses, status)
321 }321 }
M internal/app/run.go +29 -3
@@ -57,6 +57,7 @@ import (
5757 "github.com/bnema/gordon/internal/adapters/in/http/onboarding"
5858 proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy"
5959 "github.com/bnema/gordon/internal/adapters/in/http/registry"
60+ trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
6061
6162 // Boundaries
6263 "github.com/bnema/gordon/internal/boundaries/in"
@@ -296,6 +297,7 @@ type services struct {
296297 reloadCoordinator *reloadCoordinator
297298 publicTLSSvc in.PublicTLSService
298299 publicTLSRuntime publicTLSRuntime
300+ trafficManager *trafficadapter.Manager
299301 registryHandler interface {
300302 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
301303 }
@@ -558,7 +560,7 @@ func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config,
558560 return nil, err
559561 }
560562
561- if err := si.initRuntimeAndProxy(); err != nil {
563+ if err := si.initRuntimeProxyAndTraffic(); err != nil {
562564 return nil, err
563565 }
564566
@@ -710,6 +712,17 @@ func (si *serviceInit) initSecrets() error {
710712 }
711713
712714 // initRuntimeAndProxy creates container, backup, registry, image, volume, and proxy services.
715+func (si *serviceInit) initRuntimeProxyAndTraffic() error {
716+ if err := si.initRuntimeAndProxy(); err != nil {
717+ return err
718+ }
719+ si.svc.trafficManager = trafficadapter.NewManager(si.log)
720+ if err := applyTrafficRuntimeConfig(si.ctx, si.svc.trafficManager, si.cfg, si.svc.configSvc); err != nil {
721+ return si.log.WrapErr(err, "failed to apply traffic configuration")
722+ }
723+ return nil
724+}
725+
713726 func (si *serviceInit) initRuntimeAndProxy() error {
714727 var err error
715728
@@ -756,6 +769,9 @@ func (si *serviceInit) registerReloadCoordinatorHooks() {
756769 if err != nil {
757770 return err
758771 }
772+ if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil {
773+ return err
774+ }
759775 si.svc.containerSvc.UpdateConfig(containerCfg)
760776 return nil
761777 })
@@ -776,6 +792,9 @@ func (si *serviceInit) initHandlers() {
776792 si.svc.logSvc = logs.NewService(resolveLogFilePath(si.cfg), si.cfg.Logging.File.Enabled, si.svc.containerSvc, si.svc.runtime, si.log)
777793
778794 initPreviewService(si.ctx, si.cfg, si.svc, si.log)
795+ if si.svc.trafficManager == nil {
796+ si.svc.trafficManager = trafficadapter.NewManager(si.log)
797+ }
779798
780799 si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{
781800 ConfigSvc: si.svc.configSvc,
@@ -793,6 +812,7 @@ func (si *serviceInit) initHandlers() {
793812 ImageSvc: si.svc.imageSvc,
794813 VolumeSvc: si.svc.volumeSvc,
795814 PublicTLSSvc: si.svc.publicTLSSvc,
815+ TrafficSvc: si.svc.trafficManager,
796816 })
797817 }
798818
@@ -2849,7 +2869,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
28492869
28502870 waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log)
28512871 cleanupHandlers() // Stop debounce timers before draining containers
2852- gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, log)
2872+ gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, log)
28532873 return nil
28542874 }
28552875
@@ -3101,7 +3121,7 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl
31013121
31023122 // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
31033123 // the container service and cleans up runtime files.
3104-func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, log zerowrap.Logger) {
3124+func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, trafficManager *trafficadapter.Manager, log zerowrap.Logger) {
31053125 log.Info().Msg("shutting down Gordon...")
31063126
31073127 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -3117,6 +3137,12 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *
31173137 }
31183138 }
31193139
3140+ if trafficManager != nil {
3141+ if err := trafficManager.Shutdown(shutdownCtx); err != nil {
3142+ log.Warn().Err(err).Msg("traffic manager shutdown error")
3143+ }
3144+ }
3145+
31203146 // Stop PKI maintenance goroutines
31213147 if pkiSvc != nil {
31223148 pkiSvc.Stop()
@@ -57,6 +57,7 @@ import (
57 "github.com/bnema/gordon/internal/adapters/in/http/onboarding"57 "github.com/bnema/gordon/internal/adapters/in/http/onboarding"
58 proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy"58 proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy"
59 "github.com/bnema/gordon/internal/adapters/in/http/registry"59 "github.com/bnema/gordon/internal/adapters/in/http/registry"
60+ trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
60 61
61 // Boundaries62 // Boundaries
62 "github.com/bnema/gordon/internal/boundaries/in"63 "github.com/bnema/gordon/internal/boundaries/in"
@@ -296,6 +297,7 @@ type services struct {
296 reloadCoordinator *reloadCoordinator297 reloadCoordinator *reloadCoordinator
297 publicTLSSvc in.PublicTLSService298 publicTLSSvc in.PublicTLSService
298 publicTLSRuntime publicTLSRuntime299 publicTLSRuntime publicTLSRuntime
300+ trafficManager *trafficadapter.Manager
299 registryHandler interface {301 registryHandler interface {
300 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)302 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
301 }303 }
@@ -558,7 +560,7 @@ func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config,
558 return nil, err560 return nil, err
559 }561 }
560 562
561- if err := si.initRuntimeAndProxy(); err != nil {563+ if err := si.initRuntimeProxyAndTraffic(); err != nil {
562 return nil, err564 return nil, err
563 }565 }
564 566
@@ -710,6 +712,17 @@ func (si *serviceInit) initSecrets() error {
710 }712 }
711 713
712 // initRuntimeAndProxy creates container, backup, registry, image, volume, and proxy services.714 // initRuntimeAndProxy creates container, backup, registry, image, volume, and proxy services.
715+func (si *serviceInit) initRuntimeProxyAndTraffic() error {
716+ if err := si.initRuntimeAndProxy(); err != nil {
717+ return err
718+ }
719+ si.svc.trafficManager = trafficadapter.NewManager(si.log)
720+ if err := applyTrafficRuntimeConfig(si.ctx, si.svc.trafficManager, si.cfg, si.svc.configSvc); err != nil {
721+ return si.log.WrapErr(err, "failed to apply traffic configuration")
722+ }
723+ return nil
724+}
725+
713 func (si *serviceInit) initRuntimeAndProxy() error {726 func (si *serviceInit) initRuntimeAndProxy() error {
714 var err error727 var err error
715 728
@@ -756,6 +769,9 @@ func (si *serviceInit) registerReloadCoordinatorHooks() {
756 if err != nil {769 if err != nil {
757 return err770 return err
758 }771 }
772+ if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil {
773+ return err
774+ }
759 si.svc.containerSvc.UpdateConfig(containerCfg)775 si.svc.containerSvc.UpdateConfig(containerCfg)
760 return nil776 return nil
761 })777 })
@@ -776,6 +792,9 @@ func (si *serviceInit) initHandlers() {
776 si.svc.logSvc = logs.NewService(resolveLogFilePath(si.cfg), si.cfg.Logging.File.Enabled, si.svc.containerSvc, si.svc.runtime, si.log)792 si.svc.logSvc = logs.NewService(resolveLogFilePath(si.cfg), si.cfg.Logging.File.Enabled, si.svc.containerSvc, si.svc.runtime, si.log)
777 793
778 initPreviewService(si.ctx, si.cfg, si.svc, si.log)794 initPreviewService(si.ctx, si.cfg, si.svc, si.log)
795+ if si.svc.trafficManager == nil {
796+ si.svc.trafficManager = trafficadapter.NewManager(si.log)
797+ }
779 798
780 si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{799 si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{
781 ConfigSvc: si.svc.configSvc,800 ConfigSvc: si.svc.configSvc,
@@ -793,6 +812,7 @@ func (si *serviceInit) initHandlers() {
793 ImageSvc: si.svc.imageSvc,812 ImageSvc: si.svc.imageSvc,
794 VolumeSvc: si.svc.volumeSvc,813 VolumeSvc: si.svc.volumeSvc,
795 PublicTLSSvc: si.svc.publicTLSSvc,814 PublicTLSSvc: si.svc.publicTLSSvc,
815+ TrafficSvc: si.svc.trafficManager,
796 })816 })
797 }817 }
798 818
@@ -2849,7 +2869,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
2849 2869
2850 waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log)2870 waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log)
2851 cleanupHandlers() // Stop debounce timers before draining containers2871 cleanupHandlers() // Stop debounce timers before draining containers
2852- gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, log)2872+ gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, log)
2853 return nil2873 return nil
2854 }2874 }
2855 2875
@@ -3101,7 +3121,7 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl
3101 3121
3102 // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down3122 // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
3103 // the container service and cleans up runtime files.3123 // the container service and cleans up runtime files.
3104-func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, log zerowrap.Logger) {3124+func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, trafficManager *trafficadapter.Manager, log zerowrap.Logger) {
3105 log.Info().Msg("shutting down Gordon...")3125 log.Info().Msg("shutting down Gordon...")
3106 3126
3107 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)3127 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -3117,6 +3137,12 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *
3117 }3137 }
3118 }3138 }
3119 3139
3140+ if trafficManager != nil {
3141+ if err := trafficManager.Shutdown(shutdownCtx); err != nil {
3142+ log.Warn().Err(err).Msg("traffic manager shutdown error")
3143+ }
3144+ }
3145+
3120 // Stop PKI maintenance goroutines3146 // Stop PKI maintenance goroutines
3121 if pkiSvc != nil {3147 if pkiSvc != nil {
3122 pkiSvc.Stop()3148 pkiSvc.Stop()
A internal/app/traffic.go +87 -0
@@ -0,0 +1,87 @@
1+package app
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
8+ "github.com/bnema/gordon/internal/domain"
9+ trafficbuilder "github.com/bnema/gordon/internal/usecase/traffic"
10+)
11+
12+func applyTrafficRuntimeConfig(ctx context.Context, manager *trafficadapter.Manager, cfg Config, configSvc interface {
13+ GetRoutes(context.Context) []domain.Route
14+ GetExternalRoutes() map[string]string
15+}) error {
16+ if manager == nil || configSvc == nil {
17+ return nil
18+ }
19+ graph, err := trafficbuilder.Build(trafficbuilder.Input{
20+ Server: trafficbuilder.ServerConfig{
21+ Port: cfg.Server.Port,
22+ TLSPort: cfg.Server.TLSPort,
23+ },
24+ EntryPoints: cfg.EntryPoints,
25+ Traffic: cfg.Traffic,
26+ Routes: configSvc.GetRoutes(ctx),
27+ ExternalRoutes: configSvc.GetExternalRoutes(),
28+ NetworkServices: cfg.NetworkServices,
29+ })
30+ if err != nil {
31+ return err
32+ }
33+ owned, err := trafficRuntimeGraph(graph)
34+ if err != nil {
35+ return err
36+ }
37+ return manager.Apply(ctx, &owned)
38+}
39+
40+func trafficRuntimeGraph(graph domain.TrafficGraph) (domain.TrafficGraph, error) {
41+ ownedEntryPoints := map[string]struct{}{}
42+ filtered := domain.TrafficGraph{Options: graph.Options}
43+ for _, entryPoint := range graph.EntryPoints {
44+ if trafficManagerOwnsEntryPoint(entryPoint) {
45+ filtered.EntryPoints = append(filtered.EntryPoints, entryPoint)
46+ ownedEntryPoints[entryPoint.Name] = struct{}{}
47+ }
48+ }
49+
50+ usedServices := map[string]struct{}{}
51+ for _, router := range graph.Routers {
52+ if _, ok := ownedEntryPoints[router.EntryPoint]; !ok {
53+ if trafficManagerRouterProtocol(router.Protocol) {
54+ return domain.TrafficGraph{}, fmt.Errorf("traffic router %q targets entrypoint %q, which is not owned by the traffic manager", router.Name, router.EntryPoint)
55+ }
56+ continue
57+ }
58+ filtered.Routers = append(filtered.Routers, router)
59+ usedServices[router.Service] = struct{}{}
60+ }
61+ for _, service := range graph.Services {
62+ if _, ok := usedServices[service.Name]; ok {
63+ filtered.Services = append(filtered.Services, service)
64+ }
65+ }
66+ return filtered, nil
67+}
68+
69+func trafficManagerRouterProtocol(protocol domain.RouterProtocol) bool {
70+ switch protocol {
71+ case domain.RouterProtocolTCP, domain.RouterProtocolUDP, domain.RouterProtocolTLSPassthrough:
72+ return true
73+ default:
74+ return false
75+ }
76+}
77+
78+func trafficManagerOwnsEntryPoint(entryPoint domain.EntryPoint) bool {
79+ switch entryPoint.Protocol {
80+ case domain.EntryPointProtocolTCP, domain.EntryPointProtocolUDP:
81+ return true
82+ case domain.EntryPointProtocolTLSMux:
83+ return entryPoint.Name != trafficbuilder.DefaultTLSEntryPointName
84+ default:
85+ return false
86+ }
87+}
@@ -0,0 +1,87 @@
1+package app
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
8+ "github.com/bnema/gordon/internal/domain"
9+ trafficbuilder "github.com/bnema/gordon/internal/usecase/traffic"
10+)
11+
12+func applyTrafficRuntimeConfig(ctx context.Context, manager *trafficadapter.Manager, cfg Config, configSvc interface {
13+ GetRoutes(context.Context) []domain.Route
14+ GetExternalRoutes() map[string]string
15+}) error {
16+ if manager == nil || configSvc == nil {
17+ return nil
18+ }
19+ graph, err := trafficbuilder.Build(trafficbuilder.Input{
20+ Server: trafficbuilder.ServerConfig{
21+ Port: cfg.Server.Port,
22+ TLSPort: cfg.Server.TLSPort,
23+ },
24+ EntryPoints: cfg.EntryPoints,
25+ Traffic: cfg.Traffic,
26+ Routes: configSvc.GetRoutes(ctx),
27+ ExternalRoutes: configSvc.GetExternalRoutes(),
28+ NetworkServices: cfg.NetworkServices,
29+ })
30+ if err != nil {
31+ return err
32+ }
33+ owned, err := trafficRuntimeGraph(graph)
34+ if err != nil {
35+ return err
36+ }
37+ return manager.Apply(ctx, &owned)
38+}
39+
40+func trafficRuntimeGraph(graph domain.TrafficGraph) (domain.TrafficGraph, error) {
41+ ownedEntryPoints := map[string]struct{}{}
42+ filtered := domain.TrafficGraph{Options: graph.Options}
43+ for _, entryPoint := range graph.EntryPoints {
44+ if trafficManagerOwnsEntryPoint(entryPoint) {
45+ filtered.EntryPoints = append(filtered.EntryPoints, entryPoint)
46+ ownedEntryPoints[entryPoint.Name] = struct{}{}
47+ }
48+ }
49+
50+ usedServices := map[string]struct{}{}
51+ for _, router := range graph.Routers {
52+ if _, ok := ownedEntryPoints[router.EntryPoint]; !ok {
53+ if trafficManagerRouterProtocol(router.Protocol) {
54+ return domain.TrafficGraph{}, fmt.Errorf("traffic router %q targets entrypoint %q, which is not owned by the traffic manager", router.Name, router.EntryPoint)
55+ }
56+ continue
57+ }
58+ filtered.Routers = append(filtered.Routers, router)
59+ usedServices[router.Service] = struct{}{}
60+ }
61+ for _, service := range graph.Services {
62+ if _, ok := usedServices[service.Name]; ok {
63+ filtered.Services = append(filtered.Services, service)
64+ }
65+ }
66+ return filtered, nil
67+}
68+
69+func trafficManagerRouterProtocol(protocol domain.RouterProtocol) bool {
70+ switch protocol {
71+ case domain.RouterProtocolTCP, domain.RouterProtocolUDP, domain.RouterProtocolTLSPassthrough:
72+ return true
73+ default:
74+ return false
75+ }
76+}
77+
78+func trafficManagerOwnsEntryPoint(entryPoint domain.EntryPoint) bool {
79+ switch entryPoint.Protocol {
80+ case domain.EntryPointProtocolTCP, domain.EntryPointProtocolUDP:
81+ return true
82+ case domain.EntryPointProtocolTLSMux:
83+ return entryPoint.Name != trafficbuilder.DefaultTLSEntryPointName
84+ default:
85+ return false
86+ }
87+}
A internal/app/traffic_test.go +88 -0
@@ -0,0 +1,88 @@
1+package app
2+
3+import (
4+ "context"
5+ "net"
6+ "testing"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
13+ "github.com/bnema/gordon/internal/domain"
14+ "github.com/bnema/gordon/internal/usecase/traffic"
15+)
16+
17+func TestTrafficRuntimeGraphFiltersLegacyHTTPEntrypoints(t *testing.T) {
18+ graph := domain.TrafficGraph{
19+ EntryPoints: []domain.EntryPoint{
20+ {Name: traffic.DefaultHTTPEntryPointName, Address: "127.0.0.1:8080", Protocol: domain.EntryPointProtocolHTTP},
21+ {Name: traffic.DefaultTLSEntryPointName, Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolTLSMux},
22+ {Name: "postgres", Address: "127.0.0.1:15432", Protocol: domain.EntryPointProtocolTCP},
23+ },
24+ Routers: []domain.TrafficRouter{
25+ {Name: "app", EntryPoint: traffic.DefaultHTTPEntryPointName, Protocol: domain.RouterProtocolHTTP, Service: "route:app.example.com"},
26+ {Name: "postgres", EntryPoint: "postgres", Protocol: domain.RouterProtocolTCP, Service: "network_service:postgres:db"},
27+ },
28+ Services: []domain.TrafficService{
29+ {Name: "route:app.example.com"},
30+ {Name: "network_service:postgres:db", Backends: []domain.TrafficBackend{{Name: "db", Host: "127.0.0.1", Port: 5432, Protocol: domain.NetworkProtocolTCP}}},
31+ },
32+ }
33+
34+ filtered, err := trafficRuntimeGraph(graph)
35+ require.NoError(t, err)
36+ require.Len(t, filtered.EntryPoints, 1)
37+ assert.Equal(t, "postgres", filtered.EntryPoints[0].Name)
38+ require.Len(t, filtered.Routers, 1)
39+ assert.Equal(t, "postgres", filtered.Routers[0].Name)
40+ require.Len(t, filtered.Services, 1)
41+ assert.Equal(t, "network_service:postgres:db", filtered.Services[0].Name)
42+}
43+
44+func TestTrafficRuntimeGraphRejectsL4RouterOnLegacyEntrypoint(t *testing.T) {
45+ graph := domain.TrafficGraph{
46+ EntryPoints: []domain.EntryPoint{{Name: traffic.DefaultTLSEntryPointName, Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolTLSMux}},
47+ Routers: []domain.TrafficRouter{{Name: "raw", EntryPoint: traffic.DefaultTLSEntryPointName, Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: "raw.example.com"}, Service: "network_service:raw:tls"}},
48+ Services: []domain.TrafficService{{Name: "network_service:raw:tls", Backends: []domain.TrafficBackend{{Name: "raw:tls", Host: "127.0.0.1", Port: 443, Protocol: domain.NetworkProtocolTCP}}}},
49+ }
50+
51+ _, err := trafficRuntimeGraph(graph)
52+ require.ErrorContains(t, err, "not owned by the traffic manager")
53+}
54+
55+func TestApplyTrafficRuntimeConfigAppliesCustomL4Entrypoint(t *testing.T) {
56+ manager := trafficadapter.NewManager(zerowrap.Default())
57+ defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
58+ cfg := Config{
59+ EntryPoints: map[string]traffic.EntryPointConfig{
60+ "postgres": {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTCP},
61+ },
62+ NetworkServices: []traffic.NetworkServiceConfig{{
63+ Name: "postgres",
64+ Ports: []traffic.PortConfig{{Name: "db", Container: 5432, Protocol: domain.NetworkProtocolTCP}},
65+ }},
66+ }
67+ cfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "postgres", EntryPoint: "postgres", Service: "network_service:postgres:db"}}
68+
69+ require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, fakeTrafficConfigService{}))
70+ status := manager.Status()
71+ require.Len(t, status.EntryPoints, 1)
72+ assert.Equal(t, "postgres", status.EntryPoints[0].Name)
73+ assert.True(t, status.EntryPoints[0].Active)
74+}
75+
76+type fakeTrafficConfigService struct{}
77+
78+func (fakeTrafficConfigService) GetRoutes(context.Context) []domain.Route { return nil }
79+func (fakeTrafficConfigService) GetExternalRoutes() map[string]string { return nil }
80+
81+func freeTCPAddress(t *testing.T) string {
82+ t.Helper()
83+ listener, err := net.Listen("tcp", "127.0.0.1:0")
84+ require.NoError(t, err)
85+ address := listener.Addr().String()
86+ require.NoError(t, listener.Close())
87+ return address
88+}
@@ -0,0 +1,88 @@
1+package app
2+
3+import (
4+ "context"
5+ "net"
6+ "testing"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
13+ "github.com/bnema/gordon/internal/domain"
14+ "github.com/bnema/gordon/internal/usecase/traffic"
15+)
16+
17+func TestTrafficRuntimeGraphFiltersLegacyHTTPEntrypoints(t *testing.T) {
18+ graph := domain.TrafficGraph{
19+ EntryPoints: []domain.EntryPoint{
20+ {Name: traffic.DefaultHTTPEntryPointName, Address: "127.0.0.1:8080", Protocol: domain.EntryPointProtocolHTTP},
21+ {Name: traffic.DefaultTLSEntryPointName, Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolTLSMux},
22+ {Name: "postgres", Address: "127.0.0.1:15432", Protocol: domain.EntryPointProtocolTCP},
23+ },
24+ Routers: []domain.TrafficRouter{
25+ {Name: "app", EntryPoint: traffic.DefaultHTTPEntryPointName, Protocol: domain.RouterProtocolHTTP, Service: "route:app.example.com"},
26+ {Name: "postgres", EntryPoint: "postgres", Protocol: domain.RouterProtocolTCP, Service: "network_service:postgres:db"},
27+ },
28+ Services: []domain.TrafficService{
29+ {Name: "route:app.example.com"},
30+ {Name: "network_service:postgres:db", Backends: []domain.TrafficBackend{{Name: "db", Host: "127.0.0.1", Port: 5432, Protocol: domain.NetworkProtocolTCP}}},
31+ },
32+ }
33+
34+ filtered, err := trafficRuntimeGraph(graph)
35+ require.NoError(t, err)
36+ require.Len(t, filtered.EntryPoints, 1)
37+ assert.Equal(t, "postgres", filtered.EntryPoints[0].Name)
38+ require.Len(t, filtered.Routers, 1)
39+ assert.Equal(t, "postgres", filtered.Routers[0].Name)
40+ require.Len(t, filtered.Services, 1)
41+ assert.Equal(t, "network_service:postgres:db", filtered.Services[0].Name)
42+}
43+
44+func TestTrafficRuntimeGraphRejectsL4RouterOnLegacyEntrypoint(t *testing.T) {
45+ graph := domain.TrafficGraph{
46+ EntryPoints: []domain.EntryPoint{{Name: traffic.DefaultTLSEntryPointName, Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolTLSMux}},
47+ Routers: []domain.TrafficRouter{{Name: "raw", EntryPoint: traffic.DefaultTLSEntryPointName, Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: "raw.example.com"}, Service: "network_service:raw:tls"}},
48+ Services: []domain.TrafficService{{Name: "network_service:raw:tls", Backends: []domain.TrafficBackend{{Name: "raw:tls", Host: "127.0.0.1", Port: 443, Protocol: domain.NetworkProtocolTCP}}}},
49+ }
50+
51+ _, err := trafficRuntimeGraph(graph)
52+ require.ErrorContains(t, err, "not owned by the traffic manager")
53+}
54+
55+func TestApplyTrafficRuntimeConfigAppliesCustomL4Entrypoint(t *testing.T) {
56+ manager := trafficadapter.NewManager(zerowrap.Default())
57+ defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
58+ cfg := Config{
59+ EntryPoints: map[string]traffic.EntryPointConfig{
60+ "postgres": {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTCP},
61+ },
62+ NetworkServices: []traffic.NetworkServiceConfig{{
63+ Name: "postgres",
64+ Ports: []traffic.PortConfig{{Name: "db", Container: 5432, Protocol: domain.NetworkProtocolTCP}},
65+ }},
66+ }
67+ cfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "postgres", EntryPoint: "postgres", Service: "network_service:postgres:db"}}
68+
69+ require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, fakeTrafficConfigService{}))
70+ status := manager.Status()
71+ require.Len(t, status.EntryPoints, 1)
72+ assert.Equal(t, "postgres", status.EntryPoints[0].Name)
73+ assert.True(t, status.EntryPoints[0].Active)
74+}
75+
76+type fakeTrafficConfigService struct{}
77+
78+func (fakeTrafficConfigService) GetRoutes(context.Context) []domain.Route { return nil }
79+func (fakeTrafficConfigService) GetExternalRoutes() map[string]string { return nil }
80+
81+func freeTCPAddress(t *testing.T) string {
82+ t.Helper()
83+ listener, err := net.Listen("tcp", "127.0.0.1:0")
84+ require.NoError(t, err)
85+ address := listener.Addr().String()
86+ require.NoError(t, listener.Close())
87+ return address
88+}
M internal/domain/traffic.go +5 -0
@@ -117,6 +117,8 @@ type TrafficRouterStatus struct {
117117 Name string
118118 EntryPoint string
119119 Protocol RouterProtocol
120+ Rule TrafficRule
121+ Service string
120122 Active bool
121123 ActiveTCPConnections int64
122124 ActiveUDPSessions int64
@@ -135,6 +137,9 @@ type TrafficServiceStatus struct {
135137
136138 type TrafficBackendStatus struct {
137139 Name string
140+ Host string
141+ Port int
142+ Protocol NetworkProtocol
138143 Active bool
139144 ActiveTCPConnections int64
140145 ActiveUDPSessions int64
@@ -117,6 +117,8 @@ type TrafficRouterStatus struct {
117 Name string117 Name string
118 EntryPoint string118 EntryPoint string
119 Protocol RouterProtocol119 Protocol RouterProtocol
120+ Rule TrafficRule
121+ Service string
120 Active bool122 Active bool
121 ActiveTCPConnections int64123 ActiveTCPConnections int64
122 ActiveUDPSessions int64124 ActiveUDPSessions int64
@@ -135,6 +137,9 @@ type TrafficServiceStatus struct {
135 137
136 type TrafficBackendStatus struct {138 type TrafficBackendStatus struct {
137 Name string139 Name string
140+ Host string
141+ Port int
142+ Protocol NetworkProtocol
138 Active bool143 Active bool
139 ActiveTCPConnections int64144 ActiveTCPConnections int64
140 ActiveUDPSessions int64145 ActiveUDPSessions int64

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