detached c9bf406

Merge pull request #212 from bnema/feature/traffic-plane-l4

feat(traffic): add L4 traffic plane passthrough
commit
c9bf4066595071a1cdadbdf8b11c284301bbeebc
author
bnema · · 2026-06-30 18:02 UTC
committer
GitHub · · 2026-06-30 18:02 UTC
merge of
1db619a + 8a0e9d5 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
102 files · +12953 -542
A .codegraph/.gitignore +5 -0
@@ -0,0 +1,5 @@
1+# CodeGraph data files — local to each machine, not for committing.
2+# Ignore everything in .codegraph/ except this file itself, so transient
3+# files (the database, daemon.pid, sockets, logs) never show up in git.
4+*
5+!.gitignore
M .mockery.yaml +2 -0
@@ -45,6 +45,8 @@ packages:
4545 LogService:
4646 VolumeService:
4747 PublicTLSService:
48+ TrafficStatusService:
49+ StandaloneServiceService:
4850 # Exception: pushImageOps is a CLI-local interface, not a boundary port.
4951 # Mocked here because it abstracts Docker SDK calls that require a running
5052 # daemon, making unit/integration tests impractical without a test double.
M README.md +2 -2
@@ -127,7 +127,7 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf
127127 ## Features
128128
129129 - Private Docker registry on your VPS
130-- Domain-to-container routing via HTTP reverse proxy
130+- Domain-to-container routing through a smart TCP edge reverse proxy
131131 - Automatic deployment on image push
132132 - Auto-routing from image labels
133133 - Remote CLI management
@@ -138,7 +138,7 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf
138138 - Single binary, ~15MB RAM
139139
140140 > [!NOTE]
141-> Gordon can terminate TLS directly via its internal CA (default `server.tls_port = 8443`), static certificates, or public ACME certificates. Cloudflare and upstream reverse proxies are optional deployment choices, not requirements.
141+> Gordon exposes public traffic through entrypoints such as `[entrypoints.edge]` with `protocol = "smart_tcp"`. It can terminate TLS via static certificates, public ACME certificates, or its internal CA. Cloudflare and upstream reverse proxies are optional deployment choices, not requirements.
142142
143143 ## Documentation
144144
M docs/cli/ca.md +2 -2
@@ -12,7 +12,7 @@ Manage Gordon's internal Certificate Authority.
1212 | `info` | Show CA status (root CN, fingerprint, intermediate expiry) |
1313 | `install` | Install/uninstall the root CA in system trust stores |
1414
15-All subcommands require `server.tls_port` to be non-zero in the Gordon configuration.
15+All subcommands require Gordon to have TLS-capable HTTPS fallback configured on an entrypoint such as `entrypoints.edge` with `protocol = "smart_tcp"`.
1616
1717 ---
1818
@@ -120,5 +120,5 @@ Visit `http://<gordon-host>/.well-known/gordon/ca` from the device browser. The
120120
121121 ## Related
122122
123-- [Server Configuration](../config/server.md) — `tls_port`, `force_https_redirect`, and internal CA settings
123+- [Server Configuration](../config/server.md) — `force_https_redirect`, smart TCP edge, and internal CA settings
124124 - [CLI Commands](./index.md)
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
M docs/cli/remotes.md +1 -1
@@ -350,7 +350,7 @@ A common Tailscale setup is to point your domain's DNS at the machine's Tailscal
350350 - `insecure_tls = true` on the remote — skips certificate verification entirely
351351 - `sudo gordon ca install` — installs the root CA into system, Firefox, and Java trust stores
352352 - `gordon ca export --out gordon-ca.crt` — exports the root CA PEM for manual installation
353-- Visit `https://<gordon-host>:<tls_port>/ca` in a browser — onboarding page with downloads for macOS, Linux, Windows, iOS, and Android
353+- Visit `https://<gordon-host>/.well-known/gordon/ca` (or your mapped edge address) in a browser — onboarding page with downloads for macOS, Linux, Windows, iOS, and Android
354354
355355 If you have your own certificate for the domain (e.g. from a corporate CA), you can provide it via `tls_cert_file`/`tls_key_file` — see [Server Configuration](../config/server.md#custom-certificates). The static cert is served for SNI-matching domains; everything else falls through to the internal CA.
356356
M docs/cli/tls.md +8 -0
@@ -2,6 +2,14 @@
22
33 Inspect public TLS/ACME certificate status.
44
5+Gordon serves normal HTTPS fallback on TLS-capable entrypoints such as `entrypoints.edge` with `protocol = "smart_tcp"`. Certificate priority is static certificates first, then public ACME certificates, then Gordon's internal CA.
6+
7+ACME challenge notes:
8+
9+- DNS-01 (`cloudflare-dns-01`) does not require a special external port 80 edge.
10+- HTTP-01 requires an HTTP-capable smart TCP entrypoint reachable on external port 80 for every hostname being validated.
11+- TLS-ALPN-01 is not supported.
12+
513 Remote targeting uses client config or an active remote by default.
614 Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
715
A docs/cli/traffic.md +93 -0
@@ -0,0 +1,93 @@
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+Remote mode queries the running Gordon admin API. Local mode does not synthesize runtime traffic state from a fresh config load; use a configured remote target for authoritative counters and reload status.
19+
20+## Flags
21+
22+| Flag | Description |
23+|------|-------------|
24+| `--json` | Output machine-readable JSON |
25+| `--remote`, `-r` | Remote Gordon instance or saved remote name |
26+
27+## JSON Output
28+
29+```json
30+{
31+ "last_reload_status": "ok",
32+ "entrypoints": [
33+ {
34+ "name": "postgres",
35+ "address": "0.0.0.0:5432",
36+ "protocol": "tcp",
37+ "active": true,
38+ "active_tcp_connections": 1,
39+ "active_udp_sessions": 0,
40+ "total_accepted": 12,
41+ "total_refused": 0,
42+ "total_errors": 0,
43+ "bytes_in": 4096,
44+ "bytes_out": 8192,
45+ "smart_tcp": {
46+ "http_accepted": 0,
47+ "h2c_accepted": 0,
48+ "https_fallback_accepted": 0,
49+ "tls_passthrough_accepted": 0,
50+ "raw_fallback_accepted": 0,
51+ "entrypoint_cidr_refused": 0,
52+ "raw_fallback_cidr_refused": 0,
53+ "proxy_refused": 0,
54+ "unknown_no_fallback_refused": 0,
55+ "malformed_rejected": 0,
56+ "sniff_timeout": 0,
57+ "client_hello_too_large": 0
58+ }
59+ }
60+ ],
61+ "routers": [],
62+ "services": [],
63+ "counters": {
64+ "active_tcp_connections": 1,
65+ "active_udp_sessions": 0,
66+ "total_accepted": 12,
67+ "total_refused": 0,
68+ "total_errors": 0,
69+ "bytes_in": 4096,
70+ "bytes_out": 8192,
71+ "smart_tcp": {
72+ "http_accepted": 0,
73+ "h2c_accepted": 0,
74+ "https_fallback_accepted": 0,
75+ "tls_passthrough_accepted": 0,
76+ "raw_fallback_accepted": 0,
77+ "entrypoint_cidr_refused": 0,
78+ "raw_fallback_cidr_refused": 0,
79+ "proxy_refused": 0,
80+ "unknown_no_fallback_refused": 0,
81+ "malformed_rejected": 0,
82+ "sniff_timeout": 0,
83+ "client_hello_too_large": 0
84+ }
85+ }
86+}
87+```
88+
89+## Related
90+
91+- [Traffic configuration](../config/traffic.md)
92+- [Standalone services](../config/services.md)
93+- [Server status](./status.md)
M docs/config/index.md +12 -5
@@ -13,11 +13,13 @@ Gordon uses a single TOML configuration file located at `~/.config/gordon/gordon
1313
1414 ```toml
1515 [server]
16-port = 8088
17-tls_port = 8443
1816 registry_port = 5000
1917 gordon_domain = "gordon.mydomain.com"
2018
19+[entrypoints.edge]
20+address = ":443"
21+protocol = "smart_tcp"
22+
2123 [routes]
2224 "app.mydomain.com" = "myapp:latest"
2325 ```
@@ -35,11 +37,14 @@ For a complete list of all configuration options with their default values, see
3537 ```toml
3638 # Server settings
3739 [server]
38-port = 8088 # HTTP proxy port (default: 8088)
3940 registry_port = 5000 # Registry port (default: 5000)
4041 gordon_domain = "gordon.mydomain.com" # Required: Gordon domain (registry + API)
4142 # data_dir = "~/.gordon" # Default for user installations
4243
44+[entrypoints.edge]
45+address = ":443" # Deployment-selected public TCP socket
46+protocol = "smart_tcp"
47+
4348 # Authentication (includes secrets backend)
4449 [auth]
4550 enabled = true # Enable registry authentication (default: true)
@@ -172,6 +177,7 @@ keep_last = 3
172177 | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) |
173178 | `[routes]` | Domain to image mapping | [Routes](./routes.md) |
174179 | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |
180+| `[entrypoints]`, `[traffic]`, `[[network_services]]`, `[[services]]` | L4 and TLS passthrough traffic plane | [Traffic](./traffic.md) |
175181 | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |
176182 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |
177183 | `[backups]` | Database backups | [Backups](./backups.md) |
@@ -182,7 +188,6 @@ keep_last = 3
182188
183189 | Setting | Default |
184190 |---------|---------|
185-| `server.port` | `8088` |
186191 | `server.registry_port` | `5000` |
187192 | `server.data_dir` | `~/.gordon` |
188193 | `server.max_blob_chunk_size` | `"95MB"` |
@@ -257,7 +262,7 @@ gordon reload
257262
258263 | Setting |
259264 |---------|
260-| `server.port` |
265+| `entrypoints.edge.address` |
261266 | `server.data_dir` |
262267 | `server.max_blob_chunk_size` |
263268 | `server.max_blob_size` |
@@ -284,6 +289,8 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
284289 - [Server Configuration](./server.md)
285290 - [Routes Configuration](./routes.md)
286291 - [External Routes](./external-routes.md)
292+- [Standalone Services](./services.md)
293+- [Traffic Plane](./traffic.md)
287294 - [Authentication](./auth.md)
288295 - [Telemetry](./telemetry.md)
289296 - [Backups](./backups.md)
M docs/config/rate-limiting.md +1 -1
@@ -150,7 +150,7 @@ A burst of `100` with `per_ip_rps = 50` means a client can send 100 requests ins
150150
151151 **Critical for correct IP detection in production.**
152152
153-Gordon is a reverse proxy that routes requests to your containers. In production you can either terminate TLS directly in Gordon (via its internal CA on `server.tls_port`) or place it behind a TLS-terminating proxy like Cloudflare, nginx, or a load balancer.
153+Gordon is a reverse proxy that routes requests to your containers. In production you can either terminate TLS directly in Gordon (on a smart TCP edge entrypoint via its internal CA) or place it behind a TLS-terminating proxy like Cloudflare, nginx, or a load balancer.
154154
155155 ```
156156 Internet → [Cloudflare/nginx] → Gordon → Containers
M docs/config/reference.md +65 -8
@@ -9,10 +9,8 @@ Complete configuration reference with all options and their default values.
99 # SERVER
1010 # =============================================================================
1111 [server]
12-port = 8088 # HTTP proxy port
1312 registry_port = 5000 # Container registry port
14-tls_port = 8443 # HTTPS proxy port (0 = disabled, no internal CA)
15-tls_cert_file = "" # PEM cert path (optional, for static TLS alongside internal CA)
13+tls_cert_file = "" # PEM cert path (optional, for static TLS fallback)
1614 tls_key_file = "" # PEM key path (optional, must be set with tls_cert_file)
1715 force_https_redirect = false # Redirect all HTTP traffic to HTTPS (for direct-access setups)
1816 gordon_domain = "" # Required: Gordon domain (registry + API)
@@ -20,9 +18,20 @@ data_dir = "~/.gordon" # Data directory (varies by install
2018 max_blob_chunk_size = "95MB" # Max size per registry blob upload chunk
2119 max_blob_size = "1GB" # Max cumulative size per registry blob/layer upload
2220 registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all)
23-proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs)
21+proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach HTTP proxy paths (empty = allow all, e.g. Cloudflare IPs)
2422 registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only)
2523
24+# =============================================================================
25+# ENTRYPOINTS
26+# =============================================================================
27+[entrypoints.edge]
28+address = ":443" # Deployment-selected public TCP socket
29+protocol = "smart_tcp" # Sniff HTTP, h2c, TLS, passthrough, or explicit raw fallback
30+trusted_cidrs = [] # Peer socket IP allowlist for all traffic on this entrypoint
31+# raw_fallback = "ssh-fallback" # Optional TCP router for unknown non-HTTP/non-TLS bytes
32+# raw_fallback_trusted_cidrs = ["100.64.0.0/10"]
33+# allow_public_raw_fallback = false
34+
2635 # =============================================================================
2736 # DNS
2837 # =============================================================================
@@ -45,7 +54,7 @@ access_token_ttl = "15m" # Ephemeral access token lifetime (
4554 # PUBLIC TLS / ACME
4655 # =============================================================================
4756 [tls.acme]
48-enabled = false # Enable public ACME certificates (requires server.tls_port > 0)
57+enabled = false # Enable public ACME certificates (requires HTTPS fallback on a TLS-capable entrypoint)
4958 email = "" # ACME account email when enabled
5059 challenge = "auto" # "auto", "http-01", or "cloudflare-dns-01"
5160 obtain_batch_size = 1 # New certificate orders per reconcile run
@@ -164,6 +173,28 @@ preserve = true # Keep volumes when containers are
164173 [external_routes]
165174 # "domain.com" = "host:port" # Proxy to non-container services
166175
176+# =============================================================================
177+# STANDALONE SERVICES
178+# =============================================================================
179+# [[services]]
180+# name = "rust"
181+# image = "registry.example.com:5000/rust:latest"
182+# enabled = true
183+# env_file = "/srv/gordon/services/rust.env"
184+#
185+# [[services.ports]]
186+# name = "game"
187+# container = 28015
188+# protocol = "udp"
189+# publish = "127.0.0.1:38015"
190+#
191+# [[services.ports]]
192+# name = "rcon"
193+# container = 28016
194+# protocol = "tcp"
195+# publish = "127.0.0.1:38016"
196+# trusted_cidrs = ["100.64.0.0/10"]
197+
167198 # =============================================================================
168199 # NETWORK GROUPS
169200 # =============================================================================
@@ -210,9 +241,7 @@ keep_last = 3 # Keep N newest tags per repository
210241
211242 | Setting | Default | Description |
212243 |---------|---------|-------------|
213-| `server.port` | `8088` | HTTP proxy port |
214244 | `server.registry_port` | `5000` | Container registry port |
215-| `server.tls_port` | `8443` | HTTPS listener port (0 = disabled) |
216245 | `server.tls_cert_file` | `""` | PEM cert path for static TLS (optional) |
217246 | `server.tls_key_file` | `""` | PEM key path for static TLS (optional) |
218247 | `server.force_https_redirect` | `false` | Redirect all HTTP to HTTPS (for direct-access setups) |
@@ -223,10 +252,16 @@ keep_last = 3 # Keep N newest tags per repository
223252 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
224253 | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
225254 | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) |
255+| `entrypoints.<name>.address` | none | Deployment-selected listen address; `edge` is conventional for route-capable entrypoints but is not required when exactly one `smart_tcp` or `tls_mux` entrypoint exists |
256+| `entrypoints.<name>.protocol` | none | Entrypoint protocol: `smart_tcp`, `tls_mux`, `tcp`, or `udp` |
257+| `entrypoints.<name>.trusted_cidrs` | `[]` | Peer socket IP allowlist for all traffic on the entrypoint |
258+| `entrypoints.<name>.raw_fallback` | `""` | TCP router used by smart TCP for unknown non-HTTP/non-TLS bytes |
259+| `entrypoints.<name>.raw_fallback_trusted_cidrs` | `[]` | Peer socket IP allowlist for smart TCP raw fallback |
260+| `entrypoints.<name>.allow_public_raw_fallback` | `false` | Explicit acknowledgement for public raw fallback exposure |
226261 | `dns.resolvers` | `["1.1.1.1:53", "8.8.8.8:53"]` | Recursive resolvers used for public DNS visibility checks, including ACME DNS-01 propagation |
227262 | `dns.propagation_timeout` | `"5m"` | Maximum time to wait for DNS-01 TXT records to become visible through configured recursive resolvers |
228263 | `dns.polling_interval` | `"5s"` | Interval between DNS-01 propagation checks |
229-| `tls.acme.enabled` | `false` | Enable public ACME certificates (requires `server.tls_port > 0`) |
264+| `tls.acme.enabled` | `false` | Enable public ACME certificates (requires HTTPS fallback on a TLS-capable entrypoint) |
230265 | `tls.acme.email` | `""` | ACME account email when enabled |
231266 | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` |
232267 | `tls.acme.obtain_batch_size` | `1` | Maximum new ACME certificate orders per reconcile run |
@@ -279,6 +314,27 @@ keep_last = 3 # Keep N newest tags per repository
279314 | `volumes.auto_create` | `true` | Auto-create volumes |
280315 | `volumes.prefix` | `"gordon"` | Volume prefix |
281316 | `volumes.preserve` | `true` | Keep volumes |
317+| `services[].name` | none | Standalone service name used by `service:<service>:<port-name>` traffic refs |
318+| `services[].image` | none | Container image for enabled standalone services |
319+| `services[].enabled` | `false` | Whether Gordon creates, starts, and reconciles the service container |
320+| `services[].env` | `[]` | Inline `KEY=value` environment entries |
321+| `services[].env_file` | `""` | Env file loaded before inline entries |
322+| `services[].ports[].name` | none | Port name used by traffic service refs |
323+| `services[].ports[].container` | none | Container port number |
324+| `services[].ports[].protocol` | none | `tcp` or `udp` |
325+| `services[].ports[].publish` | `""` | Host-side bind address, usually loopback, that the traffic manager dials |
326+| `services[].ports[].private` | `false` | Require matching service and entrypoint `trusted_cidrs` for this port |
327+| `services[].ports[].public` | `false` | Explicit public opt-out for admin port names such as `rcon` |
328+| `services[].ports[].trusted_cidrs` | `[]` | CIDRs allowed for private port routing; must match the target entrypoint |
329+| `services[].volumes[].source` | `""` | Explicit named volume or bind source; omitted service volumes use image `VOLUME` metadata |
330+| `services[].volumes[].target` | none | Absolute container mount path |
331+| `services[].volumes[].read_only` | `false` | Mount explicit volume read-only |
332+| `services[].readiness.type` | `"none"` | `none`, `tcp`, or `log` |
333+| `services[].readiness.path` | `""` | Log readiness path inside the container |
334+| `services[].readiness.contains` | `""` | Text required in the readiness log |
335+| `services[].readiness.timeout` | default wait | Positive readiness timeout when set |
336+| `services[].cleanup.preserve_volumes` | `true` | Preserve managed image-discovered volumes on cleanup |
337+| `services[].cleanup.remove_container` | `true` | Remove old, disabled, or removed service containers |
282338 | `backups.enabled` | `false` | Backup service disabled |
283339 | `backups.schedule` | `"daily"` | Backup scheduler preset |
284340 | `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty |
@@ -364,4 +420,5 @@ gordon serve
364420 - [Telemetry](./telemetry.md)
365421 - [Network Isolation](./network-isolation.md)
366422 - [Volumes](./volumes.md)
423+- [Standalone Services](./services.md)
367424 - [Images](./images.md)
M docs/config/security-hardening.md +29 -0
@@ -56,6 +56,35 @@ require_digest = true
5656 - `require_digest = true` requires allowlisted external images to use `@sha256:<64 hex chars>`.
5757 - Include ports in allowlist entries when the registry uses a non-default port.
5858
59+## Smart TCP Raw Fallback
60+
61+Raw TCP fallback on a `smart_tcp` edge entrypoint is disabled by default. Unknown non-HTTP/non-TLS bytes reach a backend only when the entrypoint explicitly sets `raw_fallback` to a TCP router and the fallback source policy allows the peer.
62+
63+```toml
64+[entrypoints.edge]
65+address = ":443"
66+protocol = "smart_tcp"
67+raw_fallback = "ssh-fallback"
68+raw_fallback_trusted_cidrs = ["100.64.0.0/10"]
69+```
70+
71+Use `raw_fallback_trusted_cidrs` for private raw fallback. Public raw fallback requires an explicit acknowledgement:
72+
73+```toml
74+[entrypoints.edge]
75+address = ":443"
76+protocol = "smart_tcp"
77+raw_fallback = "public-raw"
78+allow_public_raw_fallback = true
79+```
80+
81+Security behavior:
82+
83+- PROXY protocol v1 and v2 prefixes are rejected; Gordon does not trust or parse PROXY headers on smart TCP entrypoints.
84+- `trusted_cidrs` and `raw_fallback_trusted_cidrs` use the peer socket IP, not `X-Forwarded-For`.
85+- HTTP-looking or TLS-looking malformed traffic is rejected and never bypasses to raw fallback.
86+- Entry-point-wide `trusted_cidrs` applies before sniffing to all protocols; raw fallback has its own narrower policy for private fallback exposure.
87+
5988 ## Docker network isolation
6089
6190 Per-app Docker networks are enabled by default. To block direct external egress from isolated networks, opt into Docker internal networks:
M docs/config/server.md +56 -74
@@ -6,29 +6,29 @@ Core server settings for Gordon.
66
77 ```toml
88 [server]
9-port = 8088 # HTTP proxy port
109 registry_port = 5000 # Container registry port
11-tls_port = 8443 # HTTPS listener port (0 = disabled)
10+gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
1211 # tls_cert_file = "" # Optional: PEM certificate for static TLS
1312 # tls_key_file = "" # Optional: PEM key for static TLS
14-# force_https_redirect = false # Redirect all HTTP traffic to HTTPS
15-gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
13+# force_https_redirect = false # Redirect cleartext HTTP requests to HTTPS
1614 # legacy_registry_domains = ["registry.example.com:5000"]
1715 # data_dir = "~/.gordon" # Data storage directory (default)
1816 max_blob_chunk_size = "95MB" # Max registry blob upload chunk
1917 max_blob_size = "1GB" # Max cumulative registry blob/layer upload
18+
19+[entrypoints.edge]
20+address = ":443" # Deployment-selected public TCP socket
21+protocol = "smart_tcp"
2022 ```
2123
2224 ## Options
2325
2426 | Option | Type | Default | Description |
2527 |--------|------|---------|-------------|
26-| `port` | int | `8088` | HTTP proxy port for routing traffic to containers |
2728 | `registry_port` | int | `5000` | Docker registry port for image push/pull |
28-| `tls_port` | int | `8443` | HTTPS listener port with internal CA. Set to `0` to disable TLS entirely |
29-| `tls_cert_file` | string | `""` | Path to PEM certificate file. When set (with `tls_key_file`), this cert is served for matching SNI domains; unmatched domains fall through to the internal CA |
29+| `tls_cert_file` | string | `""` | Path to PEM certificate file. When set with `tls_key_file`, matching SNI domains use this static cert before ACME or internal CA certs |
3030 | `tls_key_file` | string | `""` | Path to PEM private key file. Must be set together with `tls_cert_file` |
31-| `force_https_redirect` | bool | `false` | Redirect all HTTP requests to the HTTPS port. For direct-access setups without a TLS-terminating proxy |
31+| `force_https_redirect` | bool | `false` | Redirect cleartext HTTP requests to HTTPS on HTTP-capable edge entrypoints |
3232 | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) |
3333 | `registry_domain` | string | - | Deprecated migration key. Set `gordon_domain` instead. |
3434 | `legacy_registry_domains` | []string | `[]` | Additional Gordon registry hosts treated as aliases during staged migration. See [Upgrading: Staged Registry Host Rename](../upgrading.md#staged-registry-host-rename). |
@@ -37,46 +37,33 @@ max_blob_size = "1GB" # Max cumulative registry blob/layer up
3737 | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk |
3838 | `max_blob_size` | string | `"1GB"` | Maximum cumulative size for one registry blob/layer upload |
3939 | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
40-| `proxy_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
40+| `proxy_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to reach HTTP proxy paths (empty = allow all) |
4141 | `registry_listen_address` | string | `""` | Bind address for registry (empty = all interfaces) |
4242
43-## Port Configuration
44-
45-### Proxy Port
46-
47-The `port` setting controls where Gordon listens for HTTP traffic:
43+Public application traffic is configured under `[entrypoints]`. The standard edge listener is a `smart_tcp` socket:
4844
4945 ```toml
50-[server]
51-port = 8088 # Default high port for rootless or forwarded setups
52-```
53-
54-For rootless containers, you'll typically use a high port and configure firewall port forwarding:
55-
56-```bash
57-# Forward port 80 to 8088
58-sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088
46+[entrypoints.edge]
47+address = ":443"
48+protocol = "smart_tcp"
5949 ```
6050
61-### Internal CA and TLS
51+`entrypoints.edge.address` is not an HTTP or HTTPS port. It is the TCP address Gordon sniffs for cleartext HTTP, h2c, TLS passthrough, normal HTTPS fallback, or explicit raw TCP fallback. Gordon does not provide a built-in default public port; choose `:443`, a high port such as `:9000`, or container/firewall mappings to match your deployment.
6252
63-Gordon includes an internal certificate authority that issues on-demand TLS certificates for proxied domains. The HTTPS listener is enabled by default on `tls_port` (8443).
53+## Internal CA and TLS
6454
65-```toml
66-[server]
67-tls_port = 8443 # Default. Set to 0 to disable TLS entirely.
68-```
55+Gordon includes an internal certificate authority that issues on-demand TLS certificates for proxied domains served through HTTPS fallback on a TLS-capable entrypoint such as `smart_tcp`.
6956
70-With TLS enabled:
57+With TLS-capable edge traffic:
7158 - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/`
7259 - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory
7360 - The intermediate CA auto-renews before expiry
74-- An onboarding page at `https://<gordon-host>:<tls_port>/.well-known/gordon/` lets clients download the root CA certificate
61+- An onboarding page at `https://<gordon-host>/.well-known/gordon/` lets clients download the root CA certificate
7562 - The root CA is stable across restarts (generated once, persisted to disk)
7663
7764 #### Custom certificates
7865
79-To use your own certificate (e.g. from Tailscale, Let's Encrypt, or a corporate CA) alongside the internal CA:
66+To use your own certificate (e.g. from Tailscale, Let's Encrypt, or a corporate CA) alongside ACME/internal CA fallback:
8067
8168 ```toml
8269 [server]
@@ -84,15 +71,16 @@ tls_cert_file = "/etc/gordon/tls/cert.pem"
8471 tls_key_file = "/etc/gordon/tls/key.pem"
8572 ```
8673
87-The static certificate is served for SNI-matching domains (including wildcards). All other domains get on-demand certs from the internal CA.
74+Certificate priority for normal HTTPS fallback is: static certificates first, then public ACME certificates, then Gordon's internal CA.
8875
8976 #### Public ACME certificates
9077
91-Gordon can obtain public certificates through Let's Encrypt-compatible ACME when `[tls.acme]` is enabled. The HTTPS listener must also be enabled (`server.tls_port > 0`).
78+Gordon can obtain public certificates through Let's Encrypt-compatible ACME when `[tls.acme]` is enabled. Normal HTTPS fallback must be available on a TLS-capable entrypoint.
9279
9380 ```toml
94-[server]
95-tls_port = 8443
81+[entrypoints.edge]
82+address = ":443"
83+protocol = "smart_tcp"
9684
9785 [dns]
9886 resolvers = ["1.1.1.1:53", "8.8.8.8:53"]
@@ -107,11 +95,12 @@ obtain_batch_size = 1 # maximum new certificate orders per reconcile run
10795 ```
10896
10997 Challenge behavior:
110-- `http-01` serves `/.well-known/acme-challenge/<token>` on the HTTP proxy port.
111-- `cloudflare-dns-01` uses a Cloudflare API token from `pass`, `GORDON_CLOUDFLARE_API_TOKEN_FILE`, or `GORDON_CLOUDFLARE_API_TOKEN`.
98+- `cloudflare-dns-01` uses a Cloudflare API token from `pass`, `GORDON_CLOUDFLARE_API_TOKEN_FILE`, or `GORDON_CLOUDFLARE_API_TOKEN`; it does not need a special public port 80 edge.
99+- `http-01` serves `/.well-known/acme-challenge/<token>` through Gordon's HTTP handler and requires an HTTP-capable `smart_tcp` entrypoint reachable on external port 80 for each hostname being validated.
112100 - `auto` selects Cloudflare DNS-01 when a token is available, otherwise falls back to HTTP-01.
101+- `tls-alpn-01` is not supported.
113102
114-HTTP-01 requires public access to port 80 for each hostname being validated. If you use Cloudflare in DNS-only/gray-cloud mode, your firewall/NAT must allow direct public traffic to Gordon's HTTP proxy port. A firewall rule that only allows Cloudflare source IPs on port 80 is compatible with orange-cloud proxying, but it blocks gray-cloud HTTP-01 validation; use DNS-01 or temporarily open port 80 for direct validation.
103+HTTP-01 requires public access to external port 80 for each hostname being validated. If you use Cloudflare in DNS-only/gray-cloud mode, your firewall/NAT must allow direct public traffic to the HTTP-capable smart TCP entrypoint. A firewall rule that only allows Cloudflare source IPs on port 80 is compatible with orange-cloud proxying, but it blocks gray-cloud HTTP-01 validation; use DNS-01 or temporarily open port 80 for direct validation.
115104
116105 Gordon limits new ACME certificate orders to `obtain_batch_size` per reconcile run (default `1`) so enabling ACME on an existing multi-route server does not burst through every route and hit Let's Encrypt rate limits. Later reloads, restarts, or other explicit reconcile runs continue issuing remaining certificates.
117106
@@ -127,9 +116,9 @@ The defaults avoid relying on host-local DNS. This matters on hosts using Tailsc
127116
128117 Because Gordon's ACME challenge mode is global, `cloudflare-dns-01` requires a Cloudflare token that can read zones and edit DNS records for every zone used by configured HTTPS routes. If that is not desirable, use `http-01` until Gordon supports per-route or per-zone challenge policy.
129118
130-#### Direct HTTP Onboarding
119+#### Direct HTTP CA onboarding paths (`/.well-known/gordon/ca`)
131120
132-When `tls_port` is non-zero, direct HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only:
121+When Gordon is serving TLS-capable edge traffic, direct cleartext HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only:
133122
134123 - `/.well-known/gordon/`, `/.well-known/gordon/ca`, `/.well-known/gordon/ca.crt`, `/.well-known/gordon/ca.mobileconfig` — serve the onboarding page and CA certificate downloads
135124 - `/.well-known/acme-challenge/*` — serves ACME HTTP-01 challenges when public ACME HTTP-01 is enabled, otherwise returns `404 Not Found`
@@ -141,7 +130,7 @@ On HTTPS, onboarding paths are served only on `server.gordon_domain`; app hosts
141130
142131 #### HTTP to HTTPS redirect
143132
144-When clients connect directly (no Cloudflare or reverse proxy in front), enable `force_https_redirect` to redirect all HTTP traffic to the HTTPS port. Direct HTTP onboarding paths are still served even with this flag enabled.
133+When clients connect directly (no Cloudflare or reverse proxy in front), enable `force_https_redirect` to redirect cleartext HTTP traffic to HTTPS on the same smart TCP edge.
145134
146135 ```toml
147136 [server]
@@ -150,40 +139,22 @@ force_https_redirect = true
150139
151140 When `proxy_allowed_ips` is configured, non-proxy clients are redirected automatically even without this flag — trusted proxy IPs pass through to serve Cloudflare-proxied traffic.
152141
153-#### Disabling TLS
142+#### Firewall and Container Port Mapping
154143
155-For setups where TLS is handled entirely by an external proxy (Cloudflare, nginx, etc.):
144+Choose the bind address in `[entrypoints.edge]` and map external ports to it as needed. For example, a rootless service can bind a high port while the firewall exposes 443:
156145
157146 ```toml
158-[server]
159-tls_port = 0 # No HTTPS listener, no internal CA generated
147+[entrypoints.edge]
148+address = ":9000"
149+protocol = "smart_tcp"
160150 ```
161151
162-#### Firewall port forwarding
163-
164-For rootless user services, forward 443 to the high port at the firewall:
165-
166152 ```bash
167-# Forward 443 -> 8443
168-sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443
153+sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=9000
169154 sudo firewall-cmd --reload
170155 ```
171156
172-With firewalld and Tailscale:
173-
174-```bash
175-# Verify tailscale0 is in trusted zone
176-sudo firewall-cmd --get-zone-of-interface=tailscale0
177-
178-# If needed, place tailscale0 in trusted
179-sudo firewall-cmd --permanent --zone=trusted --add-interface=tailscale0
180-sudo firewall-cmd --reload
181-
182-# Forward 443 -> 8443 permanently (IPv4 + IPv6)
183-sudo firewall-cmd --permanent --zone=trusted --add-rich-rule='rule family=ipv4 forward-port port=443 protocol=tcp to-port=8443'
184-sudo firewall-cmd --permanent --zone=trusted --add-rich-rule='rule family=ipv6 forward-port port=443 protocol=tcp to-port=8443'
185-sudo firewall-cmd --reload
186-```
157+`:443`, `:9000`, and container `-p 443:9000` mappings are deployment choices, not protocol modes.
187158
188159 ### Registry Port
189160
@@ -223,11 +194,11 @@ For a staged rename, set `gordon_domain` to the new public host and add any old
223194
224195 Without this migration, a Host/remote-target mismatch can break routing or remote CLI token exchange.
225196
226-When requests arrive on the proxy port with this domain as the Host header, Gordon routes them to the backend services (registry and admin API).
197+When requests arrive on an HTTP-capable edge entrypoint with this domain as the Host header, Gordon routes them to the backend services (registry and admin API).
227198
228199 Security note:
229200 - Direct access to `/admin/*` on `registry_port` is blocked for non-loopback clients.
230-- Admin API traffic should go through the main proxy listener (`server.port`/`server.tls_port`).
201+- Admin API traffic should go through an HTTP-capable edge entrypoint such as `entrypoints.edge` with `protocol = "smart_tcp"`.
231202
232203 > **Note:** `registry_domain` is a deprecated migration key; use `gordon_domain` for new and upgraded configs.
233204
@@ -404,7 +375,7 @@ registry_listen_address = "127.0.0.1" # Loopback only
404375
405376 ## HSTS
406377
407-HSTS headers are sent automatically on the HTTPS listener (when `r.TLS != nil`). When Gordon runs behind a TLS-terminating proxy (Cloudflare, Tailscale) and only receives plain HTTP, HSTS is not sent — the edge proxy is expected to handle it.
378+HSTS headers are sent automatically on TLS requests (when `r.TLS != nil`). When Gordon runs behind a TLS-terminating proxy (Cloudflare, Tailscale) and only receives plain HTTP, HSTS is not sent — the edge proxy is expected to handle it.
408379
409380 ## Examples
410381
@@ -412,35 +383,44 @@ HSTS headers are sent automatically on the HTTPS listener (when `r.TLS != nil`).
412383
413384 ```toml
414385 [server]
415-port = 8088
416386 registry_port = 5000
417387 gordon_domain = "gordon.local"
418388 data_dir = "./dev-data"
389+
390+[entrypoints.edge]
391+address = ":9000"
392+protocol = "smart_tcp"
419393 ```
420394
421395 ### Production Configuration
422396
423397 ```toml
424398 [server]
425-port = 8088
426399 registry_port = 5000
427400 gordon_domain = "gordon.company.com"
428401 data_dir = "/var/lib/gordon"
402+
403+[entrypoints.edge]
404+address = ":443"
405+protocol = "smart_tcp"
429406 ```
430407
431408 ### Rootless Container Setup
432409
433410 ```toml
434411 [server]
435-port = 8088 # High port for rootless
436412 registry_port = 5000
437413 gordon_domain = "gordon.mydomain.com"
414+
415+[entrypoints.edge]
416+address = ":9000" # High port for rootless
417+protocol = "smart_tcp"
438418 ```
439419
440420 With firewall port forwarding:
441421
442422 ```bash
443-sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088
423+sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=9000
444424 sudo firewall-cmd --reload
445425 ```
446426
@@ -449,3 +429,5 @@ sudo firewall-cmd --reload
449429 - [Configuration Overview](./index.md)
450430 - [Installation Guide](../installation.md)
451431 - [Routes Configuration](./routes.md)
432+- [Standalone Services](./services.md)
433+- [Traffic Plane Configuration](./traffic.md)
A docs/config/services.md +94 -0
@@ -0,0 +1,94 @@
1+# Standalone Services
2+
3+Standalone services are Gordon-managed containers for non-HTTP workloads such as game servers, databases, or TCP/UDP daemons. Gordon creates, starts, restarts, reconciles, and removes these containers from the `[[services]]` configuration during startup and reload.
4+
5+Standalone services are separate from HTTP `[routes]`. Route normal web apps with `[routes]`; use `[[services]]` when Gordon must manage a long-running container that is reached by explicit L4 traffic routers.
6+
7+## Example
8+
9+```toml
10+[[services]]
11+name = "rust"
12+image = "registry.example.com:5000/rust:latest"
13+enabled = true
14+env_file = "/srv/gordon/services/rust.env"
15+
16+[services.readiness]
17+type = "log"
18+path = "/steamcmd/rust/server.log"
19+contains = "Server startup complete"
20+timeout = "2m"
21+
22+[[services.ports]]
23+name = "game"
24+container = 28015
25+protocol = "udp"
26+publish = "127.0.0.1:38015"
27+
28+[[services.ports]]
29+name = "rcon"
30+container = 28016
31+protocol = "tcp"
32+publish = "127.0.0.1:38016"
33+trusted_cidrs = ["100.64.0.0/10"]
34+```
35+
36+The `publish` address is the host-side bind address that Gordon's traffic manager dials; use loopback for private backends. Expose services through `[traffic]` routers instead of binding service containers directly to a public interface.
37+
38+## Traffic routing
39+
40+Use `service:<service>:<port-name>` from TCP, UDP, or TLS passthrough routers:
41+
42+```toml
43+[entrypoints.rust]
44+address = "0.0.0.0:28015"
45+protocol = "udp"
46+
47+[[traffic.udp.routers]]
48+name = "rust-game"
49+entrypoint = "rust"
50+service = "service:rust:game"
51+
52+[entrypoints.rcon]
53+address = "0.0.0.0:28016"
54+protocol = "tcp"
55+trusted_cidrs = ["100.64.0.0/10"]
56+
57+[[traffic.tcp.routers]]
58+name = "rust-rcon"
59+entrypoint = "rcon"
60+service = "service:rust:rcon"
61+```
62+
63+Ports named `rcon` default to private. Private ports require non-empty `trusted_cidrs` on both the service port and target entrypoint, and the CIDR sets must match. To intentionally expose an RCON port publicly, set `public = true` on that port.
64+
65+## Volumes
66+
67+Explicit volumes are optional:
68+
69+```toml
70+[[services.volumes]]
71+source = "rust-data"
72+target = "/steamcmd/rust"
73+read_only = false
74+```
75+
76+When `[[services.volumes]]` is omitted, Gordon inspects the image `VOLUME` metadata and creates deterministic Gordon-managed named volumes for those paths. If the image has no `VOLUME` metadata, the service is stateless unless the image writes inside its own filesystem.
77+
78+Gordon tracks image-discovered managed volumes and only removes those managed volumes when `cleanup.preserve_volumes = false`. Explicit named volumes and bind mounts are not deleted as managed image volumes.
79+
80+## Cleanup
81+
82+```toml
83+[services.cleanup]
84+preserve_volumes = true
85+remove_container = true
86+```
87+
88+By default Gordon removes old or disabled service containers while preserving volumes. Set `preserve_volumes = false` only for disposable managed image volumes.
89+
90+## Related
91+
92+- [Traffic Plane Configuration](./traffic.md)
93+- [Configuration Reference](./reference.md)
94+- [CLI traffic status](../cli/traffic.md)
A docs/config/traffic.md +181 -0
@@ -0,0 +1,181 @@
1+# Traffic Plane Configuration
2+
3+Gordon models network listeners as a traffic graph: entrypoints receive packets or connections, routers select a service, and services resolve to backends.
4+
5+## Smart TCP Edge Entrypoint
6+
7+Public application traffic is normally exposed with a `smart_tcp` entrypoint. The conventional route-capable entrypoint name is `edge`, but Gordon does not require that name or assign a built-in public port. When exactly one route-capable `smart_tcp` or `tls_mux` entrypoint exists, normal Gordon routes use it even if it has a custom name. Choose the address that matches your deployment, firewall, and container mapping:
8+
9+```toml
10+[entrypoints.edge]
11+address = ":443"
12+protocol = "smart_tcp"
13+trusted_cidrs = []
14+```
15+
16+Do not treat `entrypoints.edge.address` as an HTTP port or an HTTPS port. It is one TCP socket that sniffs each new connection and dispatches the original byte stream.
17+
18+Supported entrypoint protocols are `smart_tcp`, `tls_mux`, `tcp`, and `udp`. `smart_tcp` is the primary public edge model; `tls_mux` can also serve normal Gordon routes through TLS fallback, `tcp` and `udp` are for explicit L4 services, and UDP remains separate from the TCP entrypoint.
19+
20+## Smart TCP Dispatch Order
21+
22+For each accepted connection on a `smart_tcp` entrypoint Gordon:
23+
24+1. Applies entrypoint-wide `trusted_cidrs` to the peer socket IP.
25+2. Rejects PROXY protocol v1 and v2 prefixes. PROXY headers are not trusted or parsed.
26+3. Peeks a minimal prefix and replays the bytes to the selected handler/backend.
27+4. Dispatches cleartext HTTP/1.x and h2c prior-knowledge to Gordon's HTTP handler.
28+5. Dispatches TLS ClientHello traffic by SNI:
29+ - matching TLS passthrough router -> raw TCP backend without TLS termination;
30+ - no passthrough match -> normal Gordon HTTPS fallback.
31+6. Dispatches unknown non-HTTP/non-TLS bytes only to an explicit raw fallback router, after raw-fallback source policy allows it.
32+7. Refuses the connection when no deterministic handler or allowed fallback exists.
33+
34+Malformed HTTP-looking or TLS-looking traffic is rejected instead of bypassing to raw fallback.
35+
36+## Standalone and Network Services
37+
38+Standalone services are Gordon-managed containers that L4 routers can target with `service:<service>:<port-name>`. Define them under `[[services]]` when Gordon should own the container lifecycle.
39+
40+```toml
41+[[services]]
42+name = "rust"
43+image = "registry.example.com:5000/rust:latest"
44+enabled = true
45+
46+[[services.ports]]
47+name = "game"
48+container = 28015
49+protocol = "udp"
50+publish = "127.0.0.1:38015"
51+
52+[entrypoints.rust]
53+address = "0.0.0.0:28015"
54+protocol = "udp"
55+
56+[[traffic.udp.routers]]
57+name = "rust-game"
58+entrypoint = "rust"
59+service = "service:rust:game"
60+```
61+
62+Network services describe manually managed non-HTTP backends that L4 routers can target.
63+
64+```toml
65+[[network_services]]
66+name = "postgres"
67+
68+[[network_services.ports]]
69+name = "db"
70+container = 5432
71+protocol = "tcp"
72+```
73+
74+Service references use:
75+
76+- `route:<domain>` for configured HTTP routes
77+- `external_route:<domain>` for configured external HTTP routes
78+- `network_service:<service>:<port-name>` for manually managed TCP, UDP, and TLS passthrough backends
79+- `service:<service>:<port-name>` for Gordon-managed standalone service backends
80+
81+`static:<name>` is reserved and currently unsupported.
82+
83+## TLS Passthrough on Smart TCP
84+
85+TLS passthrough routers run on `smart_tcp` or `tls_mux` entrypoints and route by ClientHello SNI without terminating TLS.
86+
87+```toml
88+[entrypoints.edge]
89+address = ":443"
90+protocol = "smart_tcp"
91+
92+[[traffic.tls.routers]]
93+name = "raw-tls"
94+entrypoint = "edge"
95+sni = "raw.example.com"
96+service = "network_service:raw:tls"
97+```
98+
99+Exact SNI matches win over wildcard matches. Ambiguous wildcard overlaps and HTTP-host/TLS-passthrough conflicts on the same smart TCP entrypoint are rejected at validation time. HTTPS application routes that do not match a passthrough SNI use Gordon's normal HTTPS fallback and certificate selection.
100+
101+## Raw TCP Fallback
102+
103+Raw fallback is disabled by default. Unknown non-HTTP/non-TLS bytes reach a TCP backend only when the smart TCP entrypoint explicitly names one TCP router as `raw_fallback` and the source policy allows the peer socket IP.
104+
105+```toml
106+[entrypoints.edge]
107+address = ":443"
108+protocol = "smart_tcp"
109+raw_fallback = "ssh-fallback"
110+raw_fallback_trusted_cidrs = ["100.64.0.0/10"]
111+
112+[[traffic.tcp.routers]]
113+name = "ssh-fallback"
114+entrypoint = "edge"
115+service = "network_service:ssh:ssh"
116+```
117+
118+Use `raw_fallback_trusted_cidrs` for private raw fallback exposure. To expose raw fallback publicly, acknowledge it explicitly:
119+
120+```toml
121+[entrypoints.edge]
122+address = ":443"
123+protocol = "smart_tcp"
124+raw_fallback = "public-raw"
125+allow_public_raw_fallback = true
126+```
127+
128+`trusted_cidrs` and `raw_fallback_trusted_cidrs` both use the peer socket IP. They do not use `X-Forwarded-For`.
129+
130+## TCP Routers
131+
132+Plain TCP entrypoints support one TCP router per entrypoint. TCP routers on a `smart_tcp` entrypoint are valid only when referenced by that entrypoint's `raw_fallback`.
133+
134+```toml
135+[entrypoints.postgres]
136+address = "0.0.0.0:5432"
137+protocol = "tcp"
138+trusted_cidrs = ["100.64.0.0/10"]
139+
140+[traffic.tcp]
141+dial_timeout = "10s"
142+idle_timeout = "5m"
143+drain_timeout = "30s"
144+max_connections = 1024
145+
146+[[traffic.tcp.routers]]
147+name = "postgres"
148+entrypoint = "postgres"
149+service = "network_service:postgres:db"
150+```
151+
152+If `max_connections` is omitted or set to `0`, Gordon applies the safe runtime default of `1024` active connections per entrypoint.
153+
154+## UDP Routers
155+
156+UDP is not sniffed or unified into smart TCP. Use a separate UDP entrypoint:
157+
158+```toml
159+[entrypoints.game]
160+address = "0.0.0.0:7777"
161+protocol = "udp"
162+
163+[traffic.udp]
164+idle_timeout = "30s"
165+drain_timeout = "30s"
166+max_sessions = 4096
167+
168+[[traffic.udp.routers]]
169+name = "game"
170+entrypoint = "game"
171+service = "network_service:game:game"
172+```
173+
174+UDP sessions are keyed by client address and expire after `idle_timeout`. If `max_sessions` is omitted or set to `0`, Gordon applies the safe runtime default of `4096` active sessions per entrypoint.
175+
176+## Related
177+
178+- [Server Settings](./server.md)
179+- [Routes](./routes.md)
180+- [Standalone Services](./services.md)
181+- [CLI traffic status](../cli/traffic.md)
M docs/getting-started.md +6 -4
@@ -6,7 +6,7 @@ Deploy your first app with Gordon in under 5 minutes.
66
77 - A Linux VPS with Docker or Podman installed
88 - A domain pointing to your VPS (DNS A record)
9-- HTTPS setup: Cloudflare proxy, Tailscale, or Gordon's internal CA (`server.tls_port = 8443`)
9+- A public TCP edge address for Gordon (for example external `:443`, mapped to your chosen `entrypoints.edge.address`)
1010 - [pass](https://www.passwordstore.org/) (password manager) with GPG key initialized
1111
1212 ## 1. Install Gordon
@@ -73,11 +73,13 @@ Edit `~/.config/gordon/gordon.toml`:
7373
7474 ```toml
7575 [server]
76-port = 8088 # HTTP proxy port (default)
7776 registry_port = 5000 # Registry port
78-tls_port = 8443 # HTTPS listener (default)
7977 gordon_domain = "gordon.mydomain.com" # Registry + Admin API domain
8078
79+[entrypoints.edge]
80+address = ":443" # Public smart TCP edge (choose your bind/mapping)
81+protocol = "smart_tcp"
82+
8183 [routes]
8284 "app.mydomain.com" = "myapp:latest" # Domain → Image mapping
8385 ```
@@ -99,7 +101,7 @@ Why wildcard (`*`)?
99101
100102 If your DNS provider supports wildcard CNAME flattening (Cloudflare does), `* -> gordon.mydomain.com` is usually the cleanest option.
101103
102-> **Important for Cloudflare or other proxied setups:** when Gordon's internal CA is enabled (default `tls_port = 8443`), add your proxy edge IPs to `server.proxy_allowed_ips` or proxied HTTP traffic will get `403 Forbidden`. See [Installation](./installation.md#proxy-origin-allowlist). If you do not want Gordon to manage TLS at all, set `tls_port = 0`.
104+> **Important for Cloudflare or other proxied setups:** when Gordon serves HTTP paths behind Cloudflare or another proxy, add your proxy edge IPs to `server.proxy_allowed_ips` or proxied HTTP traffic can get `403 Forbidden`. See [Installation](./installation.md#proxy-origin-allowlist).
103105
104106 ## 6. Start Gordon as a Service
105107
M docs/index.md +2 -1
@@ -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
@@ -80,7 +81,7 @@ Gordon runs on your VPS and provides:
8081 - Domain pointing to your server
8182 - Optional Cloudflare account or other reverse proxy for edge TLS
8283
83-> **Note:** Gordon can run behind Cloudflare/nginx, or terminate TLS directly via its internal CA (enabled by default on `server.tls_port`). Set `tls_port = 0` to disable.
84+> **Note:** Gordon can run behind Cloudflare/nginx, or terminate TLS directly on a smart TCP edge entrypoint via static certificates, public ACME certificates, or its internal CA.
8485
8586 ## Related
8687
M docs/installation.md +15 -11
@@ -115,7 +115,7 @@ EOF
115115
116116 ## Firewall Configuration
117117
118-Gordon needs ports accessible for the registry and proxy.
118+Gordon needs ports accessible for the registry and the public edge entrypoint.
119119
120120 ### Using firewalld
121121
@@ -128,9 +128,10 @@ sudo systemctl enable --now firewalld
128128 sudo firewall-cmd --permanent --add-service=http
129129 sudo firewall-cmd --permanent --add-service=https
130130
131-# For rootless containers, redirect privileged ports
132-sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088
133-sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443
131+# For rootless services, bind entrypoints.edge.address to a high port (for example :9000)
132+# and redirect the external TCP edge ports your deployment uses.
133+sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=9000
134+sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=9000
134135
135136 # Apply changes
136137 sudo firewall-cmd --reload
@@ -165,10 +166,13 @@ Minimum required configuration:
165166
166167 ```toml
167168 [server]
168-port = 8088
169169 registry_port = 5000
170170 gordon_domain = "gordon.yourdomain.com"
171171
172+[entrypoints.edge]
173+address = ":443"
174+protocol = "smart_tcp"
175+
172176 [routes]
173177 "app.yourdomain.com" = "myapp:latest"
174178 ```
@@ -249,22 +253,22 @@ Point your domains to your server:
249253
250254 ## Cloudflare SSL Mode
251255
252-Gordon serves HTTP on `server.port` and HTTPS on `server.tls_port` by default. Choose the Cloudflare mode that matches how you want Cloudflare to reach your origin.
256+Gordon serves public application traffic through HTTP-capable entrypoints such as `entrypoints.edge` with `protocol = "smart_tcp"`. Choose the Cloudflare mode that matches how you want Cloudflare to reach your origin.
253257
254258 | Mode | How it works | Use when |
255259 |------|-------------|----------|
256-| **Flexible** | Cloudflare terminates TLS; connects to Gordon on HTTP | You want edge HTTPS only, or you set `tls_port = 0` |
260+| **Flexible** | Cloudflare terminates TLS; connects to Gordon with cleartext HTTP | You want edge HTTPS only |
257261 | **Full (Strict)** | Cloudflare connects to Gordon over HTTPS with a valid cert | You want end-to-end HTTPS using Gordon's public ACME certs or a static cert (`tls_cert_file` / `tls_key_file`) |
258262
259263 Wrong mode causes: **521** (Cloudflare can't connect) or **525** (TLS handshake failed).
260264
261-> **Important:** When `tls_port != 0`, you must also set `proxy_allowed_ips` with Cloudflare edge IPs — see [Proxy Origin Allowlist](#proxy-origin-allowlist) below.
262-
263-> **Rootless note:** Unprivileged users can't bind port 80. Forward port 80→8088 (or your configured port) via firewall — see the firewall section above.
265+> **Important:** For Cloudflare-proxied HTTP paths, set `proxy_allowed_ips` with Cloudflare edge IPs — see [Proxy Origin Allowlist](#proxy-origin-allowlist) below.
266+>
267+> **Rootless note:** Unprivileged users can't bind privileged ports. Bind `entrypoints.edge.address` to a high port (for example `:9000`) and forward/map external ports to it via firewall or container settings.
264268
265269 ## Proxy Origin Allowlist
266270
267-When Gordon's internal CA is enabled (default: `tls_port = 8443`), HTTP requests from non-localhost IPs are restricted to certificate onboarding paths only. Cloudflare and other reverse proxies must be listed in `proxy_allowed_ips` to reach your applications.
271+When Gordon serves HTTP paths through a smart TCP edge, direct non-localhost HTTP requests can be restricted to certificate onboarding paths. Cloudflare and other reverse proxies must be listed in `proxy_allowed_ips` to reach your applications.
268272
269273 Add Cloudflare edge IPs to your `gordon.toml`:
270274
M docs/upgrading.md +56 -3
@@ -6,6 +6,59 @@ This guide covers breaking changes and migration steps between major versions.
66
77 Route keys must be plain hostnames. Use inline tables like `"app.example.com" = { image = "myapp:latest" }`. Gordon still reads legacy `http://...` route entries for backward compatibility and rewrites them on the next save. Update `[routes]`, CLI commands, and automation that reference the old values.
88
9+## Next major: Unified smart TCP entrypoints
10+
11+### Breaking: `server.port` / `server.tls_port` no longer define public listeners
12+
13+Public application traffic is configured entrypoint-first. Migrate old HTTP/HTTPS listener settings to a smart TCP edge entrypoint.
14+
15+**Before:**
16+
17+```toml
18+[server]
19+port = 8088
20+tls_port = 8443
21+registry_port = 5000
22+gordon_domain = "gordon.example.com"
23+```
24+
25+**After:**
26+
27+```toml
28+[server]
29+registry_port = 5000
30+gordon_domain = "gordon.example.com"
31+
32+[entrypoints.edge]
33+address = ":443"
34+protocol = "smart_tcp"
35+```
36+
37+`edge` is the conventional route default, but it has no built-in port. `address = ":443"`, `address = ":9000"`, firewall forwarding, and container mappings such as `-p 443:9000` are deployment choices, not protocol modes. The `smart_tcp` protocol accepts TCP, sniffs HTTP/h2c/TLS, routes TLS passthrough by SNI when configured, and otherwise uses normal Gordon HTTPS fallback.
38+
39+If you run rootless and cannot bind 443 directly, bind a high port and map external ports to it:
40+
41+```toml
42+[entrypoints.edge]
43+address = ":9000"
44+protocol = "smart_tcp"
45+```
46+
47+```bash
48+sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=9000
49+sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=9000
50+sudo firewall-cmd --reload
51+```
52+
53+Keep `server.registry_port` for Docker/Podman push and pull traffic; it is separate from public app entrypoints.
54+
55+### ACME migration notes
56+
57+- DNS-01 (`cloudflare-dns-01`) does not require a special external port 80 edge.
58+- HTTP-01 requires an HTTP-capable `smart_tcp` entrypoint reachable on external port 80 for each hostname being validated.
59+- TLS-ALPN-01 is unsupported.
60+- Normal HTTPS fallback certificate priority is static certificates, then public ACME certificates, then Gordon's internal CA.
61+
962 ## v2.30.0 to v2.31.0
1063
1164 ### Breaking: Default Port Changes
@@ -117,9 +170,9 @@ If you have automation parsing status-like fields from `routes list`, migrate it
117170
118171 Gordon can now obtain public certificates with `[tls.acme]` using either `http-01` or `cloudflare-dns-01`.
119172
120-- `server.tls_port` must stay enabled (`> 0`)
121-- `http-01` needs public HTTP reachability for each hostname being validated
122-- `cloudflare-dns-01` needs a Cloudflare token with zone-read and DNS-edit access for every zone used by HTTPS routes
173+- Normal HTTPS fallback must be available on a TLS-capable entrypoint
174+- `http-01` needs an HTTP-capable smart TCP entrypoint reachable on external port 80 for each hostname being validated
175+- `cloudflare-dns-01` needs a Cloudflare token with zone-read and DNS-edit access for every zone used by HTTPS routes and does not require a special port-80 edge
123176 - `obtain_batch_size` limits new ACME orders per reconcile pass to reduce rate-limit spikes
124177
125178 ### New: DNS Resolver Settings for ACME DNS-01
M go.mod +1 -1
@@ -21,6 +21,7 @@ require (
2121 github.com/mattn/go-runewidth v0.0.23
2222 github.com/muesli/termenv v0.16.0
2323 github.com/rivo/uniseg v0.4.7
24+ github.com/rs/zerolog v1.35.0
2425 github.com/smallstep/truststore v0.13.0
2526 github.com/spf13/cobra v1.10.2
2627 github.com/spf13/viper v1.21.0
@@ -77,7 +78,6 @@ require (
7778 github.com/morikuni/aec v1.1.0 // indirect
7879 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
7980 github.com/muesli/cancelreader v0.2.2 // indirect
80- github.com/rs/zerolog v1.35.0 // indirect
8181 github.com/sirupsen/logrus v1.9.4 // indirect
8282 github.com/vbatts/tar-split v0.12.2 // indirect
8383 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
M gordon.toml.example +50 -2
@@ -2,8 +2,6 @@
22 # Copy this file to ~/.config/gordon/gordon.toml and adjust for your domain.
33
44 [server]
5-port = 8088
6-tls_port = 8443
75 registry_port = 5000
86 gordon_domain = "gordon.example.com"
97 # legacy_registry_domains = ["registry.example.com:5000"]
@@ -20,6 +18,16 @@ gordon_domain = "gordon.example.com"
2018 # max_proxy_response_size = "1GB"
2119 # max_concurrent_connections = 10000
2220
21+# "edge" is the conventional route-capable entrypoint name, but a single
22+# custom smart_tcp or tls_mux entrypoint can also serve normal Gordon routes.
23+[entrypoints.edge]
24+address = ":443"
25+protocol = "smart_tcp"
26+# trusted_cidrs = []
27+# raw_fallback = "ssh-fallback"
28+# raw_fallback_trusted_cidrs = ["100.64.0.0/10"]
29+# allow_public_raw_fallback = false
30+
2331 [dns]
2432 resolvers = ["1.1.1.1:53", "8.8.8.8:53"]
2533 propagation_timeout = "5m"
@@ -166,6 +174,46 @@ keep = 14
166174 [external_routes]
167175 # "status.example.com" = "127.0.0.1:9000"
168176
177+# Gordon-managed standalone L4 services. Use service:<name>:<port> from
178+# traffic routers to expose published loopback ports.
179+# [[services]]
180+# name = "rust"
181+# image = "registry.example.com:5000/rust:latest"
182+# enabled = true
183+# env_file = "/srv/gordon/services/rust.env"
184+#
185+# [[services.ports]]
186+# name = "game"
187+# container = 28015
188+# protocol = "udp"
189+# publish = "127.0.0.1:38015"
190+#
191+# [[services.ports]]
192+# name = "rcon"
193+# container = 28016
194+# protocol = "tcp"
195+# publish = "127.0.0.1:38016"
196+# trusted_cidrs = ["100.64.0.0/10"]
197+#
198+# [entrypoints.rust]
199+# address = "0.0.0.0:28015"
200+# protocol = "udp"
201+#
202+# [[traffic.udp.routers]]
203+# name = "rust-game"
204+# entrypoint = "rust"
205+# service = "service:rust:game"
206+#
207+# [entrypoints.rcon]
208+# address = "0.0.0.0:28016"
209+# protocol = "tcp"
210+# trusted_cidrs = ["100.64.0.0/10"]
211+#
212+# [[traffic.tcp.routers]]
213+# name = "rust-rcon"
214+# entrypoint = "rcon"
215+# service = "service:rust:rcon"
216+
169217 [network_groups]
170218 # "backend" = ["app.example.com", "api.example.com"]
171219
A internal/adapters/dto/traffic.go +164 -0
@@ -0,0 +1,164 @@
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+ SmartTCP SmartTCPCounters `json:"smart_tcp"`
27+}
28+
29+type TrafficRouterStatus struct {
30+ Name string `json:"name"`
31+ EntryPoint string `json:"entrypoint"`
32+ Protocol domain.RouterProtocol `json:"protocol"`
33+ Rule TrafficRule `json:"rule"`
34+ Service string `json:"service"`
35+ Active bool `json:"active"`
36+}
37+
38+type TrafficServiceStatus struct {
39+ Name string `json:"name"`
40+ Active bool `json:"active"`
41+ Backends []TrafficBackendStatus `json:"backends"`
42+}
43+
44+type TrafficRule struct {
45+ Host string `json:"host,omitempty"`
46+ SNI string `json:"sni,omitempty"`
47+}
48+
49+type TrafficBackendStatus struct {
50+ Name string `json:"name"`
51+ Host string `json:"host"`
52+ Port int `json:"port"`
53+ Protocol domain.NetworkProtocol `json:"protocol"`
54+ Active bool `json:"active"`
55+}
56+
57+type TrafficCounters struct {
58+ ActiveTCPConnections int64 `json:"active_tcp_connections"`
59+ ActiveUDPSessions int64 `json:"active_udp_sessions"`
60+ TotalAccepted int64 `json:"total_accepted"`
61+ TotalRefused int64 `json:"total_refused"`
62+ TotalErrors int64 `json:"total_errors"`
63+ BytesIn int64 `json:"bytes_in"`
64+ BytesOut int64 `json:"bytes_out"`
65+ SmartTCP SmartTCPCounters `json:"smart_tcp"`
66+}
67+
68+type SmartTCPCounters struct {
69+ HTTPAccepted int64 `json:"http_accepted"`
70+ H2CAccepted int64 `json:"h2c_accepted"`
71+ HTTPSFallbackAccepted int64 `json:"https_fallback_accepted"`
72+ TLSPassthroughAccepted int64 `json:"tls_passthrough_accepted"`
73+ RawFallbackAccepted int64 `json:"raw_fallback_accepted"`
74+ EntrypointCIDRRefused int64 `json:"entrypoint_cidr_refused"`
75+ RawFallbackCIDRRefused int64 `json:"raw_fallback_cidr_refused"`
76+ PROXYRefused int64 `json:"proxy_refused"`
77+ UnknownNoFallbackRefused int64 `json:"unknown_no_fallback_refused"`
78+ MalformedRejected int64 `json:"malformed_rejected"`
79+ SniffTimeout int64 `json:"sniff_timeout"`
80+ ClientHelloTooLarge int64 `json:"client_hello_too_large"`
81+}
82+
83+func TrafficStatusFromDomain(status domain.TrafficStatus) TrafficStatusResponse {
84+ return TrafficStatusResponse{
85+ LastReloadStatus: status.LastReloadStatus,
86+ LastReloadError: status.LastReloadError,
87+ EntryPoints: trafficEntryPointsFromDomain(status.EntryPoints),
88+ Routers: trafficRoutersFromDomain(status.Routers),
89+ Services: trafficServicesFromDomain(status.Services),
90+ Counters: trafficCountersFromDomain(status.Counters),
91+ }
92+}
93+
94+func trafficEntryPointsFromDomain(values []domain.EntryPointStatus) []TrafficEntryPointStatus {
95+ out := make([]TrafficEntryPointStatus, 0, len(values))
96+ for _, value := range values {
97+ out = append(out, TrafficEntryPointStatus{
98+ Name: value.Name, Address: value.Address, Protocol: value.Protocol, Active: value.Active,
99+ ActiveTCPConnections: value.ActiveTCPConnections, ActiveUDPSessions: value.ActiveUDPSessions,
100+ TotalAccepted: value.TotalAccepted, TotalRefused: value.TotalRefused, TotalErrors: value.TotalErrors,
101+ BytesIn: value.BytesIn, BytesOut: value.BytesOut, SmartTCP: smartTCPCountersFromDomain(value.SmartTCP),
102+ })
103+ }
104+ return out
105+}
106+
107+func trafficRoutersFromDomain(values []domain.TrafficRouterStatus) []TrafficRouterStatus {
108+ out := make([]TrafficRouterStatus, 0, len(values))
109+ for _, value := range values {
110+ out = append(out, TrafficRouterStatus{
111+ Name: value.Name, EntryPoint: value.EntryPoint, Protocol: value.Protocol,
112+ Rule: TrafficRule{Host: value.Rule.Host, SNI: value.Rule.SNI}, Service: value.Service, Active: value.Active,
113+ })
114+ }
115+ return out
116+}
117+
118+func trafficServicesFromDomain(values []domain.TrafficServiceStatus) []TrafficServiceStatus {
119+ out := make([]TrafficServiceStatus, 0, len(values))
120+ for _, value := range values {
121+ out = append(out, TrafficServiceStatus{Name: value.Name, Active: value.Active, Backends: trafficBackendsFromDomain(value.Backends)})
122+ }
123+ return out
124+}
125+
126+func trafficBackendsFromDomain(values []domain.TrafficBackendStatus) []TrafficBackendStatus {
127+ out := make([]TrafficBackendStatus, 0, len(values))
128+ for _, value := range values {
129+ out = append(out, TrafficBackendStatus{
130+ Name: value.Name, Host: value.Host, Port: value.Port, Protocol: value.Protocol, Active: value.Active,
131+ })
132+ }
133+ return out
134+}
135+
136+func trafficCountersFromDomain(value domain.TrafficCounters) TrafficCounters {
137+ return TrafficCounters{
138+ ActiveTCPConnections: value.ActiveTCPConnections,
139+ ActiveUDPSessions: value.ActiveUDPSessions,
140+ TotalAccepted: value.TotalAccepted,
141+ TotalRefused: value.TotalRefused,
142+ TotalErrors: value.TotalErrors,
143+ BytesIn: value.BytesIn,
144+ BytesOut: value.BytesOut,
145+ SmartTCP: smartTCPCountersFromDomain(value.SmartTCP),
146+ }
147+}
148+
149+func smartTCPCountersFromDomain(value domain.SmartTCPCounters) SmartTCPCounters {
150+ return SmartTCPCounters{
151+ HTTPAccepted: value.HTTPAccepted,
152+ H2CAccepted: value.H2CAccepted,
153+ HTTPSFallbackAccepted: value.HTTPSFallbackAccepted,
154+ TLSPassthroughAccepted: value.TLSPassthroughAccepted,
155+ RawFallbackAccepted: value.RawFallbackAccepted,
156+ EntrypointCIDRRefused: value.EntrypointCIDRRefused,
157+ RawFallbackCIDRRefused: value.RawFallbackCIDRRefused,
158+ PROXYRefused: value.PROXYRefused,
159+ UnknownNoFallbackRefused: value.UnknownNoFallbackRefused,
160+ MalformedRejected: value.MalformedRejected,
161+ SniffTimeout: value.SniffTimeout,
162+ ClientHelloTooLarge: value.ClientHelloTooLarge,
163+ }
164+}
M internal/adapters/in/cli/controlplane.go +1 -0
@@ -41,6 +41,7 @@ type ControlPlane interface {
4141
4242 GetStatus(ctx context.Context) (*remote.Status, error)
4343 GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error)
44+ GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error)
4445 Reload(ctx context.Context) error
4546 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)
4647 GetConfig(ctx context.Context) (*remote.Config, error)
M internal/adapters/in/cli/controlplane_local.go +4 -0
@@ -445,6 +445,10 @@ func (l *localControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusRes
445445 return &result, nil
446446 }
447447
448+func (l *localControlPlane) GetTrafficStatus(_ context.Context) (*dto.TrafficStatusResponse, error) {
449+ return nil, fmt.Errorf("local traffic status is unavailable from the in-process CLI control plane; query the running Gordon daemon with --remote or set GORDON_REMOTE to its admin URL: %w", domain.ErrTrafficStatusUnavailable)
450+}
451+
448452 func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
449453 if l.configSvc == nil {
450454 return nil, fmt.Errorf("local config service unavailable")
M internal/adapters/in/cli/controlplane_remote.go +4 -0
@@ -126,6 +126,10 @@ func (r *remoteControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusRe
126126 return r.client.GetTLSStatus(ctx)
127127 }
128128
129+func (r *remoteControlPlane) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) {
130+ return r.client.GetTrafficStatus(ctx)
131+}
132+
129133 func (r *remoteControlPlane) Reload(ctx context.Context) error {
130134 return r.client.Reload(ctx)
131135 }
M internal/adapters/in/cli/mocks/mock_control_plane.go +107 -45
@@ -66,7 +66,7 @@ type MockControlPlane_AddAttachment_Call struct {
6666 // - ctx context.Context
6767 // - domainOrGroup string
6868 // - image string
69-func (_e *MockControlPlane_Expecter) AddAttachment(ctx interface{}, domainOrGroup interface{}, image interface{}) *MockControlPlane_AddAttachment_Call {
69+func (_e *MockControlPlane_Expecter) AddAttachment(ctx any, domainOrGroup any, image any) *MockControlPlane_AddAttachment_Call {
7070 return &MockControlPlane_AddAttachment_Call{Call: _e.mock.On("AddAttachment", ctx, domainOrGroup, image)}
7171 }
7272
@@ -128,7 +128,7 @@ type MockControlPlane_AddAutoRouteAllowedDomain_Call struct {
128128 // AddAutoRouteAllowedDomain is a helper method to define mock.On call
129129 // - ctx context.Context
130130 // - pattern string
131-func (_e *MockControlPlane_Expecter) AddAutoRouteAllowedDomain(ctx interface{}, pattern interface{}) *MockControlPlane_AddAutoRouteAllowedDomain_Call {
131+func (_e *MockControlPlane_Expecter) AddAutoRouteAllowedDomain(ctx any, pattern any) *MockControlPlane_AddAutoRouteAllowedDomain_Call {
132132 return &MockControlPlane_AddAutoRouteAllowedDomain_Call{Call: _e.mock.On("AddAutoRouteAllowedDomain", ctx, pattern)}
133133 }
134134
@@ -185,7 +185,7 @@ type MockControlPlane_AddRoute_Call struct {
185185 // AddRoute is a helper method to define mock.On call
186186 // - ctx context.Context
187187 // - route domain.Route
188-func (_e *MockControlPlane_Expecter) AddRoute(ctx interface{}, route interface{}) *MockControlPlane_AddRoute_Call {
188+func (_e *MockControlPlane_Expecter) AddRoute(ctx any, route any) *MockControlPlane_AddRoute_Call {
189189 return &MockControlPlane_AddRoute_Call{Call: _e.mock.On("AddRoute", ctx, route)}
190190 }
191191
@@ -252,7 +252,7 @@ type MockControlPlane_BackupStatus_Call struct {
252252
253253 // BackupStatus is a helper method to define mock.On call
254254 // - ctx context.Context
255-func (_e *MockControlPlane_Expecter) BackupStatus(ctx interface{}) *MockControlPlane_BackupStatus_Call {
255+func (_e *MockControlPlane_Expecter) BackupStatus(ctx any) *MockControlPlane_BackupStatus_Call {
256256 return &MockControlPlane_BackupStatus_Call{Call: _e.mock.On("BackupStatus", ctx)}
257257 }
258258
@@ -315,7 +315,7 @@ type MockControlPlane_Bootstrap_Call struct {
315315 // Bootstrap is a helper method to define mock.On call
316316 // - ctx context.Context
317317 // - req dto.BootstrapRequest
318-func (_e *MockControlPlane_Expecter) Bootstrap(ctx interface{}, req interface{}) *MockControlPlane_Bootstrap_Call {
318+func (_e *MockControlPlane_Expecter) Bootstrap(ctx any, req any) *MockControlPlane_Bootstrap_Call {
319319 return &MockControlPlane_Bootstrap_Call{Call: _e.mock.On("Bootstrap", ctx, req)}
320320 }
321321
@@ -384,7 +384,7 @@ type MockControlPlane_CleanupOrphanedAttachments_Call struct {
384384 // - ctx context.Context
385385 // - owner string
386386 // - stop bool
387-func (_e *MockControlPlane_Expecter) CleanupOrphanedAttachments(ctx interface{}, owner interface{}, stop interface{}) *MockControlPlane_CleanupOrphanedAttachments_Call {
387+func (_e *MockControlPlane_Expecter) CleanupOrphanedAttachments(ctx any, owner any, stop any) *MockControlPlane_CleanupOrphanedAttachments_Call {
388388 return &MockControlPlane_CleanupOrphanedAttachments_Call{Call: _e.mock.On("CleanupOrphanedAttachments", ctx, owner, stop)}
389389 }
390390
@@ -448,7 +448,7 @@ type MockControlPlane_DeleteAttachmentSecret_Call struct {
448448 // - domain1 string
449449 // - service string
450450 // - key string
451-func (_e *MockControlPlane_Expecter) DeleteAttachmentSecret(ctx interface{}, domain1 interface{}, service interface{}, key interface{}) *MockControlPlane_DeleteAttachmentSecret_Call {
451+func (_e *MockControlPlane_Expecter) DeleteAttachmentSecret(ctx any, domain1 any, service any, key any) *MockControlPlane_DeleteAttachmentSecret_Call {
452452 return &MockControlPlane_DeleteAttachmentSecret_Call{Call: _e.mock.On("DeleteAttachmentSecret", ctx, domain1, service, key)}
453453 }
454454
@@ -516,7 +516,7 @@ type MockControlPlane_DeleteSecret_Call struct {
516516 // - ctx context.Context
517517 // - secretDomain string
518518 // - key string
519-func (_e *MockControlPlane_Expecter) DeleteSecret(ctx interface{}, secretDomain interface{}, key interface{}) *MockControlPlane_DeleteSecret_Call {
519+func (_e *MockControlPlane_Expecter) DeleteSecret(ctx any, secretDomain any, key any) *MockControlPlane_DeleteSecret_Call {
520520 return &MockControlPlane_DeleteSecret_Call{Call: _e.mock.On("DeleteSecret", ctx, secretDomain, key)}
521521 }
522522
@@ -589,7 +589,7 @@ type MockControlPlane_Deploy_Call struct {
589589 // Deploy is a helper method to define mock.On call
590590 // - ctx context.Context
591591 // - deployDomain string
592-func (_e *MockControlPlane_Expecter) Deploy(ctx interface{}, deployDomain interface{}) *MockControlPlane_Deploy_Call {
592+func (_e *MockControlPlane_Expecter) Deploy(ctx any, deployDomain any) *MockControlPlane_Deploy_Call {
593593 return &MockControlPlane_Deploy_Call{Call: _e.mock.On("Deploy", ctx, deployDomain)}
594594 }
595595
@@ -646,7 +646,7 @@ type MockControlPlane_DeployIntent_Call struct {
646646 // DeployIntent is a helper method to define mock.On call
647647 // - ctx context.Context
648648 // - imageName string
649-func (_e *MockControlPlane_Expecter) DeployIntent(ctx interface{}, imageName interface{}) *MockControlPlane_DeployIntent_Call {
649+func (_e *MockControlPlane_Expecter) DeployIntent(ctx any, imageName any) *MockControlPlane_DeployIntent_Call {
650650 return &MockControlPlane_DeployIntent_Call{Call: _e.mock.On("DeployIntent", ctx, imageName)}
651651 }
652652
@@ -714,7 +714,7 @@ type MockControlPlane_DetectDatabases_Call struct {
714714 // DetectDatabases is a helper method to define mock.On call
715715 // - ctx context.Context
716716 // - backupDomain string
717-func (_e *MockControlPlane_Expecter) DetectDatabases(ctx interface{}, backupDomain interface{}) *MockControlPlane_DetectDatabases_Call {
717+func (_e *MockControlPlane_Expecter) DetectDatabases(ctx any, backupDomain any) *MockControlPlane_DetectDatabases_Call {
718718 return &MockControlPlane_DetectDatabases_Call{Call: _e.mock.On("DetectDatabases", ctx, backupDomain)}
719719 }
720720
@@ -782,7 +782,7 @@ type MockControlPlane_FindAttachmentTargetsByImage_Call struct {
782782 // FindAttachmentTargetsByImage is a helper method to define mock.On call
783783 // - ctx context.Context
784784 // - imageName string
785-func (_e *MockControlPlane_Expecter) FindAttachmentTargetsByImage(ctx interface{}, imageName interface{}) *MockControlPlane_FindAttachmentTargetsByImage_Call {
785+func (_e *MockControlPlane_Expecter) FindAttachmentTargetsByImage(ctx any, imageName any) *MockControlPlane_FindAttachmentTargetsByImage_Call {
786786 return &MockControlPlane_FindAttachmentTargetsByImage_Call{Call: _e.mock.On("FindAttachmentTargetsByImage", ctx, imageName)}
787787 }
788788
@@ -850,7 +850,7 @@ type MockControlPlane_FindRoutesByImage_Call struct {
850850 // FindRoutesByImage is a helper method to define mock.On call
851851 // - ctx context.Context
852852 // - imageName string
853-func (_e *MockControlPlane_Expecter) FindRoutesByImage(ctx interface{}, imageName interface{}) *MockControlPlane_FindRoutesByImage_Call {
853+func (_e *MockControlPlane_Expecter) FindRoutesByImage(ctx any, imageName any) *MockControlPlane_FindRoutesByImage_Call {
854854 return &MockControlPlane_FindRoutesByImage_Call{Call: _e.mock.On("FindRoutesByImage", ctx, imageName)}
855855 }
856856
@@ -917,7 +917,7 @@ type MockControlPlane_GetAllAttachmentsConfig_Call struct {
917917
918918 // GetAllAttachmentsConfig is a helper method to define mock.On call
919919 // - ctx context.Context
920-func (_e *MockControlPlane_Expecter) GetAllAttachmentsConfig(ctx interface{}) *MockControlPlane_GetAllAttachmentsConfig_Call {
920+func (_e *MockControlPlane_Expecter) GetAllAttachmentsConfig(ctx any) *MockControlPlane_GetAllAttachmentsConfig_Call {
921921 return &MockControlPlane_GetAllAttachmentsConfig_Call{Call: _e.mock.On("GetAllAttachmentsConfig", ctx)}
922922 }
923923
@@ -980,7 +980,7 @@ type MockControlPlane_GetAttachmentsConfig_Call struct {
980980 // GetAttachmentsConfig is a helper method to define mock.On call
981981 // - ctx context.Context
982982 // - domainOrGroup string
983-func (_e *MockControlPlane_Expecter) GetAttachmentsConfig(ctx interface{}, domainOrGroup interface{}) *MockControlPlane_GetAttachmentsConfig_Call {
983+func (_e *MockControlPlane_Expecter) GetAttachmentsConfig(ctx any, domainOrGroup any) *MockControlPlane_GetAttachmentsConfig_Call {
984984 return &MockControlPlane_GetAttachmentsConfig_Call{Call: _e.mock.On("GetAttachmentsConfig", ctx, domainOrGroup)}
985985 }
986986
@@ -1047,7 +1047,7 @@ type MockControlPlane_GetAutoRouteAllowedDomains_Call struct {
10471047
10481048 // GetAutoRouteAllowedDomains is a helper method to define mock.On call
10491049 // - ctx context.Context
1050-func (_e *MockControlPlane_Expecter) GetAutoRouteAllowedDomains(ctx interface{}) *MockControlPlane_GetAutoRouteAllowedDomains_Call {
1050+func (_e *MockControlPlane_Expecter) GetAutoRouteAllowedDomains(ctx any) *MockControlPlane_GetAutoRouteAllowedDomains_Call {
10511051 return &MockControlPlane_GetAutoRouteAllowedDomains_Call{Call: _e.mock.On("GetAutoRouteAllowedDomains", ctx)}
10521052 }
10531053
@@ -1109,7 +1109,7 @@ type MockControlPlane_GetConfig_Call struct {
11091109
11101110 // GetConfig is a helper method to define mock.On call
11111111 // - ctx context.Context
1112-func (_e *MockControlPlane_Expecter) GetConfig(ctx interface{}) *MockControlPlane_GetConfig_Call {
1112+func (_e *MockControlPlane_Expecter) GetConfig(ctx any) *MockControlPlane_GetConfig_Call {
11131113 return &MockControlPlane_GetConfig_Call{Call: _e.mock.On("GetConfig", ctx)}
11141114 }
11151115
@@ -1173,7 +1173,7 @@ type MockControlPlane_GetContainerLogs_Call struct {
11731173 // - ctx context.Context
11741174 // - logDomain string
11751175 // - lines int
1176-func (_e *MockControlPlane_Expecter) GetContainerLogs(ctx interface{}, logDomain interface{}, lines interface{}) *MockControlPlane_GetContainerLogs_Call {
1176+func (_e *MockControlPlane_Expecter) GetContainerLogs(ctx any, logDomain any, lines any) *MockControlPlane_GetContainerLogs_Call {
11771177 return &MockControlPlane_GetContainerLogs_Call{Call: _e.mock.On("GetContainerLogs", ctx, logDomain, lines)}
11781178 }
11791179
@@ -1245,7 +1245,7 @@ type MockControlPlane_GetHealth_Call struct {
12451245
12461246 // GetHealth is a helper method to define mock.On call
12471247 // - ctx context.Context
1248-func (_e *MockControlPlane_Expecter) GetHealth(ctx interface{}) *MockControlPlane_GetHealth_Call {
1248+func (_e *MockControlPlane_Expecter) GetHealth(ctx any) *MockControlPlane_GetHealth_Call {
12491249 return &MockControlPlane_GetHealth_Call{Call: _e.mock.On("GetHealth", ctx)}
12501250 }
12511251
@@ -1308,7 +1308,7 @@ type MockControlPlane_GetProcessLogs_Call struct {
13081308 // GetProcessLogs is a helper method to define mock.On call
13091309 // - ctx context.Context
13101310 // - lines int
1311-func (_e *MockControlPlane_Expecter) GetProcessLogs(ctx interface{}, lines interface{}) *MockControlPlane_GetProcessLogs_Call {
1311+func (_e *MockControlPlane_Expecter) GetProcessLogs(ctx any, lines any) *MockControlPlane_GetProcessLogs_Call {
13121312 return &MockControlPlane_GetProcessLogs_Call{Call: _e.mock.On("GetProcessLogs", ctx, lines)}
13131313 }
13141314
@@ -1376,7 +1376,7 @@ type MockControlPlane_GetRoute_Call struct {
13761376 // GetRoute is a helper method to define mock.On call
13771377 // - ctx context.Context
13781378 // - routeDomain string
1379-func (_e *MockControlPlane_Expecter) GetRoute(ctx interface{}, routeDomain interface{}) *MockControlPlane_GetRoute_Call {
1379+func (_e *MockControlPlane_Expecter) GetRoute(ctx any, routeDomain any) *MockControlPlane_GetRoute_Call {
13801380 return &MockControlPlane_GetRoute_Call{Call: _e.mock.On("GetRoute", ctx, routeDomain)}
13811381 }
13821382
@@ -1443,7 +1443,7 @@ type MockControlPlane_GetStatus_Call struct {
14431443
14441444 // GetStatus is a helper method to define mock.On call
14451445 // - ctx context.Context
1446-func (_e *MockControlPlane_Expecter) GetStatus(ctx interface{}) *MockControlPlane_GetStatus_Call {
1446+func (_e *MockControlPlane_Expecter) GetStatus(ctx any) *MockControlPlane_GetStatus_Call {
14471447 return &MockControlPlane_GetStatus_Call{Call: _e.mock.On("GetStatus", ctx)}
14481448 }
14491449
@@ -1505,7 +1505,7 @@ type MockControlPlane_GetTLSStatus_Call struct {
15051505
15061506 // GetTLSStatus is a helper method to define mock.On call
15071507 // - ctx context.Context
1508-func (_e *MockControlPlane_Expecter) GetTLSStatus(ctx interface{}) *MockControlPlane_GetTLSStatus_Call {
1508+func (_e *MockControlPlane_Expecter) GetTLSStatus(ctx any) *MockControlPlane_GetTLSStatus_Call {
15091509 return &MockControlPlane_GetTLSStatus_Call{Call: _e.mock.On("GetTLSStatus", ctx)}
15101510 }
15111511
@@ -1532,6 +1532,68 @@ func (_c *MockControlPlane_GetTLSStatus_Call) RunAndReturn(run func(ctx context.
15321532 return _c
15331533 }
15341534
1535+// GetTrafficStatus provides a mock function for the type MockControlPlane
1536+func (_mock *MockControlPlane) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) {
1537+ ret := _mock.Called(ctx)
1538+
1539+ if len(ret) == 0 {
1540+ panic("no return value specified for GetTrafficStatus")
1541+ }
1542+
1543+ var r0 *dto.TrafficStatusResponse
1544+ var r1 error
1545+ if returnFunc, ok := ret.Get(0).(func(context.Context) (*dto.TrafficStatusResponse, error)); ok {
1546+ return returnFunc(ctx)
1547+ }
1548+ if returnFunc, ok := ret.Get(0).(func(context.Context) *dto.TrafficStatusResponse); ok {
1549+ r0 = returnFunc(ctx)
1550+ } else {
1551+ if ret.Get(0) != nil {
1552+ r0 = ret.Get(0).(*dto.TrafficStatusResponse)
1553+ }
1554+ }
1555+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
1556+ r1 = returnFunc(ctx)
1557+ } else {
1558+ r1 = ret.Error(1)
1559+ }
1560+ return r0, r1
1561+}
1562+
1563+// MockControlPlane_GetTrafficStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTrafficStatus'
1564+type MockControlPlane_GetTrafficStatus_Call struct {
1565+ *mock.Call
1566+}
1567+
1568+// GetTrafficStatus is a helper method to define mock.On call
1569+// - ctx context.Context
1570+func (_e *MockControlPlane_Expecter) GetTrafficStatus(ctx any) *MockControlPlane_GetTrafficStatus_Call {
1571+ return &MockControlPlane_GetTrafficStatus_Call{Call: _e.mock.On("GetTrafficStatus", ctx)}
1572+}
1573+
1574+func (_c *MockControlPlane_GetTrafficStatus_Call) Run(run func(ctx context.Context)) *MockControlPlane_GetTrafficStatus_Call {
1575+ _c.Call.Run(func(args mock.Arguments) {
1576+ var arg0 context.Context
1577+ if args[0] != nil {
1578+ arg0 = args[0].(context.Context)
1579+ }
1580+ run(
1581+ arg0,
1582+ )
1583+ })
1584+ return _c
1585+}
1586+
1587+func (_c *MockControlPlane_GetTrafficStatus_Call) Return(trafficStatusResponse *dto.TrafficStatusResponse, err error) *MockControlPlane_GetTrafficStatus_Call {
1588+ _c.Call.Return(trafficStatusResponse, err)
1589+ return _c
1590+}
1591+
1592+func (_c *MockControlPlane_GetTrafficStatus_Call) RunAndReturn(run func(ctx context.Context) (*dto.TrafficStatusResponse, error)) *MockControlPlane_GetTrafficStatus_Call {
1593+ _c.Call.Return(run)
1594+ return _c
1595+}
1596+
15351597 // ListBackups provides a mock function for the type MockControlPlane
15361598 func (_mock *MockControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
15371599 ret := _mock.Called(ctx, backupDomain)
@@ -1568,7 +1630,7 @@ type MockControlPlane_ListBackups_Call struct {
15681630 // ListBackups is a helper method to define mock.On call
15691631 // - ctx context.Context
15701632 // - backupDomain string
1571-func (_e *MockControlPlane_Expecter) ListBackups(ctx interface{}, backupDomain interface{}) *MockControlPlane_ListBackups_Call {
1633+func (_e *MockControlPlane_Expecter) ListBackups(ctx any, backupDomain any) *MockControlPlane_ListBackups_Call {
15721634 return &MockControlPlane_ListBackups_Call{Call: _e.mock.On("ListBackups", ctx, backupDomain)}
15731635 }
15741636
@@ -1635,7 +1697,7 @@ type MockControlPlane_ListNetworks_Call struct {
16351697
16361698 // ListNetworks is a helper method to define mock.On call
16371699 // - ctx context.Context
1638-func (_e *MockControlPlane_Expecter) ListNetworks(ctx interface{}) *MockControlPlane_ListNetworks_Call {
1700+func (_e *MockControlPlane_Expecter) ListNetworks(ctx any) *MockControlPlane_ListNetworks_Call {
16391701 return &MockControlPlane_ListNetworks_Call{Call: _e.mock.On("ListNetworks", ctx)}
16401702 }
16411703
@@ -1697,7 +1759,7 @@ type MockControlPlane_ListOrphanedAttachments_Call struct {
16971759
16981760 // ListOrphanedAttachments is a helper method to define mock.On call
16991761 // - ctx context.Context
1700-func (_e *MockControlPlane_Expecter) ListOrphanedAttachments(ctx interface{}) *MockControlPlane_ListOrphanedAttachments_Call {
1762+func (_e *MockControlPlane_Expecter) ListOrphanedAttachments(ctx any) *MockControlPlane_ListOrphanedAttachments_Call {
17011763 return &MockControlPlane_ListOrphanedAttachments_Call{Call: _e.mock.On("ListOrphanedAttachments", ctx)}
17021764 }
17031765
@@ -1759,7 +1821,7 @@ type MockControlPlane_ListRoutesWithDetails_Call struct {
17591821
17601822 // ListRoutesWithDetails is a helper method to define mock.On call
17611823 // - ctx context.Context
1762-func (_e *MockControlPlane_Expecter) ListRoutesWithDetails(ctx interface{}) *MockControlPlane_ListRoutesWithDetails_Call {
1824+func (_e *MockControlPlane_Expecter) ListRoutesWithDetails(ctx any) *MockControlPlane_ListRoutesWithDetails_Call {
17631825 return &MockControlPlane_ListRoutesWithDetails_Call{Call: _e.mock.On("ListRoutesWithDetails", ctx)}
17641826 }
17651827
@@ -1822,7 +1884,7 @@ type MockControlPlane_ListSecretsWithAttachments_Call struct {
18221884 // ListSecretsWithAttachments is a helper method to define mock.On call
18231885 // - ctx context.Context
18241886 // - secretDomain string
1825-func (_e *MockControlPlane_Expecter) ListSecretsWithAttachments(ctx interface{}, secretDomain interface{}) *MockControlPlane_ListSecretsWithAttachments_Call {
1887+func (_e *MockControlPlane_Expecter) ListSecretsWithAttachments(ctx any, secretDomain any) *MockControlPlane_ListSecretsWithAttachments_Call {
18261888 return &MockControlPlane_ListSecretsWithAttachments_Call{Call: _e.mock.On("ListSecretsWithAttachments", ctx, secretDomain)}
18271889 }
18281890
@@ -1890,7 +1952,7 @@ type MockControlPlane_ListTags_Call struct {
18901952 // ListTags is a helper method to define mock.On call
18911953 // - ctx context.Context
18921954 // - repository string
1893-func (_e *MockControlPlane_Expecter) ListTags(ctx interface{}, repository interface{}) *MockControlPlane_ListTags_Call {
1955+func (_e *MockControlPlane_Expecter) ListTags(ctx any, repository any) *MockControlPlane_ListTags_Call {
18941956 return &MockControlPlane_ListTags_Call{Call: _e.mock.On("ListTags", ctx, repository)}
18951957 }
18961958
@@ -1958,7 +2020,7 @@ type MockControlPlane_ListVolumeBackups_Call struct {
19582020 // ListVolumeBackups is a helper method to define mock.On call
19592021 // - ctx context.Context
19602022 // - backupDomain string
1961-func (_e *MockControlPlane_Expecter) ListVolumeBackups(ctx interface{}, backupDomain interface{}) *MockControlPlane_ListVolumeBackups_Call {
2023+func (_e *MockControlPlane_Expecter) ListVolumeBackups(ctx any, backupDomain any) *MockControlPlane_ListVolumeBackups_Call {
19622024 return &MockControlPlane_ListVolumeBackups_Call{Call: _e.mock.On("ListVolumeBackups", ctx, backupDomain)}
19632025 }
19642026
@@ -2025,7 +2087,7 @@ type MockControlPlane_ListVolumes_Call struct {
20252087
20262088 // ListVolumes is a helper method to define mock.On call
20272089 // - ctx context.Context
2028-func (_e *MockControlPlane_Expecter) ListVolumes(ctx interface{}) *MockControlPlane_ListVolumes_Call {
2090+func (_e *MockControlPlane_Expecter) ListVolumes(ctx any) *MockControlPlane_ListVolumes_Call {
20292091 return &MockControlPlane_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)}
20302092 }
20312093
@@ -2088,7 +2150,7 @@ type MockControlPlane_PruneVolumes_Call struct {
20882150 // PruneVolumes is a helper method to define mock.On call
20892151 // - ctx context.Context
20902152 // - req dto.VolumePruneRequest
2091-func (_e *MockControlPlane_Expecter) PruneVolumes(ctx interface{}, req interface{}) *MockControlPlane_PruneVolumes_Call {
2153+func (_e *MockControlPlane_Expecter) PruneVolumes(ctx any, req any) *MockControlPlane_PruneVolumes_Call {
20922154 return &MockControlPlane_PruneVolumes_Call{Call: _e.mock.On("PruneVolumes", ctx, req)}
20932155 }
20942156
@@ -2144,7 +2206,7 @@ type MockControlPlane_Reload_Call struct {
21442206
21452207 // Reload is a helper method to define mock.On call
21462208 // - ctx context.Context
2147-func (_e *MockControlPlane_Expecter) Reload(ctx interface{}) *MockControlPlane_Reload_Call {
2209+func (_e *MockControlPlane_Expecter) Reload(ctx any) *MockControlPlane_Reload_Call {
21482210 return &MockControlPlane_Reload_Call{Call: _e.mock.On("Reload", ctx)}
21492211 }
21502212
@@ -2197,7 +2259,7 @@ type MockControlPlane_RemoveAttachment_Call struct {
21972259 // - ctx context.Context
21982260 // - domainOrGroup string
21992261 // - image string
2200-func (_e *MockControlPlane_Expecter) RemoveAttachment(ctx interface{}, domainOrGroup interface{}, image interface{}) *MockControlPlane_RemoveAttachment_Call {
2262+func (_e *MockControlPlane_Expecter) RemoveAttachment(ctx any, domainOrGroup any, image any) *MockControlPlane_RemoveAttachment_Call {
22012263 return &MockControlPlane_RemoveAttachment_Call{Call: _e.mock.On("RemoveAttachment", ctx, domainOrGroup, image)}
22022264 }
22032265
@@ -2259,7 +2321,7 @@ type MockControlPlane_RemoveAutoRouteAllowedDomain_Call struct {
22592321 // RemoveAutoRouteAllowedDomain is a helper method to define mock.On call
22602322 // - ctx context.Context
22612323 // - pattern string
2262-func (_e *MockControlPlane_Expecter) RemoveAutoRouteAllowedDomain(ctx interface{}, pattern interface{}) *MockControlPlane_RemoveAutoRouteAllowedDomain_Call {
2324+func (_e *MockControlPlane_Expecter) RemoveAutoRouteAllowedDomain(ctx any, pattern any) *MockControlPlane_RemoveAutoRouteAllowedDomain_Call {
22632325 return &MockControlPlane_RemoveAutoRouteAllowedDomain_Call{Call: _e.mock.On("RemoveAutoRouteAllowedDomain", ctx, pattern)}
22642326 }
22652327
@@ -2316,7 +2378,7 @@ type MockControlPlane_RemoveRoute_Call struct {
23162378 // RemoveRoute is a helper method to define mock.On call
23172379 // - ctx context.Context
23182380 // - routeDomain string
2319-func (_e *MockControlPlane_Expecter) RemoveRoute(ctx interface{}, routeDomain interface{}) *MockControlPlane_RemoveRoute_Call {
2381+func (_e *MockControlPlane_Expecter) RemoveRoute(ctx any, routeDomain any) *MockControlPlane_RemoveRoute_Call {
23202382 return &MockControlPlane_RemoveRoute_Call{Call: _e.mock.On("RemoveRoute", ctx, routeDomain)}
23212383 }
23222384
@@ -2385,7 +2447,7 @@ type MockControlPlane_Restart_Call struct {
23852447 // - ctx context.Context
23862448 // - restartDomain string
23872449 // - withAttachments bool
2388-func (_e *MockControlPlane_Expecter) Restart(ctx interface{}, restartDomain interface{}, withAttachments interface{}) *MockControlPlane_Restart_Call {
2450+func (_e *MockControlPlane_Expecter) Restart(ctx any, restartDomain any, withAttachments any) *MockControlPlane_Restart_Call {
23892451 return &MockControlPlane_Restart_Call{Call: _e.mock.On("Restart", ctx, restartDomain, withAttachments)}
23902452 }
23912453
@@ -2459,7 +2521,7 @@ type MockControlPlane_RunBackup_Call struct {
24592521 // - ctx context.Context
24602522 // - backupDomain string
24612523 // - dbName string
2462-func (_e *MockControlPlane_Expecter) RunBackup(ctx interface{}, backupDomain interface{}, dbName interface{}) *MockControlPlane_RunBackup_Call {
2524+func (_e *MockControlPlane_Expecter) RunBackup(ctx any, backupDomain any, dbName any) *MockControlPlane_RunBackup_Call {
24632525 return &MockControlPlane_RunBackup_Call{Call: _e.mock.On("RunBackup", ctx, backupDomain, dbName)}
24642526 }
24652527
@@ -2533,7 +2595,7 @@ type MockControlPlane_RunVolumeBackups_Call struct {
25332595 // - ctx context.Context
25342596 // - backupDomain string
25352597 // - volumeName string
2536-func (_e *MockControlPlane_Expecter) RunVolumeBackups(ctx interface{}, backupDomain interface{}, volumeName interface{}) *MockControlPlane_RunVolumeBackups_Call {
2598+func (_e *MockControlPlane_Expecter) RunVolumeBackups(ctx any, backupDomain any, volumeName any) *MockControlPlane_RunVolumeBackups_Call {
25372599 return &MockControlPlane_RunVolumeBackups_Call{Call: _e.mock.On("RunVolumeBackups", ctx, backupDomain, volumeName)}
25382600 }
25392601
@@ -2597,7 +2659,7 @@ type MockControlPlane_SetAttachmentSecrets_Call struct {
25972659 // - domain1 string
25982660 // - service string
25992661 // - secrets map[string]string
2600-func (_e *MockControlPlane_Expecter) SetAttachmentSecrets(ctx interface{}, domain1 interface{}, service interface{}, secrets interface{}) *MockControlPlane_SetAttachmentSecrets_Call {
2662+func (_e *MockControlPlane_Expecter) SetAttachmentSecrets(ctx any, domain1 any, service any, secrets any) *MockControlPlane_SetAttachmentSecrets_Call {
26012663 return &MockControlPlane_SetAttachmentSecrets_Call{Call: _e.mock.On("SetAttachmentSecrets", ctx, domain1, service, secrets)}
26022664 }
26032665
@@ -2665,7 +2727,7 @@ type MockControlPlane_SetSecrets_Call struct {
26652727 // - ctx context.Context
26662728 // - secretDomain string
26672729 // - secrets map[string]string
2668-func (_e *MockControlPlane_Expecter) SetSecrets(ctx interface{}, secretDomain interface{}, secrets interface{}) *MockControlPlane_SetSecrets_Call {
2730+func (_e *MockControlPlane_Expecter) SetSecrets(ctx any, secretDomain any, secrets any) *MockControlPlane_SetSecrets_Call {
26692731 return &MockControlPlane_SetSecrets_Call{Call: _e.mock.On("SetSecrets", ctx, secretDomain, secrets)}
26702732 }
26712733
@@ -2739,7 +2801,7 @@ type MockControlPlane_StreamContainerLogs_Call struct {
27392801 // - ctx context.Context
27402802 // - logDomain string
27412803 // - lines int
2742-func (_e *MockControlPlane_Expecter) StreamContainerLogs(ctx interface{}, logDomain interface{}, lines interface{}) *MockControlPlane_StreamContainerLogs_Call {
2804+func (_e *MockControlPlane_Expecter) StreamContainerLogs(ctx any, logDomain any, lines any) *MockControlPlane_StreamContainerLogs_Call {
27432805 return &MockControlPlane_StreamContainerLogs_Call{Call: _e.mock.On("StreamContainerLogs", ctx, logDomain, lines)}
27442806 }
27452807
@@ -2812,7 +2874,7 @@ type MockControlPlane_StreamProcessLogs_Call struct {
28122874 // StreamProcessLogs is a helper method to define mock.On call
28132875 // - ctx context.Context
28142876 // - lines int
2815-func (_e *MockControlPlane_Expecter) StreamProcessLogs(ctx interface{}, lines interface{}) *MockControlPlane_StreamProcessLogs_Call {
2877+func (_e *MockControlPlane_Expecter) StreamProcessLogs(ctx any, lines any) *MockControlPlane_StreamProcessLogs_Call {
28162878 return &MockControlPlane_StreamProcessLogs_Call{Call: _e.mock.On("StreamProcessLogs", ctx, lines)}
28172879 }
28182880
@@ -2869,7 +2931,7 @@ type MockControlPlane_UpdateRoute_Call struct {
28692931 // UpdateRoute is a helper method to define mock.On call
28702932 // - ctx context.Context
28712933 // - route domain.Route
2872-func (_e *MockControlPlane_Expecter) UpdateRoute(ctx interface{}, route interface{}) *MockControlPlane_UpdateRoute_Call {
2934+func (_e *MockControlPlane_Expecter) UpdateRoute(ctx any, route any) *MockControlPlane_UpdateRoute_Call {
28732935 return &MockControlPlane_UpdateRoute_Call{Call: _e.mock.On("UpdateRoute", ctx, route)}
28742936 }
28752937
@@ -2936,7 +2998,7 @@ type MockControlPlane_VolumeBackupStatus_Call struct {
29362998
29372999 // VolumeBackupStatus is a helper method to define mock.On call
29383000 // - ctx context.Context
2939-func (_e *MockControlPlane_Expecter) VolumeBackupStatus(ctx interface{}) *MockControlPlane_VolumeBackupStatus_Call {
3001+func (_e *MockControlPlane_Expecter) VolumeBackupStatus(ctx any) *MockControlPlane_VolumeBackupStatus_Call {
29403002 return &MockControlPlane_VolumeBackupStatus_Call{Call: _e.mock.On("VolumeBackupStatus", ctx)}
29413003 }
29423004
M internal/adapters/in/cli/mocks/mock_images_client.go +2 -2
@@ -73,7 +73,7 @@ type MockimagesClient_ListImages_Call struct {
7373
7474 // ListImages is a helper method to define mock.On call
7575 // - ctx context.Context
76-func (_e *MockimagesClient_Expecter) ListImages(ctx interface{}) *MockimagesClient_ListImages_Call {
76+func (_e *MockimagesClient_Expecter) ListImages(ctx any) *MockimagesClient_ListImages_Call {
7777 return &MockimagesClient_ListImages_Call{Call: _e.mock.On("ListImages", ctx)}
7878 }
7979
@@ -136,7 +136,7 @@ type MockimagesClient_PruneImages_Call struct {
136136 // PruneImages is a helper method to define mock.On call
137137 // - ctx context.Context
138138 // - req dto.ImagePruneRequest
139-func (_e *MockimagesClient_Expecter) PruneImages(ctx interface{}, req interface{}) *MockimagesClient_PruneImages_Call {
139+func (_e *MockimagesClient_Expecter) PruneImages(ctx any, req any) *MockimagesClient_PruneImages_Call {
140140 return &MockimagesClient_PruneImages_Call{Call: _e.mock.On("PruneImages", ctx, req)}
141141 }
142142
M internal/adapters/in/cli/mocks/mock_push_image_ops.go +4 -4
@@ -62,7 +62,7 @@ type MockpushImageOps_Build_Call struct {
6262 // Build is a helper method to define mock.On call
6363 // - ctx context.Context
6464 // - args []string
65-func (_e *MockpushImageOps_Expecter) Build(ctx interface{}, args interface{}) *MockpushImageOps_Build_Call {
65+func (_e *MockpushImageOps_Expecter) Build(ctx any, args any) *MockpushImageOps_Build_Call {
6666 return &MockpushImageOps_Build_Call{Call: _e.mock.On("Build", ctx, args)}
6767 }
6868
@@ -128,7 +128,7 @@ type MockpushImageOps_Exists_Call struct {
128128 // Exists is a helper method to define mock.On call
129129 // - ctx context.Context
130130 // - imageRef string
131-func (_e *MockpushImageOps_Expecter) Exists(ctx interface{}, imageRef interface{}) *MockpushImageOps_Exists_Call {
131+func (_e *MockpushImageOps_Expecter) Exists(ctx any, imageRef any) *MockpushImageOps_Exists_Call {
132132 return &MockpushImageOps_Exists_Call{Call: _e.mock.On("Exists", ctx, imageRef)}
133133 }
134134
@@ -185,7 +185,7 @@ type MockpushImageOps_Push_Call struct {
185185 // Push is a helper method to define mock.On call
186186 // - ctx context.Context
187187 // - ref string
188-func (_e *MockpushImageOps_Expecter) Push(ctx interface{}, ref interface{}) *MockpushImageOps_Push_Call {
188+func (_e *MockpushImageOps_Expecter) Push(ctx any, ref any) *MockpushImageOps_Push_Call {
189189 return &MockpushImageOps_Push_Call{Call: _e.mock.On("Push", ctx, ref)}
190190 }
191191
@@ -243,7 +243,7 @@ type MockpushImageOps_Tag_Call struct {
243243 // - ctx context.Context
244244 // - sourceRef string
245245 // - targetRef string
246-func (_e *MockpushImageOps_Expecter) Tag(ctx interface{}, sourceRef interface{}, targetRef interface{}) *MockpushImageOps_Tag_Call {
246+func (_e *MockpushImageOps_Expecter) Tag(ctx any, sourceRef any, targetRef any) *MockpushImageOps_Tag_Call {
247247 return &MockpushImageOps_Tag_Call{Call: _e.mock.On("Tag", ctx, sourceRef, targetRef)}
248248 }
249249
M internal/adapters/in/cli/mocks/mock_volumes_client.go +2 -2
@@ -73,7 +73,7 @@ type MockvolumesClient_ListVolumes_Call struct {
7373
7474 // ListVolumes is a helper method to define mock.On call
7575 // - ctx context.Context
76-func (_e *MockvolumesClient_Expecter) ListVolumes(ctx interface{}) *MockvolumesClient_ListVolumes_Call {
76+func (_e *MockvolumesClient_Expecter) ListVolumes(ctx any) *MockvolumesClient_ListVolumes_Call {
7777 return &MockvolumesClient_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)}
7878 }
7979
@@ -136,7 +136,7 @@ type MockvolumesClient_PruneVolumes_Call struct {
136136 // PruneVolumes is a helper method to define mock.On call
137137 // - ctx context.Context
138138 // - req dto.VolumePruneRequest
139-func (_e *MockvolumesClient_Expecter) PruneVolumes(ctx interface{}, req interface{}) *MockvolumesClient_PruneVolumes_Call {
139+func (_e *MockvolumesClient_Expecter) PruneVolumes(ctx any, req any) *MockvolumesClient_PruneVolumes_Call {
140140 return &MockvolumesClient_PruneVolumes_Call{Call: _e.mock.On("PruneVolumes", ctx, req)}
141141 }
142142
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, fmt.Errorf("request traffic status: %w", err)
709+ }
710+
711+ var status dto.TrafficStatusResponse
712+ if err := parseResponse(resp, &status); err != nil {
713+ return nil, fmt.Errorf("parse traffic status response: %w", 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():
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)
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
A internal/adapters/in/cli/traffic.go +12 -0
@@ -0,0 +1,12 @@
1+package cli
2+
3+import "github.com/spf13/cobra"
4+
5+func newTrafficCmd() *cobra.Command {
6+ cmd := &cobra.Command{
7+ Use: "traffic",
8+ Short: "Inspect remote traffic plane status",
9+ }
10+ cmd.AddCommand(newTrafficStatusCmd())
11+ return cmd
12+}
A internal/adapters/in/cli/traffic_status.go +182 -0
@@ -0,0 +1,182 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "net"
8+
9+ "github.com/spf13/cobra"
10+
11+ "github.com/bnema/gordon/internal/adapters/dto"
12+)
13+
14+var trafficResolveControlPlane = resolveControlPlane
15+
16+func newTrafficStatusCmd() *cobra.Command {
17+ var jsonOut bool
18+ cmd := &cobra.Command{
19+ Use: "status",
20+ Short: "Show traffic entrypoint, router, and counter status",
21+ Args: cobra.NoArgs,
22+ RunE: func(cmd *cobra.Command, args []string) error {
23+ handle, err := trafficResolveControlPlane(configPath)
24+ if err != nil {
25+ return err
26+ }
27+ defer handle.close()
28+ return runTrafficStatus(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut)
29+ },
30+ }
31+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output JSON")
32+ return cmd
33+}
34+
35+func runTrafficStatus(ctx context.Context, cp ControlPlane, out io.Writer, jsonOut bool) error {
36+ status, err := cp.GetTrafficStatus(ctx)
37+ if err != nil {
38+ return fmt.Errorf("fetch traffic status: %w", err)
39+ }
40+ if err := renderTrafficStatus(out, status, jsonOut); err != nil {
41+ return fmt.Errorf("render traffic status: %w", err)
42+ }
43+ return nil
44+}
45+
46+func renderTrafficStatus(out io.Writer, status *dto.TrafficStatusResponse, jsonOut bool) error {
47+ if jsonOut {
48+ return writeJSON(out, status)
49+ }
50+ if err := renderTrafficStatusHeader(out, status); err != nil {
51+ return err
52+ }
53+ if err := renderTrafficEntryPoints(out, status.EntryPoints); err != nil {
54+ return err
55+ }
56+ if err := renderTrafficRouters(out, status.Routers); err != nil {
57+ return err
58+ }
59+ if err := renderTrafficServices(out, status.Services); err != nil {
60+ return err
61+ }
62+ return renderTrafficTotals(out, status.Counters)
63+}
64+
65+func renderTrafficStatusHeader(out io.Writer, status *dto.TrafficStatusResponse) error {
66+ if err := cliWriteLine(out, cliRenderTitle("Traffic Status")); err != nil {
67+ return err
68+ }
69+ if err := cliWriteLine(out, cliRenderMeta("Reload", status.LastReloadStatus)); err != nil {
70+ return err
71+ }
72+ if status.LastReloadError == "" {
73+ return nil
74+ }
75+ return cliWriteLine(out, cliRenderWarning("Last reload error: "+status.LastReloadError))
76+}
77+
78+func renderTrafficEntryPoints(out io.Writer, entries []dto.TrafficEntryPointStatus) error {
79+ if err := cliWriteLine(out, ""); err != nil {
80+ return err
81+ }
82+ if err := cliWriteLine(out, "Entrypoints:"); err != nil {
83+ return err
84+ }
85+ if len(entries) == 0 {
86+ return cliWriteLine(out, cliRenderMuted(" none"))
87+ }
88+ for _, entry := range entries {
89+ if err := renderTrafficEntryPoint(out, entry); err != nil {
90+ return err
91+ }
92+ }
93+ return nil
94+}
95+
96+func renderTrafficEntryPoint(out io.Writer, entry dto.TrafficEntryPointStatus) error {
97+ if err := cliWritef(out, " %s %s %s active=%t tcp=%d udp=%d accepted=%d refused=%d errors=%d\n",
98+ entry.Name, entry.Protocol, entry.Address, entry.Active, entry.ActiveTCPConnections, entry.ActiveUDPSessions,
99+ entry.TotalAccepted, entry.TotalRefused, entry.TotalErrors); err != nil {
100+ return err
101+ }
102+ if hasSmartTCPCounters(entry.SmartTCP) {
103+ return renderSmartTCPCounters(out, " smart_tcp", entry.SmartTCP)
104+ }
105+ return nil
106+}
107+
108+func renderTrafficTotals(out io.Writer, counters dto.TrafficCounters) error {
109+ if err := cliWriteLine(out, ""); err != nil {
110+ return err
111+ }
112+ if err := cliWritef(out, "Totals: tcp=%d udp=%d accepted=%d refused=%d errors=%d bytes_in=%d bytes_out=%d\n",
113+ counters.ActiveTCPConnections, counters.ActiveUDPSessions, counters.TotalAccepted,
114+ counters.TotalRefused, counters.TotalErrors, counters.BytesIn, counters.BytesOut); err != nil {
115+ return err
116+ }
117+ if hasSmartTCPCounters(counters.SmartTCP) {
118+ return renderSmartTCPCounters(out, "Smart TCP totals", counters.SmartTCP)
119+ }
120+ return nil
121+}
122+
123+func hasSmartTCPCounters(c dto.SmartTCPCounters) bool {
124+ return c.HTTPAccepted != 0 || c.H2CAccepted != 0 || c.HTTPSFallbackAccepted != 0 || c.TLSPassthroughAccepted != 0 ||
125+ c.RawFallbackAccepted != 0 || c.EntrypointCIDRRefused != 0 || c.RawFallbackCIDRRefused != 0 || c.PROXYRefused != 0 ||
126+ c.UnknownNoFallbackRefused != 0 || c.MalformedRejected != 0 || c.SniffTimeout != 0 || c.ClientHelloTooLarge != 0
127+}
128+
129+func renderSmartTCPCounters(out io.Writer, label string, c dto.SmartTCPCounters) error {
130+ return cliWritef(out, "%s: http_accepted=%d h2c_accepted=%d https_fallback_accepted=%d tls_passthrough_accepted=%d raw_fallback_accepted=%d entrypoint_cidr_refused=%d raw_fallback_cidr_refused=%d proxy_refused=%d unknown_no_fallback_refused=%d malformed_rejected=%d sniff_timeout=%d client_hello_too_large=%d\n",
131+ label, c.HTTPAccepted, c.H2CAccepted, c.HTTPSFallbackAccepted, c.TLSPassthroughAccepted, c.RawFallbackAccepted,
132+ c.EntrypointCIDRRefused, c.RawFallbackCIDRRefused, c.PROXYRefused, c.UnknownNoFallbackRefused, c.MalformedRejected,
133+ c.SniffTimeout, c.ClientHelloTooLarge)
134+}
135+
136+func renderTrafficRouters(out io.Writer, routers []dto.TrafficRouterStatus) error {
137+ if err := cliWriteLine(out, ""); err != nil {
138+ return err
139+ }
140+ if err := cliWriteLine(out, "Routers:"); err != nil {
141+ return err
142+ }
143+ if len(routers) == 0 {
144+ return cliWriteLine(out, cliRenderMuted(" none"))
145+ }
146+ for _, router := range routers {
147+ rule := router.Rule.Host
148+ if rule == "" {
149+ rule = router.Rule.SNI
150+ }
151+ if err := cliWritef(out, " %s %s entrypoint=%s rule=%s service=%s active=%t\n",
152+ router.Name, router.Protocol, router.EntryPoint, rule, router.Service, router.Active); err != nil {
153+ return err
154+ }
155+ }
156+ return nil
157+}
158+
159+func renderTrafficServices(out io.Writer, services []dto.TrafficServiceStatus) error {
160+ if err := cliWriteLine(out, ""); err != nil {
161+ return err
162+ }
163+ if err := cliWriteLine(out, "Services:"); err != nil {
164+ return err
165+ }
166+ if len(services) == 0 {
167+ return cliWriteLine(out, cliRenderMuted(" none"))
168+ }
169+ for _, service := range services {
170+ if err := cliWritef(out, " %s active=%t\n", service.Name, service.Active); err != nil {
171+ return err
172+ }
173+ for _, backend := range service.Backends {
174+ addr := net.JoinHostPort(backend.Host, fmt.Sprint(backend.Port))
175+ if err := cliWritef(out, " %s %s://%s active=%t\n",
176+ backend.Name, backend.Protocol, addr, backend.Active); err != nil {
177+ return err
178+ }
179+ }
180+ }
181+ return nil
182+}
A internal/adapters/in/cli/traffic_test.go +132 -0
@@ -0,0 +1,132 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "net/http"
9+ "net/http/httptest"
10+ "testing"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/gordon/internal/adapters/dto"
16+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
17+ "github.com/bnema/gordon/internal/domain"
18+)
19+
20+func TestTrafficCommandExists(t *testing.T) {
21+ cmd := NewRootCmd()
22+ traffic, _, err := cmd.Find([]string{"traffic"})
23+ require.NoError(t, err)
24+ require.NotNil(t, traffic)
25+ assert.Equal(t, "traffic", traffic.Name())
26+
27+ status, _, err := cmd.Find([]string{"traffic", "status"})
28+ require.NoError(t, err)
29+ require.NotNil(t, status)
30+ assert.Equal(t, "status", status.Name())
31+}
32+
33+func TestTrafficStatusRejectsArgs(t *testing.T) {
34+ cmd := newTrafficStatusCmd()
35+ require.Error(t, cmd.Args(cmd, []string{"extra"}))
36+}
37+
38+func TestTrafficStatusHumanOutput(t *testing.T) {
39+ status := &dto.TrafficStatusResponse{
40+ LastReloadStatus: "ok",
41+ EntryPoints: []dto.TrafficEntryPointStatus{{
42+ Name: "postgres", Address: "127.0.0.1:5432", Protocol: domain.EntryPointProtocolTCP,
43+ Active: true, ActiveTCPConnections: 1, TotalAccepted: 2, BytesIn: 10, BytesOut: 20,
44+ SmartTCP: dto.SmartTCPCounters{HTTPAccepted: 1, PROXYRefused: 1},
45+ }},
46+ Routers: []dto.TrafficRouterStatus{{
47+ Name: "pg-router", EntryPoint: "postgres", Protocol: domain.RouterProtocolTCP,
48+ Service: "network_service:postgres:db", Active: true,
49+ }},
50+ Services: []dto.TrafficServiceStatus{{
51+ Name: "network_service:postgres:db", Active: true,
52+ Backends: []dto.TrafficBackendStatus{{Name: "postgres:db", Host: "127.0.0.1", Port: 5432, Protocol: domain.NetworkProtocolTCP, Active: true}},
53+ }},
54+ Counters: dto.TrafficCounters{ActiveTCPConnections: 1, TotalAccepted: 2, BytesIn: 10, BytesOut: 20, SmartTCP: dto.SmartTCPCounters{HTTPAccepted: 1, PROXYRefused: 1}},
55+ }
56+ var buf bytes.Buffer
57+ require.NoError(t, renderTrafficStatus(&buf, status, false))
58+ output := buf.String()
59+ assert.Contains(t, output, "Traffic Status")
60+ assert.Contains(t, output, "postgres")
61+ assert.Contains(t, output, "127.0.0.1:5432")
62+ assert.Contains(t, output, "pg-router")
63+ assert.Contains(t, output, "network_service:postgres:db")
64+ assert.Contains(t, output, "tcp://127.0.0.1:5432")
65+ assert.Contains(t, output, "accepted=2")
66+ assert.Contains(t, output, "smart_tcp")
67+ assert.Contains(t, output, "http_accepted=1")
68+ assert.Contains(t, output, "proxy_refused=1")
69+}
70+
71+func TestTrafficStatusJSONOutput(t *testing.T) {
72+ status := &dto.TrafficStatusResponse{LastReloadStatus: "ok", Counters: dto.TrafficCounters{ActiveUDPSessions: 3, SmartTCP: dto.SmartTCPCounters{SniffTimeout: 1}}}
73+ var buf bytes.Buffer
74+ require.NoError(t, renderTrafficStatus(&buf, status, true))
75+ var got dto.TrafficStatusResponse
76+ require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
77+ assert.Equal(t, "ok", got.LastReloadStatus)
78+ assert.Equal(t, int64(3), got.Counters.ActiveUDPSessions)
79+ assert.Equal(t, int64(1), got.Counters.SmartTCP.SniffTimeout)
80+}
81+
82+func TestRunTrafficStatusUsesControlPlane(t *testing.T) {
83+ status := &dto.TrafficStatusResponse{LastReloadStatus: "ok"}
84+ cp := &trafficStatusPlane{status: status}
85+
86+ var buf bytes.Buffer
87+ require.NoError(t, runTrafficStatus(context.Background(), cp, &buf, true))
88+
89+ assert.True(t, cp.called)
90+ assert.Contains(t, buf.String(), `"last_reload_status": "ok"`)
91+}
92+
93+func TestRemoteTrafficStatusControlPlaneStillRenders(t *testing.T) {
94+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95+ require.Equal(t, "/admin/traffic/status", r.URL.Path)
96+ require.Equal(t, http.MethodGet, r.Method)
97+ _, err := w.Write([]byte(`{"last_reload_status":"ok","counters":{"active_udp_sessions":3}}`))
98+ require.NoError(t, err)
99+ }))
100+ defer srv.Close()
101+
102+ cp := NewRemoteControlPlane(remote.NewClient(srv.URL))
103+ var buf bytes.Buffer
104+ require.NoError(t, runTrafficStatus(context.Background(), cp, &buf, false))
105+
106+ output := buf.String()
107+ assert.Contains(t, output, "Traffic Status")
108+ assert.Contains(t, output, "Reload")
109+ assert.Contains(t, output, "ok")
110+ assert.Contains(t, output, "udp=3")
111+}
112+
113+func TestLocalTrafficStatusReturnsActionableDaemonGuidance(t *testing.T) {
114+ _, err := (&localControlPlane{}).GetTrafficStatus(context.Background())
115+ require.Error(t, err)
116+ assert.True(t, errors.Is(err, domain.ErrTrafficStatusUnavailable))
117+ assert.Contains(t, err.Error(), "in-process CLI control plane")
118+ assert.Contains(t, err.Error(), "running Gordon daemon")
119+ assert.Contains(t, err.Error(), "--remote")
120+ assert.Contains(t, err.Error(), "GORDON_REMOTE")
121+}
122+
123+type trafficStatusPlane struct {
124+ ControlPlane
125+ status *dto.TrafficStatusResponse
126+ called bool
127+}
128+
129+func (p *trafficStatusPlane) GetTrafficStatus(context.Context) (*dto.TrafficStatusResponse, error) {
130+ p.called = true
131+ return p.status, nil
132+}
M internal/adapters/in/http/admin/handler.go +4 -0
@@ -54,6 +54,7 @@ type Handler struct {
5454 previewSvc previewService
5555 reloadTrigger reloadTrigger
5656 publicTLSSvc in.PublicTLSService
57+ trafficSvc in.TrafficStatusService
5758 log zerowrap.Logger
5859 }
5960
@@ -202,6 +203,7 @@ type HandlerDeps struct {
202203 VolumeSvc in.VolumeService
203204 ReloadTrigger reloadTrigger
204205 PublicTLSSvc in.PublicTLSService
206+ TrafficSvc in.TrafficStatusService
205207 }
206208
207209 // NewHandler creates a new admin HTTP handler.
@@ -221,6 +223,7 @@ func NewHandler(deps HandlerDeps) *Handler {
221223 previewSvc: deps.PreviewSvc,
222224 reloadTrigger: deps.ReloadTrigger,
223225 publicTLSSvc: deps.PublicTLSSvc,
226+ trafficSvc: deps.TrafficSvc,
224227 log: deps.Log,
225228 }
226229 }
@@ -274,6 +277,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
274277 "/attachments/orphans": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentOrphans(w, r) },
275278 "/attachments/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentPrune(w, r) },
276279 "/tls/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTLSStatus(w, r) },
280+ "/traffic/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTrafficStatus(w, r) },
277281 }
278282 if handler, ok := exactRoutes[path]; ok {
279283 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 !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
13+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
14+ return
15+ }
16+ if r.Method != http.MethodGet {
17+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
18+ return
19+ }
20+ if h.trafficSvc == nil {
21+ h.sendJSON(w, http.StatusOK, dto.TrafficStatusFromDomain(domain.TrafficStatus{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 +74 -0
@@ -0,0 +1,74 @@
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+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
13+ "github.com/bnema/gordon/internal/domain"
14+)
15+
16+func TestHandler_TrafficStatus(t *testing.T) {
17+ trafficSvc := inmocks.NewMockTrafficStatusService(t)
18+ trafficSvc.EXPECT().Status().Return(domain.TrafficStatus{
19+ LastReloadStatus: "ok",
20+ EntryPoints: []domain.EntryPointStatus{{
21+ Name: "edge", Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolSmartTCP,
22+ Active: true, ActiveTCPConnections: 2, TotalAccepted: 3, BytesIn: 11, BytesOut: 22,
23+ SmartTCP: domain.SmartTCPCounters{HTTPAccepted: 1, PROXYRefused: 1},
24+ }},
25+ Counters: domain.TrafficCounters{ActiveTCPConnections: 2, TotalAccepted: 3, BytesIn: 11, BytesOut: 22, SmartTCP: domain.SmartTCPCounters{HTTPAccepted: 1, PROXYRefused: 1}},
26+ })
27+ handler := newTestHandler(t, func(d *HandlerDeps) {
28+ d.TrafficSvc = trafficSvc
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, "edge", body.EntryPoints[0].Name)
42+ assert.Equal(t, int64(2), body.Counters.ActiveTCPConnections)
43+ assert.Equal(t, int64(22), body.Counters.BytesOut)
44+ assert.Equal(t, int64(1), body.EntryPoints[0].SmartTCP.HTTPAccepted)
45+ assert.Equal(t, int64(1), body.Counters.SmartTCP.PROXYRefused)
46+}
47+
48+func TestHandler_TrafficStatusRequiresStatusRead(t *testing.T) {
49+ trafficSvc := inmocks.NewMockTrafficStatusService(t)
50+ handler := newTestHandler(t, func(d *HandlerDeps) { d.TrafficSvc = trafficSvc })
51+ server := newScopedTestServer(t, handler, "admin:config:read")
52+
53+ resp, err := http.Get(server.URL + "/admin/traffic/status")
54+ require.NoError(t, err)
55+ defer resp.Body.Close()
56+ assert.Equal(t, http.StatusForbidden, resp.StatusCode)
57+}
58+
59+func TestHandler_TrafficStatusUnavailableWithoutService(t *testing.T) {
60+ handler := newTestHandler(t, func(d *HandlerDeps) { d.TrafficSvc = nil })
61+ server := newScopedTestServer(t, handler, "admin:status:read")
62+
63+ resp, err := http.Get(server.URL + "/admin/traffic/status")
64+ require.NoError(t, err)
65+ defer resp.Body.Close()
66+ require.Equal(t, http.StatusOK, resp.StatusCode)
67+
68+ var body dto.TrafficStatusResponse
69+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
70+ assert.Equal(t, "unavailable", body.LastReloadStatus)
71+ assert.NotNil(t, body.EntryPoints)
72+ assert.NotNil(t, body.Routers)
73+ assert.NotNil(t, body.Services)
74+}
A internal/adapters/in/traffic/manager.go +690 -0
@@ -0,0 +1,690 @@
1+// Package traffic owns Gordon's runtime traffic entrypoints.
2+package traffic
3+
4+import (
5+ "context"
6+ "fmt"
7+ "maps"
8+ "net"
9+ "sort"
10+ "strings"
11+ "sync"
12+ "sync/atomic"
13+ "time"
14+
15+ "github.com/bnema/zerowrap"
16+ "github.com/rs/zerolog"
17+
18+ "github.com/bnema/gordon/internal/domain"
19+)
20+
21+const (
22+ reloadStatusOK = "ok"
23+ reloadStatusError = "error"
24+)
25+
26+func trafficLog(ctx context.Context) zerowrap.Logger {
27+ return zerowrap.FromCtxWithFields(ctx, map[string]any{
28+ zerowrap.FieldLayer: "adapter",
29+ zerowrap.FieldAdapter: "traffic",
30+ })
31+}
32+
33+func trafficInfo(ctx context.Context) *zerolog.Event {
34+ log := trafficLog(ctx)
35+ return log.Info()
36+}
37+
38+func trafficDebug(ctx context.Context) *zerolog.Event {
39+ log := trafficLog(ctx)
40+ return log.Debug()
41+}
42+
43+func trafficWarn(ctx context.Context) *zerolog.Event {
44+ log := trafficLog(ctx)
45+ return log.Warn()
46+}
47+
48+// Manager owns runtime entrypoints for the traffic plane.
49+type Manager struct {
50+ mu sync.Mutex
51+ snapshot atomic.Pointer[domain.TrafficGraph]
52+ listeners map[string]*entryPointRuntime
53+ udpListeners map[string]*udpEntryPointRuntime
54+
55+ lastReloadStatus string
56+ lastReloadError string
57+ tlsHTTPServers atomic.Value
58+ smartHTTPServers atomic.Value
59+ smartTLSServers atomic.Value
60+}
61+
62+// NewManager creates a traffic manager.
63+func NewManager() *Manager {
64+ manager := &Manager{
65+ listeners: map[string]*entryPointRuntime{},
66+ udpListeners: map[string]*udpEntryPointRuntime{},
67+ lastReloadStatus: reloadStatusOK,
68+ }
69+ manager.tlsHTTPServers.Store(tlsHTTPServers{})
70+ manager.smartHTTPServers.Store(smartHTTPServers{})
71+ manager.smartTLSServers.Store(smartTLSServers{})
72+ return manager
73+}
74+
75+// Apply validates and applies a new traffic graph snapshot.
76+func (m *Manager) Apply(ctx context.Context, graph *domain.TrafficGraph) error {
77+ if graph == nil {
78+ return m.recordReloadError(ctx, fmt.Errorf("%w: traffic graph is required", domain.ErrTrafficGraphRequired))
79+ }
80+ if err := graph.Validate(); err != nil {
81+ return m.recordReloadError(ctx, fmt.Errorf("validate traffic graph: %w", err))
82+ }
83+ trafficDebug(ctx).Int("entrypoints", len(graph.EntryPoints)).Int("routers", len(graph.Routers)).Int("services", len(graph.Services)).Msg("applying traffic graph")
84+
85+ nextGraph := cloneTrafficGraph(*graph)
86+ m.mu.Lock()
87+ newListeners, createdListeners, tcpUpdates, err := m.prepareTCPListeners(ctx, &nextGraph)
88+ if err != nil {
89+ m.lastReloadStatus = reloadStatusError
90+ m.lastReloadError = err.Error()
91+ m.mu.Unlock()
92+ trafficWarn(ctx).Err(err).Msg("failed to prepare tcp traffic listeners")
93+ return err
94+ }
95+ newUDPListeners, createdUDPListeners, udpUpdates, err := m.prepareUDPListeners(ctx, &nextGraph)
96+ if err != nil {
97+ m.lastReloadStatus = reloadStatusError
98+ m.lastReloadError = err.Error()
99+ stopTCPRuntimes(ctx, createdListeners, effectiveTCPOptions(snapshotTCPOptions(&nextGraph)).DrainTimeout)
100+ m.mu.Unlock()
101+ trafficWarn(ctx).Err(err).Msg("failed to prepare udp traffic listeners")
102+ return err
103+ }
104+
105+ for _, update := range tcpUpdates {
106+ update.runtime.updateEntryPoint(update.entryPoint, update.trusted, update.rawTrusted)
107+ }
108+ for _, update := range udpUpdates {
109+ update.runtime.updateEntryPoint(update.entryPoint, update.trusted)
110+ }
111+
112+ oldListeners := m.listeners
113+ oldUDPListeners := m.udpListeners
114+ m.listeners = newListeners
115+ m.udpListeners = newUDPListeners
116+ m.snapshot.Store(&nextGraph)
117+ for _, runtime := range createdListeners {
118+ runtime.start()
119+ }
120+ for _, runtime := range createdUDPListeners {
121+ runtime.start()
122+ }
123+ m.lastReloadStatus = reloadStatusOK
124+ m.lastReloadError = ""
125+ m.mu.Unlock()
126+
127+ tcpDrainTimeout := effectiveTCPOptions(snapshotTCPOptions(&nextGraph)).DrainTimeout
128+ stoppedTCP := 0
129+ for _, runtime := range oldListeners {
130+ if tcpRuntimeRetained(newListeners, runtime) {
131+ continue
132+ }
133+ stoppedTCP++
134+ runtime.stop(ctx, tcpDrainTimeout)
135+ }
136+ udpDrainTimeout := effectiveUDPOptions(snapshotUDPOptions(&nextGraph)).DrainTimeout
137+ stoppedUDP := 0
138+ for _, runtime := range oldUDPListeners {
139+ if udpRuntimeRetained(newUDPListeners, runtime) {
140+ if backend, ok := runtime.resolveUDPBackend(); ok {
141+ runtime.drainSessionsNotMatchingAfter(backend, udpDrainTimeout)
142+ } else {
143+ runtime.drainSessionsAfter(udpDrainTimeout)
144+ }
145+ continue
146+ }
147+ stoppedUDP++
148+ runtime.stop(ctx, udpDrainTimeout)
149+ }
150+ logAppliedTrafficGraph(ctx, newListeners, newUDPListeners, createdListeners, createdUDPListeners, stoppedTCP, stoppedUDP)
151+ return nil
152+}
153+
154+func tcpRuntimeRetained(listeners map[string]*entryPointRuntime, runtime *entryPointRuntime) bool {
155+ for _, candidate := range listeners {
156+ if candidate == runtime {
157+ return true
158+ }
159+ }
160+ return false
161+}
162+
163+func udpRuntimeRetained(listeners map[string]*udpEntryPointRuntime, runtime *udpEntryPointRuntime) bool {
164+ for _, candidate := range listeners {
165+ if candidate == runtime {
166+ return true
167+ }
168+ }
169+ return false
170+}
171+
172+func logAppliedTrafficGraph(
173+ ctx context.Context,
174+ newListeners map[string]*entryPointRuntime,
175+ newUDPListeners map[string]*udpEntryPointRuntime,
176+ createdListeners []*entryPointRuntime,
177+ createdUDPListeners []*udpEntryPointRuntime,
178+ stoppedTCP int,
179+ stoppedUDP int,
180+) {
181+ logEvent := trafficInfo(ctx)
182+ if len(newListeners) == 0 && len(newUDPListeners) == 0 && stoppedTCP == 0 && stoppedUDP == 0 {
183+ logEvent = trafficDebug(ctx)
184+ }
185+ logEvent.
186+ Int("tcp_listeners", len(newListeners)).
187+ Int("udp_listeners", len(newUDPListeners)).
188+ Int("created_tcp_listeners", len(createdListeners)).
189+ Int("created_udp_listeners", len(createdUDPListeners)).
190+ Int("stopped_tcp_listeners", stoppedTCP).
191+ Int("stopped_udp_listeners", stoppedUDP).
192+ Msg("traffic graph applied")
193+}
194+
195+// Shutdown stops all listeners and active TCP streams.
196+func (m *Manager) Shutdown(ctx context.Context) error {
197+ trafficInfo(ctx).Msg("shutting down traffic manager")
198+ m.mu.Lock()
199+ defer m.mu.Unlock()
200+
201+ listeners := m.listeners
202+ udpListeners := m.udpListeners
203+ m.listeners = map[string]*entryPointRuntime{}
204+ m.udpListeners = map[string]*udpEntryPointRuntime{}
205+ graph := m.snapshot.Load()
206+ m.snapshot.Store(nil)
207+
208+ tcpDrainTimeout := defaultTCPOptions().DrainTimeout
209+ udpDrainTimeout := defaultUDPOptions().DrainTimeout
210+ if graph != nil {
211+ tcpDrainTimeout = effectiveTCPOptions(graph.Options.TCP).DrainTimeout
212+ udpDrainTimeout = effectiveUDPOptions(graph.Options.UDP).DrainTimeout
213+ }
214+ for _, runtime := range listeners {
215+ runtime.stop(ctx, tcpDrainTimeout)
216+ }
217+ for _, runtime := range udpListeners {
218+ runtime.stop(ctx, udpDrainTimeout)
219+ }
220+ trafficInfo(ctx).Int("tcp_listeners", len(listeners)).Int("udp_listeners", len(udpListeners)).Msg("traffic manager shut down")
221+ return nil
222+}
223+
224+// Status returns a consistent snapshot of manager state and counters.
225+func (m *Manager) Status() domain.TrafficStatus {
226+ m.mu.Lock()
227+ listeners := make(map[string]*entryPointRuntime, len(m.listeners))
228+ maps.Copy(listeners, m.listeners)
229+ udpListeners := make(map[string]*udpEntryPointRuntime, len(m.udpListeners))
230+ maps.Copy(udpListeners, m.udpListeners)
231+ status := domain.TrafficStatus{LastReloadStatus: m.lastReloadStatus, LastReloadError: m.lastReloadError}
232+ graph := m.snapshot.Load()
233+ m.mu.Unlock()
234+
235+ if graph == nil {
236+ return status
237+ }
238+
239+ status.EntryPoints = entryPointStatuses(graph.EntryPoints, listeners, udpListeners)
240+ status.Routers = routerStatuses(graph.Routers, listeners, udpListeners)
241+ status.Services = serviceStatuses(graph.Services)
242+ status.Counters = aggregateCounters(status.EntryPoints)
243+ return status
244+}
245+
246+func (m *Manager) recordReloadError(ctx context.Context, err error) error {
247+ m.mu.Lock()
248+ m.lastReloadStatus = reloadStatusError
249+ m.lastReloadError = err.Error()
250+ m.mu.Unlock()
251+ trafficWarn(ctx).Err(err).Msg("traffic graph rejected")
252+ return err
253+}
254+
255+type tcpRuntimeUpdate struct {
256+ runtime *entryPointRuntime
257+ entryPoint domain.EntryPoint
258+ trusted []*net.IPNet
259+ rawTrusted []*net.IPNet
260+}
261+
262+type udpRuntimeUpdate struct {
263+ runtime *udpEntryPointRuntime
264+ entryPoint domain.EntryPoint
265+ trusted []*net.IPNet
266+}
267+
268+func (m *Manager) prepareTCPListeners(ctx context.Context, graph *domain.TrafficGraph) (map[string]*entryPointRuntime, []*entryPointRuntime, []tcpRuntimeUpdate, error) {
269+ current := make(map[string]*entryPointRuntime, len(m.listeners))
270+ maps.Copy(current, m.listeners)
271+
272+ next := make(map[string]*entryPointRuntime, len(current))
273+ created := []*entryPointRuntime{}
274+ updates := []tcpRuntimeUpdate{}
275+ for _, entryPoint := range graph.EntryPoints {
276+ if !isTCPListenerProtocol(entryPoint.Protocol) {
277+ continue
278+ }
279+ if runtime := current[entryPoint.Name]; runtime != nil && runtime.matches(entryPoint) {
280+ next[entryPoint.Name] = runtime
281+ delete(current, entryPoint.Name)
282+ continue
283+ }
284+ if runtime := conflictingTCPRuntime(current, entryPoint); runtime != nil {
285+ trusted, err := parseTrustedCIDRs(entryPoint.TrustedCIDRs)
286+ if err != nil {
287+ stopTCPRuntimes(ctx, created, effectiveTCPOptions(graph.Options.TCP).DrainTimeout)
288+ return nil, nil, nil, fmt.Errorf("parse trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)
289+ }
290+ rawTrusted, err := parseTrustedCIDRs(entryPoint.RawFallbackTrustedCIDRs)
291+ if err != nil {
292+ stopTCPRuntimes(ctx, created, effectiveTCPOptions(graph.Options.TCP).DrainTimeout)
293+ return nil, nil, nil, fmt.Errorf("parse raw fallback trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)
294+ }
295+ trafficDebug(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Msg("reusing tcp traffic listener for same-address entrypoint update")
296+ updates = append(updates, tcpRuntimeUpdate{runtime: runtime, entryPoint: entryPoint, trusted: trusted, rawTrusted: rawTrusted})
297+ next[entryPoint.Name] = runtime
298+ delete(current, runtime.entryPointSnapshot().Name)
299+ continue
300+ }
301+ runtime, err := m.bindTCPEntryPoint(ctx, entryPoint)
302+ if err != nil {
303+ stopTCPRuntimes(ctx, created, effectiveTCPOptions(graph.Options.TCP).DrainTimeout)
304+ return nil, nil, nil, err
305+ }
306+ next[entryPoint.Name] = runtime
307+ created = append(created, runtime)
308+ }
309+ return next, created, updates, nil
310+}
311+
312+func stopTCPRuntimes(ctx context.Context, runtimes []*entryPointRuntime, drainTimeout time.Duration) {
313+ for _, runtime := range runtimes {
314+ runtime.stop(ctx, drainTimeout)
315+ }
316+}
317+
318+func stopUDPRuntimes(ctx context.Context, runtimes []*udpEntryPointRuntime, drainTimeout time.Duration) {
319+ for _, runtime := range runtimes {
320+ runtime.stop(ctx, drainTimeout)
321+ }
322+}
323+
324+func isTCPListenerProtocol(protocol domain.EntryPointProtocol) bool {
325+ return protocol == domain.EntryPointProtocolTCP || protocol == domain.EntryPointProtocolTLSMux || protocol == domain.EntryPointProtocolSmartTCP
326+}
327+
328+func conflictingTCPRuntime(current map[string]*entryPointRuntime, entryPoint domain.EntryPoint) *entryPointRuntime {
329+ for _, runtime := range current {
330+ if runtime.sameAddress(entryPoint) {
331+ return runtime
332+ }
333+ }
334+ return nil
335+}
336+
337+func (m *Manager) bindTCPEntryPoint(ctx context.Context, entryPoint domain.EntryPoint) (*entryPointRuntime, error) {
338+ listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", entryPoint.Address)
339+ if err != nil {
340+ return nil, fmt.Errorf("bind tcp entrypoint %q on %s: %w", entryPoint.Name, entryPoint.Address, err)
341+ }
342+ trusted, err := parseTrustedCIDRs(entryPoint.TrustedCIDRs)
343+ if err != nil {
344+ _ = listener.Close()
345+ return nil, fmt.Errorf("parse trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)
346+ }
347+ rawTrusted, err := parseTrustedCIDRs(entryPoint.RawFallbackTrustedCIDRs)
348+ if err != nil {
349+ _ = listener.Close()
350+ return nil, fmt.Errorf("parse raw fallback trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)
351+ }
352+ trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("bound tcp traffic entrypoint")
353+ runtime := newEntryPointRuntime(ctx, m, entryPoint, listener, trusted, rawTrusted)
354+ return runtime, nil
355+}
356+
357+func (m *Manager) prepareUDPListeners(ctx context.Context, graph *domain.TrafficGraph) (map[string]*udpEntryPointRuntime, []*udpEntryPointRuntime, []udpRuntimeUpdate, error) {
358+ current := make(map[string]*udpEntryPointRuntime, len(m.udpListeners))
359+ maps.Copy(current, m.udpListeners)
360+
361+ next := make(map[string]*udpEntryPointRuntime, len(current))
362+ created := []*udpEntryPointRuntime{}
363+ updates := []udpRuntimeUpdate{}
364+ for _, entryPoint := range graph.EntryPoints {
365+ if entryPoint.Protocol != domain.EntryPointProtocolUDP {
366+ continue
367+ }
368+ if runtime := current[entryPoint.Name]; runtime != nil && runtime.matches(entryPoint) {
369+ next[entryPoint.Name] = runtime
370+ delete(current, entryPoint.Name)
371+ continue
372+ }
373+ if runtime := conflictingUDPRuntime(current, entryPoint); runtime != nil {
374+ trusted, err := parseTrustedCIDRs(entryPoint.TrustedCIDRs)
375+ if err != nil {
376+ stopUDPRuntimes(ctx, created, effectiveUDPOptions(graph.Options.UDP).DrainTimeout)
377+ return nil, nil, nil, fmt.Errorf("parse trusted cidrs for udp entrypoint %q: %w", entryPoint.Name, err)
378+ }
379+ trafficDebug(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Msg("reusing udp traffic listener for same-address entrypoint update")
380+ updates = append(updates, udpRuntimeUpdate{runtime: runtime, entryPoint: entryPoint, trusted: trusted})
381+ next[entryPoint.Name] = runtime
382+ delete(current, runtime.entryPointSnapshot().Name)
383+ continue
384+ }
385+ runtime, err := m.bindUDPEntryPoint(ctx, entryPoint)
386+ if err != nil {
387+ stopUDPRuntimes(ctx, created, effectiveUDPOptions(graph.Options.UDP).DrainTimeout)
388+ return nil, nil, nil, err
389+ }
390+ next[entryPoint.Name] = runtime
391+ created = append(created, runtime)
392+ }
393+ return next, created, updates, nil
394+}
395+
396+func conflictingUDPRuntime(current map[string]*udpEntryPointRuntime, entryPoint domain.EntryPoint) *udpEntryPointRuntime {
397+ for _, runtime := range current {
398+ if runtime.sameAddress(entryPoint) {
399+ return runtime
400+ }
401+ }
402+ return nil
403+}
404+
405+func (m *Manager) bindUDPEntryPoint(ctx context.Context, entryPoint domain.EntryPoint) (*udpEntryPointRuntime, error) {
406+ packetConn, err := (&net.ListenConfig{}).ListenPacket(ctx, "udp", entryPoint.Address)
407+ if err != nil {
408+ return nil, fmt.Errorf("bind udp entrypoint %q on %s: %w", entryPoint.Name, entryPoint.Address, err)
409+ }
410+ trusted, err := parseTrustedCIDRs(entryPoint.TrustedCIDRs)
411+ if err != nil {
412+ _ = packetConn.Close()
413+ return nil, fmt.Errorf("parse trusted cidrs for udp entrypoint %q: %w", entryPoint.Name, err)
414+ }
415+ trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("bound udp traffic entrypoint")
416+ return newUDPEntryPointRuntime(ctx, m, entryPoint, packetConn, trusted), nil
417+}
418+
419+func parseTrustedCIDRs(values []string) ([]*net.IPNet, error) {
420+ if len(values) == 0 {
421+ return nil, nil
422+ }
423+ trusted := make([]*net.IPNet, 0, len(values))
424+ for _, value := range values {
425+ _, network, err := net.ParseCIDR(strings.TrimSpace(value))
426+ if err != nil {
427+ return nil, err
428+ }
429+ trusted = append(trusted, network)
430+ }
431+ return trusted, nil
432+}
433+
434+func trustedCIDRsEqual(left []string, right []string) bool {
435+ left = normalizedCIDRs(left)
436+ right = normalizedCIDRs(right)
437+ if len(left) != len(right) {
438+ return false
439+ }
440+ for i := range left {
441+ if left[i] != right[i] {
442+ return false
443+ }
444+ }
445+ return true
446+}
447+
448+func normalizedCIDRs(values []string) []string {
449+ out := make([]string, 0, len(values))
450+ for _, value := range values {
451+ out = append(out, strings.TrimSpace(value))
452+ }
453+ sort.Strings(out)
454+ return out
455+}
456+
457+func trustedRemoteAddr(trusted []*net.IPNet, addr net.Addr) bool {
458+ if len(trusted) == 0 {
459+ return true
460+ }
461+ host, _, err := net.SplitHostPort(addr.String())
462+ if err != nil {
463+ return false
464+ }
465+ ip := net.ParseIP(host)
466+ if ip == nil {
467+ return false
468+ }
469+ for _, network := range trusted {
470+ if network.Contains(ip) {
471+ return true
472+ }
473+ }
474+ return false
475+}
476+
477+func entryPointStatuses(entries []domain.EntryPoint, listeners map[string]*entryPointRuntime, udpListeners map[string]*udpEntryPointRuntime) []domain.EntryPointStatus {
478+ statuses := make([]domain.EntryPointStatus, 0, len(entries))
479+ for _, entry := range entries {
480+ status := domain.EntryPointStatus{Name: entry.Name, Address: entry.Address, Protocol: entry.Protocol}
481+ if runtime := listeners[entry.Name]; runtime != nil {
482+ counters := runtime.counters.snapshot()
483+ status.Active = !runtime.isClosed()
484+ status.ActiveTCPConnections = counters.ActiveTCPConnections
485+ status.TotalAccepted = counters.TotalAccepted
486+ status.TotalRefused = counters.TotalRefused
487+ status.TotalErrors = counters.TotalErrors
488+ status.BytesIn = counters.BytesIn
489+ status.BytesOut = counters.BytesOut
490+ status.SmartTCP = counters.SmartTCP
491+ }
492+ if runtime := udpListeners[entry.Name]; runtime != nil {
493+ counters := runtime.counters.snapshot()
494+ status.Active = !runtime.isClosed()
495+ status.ActiveUDPSessions = counters.ActiveUDPSessions
496+ status.TotalAccepted = counters.TotalAccepted
497+ status.TotalRefused = counters.TotalRefused
498+ status.TotalErrors = counters.TotalErrors
499+ status.BytesIn = counters.BytesIn
500+ status.BytesOut = counters.BytesOut
501+ }
502+ statuses = append(statuses, status)
503+ }
504+ sort.Slice(statuses, func(i, j int) bool { return statuses[i].Name < statuses[j].Name })
505+ return statuses
506+}
507+
508+func routerStatuses(routers []domain.TrafficRouter, listeners map[string]*entryPointRuntime, udpListeners map[string]*udpEntryPointRuntime) []domain.TrafficRouterStatus {
509+ statuses := make([]domain.TrafficRouterStatus, 0, len(routers))
510+ for _, router := range routers {
511+ tcpActive := false
512+ if runtime := listeners[router.EntryPoint]; runtime != nil {
513+ tcpActive = !runtime.isClosed()
514+ }
515+ udpActive := false
516+ if runtime := udpListeners[router.EntryPoint]; runtime != nil {
517+ udpActive = !runtime.isClosed()
518+ }
519+ statuses = append(statuses, domain.TrafficRouterStatus{
520+ Name: router.Name, EntryPoint: router.EntryPoint, Protocol: router.Protocol,
521+ Rule: router.Rule, Service: router.Service, Active: tcpActive || udpActive,
522+ })
523+ }
524+ sort.Slice(statuses, func(i, j int) bool { return statuses[i].Name < statuses[j].Name })
525+ return statuses
526+}
527+
528+func serviceStatuses(services []domain.TrafficService) []domain.TrafficServiceStatus {
529+ statuses := make([]domain.TrafficServiceStatus, 0, len(services))
530+ for _, service := range services {
531+ status := domain.TrafficServiceStatus{Name: service.Name, Active: true, Backends: make([]domain.TrafficBackendStatus, 0, len(service.Backends))}
532+ for _, backend := range service.Backends {
533+ status.Backends = append(status.Backends, domain.TrafficBackendStatus{Name: backend.Name, Host: backend.Host, Port: backend.Port, Protocol: backend.Protocol, Active: true})
534+ }
535+ statuses = append(statuses, status)
536+ }
537+ sort.Slice(statuses, func(i, j int) bool { return statuses[i].Name < statuses[j].Name })
538+ return statuses
539+}
540+
541+func aggregateCounters(entries []domain.EntryPointStatus) domain.TrafficCounters {
542+ var counters domain.TrafficCounters
543+ for _, entry := range entries {
544+ counters.ActiveTCPConnections += entry.ActiveTCPConnections
545+ counters.ActiveUDPSessions += entry.ActiveUDPSessions
546+ counters.TotalAccepted += entry.TotalAccepted
547+ counters.TotalRefused += entry.TotalRefused
548+ counters.TotalErrors += entry.TotalErrors
549+ counters.BytesIn += entry.BytesIn
550+ counters.BytesOut += entry.BytesOut
551+ counters.SmartTCP.HTTPAccepted += entry.SmartTCP.HTTPAccepted
552+ counters.SmartTCP.H2CAccepted += entry.SmartTCP.H2CAccepted
553+ counters.SmartTCP.HTTPSFallbackAccepted += entry.SmartTCP.HTTPSFallbackAccepted
554+ counters.SmartTCP.TLSPassthroughAccepted += entry.SmartTCP.TLSPassthroughAccepted
555+ counters.SmartTCP.RawFallbackAccepted += entry.SmartTCP.RawFallbackAccepted
556+ counters.SmartTCP.EntrypointCIDRRefused += entry.SmartTCP.EntrypointCIDRRefused
557+ counters.SmartTCP.RawFallbackCIDRRefused += entry.SmartTCP.RawFallbackCIDRRefused
558+ counters.SmartTCP.PROXYRefused += entry.SmartTCP.PROXYRefused
559+ counters.SmartTCP.UnknownNoFallbackRefused += entry.SmartTCP.UnknownNoFallbackRefused
560+ counters.SmartTCP.MalformedRejected += entry.SmartTCP.MalformedRejected
561+ counters.SmartTCP.SniffTimeout += entry.SmartTCP.SniffTimeout
562+ counters.SmartTCP.ClientHelloTooLarge += entry.SmartTCP.ClientHelloTooLarge
563+ }
564+ return counters
565+}
566+
567+func cloneTrafficGraph(graph domain.TrafficGraph) domain.TrafficGraph {
568+ clone := graph
569+ clone.EntryPoints = append([]domain.EntryPoint{}, graph.EntryPoints...)
570+ for i := range clone.EntryPoints {
571+ clone.EntryPoints[i].TrustedCIDRs = append([]string(nil), graph.EntryPoints[i].TrustedCIDRs...)
572+ clone.EntryPoints[i].RawFallbackTrustedCIDRs = append([]string(nil), graph.EntryPoints[i].RawFallbackTrustedCIDRs...)
573+ }
574+ clone.Routers = append([]domain.TrafficRouter{}, graph.Routers...)
575+ clone.Services = append([]domain.TrafficService{}, graph.Services...)
576+ for i := range clone.Services {
577+ clone.Services[i].Backends = append([]domain.TrafficBackend{}, graph.Services[i].Backends...)
578+ }
579+ return clone
580+}
581+
582+func snapshotTCPOptions(graph *domain.TrafficGraph) domain.TCPOptions {
583+ if graph == nil {
584+ return domain.TCPOptions{}
585+ }
586+ return graph.Options.TCP
587+}
588+
589+func snapshotUDPOptions(graph *domain.TrafficGraph) domain.UDPOptions {
590+ if graph == nil {
591+ return domain.UDPOptions{}
592+ }
593+ return graph.Options.UDP
594+}
595+
596+type trafficCounters struct {
597+ activeTCPConnections atomic.Int64
598+ activeUDPSessions atomic.Int64
599+ totalAccepted atomic.Int64
600+ totalRefused atomic.Int64
601+ totalErrors atomic.Int64
602+ bytesIn atomic.Int64
603+ bytesOut atomic.Int64
604+ smartTCP smartTCPCounterSet
605+}
606+
607+type smartTCPCounterSet struct {
608+ httpAccepted atomic.Int64
609+ h2cAccepted atomic.Int64
610+ httpsFallbackAccepted atomic.Int64
611+ tlsPassthroughAccepted atomic.Int64
612+ rawFallbackAccepted atomic.Int64
613+ entrypointCIDRRefused atomic.Int64
614+ rawFallbackCIDRRefused atomic.Int64
615+ proxyRefused atomic.Int64
616+ unknownNoFallbackRefused atomic.Int64
617+ malformedRejected atomic.Int64
618+ sniffTimeout atomic.Int64
619+ clientHelloTooLarge atomic.Int64
620+}
621+
622+func (c *trafficCounters) snapshot() domain.TrafficCounters {
623+ return domain.TrafficCounters{
624+ ActiveTCPConnections: c.activeTCPConnections.Load(),
625+ ActiveUDPSessions: c.activeUDPSessions.Load(),
626+ TotalAccepted: c.totalAccepted.Load(),
627+ TotalRefused: c.totalRefused.Load(),
628+ TotalErrors: c.totalErrors.Load(),
629+ BytesIn: c.bytesIn.Load(),
630+ BytesOut: c.bytesOut.Load(),
631+ SmartTCP: domain.SmartTCPCounters{
632+ HTTPAccepted: c.smartTCP.httpAccepted.Load(),
633+ H2CAccepted: c.smartTCP.h2cAccepted.Load(),
634+ HTTPSFallbackAccepted: c.smartTCP.httpsFallbackAccepted.Load(),
635+ TLSPassthroughAccepted: c.smartTCP.tlsPassthroughAccepted.Load(),
636+ RawFallbackAccepted: c.smartTCP.rawFallbackAccepted.Load(),
637+ EntrypointCIDRRefused: c.smartTCP.entrypointCIDRRefused.Load(),
638+ RawFallbackCIDRRefused: c.smartTCP.rawFallbackCIDRRefused.Load(),
639+ PROXYRefused: c.smartTCP.proxyRefused.Load(),
640+ UnknownNoFallbackRefused: c.smartTCP.unknownNoFallbackRefused.Load(),
641+ MalformedRejected: c.smartTCP.malformedRejected.Load(),
642+ SniffTimeout: c.smartTCP.sniffTimeout.Load(),
643+ ClientHelloTooLarge: c.smartTCP.clientHelloTooLarge.Load(),
644+ },
645+ }
646+}
647+
648+func defaultTCPOptions() domain.TCPOptions {
649+ return domain.TCPOptions{
650+ DialTimeout: 10 * time.Second,
651+ IdleTimeout: 5 * time.Minute,
652+ DrainTimeout: 30 * time.Second,
653+ MaxConnections: 1024,
654+ }
655+}
656+
657+func effectiveTCPOptions(options domain.TCPOptions) domain.TCPOptions {
658+ defaults := defaultTCPOptions()
659+ if options.DialTimeout == 0 {
660+ options.DialTimeout = defaults.DialTimeout
661+ }
662+ if options.IdleTimeout == 0 {
663+ options.IdleTimeout = defaults.IdleTimeout
664+ }
665+ if options.DrainTimeout == 0 {
666+ options.DrainTimeout = defaults.DrainTimeout
667+ }
668+ if options.MaxConnections <= 0 {
669+ options.MaxConnections = defaults.MaxConnections
670+ }
671+ return options
672+}
673+
674+func defaultUDPOptions() domain.UDPOptions {
675+ return domain.UDPOptions{IdleTimeout: 30 * time.Second, DrainTimeout: 30 * time.Second, MaxSessions: 4096}
676+}
677+
678+func effectiveUDPOptions(options domain.UDPOptions) domain.UDPOptions {
679+ defaults := defaultUDPOptions()
680+ if options.IdleTimeout == 0 {
681+ options.IdleTimeout = defaults.IdleTimeout
682+ }
683+ if options.DrainTimeout == 0 {
684+ options.DrainTimeout = defaults.DrainTimeout
685+ }
686+ if options.MaxSessions <= 0 {
687+ options.MaxSessions = defaults.MaxSessions
688+ }
689+ return options
690+}
A internal/adapters/in/traffic/smart_tcp.go +262 -0
@@ -0,0 +1,262 @@
1+package traffic
2+
3+import (
4+ "context"
5+ "crypto/tls"
6+ "errors"
7+ "maps"
8+ "net"
9+ "net/http"
10+ "time"
11+
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+type SmartTCPHTTPServerConfig struct {
16+ Handler http.Handler
17+ Protocols *http.Protocols
18+}
19+
20+type SmartTCPTLSServerConfig struct {
21+ Handler http.Handler
22+ TLSConfig *tls.Config
23+}
24+
25+type smartHTTPServers map[string]SmartTCPHTTPServerConfig
26+type smartTLSServers map[string]SmartTCPTLSServerConfig
27+
28+func (m *Manager) SetSmartTCPHTTPServer(entryPoint string, handler http.Handler, protocols *http.Protocols) {
29+ m.mu.Lock()
30+ current := m.loadSmartHTTPServers()
31+ next := make(smartHTTPServers, len(current)+1)
32+ maps.Copy(next, current)
33+ if handler == nil {
34+ delete(next, entryPoint)
35+ } else {
36+ next[entryPoint] = SmartTCPHTTPServerConfig{Handler: handler, Protocols: cloneHTTPProtocols(protocols)}
37+ }
38+ m.smartHTTPServers.Store(next)
39+ runtimes := m.smartTCPRuntimesLocked(entryPoint)
40+ m.mu.Unlock()
41+ for _, runtime := range runtimes {
42+ runtime.refreshSmartTCPHTTPServer(entryPoint)
43+ }
44+}
45+
46+func cloneHTTPProtocols(protocols *http.Protocols) *http.Protocols {
47+ if protocols == nil {
48+ return nil
49+ }
50+ clone := *protocols
51+ return &clone
52+}
53+
54+func (m *Manager) SetSmartTCPTLSServer(entryPoint string, handler http.Handler, tlsConfig *tls.Config) {
55+ m.mu.Lock()
56+ current := m.loadSmartTLSServers()
57+ next := make(smartTLSServers, len(current)+1)
58+ maps.Copy(next, current)
59+ if handler == nil || tlsConfig == nil {
60+ delete(next, entryPoint)
61+ } else {
62+ next[entryPoint] = SmartTCPTLSServerConfig{Handler: handler, TLSConfig: tlsConfig.Clone()}
63+ }
64+ m.smartTLSServers.Store(next)
65+ runtimes := m.smartTCPRuntimesLocked(entryPoint)
66+ m.mu.Unlock()
67+ for _, runtime := range runtimes {
68+ runtime.refreshSmartTCPTLSServer(entryPoint)
69+ }
70+}
71+
72+func (m *Manager) smartTCPRuntimesLocked(entryPoint string) []*entryPointRuntime {
73+ runtimes := make([]*entryPointRuntime, 0, len(m.listeners))
74+ for _, runtime := range m.listeners {
75+ snapshot := runtime.entryPointSnapshot()
76+ if snapshot.Name == entryPoint && snapshot.Protocol == domain.EntryPointProtocolSmartTCP {
77+ runtimes = append(runtimes, runtime)
78+ }
79+ }
80+ return runtimes
81+}
82+
83+func (m *Manager) smartHTTPServer(entryPoint string) (SmartTCPHTTPServerConfig, bool) {
84+ config, ok := m.loadSmartHTTPServers()[entryPoint]
85+ return config, ok
86+}
87+
88+func (m *Manager) smartTLSServer(entryPoint string) (SmartTCPTLSServerConfig, bool) {
89+ config, ok := m.loadSmartTLSServers()[entryPoint]
90+ return config, ok
91+}
92+
93+func (m *Manager) loadSmartHTTPServers() smartHTTPServers {
94+ value := m.smartHTTPServers.Load()
95+ if value == nil {
96+ return smartHTTPServers{}
97+ }
98+ return value.(smartHTTPServers)
99+}
100+
101+func (m *Manager) loadSmartTLSServers() smartTLSServers {
102+ value := m.smartTLSServers.Load()
103+ if value == nil {
104+ return smartTLSServers{}
105+ }
106+ return value.(smartTLSServers)
107+}
108+
109+func (r *entryPointRuntime) startSmartTCPHTTPServers(entryPoint domain.EntryPoint) {
110+ if entryPoint.Protocol != domain.EntryPointProtocolSmartTCP {
111+ return
112+ }
113+ if config, ok := r.manager.smartHTTPServer(entryPoint.Name); ok && config.Handler != nil {
114+ r.replaceSmartTCPHTTPServer(entryPoint, config)
115+ }
116+ if config, ok := r.manager.smartTLSServer(entryPoint.Name); ok && config.Handler != nil && config.TLSConfig != nil {
117+ r.replaceSmartTCPTLSServer(entryPoint, config)
118+ }
119+}
120+
121+func (r *entryPointRuntime) refreshSmartTCPHTTPServer(entryPointName string) {
122+ entryPoint := r.entryPointSnapshot()
123+ if entryPoint.Name != entryPointName || entryPoint.Protocol != domain.EntryPointProtocolSmartTCP {
124+ return
125+ }
126+ config, ok := r.manager.smartHTTPServer(entryPointName)
127+ if !ok || config.Handler == nil {
128+ r.stopSmartTCPHTTPServer(r.ctx, 0)
129+ return
130+ }
131+ r.replaceSmartTCPHTTPServer(entryPoint, config)
132+}
133+
134+func (r *entryPointRuntime) refreshSmartTCPTLSServer(entryPointName string) {
135+ entryPoint := r.entryPointSnapshot()
136+ if entryPoint.Name != entryPointName || entryPoint.Protocol != domain.EntryPointProtocolSmartTCP {
137+ return
138+ }
139+ config, ok := r.manager.smartTLSServer(entryPointName)
140+ if !ok || config.Handler == nil || config.TLSConfig == nil {
141+ r.stopSmartTCPTLSServer(r.ctx, 0)
142+ return
143+ }
144+ r.replaceSmartTCPTLSServer(entryPoint, config)
145+}
146+
147+func (r *entryPointRuntime) replaceSmartTCPHTTPServer(entryPoint domain.EntryPoint, config SmartTCPHTTPServerConfig) {
148+ r.smartHTTPReplaceMu.Lock()
149+ defer r.smartHTTPReplaceMu.Unlock()
150+ if r.isClosed() {
151+ return
152+ }
153+ listener := newTLSHTTPListener(r.listener.Addr())
154+ server := &http.Server{Handler: config.Handler, Protocols: config.Protocols, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second}
155+ done := make(chan struct{})
156+ r.mu.Lock()
157+ oldListener := r.smartHTTPListener
158+ oldServer := r.smartHTTPServer
159+ oldDone := r.smartHTTPDone
160+ if r.isClosed() {
161+ r.mu.Unlock()
162+ _ = listener.Close()
163+ return
164+ }
165+ r.smartHTTPListener = listener
166+ r.smartHTTPServer = server
167+ r.smartHTTPDone = done
168+ r.mu.Unlock()
169+ go func() {
170+ defer close(done)
171+ if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
172+ trafficWarn(r.ctx).Err(err).Str("entrypoint", entryPoint.Name).Msg("smart tcp http server stopped with error")
173+ }
174+ }()
175+ stopInternalHTTPServer(r.ctx, oldListener, oldServer, oldDone, 0)
176+}
177+
178+func (r *entryPointRuntime) replaceSmartTCPTLSServer(entryPoint domain.EntryPoint, config SmartTCPTLSServerConfig) {
179+ r.smartTLSReplaceMu.Lock()
180+ defer r.smartTLSReplaceMu.Unlock()
181+ if r.isClosed() {
182+ return
183+ }
184+ listener := newTLSHTTPListener(r.listener.Addr())
185+ server := &http.Server{Handler: config.Handler, TLSConfig: config.TLSConfig.Clone(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second}
186+ done := make(chan struct{})
187+ r.mu.Lock()
188+ oldListener := r.smartTLSListener
189+ oldServer := r.smartTLSServer
190+ oldDone := r.smartTLSDone
191+ if r.isClosed() {
192+ r.mu.Unlock()
193+ _ = listener.Close()
194+ return
195+ }
196+ r.smartTLSListener = listener
197+ r.smartTLSServer = server
198+ r.smartTLSDone = done
199+ r.mu.Unlock()
200+ go func() {
201+ defer close(done)
202+ if err := server.ServeTLS(listener, "", ""); err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
203+ trafficWarn(r.ctx).Err(err).Str("entrypoint", entryPoint.Name).Msg("smart tcp https server stopped with error")
204+ }
205+ }()
206+ stopInternalHTTPServer(r.ctx, oldListener, oldServer, oldDone, 0)
207+}
208+
209+func (r *entryPointRuntime) stopSmartTCPHTTPServer(ctx context.Context, drainTimeout time.Duration) {
210+ r.smartHTTPReplaceMu.Lock()
211+ defer r.smartHTTPReplaceMu.Unlock()
212+ r.stopSmartTCPHTTPServerLocked(ctx, drainTimeout)
213+}
214+
215+func (r *entryPointRuntime) stopSmartTCPHTTPServerLocked(ctx context.Context, drainTimeout time.Duration) {
216+ r.mu.Lock()
217+ listener := r.smartHTTPListener
218+ server := r.smartHTTPServer
219+ done := r.smartHTTPDone
220+ r.smartHTTPListener = nil
221+ r.smartHTTPServer = nil
222+ r.smartHTTPDone = nil
223+ r.mu.Unlock()
224+ stopInternalHTTPServer(ctx, listener, server, done, drainTimeout)
225+}
226+
227+func (r *entryPointRuntime) stopSmartTCPTLSServer(ctx context.Context, drainTimeout time.Duration) {
228+ r.smartTLSReplaceMu.Lock()
229+ defer r.smartTLSReplaceMu.Unlock()
230+ r.stopSmartTCPTLSServerLocked(ctx, drainTimeout)
231+}
232+
233+func (r *entryPointRuntime) stopSmartTCPTLSServerLocked(ctx context.Context, drainTimeout time.Duration) {
234+ r.mu.Lock()
235+ listener := r.smartTLSListener
236+ server := r.smartTLSServer
237+ done := r.smartTLSDone
238+ r.smartTLSListener = nil
239+ r.smartTLSServer = nil
240+ r.smartTLSDone = nil
241+ r.mu.Unlock()
242+ stopInternalHTTPServer(ctx, listener, server, done, drainTimeout)
243+}
244+
245+func stopInternalHTTPServer(ctx context.Context, listener *tlsHTTPListener, server *http.Server, done chan struct{}, drainTimeout time.Duration) {
246+ if listener == nil || server == nil || done == nil {
247+ return
248+ }
249+ _ = listener.Close()
250+ if drainTimeout <= 0 {
251+ drainTimeout = defaultTCPOptions().DrainTimeout
252+ }
253+ shutdownCtx, cancel := context.WithTimeout(ctx, drainTimeout)
254+ defer cancel()
255+ if err := server.Shutdown(shutdownCtx); err != nil {
256+ _ = server.Close()
257+ }
258+ select {
259+ case <-done:
260+ case <-shutdownCtx.Done():
261+ }
262+}
A internal/adapters/in/traffic/sniff.go +210 -0
@@ -0,0 +1,210 @@
1+package traffic
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "fmt"
7+ "net"
8+ "strings"
9+ "time"
10+)
11+
12+type smartTCPDispatchKind string
13+
14+const (
15+ dispatchHTTP smartTCPDispatchKind = "http"
16+ dispatchH2C smartTCPDispatchKind = "h2c"
17+ dispatchTLS smartTCPDispatchKind = "tls"
18+ dispatchUnknown smartTCPDispatchKind = "unknown"
19+ dispatchRejectPROXY smartTCPDispatchKind = "reject_proxy"
20+ dispatchSniffTimeout smartTCPDispatchKind = "sniff_timeout"
21+ dispatchReject smartTCPDispatchKind = "reject"
22+ dispatchRejectLarge smartTCPDispatchKind = "reject_large"
23+)
24+
25+type smartTCPSniffResult struct {
26+ kind smartTCPDispatchKind
27+ conn net.Conn
28+}
29+
30+const (
31+ maxSmartTCPSniffBytes = 4096
32+ h2cPreface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
33+ proxyV2Signature = "\r\n\r\n\x00\r\nQUIT\n"
34+)
35+
36+func sniffSmartTCP(conn net.Conn, timeout time.Duration) (smartTCPSniffResult, error) {
37+ if timeout > 0 {
38+ if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
39+ return smartTCPSniffResult{}, fmt.Errorf("set sniff read deadline: %w", err)
40+ }
41+ }
42+
43+ buf := make([]byte, 0, 32)
44+ for len(buf) < maxSmartTCPSniffBytes {
45+ remaining := maxSmartTCPSniffBytes - len(buf)
46+ readSize := min(32, remaining)
47+ tmp := make([]byte, readSize)
48+ n, err := conn.Read(tmp)
49+ if n > 0 {
50+ buf = append(buf, tmp[:n]...)
51+ if kind, done := classifySmartTCPPrefix(buf); done {
52+ return sniffResult(conn, buf, kind)
53+ }
54+ }
55+ if err != nil {
56+ if isTimeoutError(err) && len(buf) > 0 {
57+ return sniffResult(conn, buf, dispatchSniffTimeout)
58+ }
59+ return smartTCPSniffResult{}, fmt.Errorf("read smart tcp prefix: %w", err)
60+ }
61+ }
62+ return sniffResult(conn, buf, dispatchRejectLarge)
63+}
64+
65+func sniffResult(conn net.Conn, buf []byte, kind smartTCPDispatchKind) (smartTCPSniffResult, error) {
66+ if err := conn.SetReadDeadline(time.Time{}); err != nil {
67+ return smartTCPSniffResult{}, fmt.Errorf("clear sniff read deadline: %w", err)
68+ }
69+ return smartTCPSniffResult{kind: kind, conn: replayConn{Conn: conn, reader: bytes.NewReader(buf)}}, nil
70+}
71+
72+func classifySmartTCPPrefix(buf []byte) (smartTCPDispatchKind, bool) {
73+ if bytes.Equal(buf, []byte(proxyV2Signature)) || (len(buf) >= len(proxyV2Signature) && bytes.HasPrefix(buf, []byte(proxyV2Signature))) {
74+ return dispatchRejectPROXY, true
75+ }
76+ if bytes.Equal(buf, []byte("PROXY ")) || (len(buf) >= len("PROXY ") && bytes.HasPrefix(buf, []byte("PROXY "))) {
77+ return dispatchRejectPROXY, true
78+ }
79+ if isCompleteH2CPreface(buf) {
80+ return dispatchH2C, true
81+ }
82+ if hasAnyPrefix([]byte(h2cPreface), buf) {
83+ return "", false
84+ }
85+ if isHTTPRequestLine(buf) {
86+ return dispatchHTTP, true
87+ }
88+ if isMalformedHTTPLookingPrefix(buf) {
89+ return dispatchReject, true
90+ }
91+ if isTLSClientHelloPrefix(buf) {
92+ return dispatchTLS, true
93+ }
94+ if isMalformedTLSRecordHeader(buf) {
95+ return dispatchReject, true
96+ }
97+ if isAmbiguousSmartTCPPrefix(buf) {
98+ return "", false
99+ }
100+ return dispatchUnknown, true
101+}
102+
103+func isCompleteH2CPreface(buf []byte) bool {
104+ return len(buf) >= len(h2cPreface) && bytes.HasPrefix(buf, []byte(h2cPreface))
105+}
106+
107+func isHTTPRequestLine(buf []byte) bool {
108+ lineBytes, _, ok := bytes.Cut(buf, []byte("\r\n"))
109+ if !ok {
110+ return false
111+ }
112+ line := string(lineBytes)
113+ parts := strings.Split(line, " ")
114+ return len(parts) == 3 && parts[0] != "" && isHTTPToken(parts[0]) && parts[1] != "" && strings.HasPrefix(parts[2], "HTTP/")
115+}
116+
117+func isHTTPToken(token string) bool {
118+ if token == "" {
119+ return false
120+ }
121+ for _, r := range token {
122+ if r <= 32 || r >= 127 || strings.ContainsRune("()<>@,;:\\\"/[]?={}\t", r) {
123+ return false
124+ }
125+ }
126+ return true
127+}
128+
129+func isTLSClientHelloPrefix(buf []byte) bool {
130+ if len(buf) < 5 || buf[0] != 22 {
131+ return false
132+ }
133+ if buf[1] != 3 || buf[2] > 4 {
134+ return false
135+ }
136+ recordLen := int(buf[3])<<8 | int(buf[4])
137+ return recordLen > 0
138+}
139+
140+func isMalformedTLSRecordHeader(buf []byte) bool {
141+ if len(buf) < 5 || buf[0] != 22 {
142+ return false
143+ }
144+ if buf[1] != 3 || buf[2] > 4 {
145+ return false
146+ }
147+ recordLen := int(buf[3])<<8 | int(buf[4])
148+ return recordLen == 0
149+}
150+
151+func isAmbiguousSmartTCPPrefix(buf []byte) bool {
152+ return hasAnyPrefix([]byte(proxyV2Signature), buf) || hasAnyPrefix([]byte(h2cPreface), buf) || hasAnyPrefix([]byte("PROXY "), buf) || couldBecomeHTTPRequest(buf) || couldBecomeTLS(buf)
153+}
154+
155+func hasAnyPrefix(full []byte, partial []byte) bool {
156+ return len(partial) < len(full) && bytes.HasPrefix(full, partial)
157+}
158+
159+func isMalformedHTTPLookingPrefix(buf []byte) bool {
160+ lineBytes, _, ok := bytes.Cut(buf, []byte("\r\n"))
161+ if !ok || !bytes.Contains(lineBytes, []byte(" ")) {
162+ return false
163+ }
164+ method, _, _ := bytes.Cut(lineBytes, []byte(" "))
165+ return isKnownHTTPMethod(string(method))
166+}
167+
168+func isKnownHTTPMethod(method string) bool {
169+ switch method {
170+ case "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PROPFIND", "MKCOL":
171+ return true
172+ default:
173+ return false
174+ }
175+}
176+
177+func couldBecomeHTTPRequest(buf []byte) bool {
178+ if len(buf) == 0 || bytes.Contains(buf, []byte{0}) {
179+ return false
180+ }
181+ if bytes.Contains(buf, []byte("\r\n")) {
182+ return false
183+ }
184+ method, _, _ := bytes.Cut(buf, []byte(" "))
185+ return isHTTPToken(string(method))
186+}
187+
188+func couldBecomeTLS(buf []byte) bool {
189+ if len(buf) == 0 || buf[0] != 22 {
190+ return false
191+ }
192+ if len(buf) == 1 {
193+ return true
194+ }
195+ if buf[1] != 3 {
196+ return false
197+ }
198+ if len(buf) == 2 {
199+ return true
200+ }
201+ if buf[2] > 4 {
202+ return false
203+ }
204+ return len(buf) < 5
205+}
206+
207+func isTimeoutError(err error) bool {
208+ var netErr net.Error
209+ return errors.As(err, &netErr) && netErr.Timeout()
210+}
A internal/adapters/in/traffic/sniff_test.go +263 -0
@@ -0,0 +1,263 @@
1+package traffic
2+
3+import (
4+ "errors"
5+ "io"
6+ "net"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestSniffSmartTCPClassifiesHTTP(t *testing.T) {
16+ for _, tc := range []struct {
17+ name string
18+ data string
19+ }{
20+ {name: "get", data: "GET / HTTP/1.1\r\n"},
21+ {name: "propfind", data: "PROPFIND / HTTP/1.1\r\n"},
22+ {name: "mkcol", data: "MKCOL / HTTP/1.1\r\n"},
23+ {name: "unknown_method", data: "FOO / HTTP/1.1\r\n"},
24+ {name: "extension_method", data: "BAR / HTTP/1.1\r\n"},
25+ } {
26+ t.Run(tc.name, func(t *testing.T) {
27+ result := sniffBytes(t, []byte(tc.data), time.Second)
28+ assert.Equal(t, dispatchHTTP, result.kind)
29+ assertReplayPrefix(t, result.conn, []byte(tc.data))
30+ })
31+ }
32+}
33+
34+func TestSniffSmartTCPClassifiesH2C(t *testing.T) {
35+ preface := []byte("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n")
36+ result := sniffBytes(t, preface, time.Second)
37+ require.Equal(t, dispatchH2C, result.kind)
38+ assertReplayPrefix(t, result.conn, preface)
39+}
40+
41+func TestSniffSmartTCPClassifiesTLSAndAllowsClientHelloParsing(t *testing.T) {
42+ hello := clientHelloBytes(t, "sniff.example.com")
43+ result := sniffBytes(t, hello, time.Second)
44+ require.Equal(t, dispatchTLS, result.kind)
45+
46+ sni, replayed, err := peekClientHelloSNI(result.conn)
47+ require.NoError(t, err)
48+ assert.Equal(t, "sniff.example.com", sni)
49+ assertReplayPrefix(t, replayed, hello)
50+}
51+
52+func TestSniffSmartTCPRejectsMalformedTLSLookingZeroLengthRecord(t *testing.T) {
53+ result := sniffBytes(t, []byte{22, 3, 3, 0, 0}, time.Second)
54+ assert.Equal(t, dispatchReject, result.kind)
55+ assertReplayPrefix(t, result.conn, []byte{22, 3, 3, 0, 0})
56+}
57+
58+func TestSniffSmartTCPClassifiesSSHBannerAsUnknown(t *testing.T) {
59+ banner := []byte("SSH-2.0-client\r\n")
60+ kind, done := classifySmartTCPPrefix(banner)
61+ require.True(t, done)
62+ assert.Equal(t, dispatchUnknown, kind)
63+
64+ result := sniffBytes(t, banner, time.Second)
65+ assert.Equal(t, dispatchUnknown, result.kind)
66+ assertReplayPrefix(t, result.conn, banner)
67+}
68+
69+func TestSniffSmartTCPRejectsMalformedHTTPLookingRequestLine(t *testing.T) {
70+ data := []byte("GET /broken\r\n")
71+ kind, done := classifySmartTCPPrefix(data)
72+ require.True(t, done)
73+ assert.Equal(t, dispatchReject, kind)
74+
75+ result := sniffBytes(t, data, time.Second)
76+ assert.Equal(t, dispatchReject, result.kind)
77+ assertReplayPrefix(t, result.conn, data)
78+}
79+
80+func TestSniffSmartTCPClassifiesTokenSpacePlaintextAsUnknown(t *testing.T) {
81+ for _, data := range [][]byte{
82+ []byte("USER anonymous\r\n"),
83+ []byte("MAIL FROM:<a@example.com>\r\n"),
84+ } {
85+ result := sniffBytes(t, data, time.Second)
86+ assert.Equal(t, dispatchUnknown, result.kind)
87+ assertReplayPrefix(t, result.conn, data)
88+ }
89+}
90+
91+func TestSniffSmartTCPRejectsPROXY(t *testing.T) {
92+ for _, tc := range []struct {
93+ name string
94+ data []byte
95+ }{
96+ {name: "v1", data: []byte("PROXY TCP4 192.0.2.1 192.0.2.2 12345 443\r\n")},
97+ {name: "v2", data: append([]byte{0x0d, 0x0a, 0x0d, 0x0a, 0x00, 0x0d, 0x0a, 0x51, 0x55, 0x49, 0x54, 0x0a}, []byte{0x21, 0x11, 0x00, 0x0c}...)},
98+ } {
99+ t.Run(tc.name, func(t *testing.T) {
100+ result := sniffBytes(t, tc.data, time.Second)
101+ assert.Equal(t, dispatchRejectPROXY, result.kind)
102+ assertReplayPrefix(t, result.conn, tc.data)
103+ })
104+ }
105+}
106+
107+func TestSniffSmartTCPUnknownIsImmediate(t *testing.T) {
108+ client, server := net.Pipe()
109+ defer client.Close()
110+
111+ start := time.Now()
112+ done := make(chan sniffOutcome, 1)
113+ go func() {
114+ result, err := sniffSmartTCP(server, time.Second)
115+ done <- sniffOutcome{result: result, err: err}
116+ }()
117+
118+ _, err := client.Write([]byte{0})
119+ require.NoError(t, err)
120+ select {
121+ case outcome := <-done:
122+ require.NoError(t, outcome.err)
123+ assert.Equal(t, dispatchUnknown, outcome.result.kind)
124+ assert.Less(t, time.Since(start), 500*time.Millisecond)
125+ case <-time.After(500 * time.Millisecond):
126+ t.Fatal("sniff did not classify unknown bytes immediately")
127+ }
128+}
129+
130+func TestSniffSmartTCPAmbiguousPartialsTimeOut(t *testing.T) {
131+ for _, tc := range []struct {
132+ name string
133+ data []byte
134+ }{
135+ {name: "g", data: []byte("G")},
136+ {name: "b", data: []byte("B")},
137+ {name: "bar", data: []byte("BAR")},
138+ {name: "pr", data: []byte("PR")},
139+ {name: "tls_content_type", data: []byte{22}},
140+ {name: "tls_version_major", data: []byte{22, 3}},
141+ {name: "tls_version_minor", data: []byte{22, 3, 3}},
142+ {name: "tls_record_length_partial", data: []byte{22, 3, 3, 0}},
143+ } {
144+ t.Run(tc.name, func(t *testing.T) {
145+ result := sniffBytes(t, tc.data, 20*time.Millisecond)
146+ assert.Equal(t, dispatchSniffTimeout, result.kind)
147+ assertReplayPrefix(t, result.conn, tc.data)
148+ })
149+ }
150+}
151+
152+func TestSniffSmartTCPReplayReturnsSniffedBytesBeforeUnderlyingBytes(t *testing.T) {
153+ client, server := net.Pipe()
154+ defer client.Close()
155+
156+ done := make(chan sniffOutcome, 1)
157+ go func() {
158+ result, err := sniffSmartTCP(server, time.Second)
159+ done <- sniffOutcome{result: result, err: err}
160+ }()
161+ _, err := client.Write([]byte("GET / HTTP/1.1\r\n"))
162+ require.NoError(t, err)
163+ outcome := <-done
164+ require.NoError(t, outcome.err)
165+ result := outcome.result
166+ writeDone := make(chan error, 1)
167+ go func() {
168+ _, err := client.Write([]byte("Host: example.com\r\n\r\n"))
169+ writeDone <- err
170+ }()
171+
172+ buf := make([]byte, len("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"))
173+ _, err = io.ReadFull(result.conn, buf)
174+ require.NoError(t, err)
175+ assert.Equal(t, "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n", string(buf))
176+ require.NoError(t, <-writeDone)
177+}
178+
179+func TestSniffSmartTCPHandlesLongHTTPRequestLine(t *testing.T) {
180+ client, server := net.Pipe()
181+ defer client.Close()
182+
183+ longRequest := "GET /" + strings.Repeat("a", 512) + " HTTP/1.1\r\n"
184+ writeDone := make(chan error, 1)
185+ go func() {
186+ _, err := client.Write([]byte(longRequest))
187+ writeDone <- err
188+ }()
189+ result, err := sniffSmartTCP(server, time.Second)
190+ require.NoError(t, err)
191+ assert.Equal(t, dispatchHTTP, result.kind)
192+ assertReplayPrefix(t, result.conn, []byte(longRequest))
193+ require.NoError(t, <-writeDone)
194+}
195+
196+func TestSniffSmartTCPClearsReadDeadlineBeforeHandoff(t *testing.T) {
197+ conn := &deadlineConn{Conn: pipeConnWithData(t, []byte("GET / HTTP/1.1\r\n"))}
198+ result, err := sniffSmartTCP(conn, time.Second)
199+ require.NoError(t, err)
200+ assert.Equal(t, dispatchHTTP, result.kind)
201+ assert.True(t, conn.sawNonZeroReadDeadline)
202+ assert.True(t, conn.sawClearedReadDeadline)
203+}
204+
205+func sniffBytes(t *testing.T, data []byte, timeout time.Duration) smartTCPSniffResult {
206+ t.Helper()
207+ conn := pipeConnWithData(t, data)
208+ result, err := sniffSmartTCP(conn, timeout)
209+ require.NoError(t, err)
210+ return result
211+}
212+
213+func pipeConnWithData(t *testing.T, data []byte) net.Conn {
214+ t.Helper()
215+ client, server := net.Pipe()
216+ done := make(chan struct{})
217+ go func() {
218+ defer close(done)
219+ _, _ = client.Write(data)
220+ }()
221+ t.Cleanup(func() {
222+ _ = client.Close()
223+ _ = server.Close()
224+ <-done
225+ })
226+ return server
227+}
228+
229+func assertReplayPrefix(t *testing.T, conn net.Conn, want []byte) {
230+ t.Helper()
231+ buf := make([]byte, len(want))
232+ _, err := io.ReadFull(conn, buf)
233+ require.NoError(t, err)
234+ assert.Equal(t, want, buf)
235+}
236+
237+type sniffOutcome struct {
238+ result smartTCPSniffResult
239+ err error
240+}
241+
242+type deadlineConn struct {
243+ net.Conn
244+ sawNonZeroReadDeadline bool
245+ sawClearedReadDeadline bool
246+}
247+
248+func (c *deadlineConn) SetReadDeadline(t time.Time) error {
249+ if t.IsZero() {
250+ c.sawClearedReadDeadline = true
251+ } else {
252+ c.sawNonZeroReadDeadline = true
253+ }
254+ return c.Conn.SetReadDeadline(t)
255+}
256+
257+func TestSniffSmartTCPDoesNotTreatClosedPipeAsSuccess(t *testing.T) {
258+ client, server := net.Pipe()
259+ _ = client.Close()
260+ _, err := sniffSmartTCP(server, time.Second)
261+ require.Error(t, err)
262+ assert.True(t, errors.Is(err, net.ErrClosed) || errors.Is(err, io.ErrClosedPipe), "expected closed connection error, got %v", err)
263+}
A internal/adapters/in/traffic/tcp.go +930 -0
@@ -0,0 +1,930 @@
1+package traffic
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "net"
9+ "net/http"
10+ "strconv"
11+ "strings"
12+ "sync"
13+ "sync/atomic"
14+ "time"
15+
16+ "github.com/bnema/gordon/internal/domain"
17+)
18+
19+type entryPointRuntime struct {
20+ manager *Manager
21+ entryPoint domain.EntryPoint
22+ listener net.Listener
23+ counters trafficCounters
24+ trusted []*net.IPNet
25+
26+ started atomic.Bool
27+ closed atomic.Bool
28+
29+ ctx context.Context
30+ cancel context.CancelFunc
31+ acceptDone chan struct{}
32+ acceptDoneOnce sync.Once
33+ activeWG sync.WaitGroup
34+
35+ mu sync.Mutex
36+ activeConns map[*trackedTCPConn]struct{}
37+
38+ tlsHTTPReplaceMu sync.Mutex
39+ tlsHTTPListener *tlsHTTPListener
40+ tlsHTTPServer *http.Server
41+ tlsHTTPDone chan struct{}
42+
43+ smartHTTPReplaceMu sync.Mutex
44+ smartHTTPListener *tlsHTTPListener
45+ smartHTTPServer *http.Server
46+ smartHTTPDone chan struct{}
47+
48+ smartTLSReplaceMu sync.Mutex
49+ smartTLSListener *tlsHTTPListener
50+ smartTLSServer *http.Server
51+ smartTLSDone chan struct{}
52+
53+ rawFallbackTrusted []*net.IPNet
54+}
55+
56+type trackedTCPConn struct {
57+ mu sync.Mutex
58+ client net.Conn
59+ backend net.Conn
60+ closing bool
61+ onDone func()
62+ doneOnce sync.Once
63+ rawFallbackName string
64+}
65+
66+func (c *trackedTCPConn) setBackend(backend net.Conn) {
67+ c.mu.Lock()
68+ if c.closing {
69+ c.mu.Unlock()
70+ _ = backend.Close()
71+ return
72+ }
73+ c.backend = backend
74+ c.mu.Unlock()
75+}
76+
77+func (c *trackedTCPConn) close() {
78+ c.mu.Lock()
79+ c.closing = true
80+ client := c.client
81+ backend := c.backend
82+ c.mu.Unlock()
83+ _ = client.Close()
84+ if backend != nil {
85+ _ = backend.Close()
86+ }
87+}
88+
89+func (c *trackedTCPConn) complete() {
90+ c.doneOnce.Do(func() {
91+ if c.onDone != nil {
92+ c.onDone()
93+ }
94+ })
95+}
96+
97+func (c *trackedTCPConn) markRawFallback(name string) {
98+ c.mu.Lock()
99+ c.rawFallbackName = name
100+ c.mu.Unlock()
101+}
102+
103+func (c *trackedTCPConn) rawFallback() (string, bool) {
104+ c.mu.Lock()
105+ defer c.mu.Unlock()
106+ return c.rawFallbackName, c.rawFallbackName != ""
107+}
108+
109+func newEntryPointRuntime(parentCtx context.Context, manager *Manager, entryPoint domain.EntryPoint, listener net.Listener, trusted []*net.IPNet, rawTrusted []*net.IPNet) *entryPointRuntime {
110+ ctx, cancel := context.WithCancel(context.WithoutCancel(parentCtx))
111+ return &entryPointRuntime{
112+ manager: manager,
113+ entryPoint: entryPoint,
114+ listener: listener,
115+ trusted: trusted,
116+ rawFallbackTrusted: rawTrusted,
117+ ctx: ctx,
118+ cancel: cancel,
119+ acceptDone: make(chan struct{}),
120+ activeConns: map[*trackedTCPConn]struct{}{},
121+ }
122+}
123+
124+func (r *entryPointRuntime) start() {
125+ if !r.started.CompareAndSwap(false, true) {
126+ return
127+ }
128+ entryPoint := r.entryPointSnapshot()
129+ r.startTLSHTTPServer(entryPoint)
130+ r.startSmartTCPHTTPServers(entryPoint)
131+ trafficInfo(r.ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("started tcp traffic entrypoint")
132+ go r.acceptLoop()
133+}
134+
135+func (r *entryPointRuntime) startTLSHTTPServer(entryPoint domain.EntryPoint) {
136+ if entryPoint.Protocol != domain.EntryPointProtocolTLSMux {
137+ return
138+ }
139+ config, ok := r.manager.tlsHTTPServer(entryPoint.Name)
140+ if !ok || config.Handler == nil || config.TLSConfig == nil {
141+ return
142+ }
143+ r.mu.Lock()
144+ alreadyStarted := r.tlsHTTPServer != nil
145+ r.mu.Unlock()
146+ if alreadyStarted {
147+ return
148+ }
149+ r.replaceTLSHTTPServer(entryPoint, config)
150+}
151+
152+func (r *entryPointRuntime) refreshTLSHTTPServer(entryPointName string) {
153+ entryPoint := r.entryPointSnapshot()
154+ if entryPoint.Name != entryPointName || entryPoint.Protocol != domain.EntryPointProtocolTLSMux {
155+ return
156+ }
157+ config, ok := r.manager.tlsHTTPServer(entryPointName)
158+ if !ok || config.Handler == nil || config.TLSConfig == nil {
159+ r.stopTLSHTTPServer(r.ctx, 0)
160+ return
161+ }
162+ r.replaceTLSHTTPServer(entryPoint, config)
163+}
164+
165+func (r *entryPointRuntime) replaceTLSHTTPServer(entryPoint domain.EntryPoint, config TLSHTTPServerConfig) {
166+ r.tlsHTTPReplaceMu.Lock()
167+ defer r.tlsHTTPReplaceMu.Unlock()
168+ if r.isClosed() {
169+ return
170+ }
171+ listener := newTLSHTTPListener(r.listener.Addr())
172+ server := &http.Server{
173+ Handler: config.Handler,
174+ TLSConfig: config.TLSConfig.Clone(),
175+ ReadHeaderTimeout: 5 * time.Second,
176+ ReadTimeout: 10 * time.Second,
177+ WriteTimeout: 10 * time.Second,
178+ IdleTimeout: 30 * time.Second,
179+ }
180+ done := make(chan struct{})
181+
182+ r.mu.Lock()
183+ oldListener := r.tlsHTTPListener
184+ oldServer := r.tlsHTTPServer
185+ oldDone := r.tlsHTTPDone
186+ if r.isClosed() {
187+ r.mu.Unlock()
188+ _ = listener.Close()
189+ return
190+ }
191+ r.tlsHTTPListener = listener
192+ r.tlsHTTPServer = server
193+ r.tlsHTTPDone = done
194+ r.mu.Unlock()
195+
196+ go func() {
197+ defer close(done)
198+ if err := server.ServeTLS(listener, "", ""); err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
199+ trafficWarn(r.ctx).Err(err).Str("entrypoint", entryPoint.Name).Msg("tls mux https server stopped with error")
200+ }
201+ }()
202+ stopTLSHTTPServer(r.ctx, entryPoint.Name, oldListener, oldServer, oldDone, 0)
203+}
204+
205+func (r *entryPointRuntime) routeToHTTPS(tracked *trackedTCPConn, conn net.Conn) bool {
206+ r.mu.Lock()
207+ listener := r.tlsHTTPListener
208+ r.mu.Unlock()
209+ return r.routeToHTTPListener(tracked, conn, listener)
210+}
211+
212+func (r *entryPointRuntime) routeToSmartHTTP(tracked *trackedTCPConn, conn net.Conn) bool {
213+ r.mu.Lock()
214+ listener := r.smartHTTPListener
215+ r.mu.Unlock()
216+ return r.routeToHTTPListener(tracked, conn, listener)
217+}
218+
219+func (r *entryPointRuntime) routeToSmartHTTPS(tracked *trackedTCPConn, conn net.Conn) bool {
220+ r.mu.Lock()
221+ listener := r.smartTLSListener
222+ r.mu.Unlock()
223+ return r.routeToHTTPListener(tracked, conn, listener)
224+}
225+
226+func (r *entryPointRuntime) routeToHTTPListener(tracked *trackedTCPConn, conn net.Conn, listener *tlsHTTPListener) bool {
227+ if listener == nil {
228+ return false
229+ }
230+ return listener.serve(&trackedHTTPConn{Conn: conn, onClose: tracked.complete})
231+}
232+
233+type trackedHTTPConn struct {
234+ net.Conn
235+ once sync.Once
236+ onClose func()
237+}
238+
239+func (c *trackedHTTPConn) Close() error {
240+ err := c.Conn.Close()
241+ c.once.Do(c.onClose)
242+ return err
243+}
244+
245+func (r *entryPointRuntime) acceptLoop() {
246+ defer r.closeAcceptDone()
247+ for {
248+ conn, err := r.listener.Accept()
249+ if err != nil {
250+ if r.isClosed() || errors.Is(err, net.ErrClosed) {
251+ return
252+ }
253+ r.counters.totalErrors.Add(1)
254+ var netErr net.Error
255+ if errors.As(err, &netErr) && netErr.Timeout() {
256+ continue
257+ }
258+ trafficWarn(r.ctx).Err(err).Str("entrypoint", r.entryPointSnapshot().Name).Msg("tcp traffic accept failed; retrying")
259+ select {
260+ case <-time.After(100 * time.Millisecond):
261+ case <-r.ctx.Done():
262+ return
263+ }
264+ continue
265+ }
266+ if !trustedRemoteAddr(r.trustedSnapshot(), conn.RemoteAddr()) {
267+ r.counters.totalRefused.Add(1)
268+ if r.entryPointSnapshot().Protocol == domain.EntryPointProtocolSmartTCP {
269+ r.counters.smartTCP.entrypointCIDRRefused.Add(1)
270+ }
271+ _ = conn.Close()
272+ continue
273+ }
274+ r.activeWG.Add(1)
275+ go r.handleTCPConn(conn)
276+ }
277+}
278+
279+func (r *entryPointRuntime) handleTCPConn(client net.Conn) {
280+ options := effectiveTCPOptions(snapshotTCPOptions(r.manager.snapshot.Load()))
281+ if !r.reserveConnection(options.MaxConnections) {
282+ r.counters.totalRefused.Add(1)
283+ _ = client.Close()
284+ r.activeWG.Done()
285+ return
286+ }
287+
288+ tracked := &trackedTCPConn{client: client}
289+ tracked.onDone = func() {
290+ r.untrack(tracked)
291+ r.releaseConnection()
292+ r.activeWG.Done()
293+ }
294+ if !r.track(tracked) {
295+ r.releaseConnection()
296+ _ = client.Close()
297+ r.activeWG.Done()
298+ return
299+ }
300+ completeOnReturn := true
301+ defer func() {
302+ if completeOnReturn {
303+ tracked.complete()
304+ }
305+ }()
306+
307+ switch r.entryPointSnapshot().Protocol {
308+ case domain.EntryPointProtocolTCP:
309+ r.handlePlainTCP(tracked, options)
310+ case domain.EntryPointProtocolTLSMux:
311+ if r.handleTLSMux(tracked, options) {
312+ completeOnReturn = false
313+ }
314+ case domain.EntryPointProtocolSmartTCP:
315+ if r.handleSmartTCP(tracked, options) {
316+ completeOnReturn = false
317+ }
318+ default:
319+ r.counters.totalRefused.Add(1)
320+ _ = tracked.client.Close()
321+ }
322+}
323+
324+func (r *entryPointRuntime) handlePlainTCP(tracked *trackedTCPConn, options domain.TCPOptions) {
325+ _, backend, ok := r.resolveTCPBackend()
326+ if !ok {
327+ r.counters.totalRefused.Add(1)
328+ _ = tracked.client.Close()
329+ return
330+ }
331+ r.proxyToBackend(tracked, tracked.client, backend, options)
332+}
333+
334+func (r *entryPointRuntime) handleSmartTCP(tracked *trackedTCPConn, options domain.TCPOptions) bool {
335+ result, err := sniffSmartTCP(tracked.client, clientHelloTimeout(options))
336+ if err != nil {
337+ r.counters.totalErrors.Add(1)
338+ _ = tracked.client.Close()
339+ return false
340+ }
341+ switch result.kind {
342+ case dispatchHTTP, dispatchH2C:
343+ return r.handleSmartTCPHTTP(tracked, result)
344+ case dispatchTLS:
345+ return r.handleSmartTCPTLS(tracked, result.conn, options)
346+ case dispatchUnknown:
347+ return r.handleSmartTCPUnknown(tracked, result.conn, options)
348+ case dispatchRejectPROXY:
349+ return r.refuseSmartTCP(result.conn, r.counters.smartTCP.proxyRefused.Add)
350+ case dispatchSniffTimeout:
351+ return r.refuseSmartTCP(result.conn, r.counters.smartTCP.sniffTimeout.Add)
352+ case dispatchReject:
353+ return r.refuseSmartTCP(result.conn, r.counters.smartTCP.malformedRejected.Add)
354+ case dispatchRejectLarge:
355+ return r.refuseSmartTCP(result.conn, r.counters.smartTCP.clientHelloTooLarge.Add)
356+ default:
357+ return r.refuseSmartTCP(result.conn, nil)
358+ }
359+}
360+
361+func (r *entryPointRuntime) handleSmartTCPHTTP(tracked *trackedTCPConn, result smartTCPSniffResult) bool {
362+ if !r.routeToSmartHTTP(tracked, result.conn) {
363+ return r.refuseSmartTCP(result.conn, nil)
364+ }
365+ r.counters.totalAccepted.Add(1)
366+ if result.kind == dispatchH2C {
367+ r.counters.smartTCP.h2cAccepted.Add(1)
368+ } else {
369+ r.counters.smartTCP.httpAccepted.Add(1)
370+ }
371+ return true
372+}
373+
374+func (r *entryPointRuntime) handleSmartTCPTLS(tracked *trackedTCPConn, conn net.Conn, options domain.TCPOptions) bool {
375+ peeked, err := r.peekTLSClientHello(conn, options)
376+ if err != nil {
377+ return r.handleSmartTCPTLSPeekError(conn, err)
378+ }
379+ if backend, ok := r.resolveTLSBackend(peeked.sni); ok {
380+ r.proxyToBackendAfterDial(tracked, peeked.conn, backend, options, func() {
381+ r.counters.smartTCP.tlsPassthroughAccepted.Add(1)
382+ })
383+ return false
384+ }
385+ if r.routeToSmartHTTPS(tracked, peeked.conn) {
386+ r.counters.totalAccepted.Add(1)
387+ r.counters.smartTCP.httpsFallbackAccepted.Add(1)
388+ return true
389+ }
390+ return r.refuseSmartTCP(peeked.conn, nil)
391+}
392+
393+func (r *entryPointRuntime) handleSmartTCPTLSPeekError(conn net.Conn, err error) bool {
394+ if isClientHelloTooLargeError(err) {
395+ r.counters.totalRefused.Add(1)
396+ r.counters.smartTCP.clientHelloTooLarge.Add(1)
397+ } else {
398+ r.counters.totalErrors.Add(1)
399+ }
400+ _ = conn.Close()
401+ return false
402+}
403+
404+func (r *entryPointRuntime) handleSmartTCPUnknown(tracked *trackedTCPConn, conn net.Conn, options domain.TCPOptions) bool {
405+ backend, rawFallbackName, ok, rawCIDRRefused := r.resolveRawFallbackBackendDetailed(conn.RemoteAddr())
406+ if ok {
407+ tracked.markRawFallback(rawFallbackName)
408+ r.proxyToBackendAfterDial(tracked, conn, backend, options, func() {
409+ r.counters.smartTCP.rawFallbackAccepted.Add(1)
410+ })
411+ return false
412+ }
413+ if rawCIDRRefused {
414+ return r.refuseSmartTCP(conn, r.counters.smartTCP.rawFallbackCIDRRefused.Add)
415+ }
416+ return r.refuseSmartTCP(conn, r.counters.smartTCP.unknownNoFallbackRefused.Add)
417+}
418+
419+func (r *entryPointRuntime) refuseSmartTCP(conn net.Conn, increment func(int64) int64) bool {
420+ r.counters.totalRefused.Add(1)
421+ if increment != nil {
422+ increment(1)
423+ }
424+ _ = conn.Close()
425+ return false
426+}
427+
428+func (r *entryPointRuntime) handleTLSMux(tracked *trackedTCPConn, options domain.TCPOptions) bool {
429+ peeked, err := r.peekTLSClientHello(tracked.client, options)
430+ if err != nil {
431+ r.counters.totalErrors.Add(1)
432+ _ = tracked.client.Close()
433+ return false
434+ }
435+ if backend, ok := r.resolveTLSBackend(peeked.sni); ok {
436+ r.proxyToBackend(tracked, peeked.conn, backend, options)
437+ return false
438+ }
439+ if r.routeToHTTPS(tracked, peeked.conn) {
440+ r.counters.totalAccepted.Add(1)
441+ return true
442+ }
443+ r.counters.totalRefused.Add(1)
444+ _ = peeked.conn.Close()
445+ return false
446+}
447+
448+func (r *entryPointRuntime) peekTLSClientHello(client net.Conn, options domain.TCPOptions) (peekedTLSConn, error) {
449+ if err := client.SetReadDeadline(time.Now().Add(clientHelloTimeout(options))); err != nil {
450+ return peekedTLSConn{}, fmt.Errorf("set client hello read deadline for %s: %w", client.RemoteAddr(), err)
451+ }
452+ sni, replayed, err := peekClientHelloSNI(client)
453+ if clearErr := client.SetReadDeadline(time.Time{}); err == nil && clearErr != nil {
454+ return peekedTLSConn{}, fmt.Errorf("clear client hello read deadline for %s: %w", client.RemoteAddr(), clearErr)
455+ }
456+ if err != nil {
457+ return peekedTLSConn{}, fmt.Errorf("peek client hello from %s: %w", client.RemoteAddr(), err)
458+ }
459+ return peekedTLSConn{sni: sni, conn: replayed}, nil
460+}
461+
462+func isClientHelloTooLargeError(err error) bool {
463+ return errors.Is(err, domain.ErrClientHelloTooLarge)
464+}
465+
466+func clientHelloTimeout(options domain.TCPOptions) time.Duration {
467+ const maxTimeout = 5 * time.Second
468+ if options.DialTimeout > 0 && options.DialTimeout < maxTimeout {
469+ return options.DialTimeout
470+ }
471+ return maxTimeout
472+}
473+
474+type peekedTLSConn struct {
475+ sni string
476+ conn net.Conn
477+}
478+
479+func (r *entryPointRuntime) proxyToBackend(tracked *trackedTCPConn, client net.Conn, backend domain.TrafficBackend, options domain.TCPOptions) bool {
480+ return r.proxyToBackendAfterDial(tracked, client, backend, options, nil)
481+}
482+
483+func (r *entryPointRuntime) proxyToBackendAfterDial(tracked *trackedTCPConn, client net.Conn, backend domain.TrafficBackend, options domain.TCPOptions, afterDial func()) bool {
484+ dialCtx, cancel := context.WithTimeout(r.ctx, options.DialTimeout)
485+ defer cancel()
486+ backendConn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", net.JoinHostPort(backend.Host, strconv.Itoa(backend.Port)))
487+ if err != nil {
488+ r.counters.totalErrors.Add(1)
489+ _ = client.Close()
490+ return false
491+ }
492+
493+ r.setBackend(tracked, backendConn)
494+ r.counters.totalAccepted.Add(1)
495+ if afterDial != nil {
496+ afterDial()
497+ }
498+ r.proxyTCP(client, backendConn, options.IdleTimeout)
499+ return true
500+}
501+
502+func (r *entryPointRuntime) resolveRawFallbackBackendDetailed(remote net.Addr) (domain.TrafficBackend, string, bool, bool) {
503+ graph := r.manager.snapshot.Load()
504+ if graph == nil {
505+ return domain.TrafficBackend{}, "", false, false
506+ }
507+ entryPoint := r.entryPointSnapshot()
508+ if entryPoint.RawFallback == "" {
509+ return domain.TrafficBackend{}, "", false, false
510+ }
511+ if !entryPoint.AllowPublicRawFallback {
512+ trusted := r.rawFallbackTrustedSnapshot()
513+ if len(trusted) == 0 || !trustedRemoteAddr(trusted, remote) {
514+ return domain.TrafficBackend{}, "", false, true
515+ }
516+ }
517+ for _, router := range graph.Routers {
518+ if router.Name == entryPoint.RawFallback && router.EntryPoint == entryPoint.Name && router.Protocol == domain.RouterProtocolTCP {
519+ backend, ok := r.backendForRouter(graph, router)
520+ return backend, entryPoint.RawFallback, ok, false
521+ }
522+ }
523+ return domain.TrafficBackend{}, "", false, false
524+}
525+
526+func (r *entryPointRuntime) resolveTCPBackend() (domain.TrafficRouter, domain.TrafficBackend, bool) {
527+ graph := r.manager.snapshot.Load()
528+ if graph == nil {
529+ return domain.TrafficRouter{}, domain.TrafficBackend{}, false
530+ }
531+ entryPoint := r.entryPointSnapshot()
532+ var router domain.TrafficRouter
533+ for _, candidate := range graph.Routers {
534+ if candidate.EntryPoint == entryPoint.Name && candidate.Protocol == domain.RouterProtocolTCP {
535+ if router.Name != "" {
536+ return domain.TrafficRouter{}, domain.TrafficBackend{}, false
537+ }
538+ router = candidate
539+ }
540+ }
541+ if router.Name == "" {
542+ return domain.TrafficRouter{}, domain.TrafficBackend{}, false
543+ }
544+ for _, service := range graph.Services {
545+ if service.Name != router.Service || len(service.Backends) != 1 {
546+ continue
547+ }
548+ backend := service.Backends[0]
549+ if backend.Protocol != domain.NetworkProtocolTCP {
550+ return domain.TrafficRouter{}, domain.TrafficBackend{}, false
551+ }
552+ return router, backend, true
553+ }
554+ return domain.TrafficRouter{}, domain.TrafficBackend{}, false
555+}
556+
557+func (r *entryPointRuntime) resolveTLSBackend(sni string) (domain.TrafficBackend, bool) {
558+ graph := r.manager.snapshot.Load()
559+ sni = normalizeTLSName(sni)
560+ if graph == nil || sni == "" {
561+ return domain.TrafficBackend{}, false
562+ }
563+ if router, ok := r.findExactTLSRouter(graph, sni); ok {
564+ return r.backendForRouter(graph, router)
565+ }
566+ if router, ok := r.findWildcardTLSRouter(graph, sni); ok {
567+ return r.backendForRouter(graph, router)
568+ }
569+ return domain.TrafficBackend{}, false
570+}
571+
572+func (r *entryPointRuntime) findExactTLSRouter(graph *domain.TrafficGraph, sni string) (domain.TrafficRouter, bool) {
573+ entryPoint := r.entryPointSnapshot()
574+ for _, router := range graph.Routers {
575+ if router.EntryPoint == entryPoint.Name && router.Protocol == domain.RouterProtocolTLSPassthrough && normalizeTLSName(router.Rule.SNI) == sni {
576+ return router, true
577+ }
578+ }
579+ return domain.TrafficRouter{}, false
580+}
581+
582+func (r *entryPointRuntime) findWildcardTLSRouter(graph *domain.TrafficGraph, sni string) (domain.TrafficRouter, bool) {
583+ entryPoint := r.entryPointSnapshot()
584+ for _, router := range graph.Routers {
585+ wildcard := normalizeTLSName(router.Rule.SNI)
586+ if router.EntryPoint == entryPoint.Name && router.Protocol == domain.RouterProtocolTLSPassthrough && hostMatchesTLSWildcard(sni, wildcard) {
587+ return router, true
588+ }
589+ }
590+ return domain.TrafficRouter{}, false
591+}
592+
593+func (r *entryPointRuntime) backendForRouter(graph *domain.TrafficGraph, router domain.TrafficRouter) (domain.TrafficBackend, bool) {
594+ for _, service := range graph.Services {
595+ if service.Name == router.Service && len(service.Backends) == 1 && service.Backends[0].Protocol == domain.NetworkProtocolTCP {
596+ return service.Backends[0], true
597+ }
598+ }
599+ return domain.TrafficBackend{}, false
600+}
601+
602+func hostMatchesTLSWildcard(host string, wildcard string) bool {
603+ suffix, ok := strings.CutPrefix(wildcard, "*.")
604+ if !ok || host == suffix {
605+ return false
606+ }
607+ prefix, ok := strings.CutSuffix(host, "."+suffix)
608+ return ok && prefix != "" && !strings.Contains(prefix, ".")
609+}
610+
611+func (r *entryPointRuntime) proxyTCP(client net.Conn, backend net.Conn, idleTimeout time.Duration) {
612+ defer client.Close()
613+ defer backend.Close()
614+
615+ var wg sync.WaitGroup
616+ wg.Add(2)
617+ go func() {
618+ defer wg.Done()
619+ n, err := copyWithIdleTimeout(backend, client, idleTimeout)
620+ r.counters.bytesIn.Add(n)
621+ if err != nil && !isExpectedCopyError(err) {
622+ r.counters.totalErrors.Add(1)
623+ }
624+ closeWrite(backend)
625+ }()
626+ go func() {
627+ defer wg.Done()
628+ n, err := copyWithIdleTimeout(client, backend, idleTimeout)
629+ r.counters.bytesOut.Add(n)
630+ if err != nil && !isExpectedCopyError(err) {
631+ r.counters.totalErrors.Add(1)
632+ }
633+ closeWrite(client)
634+ }()
635+ wg.Wait()
636+}
637+
638+func copyWithIdleTimeout(dst net.Conn, src net.Conn, idleTimeout time.Duration) (int64, error) {
639+ if idleTimeout <= 0 {
640+ return io.Copy(dst, src)
641+ }
642+ return io.Copy(deadlineWriter{Conn: dst, idleTimeout: idleTimeout}, idleConn{Conn: src, idleTimeout: idleTimeout})
643+}
644+
645+type idleConn struct {
646+ net.Conn
647+ idleTimeout time.Duration
648+}
649+
650+func (c idleConn) Read(p []byte) (int, error) {
651+ if err := c.SetReadDeadline(time.Now().Add(c.idleTimeout)); err != nil {
652+ return 0, err
653+ }
654+ return c.Conn.Read(p)
655+}
656+
657+type deadlineWriter struct {
658+ net.Conn
659+ idleTimeout time.Duration
660+}
661+
662+func (c deadlineWriter) Write(p []byte) (int, error) {
663+ if err := c.SetWriteDeadline(time.Now().Add(c.idleTimeout)); err != nil {
664+ return 0, err
665+ }
666+ return c.Conn.Write(p)
667+}
668+
669+func closeWrite(conn net.Conn) {
670+ if writeCloser, ok := conn.(interface{ CloseWrite() error }); ok {
671+ _ = writeCloser.CloseWrite()
672+ }
673+}
674+
675+func isExpectedCopyError(err error) bool {
676+ if err == nil || errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
677+ return true
678+ }
679+ var netErr net.Error
680+ if errors.As(err, &netErr) && netErr.Timeout() {
681+ return true
682+ }
683+ return false
684+}
685+
686+func (r *entryPointRuntime) reserveConnection(maxConnections int) bool {
687+ active := r.counters.activeTCPConnections.Add(1)
688+ if maxConnections > 0 && active > int64(maxConnections) {
689+ r.counters.activeTCPConnections.Add(-1)
690+ return false
691+ }
692+ return true
693+}
694+
695+func (r *entryPointRuntime) releaseConnection() {
696+ r.counters.activeTCPConnections.Add(-1)
697+}
698+
699+func (r *entryPointRuntime) track(conn *trackedTCPConn) bool {
700+ r.mu.Lock()
701+ defer r.mu.Unlock()
702+ if r.isClosed() {
703+ return false
704+ }
705+ r.activeConns[conn] = struct{}{}
706+ return true
707+}
708+
709+func (r *entryPointRuntime) setBackend(conn *trackedTCPConn, backend net.Conn) {
710+ r.mu.Lock()
711+ _, tracked := r.activeConns[conn]
712+ r.mu.Unlock()
713+ if !tracked {
714+ _ = backend.Close()
715+ return
716+ }
717+ conn.setBackend(backend)
718+}
719+
720+func (r *entryPointRuntime) untrack(conn *trackedTCPConn) {
721+ r.mu.Lock()
722+ delete(r.activeConns, conn)
723+ r.mu.Unlock()
724+}
725+
726+func (r *entryPointRuntime) stop(ctx context.Context, drainTimeout time.Duration) {
727+ if r.closed.CompareAndSwap(false, true) {
728+ entryPoint := r.entryPointSnapshot()
729+ trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Msg("stopping tcp traffic entrypoint")
730+ r.cancel()
731+ _ = r.listener.Close()
732+ if !r.started.Load() {
733+ r.closeAcceptDone()
734+ }
735+ }
736+ select {
737+ case <-r.acceptDone:
738+ case <-ctx.Done():
739+ return
740+ }
741+ r.stopTLSHTTPServer(ctx, drainTimeout)
742+ r.stopSmartTCPHTTPServer(ctx, drainTimeout)
743+ r.stopSmartTCPTLSServer(ctx, drainTimeout)
744+ if r.waitActive(ctx, drainTimeout) {
745+ trafficInfo(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Msg("stopped tcp traffic entrypoint")
746+ return
747+ }
748+ trafficDebug(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Dur("drain_timeout", drainTimeout).Msg("forcing tcp traffic entrypoint drain")
749+ r.closeActiveConns()
750+ select {
751+ case <-r.activeDone():
752+ trafficInfo(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Msg("stopped tcp traffic entrypoint")
753+ case <-ctx.Done():
754+ }
755+}
756+
757+func (r *entryPointRuntime) stopTLSHTTPServer(ctx context.Context, drainTimeout time.Duration) {
758+ r.tlsHTTPReplaceMu.Lock()
759+ defer r.tlsHTTPReplaceMu.Unlock()
760+ r.stopTLSHTTPServerLocked(ctx, drainTimeout)
761+}
762+
763+func (r *entryPointRuntime) stopTLSHTTPServerLocked(ctx context.Context, drainTimeout time.Duration) {
764+ r.mu.Lock()
765+ listener := r.tlsHTTPListener
766+ server := r.tlsHTTPServer
767+ done := r.tlsHTTPDone
768+ r.tlsHTTPListener = nil
769+ r.tlsHTTPServer = nil
770+ r.tlsHTTPDone = nil
771+ r.mu.Unlock()
772+
773+ stopTLSHTTPServer(ctx, r.entryPointSnapshot().Name, listener, server, done, drainTimeout)
774+}
775+
776+func stopTLSHTTPServer(ctx context.Context, entryPointName string, listener *tlsHTTPListener, server *http.Server, done chan struct{}, drainTimeout time.Duration) {
777+ if listener == nil || server == nil || done == nil {
778+ return
779+ }
780+ _ = listener.Close()
781+ if drainTimeout <= 0 {
782+ drainTimeout = defaultTCPOptions().DrainTimeout
783+ }
784+ shutdownCtx, cancel := context.WithTimeout(ctx, drainTimeout)
785+ defer cancel()
786+ if err := server.Shutdown(shutdownCtx); err != nil {
787+ trafficDebug(ctx).Err(err).Str("entrypoint", entryPointName).Msg("forcing tls mux https server close")
788+ _ = server.Close()
789+ }
790+ select {
791+ case <-done:
792+ case <-ctx.Done():
793+ }
794+}
795+
796+func (r *entryPointRuntime) waitActive(ctx context.Context, drainTimeout time.Duration) bool {
797+ if drainTimeout <= 0 {
798+ drainTimeout = defaultTCPOptions().DrainTimeout
799+ }
800+ drainCtx, cancel := context.WithTimeout(ctx, drainTimeout)
801+ defer cancel()
802+ select {
803+ case <-r.activeDone():
804+ return true
805+ case <-drainCtx.Done():
806+ return false
807+ }
808+}
809+
810+func (r *entryPointRuntime) closeAcceptDone() {
811+ r.acceptDoneOnce.Do(func() { close(r.acceptDone) })
812+}
813+
814+func (r *entryPointRuntime) activeDone() <-chan struct{} {
815+ done := make(chan struct{})
816+ go func() {
817+ r.activeWG.Wait()
818+ close(done)
819+ }()
820+ return done
821+}
822+
823+func (r *entryPointRuntime) closeActiveConns() {
824+ r.mu.Lock()
825+ defer r.mu.Unlock()
826+ for conn := range r.activeConns {
827+ conn.close()
828+ }
829+}
830+
831+func (r *entryPointRuntime) matches(entryPoint domain.EntryPoint) bool {
832+ current := r.entryPointSnapshot()
833+ return current.Name == entryPoint.Name && current.Address == entryPoint.Address && current.Protocol == entryPoint.Protocol && trustedCIDRsEqual(current.TrustedCIDRs, entryPoint.TrustedCIDRs) && current.RawFallback == entryPoint.RawFallback && trustedCIDRsEqual(current.RawFallbackTrustedCIDRs, entryPoint.RawFallbackTrustedCIDRs) && current.AllowPublicRawFallback == entryPoint.AllowPublicRawFallback
834+}
835+
836+func (r *entryPointRuntime) sameAddress(entryPoint domain.EntryPoint) bool {
837+ return r.entryPointSnapshot().Address == entryPoint.Address
838+}
839+
840+func (r *entryPointRuntime) updateEntryPoint(entryPoint domain.EntryPoint, trusted []*net.IPNet, rawTrusted []*net.IPNet) {
841+ r.mu.Lock()
842+ previousName := r.entryPoint.Name
843+ previousProtocol := r.entryPoint.Protocol
844+ r.entryPoint = entryPoint
845+ r.trusted = trusted
846+ r.rawFallbackTrusted = rawTrusted
847+ stale := make([]*trackedTCPConn, 0)
848+ for conn := range r.activeConns {
849+ if !trustedRemoteAddr(trusted, conn.client.RemoteAddr()) || rawFallbackConnStale(conn, entryPoint, rawTrusted) {
850+ stale = append(stale, conn)
851+ }
852+ }
853+ r.mu.Unlock()
854+ for _, conn := range stale {
855+ conn.close()
856+ }
857+ if previousProtocol == domain.EntryPointProtocolTLSMux && entryPoint.Protocol != domain.EntryPointProtocolTLSMux {
858+ r.stopTLSHTTPServer(r.ctx, 0)
859+ }
860+ if previousProtocol == domain.EntryPointProtocolSmartTCP && entryPoint.Protocol != domain.EntryPointProtocolSmartTCP {
861+ r.stopSmartTCPHTTPServer(r.ctx, 0)
862+ r.stopSmartTCPTLSServer(r.ctx, 0)
863+ }
864+ if entryPoint.Protocol == domain.EntryPointProtocolSmartTCP {
865+ r.refreshSmartTCPHTTPServer(entryPoint.Name)
866+ r.refreshSmartTCPTLSServer(entryPoint.Name)
867+ }
868+ if entryPoint.Protocol == domain.EntryPointProtocolTLSMux {
869+ if previousProtocol == domain.EntryPointProtocolTLSMux && previousName != entryPoint.Name {
870+ r.refreshTLSHTTPServer(entryPoint.Name)
871+ return
872+ }
873+ r.startTLSHTTPServer(entryPoint)
874+ }
875+}
876+
877+func rawFallbackConnStale(conn *trackedTCPConn, entryPoint domain.EntryPoint, rawTrusted []*net.IPNet) bool {
878+ rawFallbackName, ok := conn.rawFallback()
879+ if !ok {
880+ return false
881+ }
882+ if entryPoint.Protocol != domain.EntryPointProtocolSmartTCP || entryPoint.RawFallback == "" || entryPoint.RawFallback != rawFallbackName {
883+ return true
884+ }
885+ if entryPoint.AllowPublicRawFallback {
886+ return false
887+ }
888+ return len(rawTrusted) == 0 || !trustedRemoteAddr(rawTrusted, conn.client.RemoteAddr())
889+}
890+
891+func (r *entryPointRuntime) entryPointSnapshot() domain.EntryPoint {
892+ r.mu.Lock()
893+ defer r.mu.Unlock()
894+ return r.entryPoint
895+}
896+
897+func (r *entryPointRuntime) trustedSnapshot() []*net.IPNet {
898+ r.mu.Lock()
899+ defer r.mu.Unlock()
900+ return r.trusted
901+}
902+
903+func (r *entryPointRuntime) rawFallbackTrustedSnapshot() []*net.IPNet {
904+ r.mu.Lock()
905+ defer r.mu.Unlock()
906+ return r.rawFallbackTrusted
907+}
908+
909+func (r *entryPointRuntime) isClosed() bool {
910+ return r.closed.Load()
911+}
912+
913+func backendFromAddress(name string, address string) (domain.TrafficBackend, error) {
914+ host, portValue, err := net.SplitHostPort(address)
915+ if err != nil {
916+ return domain.TrafficBackend{}, fmt.Errorf("split backend address %q: %w", address, err)
917+ }
918+ port, err := strconv.Atoi(portValue)
919+ if err != nil {
920+ return domain.TrafficBackend{}, fmt.Errorf("parse backend port %q from address %q: %w", portValue, address, err)
921+ }
922+ if port < 1 || port > 65535 {
923+ return domain.TrafficBackend{}, fmt.Errorf("backend port %q from address %q is outside 1-65535", portValue, address)
924+ }
925+ return domain.TrafficBackend{Name: name, Host: host, Port: port, Protocol: domain.NetworkProtocolTCP}, nil
926+}
927+
928+func serviceRef(name string, portName string) string {
929+ return fmt.Sprintf("network_service:%s:%s", name, portName)
930+}
A internal/adapters/in/traffic/tcp_test.go +1228 -0
@@ -0,0 +1,1228 @@
1+package traffic
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "crypto/tls"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "net"
12+ "net/http"
13+ "sync/atomic"
14+ "testing"
15+ "time"
16+
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/require"
19+
20+ "github.com/bnema/gordon/internal/domain"
21+)
22+
23+func TestTrafficRuntimeDefaultsApplySafeLimits(t *testing.T) {
24+ assert.Equal(t, 1024, effectiveTCPOptions(domain.TCPOptions{}).MaxConnections)
25+ assert.Equal(t, 4096, effectiveUDPOptions(domain.UDPOptions{}).MaxSessions)
26+}
27+
28+func TestBackendFromAddressRejectsOutOfRangePort(t *testing.T) {
29+ _, err := backendFromAddress("bad:tcp", "127.0.0.1:70000")
30+ require.Error(t, err)
31+ assert.Contains(t, err.Error(), "outside 1-65535")
32+}
33+
34+func TestSmartTCPDispatch(t *testing.T) {
35+ t.Run("cleartext HTTP request reaches HTTP handler", func(t *testing.T) {
36+ manager, graph, hits := startSmartTCP(t, nil, nil)
37+ defer shutdownManager(t, manager)
38+
39+ conn := dialTCP(t, graph.EntryPoints[0].Address)
40+ defer conn.Close()
41+ _, err := conn.Write([]byte("GET /clear HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
42+ require.NoError(t, err)
43+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
44+ require.NoError(t, err)
45+ body, err := io.ReadAll(resp.Body)
46+ require.NoError(t, err)
47+ require.NoError(t, resp.Body.Close())
48+ assert.Contains(t, string(body), "http:/clear")
49+ assert.Equal(t, int64(1), hits.httpHits.Load())
50+ assert.Equal(t, int64(0), hits.rawHits.Load())
51+ })
52+
53+ t.Run("h2c prior-knowledge reaches HTTP handler", func(t *testing.T) {
54+ manager, graph, hits := startSmartTCP(t, nil, nil)
55+ defer shutdownManager(t, manager)
56+ conn := dialTCP(t, graph.EntryPoints[0].Address)
57+ defer conn.Close()
58+ _, err := conn.Write([]byte(h2cPreface))
59+ require.NoError(t, err)
60+ assert.Eventually(t, func() bool { return hits.httpHits.Load() == 1 }, time.Second, 10*time.Millisecond)
61+ assert.Equal(t, int64(0), hits.rawHits.Load())
62+ })
63+
64+ t.Run("HTTPS request reaches configured HTTPS fallback", func(t *testing.T) {
65+ manager, graph, _ := startSmartTCP(t, nil, nil)
66+ defer shutdownManager(t, manager)
67+ assertHTTPSBody(t, graph.EntryPoints[0].Address, "app.example.com", "smart https")
68+ })
69+
70+ t.Run("TLS passthrough SNI match wins before HTTPS fallback", func(t *testing.T) {
71+ backend := startTLSGreetingServer(t, "raw.example.com", "raw tls\n")
72+ manager, graph, _ := startSmartTCP(t, &smartTCPRoute{tlsSNI: "raw.example.com", backendAddress: backend.address}, nil)
73+ defer shutdownManager(t, manager)
74+ assertTLSGreeting(t, graph.EntryPoints[0].Address, "raw.example.com", "raw tls\n")
75+ assertHTTPSBody(t, graph.EntryPoints[0].Address, "app.example.com", "smart https")
76+ })
77+
78+ t.Run("no-SNI TLS reaches HTTPS fallback", func(t *testing.T) {
79+ manager, graph, _ := startSmartTCP(t, nil, nil)
80+ defer shutdownManager(t, manager)
81+ assertHTTPSBodyNoSNI(t, graph.EntryPoints[0].Address, "smart https")
82+ })
83+
84+ t.Run("unknown bytes reach raw fallback immediately when source policy allows", func(t *testing.T) {
85+ backend := startTCPEchoServer(t, 0)
86+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, trustedCIDRs: []string{"127.0.0.0/8"}})
87+ defer shutdownManager(t, manager)
88+ conn := dialTCP(t, graph.EntryPoints[0].Address)
89+ defer conn.Close()
90+ assertRoundTrip(t, conn, "ssh-raw")
91+ })
92+
93+ t.Run("unknown bytes refused without raw fallback", func(t *testing.T) {
94+ manager, graph, _ := startSmartTCP(t, nil, nil)
95+ defer shutdownManager(t, manager)
96+ assertTCPRejected(t, manager, graph.EntryPoints[0].Address, 1)
97+ })
98+
99+ t.Run("unknown bytes refused when raw fallback source policy denies peer", func(t *testing.T) {
100+ backend := startTCPEchoServer(t, 0)
101+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, trustedCIDRs: []string{"192.0.2.0/24"}})
102+ defer shutdownManager(t, manager)
103+ assertTCPRejected(t, manager, graph.EntryPoints[0].Address, 1)
104+ })
105+
106+ t.Run("malformed HTTP-looking bytes do not reach raw fallback", func(t *testing.T) {
107+ backend := startTCPEchoServer(t, 0)
108+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, allowPublic: true})
109+ defer shutdownManager(t, manager)
110+ conn := dialTCP(t, graph.EntryPoints[0].Address)
111+ defer conn.Close()
112+ _, err := conn.Write([]byte("GET /broken\r\n"))
113+ require.NoError(t, err)
114+ _, err = bufio.NewReader(conn).ReadByte()
115+ require.Error(t, err)
116+ })
117+
118+ t.Run("malformed TLS-looking bytes do not reach raw fallback", func(t *testing.T) {
119+ backend := startTCPEchoServer(t, 0)
120+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, allowPublic: true})
121+ defer shutdownManager(t, manager)
122+ conn := dialTCP(t, graph.EntryPoints[0].Address)
123+ defer conn.Close()
124+ _, err := conn.Write([]byte{22, 3, 3, 0, 8, 2, 0, 0, 4, 0, 0, 0, 0})
125+ require.NoError(t, err)
126+ _, err = bufio.NewReader(conn).ReadByte()
127+ require.Error(t, err)
128+ })
129+
130+ t.Run("PROXY v1 and v2 prefixes rejected", func(t *testing.T) {
131+ manager, graph, _ := startSmartTCP(t, nil, nil)
132+ defer shutdownManager(t, manager)
133+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, []byte("PROXY TCP4 127.0.0.1 127.0.0.1 1 2\r\n"))
134+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, append([]byte(proxyV2Signature), 0x21, 0x11, 0, 0))
135+ })
136+
137+ t.Run("sniff deadline cleared before backend handoff", func(t *testing.T) {
138+ backend := startTCPEchoServer(t, 150*time.Millisecond)
139+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, allowPublic: true})
140+ defer shutdownManager(t, manager)
141+ conn := dialTCP(t, graph.EntryPoints[0].Address)
142+ defer conn.Close()
143+ assertRoundTrip(t, conn, "deadline-clear")
144+ })
145+
146+ t.Run("raw fallback trusted CIDR reload reuses same listener", func(t *testing.T) {
147+ backend := startTCPEchoServer(t, 0)
148+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, trustedCIDRs: []string{"192.0.2.0/24"}})
149+ defer shutdownManager(t, manager)
150+ assertTCPRejected(t, manager, graph.EntryPoints[0].Address, 1)
151+
152+ initialRuntime := tcpRuntimeForTest(t, manager, "edge")
153+ replacement := graph
154+ replacement.EntryPoints = append([]domain.EntryPoint(nil), graph.EntryPoints...)
155+ replacement.EntryPoints[0].RawFallbackTrustedCIDRs = []string{"127.0.0.0/8"}
156+ replacement.Routers = append([]domain.TrafficRouter(nil), graph.Routers...)
157+ replacement.Services = append([]domain.TrafficService(nil), graph.Services...)
158+ require.NoError(t, manager.Apply(context.Background(), &replacement))
159+ assert.Same(t, initialRuntime, tcpRuntimeForTest(t, manager, "edge"))
160+
161+ conn := dialTCP(t, replacement.EntryPoints[0].Address)
162+ defer conn.Close()
163+ assertRoundTrip(t, conn, "raw-reloaded")
164+ })
165+
166+ t.Run("raw fallback connection closes when trusted CIDR no longer allows peer", func(t *testing.T) {
167+ backend := startTCPEchoServer(t, 0)
168+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, trustedCIDRs: []string{"127.0.0.0/8"}})
169+ defer shutdownManager(t, manager)
170+
171+ conn := dialTCP(t, graph.EntryPoints[0].Address)
172+ defer conn.Close()
173+ assertRoundTrip(t, conn, "before")
174+
175+ updated := graph
176+ updated.EntryPoints = append([]domain.EntryPoint(nil), graph.EntryPoints...)
177+ updated.EntryPoints[0].RawFallbackTrustedCIDRs = []string{"192.0.2.0/24"}
178+ require.NoError(t, manager.Apply(context.Background(), &updated))
179+
180+ assert.Eventually(t, func() bool {
181+ _, writeErr := conn.Write([]byte("after\n"))
182+ if writeErr != nil {
183+ return true
184+ }
185+ _ = conn.SetReadDeadline(time.Now().Add(10 * time.Millisecond))
186+ _, readErr := bufio.NewReader(conn).ReadByte()
187+ return readErr != nil
188+ }, time.Second, 10*time.Millisecond)
189+ })
190+
191+ t.Run("TLS ClientHello read after sniff is bounded", func(t *testing.T) {
192+ manager, graph, _ := startSmartTCP(t, nil, nil)
193+ defer shutdownManager(t, manager)
194+ graph.Options.TCP.DialTimeout = 100 * time.Millisecond
195+ require.NoError(t, manager.Apply(context.Background(), &graph))
196+
197+ conn := dialTCP(t, graph.EntryPoints[0].Address)
198+ defer conn.Close()
199+ start := time.Now()
200+ _, err := conn.Write([]byte{22, 3, 3, 0, 42})
201+ require.NoError(t, err)
202+ require.NoError(t, conn.SetReadDeadline(time.Now().Add(750*time.Millisecond)))
203+ _, err = bufio.NewReader(conn).ReadByte()
204+ require.Error(t, err)
205+ assert.Less(t, time.Since(start), 500*time.Millisecond)
206+ })
207+}
208+
209+func TestSetSmartTCPHTTPServerCopiesProtocols(t *testing.T) {
210+ manager := NewManager()
211+ protocols := new(http.Protocols)
212+ protocols.SetHTTP1(true)
213+ manager.SetSmartTCPHTTPServer("edge", http.NotFoundHandler(), protocols)
214+
215+ protocols.SetHTTP1(false)
216+ protocols.SetUnencryptedHTTP2(true)
217+
218+ config, ok := manager.smartHTTPServer("edge")
219+ require.True(t, ok)
220+ assert.True(t, config.Protocols.HTTP1())
221+ assert.False(t, config.Protocols.UnencryptedHTTP2())
222+}
223+
224+func TestSmartTCPStatusCounters(t *testing.T) {
225+ t.Run("HTTP accepted", func(t *testing.T) {
226+ manager, graph, _ := startSmartTCP(t, nil, nil)
227+ defer shutdownManager(t, manager)
228+ conn := dialTCP(t, graph.EntryPoints[0].Address)
229+ defer conn.Close()
230+ _, err := conn.Write([]byte("GET /status HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
231+ require.NoError(t, err)
232+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
233+ require.NoError(t, err)
234+ _, _ = io.ReadAll(resp.Body)
235+ _ = resp.Body.Close()
236+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.HTTPAccepted == 1 })
237+ })
238+
239+ t.Run("h2c accepted", func(t *testing.T) {
240+ manager, graph, _ := startSmartTCP(t, nil, nil)
241+ defer shutdownManager(t, manager)
242+ conn := dialTCP(t, graph.EntryPoints[0].Address)
243+ defer conn.Close()
244+ _, err := conn.Write([]byte(h2cPreface))
245+ require.NoError(t, err)
246+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.H2CAccepted == 1 })
247+ })
248+
249+ t.Run("HTTPS fallback accepted", func(t *testing.T) {
250+ manager, graph, _ := startSmartTCP(t, nil, nil)
251+ defer shutdownManager(t, manager)
252+ assertHTTPSBody(t, graph.EntryPoints[0].Address, "app.example.com", "smart https")
253+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.HTTPSFallbackAccepted == 1 })
254+ })
255+
256+ t.Run("TLS passthrough accepted", func(t *testing.T) {
257+ backend := startTLSGreetingServer(t, "raw.example.com", "raw tls\n")
258+ manager, graph, _ := startSmartTCP(t, &smartTCPRoute{tlsSNI: "raw.example.com", backendAddress: backend.address}, nil)
259+ defer shutdownManager(t, manager)
260+ assertTLSGreeting(t, graph.EntryPoints[0].Address, "raw.example.com", "raw tls\n")
261+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.TLSPassthroughAccepted == 1 })
262+ })
263+
264+ t.Run("raw fallback accepted", func(t *testing.T) {
265+ backend := startTCPEchoServer(t, 0)
266+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, allowPublic: true})
267+ defer shutdownManager(t, manager)
268+ conn := dialTCP(t, graph.EntryPoints[0].Address)
269+ defer conn.Close()
270+ assertRoundTrip(t, conn, "raw")
271+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.RawFallbackAccepted == 1 })
272+ })
273+
274+ t.Run("raw fallback backend dial failure is not accepted", func(t *testing.T) {
275+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: freeTCPAddress(t), allowPublic: true})
276+ defer shutdownManager(t, manager)
277+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, []byte{0})
278+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.RawFallbackAccepted == 0 })
279+ assert.Eventually(t, func() bool {
280+ return manager.Status().Counters.TotalAccepted == 0 && manager.Status().Counters.TotalErrors == 1
281+ }, time.Second, 10*time.Millisecond)
282+ })
283+
284+ t.Run("refused and rejected classes", func(t *testing.T) {
285+ backend := startTCPEchoServer(t, 0)
286+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, trustedCIDRs: []string{"192.0.2.0/24"}})
287+ defer shutdownManager(t, manager)
288+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, []byte("PROXY TCP4 127.0.0.1 127.0.0.1 1 2\r\n"))
289+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, []byte{0})
290+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, []byte("GET /broken\r\n"))
291+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, bytes.Repeat([]byte("G"), maxSmartTCPSniffBytes+1))
292+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool {
293+ return c.PROXYRefused == 1 && c.RawFallbackCIDRRefused == 1 && c.MalformedRejected == 1 && c.ClientHelloTooLarge == 1
294+ })
295+ })
296+
297+ t.Run("unknown without fallback refused", func(t *testing.T) {
298+ manager, graph, _ := startSmartTCP(t, nil, nil)
299+ defer shutdownManager(t, manager)
300+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, []byte{0})
301+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.UnknownNoFallbackRefused == 1 })
302+ })
303+
304+ t.Run("sniff timeout", func(t *testing.T) {
305+ manager, graph, _ := startSmartTCP(t, nil, nil)
306+ defer shutdownManager(t, manager)
307+ graph.Options.TCP.DialTimeout = 50 * time.Millisecond
308+ require.NoError(t, manager.Apply(context.Background(), &graph))
309+ conn := dialTCP(t, graph.EntryPoints[0].Address)
310+ defer conn.Close()
311+ _, err := conn.Write([]byte("P"))
312+ require.NoError(t, err)
313+ _, _ = bufio.NewReader(conn).ReadByte()
314+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.SniffTimeout == 1 })
315+ })
316+
317+ t.Run("entrypoint CIDR refused", func(t *testing.T) {
318+ manager, graph, _ := startSmartTCP(t, nil, nil)
319+ defer shutdownManager(t, manager)
320+ graph.EntryPoints[0].TrustedCIDRs = []string{"192.0.2.0/24"}
321+ require.NoError(t, manager.Apply(context.Background(), &graph))
322+ conn := dialTCP(t, graph.EntryPoints[0].Address)
323+ _ = conn.Close()
324+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.EntrypointCIDRRefused == 1 })
325+ })
326+}
327+
328+func TestManagerStatusAggregatesSmartTCP(t *testing.T) {
329+ manager, graph, _ := startSmartTCP(t, nil, nil)
330+ defer shutdownManager(t, manager)
331+ conn := dialTCP(t, graph.EntryPoints[0].Address)
332+ defer conn.Close()
333+ _, err := conn.Write([]byte("GET /aggregate HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
334+ require.NoError(t, err)
335+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
336+ require.NoError(t, err)
337+ _, _ = io.ReadAll(resp.Body)
338+ _ = resp.Body.Close()
339+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.HTTPAccepted == 1 })
340+}
341+
342+func assertSmartTCPStatus(t *testing.T, manager *Manager, match func(domain.SmartTCPCounters) bool) {
343+ t.Helper()
344+ assert.Eventually(t, func() bool {
345+ status := manager.Status()
346+ if len(status.EntryPoints) == 0 || !match(status.EntryPoints[0].SmartTCP) {
347+ return false
348+ }
349+ return match(status.Counters.SmartTCP)
350+ }, time.Second, 10*time.Millisecond)
351+}
352+
353+func TestSmartTCPLifecycleHardening(t *testing.T) {
354+ t.Run("internal HTTP and HTTPS fallback servers stop when entrypoint removed", func(t *testing.T) {
355+ manager, graph, _ := startSmartTCP(t, nil, nil)
356+ initialRuntime := tcpRuntimeForTest(t, manager, "edge")
357+ require.NotNil(t, initialRuntime)
358+
359+ require.NoError(t, manager.Apply(context.Background(), &domain.TrafficGraph{Options: graph.Options}))
360+ assert.Eventually(t, func() bool {
361+ initialRuntime.mu.Lock()
362+ defer initialRuntime.mu.Unlock()
363+ return initialRuntime.smartHTTPServer == nil && initialRuntime.smartTLSServer == nil
364+ }, time.Second, 10*time.Millisecond)
365+ shutdownManager(t, manager)
366+ })
367+
368+ t.Run("HTTP fallback handoff remains counted until hijacked connection closes", func(t *testing.T) {
369+ manager := NewManager()
370+ graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
371+ graph.Options.TCP.MaxConnections = 1
372+ hijacked := make(chan struct{})
373+ hijackedClosed := &atomic.Bool{}
374+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
375+ hj, ok := w.(http.Hijacker)
376+ require.True(t, ok)
377+ conn, rw, err := hj.Hijack()
378+ require.NoError(t, err)
379+ defer conn.Close()
380+ _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\nready\n")
381+ _ = rw.Flush()
382+ if hijackedClosed.CompareAndSwap(false, true) {
383+ close(hijacked)
384+ }
385+ _, _ = rw.ReadString('\n')
386+ }), nil)
387+ manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
388+ require.NoError(t, manager.Apply(context.Background(), &graph))
389+ defer shutdownManager(t, manager)
390+
391+ first := dialTCP(t, graph.EntryPoints[0].Address)
392+ defer first.Close()
393+ firstReader := bufio.NewReader(first)
394+ _, err := first.Write([]byte("GET /ws HTTP/1.1\r\nHost: app.example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"))
395+ require.NoError(t, err)
396+ resp, err := http.ReadResponse(firstReader, nil)
397+ require.NoError(t, err)
398+ assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode)
399+ ready, err := firstReader.ReadString('\n')
400+ require.NoError(t, err)
401+ assert.Equal(t, "ready\n", ready)
402+ <-hijacked
403+ require.Eventually(t, func() bool { return manager.Status().Counters.ActiveTCPConnections == 1 }, time.Second, 10*time.Millisecond)
404+
405+ second := dialTCP(t, graph.EntryPoints[0].Address)
406+ _, err = second.Write([]byte("GET / HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
407+ require.NoError(t, err)
408+ _, err = bufio.NewReader(second).ReadByte()
409+ _ = second.Close()
410+ require.Error(t, err)
411+ assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == 1 }, time.Second, 10*time.Millisecond)
412+
413+ _, err = first.Write([]byte("close\n"))
414+ require.NoError(t, err)
415+ require.Eventually(t, func() bool { return manager.Status().Counters.ActiveTCPConnections == 0 }, time.Second, 10*time.Millisecond)
416+ third := dialTCP(t, graph.EntryPoints[0].Address)
417+ defer third.Close()
418+ _, err = third.Write([]byte("GET /after HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
419+ require.NoError(t, err)
420+ resp, err = http.ReadResponse(bufio.NewReader(third), nil)
421+ require.NoError(t, err)
422+ assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode)
423+ })
424+
425+ t.Run("shutdown force drain closes open HTTP fallback handoff", func(t *testing.T) {
426+ manager := NewManager()
427+ graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
428+ graph.Options.TCP.DrainTimeout = 50 * time.Millisecond
429+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
430+ hj, ok := w.(http.Hijacker)
431+ require.True(t, ok)
432+ conn, rw, err := hj.Hijack()
433+ require.NoError(t, err)
434+ defer conn.Close()
435+ _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\nready\n")
436+ _ = rw.Flush()
437+ _, _ = rw.ReadString('\n')
438+ }), nil)
439+ manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
440+ require.NoError(t, manager.Apply(context.Background(), &graph))
441+
442+ conn := dialTCP(t, graph.EntryPoints[0].Address)
443+ defer conn.Close()
444+ reader := bufio.NewReader(conn)
445+ _, err := conn.Write([]byte("GET /ws HTTP/1.1\r\nHost: app.example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"))
446+ require.NoError(t, err)
447+ resp, err := http.ReadResponse(reader, nil)
448+ require.NoError(t, err)
449+ assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode)
450+ ready, err := reader.ReadString('\n')
451+ require.NoError(t, err)
452+ assert.Equal(t, "ready\n", ready)
453+
454+ started := time.Now()
455+ require.NoError(t, manager.Apply(context.Background(), &domain.TrafficGraph{Options: graph.Options}))
456+ assert.GreaterOrEqual(t, time.Since(started), 40*time.Millisecond)
457+ _, err = conn.Write([]byte("after\n"))
458+ if err == nil {
459+ _, err = reader.ReadByte()
460+ }
461+ require.Error(t, err)
462+ })
463+
464+ t.Run("trusted CIDR change closes active smart TCP connections no longer allowed", func(t *testing.T) {
465+ backend := startTCPEchoServer(t, 0)
466+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, allowPublic: true})
467+ defer shutdownManager(t, manager)
468+ graph.EntryPoints[0].TrustedCIDRs = []string{"127.0.0.0/8"}
469+ require.NoError(t, manager.Apply(context.Background(), &graph))
470+
471+ conn := dialTCP(t, graph.EntryPoints[0].Address)
472+ defer conn.Close()
473+ assertRoundTrip(t, conn, "before")
474+
475+ updated := graph
476+ updated.EntryPoints[0].TrustedCIDRs = []string{"192.0.2.0/24"}
477+ require.NoError(t, manager.Apply(context.Background(), &updated))
478+ assert.Eventually(t, func() bool {
479+ _, writeErr := conn.Write([]byte("after\n"))
480+ if writeErr != nil {
481+ return true
482+ }
483+ _ = conn.SetReadDeadline(time.Now().Add(10 * time.Millisecond))
484+ _, readErr := bufio.NewReader(conn).ReadByte()
485+ return readErr != nil
486+ }, time.Second, 10*time.Millisecond)
487+ })
488+
489+ t.Run("active upgraded connection remains tied to accepted generation", func(t *testing.T) {
490+ manager := NewManager()
491+ graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
492+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
493+ hj, ok := w.(http.Hijacker)
494+ require.True(t, ok)
495+ conn, rw, err := hj.Hijack()
496+ require.NoError(t, err)
497+ _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\nold-ready\n")
498+ _ = rw.Flush()
499+ line, err := rw.ReadString('\n')
500+ if err == nil {
501+ _, _ = conn.Write([]byte("old:" + line))
502+ }
503+ _ = conn.Close()
504+ }), nil)
505+ manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
506+ require.NoError(t, manager.Apply(context.Background(), &graph))
507+ defer shutdownManager(t, manager)
508+
509+ conn := dialTCP(t, graph.EntryPoints[0].Address)
510+ defer conn.Close()
511+ reader := bufio.NewReader(conn)
512+ _, err := conn.Write([]byte("GET /ws HTTP/1.1\r\nHost: app.example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"))
513+ require.NoError(t, err)
514+ resp, err := http.ReadResponse(reader, nil)
515+ require.NoError(t, err)
516+ assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode)
517+ ready, err := reader.ReadString('\n')
518+ require.NoError(t, err)
519+ assert.Equal(t, "old-ready\n", ready)
520+
521+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("new")) }), nil)
522+ _, err = conn.Write([]byte("ping\n"))
523+ require.NoError(t, err)
524+ got, err := reader.ReadString('\n')
525+ require.NoError(t, err)
526+ assert.Equal(t, "old:ping\n", got)
527+ })
528+
529+ t.Run("listener is not reused for incompatible address", func(t *testing.T) {
530+ manager, graph, _ := startSmartTCP(t, nil, nil)
531+ defer shutdownManager(t, manager)
532+ initialRuntime := tcpRuntimeForTest(t, manager, "edge")
533+
534+ updated := graph
535+ updated.EntryPoints[0].Address = freeTCPAddress(t)
536+ require.NoError(t, manager.Apply(context.Background(), &updated))
537+ assert.NotSame(t, initialRuntime, tcpRuntimeForTest(t, manager, "edge"))
538+ })
539+}
540+
541+func TestSmartTCPHTTPHandoff(t *testing.T) {
542+ t.Run("HTTP Upgrade h2c reaches handler and not raw fallback", func(t *testing.T) {
543+ manager, graph, hits := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: startTCPEchoServer(t, 0).address, allowPublic: true})
544+ defer shutdownManager(t, manager)
545+ conn := dialTCP(t, graph.EntryPoints[0].Address)
546+ defer conn.Close()
547+ _, err := conn.Write([]byte("GET /upgrade HTTP/1.1\r\nHost: app.example.com\r\nConnection: Upgrade, HTTP2-Settings\r\nUpgrade: h2c\r\nHTTP2-Settings: AAMAAABkAAQAAP__\r\n\r\n"))
548+ require.NoError(t, err)
549+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
550+ require.NoError(t, err)
551+ _, _ = io.ReadAll(resp.Body)
552+ _ = resp.Body.Close()
553+ assert.Equal(t, int64(1), hits.httpHits.Load())
554+ assert.Equal(t, int64(0), hits.rawHits.Load())
555+ })
556+
557+ t.Run("websocket upgrade supports hijacker", func(t *testing.T) {
558+ manager := NewManager()
559+ graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
560+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
561+ hj, ok := w.(http.Hijacker)
562+ require.True(t, ok)
563+ conn, rw, err := hj.Hijack()
564+ require.NoError(t, err)
565+ defer conn.Close()
566+ _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\nws-ok")
567+ _ = rw.Flush()
568+ }), nil)
569+ manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
570+ require.NoError(t, manager.Apply(context.Background(), &graph))
571+ defer shutdownManager(t, manager)
572+ conn := dialTCP(t, graph.EntryPoints[0].Address)
573+ defer conn.Close()
574+ _, err := conn.Write([]byte("GET /ws HTTP/1.1\r\nHost: app.example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"))
575+ require.NoError(t, err)
576+ body, err := io.ReadAll(conn)
577+ require.NoError(t, err)
578+ assert.Contains(t, string(body), "ws-ok")
579+ })
580+
581+ t.Run("CONNECT reaches HTTP handler and not raw fallback", func(t *testing.T) {
582+ manager, graph, hits := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: startTCPEchoServer(t, 0).address, allowPublic: true})
583+ defer shutdownManager(t, manager)
584+ conn := dialTCP(t, graph.EntryPoints[0].Address)
585+ defer conn.Close()
586+ _, err := conn.Write([]byte("CONNECT db.example.com:443 HTTP/1.1\r\nHost: db.example.com:443\r\nConnection: close\r\n\r\n"))
587+ require.NoError(t, err)
588+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
589+ require.NoError(t, err)
590+ _, _ = io.ReadAll(resp.Body)
591+ _ = resp.Body.Close()
592+ assert.Equal(t, int64(1), hits.httpHits.Load())
593+ assert.Equal(t, int64(0), hits.rawHits.Load())
594+ })
595+
596+ t.Run("handoff connection has cleared deadlines and useful addresses", func(t *testing.T) {
597+ seen := make(chan string, 1)
598+ manager := NewManager()
599+ graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
600+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
601+ time.Sleep(150 * time.Millisecond)
602+ seen <- r.RemoteAddr + "|" + r.Context().Value(http.LocalAddrContextKey).(net.Addr).String()
603+ _, _ = w.Write([]byte("addr-ok"))
604+ }), nil)
605+ manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
606+ require.NoError(t, manager.Apply(context.Background(), &graph))
607+ defer shutdownManager(t, manager)
608+ conn := dialTCP(t, graph.EntryPoints[0].Address)
609+ defer conn.Close()
610+ _, err := conn.Write([]byte("GET /addr HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
611+ require.NoError(t, err)
612+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
613+ require.NoError(t, err)
614+ _, _ = io.ReadAll(resp.Body)
615+ _ = resp.Body.Close()
616+ got := <-seen
617+ assert.Contains(t, got, "127.0.0.1")
618+ assert.Contains(t, got, graph.EntryPoints[0].Address)
619+ })
620+}
621+
622+func TestRouteToHTTPListenerServeFailureReturnsFalseAndLeavesTrackedForCaller(t *testing.T) {
623+ listener, err := net.Listen("tcp", "127.0.0.1:0")
624+ require.NoError(t, err)
625+ defer listener.Close()
626+
627+ runtime := newEntryPointRuntime(context.Background(), NewManager(), domain.EntryPoint{Name: "edge"}, listener, nil, nil)
628+ httpListener := newTLSHTTPListener(listener.Addr())
629+ require.NoError(t, httpListener.Close())
630+
631+ clientConn, serverConn := net.Pipe()
632+ defer clientConn.Close()
633+ defer serverConn.Close()
634+ tracked := &trackedTCPConn{client: clientConn}
635+ runtime.track(tracked)
636+
637+ accepted := runtime.routeToHTTPListener(tracked, serverConn, httpListener)
638+ require.False(t, accepted)
639+
640+ runtime.mu.Lock()
641+ _, stillTracked := runtime.activeConns[tracked]
642+ runtime.mu.Unlock()
643+ assert.True(t, stillTracked)
644+
645+ runtime.untrack(tracked)
646+}
647+
648+func TestTCPManagerLifecycle(t *testing.T) {
649+ backend := startTCPEchoServer(t, 0)
650+ graph := tcpGraph(t, freeTCPAddress(t), backend.address)
651+ manager := NewManager()
652+
653+ require.NoError(t, manager.Apply(context.Background(), &graph))
654+ defer shutdownManager(t, manager)
655+
656+ conn := dialTCP(t, graph.EntryPoints[0].Address)
657+ require.NoError(t, conn.Close())
658+
659+ status := manager.Status()
660+ require.Equal(t, reloadStatusOK, status.LastReloadStatus)
661+ require.Len(t, status.EntryPoints, 1)
662+ assert.True(t, status.EntryPoints[0].Active)
663+
664+ require.NoError(t, manager.Shutdown(context.Background()))
665+ _, err := net.DialTimeout("tcp", graph.EntryPoints[0].Address, 100*time.Millisecond)
666+ require.Error(t, err)
667+}
668+
669+func TestTCPManagerRejectsInvalidGraphWithoutReplacingSnapshot(t *testing.T) {
670+ backend := startTCPEchoServer(t, 0)
671+ graph := tcpGraph(t, freeTCPAddress(t), backend.address)
672+ manager := NewManager()
673+ require.NoError(t, manager.Apply(context.Background(), &graph))
674+ defer shutdownManager(t, manager)
675+
676+ invalid := graph
677+ invalid.EntryPoints = append(invalid.EntryPoints, graph.EntryPoints[0])
678+ err := manager.Apply(context.Background(), &invalid)
679+ require.Error(t, err)
680+
681+ status := manager.Status()
682+ assert.Equal(t, reloadStatusError, status.LastReloadStatus)
683+ assert.Contains(t, status.LastReloadError, "duplicate entrypoint")
684+
685+ conn := dialTCP(t, graph.EntryPoints[0].Address)
686+ defer conn.Close()
687+ assertRoundTrip(t, conn, "still works")
688+}
689+
690+func TestTCPManagerReplacesSameAddressEntrypoint(t *testing.T) {
691+ backend := startTCPEchoServer(t, 0)
692+ graph := tcpGraph(t, freeTCPAddress(t), backend.address)
693+ manager := NewManager()
694+ require.NoError(t, manager.Apply(context.Background(), &graph))
695+ defer shutdownManager(t, manager)
696+
697+ replacement := graph
698+ replacement.EntryPoints = []domain.EntryPoint{{Name: "postgres-tls", Address: graph.EntryPoints[0].Address, Protocol: domain.EntryPointProtocolTLSMux}}
699+ replacement.Routers = nil
700+ replacement.Services = nil
701+ require.NoError(t, manager.Apply(context.Background(), &replacement))
702+
703+ status := manager.Status()
704+ require.Len(t, status.EntryPoints, 1)
705+ assert.Equal(t, "postgres-tls", status.EntryPoints[0].Name)
706+ assert.Equal(t, domain.EntryPointProtocolTLSMux, status.EntryPoints[0].Protocol)
707+}
708+
709+func TestTCPManagerSameAddressProtocolChangeStartsTLSHTTPServer(t *testing.T) {
710+ backend := startTCPEchoServer(t, 0)
711+ graph := tcpGraph(t, freeTCPAddress(t), backend.address)
712+ manager := NewManager()
713+ require.NoError(t, manager.Apply(context.Background(), &graph))
714+ defer shutdownManager(t, manager)
715+
716+ manager.SetTLSHTTPServer("websecure", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
717+ _, _ = w.Write([]byte("secure"))
718+ }), testTLSConfig(t, "app.example.com"))
719+ replacement := domain.TrafficGraph{
720+ Options: graph.Options,
721+ EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: graph.EntryPoints[0].Address, Protocol: domain.EntryPointProtocolTLSMux}},
722+ }
723+ require.NoError(t, manager.Apply(context.Background(), &replacement))
724+
725+ assertHTTPSBody(t, graph.EntryPoints[0].Address, "app.example.com", "secure")
726+}
727+
728+func TestTCPManagerSameAddressTLSMuxRenameRefreshesHTTPSRoute(t *testing.T) {
729+ address := freeTCPAddress(t)
730+ manager := NewManager()
731+ manager.SetTLSHTTPServer("old-secure", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
732+ _, _ = w.Write([]byte("old"))
733+ }), testTLSConfig(t, "app.example.com"))
734+ manager.SetTLSHTTPServer("new-secure", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
735+ _, _ = w.Write([]byte("new"))
736+ }), testTLSConfig(t, "app.example.com"))
737+ graph := domain.TrafficGraph{
738+ Options: domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: time.Second, IdleTimeout: time.Minute, DrainTimeout: time.Second}},
739+ EntryPoints: []domain.EntryPoint{{Name: "old-secure", Address: address, Protocol: domain.EntryPointProtocolTLSMux}},
740+ }
741+ require.NoError(t, manager.Apply(context.Background(), &graph))
742+ defer shutdownManager(t, manager)
743+ assertHTTPSBody(t, address, "app.example.com", "old")
744+
745+ renamed := graph
746+ renamed.EntryPoints[0].Name = "new-secure"
747+ require.NoError(t, manager.Apply(context.Background(), &renamed))
748+
749+ assertHTTPSBody(t, address, "app.example.com", "new")
750+}
751+
752+func TestTCPRuntimeDoesNotPublishTLSHTTPServerAfterClose(t *testing.T) {
753+ listener, err := net.Listen("tcp", "127.0.0.1:0")
754+ require.NoError(t, err)
755+ defer listener.Close()
756+ manager := NewManager()
757+ runtime := newEntryPointRuntime(context.Background(), manager, domain.EntryPoint{Name: "websecure", Address: listener.Addr().String(), Protocol: domain.EntryPointProtocolTLSMux}, listener, nil, nil)
758+ runtime.closed.Store(true)
759+
760+ runtime.replaceTLSHTTPServer(runtime.entryPointSnapshot(), TLSHTTPServerConfig{
761+ Handler: http.NotFoundHandler(),
762+ TLSConfig: testTLSConfig(t, "app.example.com"),
763+ })
764+
765+ runtime.mu.Lock()
766+ server := runtime.tlsHTTPServer
767+ runtime.mu.Unlock()
768+ require.Nil(t, server)
769+}
770+
771+func TestTCPManagerSameAddressProtocolChangeStopsTLSHTTPServer(t *testing.T) {
772+ backend := startTCPEchoServer(t, 0)
773+ graph := domain.TrafficGraph{
774+ Options: domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: time.Second, IdleTimeout: time.Minute, DrainTimeout: time.Second}},
775+ EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTLSMux}},
776+ }
777+ manager := NewManager()
778+ manager.SetTLSHTTPServer("websecure", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
779+ _, _ = w.Write([]byte("secure"))
780+ }), testTLSConfig(t, "app.example.com"))
781+ require.NoError(t, manager.Apply(context.Background(), &graph))
782+ defer shutdownManager(t, manager)
783+ assertHTTPSBody(t, graph.EntryPoints[0].Address, "app.example.com", "secure")
784+
785+ replacement := tcpGraph(t, graph.EntryPoints[0].Address, backend.address)
786+ require.NoError(t, manager.Apply(context.Background(), &replacement))
787+
788+ _, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", graph.EntryPoints[0].Address, &tls.Config{ServerName: "app.example.com", InsecureSkipVerify: true})
789+ require.Error(t, err)
790+}
791+
792+func TestTrackedTCPConnClosesLateBackendAfterStaleClose(t *testing.T) {
793+ clientA, clientB := net.Pipe()
794+ backendA, backendB := net.Pipe()
795+ defer clientA.Close()
796+ defer backendB.Close()
797+
798+ conn := &trackedTCPConn{client: clientB}
799+ conn.close()
800+ conn.setBackend(backendA)
801+
802+ _, err := backendB.Write([]byte("late backend"))
803+ if err == nil {
804+ _ = backendA.SetReadDeadline(time.Now().Add(50 * time.Millisecond))
805+ _, err = backendA.Read(make([]byte, 1))
806+ }
807+ require.Error(t, err)
808+}
809+
810+func TestTCPPassthroughRejectsUntrustedCIDR(t *testing.T) {
811+ backend := startTCPEchoServer(t, 0)
812+ graph := tcpGraph(t, freeTCPAddress(t), backend.address)
813+ graph.EntryPoints[0].TrustedCIDRs = []string{"192.0.2.0/24"}
814+ manager := NewManager()
815+ require.NoError(t, manager.Apply(context.Background(), &graph))
816+ defer shutdownManager(t, manager)
817+
818+ assertTCPRejected(t, manager, graph.EntryPoints[0].Address, 1)
819+}
820+
821+func TestTCPPassthroughReloadAppliesTrustedCIDRChange(t *testing.T) {
822+ backend := startTCPEchoServer(t, 0)
823+ graph := tcpGraph(t, freeTCPAddress(t), backend.address)
824+ graph.EntryPoints[0].TrustedCIDRs = []string{"127.0.0.0/8"}
825+ manager := NewManager()
826+ require.NoError(t, manager.Apply(context.Background(), &graph))
827+ defer shutdownManager(t, manager)
828+
829+ conn := dialTCP(t, graph.EntryPoints[0].Address)
830+ assertRoundTrip(t, conn, "allowed")
831+ require.NoError(t, conn.Close())
832+
833+ updated := graph
834+ updated.EntryPoints[0].TrustedCIDRs = []string{"192.0.2.0/24"}
835+ require.NoError(t, manager.Apply(context.Background(), &updated))
836+ assertTCPRejected(t, manager, graph.EntryPoints[0].Address, 1)
837+}
838+
839+func TestTLSMuxReloadClosesActiveUntrustedHTTPSFallbackConnections(t *testing.T) {
840+ graph := domain.TrafficGraph{
841+ Options: domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: time.Second, IdleTimeout: time.Minute, DrainTimeout: time.Second}},
842+ EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTLSMux, TrustedCIDRs: []string{"127.0.0.0/8"}}},
843+ }
844+ manager := NewManager()
845+ manager.SetTLSHTTPServer("websecure", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
846+ body := "secure"
847+ w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
848+ _, _ = w.Write([]byte(body))
849+ }), testTLSConfig(t, "app.example.com"))
850+ require.NoError(t, manager.Apply(context.Background(), &graph))
851+ defer shutdownManager(t, manager)
852+
853+ conn, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", graph.EntryPoints[0].Address, &tls.Config{ServerName: "app.example.com", InsecureSkipVerify: true})
854+ require.NoError(t, err)
855+ defer conn.Close()
856+ reader := bufio.NewReader(conn)
857+ _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: app.example.com\r\nConnection: keep-alive\r\n\r\n"))
858+ require.NoError(t, err)
859+ resp, err := http.ReadResponse(reader, nil)
860+ require.NoError(t, err)
861+ _, err = io.ReadAll(resp.Body)
862+ require.NoError(t, err)
863+ require.NoError(t, resp.Body.Close())
864+
865+ updated := graph
866+ updated.EntryPoints[0].TrustedCIDRs = []string{"192.0.2.0/24"}
867+ require.NoError(t, manager.Apply(context.Background(), &updated))
868+
869+ _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
870+ if err == nil {
871+ _ = conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
872+ _, err = http.ReadResponse(reader, nil)
873+ }
874+ require.Error(t, err)
875+}
876+
877+func TestTCPPassthroughReloadClosesActiveUntrustedConnections(t *testing.T) {
878+ backend := startTCPEchoServer(t, 0)
879+ graph := tcpGraph(t, freeTCPAddress(t), backend.address)
880+ graph.EntryPoints[0].TrustedCIDRs = []string{"127.0.0.0/8"}
881+ manager := NewManager()
882+ require.NoError(t, manager.Apply(context.Background(), &graph))
883+ defer shutdownManager(t, manager)
884+
885+ conn := dialTCP(t, graph.EntryPoints[0].Address)
886+ defer conn.Close()
887+ assertRoundTrip(t, conn, "before")
888+
889+ updated := graph
890+ updated.EntryPoints[0].TrustedCIDRs = []string{"192.0.2.0/24"}
891+ require.NoError(t, manager.Apply(context.Background(), &updated))
892+
893+ assert.Eventually(t, func() bool {
894+ _, writeErr := conn.Write([]byte("after"))
895+ if writeErr != nil {
896+ return true
897+ }
898+ _ = conn.SetReadDeadline(time.Now().Add(10 * time.Millisecond))
899+ _, readErr := bufio.NewReader(conn).ReadByte()
900+ return readErr != nil
901+ }, time.Second, 10*time.Millisecond)
902+}
903+
904+func assertTCPRejected(t *testing.T, manager *Manager, address string, refused int64) {
905+ t.Helper()
906+ conn := dialTCP(t, address)
907+ defer conn.Close()
908+ _, writeErr := conn.Write([]byte("blocked"))
909+ if writeErr == nil {
910+ _, readErr := bufio.NewReader(conn).ReadByte()
911+ require.Error(t, readErr)
912+ }
913+ assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == refused }, time.Second, 10*time.Millisecond)
914+}
915+
916+func TestTCPPassthroughEcho(t *testing.T) {
917+ backend := startTCPEchoServer(t, 0)
918+ graph := tcpGraph(t, freeTCPAddress(t), backend.address)
919+ manager := NewManager()
920+ require.NoError(t, manager.Apply(context.Background(), &graph))
921+ defer shutdownManager(t, manager)
922+
923+ conn := dialTCP(t, graph.EntryPoints[0].Address)
924+ defer conn.Close()
925+ assertRoundTrip(t, conn, "hello tcp")
926+}
927+
928+func TestTCPPassthroughUnknownRouterCloses(t *testing.T) {
929+ graph := tcpGraph(t, freeTCPAddress(t), freeTCPAddress(t))
930+ graph.Routers = nil
931+ graph.Services = nil
932+ manager := NewManager()
933+ require.NoError(t, manager.Apply(context.Background(), &graph))
934+ defer shutdownManager(t, manager)
935+
936+ conn := dialTCP(t, graph.EntryPoints[0].Address)
937+ defer conn.Close()
938+ _, writeErr := conn.Write([]byte("hello"))
939+ if writeErr == nil {
940+ _, readErr := bufio.NewReader(conn).ReadByte()
941+ require.Error(t, readErr)
942+ }
943+ assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == 1 }, time.Second, 10*time.Millisecond)
944+}
945+
946+func TestTCPPassthroughBackendDialFailure(t *testing.T) {
947+ graph := tcpGraph(t, freeTCPAddress(t), freeTCPAddress(t))
948+ graph.Options.TCP.DialTimeout = 50 * time.Millisecond
949+ manager := NewManager()
950+ require.NoError(t, manager.Apply(context.Background(), &graph))
951+ defer shutdownManager(t, manager)
952+
953+ conn := dialTCP(t, graph.EntryPoints[0].Address)
954+ defer conn.Close()
955+ _, writeErr := conn.Write([]byte("hello"))
956+ if writeErr == nil {
957+ _, readErr := bufio.NewReader(conn).ReadByte()
958+ require.Error(t, readErr)
959+ }
960+ assert.Eventually(t, func() bool { return manager.Status().Counters.TotalErrors == 1 }, time.Second, 10*time.Millisecond)
961+}
962+
963+func TestTCPPassthroughShutdownStopsAccepting(t *testing.T) {
964+ backend := startTCPEchoServer(t, 0)
965+ graph := tcpGraph(t, freeTCPAddress(t), backend.address)
966+ manager := NewManager()
967+ require.NoError(t, manager.Apply(context.Background(), &graph))
968+
969+ require.NoError(t, manager.Shutdown(context.Background()))
970+ _, err := net.DialTimeout("tcp", graph.EntryPoints[0].Address, 100*time.Millisecond)
971+ require.Error(t, err)
972+}
973+
974+func TestTCPPassthroughIdleTimeoutClosesConnection(t *testing.T) {
975+ backend := startTCPEchoServer(t, 0)
976+ graph := tcpGraph(t, freeTCPAddress(t), backend.address)
977+ graph.Options.TCP.IdleTimeout = 50 * time.Millisecond
978+ manager := NewManager()
979+ require.NoError(t, manager.Apply(context.Background(), &graph))
980+ defer shutdownManager(t, manager)
981+
982+ conn := dialTCP(t, graph.EntryPoints[0].Address)
983+ defer conn.Close()
984+ require.NoError(t, conn.SetReadDeadline(time.Now().Add(250*time.Millisecond)))
985+ started := time.Now()
986+ _, err := bufio.NewReader(conn).ReadByte()
987+ require.Error(t, err)
988+ var netErr net.Error
989+ require.False(t, errors.As(err, &netErr) && netErr.Timeout(), "read hit the client deadline instead of the proxy idle timeout")
990+ assert.Less(t, time.Since(started), 250*time.Millisecond)
991+}
992+
993+func TestTCPPassthroughMaxConnectionsRejectsOverflow(t *testing.T) {
994+ backend := startTCPEchoServer(t, 0)
995+ graph := tcpGraph(t, freeTCPAddress(t), backend.address)
996+ graph.Options.TCP.MaxConnections = 1
997+ manager := NewManager()
998+ require.NoError(t, manager.Apply(context.Background(), &graph))
999+ defer shutdownManager(t, manager)
1000+
1001+ first := dialTCP(t, graph.EntryPoints[0].Address)
1002+ defer first.Close()
1003+ assertRoundTrip(t, first, "first")
1004+ require.Eventually(t, func() bool { return manager.Status().Counters.ActiveTCPConnections == 1 }, time.Second, 10*time.Millisecond)
1005+
1006+ second := dialTCP(t, graph.EntryPoints[0].Address)
1007+ defer second.Close()
1008+ _, err := second.Write([]byte("second"))
1009+ require.NoError(t, err)
1010+ _, err = bufio.NewReader(second).ReadByte()
1011+ require.Error(t, err)
1012+ assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == 1 }, time.Second, 10*time.Millisecond)
1013+}
1014+
1015+func TestTCPPassthroughDrainWaitsForActiveConnectionThenTimesOut(t *testing.T) {
1016+ backend := startTCPEchoServer(t, 0)
1017+ graph := tcpGraph(t, freeTCPAddress(t), backend.address)
1018+ graph.Options.TCP.DrainTimeout = 50 * time.Millisecond
1019+ manager := NewManager()
1020+ require.NoError(t, manager.Apply(context.Background(), &graph))
1021+ defer shutdownManager(t, manager)
1022+
1023+ conn := dialTCP(t, graph.EntryPoints[0].Address)
1024+ defer conn.Close()
1025+ assertRoundTrip(t, conn, "held")
1026+ require.Eventually(t, func() bool { return manager.Status().Counters.ActiveTCPConnections == 1 }, time.Second, 10*time.Millisecond)
1027+
1028+ empty := domain.TrafficGraph{Options: graph.Options}
1029+ started := time.Now()
1030+ require.NoError(t, manager.Apply(context.Background(), &empty))
1031+ assert.GreaterOrEqual(t, time.Since(started), 40*time.Millisecond)
1032+
1033+ _, err := conn.Write([]byte("after drain"))
1034+ if err == nil {
1035+ _, err = bufio.NewReader(conn).ReadByte()
1036+ }
1037+ require.Error(t, err)
1038+}
1039+
1040+func TestTCPPassthroughStatusCountersTrackAcceptedRefusedErrorsAndBytes(t *testing.T) {
1041+ backend := startTCPEchoServer(t, 0)
1042+ graph := tcpGraph(t, freeTCPAddress(t), backend.address)
1043+ graph.Options.TCP.MaxConnections = 1
1044+ manager := NewManager()
1045+ require.NoError(t, manager.Apply(context.Background(), &graph))
1046+ defer shutdownManager(t, manager)
1047+
1048+ conn := dialTCP(t, graph.EntryPoints[0].Address)
1049+ assertRoundTrip(t, conn, "bytes")
1050+ second := dialTCP(t, graph.EntryPoints[0].Address)
1051+ _, err := second.Write([]byte("refused"))
1052+ require.NoError(t, err)
1053+ _, _ = bufio.NewReader(second).ReadByte()
1054+ _ = second.Close()
1055+ _ = conn.Close()
1056+
1057+ assert.Eventually(t, func() bool {
1058+ status := manager.Status()
1059+ return status.Counters.TotalAccepted == 1 &&
1060+ status.Counters.TotalRefused == 1 &&
1061+ status.Counters.BytesIn >= int64(len("bytes\n")) &&
1062+ status.Counters.BytesOut >= int64(len("bytes\n"))
1063+ }, time.Second, 10*time.Millisecond)
1064+
1065+ bad := tcpGraph(t, freeTCPAddress(t), freeTCPAddress(t))
1066+ bad.Options.TCP.DialTimeout = 50 * time.Millisecond
1067+ require.NoError(t, manager.Apply(context.Background(), &bad))
1068+ badConn := dialTCP(t, bad.EntryPoints[0].Address)
1069+ _, err = badConn.Write([]byte("boom"))
1070+ require.NoError(t, err)
1071+ _, _ = bufio.NewReader(badConn).ReadByte()
1072+ _ = badConn.Close()
1073+ assert.Eventually(t, func() bool { return manager.Status().Counters.TotalErrors == 1 }, time.Second, 10*time.Millisecond)
1074+}
1075+
1076+type smartTCPRoute struct {
1077+ tlsSNI string
1078+ backendAddress string
1079+ trustedCIDRs []string
1080+ allowPublic bool
1081+}
1082+
1083+type smartTCPHits struct {
1084+ httpHits *atomic.Int64
1085+ rawHits *atomic.Int64
1086+}
1087+
1088+func startSmartTCP(t *testing.T, tlsRoute *smartTCPRoute, rawRoute *smartTCPRoute) (*Manager, domain.TrafficGraph, smartTCPHits) {
1089+ t.Helper()
1090+ manager := NewManager()
1091+ graph := smartTCPGraph(t, freeTCPAddress(t), tlsRoute, rawRoute)
1092+ httpHits := &atomic.Int64{}
1093+ rawHits := &atomic.Int64{}
1094+ manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1095+ httpHits.Add(1)
1096+ _, _ = w.Write([]byte("http:" + r.URL.Path))
1097+ }), nil)
1098+ manager.SetSmartTCPTLSServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1099+ _, _ = w.Write([]byte("smart https"))
1100+ }), testTLSConfig(t, "app.example.com"))
1101+ require.NoError(t, manager.Apply(context.Background(), &graph))
1102+ return manager, graph, smartTCPHits{httpHits: httpHits, rawHits: rawHits}
1103+}
1104+
1105+func smartTCPGraph(t *testing.T, listenAddress string, tlsRoute *smartTCPRoute, rawRoute *smartTCPRoute) domain.TrafficGraph {
1106+ t.Helper()
1107+ entry := domain.EntryPoint{Name: "edge", Address: listenAddress, Protocol: domain.EntryPointProtocolSmartTCP}
1108+ routers := []domain.TrafficRouter{{Name: "route:app", EntryPoint: "edge", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app"}}
1109+ services := []domain.TrafficService{{Name: "route:app"}}
1110+ if tlsRoute != nil {
1111+ backend, err := backendFromAddress("tls:raw", tlsRoute.backendAddress)
1112+ require.NoError(t, err)
1113+ routers = append(routers, domain.TrafficRouter{Name: "raw-tls", EntryPoint: "edge", Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: tlsRoute.tlsSNI}, Service: "network_service:tls:raw"})
1114+ services = append(services, domain.TrafficService{Name: "network_service:tls:raw", Backends: []domain.TrafficBackend{backend}})
1115+ }
1116+ if rawRoute != nil {
1117+ backend, err := backendFromAddress("raw:tcp", rawRoute.backendAddress)
1118+ require.NoError(t, err)
1119+ entry.RawFallback = "raw-fallback"
1120+ entry.RawFallbackTrustedCIDRs = rawRoute.trustedCIDRs
1121+ entry.AllowPublicRawFallback = rawRoute.allowPublic
1122+ routers = append(routers, domain.TrafficRouter{Name: "raw-fallback", EntryPoint: "edge", Protocol: domain.RouterProtocolTCP, Service: "network_service:raw:tcp"})
1123+ services = append(services, domain.TrafficService{Name: "network_service:raw:tcp", Backends: []domain.TrafficBackend{backend}})
1124+ }
1125+ graph := domain.TrafficGraph{Options: domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: time.Second, IdleTimeout: time.Minute, DrainTimeout: time.Second}}, EntryPoints: []domain.EntryPoint{entry}, Routers: routers, Services: services}
1126+ require.NoError(t, graph.Validate())
1127+ return graph
1128+}
1129+
1130+func assertSmartTCPWriteRejected(t *testing.T, address string, payload []byte) {
1131+ t.Helper()
1132+ conn := dialTCP(t, address)
1133+ defer conn.Close()
1134+ _, err := conn.Write(payload)
1135+ require.NoError(t, err)
1136+ _, err = bufio.NewReader(conn).ReadByte()
1137+ require.Error(t, err)
1138+}
1139+
1140+func tcpGraph(t *testing.T, listenAddress string, backendAddress string) domain.TrafficGraph {
1141+ t.Helper()
1142+ backend, err := backendFromAddress("echo:tcp", backendAddress)
1143+ require.NoError(t, err)
1144+ ref := serviceRef("echo", "tcp")
1145+ graph := domain.TrafficGraph{
1146+ Options: domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: time.Second, IdleTimeout: time.Minute, DrainTimeout: time.Second}},
1147+ EntryPoints: []domain.EntryPoint{{Name: "tcp", Address: listenAddress, Protocol: domain.EntryPointProtocolTCP}},
1148+ Routers: []domain.TrafficRouter{{Name: "echo", EntryPoint: "tcp", Protocol: domain.RouterProtocolTCP, Service: ref}},
1149+ Services: []domain.TrafficService{{Name: ref, Backends: []domain.TrafficBackend{backend}}},
1150+ }
1151+ require.NoError(t, graph.Validate())
1152+ return graph
1153+}
1154+
1155+func freeTCPAddress(t *testing.T) string {
1156+ t.Helper()
1157+ listener, err := net.Listen("tcp", "127.0.0.1:0")
1158+ require.NoError(t, err)
1159+ address := listener.Addr().String()
1160+ require.NoError(t, listener.Close())
1161+ return address
1162+}
1163+
1164+func dialTCP(t *testing.T, address string) net.Conn {
1165+ t.Helper()
1166+ conn, err := net.DialTimeout("tcp", address, time.Second)
1167+ require.NoError(t, err)
1168+ return conn
1169+}
1170+
1171+func assertRoundTrip(t *testing.T, conn net.Conn, message string) {
1172+ t.Helper()
1173+ _, err := conn.Write([]byte(message + "\n"))
1174+ require.NoError(t, err)
1175+ line, err := bufio.NewReader(conn).ReadString('\n')
1176+ require.NoError(t, err)
1177+ assert.Equal(t, message+"\n", line)
1178+}
1179+
1180+func shutdownManager(t *testing.T, manager *Manager) {
1181+ t.Helper()
1182+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
1183+ defer cancel()
1184+ require.NoError(t, manager.Shutdown(ctx))
1185+}
1186+
1187+func tcpRuntimeForTest(t *testing.T, manager *Manager, name string) *entryPointRuntime {
1188+ t.Helper()
1189+ manager.mu.Lock()
1190+ defer manager.mu.Unlock()
1191+ runtime := manager.listeners[name]
1192+ require.NotNil(t, runtime)
1193+ return runtime
1194+}
1195+
1196+type tcpEchoServer struct {
1197+ address string
1198+ close func()
1199+}
1200+
1201+func startTCPEchoServer(t *testing.T, readDelay time.Duration) tcpEchoServer {
1202+ t.Helper()
1203+ listener, err := net.Listen("tcp", "127.0.0.1:0")
1204+ require.NoError(t, err)
1205+ done := make(chan struct{})
1206+ go func() {
1207+ defer close(done)
1208+ for {
1209+ conn, err := listener.Accept()
1210+ if err != nil {
1211+ return
1212+ }
1213+ go func() {
1214+ defer conn.Close()
1215+ if readDelay > 0 {
1216+ time.Sleep(readDelay)
1217+ }
1218+ _, _ = io.Copy(conn, conn)
1219+ }()
1220+ }
1221+ }()
1222+ server := tcpEchoServer{address: listener.Addr().String(), close: func() {
1223+ _ = listener.Close()
1224+ <-done
1225+ }}
1226+ t.Cleanup(server.close)
1227+ return server
1228+}
A internal/adapters/in/traffic/tls_mux.go +359 -0
@@ -0,0 +1,359 @@
1+package traffic
2+
3+import (
4+ "bytes"
5+ "crypto/tls"
6+ "fmt"
7+ "maps"
8+ "net"
9+ "net/http"
10+ "strings"
11+ "sync"
12+ "time"
13+
14+ "github.com/bnema/gordon/internal/domain"
15+)
16+
17+const (
18+ maxClientHelloBytes = 64 << 10
19+ clientHelloReadTimeout = 250 * time.Millisecond
20+)
21+
22+// TLSHTTPServerConfig describes the HTTPS server attached to a tls_mux entrypoint.
23+type TLSHTTPServerConfig struct {
24+ Handler http.Handler
25+ TLSConfig *tls.Config
26+}
27+
28+type tlsHTTPServers map[string]TLSHTTPServerConfig
29+
30+// SetTLSHTTPServer installs or removes the HTTPS server for a tls_mux entrypoint.
31+func (m *Manager) SetTLSHTTPServer(entryPoint string, handler http.Handler, tlsConfig *tls.Config) {
32+ m.mu.Lock()
33+ current := m.loadTLSHTTPServers()
34+ next := make(tlsHTTPServers, len(current)+1)
35+ maps.Copy(next, current)
36+ if handler == nil || tlsConfig == nil {
37+ delete(next, entryPoint)
38+ } else {
39+ next[entryPoint] = TLSHTTPServerConfig{Handler: handler, TLSConfig: tlsConfig.Clone()}
40+ }
41+ m.tlsHTTPServers.Store(next)
42+ runtimes := make([]*entryPointRuntime, 0, len(m.listeners))
43+ for _, runtime := range m.listeners {
44+ if runtime.entryPointSnapshot().Name == entryPoint {
45+ runtimes = append(runtimes, runtime)
46+ }
47+ }
48+ m.mu.Unlock()
49+
50+ for _, runtime := range runtimes {
51+ runtime.refreshTLSHTTPServer(entryPoint)
52+ }
53+}
54+
55+func (m *Manager) tlsHTTPServer(entryPoint string) (TLSHTTPServerConfig, bool) {
56+ config, ok := m.loadTLSHTTPServers()[entryPoint]
57+ return config, ok
58+}
59+
60+func (m *Manager) loadTLSHTTPServers() tlsHTTPServers {
61+ value := m.tlsHTTPServers.Load()
62+ if value == nil {
63+ return tlsHTTPServers{}
64+ }
65+ return value.(tlsHTTPServers)
66+}
67+
68+func peekClientHelloSNI(conn net.Conn) (string, net.Conn, error) {
69+ return peekClientHelloSNIWithLimit(conn, maxClientHelloBytes)
70+}
71+
72+func peekClientHelloSNIWithLimit(conn net.Conn, maxBytes int) (string, net.Conn, error) {
73+ if err := conn.SetReadDeadline(time.Now().Add(clientHelloReadTimeout)); err != nil {
74+ return "", nil, fmt.Errorf("set client hello read deadline: %w", err)
75+ }
76+ defer func() { _ = conn.SetReadDeadline(time.Time{}) }()
77+
78+ buf := make([]byte, 0, 4096)
79+ for len(buf) < maxBytes {
80+ remaining := maxBytes - len(buf)
81+ readSize := min(1024, remaining)
82+ tmp := make([]byte, readSize)
83+ n, err := conn.Read(tmp)
84+ if n > 0 {
85+ buf = append(buf, tmp[:n]...)
86+ sni, complete, parseErr := parseClientHelloSNI(buf)
87+ if parseErr != nil {
88+ return "", nil, fmt.Errorf("parse client hello sni: %w", parseErr)
89+ }
90+ if complete {
91+ return sni, replayConn{Conn: conn, reader: bytes.NewReader(buf)}, nil
92+ }
93+ }
94+ if err != nil {
95+ return "", nil, fmt.Errorf("read client hello: %w", err)
96+ }
97+ }
98+ return "", nil, fmt.Errorf("%w: %d bytes", domain.ErrClientHelloTooLarge, maxBytes)
99+}
100+
101+func parseClientHelloSNI(data []byte) (string, bool, error) {
102+ if len(data) < 5 {
103+ return "", false, nil
104+ }
105+ if data[0] != 22 {
106+ return "", false, fmt.Errorf("tls record is not a handshake")
107+ }
108+ record, consumed, complete, err := tlsRecordPayload(data, 0)
109+ if err != nil || !complete {
110+ return "", complete, err
111+ }
112+ handshake := append([]byte(nil), record...)
113+ for len(handshake) < 4 {
114+ if len(data) < consumed+5 {
115+ return "", false, nil
116+ }
117+ next, nextConsumed, nextComplete, err := tlsRecordPayload(data, consumed)
118+ if err != nil || !nextComplete {
119+ return "", nextComplete, err
120+ }
121+ handshake = append(handshake, next...)
122+ consumed = nextConsumed
123+ }
124+ if handshake[0] != 1 {
125+ return "", true, fmt.Errorf("tls handshake is not client hello")
126+ }
127+ handshakeLen := int(handshake[1])<<16 | int(handshake[2])<<8 | int(handshake[3])
128+ for len(handshake)-4 < handshakeLen {
129+ if len(data) < consumed+5 {
130+ return "", false, nil
131+ }
132+ next, nextConsumed, nextComplete, err := tlsRecordPayload(data, consumed)
133+ if err != nil || !nextComplete {
134+ return "", nextComplete, err
135+ }
136+ handshake = append(handshake, next...)
137+ consumed = nextConsumed
138+ }
139+ sni, err := parseClientHelloExtensions(handshake[4 : 4+handshakeLen])
140+ return sni, true, err
141+}
142+
143+func tlsRecordPayload(data []byte, offset int) ([]byte, int, bool, error) {
144+ if data[offset] != 22 {
145+ return nil, offset, false, fmt.Errorf("tls record is not a handshake")
146+ }
147+ recordLen := int(data[offset+3])<<8 | int(data[offset+4])
148+ if recordLen <= 0 {
149+ return nil, offset, false, fmt.Errorf("tls handshake record is empty")
150+ }
151+ end := offset + 5 + recordLen
152+ if len(data) < end {
153+ return nil, offset, false, nil
154+ }
155+ return data[offset+5 : end], end, true, nil
156+}
157+
158+func parseClientHelloExtensions(body []byte) (string, error) {
159+ reader := bytes.NewReader(body)
160+ if reader.Len() < 34 {
161+ return "", fmt.Errorf("client hello is truncated")
162+ }
163+ _, _ = readBytes(reader, 34) // legacy_version + random
164+ if err := skipLengthPrefixed(reader, 1); err != nil {
165+ return "", err
166+ }
167+ if err := skipLengthPrefixed(reader, 2); err != nil {
168+ return "", err
169+ }
170+ if err := skipLengthPrefixed(reader, 1); err != nil {
171+ return "", err
172+ }
173+ if reader.Len() == 0 {
174+ return "", nil
175+ }
176+ extLen, err := readUint16(reader)
177+ if err != nil {
178+ return "", err
179+ }
180+ if extLen > reader.Len() {
181+ return "", fmt.Errorf("client hello extensions are truncated")
182+ }
183+ extBytes, _ := readBytes(reader, extLen)
184+ extensions := bytes.NewReader(extBytes)
185+ for extensions.Len() > 0 {
186+ extType, err := readUint16(extensions)
187+ if err != nil {
188+ return "", err
189+ }
190+ extDataLen, err := readUint16(extensions)
191+ if err != nil {
192+ return "", err
193+ }
194+ if extDataLen > extensions.Len() {
195+ return "", fmt.Errorf("client hello extension is truncated")
196+ }
197+ extData, _ := readBytes(extensions, extDataLen)
198+ if extType == 0 {
199+ return parseSNIExtension(extData)
200+ }
201+ }
202+ return "", nil
203+}
204+
205+func parseSNIExtension(data []byte) (string, error) {
206+ reader := bytes.NewReader(data)
207+ listLen, err := readUint16(reader)
208+ if err != nil {
209+ return "", err
210+ }
211+ if listLen > reader.Len() {
212+ return "", fmt.Errorf("sni extension is truncated")
213+ }
214+ listBytes, _ := readBytes(reader, listLen)
215+ list := bytes.NewReader(listBytes)
216+ for list.Len() > 0 {
217+ nameType, err := list.ReadByte()
218+ if err != nil {
219+ return "", err
220+ }
221+ nameLen, err := readUint16(list)
222+ if err != nil {
223+ return "", err
224+ }
225+ if nameLen > list.Len() {
226+ return "", fmt.Errorf("sni name is truncated")
227+ }
228+ nameBytes, _ := readBytes(list, nameLen)
229+ name := string(nameBytes)
230+ if nameType == 0 {
231+ return normalizeTLSName(name), nil
232+ }
233+ }
234+ return "", nil
235+}
236+
237+func skipLengthPrefixed(reader *bytes.Reader, size int) error {
238+ length, err := readLength(reader, size)
239+ if err != nil {
240+ return err
241+ }
242+ if length > reader.Len() {
243+ return fmt.Errorf("client hello field is truncated")
244+ }
245+ _, _ = readBytes(reader, length)
246+ return nil
247+}
248+
249+func readBytes(reader *bytes.Reader, size int) ([]byte, error) {
250+ if reader.Len() < size {
251+ return nil, fmt.Errorf("client hello field is truncated")
252+ }
253+ value := make([]byte, size)
254+ _, _ = reader.Read(value)
255+ return value, nil
256+}
257+
258+func readLength(reader *bytes.Reader, size int) (int, error) {
259+ if reader.Len() < size {
260+ return 0, fmt.Errorf("client hello length is truncated")
261+ }
262+ value := 0
263+ for range size {
264+ b, _ := reader.ReadByte()
265+ value = value<<8 | int(b)
266+ }
267+ return value, nil
268+}
269+
270+func readUint16(reader *bytes.Reader) (int, error) {
271+ return readLength(reader, 2)
272+}
273+
274+func normalizeTLSName(value string) string {
275+ return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(value)), ".")
276+}
277+
278+type replayConn struct {
279+ net.Conn
280+ reader *bytes.Reader
281+}
282+
283+func (c replayConn) Read(p []byte) (int, error) {
284+ if c.reader != nil && c.reader.Len() > 0 {
285+ return c.reader.Read(p)
286+ }
287+ return c.Conn.Read(p)
288+}
289+
290+type tlsHTTPListener struct {
291+ addr net.Addr
292+ conns chan net.Conn
293+ done chan struct{}
294+ once sync.Once
295+ mu sync.Mutex
296+ closed bool
297+}
298+
299+func newTLSHTTPListener(addr net.Addr) *tlsHTTPListener {
300+ return &tlsHTTPListener{addr: addr, conns: make(chan net.Conn, 128), done: make(chan struct{})}
301+}
302+
303+func (l *tlsHTTPListener) Accept() (net.Conn, error) {
304+ select {
305+ case <-l.done:
306+ return nil, net.ErrClosed
307+ default:
308+ }
309+ select {
310+ case conn := <-l.conns:
311+ if l.isClosed() {
312+ _ = conn.Close()
313+ return nil, net.ErrClosed
314+ }
315+ return conn, nil
316+ case <-l.done:
317+ return nil, net.ErrClosed
318+ }
319+}
320+
321+func (l *tlsHTTPListener) Close() error {
322+ l.once.Do(func() {
323+ l.mu.Lock()
324+ l.closed = true
325+ l.mu.Unlock()
326+ close(l.done)
327+ for {
328+ select {
329+ case conn := <-l.conns:
330+ _ = conn.Close()
331+ default:
332+ return
333+ }
334+ }
335+ })
336+ return nil
337+}
338+
339+func (l *tlsHTTPListener) Addr() net.Addr { return l.addr }
340+
341+func (l *tlsHTTPListener) serve(conn net.Conn) bool {
342+ l.mu.Lock()
343+ defer l.mu.Unlock()
344+ if l.closed {
345+ return false
346+ }
347+ select {
348+ case l.conns <- conn:
349+ return true
350+ default:
351+ return false
352+ }
353+}
354+
355+func (l *tlsHTTPListener) isClosed() bool {
356+ l.mu.Lock()
357+ defer l.mu.Unlock()
358+ return l.closed
359+}
A internal/adapters/in/traffic/tls_mux_test.go +434 -0
@@ -0,0 +1,434 @@
1+package traffic
2+
3+import (
4+ "bufio"
5+ "context"
6+ "crypto/ecdsa"
7+ "crypto/elliptic"
8+ "crypto/rand"
9+ "crypto/tls"
10+ "crypto/x509"
11+ "crypto/x509/pkix"
12+ "encoding/pem"
13+ "errors"
14+ "fmt"
15+ "io"
16+ "math/big"
17+ "net"
18+ "net/http"
19+ "strings"
20+ "testing"
21+ "time"
22+
23+ "github.com/stretchr/testify/assert"
24+ "github.com/stretchr/testify/require"
25+
26+ "github.com/bnema/gordon/internal/domain"
27+)
28+
29+func TestClientHelloTooLargeUsesDomainSentinel(t *testing.T) {
30+ client, server := net.Pipe()
31+ go func() {
32+ _, _ = client.Write(clientHelloBytes(t, "large.example.com"))
33+ _ = client.Close()
34+ }()
35+ _, _, err := peekClientHelloSNIWithLimit(server, 1)
36+ require.Error(t, err)
37+ assert.True(t, errors.Is(err, domain.ErrClientHelloTooLarge))
38+ _ = server.Close()
39+}
40+
41+func TestClientHelloPeek(t *testing.T) {
42+ t.Run("valid sni returns replayable bytes", func(t *testing.T) {
43+ sni, replayed := peekHelloFromTLSClient(t, "raw.example.com")
44+ assert.Equal(t, "raw.example.com", sni)
45+ buf := make([]byte, 5)
46+ n, err := replayed.Read(buf)
47+ require.NoError(t, err)
48+ assert.Equal(t, 5, n)
49+ assert.Equal(t, byte(22), buf[0])
50+ })
51+
52+ t.Run("no sni", func(t *testing.T) {
53+ sni, _ := peekHelloFromTLSClient(t, "")
54+ assert.Empty(t, sni)
55+ })
56+
57+ t.Run("malformed", func(t *testing.T) {
58+ client, server := net.Pipe()
59+ go func() {
60+ _, _ = client.Write([]byte("not tls"))
61+ _ = client.Close()
62+ }()
63+ _, _, err := peekClientHelloSNI(server)
64+ require.Error(t, err)
65+ })
66+
67+ t.Run("fragmented", func(t *testing.T) {
68+ hello := clientHelloBytes(t, "fragmented.example.com")
69+ client, server := net.Pipe()
70+ go func() {
71+ for _, b := range hello {
72+ _, _ = client.Write([]byte{b})
73+ }
74+ _ = client.Close()
75+ }()
76+ sni, replayed, err := peekClientHelloSNI(server)
77+ require.NoError(t, err)
78+ assert.Equal(t, "fragmented.example.com", sni)
79+ buf := make([]byte, len(hello))
80+ _, err = io.ReadFull(replayed, buf)
81+ require.NoError(t, err)
82+ assert.Equal(t, hello, buf)
83+ })
84+
85+ t.Run("client_hello_across_multiple_tls_records", func(t *testing.T) {
86+ hello := splitClientHelloAcrossRecords(t, clientHelloBytes(t, "multi-record.example.com"), 10)
87+ client, server := net.Pipe()
88+ go func() {
89+ _, _ = client.Write(hello)
90+ _ = client.Close()
91+ }()
92+ sni, replayed, err := peekClientHelloSNI(server)
93+ require.NoError(t, err)
94+ assert.Equal(t, "multi-record.example.com", sni)
95+ buf := make([]byte, len(hello))
96+ _, err = io.ReadFull(replayed, buf)
97+ require.NoError(t, err)
98+ assert.Equal(t, hello, buf)
99+ })
100+
101+ t.Run("client_hello_header_across_multiple_tls_records", func(t *testing.T) {
102+ hello := splitClientHelloAcrossRecords(t, clientHelloBytes(t, "split-header.example.com"), 2)
103+ client, server := net.Pipe()
104+ go func() {
105+ _, _ = client.Write(hello)
106+ _ = client.Close()
107+ }()
108+ sni, replayed, err := peekClientHelloSNI(server)
109+ require.NoError(t, err)
110+ assert.Equal(t, "split-header.example.com", sni)
111+ buf := make([]byte, len(hello))
112+ _, err = io.ReadFull(replayed, buf)
113+ require.NoError(t, err)
114+ assert.Equal(t, hello, buf)
115+ })
116+}
117+
118+func splitClientHelloAcrossRecords(t *testing.T, hello []byte, firstPayloadLen int) []byte {
119+ t.Helper()
120+ require.GreaterOrEqual(t, len(hello), 15)
121+ require.Equal(t, byte(22), hello[0])
122+ recordLen := int(hello[3])<<8 | int(hello[4])
123+ require.Greater(t, recordLen, firstPayloadLen)
124+ first := append([]byte{}, hello[:5]...)
125+ first[3] = byte(firstPayloadLen >> 8)
126+ first[4] = byte(firstPayloadLen)
127+ first = append(first, hello[5:5+firstPayloadLen]...)
128+ remainingPayloadLen := recordLen - firstPayloadLen
129+ second := append([]byte{22, hello[1], hello[2], byte(remainingPayloadLen >> 8), byte(remainingPayloadLen)}, hello[5+firstPayloadLen:5+recordLen]...)
130+ out := append(first, second...)
131+ out = append(out, hello[5+recordLen:]...)
132+ return out
133+}
134+
135+func TestTLSPassthrough(t *testing.T) {
136+ backendA := startTLSGreetingServer(t, "backend-a", "backend-a\n")
137+ backendB := startTLSGreetingServer(t, "backend-b", "backend-b\n")
138+ backendExact := startTLSGreetingServer(t, "backend-exact", "backend-exact\n")
139+
140+ graph := tlsGraph(t, freeTCPAddress(t), []tlsRoute{
141+ {name: "raw", sni: "raw.example.com", service: "raw", backend: backendA.address},
142+ {name: "wild", sni: "*.example.com", service: "wild", backend: backendB.address},
143+ {name: "exact", sni: "api.example.com", service: "exact", backend: backendExact.address},
144+ })
145+ manager := NewManager()
146+ require.NoError(t, manager.Apply(context.Background(), &graph))
147+ defer shutdownManager(t, manager)
148+
149+ assertTLSGreeting(t, graph.EntryPoints[0].Address, "raw.example.com", "backend-a\n")
150+ assertTLSGreeting(t, graph.EntryPoints[0].Address, "shop.example.com", "backend-b\n")
151+ assertTLSGreeting(t, graph.EntryPoints[0].Address, "api.example.com", "backend-exact\n")
152+}
153+
154+func TestTLSMuxUnknownClosesWithoutHTTPSRoute(t *testing.T) {
155+ backend := startTLSGreetingServer(t, "backend", "backend\n")
156+ graph := tlsGraph(t, freeTCPAddress(t), []tlsRoute{{name: "raw", sni: "raw.example.com", service: "raw", backend: backend.address}})
157+ manager := NewManager()
158+ require.NoError(t, manager.Apply(context.Background(), &graph))
159+ defer shutdownManager(t, manager)
160+
161+ _, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", graph.EntryPoints[0].Address, &tls.Config{ServerName: "unknown.example.com", InsecureSkipVerify: true})
162+ require.Error(t, err)
163+ assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == 1 }, time.Second, 10*time.Millisecond)
164+}
165+
166+func TestTLSMuxIncompleteClientHelloDoesNotHangShutdown(t *testing.T) {
167+ graph := domain.TrafficGraph{
168+ Options: domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: time.Second, IdleTimeout: time.Minute, DrainTimeout: 50 * time.Millisecond}},
169+ EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTLSMux}},
170+ }
171+ manager := NewManager()
172+ require.NoError(t, manager.Apply(context.Background(), &graph))
173+
174+ conn := dialTCP(t, graph.EntryPoints[0].Address)
175+ _, err := conn.Write([]byte{22})
176+ require.NoError(t, err)
177+
178+ ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
179+ defer cancel()
180+ started := time.Now()
181+ require.NoError(t, manager.Shutdown(ctx))
182+ assert.Less(t, time.Since(started), 250*time.Millisecond)
183+ _ = conn.Close()
184+}
185+
186+func TestTLSMuxHTTPServerRefreshesActiveRuntime(t *testing.T) {
187+ graph := tlsGraph(t, freeTCPAddress(t), nil)
188+ manager := NewManager()
189+ manager.SetTLSHTTPServer("websecure", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
190+ _, _ = w.Write([]byte("before"))
191+ }), testTLSConfig(t, "app.example.com"))
192+ require.NoError(t, manager.Apply(context.Background(), &graph))
193+ defer shutdownManager(t, manager)
194+ assertHTTPSBody(t, graph.EntryPoints[0].Address, "app.example.com", "before")
195+
196+ manager.SetTLSHTTPServer("websecure", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
197+ _, _ = w.Write([]byte("after"))
198+ }), testTLSConfig(t, "app.example.com"))
199+
200+ assertHTTPSBody(t, graph.EntryPoints[0].Address, "app.example.com", "after")
201+}
202+
203+func TestTLSHTTPListenerServeRejectsAfterClose(t *testing.T) {
204+ for range 1000 {
205+ addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:443")
206+ require.NoError(t, err)
207+ listener := newTLSHTTPListener(addr)
208+ require.NoError(t, listener.Close())
209+ client, server := net.Pipe()
210+ accepted := listener.serve(server)
211+ _ = client.Close()
212+ _ = server.Close()
213+ require.False(t, accepted)
214+ }
215+}
216+
217+func TestTLSHTTPListenerCloseDrainsQueuedConnections(t *testing.T) {
218+ addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:443")
219+ require.NoError(t, err)
220+ listener := newTLSHTTPListener(addr)
221+ client, server := net.Pipe()
222+ defer client.Close()
223+ require.True(t, listener.serve(server))
224+ require.NoError(t, listener.Close())
225+ _, err = listener.Accept()
226+ require.ErrorIs(t, err, net.ErrClosed)
227+ _, err = server.Write([]byte("closed"))
228+ require.Error(t, err)
229+}
230+
231+func TestTLSMuxHTTPSRoute(t *testing.T) {
232+ backend := startTLSGreetingServer(t, "raw-backend", "raw\n")
233+ graph := tlsGraph(t, freeTCPAddress(t), []tlsRoute{{name: "raw", sni: "raw.example.com", service: "raw", backend: backend.address}})
234+ manager := NewManager()
235+ manager.SetTLSHTTPServer("websecure", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
236+ _, _ = w.Write([]byte("https route"))
237+ }), testTLSConfig(t, "app.example.com"))
238+ require.NoError(t, manager.Apply(context.Background(), &graph))
239+ defer shutdownManager(t, manager)
240+
241+ assertTLSGreeting(t, graph.EntryPoints[0].Address, "raw.example.com", "raw\n")
242+ assertHTTPSBody(t, graph.EntryPoints[0].Address, "app.example.com", "https route")
243+ assertHTTPSBodyNoSNI(t, graph.EntryPoints[0].Address, "https route")
244+
245+ conn := dialTCP(t, graph.EntryPoints[0].Address)
246+ _, err := conn.Write([]byte("malformed"))
247+ require.NoError(t, err)
248+ _ = conn.Close()
249+ assert.Eventually(t, func() bool { return manager.Status().Counters.TotalErrors >= 1 }, time.Second, 10*time.Millisecond)
250+}
251+
252+func TestTLSMuxHTTP2Route(t *testing.T) {
253+ graph := tlsGraph(t, freeTCPAddress(t), nil)
254+ manager := NewManager()
255+ tlsConfig := testTLSConfig(t, "app.example.com")
256+ tlsConfig.NextProtos = []string{"h2", "http/1.1"}
257+ manager.SetTLSHTTPServer("websecure", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
258+ _, _ = w.Write([]byte(r.Proto))
259+ }), tlsConfig)
260+ require.NoError(t, manager.Apply(context.Background(), &graph))
261+ defer shutdownManager(t, manager)
262+
263+ assertHTTPSBody(t, graph.EntryPoints[0].Address, "app.example.com", "HTTP/2.0")
264+}
265+
266+func peekHelloFromTLSClient(t *testing.T, serverName string) (string, net.Conn) {
267+ t.Helper()
268+ client, server := net.Pipe()
269+ done := make(chan struct{})
270+ go func() {
271+ defer close(done)
272+ _ = tls.Client(client, &tls.Config{ServerName: serverName, InsecureSkipVerify: true}).Handshake()
273+ _ = client.Close()
274+ }()
275+ sni, replayed, err := peekClientHelloSNI(server)
276+ require.NoError(t, err)
277+ t.Cleanup(func() {
278+ _ = replayed.Close()
279+ <-done
280+ })
281+ return sni, replayed
282+}
283+
284+func clientHelloBytes(t *testing.T, serverName string) []byte {
285+ t.Helper()
286+ _, replayed := peekHelloFromTLSClient(t, serverName)
287+ buf := make([]byte, maxClientHelloBytes)
288+ n, err := replayed.Read(buf)
289+ require.NoError(t, err)
290+ return append([]byte{}, buf[:n]...)
291+}
292+
293+type tlsRoute struct {
294+ name string
295+ sni string
296+ service string
297+ backend string
298+}
299+
300+func tlsGraph(t *testing.T, listenAddress string, routes []tlsRoute) domain.TrafficGraph {
301+ t.Helper()
302+ graph := domain.TrafficGraph{
303+ Options: domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: time.Second, IdleTimeout: time.Minute, DrainTimeout: time.Second}},
304+ EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: listenAddress, Protocol: domain.EntryPointProtocolTLSMux}},
305+ }
306+ for _, route := range routes {
307+ backend, err := backendFromAddress(route.service+":tls", route.backend)
308+ require.NoError(t, err)
309+ ref := serviceRef(route.service, "tls")
310+ graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: route.name, EntryPoint: "websecure", Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: route.sni}, Service: ref})
311+ graph.Services = append(graph.Services, domain.TrafficService{Name: ref, Backends: []domain.TrafficBackend{backend}})
312+ }
313+ require.NoError(t, graph.Validate())
314+ return graph
315+}
316+
317+func assertTLSGreeting(t *testing.T, address string, serverName string, want string) {
318+ t.Helper()
319+ conn, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", address, &tls.Config{ServerName: serverName, InsecureSkipVerify: true})
320+ require.NoError(t, err)
321+ defer conn.Close()
322+ line, err := bufio.NewReader(conn).ReadString('\n')
323+ require.NoError(t, err)
324+ assert.Equal(t, want, line)
325+}
326+
327+func assertHTTPSBody(t *testing.T, address string, serverName string, wantBody string) {
328+ t.Helper()
329+ transport := &http.Transport{
330+ TLSClientConfig: &tls.Config{ServerName: serverName, InsecureSkipVerify: true},
331+ ForceAttemptHTTP2: true,
332+ }
333+ client := &http.Client{Transport: transport, Timeout: time.Second}
334+ defer transport.CloseIdleConnections()
335+
336+ req, err := http.NewRequest(http.MethodGet, "https://"+address+"/", nil)
337+ require.NoError(t, err)
338+ req.Host = serverName
339+ resp, err := client.Do(req)
340+ require.NoError(t, err)
341+ defer resp.Body.Close()
342+ body, err := io.ReadAll(resp.Body)
343+ require.NoError(t, err)
344+ assert.Contains(t, string(body), wantBody)
345+}
346+
347+func assertHTTPSBodyNoSNI(t *testing.T, address string, wantBody string) {
348+ t.Helper()
349+ conn, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", address, &tls.Config{InsecureSkipVerify: true})
350+ require.NoError(t, err)
351+ defer conn.Close()
352+ _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
353+ require.NoError(t, err)
354+ body, err := io.ReadAll(conn)
355+ require.NoError(t, err)
356+ assert.Contains(t, string(body), wantBody)
357+}
358+
359+type tlsGreetingServer struct{ address string }
360+
361+func startTLSGreetingServer(t *testing.T, commonName string, greeting string) tlsGreetingServer {
362+ t.Helper()
363+ listener, err := tls.Listen("tcp", "127.0.0.1:0", testTLSConfig(t, commonName))
364+ require.NoError(t, err)
365+ done := make(chan struct{})
366+ go func() {
367+ defer close(done)
368+ for {
369+ conn, err := listener.Accept()
370+ if err != nil {
371+ return
372+ }
373+ go func() {
374+ defer conn.Close()
375+ _, _ = conn.Write([]byte(greeting))
376+ }()
377+ }
378+ }()
379+ t.Cleanup(func() {
380+ _ = listener.Close()
381+ <-done
382+ })
383+ return tlsGreetingServer{address: listener.Addr().String()}
384+}
385+
386+func testTLSConfig(t *testing.T, commonName string) *tls.Config {
387+ t.Helper()
388+ cert, err := testCertificate(commonName)
389+ require.NoError(t, err)
390+ return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}
391+}
392+
393+func testCertificate(commonName string) (tls.Certificate, error) {
394+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
395+ if err != nil {
396+ return tls.Certificate{}, fmt.Errorf("generate test certificate key: %w", err)
397+ }
398+ serial, err := rand.Int(rand.Reader, big.NewInt(1<<62))
399+ if err != nil {
400+ return tls.Certificate{}, fmt.Errorf("generate test certificate serial: %w", err)
401+ }
402+ template := x509.Certificate{
403+ SerialNumber: serial,
404+ Subject: pkix.Name{CommonName: commonName},
405+ DNSNames: []string{commonName},
406+ NotBefore: time.Now().Add(-time.Hour),
407+ NotAfter: time.Now().Add(time.Hour),
408+ KeyUsage: x509.KeyUsageDigitalSignature,
409+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
410+ }
411+ der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
412+ if err != nil {
413+ return tls.Certificate{}, fmt.Errorf("create test certificate: %w", err)
414+ }
415+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
416+ keyBytes, err := x509.MarshalECPrivateKey(key)
417+ if err != nil {
418+ return tls.Certificate{}, fmt.Errorf("marshal test certificate key: %w", err)
419+ }
420+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
421+ cert, err := tls.X509KeyPair(certPEM, keyPEM)
422+ if err != nil {
423+ return tls.Certificate{}, fmt.Errorf("parse test certificate key pair: %w", err)
424+ }
425+ return cert, nil
426+}
427+
428+func TestHostMatchesTLSWildcard(t *testing.T) {
429+ assert.True(t, hostMatchesTLSWildcard("api.example.com", "*.example.com"))
430+ assert.False(t, hostMatchesTLSWildcard("example.com", "*.example.com"))
431+ assert.False(t, hostMatchesTLSWildcard("v1.api.example.com", "*.example.com"))
432+ assert.False(t, hostMatchesTLSWildcard("api.other.com", "*.example.com"))
433+ assert.True(t, strings.EqualFold(normalizeTLSName("API.EXAMPLE.COM."), "api.example.com"))
434+}
A internal/adapters/in/traffic/udp.go +460 -0
@@ -0,0 +1,460 @@
1+package traffic
2+
3+import (
4+ "context"
5+ "errors"
6+ "net"
7+ "strconv"
8+ "sync"
9+ "sync/atomic"
10+ "time"
11+
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+const (
16+ udpBufferSize = 64 << 10
17+ udpWorkerCount = 16
18+ udpDatagramBacklog = 1024
19+)
20+
21+type udpEntryPointRuntime struct {
22+ manager *Manager
23+ entryPoint domain.EntryPoint
24+ packetConn net.PacketConn
25+ counters trafficCounters
26+ trusted []*net.IPNet
27+
28+ started atomic.Bool
29+ closed atomic.Bool
30+
31+ ctx context.Context
32+ cancel context.CancelFunc
33+ done chan struct{}
34+ doneOnce sync.Once
35+
36+ datagrams chan udpDatagram
37+
38+ mu sync.Mutex
39+ sessions map[string]*udpSession
40+}
41+
42+type udpDatagram struct {
43+ clientAddr net.Addr
44+ packet []byte
45+}
46+
47+type udpSession struct {
48+ clientAddr net.Addr
49+ backend net.Conn
50+ backendRef domain.TrafficBackend
51+ lastSeen atomic.Int64
52+ done chan struct{}
53+ once sync.Once
54+}
55+
56+func newUDPEntryPointRuntime(parentCtx context.Context, manager *Manager, entryPoint domain.EntryPoint, packetConn net.PacketConn, trusted []*net.IPNet) *udpEntryPointRuntime {
57+ ctx, cancel := context.WithCancel(context.WithoutCancel(parentCtx))
58+ return &udpEntryPointRuntime{
59+ manager: manager,
60+ entryPoint: entryPoint,
61+ packetConn: packetConn,
62+ trusted: trusted,
63+ ctx: ctx,
64+ cancel: cancel,
65+ done: make(chan struct{}),
66+ datagrams: make(chan udpDatagram, udpDatagramBacklog),
67+ sessions: map[string]*udpSession{},
68+ }
69+}
70+
71+func (r *udpEntryPointRuntime) start() {
72+ if !r.started.CompareAndSwap(false, true) {
73+ return
74+ }
75+ entryPoint := r.entryPointSnapshot()
76+ trafficInfo(r.ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("started udp traffic entrypoint")
77+ for range udpWorkerCount {
78+ go r.datagramWorker()
79+ }
80+ go r.readLoop()
81+ go r.expireLoop()
82+}
83+
84+func (r *udpEntryPointRuntime) readLoop() {
85+ defer r.closeDone()
86+ buf := make([]byte, udpBufferSize)
87+ for {
88+ n, clientAddr, err := r.packetConn.ReadFrom(buf)
89+ if err != nil {
90+ if r.isClosed() || errors.Is(err, net.ErrClosed) {
91+ return
92+ }
93+ r.counters.totalErrors.Add(1)
94+ continue
95+ }
96+ if !trustedRemoteAddr(r.trustedSnapshot(), clientAddr) {
97+ r.counters.totalRefused.Add(1)
98+ continue
99+ }
100+ select {
101+ case <-r.ctx.Done():
102+ return
103+ default:
104+ }
105+ if len(r.datagrams) >= cap(r.datagrams) {
106+ r.counters.totalRefused.Add(1)
107+ continue
108+ }
109+ packet := append([]byte(nil), buf[:n]...)
110+ select {
111+ case r.datagrams <- udpDatagram{clientAddr: clientAddr, packet: packet}:
112+ case <-r.ctx.Done():
113+ return
114+ default:
115+ r.counters.totalRefused.Add(1)
116+ }
117+ }
118+}
119+
120+func (r *udpEntryPointRuntime) datagramWorker() {
121+ for {
122+ select {
123+ case <-r.ctx.Done():
124+ return
125+ case datagram := <-r.datagrams:
126+ select {
127+ case <-r.ctx.Done():
128+ return
129+ default:
130+ }
131+ r.handleDatagram(datagram.clientAddr, datagram.packet)
132+ }
133+ }
134+}
135+
136+func (r *udpEntryPointRuntime) handleDatagram(clientAddr net.Addr, packet []byte) {
137+ options := effectiveUDPOptions(snapshotUDPOptions(r.manager.snapshot.Load()))
138+ session, ok := r.session(clientAddr.String(), clientAddr, options)
139+ if !ok {
140+ return
141+ }
142+ n, err := session.backend.Write(packet)
143+ r.counters.bytesIn.Add(int64(n))
144+ if err != nil {
145+ r.counters.totalErrors.Add(1)
146+ r.removeSession(clientAddr.String())
147+ }
148+}
149+
150+func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options domain.UDPOptions) (*udpSession, bool) {
151+ r.mu.Lock()
152+ if session := r.sessions[key]; session != nil {
153+ session.touch()
154+ r.mu.Unlock()
155+ return session, true
156+ }
157+ r.mu.Unlock()
158+
159+ backend, ok := r.resolveUDPBackend()
160+ if !ok {
161+ r.counters.totalRefused.Add(1)
162+ return nil, false
163+ }
164+
165+ r.mu.Lock()
166+ if options.MaxSessions > 0 && len(r.sessions) >= options.MaxSessions {
167+ r.mu.Unlock()
168+ r.counters.totalRefused.Add(1)
169+ return nil, false
170+ }
171+ r.mu.Unlock()
172+ dialCtx, cancel := context.WithTimeout(r.ctx, udpDialTimeout(options))
173+ backendConn, err := (&net.Dialer{}).DialContext(dialCtx, "udp", net.JoinHostPort(backend.Host, strconv.Itoa(backend.Port)))
174+ cancel()
175+ if err != nil {
176+ r.counters.totalErrors.Add(1)
177+ return nil, false
178+ }
179+ session := &udpSession{clientAddr: clientAddr, backend: backendConn, backendRef: backend, done: make(chan struct{})}
180+ session.touch()
181+
182+ r.mu.Lock()
183+ if existing := r.sessions[key]; existing != nil {
184+ r.mu.Unlock()
185+ _ = backendConn.Close()
186+ return existing, true
187+ }
188+ if options.MaxSessions > 0 && len(r.sessions) >= options.MaxSessions {
189+ r.mu.Unlock()
190+ _ = backendConn.Close()
191+ r.counters.totalRefused.Add(1)
192+ return nil, false
193+ }
194+ r.sessions[key] = session
195+ r.mu.Unlock()
196+
197+ r.counters.activeUDPSessions.Add(1)
198+ r.counters.totalAccepted.Add(1)
199+ go r.backendLoop(key, session)
200+ return session, true
201+}
202+
203+func trafficBackendEqual(left domain.TrafficBackend, right domain.TrafficBackend) bool {
204+ return left.Name == right.Name && left.Host == right.Host && left.Port == right.Port && left.Protocol == right.Protocol
205+}
206+
207+func udpDialTimeout(options domain.UDPOptions) time.Duration {
208+ if options.IdleTimeout > 0 && options.IdleTimeout < 5*time.Second {
209+ return options.IdleTimeout
210+ }
211+ return 5 * time.Second
212+}
213+
214+func (r *udpEntryPointRuntime) backendLoop(key string, session *udpSession) {
215+ defer r.removeSession(key)
216+ buf := make([]byte, udpBufferSize)
217+ for {
218+ n, err := session.backend.Read(buf)
219+ if err != nil {
220+ if !errors.Is(err, net.ErrClosed) && !r.isClosed() {
221+ r.counters.totalErrors.Add(1)
222+ }
223+ return
224+ }
225+ written, err := r.packetConn.WriteTo(buf[:n], session.clientAddr)
226+ r.counters.bytesOut.Add(int64(written))
227+ if err != nil {
228+ if !r.isClosed() {
229+ r.counters.totalErrors.Add(1)
230+ }
231+ return
232+ }
233+ }
234+}
235+
236+func (r *udpEntryPointRuntime) expireLoop() {
237+ ticker := time.NewTicker(25 * time.Millisecond)
238+ defer ticker.Stop()
239+ for {
240+ select {
241+ case <-r.ctx.Done():
242+ return
243+ case <-ticker.C:
244+ r.expireIdleSessions(effectiveUDPOptions(snapshotUDPOptions(r.manager.snapshot.Load())).IdleTimeout)
245+ }
246+ }
247+}
248+
249+func (r *udpEntryPointRuntime) expireIdleSessions(idleTimeout time.Duration) {
250+ if idleTimeout <= 0 {
251+ idleTimeout = defaultUDPOptions().IdleTimeout
252+ }
253+ cutoff := time.Now().Add(-idleTimeout).UnixNano()
254+ expired := []*udpSession{}
255+ r.mu.Lock()
256+ for key, session := range r.sessions {
257+ if session.lastSeen.Load() <= cutoff {
258+ delete(r.sessions, key)
259+ expired = append(expired, session)
260+ }
261+ }
262+ r.mu.Unlock()
263+ for _, session := range expired {
264+ session.close()
265+ r.counters.activeUDPSessions.Add(-1)
266+ }
267+}
268+
269+func (r *udpEntryPointRuntime) removeSession(key string) {
270+ r.mu.Lock()
271+ session := r.sessions[key]
272+ if session != nil {
273+ delete(r.sessions, key)
274+ }
275+ r.mu.Unlock()
276+ if session == nil {
277+ return
278+ }
279+ session.close()
280+ r.counters.activeUDPSessions.Add(-1)
281+}
282+
283+func (r *udpEntryPointRuntime) resolveUDPBackend() (domain.TrafficBackend, bool) {
284+ graph := r.manager.snapshot.Load()
285+ if graph == nil {
286+ return domain.TrafficBackend{}, false
287+ }
288+ entryPoint := r.entryPointSnapshot()
289+ var router domain.TrafficRouter
290+ for _, candidate := range graph.Routers {
291+ if candidate.EntryPoint == entryPoint.Name && candidate.Protocol == domain.RouterProtocolUDP {
292+ if router.Name != "" {
293+ return domain.TrafficBackend{}, false
294+ }
295+ router = candidate
296+ }
297+ }
298+ if router.Name == "" {
299+ return domain.TrafficBackend{}, false
300+ }
301+ for _, service := range graph.Services {
302+ if service.Name == router.Service && len(service.Backends) == 1 && service.Backends[0].Protocol == domain.NetworkProtocolUDP {
303+ return service.Backends[0], true
304+ }
305+ }
306+ return domain.TrafficBackend{}, false
307+}
308+
309+func (r *udpEntryPointRuntime) stop(ctx context.Context, drainTimeout time.Duration) {
310+ if r.closed.CompareAndSwap(false, true) {
311+ entryPoint := r.entryPointSnapshot()
312+ trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Msg("stopping udp traffic entrypoint")
313+ r.cancel()
314+ _ = r.packetConn.Close()
315+ if !r.started.Load() {
316+ r.closeDone()
317+ }
318+ }
319+ select {
320+ case <-r.done:
321+ case <-ctx.Done():
322+ r.closeSessions()
323+ return
324+ }
325+ if r.waitSessions(ctx, drainTimeout) {
326+ trafficInfo(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Msg("stopped udp traffic entrypoint")
327+ return
328+ }
329+ trafficDebug(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Dur("drain_timeout", drainTimeout).Msg("forcing udp traffic entrypoint drain")
330+ r.closeSessions()
331+ select {
332+ case <-r.sessionsDone():
333+ trafficInfo(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Msg("stopped udp traffic entrypoint")
334+ case <-ctx.Done():
335+ }
336+}
337+
338+func (r *udpEntryPointRuntime) drainSessionsAfter(drainTimeout time.Duration) {
339+ trafficDebug(r.ctx).Str("entrypoint", r.entryPointSnapshot().Name).Dur("drain_timeout", drainTimeout).Msg("scheduled udp session drain after router removal")
340+ r.drainSessionsMatchingAfter(func(*udpSession) bool { return true }, drainTimeout)
341+}
342+
343+func (r *udpEntryPointRuntime) drainSessionsNotMatchingAfter(backend domain.TrafficBackend, drainTimeout time.Duration) {
344+ trafficDebug(r.ctx).Str("entrypoint", r.entryPointSnapshot().Name).Dur("drain_timeout", drainTimeout).Msg("scheduled stale udp session drain after backend update")
345+ r.drainSessionsMatchingAfter(func(session *udpSession) bool { return !trafficBackendEqual(session.backendRef, backend) }, drainTimeout)
346+}
347+
348+func (r *udpEntryPointRuntime) drainSessionsMatchingAfter(match func(*udpSession) bool, drainTimeout time.Duration) {
349+ go func() {
350+ if drainTimeout <= 0 {
351+ drainTimeout = defaultUDPOptions().DrainTimeout
352+ }
353+ select {
354+ case <-time.After(drainTimeout):
355+ r.closeSessionsMatching(match)
356+ case <-r.ctx.Done():
357+ }
358+ }()
359+}
360+
361+func (r *udpEntryPointRuntime) waitSessions(ctx context.Context, drainTimeout time.Duration) bool {
362+ if drainTimeout <= 0 {
363+ drainTimeout = defaultUDPOptions().DrainTimeout
364+ }
365+ drainCtx, cancel := context.WithTimeout(ctx, drainTimeout)
366+ defer cancel()
367+ select {
368+ case <-r.sessionsDone():
369+ return true
370+ case <-drainCtx.Done():
371+ return false
372+ }
373+}
374+
375+func (r *udpEntryPointRuntime) closeDone() {
376+ r.doneOnce.Do(func() { close(r.done) })
377+}
378+
379+func (r *udpEntryPointRuntime) sessionsDone() <-chan struct{} {
380+ done := make(chan struct{})
381+ go func() {
382+ for {
383+ r.mu.Lock()
384+ count := len(r.sessions)
385+ r.mu.Unlock()
386+ if count == 0 {
387+ close(done)
388+ return
389+ }
390+ time.Sleep(10 * time.Millisecond)
391+ }
392+ }()
393+ return done
394+}
395+
396+func (r *udpEntryPointRuntime) closeSessions() {
397+ r.closeSessionsMatching(func(*udpSession) bool { return true })
398+}
399+
400+func (r *udpEntryPointRuntime) closeSessionsMatching(match func(*udpSession) bool) {
401+ r.mu.Lock()
402+ keys := make([]string, 0, len(r.sessions))
403+ for key, session := range r.sessions {
404+ if match(session) {
405+ keys = append(keys, key)
406+ }
407+ }
408+ r.mu.Unlock()
409+ for _, key := range keys {
410+ r.removeSession(key)
411+ }
412+}
413+
414+func (r *udpEntryPointRuntime) matches(entryPoint domain.EntryPoint) bool {
415+ current := r.entryPointSnapshot()
416+ return current.Name == entryPoint.Name && current.Address == entryPoint.Address && current.Protocol == entryPoint.Protocol && trustedCIDRsEqual(current.TrustedCIDRs, entryPoint.TrustedCIDRs)
417+}
418+
419+func (r *udpEntryPointRuntime) sameAddress(entryPoint domain.EntryPoint) bool {
420+ return r.entryPointSnapshot().Address == entryPoint.Address
421+}
422+
423+func (r *udpEntryPointRuntime) updateEntryPoint(entryPoint domain.EntryPoint, trusted []*net.IPNet) {
424+ r.mu.Lock()
425+ r.entryPoint = entryPoint
426+ r.trusted = trusted
427+ stale := make([]string, 0)
428+ for key, session := range r.sessions {
429+ if !trustedRemoteAddr(trusted, session.clientAddr) {
430+ stale = append(stale, key)
431+ }
432+ }
433+ r.mu.Unlock()
434+ for _, key := range stale {
435+ r.removeSession(key)
436+ }
437+}
438+
439+func (r *udpEntryPointRuntime) entryPointSnapshot() domain.EntryPoint {
440+ r.mu.Lock()
441+ defer r.mu.Unlock()
442+ return r.entryPoint
443+}
444+
445+func (r *udpEntryPointRuntime) trustedSnapshot() []*net.IPNet {
446+ r.mu.Lock()
447+ defer r.mu.Unlock()
448+ return r.trusted
449+}
450+
451+func (r *udpEntryPointRuntime) isClosed() bool { return r.closed.Load() }
452+
453+func (s *udpSession) touch() { s.lastSeen.Store(time.Now().UnixNano()) }
454+
455+func (s *udpSession) close() {
456+ s.once.Do(func() {
457+ _ = s.backend.Close()
458+ close(s.done)
459+ })
460+}
A internal/adapters/in/traffic/udp_test.go +361 -0
@@ -0,0 +1,361 @@
1+package traffic
2+
3+import (
4+ "context"
5+ "net"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+func TestUDPPassthroughEcho(t *testing.T) {
16+ backend := startUDPEchoServer(t)
17+ graph := udpGraph(t, freeUDPAddress(t), backend.address)
18+ manager := NewManager()
19+ require.NoError(t, manager.Apply(context.Background(), &graph))
20+ defer shutdownManager(t, manager)
21+
22+ conn := dialUDP(t, graph.EntryPoints[0].Address)
23+ defer conn.Close()
24+ assertUDPRoundTrip(t, conn, "hello udp")
25+}
26+
27+func TestUDPRejectsUntrustedCIDR(t *testing.T) {
28+ backend := startUDPEchoServer(t)
29+ graph := udpGraph(t, freeUDPAddress(t), backend.address)
30+ graph.EntryPoints[0].TrustedCIDRs = []string{"192.0.2.0/24"}
31+ manager := NewManager()
32+ require.NoError(t, manager.Apply(context.Background(), &graph))
33+ defer shutdownManager(t, manager)
34+
35+ assertUDPRejected(t, manager, graph.EntryPoints[0].Address, 1)
36+}
37+
38+func TestUDPReloadAppliesTrustedCIDRChange(t *testing.T) {
39+ backend := startUDPEchoServer(t)
40+ graph := udpGraph(t, freeUDPAddress(t), backend.address)
41+ graph.EntryPoints[0].TrustedCIDRs = []string{"127.0.0.0/8"}
42+ manager := NewManager()
43+ require.NoError(t, manager.Apply(context.Background(), &graph))
44+ defer shutdownManager(t, manager)
45+
46+ conn := dialUDP(t, graph.EntryPoints[0].Address)
47+ assertUDPRoundTrip(t, conn, "allowed")
48+ require.NoError(t, conn.Close())
49+
50+ updated := graph
51+ updated.EntryPoints[0].TrustedCIDRs = []string{"192.0.2.0/24"}
52+ require.NoError(t, manager.Apply(context.Background(), &updated))
53+ assertUDPRejected(t, manager, graph.EntryPoints[0].Address, 1)
54+}
55+
56+func TestUDPReloadClosesActiveUntrustedSessions(t *testing.T) {
57+ backend := startUDPEchoServer(t)
58+ graph := udpGraph(t, freeUDPAddress(t), backend.address)
59+ graph.EntryPoints[0].TrustedCIDRs = []string{"127.0.0.0/8"}
60+ manager := NewManager()
61+ require.NoError(t, manager.Apply(context.Background(), &graph))
62+ defer shutdownManager(t, manager)
63+
64+ conn := dialUDP(t, graph.EntryPoints[0].Address)
65+ defer conn.Close()
66+ assertUDPRoundTrip(t, conn, "before")
67+ require.Eventually(t, func() bool { return manager.Status().Counters.ActiveUDPSessions == 1 }, time.Second, 10*time.Millisecond)
68+
69+ updated := graph
70+ updated.EntryPoints[0].TrustedCIDRs = []string{"192.0.2.0/24"}
71+ require.NoError(t, manager.Apply(context.Background(), &updated))
72+
73+ assert.Eventually(t, func() bool { return manager.Status().Counters.ActiveUDPSessions == 0 }, time.Second, 10*time.Millisecond)
74+ _, err := conn.Write([]byte("after"))
75+ require.NoError(t, err)
76+ _ = conn.SetReadDeadline(time.Now().Add(80 * time.Millisecond))
77+ buf := make([]byte, 32)
78+ _, err = conn.Read(buf)
79+ require.Error(t, err)
80+}
81+
82+func TestUDPReloadReplacesSessionWhenBackendChanges(t *testing.T) {
83+ backendA := startUDPEchoServerWithPrefix(t, "a:")
84+ backendB := startUDPEchoServerWithPrefix(t, "b:")
85+ graph := udpGraph(t, freeUDPAddress(t), backendA.address)
86+ manager := NewManager()
87+ require.NoError(t, manager.Apply(context.Background(), &graph))
88+ defer shutdownManager(t, manager)
89+
90+ conn := dialUDP(t, graph.EntryPoints[0].Address)
91+ defer conn.Close()
92+ assertUDPRoundTripWant(t, conn, "first", "a:first")
93+
94+ updated := graph
95+ backend, err := backendFromAddress("echo:udp", backendB.address)
96+ require.NoError(t, err)
97+ backend.Protocol = domain.NetworkProtocolUDP
98+ updated.Services[0].Backends = []domain.TrafficBackend{backend}
99+ require.NoError(t, manager.Apply(context.Background(), &updated))
100+
101+ assertUDPRoundTripWant(t, conn, "second", "a:second")
102+ assert.Eventually(t, func() bool { return manager.Status().Counters.ActiveUDPSessions == 0 }, time.Second, 10*time.Millisecond)
103+
104+ newConn := dialUDP(t, graph.EntryPoints[0].Address)
105+ defer newConn.Close()
106+ assertUDPRoundTripWant(t, newConn, "third", "b:third")
107+}
108+
109+func assertUDPRejected(t *testing.T, manager *Manager, address string, refused int64) {
110+ t.Helper()
111+ conn := dialUDP(t, address)
112+ defer conn.Close()
113+ _, err := conn.Write([]byte("blocked"))
114+ require.NoError(t, err)
115+ _ = conn.SetReadDeadline(time.Now().Add(80 * time.Millisecond))
116+ buf := make([]byte, 32)
117+ _, err = conn.Read(buf)
118+ require.Error(t, err)
119+ assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == refused }, time.Second, 10*time.Millisecond)
120+}
121+
122+func TestUDPTwoClientsGetIsolatedSessions(t *testing.T) {
123+ backend := startUDPEchoServer(t)
124+ graph := udpGraph(t, freeUDPAddress(t), backend.address)
125+ manager := NewManager()
126+ require.NoError(t, manager.Apply(context.Background(), &graph))
127+ defer shutdownManager(t, manager)
128+
129+ first := dialUDP(t, graph.EntryPoints[0].Address)
130+ defer first.Close()
131+ second := dialUDP(t, graph.EntryPoints[0].Address)
132+ defer second.Close()
133+ assertUDPRoundTrip(t, first, "first")
134+ assertUDPRoundTrip(t, second, "second")
135+ assert.Eventually(t, func() bool { return manager.Status().Counters.ActiveUDPSessions == 2 }, time.Second, 10*time.Millisecond)
136+}
137+
138+func TestUDPIdleSessionExpires(t *testing.T) {
139+ backend := startUDPEchoServer(t)
140+ graph := udpGraph(t, freeUDPAddress(t), backend.address)
141+ graph.Options.UDP.IdleTimeout = 40 * time.Millisecond
142+ manager := NewManager()
143+ require.NoError(t, manager.Apply(context.Background(), &graph))
144+ defer shutdownManager(t, manager)
145+
146+ conn := dialUDP(t, graph.EntryPoints[0].Address)
147+ defer conn.Close()
148+ assertUDPRoundTrip(t, conn, "expire")
149+ assert.Eventually(t, func() bool { return manager.Status().Counters.ActiveUDPSessions == 0 }, time.Second, 10*time.Millisecond)
150+}
151+
152+func TestUDPMaxSessionsRejectsOverflow(t *testing.T) {
153+ backend := startUDPEchoServer(t)
154+ graph := udpGraph(t, freeUDPAddress(t), backend.address)
155+ graph.Options.UDP.MaxSessions = 1
156+ manager := NewManager()
157+ require.NoError(t, manager.Apply(context.Background(), &graph))
158+ defer shutdownManager(t, manager)
159+
160+ first := dialUDP(t, graph.EntryPoints[0].Address)
161+ defer first.Close()
162+ assertUDPRoundTrip(t, first, "first")
163+
164+ second := dialUDP(t, graph.EntryPoints[0].Address)
165+ defer second.Close()
166+ _, err := second.Write([]byte("second"))
167+ require.NoError(t, err)
168+ _ = second.SetReadDeadline(time.Now().Add(80 * time.Millisecond))
169+ buf := make([]byte, 32)
170+ _, err = second.Read(buf)
171+ require.Error(t, err)
172+ assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == 1 }, time.Second, 10*time.Millisecond)
173+}
174+
175+func TestUDPRemovedRouterWithRetainedEntryPointDrainsSession(t *testing.T) {
176+ backend := startUDPEchoServer(t)
177+ graph := udpGraph(t, freeUDPAddress(t), backend.address)
178+ graph.Options.UDP.DrainTimeout = 50 * time.Millisecond
179+ manager := NewManager()
180+ require.NoError(t, manager.Apply(context.Background(), &graph))
181+ defer shutdownManager(t, manager)
182+
183+ conn := dialUDP(t, graph.EntryPoints[0].Address)
184+ defer conn.Close()
185+ assertUDPRoundTrip(t, conn, "held")
186+
187+ next := graph
188+ next.Routers = nil
189+ next.Services = nil
190+ require.NoError(t, manager.Apply(context.Background(), &next))
191+ assertUDPRoundTrip(t, conn, "during-drain")
192+ assert.Eventually(t, func() bool { return manager.Status().Counters.ActiveUDPSessions == 0 }, time.Second, 10*time.Millisecond)
193+
194+ _, err := conn.Write([]byte("after"))
195+ require.NoError(t, err)
196+ _ = conn.SetReadDeadline(time.Now().Add(80 * time.Millisecond))
197+ buf := make([]byte, 32)
198+ _, err = conn.Read(buf)
199+ require.Error(t, err)
200+}
201+
202+func TestUDPBackendChangeDrainsExistingSession(t *testing.T) {
203+ oldBackend := startUDPEchoServerWithPrefix(t, "old:")
204+ newBackend := startUDPEchoServerWithPrefix(t, "new:")
205+ graph := udpGraph(t, freeUDPAddress(t), oldBackend.address)
206+ graph.Options.UDP.DrainTimeout = 50 * time.Millisecond
207+ manager := NewManager()
208+ require.NoError(t, manager.Apply(context.Background(), &graph))
209+ defer shutdownManager(t, manager)
210+
211+ oldConn := dialUDP(t, graph.EntryPoints[0].Address)
212+ defer oldConn.Close()
213+ assertUDPRoundTripWant(t, oldConn, "held", "old:held")
214+
215+ next := udpGraph(t, graph.EntryPoints[0].Address, newBackend.address)
216+ next.Options = graph.Options
217+ require.NoError(t, manager.Apply(context.Background(), &next))
218+ assertUDPRoundTripWant(t, oldConn, "during-drain", "old:during-drain")
219+ assert.Eventually(t, func() bool { return manager.Status().Counters.ActiveUDPSessions == 0 }, time.Second, 10*time.Millisecond)
220+
221+ newConn := dialUDP(t, graph.EntryPoints[0].Address)
222+ defer newConn.Close()
223+ assertUDPRoundTripWant(t, newConn, "after", "new:after")
224+}
225+
226+func TestUDPRemovedRouterDrainsThenClosesSessions(t *testing.T) {
227+ backend := startUDPEchoServer(t)
228+ graph := udpGraph(t, freeUDPAddress(t), backend.address)
229+ graph.Options.UDP.DrainTimeout = 50 * time.Millisecond
230+ manager := NewManager()
231+ require.NoError(t, manager.Apply(context.Background(), &graph))
232+ defer shutdownManager(t, manager)
233+
234+ conn := dialUDP(t, graph.EntryPoints[0].Address)
235+ defer conn.Close()
236+ assertUDPRoundTrip(t, conn, "held")
237+ require.Eventually(t, func() bool { return manager.Status().Counters.ActiveUDPSessions == 1 }, time.Second, 10*time.Millisecond)
238+
239+ empty := domain.TrafficGraph{Options: graph.Options}
240+ started := time.Now()
241+ require.NoError(t, manager.Apply(context.Background(), &empty))
242+ assert.GreaterOrEqual(t, time.Since(started), 40*time.Millisecond)
243+ assert.Equal(t, int64(0), manager.Status().Counters.ActiveUDPSessions)
244+}
245+
246+func TestUDPShutdownClosesSocket(t *testing.T) {
247+ backend := startUDPEchoServer(t)
248+ graph := udpGraph(t, freeUDPAddress(t), backend.address)
249+ manager := NewManager()
250+ require.NoError(t, manager.Apply(context.Background(), &graph))
251+ require.NoError(t, manager.Shutdown(context.Background()))
252+
253+ conn := dialUDP(t, graph.EntryPoints[0].Address)
254+ defer conn.Close()
255+ _, err := conn.Write([]byte("closed"))
256+ require.NoError(t, err)
257+ _ = conn.SetReadDeadline(time.Now().Add(80 * time.Millisecond))
258+ buf := make([]byte, 32)
259+ _, err = conn.Read(buf)
260+ require.Error(t, err)
261+}
262+
263+func TestUDPStatusCountersTrackDatagrams(t *testing.T) {
264+ backend := startUDPEchoServer(t)
265+ graph := udpGraph(t, freeUDPAddress(t), backend.address)
266+ manager := NewManager()
267+ require.NoError(t, manager.Apply(context.Background(), &graph))
268+ defer shutdownManager(t, manager)
269+
270+ conn := dialUDP(t, graph.EntryPoints[0].Address)
271+ defer conn.Close()
272+ assertUDPRoundTrip(t, conn, "bytes")
273+
274+ assert.Eventually(t, func() bool {
275+ status := manager.Status()
276+ return status.Counters.TotalAccepted == 1 &&
277+ status.Counters.ActiveUDPSessions == 1 &&
278+ status.Counters.BytesIn >= int64(len("bytes")) &&
279+ status.Counters.BytesOut >= int64(len("bytes"))
280+ }, time.Second, 10*time.Millisecond)
281+}
282+
283+func udpGraph(t *testing.T, listenAddress string, backendAddress string) domain.TrafficGraph {
284+ t.Helper()
285+ backend, err := backendFromAddress("echo:udp", backendAddress)
286+ require.NoError(t, err)
287+ backend.Protocol = domain.NetworkProtocolUDP
288+ ref := serviceRef("echo", "udp")
289+ graph := domain.TrafficGraph{
290+ Options: domain.TrafficOptions{UDP: domain.UDPOptions{IdleTimeout: time.Minute, DrainTimeout: 50 * time.Millisecond}},
291+ EntryPoints: []domain.EntryPoint{{Name: "udp", Address: listenAddress, Protocol: domain.EntryPointProtocolUDP}},
292+ Routers: []domain.TrafficRouter{{Name: "echo", EntryPoint: "udp", Protocol: domain.RouterProtocolUDP, Service: ref}},
293+ Services: []domain.TrafficService{{Name: ref, Backends: []domain.TrafficBackend{backend}}},
294+ }
295+ require.NoError(t, graph.Validate())
296+ return graph
297+}
298+
299+func freeUDPAddress(t *testing.T) string {
300+ t.Helper()
301+ conn, err := net.ListenPacket("udp", "127.0.0.1:0")
302+ require.NoError(t, err)
303+ address := conn.LocalAddr().String()
304+ require.NoError(t, conn.Close())
305+ return address
306+}
307+
308+func dialUDP(t *testing.T, address string) *net.UDPConn {
309+ t.Helper()
310+ udpAddr, err := net.ResolveUDPAddr("udp", address)
311+ require.NoError(t, err)
312+ conn, err := net.DialUDP("udp", nil, udpAddr)
313+ require.NoError(t, err)
314+ return conn
315+}
316+
317+func assertUDPRoundTrip(t *testing.T, conn *net.UDPConn, message string) {
318+ t.Helper()
319+ assertUDPRoundTripWant(t, conn, message, message)
320+}
321+
322+func assertUDPRoundTripWant(t *testing.T, conn *net.UDPConn, message string, want string) {
323+ t.Helper()
324+ _, err := conn.Write([]byte(message))
325+ require.NoError(t, err)
326+ _ = conn.SetReadDeadline(time.Now().Add(time.Second))
327+ buf := make([]byte, 128)
328+ n, err := conn.Read(buf)
329+ require.NoError(t, err)
330+ assert.Equal(t, want, string(buf[:n]))
331+}
332+
333+type udpEchoServer struct{ address string }
334+
335+func startUDPEchoServer(t *testing.T) udpEchoServer {
336+ t.Helper()
337+ return startUDPEchoServerWithPrefix(t, "")
338+}
339+
340+func startUDPEchoServerWithPrefix(t *testing.T, prefix string) udpEchoServer {
341+ t.Helper()
342+ conn, err := net.ListenPacket("udp", "127.0.0.1:0")
343+ require.NoError(t, err)
344+ done := make(chan struct{})
345+ go func() {
346+ defer close(done)
347+ buf := make([]byte, udpBufferSize)
348+ for {
349+ n, addr, err := conn.ReadFrom(buf)
350+ if err != nil {
351+ return
352+ }
353+ _, _ = conn.WriteTo(append([]byte(prefix), buf[:n]...), addr)
354+ }
355+ }()
356+ t.Cleanup(func() {
357+ _ = conn.Close()
358+ <-done
359+ })
360+ return udpEchoServer{address: conn.LocalAddr().String()}
361+}
M internal/adapters/out/docker/runtime.go +57 -35
@@ -141,42 +141,11 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
141141 })
142142 log := zerowrap.FromCtx(ctx)
143143
144- // Convert ports to Docker format
145- exposedPorts := make(nat.PortSet)
146- portBindings := make(nat.PortMap)
147-
148- for _, port := range config.Ports {
149- containerPort := nat.Port(fmt.Sprintf("%d/tcp", port))
150- exposedPorts[containerPort] = struct{}{}
151-
152- // Bind to random available port on localhost only.
153- // SECURITY: Using 127.0.0.1 prevents direct access from the network,
154- // forcing all traffic through Gordon's reverse proxy where auth,
155- // rate limiting, and security headers are applied.
156- portBindings[containerPort] = []nat.PortBinding{
157- {
158- HostIP: "127.0.0.1",
159- HostPort: "0", // Docker will assign a random available port
160- },
161- }
162- }
163-
164- // Convert volumes to Docker format
165- var binds []string
166- if config.Volumes != nil {
167- for containerPath, volumeName := range config.Volumes {
168- bind := fmt.Sprintf("%s:%s", volumeName, containerPath)
169- binds = append(binds, bind)
170- log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding volume mount")
171- }
172- }
173- if config.ReadOnlyVolumes != nil {
174- for containerPath, volumeName := range config.ReadOnlyVolumes {
175- bind := fmt.Sprintf("%s:%s:ro", volumeName, containerPath)
176- binds = append(binds, bind)
177- log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding read-only volume mount")
178- }
144+ exposedPorts, portBindings, err := buildPortBindings(config)
145+ if err != nil {
146+ return nil, err
179147 }
148+ binds := buildVolumeBinds(config, log)
180149
181150 // Create container configuration
182151 containerConfig := &container.Config{
@@ -250,6 +219,59 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
250219 return r.InspectContainer(ctx, resp.ID)
251220 }
252221
222+func buildPortBindings(config *domain.ContainerConfig) (nat.PortSet, nat.PortMap, error) {
223+ exposedPorts := make(nat.PortSet)
224+ portBindings := make(nat.PortMap)
225+ addLegacyPortBindings(config.Ports, exposedPorts, portBindings)
226+ if err := addExplicitPortBindings(config.PortPublishes, exposedPorts, portBindings); err != nil {
227+ return nil, nil, err
228+ }
229+ return exposedPorts, portBindings, nil
230+}
231+
232+func addLegacyPortBindings(ports []int, exposedPorts nat.PortSet, portBindings nat.PortMap) {
233+ for _, port := range ports {
234+ containerPort := nat.Port(fmt.Sprintf("%d/tcp", port))
235+ exposedPorts[containerPort] = struct{}{}
236+
237+ // Bind to random available port on localhost only.
238+ // SECURITY: Using 127.0.0.1 prevents direct access from the network,
239+ // forcing all traffic through Gordon's reverse proxy where auth,
240+ // rate limiting, and security headers are applied.
241+ portBindings[containerPort] = []nat.PortBinding{{HostIP: "127.0.0.1", HostPort: "0"}}
242+ }
243+}
244+
245+func addExplicitPortBindings(publishes []domain.ContainerPortPublish, exposedPorts nat.PortSet, portBindings nat.PortMap) error {
246+ for _, publish := range publishes {
247+ if publish.Protocol != domain.NetworkProtocolTCP && publish.Protocol != domain.NetworkProtocolUDP {
248+ return fmt.Errorf("unsupported container port publish protocol %q", publish.Protocol)
249+ }
250+ containerPort := nat.Port(fmt.Sprintf("%d/%s", publish.ContainerPort, publish.Protocol))
251+ exposedPorts[containerPort] = struct{}{}
252+ portBindings[containerPort] = append(portBindings[containerPort], nat.PortBinding{
253+ HostIP: publish.HostIP,
254+ HostPort: strconv.Itoa(publish.HostPort),
255+ })
256+ }
257+ return nil
258+}
259+
260+func buildVolumeBinds(config *domain.ContainerConfig, log zerowrap.Logger) []string {
261+ binds := make([]string, 0, len(config.Volumes)+len(config.ReadOnlyVolumes))
262+ for containerPath, volumeName := range config.Volumes {
263+ bind := fmt.Sprintf("%s:%s", volumeName, containerPath)
264+ binds = append(binds, bind)
265+ log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding volume mount")
266+ }
267+ for containerPath, volumeName := range config.ReadOnlyVolumes {
268+ bind := fmt.Sprintf("%s:%s:ro", volumeName, containerPath)
269+ binds = append(binds, bind)
270+ log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding read-only volume mount")
271+ }
272+ return binds
273+}
274+
253275 // StartContainer starts a container.
254276 func (r *Runtime) StartContainer(ctx context.Context, containerID string) error {
255277 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
M internal/adapters/out/docker/runtime_create_test.go +74 -11
@@ -15,7 +15,78 @@ import (
1515 "github.com/bnema/gordon/internal/domain"
1616 )
1717
18+func TestRuntime_CreateContainerPublishesExplicitUDPPort(t *testing.T) {
19+ createBody := createTestContainer(t, &domain.ContainerConfig{
20+ Image: "rust:latest",
21+ Name: "gordon-rust",
22+ PortPublishes: []domain.ContainerPortPublish{
23+ {
24+ HostIP: "127.0.0.1",
25+ HostPort: 38015,
26+ ContainerPort: 28015,
27+ Protocol: domain.NetworkProtocolUDP,
28+ },
29+ },
30+ })
31+
32+ exposedPorts, ok := createBody["ExposedPorts"].(map[string]any)
33+ require.True(t, ok)
34+ assert.Contains(t, exposedPorts, "28015/udp")
35+
36+ hostConfig, ok := createBody["HostConfig"].(map[string]any)
37+ require.True(t, ok)
38+ portBindings, ok := hostConfig["PortBindings"].(map[string]any)
39+ require.True(t, ok)
40+ bindings, ok := portBindings["28015/udp"].([]any)
41+ require.True(t, ok)
42+ require.Len(t, bindings, 1)
43+ binding, ok := bindings[0].(map[string]any)
44+ require.True(t, ok)
45+ assert.Equal(t, "127.0.0.1", binding["HostIp"])
46+ assert.Equal(t, "38015", binding["HostPort"])
47+}
48+
49+func TestRuntime_CreateContainerKeepsLegacyPortsRandomLoopbackTCP(t *testing.T) {
50+ createBody := createTestContainer(t, &domain.ContainerConfig{
51+ Image: "nginx:latest",
52+ Name: "gordon-app.example.com",
53+ Ports: []int{8080},
54+ })
55+
56+ exposedPorts, ok := createBody["ExposedPorts"].(map[string]any)
57+ require.True(t, ok)
58+ assert.Contains(t, exposedPorts, "8080/tcp")
59+
60+ hostConfig, ok := createBody["HostConfig"].(map[string]any)
61+ require.True(t, ok)
62+ portBindings, ok := hostConfig["PortBindings"].(map[string]any)
63+ require.True(t, ok)
64+ bindings, ok := portBindings["8080/tcp"].([]any)
65+ require.True(t, ok)
66+ require.Len(t, bindings, 1)
67+ binding, ok := bindings[0].(map[string]any)
68+ require.True(t, ok)
69+ assert.Equal(t, "127.0.0.1", binding["HostIp"])
70+ assert.Equal(t, "0", binding["HostPort"])
71+}
72+
1873 func TestRuntime_CreateContainerAppliesRestartPolicy(t *testing.T) {
74+ createBody := createTestContainer(t, &domain.ContainerConfig{
75+ Image: "nginx:latest",
76+ Name: "gordon-app.example.com",
77+ RestartPolicy: domain.RestartPolicyAlways,
78+ })
79+
80+ hostConfig, ok := createBody["HostConfig"].(map[string]any)
81+ require.True(t, ok)
82+ restartPolicy, ok := hostConfig["RestartPolicy"].(map[string]any)
83+ require.True(t, ok)
84+ assert.Equal(t, domain.RestartPolicyAlways, restartPolicy["Name"])
85+}
86+
87+func createTestContainer(t *testing.T, config *domain.ContainerConfig) map[string]any {
88+ t.Helper()
89+
1990 var createBody map[string]any
2091 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2192 switch {
@@ -45,18 +116,10 @@ func TestRuntime_CreateContainerAppliesRestartPolicy(t *testing.T) {
45116 require.NoError(t, err)
46117
47118 runtime := NewRuntimeWithClient(cli)
48- created, err := runtime.CreateContainer(context.Background(), &domain.ContainerConfig{
49- Image: "nginx:latest",
50- Name: "gordon-app.example.com",
51- RestartPolicy: domain.RestartPolicyAlways,
52- })
119+ created, err := runtime.CreateContainer(context.Background(), config)
53120 require.NoError(t, err)
54121 assert.Equal(t, "abc123", created.ID)
55-
56122 require.NotNil(t, createBody)
57- hostConfig, ok := createBody["HostConfig"].(map[string]any)
58- require.True(t, ok)
59- restartPolicy, ok := hostConfig["RestartPolicy"].(map[string]any)
60- require.True(t, ok)
61- assert.Equal(t, domain.RestartPolicyAlways, restartPolicy["Name"])
123+
124+ return createBody
62125 }
M internal/app/public_tls_wiring_test.go +154 -0
@@ -11,6 +11,7 @@ import (
1111 "testing"
1212 "time"
1313
14+ "github.com/spf13/viper"
1415 "github.com/stretchr/testify/assert"
1516 "github.com/stretchr/testify/mock"
1617 "github.com/stretchr/testify/require"
@@ -19,10 +20,163 @@ import (
1920 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
2021 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
2122 "github.com/bnema/gordon/internal/domain"
23+ config "github.com/bnema/gordon/internal/usecase/config"
2224 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
25+ "github.com/bnema/gordon/internal/usecase/publictls"
26+ traffic "github.com/bnema/gordon/internal/usecase/traffic"
2327 "github.com/bnema/zerowrap"
2428 )
2529
30+func TestPublicTLSReadinessAllowsDNS01WithoutEdgePort(t *testing.T) {
31+ cfg := Config{}
32+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeCloudflareDNS01)
33+
34+ require.NoError(t, validatePublicTLSReadiness(cfg))
35+ assert.Equal(t, 0, effectivePublicTLSPort(cfg))
36+}
37+
38+func TestPublicTLSReadinessRejectsAutoResolvedHTTP01WithoutBoundExternalPort80(t *testing.T) {
39+ cfg := Config{}
40+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeAuto)
41+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
42+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
43+ }
44+ effective := publictls.EffectiveChallenge{
45+ ConfiguredMode: domain.ACMEChallengeAuto,
46+ Mode: domain.ACMEChallengeHTTP01,
47+ TokenSource: domain.ACMETokenSourceNone,
48+ Reason: "auto selected http-01 (no cloudflare token)",
49+ }
50+
51+ err := validateEffectivePublicTLSReadiness(cfg, effective)
52+ require.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
53+}
54+
55+func TestEffectivePublicTLSPortPrefersTLSCapableEntrypointOverLegacyTLSPort(t *testing.T) {
56+ cfg := Config{}
57+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeCloudflareDNS01)
58+ cfg.Server.TLSPort = 8443
59+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
60+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
61+ }
62+
63+ assert.Equal(t, 443, effectivePublicTLSPort(cfg))
64+}
65+
66+func TestEffectivePublicTLSPortPrefersDefaultEdgeWithMultipleTLSCapableEntrypoints(t *testing.T) {
67+ cfg := Config{}
68+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
69+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
70+ "public": {Address: ":8443", Protocol: domain.EntryPointProtocolTLSMux},
71+ }
72+
73+ for range 20 {
74+ assert.Equal(t, 443, effectivePublicTLSPort(cfg))
75+ }
76+}
77+
78+func TestEffectivePublicTLSPortUsesSoleNonEdgeTLSCapableEntrypoint(t *testing.T) {
79+ cfg := Config{}
80+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
81+ "public": {Address: ":8443", Protocol: domain.EntryPointProtocolTLSMux},
82+ "ssh": {Address: ":22", Protocol: domain.EntryPointProtocolTCP},
83+ }
84+
85+ assert.Equal(t, 8443, effectivePublicTLSPort(cfg))
86+}
87+
88+func TestEffectiveProxyPortsIgnoreLegacyServerPortsWithoutEntrypoints(t *testing.T) {
89+ cfg := Config{}
90+ cfg.Server.Port = 8080
91+ cfg.Server.TLSPort = 8443
92+
93+ assert.Equal(t, 0, effectiveProxyHTTPPort(cfg))
94+ assert.Equal(t, 0, effectiveProxyTLSPort(cfg))
95+}
96+
97+func TestEffectivePublicTLSPortReturnsZeroWhenAmbiguousWithoutEdge(t *testing.T) {
98+ cfg := Config{}
99+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
100+ "public-a": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
101+ "public-b": {Address: ":8443", Protocol: domain.EntryPointProtocolTLSMux},
102+ }
103+
104+ for range 20 {
105+ assert.Equal(t, 0, effectivePublicTLSPort(cfg))
106+ }
107+}
108+
109+func TestPublicTLSReadinessRequiresHTTP01ActuallyBoundExternalPort80(t *testing.T) {
110+ t.Run("rejects http entrypoint when smart tcp suppresses standalone http", func(t *testing.T) {
111+ cfg := Config{}
112+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeHTTP01)
113+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
114+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
115+ "web": {Address: ":80", Protocol: domain.EntryPointProtocolHTTP},
116+ }
117+
118+ err := validatePublicTLSReadiness(cfg)
119+ require.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
120+ })
121+
122+ t.Run("allows smart tcp entrypoint on port 80", func(t *testing.T) {
123+ cfg := Config{}
124+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeHTTP01)
125+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
126+ traffic.DefaultEdgeEntryPointName: {Address: ":80", Protocol: domain.EntryPointProtocolSmartTCP},
127+ }
128+
129+ require.NoError(t, validatePublicTLSReadiness(cfg))
130+ assert.Equal(t, 80, effectiveHTTP01Port(cfg))
131+ })
132+
133+ t.Run("rejects legacy standalone http port 80 without explicit entrypoint", func(t *testing.T) {
134+ cfg := Config{}
135+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeHTTP01)
136+ cfg.Server.Port = 80
137+
138+ err := validatePublicTLSReadiness(cfg)
139+ require.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
140+ assert.Equal(t, 0, effectiveHTTP01Port(cfg))
141+ })
142+
143+ t.Run("rejects legacy port 80 when smart tcp suppresses standalone http", func(t *testing.T) {
144+ cfg := Config{}
145+ cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeHTTP01)
146+ cfg.Server.Port = 80
147+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
148+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
149+ }
150+
151+ err := validatePublicTLSReadiness(cfg)
152+ require.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
153+ })
154+}
155+
156+func TestPKIInitializesForSmartTCPEvenWithoutLegacyTLSPort(t *testing.T) {
157+ si := &serviceInit{
158+ ctx: context.Background(),
159+ log: zerowrap.Default(),
160+ svc: &services{configSvc: cfgServiceForPKITest(t)},
161+ }
162+ si.cfg.Server.DataDir = t.TempDir()
163+ si.cfg.EntryPoints = map[string]traffic.EntryPointConfig{
164+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
165+ }
166+
167+ require.NoError(t, si.initPKI())
168+ require.NotNil(t, si.svc.pkiSvc)
169+ si.svc.pkiSvc.Stop()
170+}
171+
172+func cfgServiceForPKITest(t *testing.T) *config.Service {
173+ t.Helper()
174+ v := viper.New()
175+ svc := config.NewService(v, nil)
176+ require.NoError(t, svc.Load(context.Background()))
177+ return svc
178+}
179+
26180 // generateTestCert creates a self-signed certificate for the given hostnames.
27181 func generateTestCert(t *testing.T, hosts ...string) tls.Certificate {
28182 t.Helper()
M internal/app/run.go +403 -131
@@ -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"
@@ -84,6 +85,8 @@ import (
8485 "github.com/bnema/gordon/internal/usecase/publictls"
8586 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
8687 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
88+ servicecfg "github.com/bnema/gordon/internal/usecase/services"
89+ "github.com/bnema/gordon/internal/usecase/traffic"
8790 volumesSvc "github.com/bnema/gordon/internal/usecase/volumes"
8891
8992 // Pkg
@@ -174,6 +177,11 @@ type Config struct {
174177 } `mapstructure:"rate_limit"`
175178 } `mapstructure:"api"`
176179
180+ EntryPoints map[string]traffic.EntryPointConfig `mapstructure:"entrypoints"`
181+ Traffic traffic.Config `mapstructure:"traffic"`
182+ NetworkServices []traffic.NetworkServiceConfig `mapstructure:"network_services"`
183+ Services []servicecfg.Config `mapstructure:"services"`
184+
177185 Backups struct {
178186 // Legacy database backup keys. Prefer backups.databases.* for new configs.
179187 Enabled bool `mapstructure:"enabled"`
@@ -255,43 +263,50 @@ type Config struct {
255263
256264 // services holds all the services used by the application.
257265 type services struct {
258- runtime *docker.Runtime
259- eventBus *eventbus.InMemory
260- blobStorage *filesystem.BlobStorage
261- manifestStorage *filesystem.ManifestStorage
262- backupStorage *filesystem.BackupStorage
263- volumeBackupStore out.VolumeBackupStorage
264- volumeBackupCfg domain.VolumeBackupConfig
265- envLoader out.EnvLoader
266- logWriter *logwriter.LogWriter
267- tokenStore out.TokenStore
268- configSvc *config.Service
269- secretSvc *secretsSvc.Service
270- containerSvc *container.Service
271- backupSvc *backup.Service
272- volumeBackupSvc *backup.VolumeService
273- registrySvc *registrySvc.Service
274- healthSvc *health.Service
275- logSvc *logs.Service
276- imageSvc *images.Service
277- volumeSvc *volumesSvc.Service
278- proxySvc *proxy.Service
279- authSvc *auth.Service
280- authHandler *authhandler.Handler
281- adminHandler *admin.Handler
282- internalRegUser string
283- internalRegPass string
284- previewStore *filesystem.PreviewStore
285- previewService *preview.Service
286- envDir string
287- maxBlobChunkSize int64
288- maxBlobSize int64
289- caAdapter *pkiadapter.CA
290- pkiSvc *pkiusecase.Service
291- reloadCoordinator *reloadCoordinator
292- publicTLSSvc in.PublicTLSService
293- publicTLSRuntime publicTLSRuntime
294- registryHandler interface {
266+ runtime *docker.Runtime
267+ eventBus *eventbus.InMemory
268+ blobStorage *filesystem.BlobStorage
269+ manifestStorage *filesystem.ManifestStorage
270+ backupStorage *filesystem.BackupStorage
271+ volumeBackupStore out.VolumeBackupStorage
272+ volumeBackupCfg domain.VolumeBackupConfig
273+ envLoader out.EnvLoader
274+ logWriter *logwriter.LogWriter
275+ tokenStore out.TokenStore
276+ configSvc *config.Service
277+ secretSvc *secretsSvc.Service
278+ containerSvc *container.Service
279+ backupSvc *backup.Service
280+ volumeBackupSvc *backup.VolumeService
281+ registrySvc *registrySvc.Service
282+ healthSvc *health.Service
283+ logSvc *logs.Service
284+ imageSvc *images.Service
285+ volumeSvc *volumesSvc.Service
286+ proxySvc *proxy.Service
287+ standaloneServiceSvc in.StandaloneServiceService
288+ serviceSecretProvider out.SecretProvider
289+ authSvc *auth.Service
290+ authHandler *authhandler.Handler
291+ adminHandler *admin.Handler
292+ httpProxyHandler http.Handler
293+ httpsProxyHandler http.Handler
294+ internalRegUser string
295+ internalRegPass string
296+ previewStore *filesystem.PreviewStore
297+ previewService *preview.Service
298+ envDir string
299+ maxBlobChunkSize int64
300+ maxBlobSize int64
301+ caAdapter *pkiadapter.CA
302+ pkiSvc *pkiusecase.Service
303+ reloadCoordinator *reloadCoordinator
304+ publicTLSSvc in.PublicTLSService
305+ publicTLSRuntime publicTLSRuntime
306+ trafficManager *trafficadapter.Manager
307+ tlsHTTPEntryPoints map[string]struct{}
308+ smartHTTPEntryPoints map[string]struct{}
309+ registryHandler interface {
295310 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
296311 }
297312 }
@@ -553,7 +568,7 @@ func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config,
553568 return nil, err
554569 }
555570
556- if err := si.initRuntimeAndProxy(); err != nil {
571+ if err := si.initRuntimeProxyAndTraffic(); err != nil {
557572 return nil, err
558573 }
559574
@@ -567,8 +582,8 @@ func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config,
567582
568583 // initPKI initialises the internal CA and PKI service when TLS is enabled.
569584 func (si *serviceInit) initPKI() error {
570- if si.cfg.Server.TLSPort == 0 {
571- si.log.Info().Msg("internal CA disabled (server.tls_port=0)")
585+ if !hasTLSCapableEntrypoint(si.cfg) {
586+ si.log.Info().Msg("internal CA disabled (no TLS-capable entrypoint configured)")
572587 return nil
573588 }
574589
@@ -591,8 +606,8 @@ func (si *serviceInit) initPublicTLS() error {
591606 return nil
592607 }
593608
594- if si.cfg.Server.TLSPort == 0 {
595- return fmt.Errorf("%w: tls.acme.enabled requires server.tls_port > 0", domain.ErrACMEChallengeInvalid)
609+ if err := validatePublicTLSReadiness(si.cfg); err != nil {
610+ return err
596611 }
597612
598613 ctx := si.ctx
@@ -607,8 +622,8 @@ func (si *serviceInit) initPublicTLS() error {
607622 Enabled: si.cfg.TLS.ACME.Enabled,
608623 Email: si.cfg.TLS.ACME.Email,
609624 Challenge: si.cfg.TLS.ACME.Challenge,
610- HTTPPort: si.cfg.Server.Port,
611- TLSPort: si.cfg.Server.TLSPort,
625+ HTTPPort: effectiveHTTP01Port(si.cfg),
626+ TLSPort: effectivePublicTLSPort(si.cfg),
612627 DataDir: resolveDataDir(si.cfg.Server.DataDir),
613628 ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize,
614629 DNS: dnsCfg,
@@ -620,6 +635,9 @@ func (si *serviceInit) initPublicTLS() error {
620635 if err != nil {
621636 return log.WrapErr(err, "resolve ACME challenge")
622637 }
638+ if err := validateEffectivePublicTLSReadiness(si.cfg, effective); err != nil {
639+ return err
640+ }
623641
624642 store, err := acmestore.New(filepath.Join(resolveDataDir(si.cfg.Server.DataDir), "acme"))
625643 if err != nil {
@@ -700,11 +718,20 @@ func (si *serviceInit) initSecrets() error {
700718 return err
701719 }
702720
721+ si.svc.serviceSecretProvider = createStandaloneServiceSecretProvider(backend, resolveDataDir(si.cfg.Server.DataDir), si.log)
703722 si.svc.secretSvc = secretsSvc.NewService(domainSecretStore, si.log, si.svc.eventBus)
704723 return nil
705724 }
706725
707726 // initRuntimeAndProxy creates container, backup, registry, image, volume, and proxy services.
727+func (si *serviceInit) initRuntimeProxyAndTraffic() error {
728+ if err := si.initRuntimeAndProxy(); err != nil {
729+ return err
730+ }
731+ si.svc.trafficManager = trafficadapter.NewManager()
732+ return nil
733+}
734+
708735 func (si *serviceInit) initRuntimeAndProxy() error {
709736 var err error
710737
@@ -732,6 +759,7 @@ func (si *serviceInit) initRuntimeAndProxy() error {
732759 si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
733760 si.svc.maxBlobSize = proxyCfg.maxBlobSize
734761 si.svc.proxySvc = proxy.NewService(si.svc.runtime, si.svc.containerSvc, si.svc.configSvc, proxyCfg.proxyConfig)
762+ si.svc.standaloneServiceSvc = servicecfg.NewServiceWithSecretProvider(si.svc.runtime, si.svc.serviceSecretProvider)
735763
736764 // Wire synchronous proxy cache invalidation for zero-downtime deployments.
737765 // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget().
@@ -751,6 +779,22 @@ func (si *serviceInit) registerReloadCoordinatorHooks() {
751779 if err != nil {
752780 return err
753781 }
782+ var tlsConfig *tls.Config
783+ if hasTLSCapableEntrypoint(reloadCfg) && si.svc.httpsProxyHandler != nil {
784+ var tlsErr error
785+ tlsConfig, tlsErr = proxyTLSConfig(reloadCfg, si.svc.pkiSvc, si.svc.publicTLSSvc, si.log)
786+ if tlsErr != nil {
787+ return tlsErr
788+ }
789+ }
790+ if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil {
791+ return err
792+ }
793+ si.svc.tlsHTTPEntryPoints = registerTLSMuxHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpsProxyHandler, tlsConfig, si.svc.tlsHTTPEntryPoints)
794+ si.svc.smartHTTPEntryPoints = registerSmartTCPHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpProxyHandler, si.svc.httpsProxyHandler, tlsConfig, si.svc.smartHTTPEntryPoints)
795+ if err := reconcileStandaloneServices(reloadCtx, si.svc.standaloneServiceSvc, reloadCfg); err != nil {
796+ return err
797+ }
754798 si.svc.containerSvc.UpdateConfig(containerCfg)
755799 return nil
756800 })
@@ -771,6 +815,9 @@ func (si *serviceInit) initHandlers() {
771815 si.svc.logSvc = logs.NewService(resolveLogFilePath(si.cfg), si.cfg.Logging.File.Enabled, si.svc.containerSvc, si.svc.runtime, si.log)
772816
773817 initPreviewService(si.ctx, si.cfg, si.svc, si.log)
818+ if si.svc.trafficManager == nil {
819+ si.svc.trafficManager = trafficadapter.NewManager()
820+ }
774821
775822 si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{
776823 ConfigSvc: si.svc.configSvc,
@@ -788,6 +835,7 @@ func (si *serviceInit) initHandlers() {
788835 ImageSvc: si.svc.imageSvc,
789836 VolumeSvc: si.svc.volumeSvc,
790837 PublicTLSSvc: si.svc.publicTLSSvc,
838+ TrafficSvc: si.svc.trafficManager,
791839 })
792840 }
793841
@@ -1616,6 +1664,31 @@ func readFileBeneath(root, cleanedRelPath string) ([]byte, error) {
16161664 return nil, fmt.Errorf("invalid secret path: empty path")
16171665 }
16181666
1667+type unsafeSecretProvider struct {
1668+ dataDir string
1669+}
1670+
1671+func (p unsafeSecretProvider) Name() string { return string(domain.SecretsBackendUnsafe) }
1672+
1673+func (p unsafeSecretProvider) IsAvailable() bool { return true }
1674+
1675+func (p unsafeSecretProvider) GetSecret(_ context.Context, path string) (string, error) {
1676+ return readUnsafeSecret(p.dataDir, path)
1677+}
1678+
1679+func createStandaloneServiceSecretProvider(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) out.SecretProvider {
1680+ switch backend {
1681+ case domain.SecretsBackendPass:
1682+ return secrets.NewPassProvider(log)
1683+ case domain.SecretsBackendSops:
1684+ return secrets.NewSopsProvider(log)
1685+ case domain.SecretsBackendUnsafe:
1686+ return unsafeSecretProvider{dataDir: dataDir}
1687+ default:
1688+ return nil
1689+ }
1690+}
1691+
16191692 func readUnsafeSecret(dataDir, secretPath string) (string, error) {
16201693 if filepath.IsAbs(secretPath) {
16211694 return "", fmt.Errorf("invalid secret path: absolute paths are not allowed")
@@ -2342,7 +2415,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
23422415 middleware.PanicRecovery(log),
23432416 middleware.RequestLogger(log, trustedNets),
23442417 middleware.SecurityHeaders,
2345- middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.Port, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log, func(host string) bool {
2418+ middleware.HTTPSRedirect(proxyAllowedNets, effectiveProxyHTTPPort(cfg), effectiveProxyTLSPort(cfg), cfg.Server.ForceHTTPSRedirect, log, func(host string) bool {
23462419 return svc.proxySvc.IsKnownHost(context.Background(), host)
23472420 }),
23482421 }
@@ -2357,7 +2430,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
23572430
23582431 // Build the onboarding handler once if internal CA is available and TLS is enabled.
23592432 var obHandler *onboarding.Handler
2360- if svc.caAdapter != nil && cfg.Server.TLSPort != 0 {
2433+ if svc.caAdapter != nil && effectiveProxyTLSPort(cfg) != 0 {
23612434 mobileconfigBytes := pkiadapter.GenerateMobileconfig(
23622435 svc.caAdapter.RootCertificateDER(),
23632436 svc.caAdapter.RootCommonName(),
@@ -2366,8 +2439,8 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
23662439 svc.caAdapter.RootCertificate(),
23672440 mobileconfigBytes,
23682441 svc.caAdapter.RootFingerprint(),
2369- cfg.Server.Port,
2370- cfg.Server.TLSPort,
2442+ effectiveProxyHTTPPort(cfg),
2443+ effectiveProxyTLSPort(cfg),
23712444 )
23722445 }
23732446
@@ -2433,6 +2506,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
24332506 proxyOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(proxyOut)
24342507 httpsOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(httpsOut)
24352508 }
2509+ svc.httpsProxyHandler = httpsOut
24362510
24372511 return registryOut, proxyOut, httpsOut
24382512 }
@@ -2780,6 +2854,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
27802854 errChan := make(chan error, 3)
27812855
27822856 registryHandler, httpProxyHandler, httpsProxyHandler := createHTTPHandlers(svc, cfg, log, accessWriter)
2857+ svc.httpProxyHandler = httpProxyHandler
2858+ svc.httpsProxyHandler = httpsProxyHandler
27832859
27842860 registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort))
27852861 registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log)
@@ -2798,33 +2874,25 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
27982874 }
27992875 }
28002876
2801- proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, svc.publicTLSSvc, errChan, log)
2877+ proxySrv, proxyReady, tlsSrv, _, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, log)
28022878 if err != nil {
28032879 closeStarted(registrySrv)
28042880 return err
28052881 }
2882+ svc.tlsHTTPEntryPoints = tlsMuxHTTPServerNames(cfg)
2883+ svc.smartHTTPEntryPoints = smartTCPHTTPServerNames(cfg)
28062884
2807- // Wait for all servers to bind their ports before auto-starting containers.
2808- // This prevents the race where auto-start pulls from the registry before it's listening.
2809- if err := waitForServerReady(registryReady, errChan); err != nil {
2810- closeStarted(registrySrv, proxySrv, tlsSrv)
2811- return err
2812- }
2813- if err := waitForServerReady(proxyReady, errChan); err != nil {
2885+ // Wait for the registry and HTTP proxy to bind before applying the traffic graph.
2886+ // This prevents auto-start races while keeping the TLS mux under one owner.
2887+ if err := waitForCoreProxyReadyAndApplyTraffic(ctx, cfg, svc, registryReady, proxyReady, errChan); err != nil {
28142888 closeStarted(registrySrv, proxySrv, tlsSrv)
28152889 return err
28162890 }
2817- if tlsReady != nil {
2818- if err := waitForServerReady(tlsReady, errChan); err != nil {
2819- closeStarted(registrySrv, proxySrv, tlsSrv)
2820- return err
2821- }
2822- }
28232891
28242892 logEvent := log.Info().
28252893 Int("proxy_port", cfg.Server.Port).
28262894 Int("registry_port", cfg.Server.RegistryPort)
2827- if tlsSrv != nil {
2895+ if cfg.Server.TLSPort != 0 {
28282896 logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort)
28292897 }
28302898 logEvent.Msg("Gordon is running")
@@ -2833,6 +2901,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
28332901
28342902 schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)
28352903 if err != nil {
2904+ shutdownTrafficManagerForStartupCleanup(svc.trafficManager, log)
2905+ closeStarted(registrySrv, proxySrv, tlsSrv)
28362906 return err
28372907 }
28382908 if schedulerCleanup != nil {
@@ -2844,7 +2914,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
28442914
28452915 waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log)
28462916 cleanupHandlers() // Stop debounce timers before draining containers
2847- gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, log)
2917+ gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, log)
28482918 return nil
28492919 }
28502920
@@ -2854,7 +2924,48 @@ func startPublicTLSRuntimeWithWarning(ctx context.Context, svc publicTLSRuntime,
28542924 }
28552925 }
28562926
2927+func waitForCoreProxyReadyAndApplyTraffic(ctx context.Context, cfg Config, svc *services, registryReady <-chan struct{}, proxyReady <-chan struct{}, errChan <-chan error) error {
2928+ if err := waitForServerReady(registryReady, errChan); err != nil {
2929+ return err
2930+ }
2931+ if err := waitForServerReady(proxyReady, errChan); err != nil {
2932+ return err
2933+ }
2934+ if err := applyTrafficRuntimeConfig(ctx, svc.trafficManager, cfg, svc.configSvc); err != nil {
2935+ return err
2936+ }
2937+ return reconcileStandaloneServices(ctx, svc.standaloneServiceSvc, cfg)
2938+}
2939+
2940+func reconcileStandaloneServices(ctx context.Context, serviceSvc in.StandaloneServiceService, cfg Config) error {
2941+ if serviceSvc == nil {
2942+ return nil
2943+ }
2944+ standaloneServices, err := servicecfg.ToDomain(cfg.Services)
2945+ if err != nil {
2946+ return fmt.Errorf("convert standalone service config: %w", err)
2947+ }
2948+ if err := serviceSvc.Reconcile(ctx, standaloneServices); err != nil {
2949+ return fmt.Errorf("reconcile standalone services: %w", err)
2950+ }
2951+ return nil
2952+}
2953+
2954+func shutdownTrafficManagerForStartupCleanup(manager *trafficadapter.Manager, log zerowrap.Logger) {
2955+ if manager == nil {
2956+ return
2957+ }
2958+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
2959+ defer shutdownCancel()
2960+ if err := manager.Shutdown(shutdownCtx); err != nil {
2961+ log.Error().Err(err).Msg("failed to shut down traffic manager during startup cleanup")
2962+ }
2963+}
2964+
28572965 func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
2966+ if ready == nil {
2967+ return nil
2968+ }
28582969 select {
28592970 case <-ready:
28602971 return nil
@@ -3096,7 +3207,7 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl
30963207
30973208 // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
30983209 // the container service and cleans up runtime files.
3099-func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, log zerowrap.Logger) {
3210+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) {
31003211 log.Info().Msg("shutting down Gordon...")
31013212
31023213 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -3112,6 +3223,12 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *
31123223 }
31133224 }
31143225
3226+ if trafficManager != nil {
3227+ if err := trafficManager.Shutdown(shutdownCtx); err != nil {
3228+ log.Warn().Err(err).Msg("traffic manager shutdown error")
3229+ }
3230+ }
3231+
31153232 // Stop PKI maintenance goroutines
31163233 if pkiSvc != nil {
31173234 pkiSvc.Stop()
@@ -3234,31 +3351,170 @@ func matchingStaticCert(certs []tls.Certificate, serverName string) *tls.Certifi
32343351 return matchingPreparedStaticCert(prepareStaticTLSCertificates(certs), serverName)
32353352 }
32363353
3237-func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {
3238- // HTTP listener (Cloudflare proxy + onboarding for direct clients)
3239- var httpProtos http.Protocols
3240- httpProtos.SetHTTP1(true)
3241- httpProtos.SetUnencryptedHTTP2(true)
3242- httpSrv, httpReady := startServer(
3243- fmt.Sprintf(":%d", cfg.Server.Port),
3244- httpHandler,
3245- "proxy-http",
3246- &httpProtos,
3247- errChan,
3248- log,
3249- )
3354+func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, trafficManager *trafficadapter.Manager, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {
3355+ var httpSrv *http.Server
3356+ var httpReady <-chan struct{}
32503357
3251- if cfg.Server.TLSPort == 0 {
3358+ needsTLS := hasTLSCapableEntrypoint(cfg)
3359+ if !hasSmartTCPEntrypoint(cfg) && !needsTLS {
32523360 return httpSrv, httpReady, nil, nil, nil
32533361 }
3362+ cleanupHTTP := func(err error) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {
3363+ return httpSrv, httpReady, nil, nil, err
3364+ }
3365+
3366+ var tlsConfig *tls.Config
3367+ if needsTLS {
3368+ var err error
3369+ tlsConfig, err = proxyTLSConfig(cfg, pkiSvc, publicTLS, log)
3370+ if err != nil {
3371+ return cleanupHTTP(err)
3372+ }
3373+ }
3374+ if trafficManager == nil {
3375+ return cleanupHTTP(fmt.Errorf("traffic manager is required when traffic entrypoints are enabled"))
3376+ }
3377+ registerTLSMuxHTTPServers(trafficManager, cfg, httpsHandler, tlsConfig, nil)
3378+ registerSmartTCPHTTPServers(trafficManager, cfg, httpHandler, httpsHandler, tlsConfig, nil)
3379+
3380+ return httpSrv, httpReady, nil, nil, nil
3381+}
3382+
3383+func hasSmartTCPEntrypoint(cfg Config) bool {
3384+ for _, entryPoint := range cfg.EntryPoints {
3385+ if entryPoint.Protocol == domain.EntryPointProtocolSmartTCP {
3386+ return true
3387+ }
3388+ }
3389+ return false
3390+}
3391+
3392+func hasTLSCapableEntrypoint(cfg Config) bool {
3393+ for _, entryPoint := range cfg.EntryPoints {
3394+ switch entryPoint.Protocol {
3395+ case domain.EntryPointProtocolSmartTCP, domain.EntryPointProtocolTLSMux:
3396+ return true
3397+ }
3398+ }
3399+ return false
3400+}
3401+
3402+func effectivePublicTLSPort(cfg Config) int {
3403+ return effectiveEntrypointPort(cfg, tlsCapableEntryPoint)
3404+}
3405+
3406+func effectiveProxyTLSPort(cfg Config) int {
3407+ return effectivePublicTLSPort(cfg)
3408+}
3409+
3410+func effectiveProxyHTTPPort(cfg Config) int {
3411+ return effectiveEntrypointPort(cfg, func(protocol domain.EntryPointProtocol) bool {
3412+ return protocol == domain.EntryPointProtocolSmartTCP
3413+ })
3414+}
3415+
3416+func effectiveEntrypointPort(cfg Config, match func(domain.EntryPointProtocol) bool) int {
3417+ if entryPoint, ok := cfg.EntryPoints[traffic.DefaultEdgeEntryPointName]; ok && match(entryPoint.Protocol) {
3418+ if port := portFromAddress(entryPoint.Address); port > 0 {
3419+ return port
3420+ }
3421+ }
3422+
3423+ var candidatePort int
3424+ candidates := 0
3425+ for name, entryPoint := range cfg.EntryPoints {
3426+ if name == traffic.DefaultEdgeEntryPointName || !match(entryPoint.Protocol) {
3427+ continue
3428+ }
3429+ port := portFromAddress(entryPoint.Address)
3430+ if port == 0 {
3431+ continue
3432+ }
3433+ candidatePort = port
3434+ candidates++
3435+ }
3436+ if candidates == 1 {
3437+ return candidatePort
3438+ }
3439+ return 0
3440+}
3441+
3442+func tlsCapableEntryPoint(protocol domain.EntryPointProtocol) bool {
3443+ switch protocol {
3444+ case domain.EntryPointProtocolSmartTCP, domain.EntryPointProtocolTLSMux:
3445+ return true
3446+ default:
3447+ return false
3448+ }
3449+}
3450+
3451+func effectiveHTTP01Port(cfg Config) int {
3452+ if hasSmartTCPHTTP01Entrypoint(cfg) {
3453+ return 80
3454+ }
3455+ return 0
3456+}
3457+
3458+func validatePublicTLSReadiness(cfg Config) error {
3459+ mode, err := domain.ParseACMEChallengeMode(cfg.TLS.ACME.Challenge)
3460+ if err != nil {
3461+ return err
3462+ }
3463+ switch mode {
3464+ case domain.ACMEChallengeCloudflareDNS01, domain.ACMEChallengeAuto:
3465+ return nil
3466+ case domain.ACMEChallengeHTTP01:
3467+ return validateHTTP01ChallengeReadiness(cfg)
3468+ default:
3469+ return fmt.Errorf("%w: %q", domain.ErrACMEChallengeInvalid, mode)
3470+ }
3471+}
3472+
3473+func validateEffectivePublicTLSReadiness(cfg Config, effective publictls.EffectiveChallenge) error {
3474+ if effective.Mode != domain.ACMEChallengeHTTP01 {
3475+ return nil
3476+ }
3477+ return validateHTTP01ChallengeReadiness(cfg)
3478+}
3479+
3480+func validateHTTP01ChallengeReadiness(cfg Config) error {
3481+ if hasBoundHTTP01ChallengeListener(cfg) {
3482+ return nil
3483+ }
3484+ return fmt.Errorf("%w: http-01 requires an actually bound HTTP-01 challenge listener on external :80", domain.ErrACMEChallengeInvalid)
3485+}
3486+
3487+func hasBoundHTTP01ChallengeListener(cfg Config) bool {
3488+ return hasSmartTCPHTTP01Entrypoint(cfg)
3489+}
3490+
3491+func hasSmartTCPHTTP01Entrypoint(cfg Config) bool {
3492+ for _, entryPoint := range cfg.EntryPoints {
3493+ if entryPoint.Protocol == domain.EntryPointProtocolSmartTCP && portFromAddress(entryPoint.Address) == 80 {
3494+ return true
3495+ }
3496+ }
3497+ return false
3498+}
3499+
3500+func portFromAddress(address string) int {
3501+ _, portText, err := net.SplitHostPort(address)
3502+ if err != nil {
3503+ return 0
3504+ }
3505+ port, err := strconv.Atoi(portText)
3506+ if err != nil {
3507+ return 0
3508+ }
3509+ return port
3510+}
32543511
3255- // Load static cert into a local slice (not tls.Config.Certificates) so the
3256- // certificate selector has full control over priority ordering.
3512+func proxyTLSConfig(cfg Config, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, log zerowrap.Logger) (*tls.Config, error) {
32573513 var staticCerts []tls.Certificate
32583514 if cfg.Server.TLSCertFile != "" {
32593515 staticCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile)
32603516 if err != nil {
3261- return nil, nil, nil, nil, fmt.Errorf("load TLS keypair: %w", err)
3517+ return nil, fmt.Errorf("load TLS keypair: %w", err)
32623518 }
32633519 staticCerts = []tls.Certificate{staticCert}
32643520 log.Info().
@@ -3266,30 +3522,81 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv
32663522 Str("key", cfg.Server.TLSKeyFile).
32673523 Msg("loaded static TLS certificate (public ACME and internal CA handle remaining domains)")
32683524 }
3269-
32703525 selector := &certificateSelector{
32713526 staticCerts: prepareStaticTLSCertificates(staticCerts),
32723527 publicTLS: publicTLS,
32733528 localPKI: pkiSvc,
32743529 }
3275-
3276- // HTTPS listener — static cert (if provided) takes priority by SNI match via
3277- // certificateSelector, then public ACME TLS, then on-demand internal CA certs.
3278- tlsConfig := &tls.Config{
3530+ return &tls.Config{
32793531 MinVersion: tls.VersionTLS12,
32803532 GetCertificate: selector.GetCertificate,
32813533 NextProtos: []string{"h2", "http/1.1"},
3534+ }, nil
3535+}
3536+
3537+func registerSmartTCPHTTPServers(manager *trafficadapter.Manager, cfg Config, httpHandler, httpsHandler http.Handler, tlsConfig *tls.Config, previous map[string]struct{}) map[string]struct{} {
3538+ if manager == nil {
3539+ return previous
32823540 }
3283- tlsSrv, tlsReady := startTLSServerWithConfig(
3284- fmt.Sprintf(":%d", cfg.Server.TLSPort),
3285- httpsHandler,
3286- "proxy-tls",
3287- tlsConfig,
3288- errChan,
3289- log,
3290- )
3541+ next := smartTCPHTTPServerNames(cfg)
3542+ for name := range previous {
3543+ if _, ok := next[name]; !ok {
3544+ manager.SetSmartTCPHTTPServer(name, nil, nil)
3545+ manager.SetSmartTCPTLSServer(name, nil, nil)
3546+ }
3547+ }
3548+ var httpProtos http.Protocols
3549+ httpProtos.SetHTTP1(true)
3550+ httpProtos.SetUnencryptedHTTP2(true)
3551+ for name := range next {
3552+ if httpHandler != nil {
3553+ manager.SetSmartTCPHTTPServer(name, httpHandler, &httpProtos)
3554+ }
3555+ if httpsHandler != nil && tlsConfig != nil {
3556+ manager.SetSmartTCPTLSServer(name, httpsHandler, tlsConfig)
3557+ }
3558+ }
3559+ return next
3560+}
3561+
3562+func smartTCPHTTPServerNames(cfg Config) map[string]struct{} {
3563+ names := map[string]struct{}{}
3564+ for name, entryPoint := range cfg.EntryPoints {
3565+ if entryPoint.Protocol == domain.EntryPointProtocolSmartTCP {
3566+ names[name] = struct{}{}
3567+ }
3568+ }
3569+ return names
3570+}
32913571
3292- return httpSrv, httpReady, tlsSrv, tlsReady, nil
3572+func registerTLSMuxHTTPServers(manager *trafficadapter.Manager, cfg Config, httpsHandler http.Handler, tlsConfig *tls.Config, previous map[string]struct{}) map[string]struct{} {
3573+ if manager == nil {
3574+ return previous
3575+ }
3576+ next := tlsMuxHTTPServerNames(cfg)
3577+ for name := range previous {
3578+ if _, ok := next[name]; !ok {
3579+ manager.SetTLSHTTPServer(name, nil, nil)
3580+ }
3581+ }
3582+ if httpsHandler == nil || tlsConfig == nil {
3583+ return next
3584+ }
3585+ for name := range next {
3586+ manager.SetTLSHTTPServer(name, httpsHandler, tlsConfig)
3587+ }
3588+ return next
3589+}
3590+
3591+func tlsMuxHTTPServerNames(cfg Config) map[string]struct{} {
3592+ names := map[string]struct{}{}
3593+ for name, entryPoint := range cfg.EntryPoints {
3594+ domainEntryPoint := domain.EntryPoint{Name: name, Protocol: entryPoint.Protocol}
3595+ if trafficManagerOwnsEntryPoint(domainEntryPoint) && domainEntryPoint.Protocol == domain.EntryPointProtocolTLSMux {
3596+ names[name] = struct{}{}
3597+ }
3598+ }
3599+ return names
32933600 }
32943601
32953602 // startServer starts an HTTP server, returning the server instance and a channel
@@ -3329,39 +3636,6 @@ func startServer(addr string, handler http.Handler, name string, protocols *http
33293636 return server, ready
33303637 }
33313638
3332-// startTLSServerWithConfig starts an HTTPS server using a pre-built tls.Config
3333-// (e.g. with GetCertificate for on-demand issuance via the internal CA).
3334-// Returns the server instance and a channel that closes once the port is bound.
3335-func startTLSServerWithConfig(addr string, handler http.Handler, name string, tlsCfg *tls.Config, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {
3336- srv := &http.Server{
3337- Addr: addr,
3338- Handler: handler,
3339- TLSConfig: tlsCfg,
3340- ReadHeaderTimeout: 10 * time.Second,
3341- ReadTimeout: 5 * time.Minute,
3342- WriteTimeout: 5 * time.Minute,
3343- IdleTimeout: 120 * time.Second,
3344- MaxHeaderBytes: 1 << 20,
3345- }
3346-
3347- ready := make(chan struct{})
3348- go func() {
3349- ln, err := net.Listen("tcp", addr)
3350- if err != nil {
3351- errChan <- fmt.Errorf("%s listen: %w", name, err)
3352- return
3353- }
3354- tlsLn := tls.NewListener(ln, tlsCfg)
3355- log.Info().Str("addr", addr).Str("name", name).Msg("TLS server starting")
3356- close(ready)
3357- if err := srv.Serve(tlsLn); err != nil && err != http.ErrServerClosed {
3358- errChan <- fmt.Errorf("%s: %w", name, err)
3359- }
3360- }()
3361-
3362- return srv, ready
3363-}
3364-
33653639 // SendReloadSignal sends SIGUSR1 to the running Gordon process.
33663640 func SendReloadSignal() error {
33673641 process, _, err := findRunningProcess()
@@ -3613,10 +3887,8 @@ func isProcessAlive(pid int) bool {
36133887
36143888 // loadConfig loads configuration from file and sets defaults.
36153889 func loadConfig(v *viper.Viper, configPath string) error {
3616- v.SetDefault("server.port", 8088)
36173890 v.SetDefault("server.registry_port", 5000)
36183891 v.SetDefault("server.legacy_registry_domains", []string{})
3619- v.SetDefault("server.tls_port", 8443)
36203892 v.SetDefault("server.tls_cert_file", "")
36213893 v.SetDefault("server.tls_key_file", "")
36223894 v.SetDefault("tls.acme.enabled", false)
M internal/app/run_http_handlers_test.go +55 -2
@@ -21,7 +21,9 @@ import (
2121 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
2222 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
2323 out "github.com/bnema/gordon/internal/boundaries/out"
24+ "github.com/bnema/gordon/internal/domain"
2425 proxyusecase "github.com/bnema/gordon/internal/usecase/proxy"
26+ traffic "github.com/bnema/gordon/internal/usecase/traffic"
2527 )
2628
2729 func newNotFoundProxyService(t *testing.T) *proxyusecase.Service {
@@ -126,6 +128,9 @@ func newOnboardingConfig() Config {
126128 cfg.Server.Port = 8088
127129 cfg.Server.TLSPort = 8443
128130 cfg.Server.ProxyAllowedIPs = []string{"173.245.48.0/20"} // Cloudflare sample
131+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
132+ traffic.DefaultEdgeEntryPointName: {Address: ":8443", Protocol: domain.EntryPointProtocolSmartTCP},
133+ }
129134 return cfg
130135 }
131136
@@ -240,6 +245,52 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *
240245 assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
241246 }
242247
248+func TestCreateHTTPHandlers_RedirectUsesSelectedEntrypointPorts(t *testing.T) {
249+ t.Parallel()
250+ cfg := Config{}
251+ cfg.Server.Port = 8088
252+ cfg.Server.TLSPort = 8443
253+ cfg.Server.ForceHTTPSRedirect = true
254+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
255+ traffic.DefaultEdgeEntryPointName: {Address: ":9443", Protocol: domain.EntryPointProtocolSmartTCP},
256+ }
257+ configSvc := inmocks.NewMockConfigService(t)
258+ configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{Domain: "app.example.com"}, nil)
259+ svc := &services{proxySvc: proxyusecase.NewService(nil, nil, configSvc, proxyusecase.Config{})}
260+
261+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
262+
263+ req := httptest.NewRequest(http.MethodGet, "/path", nil)
264+ req.RemoteAddr = directAddr
265+ req.Host = "app.example.com:9443"
266+ rec := httptest.NewRecorder()
267+ httpHandler.ServeHTTP(rec, req)
268+
269+ assert.Equal(t, http.StatusPermanentRedirect, rec.Code)
270+ assert.Equal(t, "https://app.example.com:9443/path", rec.Header().Get("Location"))
271+}
272+
273+func TestCreateHTTPHandlers_OnboardingUsesSelectedEntrypointPorts(t *testing.T) {
274+ t.Parallel()
275+ ca := newTestCA(t)
276+ cfg := newOnboardingConfig()
277+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
278+ "public": {Address: ":9443", Protocol: domain.EntryPointProtocolSmartTCP},
279+ }
280+ svc := &services{caAdapter: ca}
281+
282+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
283+
284+ req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/", nil)
285+ req.RemoteAddr = directAddr
286+ req.Host = "o2.bnema.dev:9443"
287+ rec := httptest.NewRecorder()
288+ httpHandler.ServeHTTP(rec, req)
289+
290+ assert.Equal(t, http.StatusOK, rec.Code)
291+ assert.Contains(t, rec.Body.String(), "https://o2.bnema.dev:9443/")
292+}
293+
243294 func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailableOnGordonDomain(t *testing.T) {
244295 t.Parallel()
245296 ca := newTestCA(t)
@@ -401,7 +452,7 @@ func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T)
401452 t.Parallel()
402453 ca := newTestCA(t)
403454 cfg := newOnboardingConfig()
404- cfg.Server.TLSPort = 0 // TLS disabled
455+ cfg.EntryPoints = nil // TLS disabled: no TLS-capable traffic entrypoint
405456 svc := &services{caAdapter: ca}
406457
407458 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
@@ -473,7 +524,9 @@ func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) {
473524 log: zerowrap.Default(),
474525 svc: &services{},
475526 }
476- si.cfg.Server.TLSPort = 8443
527+ si.cfg.EntryPoints = map[string]traffic.EntryPointConfig{
528+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
529+ }
477530 dataDir := t.TempDir()
478531 blockingPath := filepath.Join(dataDir, "not-a-directory")
479532 require.NoError(t, os.WriteFile(blockingPath, []byte("blocking file"), 0600))
M internal/app/run_reload_test.go +201 -0
@@ -3,6 +3,12 @@ package app
33 import (
44 "context"
55 "errors"
6+ "net"
7+ "net/http"
8+ "os"
9+ "path/filepath"
10+ "strconv"
11+ "strings"
612 "sync"
713 "testing"
814 "time"
@@ -10,13 +16,18 @@ import (
1016 "github.com/bnema/zerowrap"
1117 "github.com/spf13/viper"
1218 "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/mock"
1320 "github.com/stretchr/testify/require"
1421
1522 "github.com/bnema/gordon/internal/adapters/in/http/registry"
23+ trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
24+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
1625 "github.com/bnema/gordon/internal/domain"
1726 cfgusecase "github.com/bnema/gordon/internal/usecase/config"
1827 "github.com/bnema/gordon/internal/usecase/container"
1928 "github.com/bnema/gordon/internal/usecase/proxy"
29+ servicecfg "github.com/bnema/gordon/internal/usecase/services"
30+ traffic "github.com/bnema/gordon/internal/usecase/traffic"
2031 )
2132
2233 type watchRecorder struct {
@@ -127,6 +138,37 @@ func (r *containerConfigApplyRecorder) Apply(ctx context.Context, cfg Config) er
127138 return nil
128139 }
129140
141+type standaloneServiceRecorder struct {
142+ mu sync.Mutex
143+ calls int
144+ services []domain.StandaloneService
145+ err error
146+ onReconcile func()
147+}
148+
149+func (r *standaloneServiceRecorder) Reconcile(_ context.Context, services []domain.StandaloneService) error {
150+ r.mu.Lock()
151+ r.calls++
152+ r.services = append([]domain.StandaloneService(nil), services...)
153+ err := r.err
154+ onReconcile := r.onReconcile
155+ r.mu.Unlock()
156+ if onReconcile != nil {
157+ onReconcile()
158+ }
159+ return err
160+}
161+
162+func (r *standaloneServiceRecorder) Status(context.Context) ([]domain.StandaloneServiceStatus, error) {
163+ return nil, nil
164+}
165+
166+func (r *standaloneServiceRecorder) Calls() int {
167+ r.mu.Lock()
168+ defer r.mu.Unlock()
169+ return r.calls
170+}
171+
130172 type eventBusRecorder struct {
131173 mu sync.Mutex
132174 calls int
@@ -227,6 +269,57 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven
227269 }, proxySvc.config)
228270 }
229271
272+func TestServiceInit_ReloadRegistersCustomTLSMuxHTTPSFallback(t *testing.T) {
273+ ctx := context.Background()
274+ v := viper.New()
275+ v.Set("server.gordon_domain", "reload.example.com")
276+ v.Set("server.registry_port", 5000)
277+
278+ configSvc := cfgusecase.NewService(v, nil)
279+ require.NoError(t, configSvc.Load(ctx))
280+ manager := trafficadapter.NewManager()
281+ defer func() { require.NoError(t, manager.Shutdown(ctx)) }()
282+ httpsHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
283+ _, _ = w.Write([]byte("reload secure"))
284+ })
285+ pkiSvc := newTestPKIService(t)
286+
287+ customAddress := freeTCPAddress(t)
288+ si := &serviceInit{
289+ ctx: ctx,
290+ v: v,
291+ cfg: Config{},
292+ log: zerowrap.Default(),
293+ svc: &services{
294+ configSvc: configSvc,
295+ containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}, configSvc),
296+ internalRegUser: "gordon",
297+ internalRegPass: "secret",
298+ reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),
299+ trafficManager: manager,
300+ httpsProxyHandler: httpsHandler,
301+ pkiSvc: pkiSvc,
302+ },
303+ }
304+ si.registerReloadCoordinatorHooks()
305+
306+ reloadCfg := Config{}
307+ reloadCfg.Server.GordonDomain = "reload.example.com"
308+ reloadCfg.Server.RegistryPort = 5000
309+ reloadCfg.Server.TLSPort = freeTCPPort(t)
310+ reloadCfg.EntryPoints = map[string]traffic.EntryPointConfig{
311+ "custom-secure": {Address: customAddress, Protocol: domain.EntryPointProtocolTLSMux},
312+ }
313+
314+ require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
315+ _, portValue, err := net.SplitHostPort(customAddress)
316+ require.NoError(t, err)
317+ port, err := strconv.Atoi(portValue)
318+ require.NoError(t, err)
319+ body := httpsGetForTest(t, port, "app.example.com")
320+ require.Contains(t, body, "reload secure")
321+}
322+
230323 func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(t *testing.T) {
231324 ctx := context.Background()
232325 v := viper.New()
@@ -260,6 +353,114 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(
260353
261354 require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
262355 }
356+func TestStandaloneServiceSecretProviderUnsafeBackendReconcilesServiceSecrets(t *testing.T) {
357+ ctx := context.Background()
358+ dataDir := t.TempDir()
359+ secretPath := "service/game/rcon"
360+ require.NoError(t, os.MkdirAll(filepath.Join(dataDir, "secrets", "service", "game"), 0700))
361+ require.NoError(t, os.WriteFile(filepath.Join(dataDir, "secrets", secretPath), []byte("test-secret-value"), 0600))
362+
363+ rt := outmocks.NewMockContainerRuntime(t)
364+ var sawSecretEnv bool
365+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{}, nil).Once()
366+ rt.On("InspectImageVolumes", mock.Anything, "game:latest").Return([]string(nil), nil).Once()
367+ rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Run(func(args mock.Arguments) {
368+ config := args.Get(1).(*domain.ContainerConfig)
369+ for _, entry := range config.Env {
370+ if strings.HasPrefix(entry, "RCON_PASSWORD=") {
371+ sawSecretEnv = true
372+ }
373+ }
374+ }).Return(&domain.Container{ID: "created-1"}, nil).Once()
375+ rt.On("StartContainer", mock.Anything, "created-1").Return(nil).Once()
376+
377+ provider := createStandaloneServiceSecretProvider(domain.SecretsBackendUnsafe, dataDir, zerowrap.Default())
378+ reconciler := servicecfg.NewServiceWithSecretProvider(rt, provider)
379+ err := reconciler.Reconcile(ctx, []domain.StandaloneService{{Name: "game", Image: "game:latest", Enabled: true, Secrets: []domain.StandaloneServiceSecretRef{{Name: "rcon", Key: "RCON_PASSWORD"}}}})
380+
381+ require.NoError(t, err)
382+ assert.True(t, sawSecretEnv)
383+}
384+
385+func TestServiceInit_RegisterReloadCoordinatorHooks_AppliesTrafficBeforeStandaloneServices(t *testing.T) {
386+ ctx := context.Background()
387+ v := viper.New()
388+ v.Set("server.gordon_domain", "reload.example.com")
389+ v.Set("server.registry_port", 5000)
390+
391+ configSvc := cfgusecase.NewService(v, nil)
392+ require.NoError(t, configSvc.Load(ctx))
393+ manager := trafficadapter.NewManager()
394+ defer func() { require.NoError(t, manager.Shutdown(ctx)) }()
395+
396+ reconciler := &standaloneServiceRecorder{onReconcile: func() {
397+ assert.NotEmpty(t, manager.Status().EntryPoints, "traffic graph should apply before standalone services reconcile")
398+ }}
399+ si := &serviceInit{
400+ ctx: ctx,
401+ v: v,
402+ cfg: Config{},
403+ log: zerowrap.Default(),
404+ svc: &services{
405+ configSvc: configSvc,
406+ containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}, configSvc),
407+ internalRegUser: "gordon",
408+ internalRegPass: "secret",
409+ reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),
410+ trafficManager: manager,
411+ standaloneServiceSvc: reconciler,
412+ },
413+ }
414+ si.registerReloadCoordinatorHooks()
415+
416+ reloadCfg := Config{}
417+ reloadCfg.Server.GordonDomain = "reload.example.com"
418+ reloadCfg.Server.RegistryPort = 5000
419+ reloadCfg.Services = []servicecfg.Config{{Name: "game", Image: "game:latest", Enabled: true}}
420+ reloadCfg.EntryPoints = map[string]traffic.EntryPointConfig{"game": {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTCP}}
421+ reloadCfg.NetworkServices = []traffic.NetworkServiceConfig{{Name: "game", Ports: []traffic.PortConfig{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolTCP}}}}
422+ reloadCfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "game", EntryPoint: "game", Service: "network_service:game:game"}}
423+
424+ require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
425+ require.Equal(t, 1, reconciler.Calls())
426+ require.Len(t, reconciler.services, 1)
427+ assert.Equal(t, "game", reconciler.services[0].Name)
428+ assert.NotEmpty(t, manager.Status().EntryPoints)
429+}
430+
431+func TestWaitForCoreProxyReadyAndApplyTraffic_AppliesTrafficBeforeStandaloneServices(t *testing.T) {
432+ ctx := context.Background()
433+ v := viper.New()
434+ configSvc := cfgusecase.NewService(v, nil)
435+ require.NoError(t, configSvc.Load(ctx))
436+ manager := trafficadapter.NewManager()
437+ defer func() { require.NoError(t, manager.Shutdown(ctx)) }()
438+
439+ reconciler := &standaloneServiceRecorder{onReconcile: func() {
440+ assert.NotEmpty(t, manager.Status().EntryPoints, "traffic graph should apply before standalone services reconcile")
441+ }}
442+ cfg := Config{}
443+ cfg.Services = []servicecfg.Config{{Name: "game", Image: "game:latest", Enabled: true}}
444+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{"game": {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTCP}}
445+ cfg.NetworkServices = []traffic.NetworkServiceConfig{{Name: "game", Ports: []traffic.PortConfig{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolTCP}}}}
446+ cfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "game", EntryPoint: "game", Service: "network_service:game:game"}}
447+
448+ ready := make(chan struct{})
449+ close(ready)
450+ svc := &services{configSvc: configSvc, trafficManager: manager, standaloneServiceSvc: reconciler}
451+ require.NoError(t, waitForCoreProxyReadyAndApplyTraffic(ctx, cfg, svc, ready, ready, nil))
452+ require.Equal(t, 1, reconciler.Calls())
453+ assert.NotEmpty(t, manager.Status().EntryPoints)
454+}
455+
456+func TestReconcileStandaloneServices_ConfigConversionErrorIsClear(t *testing.T) {
457+ reconciler := &standaloneServiceRecorder{}
458+ err := reconcileStandaloneServices(context.Background(), reconciler, Config{Services: []servicecfg.Config{{Name: "broken", Image: "", Enabled: true}}})
459+ require.Error(t, err)
460+ assert.ErrorContains(t, err, "convert standalone service config")
461+ assert.Equal(t, 0, reconciler.Calls())
462+}
463+
263464 func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {
264465 ctx := context.Background()
265466 v := viper.New()
A internal/app/start_proxy_servers_test.go +355 -0
@@ -0,0 +1,355 @@
1+package app
2+
3+import (
4+ "context"
5+ "crypto/tls"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "net"
10+ "net/http"
11+ "strconv"
12+ "testing"
13+ "time"
14+
15+ "github.com/bnema/zerowrap"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+
19+ trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
20+ pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
21+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
22+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
23+ "github.com/bnema/gordon/internal/domain"
24+ pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
25+ traffic "github.com/bnema/gordon/internal/usecase/traffic"
26+)
27+
28+func TestWaitForServerReady_NilReadyReturnsImmediately(t *testing.T) {
29+ errChan := make(chan error)
30+ done := make(chan error, 1)
31+
32+ go func() {
33+ done <- waitForServerReady(nil, errChan)
34+ }()
35+
36+ select {
37+ case err := <-done:
38+ require.NoError(t, err)
39+ case <-time.After(100 * time.Millisecond):
40+ t.Fatal("nil ready channel blocked startup readiness")
41+ }
42+}
43+
44+func TestWaitForServerReady_NonNilReadyPreservesErrorBehavior(t *testing.T) {
45+ expected := errors.New("listen failed")
46+ errChan := make(chan error, 1)
47+ errChan <- expected
48+
49+ err := waitForServerReady(make(chan struct{}), errChan)
50+ require.ErrorIs(t, err, expected)
51+}
52+
53+func TestStartProxyServers_DoesNotStartLegacyHTTPListenerFromServerPort(t *testing.T) {
54+ cfg := Config{}
55+ cfg.Server.Port = freeTCPPort(t)
56+
57+ manager := trafficadapter.NewManager()
58+ defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
59+
60+ httpSrv, httpReady, tlsSrv, tlsReady, err := startProxyServers(
61+ cfg,
62+ http.NotFoundHandler(),
63+ http.NotFoundHandler(),
64+ newTestPKIService(t),
65+ nil,
66+ manager,
67+ zerowrap.Default(),
68+ )
69+ require.NoError(t, err)
70+ require.Nil(t, httpSrv)
71+ require.Nil(t, httpReady)
72+ require.Nil(t, tlsSrv)
73+ require.Nil(t, tlsReady)
74+ assertTCPPortClosed(t, fmt.Sprintf("127.0.0.1:%d", cfg.Server.Port))
75+}
76+
77+func TestStartProxyServers_DoesNotTreatLegacyTLSPortAsTLSCapable(t *testing.T) {
78+ cfg := Config{}
79+ cfg.Server.TLSPort = freeTCPPort(t)
80+
81+ manager := trafficadapter.NewManager()
82+ defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
83+
84+ httpSrv, httpReady, tlsSrv, tlsReady, err := startProxyServers(
85+ cfg,
86+ http.NotFoundHandler(),
87+ http.NotFoundHandler(),
88+ newTestPKIService(t),
89+ nil,
90+ manager,
91+ zerowrap.Default(),
92+ )
93+ require.NoError(t, err)
94+ require.Nil(t, httpSrv)
95+ require.Nil(t, httpReady)
96+ require.Nil(t, tlsSrv)
97+ require.Nil(t, tlsReady)
98+ assertTCPPortClosed(t, fmt.Sprintf("127.0.0.1:%d", cfg.Server.TLSPort))
99+}
100+
101+func TestStartProxyServers_ConfiguresSmartTCPHTTPAndHTTPSRoutesWithoutPublicHTTPListener(t *testing.T) {
102+ cfg := Config{}
103+ cfg.Server.Port = freeTCPPort(t)
104+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
105+ traffic.DefaultEdgeEntryPointName: {
106+ Address: freeTCPAddress(t),
107+ Protocol: domain.EntryPointProtocolSmartTCP,
108+ },
109+ }
110+
111+ manager := trafficadapter.NewManager()
112+ defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
113+ pkiSvc := newTestPKIService(t)
114+
115+ httpSrv, httpReady, tlsSrv, tlsReady, err := startProxyServers(
116+ cfg,
117+ http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("cleartext " + r.Proto)) }),
118+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("secure")) }),
119+ pkiSvc,
120+ nil,
121+ manager,
122+ zerowrap.Default(),
123+ )
124+ require.NoError(t, err)
125+ require.Nil(t, httpSrv)
126+ require.Nil(t, httpReady)
127+ require.Nil(t, tlsSrv)
128+ require.Nil(t, tlsReady)
129+ assertTCPPortClosed(t, fmt.Sprintf("127.0.0.1:%d", cfg.Server.Port))
130+
131+ configSvc := inmocks.NewMockConfigService(t)
132+ configSvc.EXPECT().GetRoutes(context.Background()).Return([]domain.Route{{Domain: "app.example.com", HTTPS: true}})
133+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
134+ require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc))
135+
136+ _, smartPortText, err := net.SplitHostPort(cfg.EntryPoints[traffic.DefaultEdgeEntryPointName].Address)
137+ require.NoError(t, err)
138+ smartPort, err := strconv.Atoi(smartPortText)
139+ require.NoError(t, err)
140+ require.Contains(t, httpGetForTest(t, smartPort, "app.example.com"), "cleartext HTTP/1.1")
141+ require.Contains(t, httpsGetForTest(t, smartPort, "app.example.com"), "secure")
142+}
143+
144+func TestStartProxyServers_ConfiguresTrafficManagerHTTPSRoute(t *testing.T) {
145+ cfg := Config{}
146+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
147+ traffic.DefaultEdgeEntryPointName: {
148+ Address: freeTCPAddress(t),
149+ Protocol: domain.EntryPointProtocolTLSMux,
150+ },
151+ }
152+
153+ manager := trafficadapter.NewManager()
154+ defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
155+ pkiSvc := newTestPKIService(t)
156+
157+ httpSrv, httpReady, tlsSrv, tlsReady, err := startProxyServers(
158+ cfg,
159+ http.NotFoundHandler(),
160+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("secure")) }),
161+ pkiSvc,
162+ nil,
163+ manager,
164+ zerowrap.Default(),
165+ )
166+ require.NoError(t, err)
167+ require.Nil(t, httpSrv)
168+ require.Nil(t, httpReady)
169+ require.Nil(t, tlsSrv)
170+ require.Nil(t, tlsReady)
171+
172+ configSvc := inmocks.NewMockConfigService(t)
173+ configSvc.EXPECT().GetRoutes(context.Background()).Return([]domain.Route{{Domain: "app.example.com", HTTPS: true}})
174+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
175+ require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc))
176+
177+ _, portText, err := net.SplitHostPort(cfg.EntryPoints[traffic.DefaultEdgeEntryPointName].Address)
178+ require.NoError(t, err)
179+ port, err := strconv.Atoi(portText)
180+ require.NoError(t, err)
181+ body := httpsGetForTest(t, port, "app.example.com")
182+ require.Contains(t, body, "secure")
183+}
184+
185+func TestStartProxyServers_ConfiguresCustomTLSMuxHTTPSRoute(t *testing.T) {
186+ cfg := Config{}
187+ cfg.Server.Port = freeTCPPort(t)
188+ cfg.Server.TLSPort = freeTCPPort(t)
189+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
190+ "custom-secure": {Address: fmt.Sprintf("127.0.0.1:%d", freeTCPPort(t)), Protocol: domain.EntryPointProtocolTLSMux},
191+ }
192+ cfg.Traffic.TLS.Routers = []traffic.RouterConfig{{Name: "raw", EntryPoint: "custom-secure", SNI: "raw.example.com", Service: "network_service:raw:tls"}}
193+ cfg.NetworkServices = []traffic.NetworkServiceConfig{{Name: "raw", Ports: []traffic.PortConfig{{Name: "tls", Container: 443, Protocol: domain.NetworkProtocolTCP}}}}
194+
195+ manager := trafficadapter.NewManager()
196+ defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
197+ pkiSvc := newTestPKIService(t)
198+
199+ httpSrv, httpReady, tlsSrv, tlsReady, err := startProxyServers(
200+ cfg,
201+ http.NotFoundHandler(),
202+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("custom secure")) }),
203+ pkiSvc,
204+ nil,
205+ manager,
206+ zerowrap.Default(),
207+ )
208+ require.NoError(t, err)
209+ require.Nil(t, httpSrv)
210+ require.Nil(t, httpReady)
211+ require.Nil(t, tlsSrv)
212+ require.Nil(t, tlsReady)
213+
214+ configSvc := inmocks.NewMockConfigService(t)
215+ configSvc.EXPECT().GetRoutes(context.Background()).Return(nil)
216+ configSvc.EXPECT().GetExternalRoutes().Return(nil)
217+ require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc))
218+
219+ _, customPort, err := net.SplitHostPort(cfg.EntryPoints["custom-secure"].Address)
220+ require.NoError(t, err)
221+ port, err := strconv.Atoi(customPort)
222+ require.NoError(t, err)
223+ body := httpsGetForTest(t, port, "app.example.com")
224+ require.Contains(t, body, "custom secure")
225+}
226+
227+func TestStartProxyServers_TLSSetupFailureStopsHTTPServer(t *testing.T) {
228+ cfg := Config{}
229+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
230+ traffic.DefaultEdgeEntryPointName: {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolSmartTCP},
231+ }
232+ cfg.Server.TLSCertFile = "/path/that/does/not/exist.crt"
233+ cfg.Server.TLSKeyFile = "/path/that/does/not/exist.key"
234+
235+ httpSrv, httpReady, tlsSrv, tlsReady, err := startProxyServers(
236+ cfg,
237+ http.NotFoundHandler(),
238+ http.NotFoundHandler(),
239+ newTestPKIService(t),
240+ nil,
241+ trafficadapter.NewManager(),
242+ zerowrap.Default(),
243+ )
244+ require.ErrorContains(t, err, "load TLS keypair")
245+ require.Nil(t, tlsSrv)
246+ require.Nil(t, tlsReady)
247+ if httpReady != nil {
248+ select {
249+ case <-httpReady:
250+ case <-time.After(time.Second):
251+ }
252+ }
253+ if httpSrv != nil {
254+ _, err = net.DialTimeout("tcp", httpSrv.Addr, 100*time.Millisecond)
255+ require.Error(t, err)
256+ }
257+}
258+
259+func TestStartProxyServers_TLSRequiresTrafficManager(t *testing.T) {
260+ cfg := Config{}
261+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
262+ traffic.DefaultEdgeEntryPointName: {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolSmartTCP},
263+ }
264+
265+ httpSrv, _, tlsSrv, _, err := startProxyServers(
266+ cfg,
267+ http.NotFoundHandler(),
268+ http.NotFoundHandler(),
269+ newTestPKIService(t),
270+ nil,
271+ nil,
272+ zerowrap.Default(),
273+ )
274+ if httpSrv != nil {
275+ shutdownHTTPServer(t, httpSrv)
276+ }
277+ if tlsSrv != nil {
278+ shutdownHTTPServer(t, tlsSrv)
279+ }
280+ require.ErrorContains(t, err, "traffic manager is required")
281+}
282+
283+func newTestPKIService(t *testing.T) *pkiusecase.Service {
284+ t.Helper()
285+ routes := outmocks.NewMockRouteChecker(t)
286+ routes.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.example.com", HTTPS: true}}).Maybe()
287+ routes.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
288+ ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default())
289+ require.NoError(t, err)
290+ pkiSvc := pkiusecase.NewService(context.Background(), ca, routes, zerowrap.Default())
291+ t.Cleanup(pkiSvc.Stop)
292+ return pkiSvc
293+}
294+
295+func freeTCPPort(t *testing.T) int {
296+ t.Helper()
297+ listener, err := net.Listen("tcp", "127.0.0.1:0")
298+ require.NoError(t, err)
299+ defer listener.Close()
300+ _, portValue, err := net.SplitHostPort(listener.Addr().String())
301+ require.NoError(t, err)
302+ port, err := strconv.Atoi(portValue)
303+ require.NoError(t, err)
304+ return port
305+}
306+
307+func shutdownHTTPServer(t *testing.T, srv *http.Server) {
308+ t.Helper()
309+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
310+ defer cancel()
311+ require.NoError(t, srv.Shutdown(ctx))
312+}
313+
314+func httpGetForTest(t *testing.T, port int, host string) string {
315+ t.Helper()
316+ client := &http.Client{Timeout: 2 * time.Second}
317+ req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/", port), nil)
318+ require.NoError(t, err)
319+ req.Host = host
320+ resp, err := client.Do(req)
321+ require.NoError(t, err)
322+ defer resp.Body.Close()
323+ body, err := io.ReadAll(resp.Body)
324+ require.NoError(t, err)
325+ return string(body)
326+}
327+
328+func assertTCPPortClosed(t *testing.T, address string) {
329+ t.Helper()
330+ conn, err := net.DialTimeout("tcp", address, 100*time.Millisecond)
331+ if err == nil {
332+ _ = conn.Close()
333+ t.Fatalf("expected %s to be closed", address)
334+ }
335+}
336+
337+func httpsGetForTest(t *testing.T, port int, serverName string) string {
338+ t.Helper()
339+ transport := &http.Transport{
340+ TLSClientConfig: &tls.Config{ServerName: serverName, InsecureSkipVerify: true},
341+ ForceAttemptHTTP2: true,
342+ }
343+ client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
344+ defer transport.CloseIdleConnections()
345+
346+ req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://127.0.0.1:%d/", port), nil)
347+ require.NoError(t, err)
348+ req.Host = serverName
349+ resp, err := client.Do(req)
350+ require.NoError(t, err)
351+ defer resp.Body.Close()
352+ body, err := io.ReadAll(resp.Body)
353+ require.NoError(t, err)
354+ return string(body)
355+}
A internal/app/traffic.go +88 -0
@@ -0,0 +1,88 @@
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/boundaries/in"
9+ "github.com/bnema/gordon/internal/domain"
10+ servicecfg "github.com/bnema/gordon/internal/usecase/services"
11+ trafficbuilder "github.com/bnema/gordon/internal/usecase/traffic"
12+)
13+
14+func applyTrafficRuntimeConfig(ctx context.Context, manager *trafficadapter.Manager, cfg Config, configSvc in.ConfigService) error {
15+ if manager == nil || configSvc == nil {
16+ return nil
17+ }
18+ standaloneServices, err := servicecfg.ToDomain(cfg.Services)
19+ if err != nil {
20+ return fmt.Errorf("convert standalone service config: %w", err)
21+ }
22+ graph, err := trafficbuilder.Build(trafficbuilder.Input{
23+ EntryPoints: cfg.EntryPoints,
24+ Traffic: cfg.Traffic,
25+ Routes: configSvc.GetRoutes(ctx),
26+ ExternalRoutes: configSvc.GetExternalRoutes(),
27+ NetworkServices: cfg.NetworkServices,
28+ Services: standaloneServices,
29+ })
30+ if err != nil {
31+ return fmt.Errorf("build traffic graph: %w", err)
32+ }
33+ owned, err := trafficRuntimeGraph(graph)
34+ if err != nil {
35+ return fmt.Errorf("filter traffic graph for runtime ownership: %w", err)
36+ }
37+ if err := manager.Apply(ctx, &owned); err != nil {
38+ return fmt.Errorf("apply traffic graph: %w", err)
39+ }
40+ return nil
41+}
42+
43+func trafficRuntimeGraph(graph domain.TrafficGraph) (domain.TrafficGraph, error) {
44+ ownedEntryPoints := map[string]struct{}{}
45+ filtered := domain.TrafficGraph{Options: graph.Options}
46+ for _, entryPoint := range graph.EntryPoints {
47+ if trafficManagerOwnsEntryPoint(entryPoint) {
48+ filtered.EntryPoints = append(filtered.EntryPoints, entryPoint)
49+ ownedEntryPoints[entryPoint.Name] = struct{}{}
50+ }
51+ }
52+
53+ usedServices := map[string]struct{}{}
54+ for _, router := range graph.Routers {
55+ if _, ok := ownedEntryPoints[router.EntryPoint]; !ok {
56+ if trafficManagerRouterProtocol(router.Protocol) {
57+ return domain.TrafficGraph{}, fmt.Errorf("traffic router %q targets entrypoint %q, which is not owned by the traffic manager", router.Name, router.EntryPoint)
58+ }
59+ continue
60+ }
61+ filtered.Routers = append(filtered.Routers, router)
62+ usedServices[router.Service] = struct{}{}
63+ }
64+ for _, service := range graph.Services {
65+ if _, ok := usedServices[service.Name]; ok {
66+ filtered.Services = append(filtered.Services, service)
67+ }
68+ }
69+ return filtered, nil
70+}
71+
72+func trafficManagerRouterProtocol(protocol domain.RouterProtocol) bool {
73+ switch protocol {
74+ case domain.RouterProtocolTCP, domain.RouterProtocolUDP, domain.RouterProtocolTLSPassthrough:
75+ return true
76+ default:
77+ return false
78+ }
79+}
80+
81+func trafficManagerOwnsEntryPoint(entryPoint domain.EntryPoint) bool {
82+ switch entryPoint.Protocol {
83+ case domain.EntryPointProtocolTLSMux, domain.EntryPointProtocolSmartTCP, domain.EntryPointProtocolTCP, domain.EntryPointProtocolUDP:
84+ return true
85+ default:
86+ return false
87+ }
88+}
A internal/app/traffic_test.go +213 -0
@@ -0,0 +1,213 @@
1+package app
2+
3+import (
4+ "context"
5+ "net"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
12+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
13+ "github.com/bnema/gordon/internal/domain"
14+ servicecfg "github.com/bnema/gordon/internal/usecase/services"
15+ "github.com/bnema/gordon/internal/usecase/traffic"
16+)
17+
18+func TestTrafficRuntimeGraphKeepsSmartTCPHTTPAndTLSPassthroughRouters(t *testing.T) {
19+ graph := domain.TrafficGraph{
20+ EntryPoints: []domain.EntryPoint{
21+ {Name: traffic.DefaultEdgeEntryPointName, Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolSmartTCP, RawFallback: "ssh", RawFallbackTrustedCIDRs: []string{"127.0.0.0/8"}},
22+ },
23+ Routers: []domain.TrafficRouter{
24+ {Name: "app", EntryPoint: traffic.DefaultEdgeEntryPointName, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
25+ {Name: "tls-raw", EntryPoint: traffic.DefaultEdgeEntryPointName, Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: "raw.example.com"}, Service: "network_service:raw:tls"},
26+ {Name: "ssh", EntryPoint: traffic.DefaultEdgeEntryPointName, Protocol: domain.RouterProtocolTCP, Service: "network_service:ssh:ssh"},
27+ },
28+ Services: []domain.TrafficService{
29+ {Name: "route:app.example.com"},
30+ {Name: "network_service:raw:tls", Backends: []domain.TrafficBackend{{Name: "raw:tls", Host: "127.0.0.1", Port: 443, Protocol: domain.NetworkProtocolTCP}}},
31+ {Name: "network_service:ssh:ssh", Backends: []domain.TrafficBackend{{Name: "ssh:ssh", Host: "127.0.0.1", Port: 22, Protocol: domain.NetworkProtocolTCP}}},
32+ },
33+ }
34+
35+ filtered, err := trafficRuntimeGraph(graph)
36+ require.NoError(t, err)
37+ assert.ElementsMatch(t, []string{traffic.DefaultEdgeEntryPointName}, entryPointNames(filtered.EntryPoints))
38+ assert.ElementsMatch(t, []string{"app", "tls-raw", "ssh"}, routerNames(filtered.Routers))
39+ assert.ElementsMatch(t, []string{"route:app.example.com", "network_service:raw:tls", "network_service:ssh:ssh"}, serviceNames(filtered.Services))
40+}
41+
42+func TestTrafficRuntimeGraphHTTPEntrypointIsInvalidRatherThanSilentlyFiltered(t *testing.T) {
43+ graph := domain.TrafficGraph{
44+ EntryPoints: []domain.EntryPoint{{Name: "edge", Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolHTTP}},
45+ Routers: []domain.TrafficRouter{{Name: "app", EntryPoint: "edge", Protocol: domain.RouterProtocolHTTP, Service: "route:app.example.com"}},
46+ Services: []domain.TrafficService{{Name: "route:app.example.com"}},
47+ }
48+
49+ require.ErrorContains(t, graph.Validate(), "unsupported traffic entrypoint protocol \"http\"")
50+}
51+
52+func TestTrafficRuntimeGraphKeepsDefaultTLSEntrypoint(t *testing.T) {
53+ graph := domain.TrafficGraph{
54+ EntryPoints: []domain.EntryPoint{
55+ {Name: "websecure", Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolTLSMux},
56+ {Name: "postgres", Address: "127.0.0.1:15432", Protocol: domain.EntryPointProtocolTCP},
57+ },
58+ Routers: []domain.TrafficRouter{
59+ {Name: "app-secure", EntryPoint: "websecure", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
60+ {Name: "postgres", EntryPoint: "postgres", Protocol: domain.RouterProtocolTCP, Service: "network_service:postgres:db"},
61+ },
62+ Services: []domain.TrafficService{
63+ {Name: "route:app.example.com"},
64+ {Name: "network_service:postgres:db", Backends: []domain.TrafficBackend{{Name: "db", Host: "127.0.0.1", Port: 5432, Protocol: domain.NetworkProtocolTCP}}},
65+ },
66+ }
67+
68+ filtered, err := trafficRuntimeGraph(graph)
69+ require.NoError(t, err)
70+ assert.ElementsMatch(t, []string{"websecure", "postgres"}, entryPointNames(filtered.EntryPoints))
71+ assert.ElementsMatch(t, []string{"app-secure", "postgres"}, routerNames(filtered.Routers))
72+ assert.ElementsMatch(t, []string{"route:app.example.com", "network_service:postgres:db"}, serviceNames(filtered.Services))
73+}
74+
75+func TestTrafficRuntimeGraphAllowsTLSPassthroughOnDefaultTLS(t *testing.T) {
76+ graph := domain.TrafficGraph{
77+ EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolTLSMux}},
78+ Routers: []domain.TrafficRouter{{Name: "raw", EntryPoint: "websecure", Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: "raw.example.com"}, Service: "network_service:raw:tls"}},
79+ Services: []domain.TrafficService{{Name: "network_service:raw:tls", Backends: []domain.TrafficBackend{{Name: "raw:tls", Host: "127.0.0.1", Port: 443, Protocol: domain.NetworkProtocolTCP}}}},
80+ }
81+
82+ filtered, err := trafficRuntimeGraph(graph)
83+ require.NoError(t, err)
84+ require.Len(t, filtered.EntryPoints, 1)
85+ assert.Equal(t, "websecure", filtered.EntryPoints[0].Name)
86+ require.Len(t, filtered.Routers, 1)
87+ assert.Equal(t, "raw", filtered.Routers[0].Name)
88+}
89+
90+func TestApplyTrafficRuntimeConfigAppliesSmartTCPEntrypointWithRawFallbackPolicy(t *testing.T) {
91+ manager := trafficadapter.NewManager()
92+ defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
93+ cfg := Config{
94+ EntryPoints: map[string]traffic.EntryPointConfig{
95+ traffic.DefaultEdgeEntryPointName: {
96+ Address: freeTCPAddress(t),
97+ Protocol: domain.EntryPointProtocolSmartTCP,
98+ RawFallback: "ssh",
99+ RawFallbackTrustedCIDRs: []string{"127.0.0.0/8"},
100+ },
101+ },
102+ NetworkServices: []traffic.NetworkServiceConfig{{
103+ Name: "ssh",
104+ Ports: []traffic.PortConfig{{Name: "ssh", Container: 22, Protocol: domain.NetworkProtocolTCP}},
105+ }},
106+ }
107+ cfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "ssh", EntryPoint: traffic.DefaultEdgeEntryPointName, Service: "network_service:ssh:ssh"}}
108+
109+ configSvc := inmocks.NewMockConfigService(t)
110+ configSvc.EXPECT().GetRoutes(context.Background()).Return([]domain.Route{{Domain: "app.example.com", HTTPS: true}})
111+ configSvc.EXPECT().GetExternalRoutes().Return(nil)
112+
113+ require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc))
114+ status := manager.Status()
115+ require.Len(t, status.EntryPoints, 1)
116+ assert.Equal(t, traffic.DefaultEdgeEntryPointName, status.EntryPoints[0].Name)
117+ assert.True(t, status.EntryPoints[0].Active)
118+ assert.Equal(t, domain.EntryPointProtocolSmartTCP, status.EntryPoints[0].Protocol)
119+}
120+
121+func TestApplyTrafficRuntimeConfigPassesStandaloneServicesToBuilder(t *testing.T) {
122+ manager := trafficadapter.NewManager()
123+ defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
124+ cfg := Config{
125+ EntryPoints: map[string]traffic.EntryPointConfig{
126+ "rust": {Address: freeUDPAddress(t), Protocol: domain.EntryPointProtocolUDP},
127+ },
128+ Services: []servicecfg.Config{{
129+ Name: "rust",
130+ Image: "localhost/rust:latest",
131+ Enabled: true,
132+ Ports: []servicecfg.PortConfig{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolUDP, Publish: "127.0.0.1:38015"}},
133+ }},
134+ }
135+ cfg.Traffic.UDP.Routers = []traffic.RouterConfig{{Name: "rust-game", EntryPoint: "rust", Service: "service:rust:game"}}
136+
137+ configSvc := inmocks.NewMockConfigService(t)
138+ configSvc.EXPECT().GetRoutes(context.Background()).Return(nil)
139+ configSvc.EXPECT().GetExternalRoutes().Return(nil)
140+
141+ require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc))
142+ status := manager.Status()
143+ require.Len(t, status.EntryPoints, 1)
144+ assert.Equal(t, "rust", status.EntryPoints[0].Name)
145+ assert.True(t, status.EntryPoints[0].Active)
146+}
147+
148+func TestApplyTrafficRuntimeConfigAppliesCustomL4Entrypoint(t *testing.T) {
149+ manager := trafficadapter.NewManager()
150+ defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
151+ cfg := Config{
152+ EntryPoints: map[string]traffic.EntryPointConfig{
153+ "postgres": {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTCP},
154+ },
155+ NetworkServices: []traffic.NetworkServiceConfig{{
156+ Name: "postgres",
157+ Ports: []traffic.PortConfig{{Name: "db", Container: 5432, Protocol: domain.NetworkProtocolTCP}},
158+ }},
159+ }
160+ cfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "postgres", EntryPoint: "postgres", Service: "network_service:postgres:db"}}
161+
162+ configSvc := inmocks.NewMockConfigService(t)
163+ configSvc.EXPECT().GetRoutes(context.Background()).Return(nil)
164+ configSvc.EXPECT().GetExternalRoutes().Return(nil)
165+
166+ require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc))
167+ status := manager.Status()
168+ require.Len(t, status.EntryPoints, 1)
169+ assert.Equal(t, "postgres", status.EntryPoints[0].Name)
170+ assert.True(t, status.EntryPoints[0].Active)
171+}
172+
173+func entryPointNames(entries []domain.EntryPoint) []string {
174+ names := make([]string, 0, len(entries))
175+ for _, entry := range entries {
176+ names = append(names, entry.Name)
177+ }
178+ return names
179+}
180+
181+func routerNames(routers []domain.TrafficRouter) []string {
182+ names := make([]string, 0, len(routers))
183+ for _, router := range routers {
184+ names = append(names, router.Name)
185+ }
186+ return names
187+}
188+
189+func serviceNames(services []domain.TrafficService) []string {
190+ names := make([]string, 0, len(services))
191+ for _, service := range services {
192+ names = append(names, service.Name)
193+ }
194+ return names
195+}
196+
197+func freeTCPAddress(t *testing.T) string {
198+ t.Helper()
199+ listener, err := net.Listen("tcp", "127.0.0.1:0")
200+ require.NoError(t, err)
201+ address := listener.Addr().String()
202+ require.NoError(t, listener.Close())
203+ return address
204+}
205+
206+func freeUDPAddress(t *testing.T) string {
207+ t.Helper()
208+ packetConn, err := net.ListenPacket("udp", "127.0.0.1:0")
209+ require.NoError(t, err)
210+ address := packetConn.LocalAddr().String()
211+ require.NoError(t, packetConn.Close())
212+ return address
213+}
M internal/boundaries/in/mocks/mock_auth_service.go +8 -8
@@ -73,7 +73,7 @@ type MockAuthService_ExtendToken_Call struct {
7373 // ExtendToken is a helper method to define mock.On call
7474 // - ctx context.Context
7575 // - tokenString string
76-func (_e *MockAuthService_Expecter) ExtendToken(ctx interface{}, tokenString interface{}) *MockAuthService_ExtendToken_Call {
76+func (_e *MockAuthService_Expecter) ExtendToken(ctx any, tokenString any) *MockAuthService_ExtendToken_Call {
7777 return &MockAuthService_ExtendToken_Call{Call: _e.mock.On("ExtendToken", ctx, tokenString)}
7878 }
7979
@@ -141,7 +141,7 @@ type MockAuthService_GenerateAccessToken_Call struct {
141141 // - subject string
142142 // - scopes []string
143143 // - expiry time.Duration
144-func (_e *MockAuthService_Expecter) GenerateAccessToken(ctx interface{}, subject interface{}, scopes interface{}, expiry interface{}) *MockAuthService_GenerateAccessToken_Call {
144+func (_e *MockAuthService_Expecter) GenerateAccessToken(ctx any, subject any, scopes any, expiry any) *MockAuthService_GenerateAccessToken_Call {
145145 return &MockAuthService_GenerateAccessToken_Call{Call: _e.mock.On("GenerateAccessToken", ctx, subject, scopes, expiry)}
146146 }
147147
@@ -219,7 +219,7 @@ type MockAuthService_GenerateToken_Call struct {
219219 // - subject string
220220 // - scopes []string
221221 // - expiry time.Duration
222-func (_e *MockAuthService_Expecter) GenerateToken(ctx interface{}, subject interface{}, scopes interface{}, expiry interface{}) *MockAuthService_GenerateToken_Call {
222+func (_e *MockAuthService_Expecter) GenerateToken(ctx any, subject any, scopes any, expiry any) *MockAuthService_GenerateToken_Call {
223223 return &MockAuthService_GenerateToken_Call{Call: _e.mock.On("GenerateToken", ctx, subject, scopes, expiry)}
224224 }
225225
@@ -340,7 +340,7 @@ type MockAuthService_GetAuthStatus_Call struct {
340340
341341 // GetAuthStatus is a helper method to define mock.On call
342342 // - ctx context.Context
343-func (_e *MockAuthService_Expecter) GetAuthStatus(ctx interface{}) *MockAuthService_GetAuthStatus_Call {
343+func (_e *MockAuthService_Expecter) GetAuthStatus(ctx any) *MockAuthService_GetAuthStatus_Call {
344344 return &MockAuthService_GetAuthStatus_Call{Call: _e.mock.On("GetAuthStatus", ctx)}
345345 }
346346
@@ -490,7 +490,7 @@ type MockAuthService_ListTokens_Call struct {
490490
491491 // ListTokens is a helper method to define mock.On call
492492 // - ctx context.Context
493-func (_e *MockAuthService_Expecter) ListTokens(ctx interface{}) *MockAuthService_ListTokens_Call {
493+func (_e *MockAuthService_Expecter) ListTokens(ctx any) *MockAuthService_ListTokens_Call {
494494 return &MockAuthService_ListTokens_Call{Call: _e.mock.On("ListTokens", ctx)}
495495 }
496496
@@ -550,7 +550,7 @@ type MockAuthService_RevokeAllTokens_Call struct {
550550
551551 // RevokeAllTokens is a helper method to define mock.On call
552552 // - ctx context.Context
553-func (_e *MockAuthService_Expecter) RevokeAllTokens(ctx interface{}) *MockAuthService_RevokeAllTokens_Call {
553+func (_e *MockAuthService_Expecter) RevokeAllTokens(ctx any) *MockAuthService_RevokeAllTokens_Call {
554554 return &MockAuthService_RevokeAllTokens_Call{Call: _e.mock.On("RevokeAllTokens", ctx)}
555555 }
556556
@@ -602,7 +602,7 @@ type MockAuthService_RevokeToken_Call struct {
602602 // RevokeToken is a helper method to define mock.On call
603603 // - ctx context.Context
604604 // - tokenID string
605-func (_e *MockAuthService_Expecter) RevokeToken(ctx interface{}, tokenID interface{}) *MockAuthService_RevokeToken_Call {
605+func (_e *MockAuthService_Expecter) RevokeToken(ctx any, tokenID any) *MockAuthService_RevokeToken_Call {
606606 return &MockAuthService_RevokeToken_Call{Call: _e.mock.On("RevokeToken", ctx, tokenID)}
607607 }
608608
@@ -670,7 +670,7 @@ type MockAuthService_ValidateToken_Call struct {
670670 // ValidateToken is a helper method to define mock.On call
671671 // - ctx context.Context
672672 // - tokenString string
673-func (_e *MockAuthService_Expecter) ValidateToken(ctx interface{}, tokenString interface{}) *MockAuthService_ValidateToken_Call {
673+func (_e *MockAuthService_Expecter) ValidateToken(ctx any, tokenString any) *MockAuthService_ValidateToken_Call {
674674 return &MockAuthService_ValidateToken_Call{Call: _e.mock.On("ValidateToken", ctx, tokenString)}
675675 }
676676
M internal/boundaries/in/mocks/mock_backup_service.go +6 -6
@@ -75,7 +75,7 @@ type MockBackupService_DetectDatabases_Call struct {
7575 // DetectDatabases is a helper method to define mock.On call
7676 // - ctx context.Context
7777 // - domainName string
78-func (_e *MockBackupService_Expecter) DetectDatabases(ctx interface{}, domainName interface{}) *MockBackupService_DetectDatabases_Call {
78+func (_e *MockBackupService_Expecter) DetectDatabases(ctx any, domainName any) *MockBackupService_DetectDatabases_Call {
7979 return &MockBackupService_DetectDatabases_Call{Call: _e.mock.On("DetectDatabases", ctx, domainName)}
8080 }
8181
@@ -143,7 +143,7 @@ type MockBackupService_ListBackups_Call struct {
143143 // ListBackups is a helper method to define mock.On call
144144 // - ctx context.Context
145145 // - domainName string
146-func (_e *MockBackupService_Expecter) ListBackups(ctx interface{}, domainName interface{}) *MockBackupService_ListBackups_Call {
146+func (_e *MockBackupService_Expecter) ListBackups(ctx any, domainName any) *MockBackupService_ListBackups_Call {
147147 return &MockBackupService_ListBackups_Call{Call: _e.mock.On("ListBackups", ctx, domainName)}
148148 }
149149
@@ -201,7 +201,7 @@ type MockBackupService_Restore_Call struct {
201201 // - ctx context.Context
202202 // - domainName string
203203 // - backupID string
204-func (_e *MockBackupService_Expecter) Restore(ctx interface{}, domainName interface{}, backupID interface{}) *MockBackupService_Restore_Call {
204+func (_e *MockBackupService_Expecter) Restore(ctx any, domainName any, backupID any) *MockBackupService_Restore_Call {
205205 return &MockBackupService_Restore_Call{Call: _e.mock.On("Restore", ctx, domainName, backupID)}
206206 }
207207
@@ -264,7 +264,7 @@ type MockBackupService_RestorePITR_Call struct {
264264 // - ctx context.Context
265265 // - domainName string
266266 // - targetTime time.Time
267-func (_e *MockBackupService_Expecter) RestorePITR(ctx interface{}, domainName interface{}, targetTime interface{}) *MockBackupService_RestorePITR_Call {
267+func (_e *MockBackupService_Expecter) RestorePITR(ctx any, domainName any, targetTime any) *MockBackupService_RestorePITR_Call {
268268 return &MockBackupService_RestorePITR_Call{Call: _e.mock.On("RestorePITR", ctx, domainName, targetTime)}
269269 }
270270
@@ -338,7 +338,7 @@ type MockBackupService_RunBackup_Call struct {
338338 // - ctx context.Context
339339 // - domainName string
340340 // - dbName string
341-func (_e *MockBackupService_Expecter) RunBackup(ctx interface{}, domainName interface{}, dbName interface{}) *MockBackupService_RunBackup_Call {
341+func (_e *MockBackupService_Expecter) RunBackup(ctx any, domainName any, dbName any) *MockBackupService_RunBackup_Call {
342342 return &MockBackupService_RunBackup_Call{Call: _e.mock.On("RunBackup", ctx, domainName, dbName)}
343343 }
344344
@@ -410,7 +410,7 @@ type MockBackupService_Status_Call struct {
410410
411411 // Status is a helper method to define mock.On call
412412 // - ctx context.Context
413-func (_e *MockBackupService_Expecter) Status(ctx interface{}) *MockBackupService_Status_Call {
413+func (_e *MockBackupService_Expecter) Status(ctx any) *MockBackupService_Status_Call {
414414 return &MockBackupService_Status_Call{Call: _e.mock.On("Status", ctx)}
415415 }
416416
M internal/boundaries/in/mocks/mock_config_service.go +18 -18
@@ -64,7 +64,7 @@ type MockConfigService_AddAttachment_Call struct {
6464 // - ctx context.Context
6565 // - domainOrGroup string
6666 // - image string
67-func (_e *MockConfigService_Expecter) AddAttachment(ctx interface{}, domainOrGroup interface{}, image interface{}) *MockConfigService_AddAttachment_Call {
67+func (_e *MockConfigService_Expecter) AddAttachment(ctx any, domainOrGroup any, image any) *MockConfigService_AddAttachment_Call {
6868 return &MockConfigService_AddAttachment_Call{Call: _e.mock.On("AddAttachment", ctx, domainOrGroup, image)}
6969 }
7070
@@ -126,7 +126,7 @@ type MockConfigService_AddAutoRouteAllowedDomain_Call struct {
126126 // AddAutoRouteAllowedDomain is a helper method to define mock.On call
127127 // - ctx context.Context
128128 // - pattern string
129-func (_e *MockConfigService_Expecter) AddAutoRouteAllowedDomain(ctx interface{}, pattern interface{}) *MockConfigService_AddAutoRouteAllowedDomain_Call {
129+func (_e *MockConfigService_Expecter) AddAutoRouteAllowedDomain(ctx any, pattern any) *MockConfigService_AddAutoRouteAllowedDomain_Call {
130130 return &MockConfigService_AddAutoRouteAllowedDomain_Call{Call: _e.mock.On("AddAutoRouteAllowedDomain", ctx, pattern)}
131131 }
132132
@@ -183,7 +183,7 @@ type MockConfigService_AddRoute_Call struct {
183183 // AddRoute is a helper method to define mock.On call
184184 // - ctx context.Context
185185 // - route domain.Route
186-func (_e *MockConfigService_Expecter) AddRoute(ctx interface{}, route interface{}) *MockConfigService_AddRoute_Call {
186+func (_e *MockConfigService_Expecter) AddRoute(ctx any, route any) *MockConfigService_AddRoute_Call {
187187 return &MockConfigService_AddRoute_Call{Call: _e.mock.On("AddRoute", ctx, route)}
188188 }
189189
@@ -242,7 +242,7 @@ type MockConfigService_FindAttachmentTargetsByImage_Call struct {
242242 // FindAttachmentTargetsByImage is a helper method to define mock.On call
243243 // - ctx context.Context
244244 // - imageName string
245-func (_e *MockConfigService_Expecter) FindAttachmentTargetsByImage(ctx interface{}, imageName interface{}) *MockConfigService_FindAttachmentTargetsByImage_Call {
245+func (_e *MockConfigService_Expecter) FindAttachmentTargetsByImage(ctx any, imageName any) *MockConfigService_FindAttachmentTargetsByImage_Call {
246246 return &MockConfigService_FindAttachmentTargetsByImage_Call{Call: _e.mock.On("FindAttachmentTargetsByImage", ctx, imageName)}
247247 }
248248
@@ -301,7 +301,7 @@ type MockConfigService_FindRoutesByImage_Call struct {
301301 // FindRoutesByImage is a helper method to define mock.On call
302302 // - ctx context.Context
303303 // - imageName string
304-func (_e *MockConfigService_Expecter) FindRoutesByImage(ctx interface{}, imageName interface{}) *MockConfigService_FindRoutesByImage_Call {
304+func (_e *MockConfigService_Expecter) FindRoutesByImage(ctx any, imageName any) *MockConfigService_FindRoutesByImage_Call {
305305 return &MockConfigService_FindRoutesByImage_Call{Call: _e.mock.On("FindRoutesByImage", ctx, imageName)}
306306 }
307307
@@ -359,7 +359,7 @@ type MockConfigService_GetAllAttachments_Call struct {
359359
360360 // GetAllAttachments is a helper method to define mock.On call
361361 // - ctx context.Context
362-func (_e *MockConfigService_Expecter) GetAllAttachments(ctx interface{}) *MockConfigService_GetAllAttachments_Call {
362+func (_e *MockConfigService_Expecter) GetAllAttachments(ctx any) *MockConfigService_GetAllAttachments_Call {
363363 return &MockConfigService_GetAllAttachments_Call{Call: _e.mock.On("GetAllAttachments", ctx)}
364364 }
365365
@@ -468,7 +468,7 @@ type MockConfigService_GetAttachmentsFor_Call struct {
468468 // GetAttachmentsFor is a helper method to define mock.On call
469469 // - ctx context.Context
470470 // - domainOrGroup string
471-func (_e *MockConfigService_Expecter) GetAttachmentsFor(ctx interface{}, domainOrGroup interface{}) *MockConfigService_GetAttachmentsFor_Call {
471+func (_e *MockConfigService_Expecter) GetAttachmentsFor(ctx any, domainOrGroup any) *MockConfigService_GetAttachmentsFor_Call {
472472 return &MockConfigService_GetAttachmentsFor_Call{Call: _e.mock.On("GetAttachmentsFor", ctx, domainOrGroup)}
473473 }
474474
@@ -535,7 +535,7 @@ type MockConfigService_GetAutoRouteAllowedDomains_Call struct {
535535
536536 // GetAutoRouteAllowedDomains is a helper method to define mock.On call
537537 // - ctx context.Context
538-func (_e *MockConfigService_Expecter) GetAutoRouteAllowedDomains(ctx interface{}) *MockConfigService_GetAutoRouteAllowedDomains_Call {
538+func (_e *MockConfigService_Expecter) GetAutoRouteAllowedDomains(ctx any) *MockConfigService_GetAutoRouteAllowedDomains_Call {
539539 return &MockConfigService_GetAutoRouteAllowedDomains_Call{Call: _e.mock.On("GetAutoRouteAllowedDomains", ctx)}
540540 }
541541
@@ -910,7 +910,7 @@ type MockConfigService_GetRoute_Call struct {
910910 // GetRoute is a helper method to define mock.On call
911911 // - ctx context.Context
912912 // - domain1 string
913-func (_e *MockConfigService_Expecter) GetRoute(ctx interface{}, domain1 interface{}) *MockConfigService_GetRoute_Call {
913+func (_e *MockConfigService_Expecter) GetRoute(ctx any, domain1 any) *MockConfigService_GetRoute_Call {
914914 return &MockConfigService_GetRoute_Call{Call: _e.mock.On("GetRoute", ctx, domain1)}
915915 }
916916
@@ -968,7 +968,7 @@ type MockConfigService_GetRoutes_Call struct {
968968
969969 // GetRoutes is a helper method to define mock.On call
970970 // - ctx context.Context
971-func (_e *MockConfigService_Expecter) GetRoutes(ctx interface{}) *MockConfigService_GetRoutes_Call {
971+func (_e *MockConfigService_Expecter) GetRoutes(ctx any) *MockConfigService_GetRoutes_Call {
972972 return &MockConfigService_GetRoutes_Call{Call: _e.mock.On("GetRoutes", ctx)}
973973 }
974974
@@ -1239,7 +1239,7 @@ type MockConfigService_Load_Call struct {
12391239
12401240 // Load is a helper method to define mock.On call
12411241 // - ctx context.Context
1242-func (_e *MockConfigService_Expecter) Load(ctx interface{}) *MockConfigService_Load_Call {
1242+func (_e *MockConfigService_Expecter) Load(ctx any) *MockConfigService_Load_Call {
12431243 return &MockConfigService_Load_Call{Call: _e.mock.On("Load", ctx)}
12441244 }
12451245
@@ -1290,7 +1290,7 @@ type MockConfigService_Reload_Call struct {
12901290
12911291 // Reload is a helper method to define mock.On call
12921292 // - ctx context.Context
1293-func (_e *MockConfigService_Expecter) Reload(ctx interface{}) *MockConfigService_Reload_Call {
1293+func (_e *MockConfigService_Expecter) Reload(ctx any) *MockConfigService_Reload_Call {
12941294 return &MockConfigService_Reload_Call{Call: _e.mock.On("Reload", ctx)}
12951295 }
12961296
@@ -1343,7 +1343,7 @@ type MockConfigService_RemoveAttachment_Call struct {
13431343 // - ctx context.Context
13441344 // - domainOrGroup string
13451345 // - image string
1346-func (_e *MockConfigService_Expecter) RemoveAttachment(ctx interface{}, domainOrGroup interface{}, image interface{}) *MockConfigService_RemoveAttachment_Call {
1346+func (_e *MockConfigService_Expecter) RemoveAttachment(ctx any, domainOrGroup any, image any) *MockConfigService_RemoveAttachment_Call {
13471347 return &MockConfigService_RemoveAttachment_Call{Call: _e.mock.On("RemoveAttachment", ctx, domainOrGroup, image)}
13481348 }
13491349
@@ -1405,7 +1405,7 @@ type MockConfigService_RemoveAutoRouteAllowedDomain_Call struct {
14051405 // RemoveAutoRouteAllowedDomain is a helper method to define mock.On call
14061406 // - ctx context.Context
14071407 // - pattern string
1408-func (_e *MockConfigService_Expecter) RemoveAutoRouteAllowedDomain(ctx interface{}, pattern interface{}) *MockConfigService_RemoveAutoRouteAllowedDomain_Call {
1408+func (_e *MockConfigService_Expecter) RemoveAutoRouteAllowedDomain(ctx any, pattern any) *MockConfigService_RemoveAutoRouteAllowedDomain_Call {
14091409 return &MockConfigService_RemoveAutoRouteAllowedDomain_Call{Call: _e.mock.On("RemoveAutoRouteAllowedDomain", ctx, pattern)}
14101410 }
14111411
@@ -1462,7 +1462,7 @@ type MockConfigService_RemoveRoute_Call struct {
14621462 // RemoveRoute is a helper method to define mock.On call
14631463 // - ctx context.Context
14641464 // - domain1 string
1465-func (_e *MockConfigService_Expecter) RemoveRoute(ctx interface{}, domain1 interface{}) *MockConfigService_RemoveRoute_Call {
1465+func (_e *MockConfigService_Expecter) RemoveRoute(ctx any, domain1 any) *MockConfigService_RemoveRoute_Call {
14661466 return &MockConfigService_RemoveRoute_Call{Call: _e.mock.On("RemoveRoute", ctx, domain1)}
14671467 }
14681468
@@ -1518,7 +1518,7 @@ type MockConfigService_Save_Call struct {
15181518
15191519 // Save is a helper method to define mock.On call
15201520 // - ctx context.Context
1521-func (_e *MockConfigService_Expecter) Save(ctx interface{}) *MockConfigService_Save_Call {
1521+func (_e *MockConfigService_Expecter) Save(ctx any) *MockConfigService_Save_Call {
15221522 return &MockConfigService_Save_Call{Call: _e.mock.On("Save", ctx)}
15231523 }
15241524
@@ -1570,7 +1570,7 @@ type MockConfigService_UpdateRoute_Call struct {
15701570 // UpdateRoute is a helper method to define mock.On call
15711571 // - ctx context.Context
15721572 // - route domain.Route
1573-func (_e *MockConfigService_Expecter) UpdateRoute(ctx interface{}, route interface{}) *MockConfigService_UpdateRoute_Call {
1573+func (_e *MockConfigService_Expecter) UpdateRoute(ctx any, route any) *MockConfigService_UpdateRoute_Call {
15741574 return &MockConfigService_UpdateRoute_Call{Call: _e.mock.On("UpdateRoute", ctx, route)}
15751575 }
15761576
@@ -1627,7 +1627,7 @@ type MockConfigService_Watch_Call struct {
16271627 // Watch is a helper method to define mock.On call
16281628 // - ctx context.Context
16291629 // - onChange func()
1630-func (_e *MockConfigService_Expecter) Watch(ctx interface{}, onChange interface{}) *MockConfigService_Watch_Call {
1630+func (_e *MockConfigService_Expecter) Watch(ctx any, onChange any) *MockConfigService_Watch_Call {
16311631 return &MockConfigService_Watch_Call{Call: _e.mock.On("Watch", ctx, onChange)}
16321632 }
16331633
M internal/boundaries/in/mocks/mock_container_service.go +17 -17
@@ -63,7 +63,7 @@ type MockContainerService_AutoStart_Call struct {
6363 // AutoStart is a helper method to define mock.On call
6464 // - ctx context.Context
6565 // - routes []domain.Route
66-func (_e *MockContainerService_Expecter) AutoStart(ctx interface{}, routes interface{}) *MockContainerService_AutoStart_Call {
66+func (_e *MockContainerService_Expecter) AutoStart(ctx any, routes any) *MockContainerService_AutoStart_Call {
6767 return &MockContainerService_AutoStart_Call{Call: _e.mock.On("AutoStart", ctx, routes)}
6868 }
6969
@@ -132,7 +132,7 @@ type MockContainerService_CleanupOrphanedAttachments_Call struct {
132132 // - ctx context.Context
133133 // - owner string
134134 // - stop bool
135-func (_e *MockContainerService_Expecter) CleanupOrphanedAttachments(ctx interface{}, owner interface{}, stop interface{}) *MockContainerService_CleanupOrphanedAttachments_Call {
135+func (_e *MockContainerService_Expecter) CleanupOrphanedAttachments(ctx any, owner any, stop any) *MockContainerService_CleanupOrphanedAttachments_Call {
136136 return &MockContainerService_CleanupOrphanedAttachments_Call{Call: _e.mock.On("CleanupOrphanedAttachments", ctx, owner, stop)}
137137 }
138138
@@ -205,7 +205,7 @@ type MockContainerService_Deploy_Call struct {
205205 // Deploy is a helper method to define mock.On call
206206 // - ctx context.Context
207207 // - route domain.Route
208-func (_e *MockContainerService_Expecter) Deploy(ctx interface{}, route interface{}) *MockContainerService_Deploy_Call {
208+func (_e *MockContainerService_Expecter) Deploy(ctx any, route any) *MockContainerService_Deploy_Call {
209209 return &MockContainerService_Deploy_Call{Call: _e.mock.On("Deploy", ctx, route)}
210210 }
211211
@@ -273,7 +273,7 @@ type MockContainerService_Get_Call struct {
273273 // Get is a helper method to define mock.On call
274274 // - ctx context.Context
275275 // - domain1 string
276-func (_e *MockContainerService_Expecter) Get(ctx interface{}, domain1 interface{}) *MockContainerService_Get_Call {
276+func (_e *MockContainerService_Expecter) Get(ctx any, domain1 any) *MockContainerService_Get_Call {
277277 return &MockContainerService_Get_Call{Call: _e.mock.On("Get", ctx, domain1)}
278278 }
279279
@@ -331,7 +331,7 @@ type MockContainerService_HealthCheck_Call struct {
331331
332332 // HealthCheck is a helper method to define mock.On call
333333 // - ctx context.Context
334-func (_e *MockContainerService_Expecter) HealthCheck(ctx interface{}) *MockContainerService_HealthCheck_Call {
334+func (_e *MockContainerService_Expecter) HealthCheck(ctx any) *MockContainerService_HealthCheck_Call {
335335 return &MockContainerService_HealthCheck_Call{Call: _e.mock.On("HealthCheck", ctx)}
336336 }
337337
@@ -384,7 +384,7 @@ type MockContainerService_List_Call struct {
384384
385385 // List is a helper method to define mock.On call
386386 // - ctx context.Context
387-func (_e *MockContainerService_Expecter) List(ctx interface{}) *MockContainerService_List_Call {
387+func (_e *MockContainerService_Expecter) List(ctx any) *MockContainerService_List_Call {
388388 return &MockContainerService_List_Call{Call: _e.mock.On("List", ctx)}
389389 }
390390
@@ -438,7 +438,7 @@ type MockContainerService_ListAttachments_Call struct {
438438 // ListAttachments is a helper method to define mock.On call
439439 // - ctx context.Context
440440 // - domain1 string
441-func (_e *MockContainerService_Expecter) ListAttachments(ctx interface{}, domain1 interface{}) *MockContainerService_ListAttachments_Call {
441+func (_e *MockContainerService_Expecter) ListAttachments(ctx any, domain1 any) *MockContainerService_ListAttachments_Call {
442442 return &MockContainerService_ListAttachments_Call{Call: _e.mock.On("ListAttachments", ctx, domain1)}
443443 }
444444
@@ -505,7 +505,7 @@ type MockContainerService_ListNetworks_Call struct {
505505
506506 // ListNetworks is a helper method to define mock.On call
507507 // - ctx context.Context
508-func (_e *MockContainerService_Expecter) ListNetworks(ctx interface{}) *MockContainerService_ListNetworks_Call {
508+func (_e *MockContainerService_Expecter) ListNetworks(ctx any) *MockContainerService_ListNetworks_Call {
509509 return &MockContainerService_ListNetworks_Call{Call: _e.mock.On("ListNetworks", ctx)}
510510 }
511511
@@ -567,7 +567,7 @@ type MockContainerService_ListOrphanedAttachments_Call struct {
567567
568568 // ListOrphanedAttachments is a helper method to define mock.On call
569569 // - ctx context.Context
570-func (_e *MockContainerService_Expecter) ListOrphanedAttachments(ctx interface{}) *MockContainerService_ListOrphanedAttachments_Call {
570+func (_e *MockContainerService_Expecter) ListOrphanedAttachments(ctx any) *MockContainerService_ListOrphanedAttachments_Call {
571571 return &MockContainerService_ListOrphanedAttachments_Call{Call: _e.mock.On("ListOrphanedAttachments", ctx)}
572572 }
573573
@@ -620,7 +620,7 @@ type MockContainerService_ListRoutesWithDetails_Call struct {
620620
621621 // ListRoutesWithDetails is a helper method to define mock.On call
622622 // - ctx context.Context
623-func (_e *MockContainerService_Expecter) ListRoutesWithDetails(ctx interface{}) *MockContainerService_ListRoutesWithDetails_Call {
623+func (_e *MockContainerService_Expecter) ListRoutesWithDetails(ctx any) *MockContainerService_ListRoutesWithDetails_Call {
624624 return &MockContainerService_ListRoutesWithDetails_Call{Call: _e.mock.On("ListRoutesWithDetails", ctx)}
625625 }
626626
@@ -683,7 +683,7 @@ type MockContainerService_ReconcileRemovedRoute_Call struct {
683683 // ReconcileRemovedRoute is a helper method to define mock.On call
684684 // - ctx context.Context
685685 // - domain1 string
686-func (_e *MockContainerService_Expecter) ReconcileRemovedRoute(ctx interface{}, domain1 interface{}) *MockContainerService_ReconcileRemovedRoute_Call {
686+func (_e *MockContainerService_Expecter) ReconcileRemovedRoute(ctx any, domain1 any) *MockContainerService_ReconcileRemovedRoute_Call {
687687 return &MockContainerService_ReconcileRemovedRoute_Call{Call: _e.mock.On("ReconcileRemovedRoute", ctx, domain1)}
688688 }
689689
@@ -741,7 +741,7 @@ type MockContainerService_Remove_Call struct {
741741 // - ctx context.Context
742742 // - containerID string
743743 // - force bool
744-func (_e *MockContainerService_Expecter) Remove(ctx interface{}, containerID interface{}, force interface{}) *MockContainerService_Remove_Call {
744+func (_e *MockContainerService_Expecter) Remove(ctx any, containerID any, force any) *MockContainerService_Remove_Call {
745745 return &MockContainerService_Remove_Call{Call: _e.mock.On("Remove", ctx, containerID, force)}
746746 }
747747
@@ -804,7 +804,7 @@ type MockContainerService_Restart_Call struct {
804804 // - ctx context.Context
805805 // - domain1 string
806806 // - withAttachments bool
807-func (_e *MockContainerService_Expecter) Restart(ctx interface{}, domain1 interface{}, withAttachments interface{}) *MockContainerService_Restart_Call {
807+func (_e *MockContainerService_Expecter) Restart(ctx any, domain1 any, withAttachments any) *MockContainerService_Restart_Call {
808808 return &MockContainerService_Restart_Call{Call: _e.mock.On("Restart", ctx, domain1, withAttachments)}
809809 }
810810
@@ -865,7 +865,7 @@ type MockContainerService_Shutdown_Call struct {
865865
866866 // Shutdown is a helper method to define mock.On call
867867 // - ctx context.Context
868-func (_e *MockContainerService_Expecter) Shutdown(ctx interface{}) *MockContainerService_Shutdown_Call {
868+func (_e *MockContainerService_Expecter) Shutdown(ctx any) *MockContainerService_Shutdown_Call {
869869 return &MockContainerService_Shutdown_Call{Call: _e.mock.On("Shutdown", ctx)}
870870 }
871871
@@ -917,7 +917,7 @@ type MockContainerService_Stop_Call struct {
917917 // Stop is a helper method to define mock.On call
918918 // - ctx context.Context
919919 // - containerID string
920-func (_e *MockContainerService_Expecter) Stop(ctx interface{}, containerID interface{}) *MockContainerService_Stop_Call {
920+func (_e *MockContainerService_Expecter) Stop(ctx any, containerID any) *MockContainerService_Stop_Call {
921921 return &MockContainerService_Stop_Call{Call: _e.mock.On("Stop", ctx, containerID)}
922922 }
923923
@@ -973,7 +973,7 @@ type MockContainerService_SyncContainers_Call struct {
973973
974974 // SyncContainers is a helper method to define mock.On call
975975 // - ctx context.Context
976-func (_e *MockContainerService_Expecter) SyncContainers(ctx interface{}) *MockContainerService_SyncContainers_Call {
976+func (_e *MockContainerService_Expecter) SyncContainers(ctx any) *MockContainerService_SyncContainers_Call {
977977 return &MockContainerService_SyncContainers_Call{Call: _e.mock.On("SyncContainers", ctx)}
978978 }
979979
@@ -1013,7 +1013,7 @@ type MockContainerService_UpdateAttachments_Call struct {
10131013
10141014 // UpdateAttachments is a helper method to define mock.On call
10151015 // - attachments map[string][]string
1016-func (_e *MockContainerService_Expecter) UpdateAttachments(attachments interface{}) *MockContainerService_UpdateAttachments_Call {
1016+func (_e *MockContainerService_Expecter) UpdateAttachments(attachments any) *MockContainerService_UpdateAttachments_Call {
10171017 return &MockContainerService_UpdateAttachments_Call{Call: _e.mock.On("UpdateAttachments", attachments)}
10181018 }
10191019
M internal/boundaries/in/mocks/mock_health_service.go +2 -2
@@ -64,7 +64,7 @@ type MockHealthService_CheckAllRoutes_Call struct {
6464
6565 // CheckAllRoutes is a helper method to define mock.On call
6666 // - ctx context.Context
67-func (_e *MockHealthService_Expecter) CheckAllRoutes(ctx interface{}) *MockHealthService_CheckAllRoutes_Call {
67+func (_e *MockHealthService_Expecter) CheckAllRoutes(ctx any) *MockHealthService_CheckAllRoutes_Call {
6868 return &MockHealthService_CheckAllRoutes_Call{Call: _e.mock.On("CheckAllRoutes", ctx)}
6969 }
7070
@@ -118,7 +118,7 @@ type MockHealthService_CheckRoute_Call struct {
118118 // CheckRoute is a helper method to define mock.On call
119119 // - ctx context.Context
120120 // - route domain.Route
121-func (_e *MockHealthService_Expecter) CheckRoute(ctx interface{}, route interface{}) *MockHealthService_CheckRoute_Call {
121+func (_e *MockHealthService_Expecter) CheckRoute(ctx any, route any) *MockHealthService_CheckRoute_Call {
122122 return &MockHealthService_CheckRoute_Call{Call: _e.mock.On("CheckRoute", ctx, route)}
123123 }
124124
M internal/boundaries/in/mocks/mock_http_prober.go +1 -1
@@ -77,7 +77,7 @@ type MockHTTPProber_Probe_Call struct {
7777 // Probe is a helper method to define mock.On call
7878 // - ctx context.Context
7979 // - url string
80-func (_e *MockHTTPProber_Expecter) Probe(ctx interface{}, url interface{}) *MockHTTPProber_Probe_Call {
80+func (_e *MockHTTPProber_Expecter) Probe(ctx any, url any) *MockHTTPProber_Probe_Call {
8181 return &MockHTTPProber_Probe_Call{Call: _e.mock.On("Probe", ctx, url)}
8282 }
8383
M internal/boundaries/in/mocks/mock_log_service.go +4 -4
@@ -74,7 +74,7 @@ type MockLogService_FollowContainerLogs_Call struct {
7474 // - ctx context.Context
7575 // - domain string
7676 // - initialLines int
77-func (_e *MockLogService_Expecter) FollowContainerLogs(ctx interface{}, domain interface{}, initialLines interface{}) *MockLogService_FollowContainerLogs_Call {
77+func (_e *MockLogService_Expecter) FollowContainerLogs(ctx any, domain any, initialLines any) *MockLogService_FollowContainerLogs_Call {
7878 return &MockLogService_FollowContainerLogs_Call{Call: _e.mock.On("FollowContainerLogs", ctx, domain, initialLines)}
7979 }
8080
@@ -147,7 +147,7 @@ type MockLogService_FollowProcessLogs_Call struct {
147147 // FollowProcessLogs is a helper method to define mock.On call
148148 // - ctx context.Context
149149 // - initialLines int
150-func (_e *MockLogService_Expecter) FollowProcessLogs(ctx interface{}, initialLines interface{}) *MockLogService_FollowProcessLogs_Call {
150+func (_e *MockLogService_Expecter) FollowProcessLogs(ctx any, initialLines any) *MockLogService_FollowProcessLogs_Call {
151151 return &MockLogService_FollowProcessLogs_Call{Call: _e.mock.On("FollowProcessLogs", ctx, initialLines)}
152152 }
153153
@@ -216,7 +216,7 @@ type MockLogService_GetContainerLogs_Call struct {
216216 // - ctx context.Context
217217 // - domain string
218218 // - lines int
219-func (_e *MockLogService_Expecter) GetContainerLogs(ctx interface{}, domain interface{}, lines interface{}) *MockLogService_GetContainerLogs_Call {
219+func (_e *MockLogService_Expecter) GetContainerLogs(ctx any, domain any, lines any) *MockLogService_GetContainerLogs_Call {
220220 return &MockLogService_GetContainerLogs_Call{Call: _e.mock.On("GetContainerLogs", ctx, domain, lines)}
221221 }
222222
@@ -289,7 +289,7 @@ type MockLogService_GetProcessLogs_Call struct {
289289 // GetProcessLogs is a helper method to define mock.On call
290290 // - ctx context.Context
291291 // - lines int
292-func (_e *MockLogService_Expecter) GetProcessLogs(ctx interface{}, lines interface{}) *MockLogService_GetProcessLogs_Call {
292+func (_e *MockLogService_Expecter) GetProcessLogs(ctx any, lines any) *MockLogService_GetProcessLogs_Call {
293293 return &MockLogService_GetProcessLogs_Call{Call: _e.mock.On("GetProcessLogs", ctx, lines)}
294294 }
295295
M internal/boundaries/in/mocks/mock_proxy_service.go +7 -7
@@ -75,7 +75,7 @@ type MockProxyService_GetTarget_Call struct {
7575 // GetTarget is a helper method to define mock.On call
7676 // - ctx context.Context
7777 // - domain1 string
78-func (_e *MockProxyService_Expecter) GetTarget(ctx interface{}, domain1 interface{}) *MockProxyService_GetTarget_Call {
78+func (_e *MockProxyService_Expecter) GetTarget(ctx any, domain1 any) *MockProxyService_GetTarget_Call {
7979 return &MockProxyService_GetTarget_Call{Call: _e.mock.On("GetTarget", ctx, domain1)}
8080 }
8181
@@ -132,7 +132,7 @@ type MockProxyService_IsKnownHost_Call struct {
132132 // IsKnownHost is a helper method to define mock.On call
133133 // - ctx context.Context
134134 // - host string
135-func (_e *MockProxyService_Expecter) IsKnownHost(ctx interface{}, host interface{}) *MockProxyService_IsKnownHost_Call {
135+func (_e *MockProxyService_Expecter) IsKnownHost(ctx any, host any) *MockProxyService_IsKnownHost_Call {
136136 return &MockProxyService_IsKnownHost_Call{Call: _e.mock.On("IsKnownHost", ctx, host)}
137137 }
138138
@@ -188,7 +188,7 @@ type MockProxyService_IsRegistryDomain_Call struct {
188188
189189 // IsRegistryDomain is a helper method to define mock.On call
190190 // - host string
191-func (_e *MockProxyService_Expecter) IsRegistryDomain(host interface{}) *MockProxyService_IsRegistryDomain_Call {
191+func (_e *MockProxyService_Expecter) IsRegistryDomain(host any) *MockProxyService_IsRegistryDomain_Call {
192192 return &MockProxyService_IsRegistryDomain_Call{Call: _e.mock.On("IsRegistryDomain", host)}
193193 }
194194
@@ -283,7 +283,7 @@ type MockProxyService_RefreshTargets_Call struct {
283283
284284 // RefreshTargets is a helper method to define mock.On call
285285 // - ctx context.Context
286-func (_e *MockProxyService_Expecter) RefreshTargets(ctx interface{}) *MockProxyService_RefreshTargets_Call {
286+func (_e *MockProxyService_Expecter) RefreshTargets(ctx any) *MockProxyService_RefreshTargets_Call {
287287 return &MockProxyService_RefreshTargets_Call{Call: _e.mock.On("RefreshTargets", ctx)}
288288 }
289289
@@ -336,7 +336,7 @@ type MockProxyService_RegisterTarget_Call struct {
336336 // - ctx context.Context
337337 // - domain1 string
338338 // - target *domain.ProxyTarget
339-func (_e *MockProxyService_Expecter) RegisterTarget(ctx interface{}, domain1 interface{}, target interface{}) *MockProxyService_RegisterTarget_Call {
339+func (_e *MockProxyService_Expecter) RegisterTarget(ctx any, domain1 any, target any) *MockProxyService_RegisterTarget_Call {
340340 return &MockProxyService_RegisterTarget_Call{Call: _e.mock.On("RegisterTarget", ctx, domain1, target)}
341341 }
342342
@@ -432,7 +432,7 @@ type MockProxyService_TrackInFlight_Call struct {
432432
433433 // TrackInFlight is a helper method to define mock.On call
434434 // - containerID string
435-func (_e *MockProxyService_Expecter) TrackInFlight(containerID interface{}) *MockProxyService_TrackInFlight_Call {
435+func (_e *MockProxyService_Expecter) TrackInFlight(containerID any) *MockProxyService_TrackInFlight_Call {
436436 return &MockProxyService_TrackInFlight_Call{Call: _e.mock.On("TrackInFlight", containerID)}
437437 }
438438
@@ -517,7 +517,7 @@ type MockProxyService_UnregisterTarget_Call struct {
517517 // UnregisterTarget is a helper method to define mock.On call
518518 // - ctx context.Context
519519 // - domain1 string
520-func (_e *MockProxyService_Expecter) UnregisterTarget(ctx interface{}, domain1 interface{}) *MockProxyService_UnregisterTarget_Call {
520+func (_e *MockProxyService_Expecter) UnregisterTarget(ctx any, domain1 any) *MockProxyService_UnregisterTarget_Call {
521521 return &MockProxyService_UnregisterTarget_Call{Call: _e.mock.On("UnregisterTarget", ctx, domain1)}
522522 }
523523
M internal/boundaries/in/mocks/mock_registry_service.go +13 -13
@@ -77,7 +77,7 @@ type MockRegistryService_AppendBlobChunk_Call struct {
7777 // - data io.Reader
7878 // - contentLength int64
7979 // - maxBlobSize int64
80-func (_e *MockRegistryService_Expecter) AppendBlobChunk(ctx interface{}, name interface{}, uuid interface{}, data interface{}, contentLength interface{}, maxBlobSize interface{}) *MockRegistryService_AppendBlobChunk_Call {
80+func (_e *MockRegistryService_Expecter) AppendBlobChunk(ctx any, name any, uuid any, data any, contentLength any, maxBlobSize any) *MockRegistryService_AppendBlobChunk_Call {
8181 return &MockRegistryService_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", ctx, name, uuid, data, contentLength, maxBlobSize)}
8282 }
8383
@@ -154,7 +154,7 @@ type MockRegistryService_BlobExists_Call struct {
154154 // BlobExists is a helper method to define mock.On call
155155 // - ctx context.Context
156156 // - digest string
157-func (_e *MockRegistryService_Expecter) BlobExists(ctx interface{}, digest interface{}) *MockRegistryService_BlobExists_Call {
157+func (_e *MockRegistryService_Expecter) BlobExists(ctx any, digest any) *MockRegistryService_BlobExists_Call {
158158 return &MockRegistryService_BlobExists_Call{Call: _e.mock.On("BlobExists", ctx, digest)}
159159 }
160160
@@ -211,7 +211,7 @@ type MockRegistryService_CancelUpload_Call struct {
211211 // CancelUpload is a helper method to define mock.On call
212212 // - ctx context.Context
213213 // - uuid string
214-func (_e *MockRegistryService_Expecter) CancelUpload(ctx interface{}, uuid interface{}) *MockRegistryService_CancelUpload_Call {
214+func (_e *MockRegistryService_Expecter) CancelUpload(ctx any, uuid any) *MockRegistryService_CancelUpload_Call {
215215 return &MockRegistryService_CancelUpload_Call{Call: _e.mock.On("CancelUpload", ctx, uuid)}
216216 }
217217
@@ -269,7 +269,7 @@ type MockRegistryService_DeleteManifest_Call struct {
269269 // - ctx context.Context
270270 // - name string
271271 // - reference string
272-func (_e *MockRegistryService_Expecter) DeleteManifest(ctx interface{}, name interface{}, reference interface{}) *MockRegistryService_DeleteManifest_Call {
272+func (_e *MockRegistryService_Expecter) DeleteManifest(ctx any, name any, reference any) *MockRegistryService_DeleteManifest_Call {
273273 return &MockRegistryService_DeleteManifest_Call{Call: _e.mock.On("DeleteManifest", ctx, name, reference)}
274274 }
275275
@@ -332,7 +332,7 @@ type MockRegistryService_FinishUpload_Call struct {
332332 // - ctx context.Context
333333 // - uuid string
334334 // - digest string
335-func (_e *MockRegistryService_Expecter) FinishUpload(ctx interface{}, uuid interface{}, digest interface{}) *MockRegistryService_FinishUpload_Call {
335+func (_e *MockRegistryService_Expecter) FinishUpload(ctx any, uuid any, digest any) *MockRegistryService_FinishUpload_Call {
336336 return &MockRegistryService_FinishUpload_Call{Call: _e.mock.On("FinishUpload", ctx, uuid, digest)}
337337 }
338338
@@ -405,7 +405,7 @@ type MockRegistryService_GetBlob_Call struct {
405405 // GetBlob is a helper method to define mock.On call
406406 // - ctx context.Context
407407 // - digest string
408-func (_e *MockRegistryService_Expecter) GetBlob(ctx interface{}, digest interface{}) *MockRegistryService_GetBlob_Call {
408+func (_e *MockRegistryService_Expecter) GetBlob(ctx any, digest any) *MockRegistryService_GetBlob_Call {
409409 return &MockRegistryService_GetBlob_Call{Call: _e.mock.On("GetBlob", ctx, digest)}
410410 }
411411
@@ -471,7 +471,7 @@ type MockRegistryService_GetBlobPath_Call struct {
471471 // GetBlobPath is a helper method to define mock.On call
472472 // - ctx context.Context
473473 // - digest string
474-func (_e *MockRegistryService_Expecter) GetBlobPath(ctx interface{}, digest interface{}) *MockRegistryService_GetBlobPath_Call {
474+func (_e *MockRegistryService_Expecter) GetBlobPath(ctx any, digest any) *MockRegistryService_GetBlobPath_Call {
475475 return &MockRegistryService_GetBlobPath_Call{Call: _e.mock.On("GetBlobPath", ctx, digest)}
476476 }
477477
@@ -540,7 +540,7 @@ type MockRegistryService_GetManifest_Call struct {
540540 // - ctx context.Context
541541 // - name string
542542 // - reference string
543-func (_e *MockRegistryService_Expecter) GetManifest(ctx interface{}, name interface{}, reference interface{}) *MockRegistryService_GetManifest_Call {
543+func (_e *MockRegistryService_Expecter) GetManifest(ctx any, name any, reference any) *MockRegistryService_GetManifest_Call {
544544 return &MockRegistryService_GetManifest_Call{Call: _e.mock.On("GetManifest", ctx, name, reference)}
545545 }
546546
@@ -612,7 +612,7 @@ type MockRegistryService_ListRepositories_Call struct {
612612
613613 // ListRepositories is a helper method to define mock.On call
614614 // - ctx context.Context
615-func (_e *MockRegistryService_Expecter) ListRepositories(ctx interface{}) *MockRegistryService_ListRepositories_Call {
615+func (_e *MockRegistryService_Expecter) ListRepositories(ctx any) *MockRegistryService_ListRepositories_Call {
616616 return &MockRegistryService_ListRepositories_Call{Call: _e.mock.On("ListRepositories", ctx)}
617617 }
618618
@@ -675,7 +675,7 @@ type MockRegistryService_ListTags_Call struct {
675675 // ListTags is a helper method to define mock.On call
676676 // - ctx context.Context
677677 // - name string
678-func (_e *MockRegistryService_Expecter) ListTags(ctx interface{}, name interface{}) *MockRegistryService_ListTags_Call {
678+func (_e *MockRegistryService_Expecter) ListTags(ctx any, name any) *MockRegistryService_ListTags_Call {
679679 return &MockRegistryService_ListTags_Call{Call: _e.mock.On("ListTags", ctx, name)}
680680 }
681681
@@ -734,7 +734,7 @@ type MockRegistryService_PutBlob_Call struct {
734734 // - digest string
735735 // - data io.Reader
736736 // - size int64
737-func (_e *MockRegistryService_Expecter) PutBlob(ctx interface{}, digest interface{}, data interface{}, size interface{}) *MockRegistryService_PutBlob_Call {
737+func (_e *MockRegistryService_Expecter) PutBlob(ctx any, digest any, data any, size any) *MockRegistryService_PutBlob_Call {
738738 return &MockRegistryService_PutBlob_Call{Call: _e.mock.On("PutBlob", ctx, digest, data, size)}
739739 }
740740
@@ -810,7 +810,7 @@ type MockRegistryService_PutManifest_Call struct {
810810 // PutManifest is a helper method to define mock.On call
811811 // - ctx context.Context
812812 // - manifest *domain.Manifest
813-func (_e *MockRegistryService_Expecter) PutManifest(ctx interface{}, manifest interface{}) *MockRegistryService_PutManifest_Call {
813+func (_e *MockRegistryService_Expecter) PutManifest(ctx any, manifest any) *MockRegistryService_PutManifest_Call {
814814 return &MockRegistryService_PutManifest_Call{Call: _e.mock.On("PutManifest", ctx, manifest)}
815815 }
816816
@@ -876,7 +876,7 @@ type MockRegistryService_StartUpload_Call struct {
876876 // StartUpload is a helper method to define mock.On call
877877 // - ctx context.Context
878878 // - name string
879-func (_e *MockRegistryService_Expecter) StartUpload(ctx interface{}, name interface{}) *MockRegistryService_StartUpload_Call {
879+func (_e *MockRegistryService_Expecter) StartUpload(ctx any, name any) *MockRegistryService_StartUpload_Call {
880880 return &MockRegistryService_StartUpload_Call{Call: _e.mock.On("StartUpload", ctx, name)}
881881 }
882882
M internal/boundaries/in/mocks/mock_secret_service.go +7 -7
@@ -64,7 +64,7 @@ type MockSecretService_Delete_Call struct {
6464 // - ctx context.Context
6565 // - domain string
6666 // - key string
67-func (_e *MockSecretService_Expecter) Delete(ctx interface{}, domain interface{}, key interface{}) *MockSecretService_Delete_Call {
67+func (_e *MockSecretService_Expecter) Delete(ctx any, domain any, key any) *MockSecretService_Delete_Call {
6868 return &MockSecretService_Delete_Call{Call: _e.mock.On("Delete", ctx, domain, key)}
6969 }
7070
@@ -128,7 +128,7 @@ type MockSecretService_DeleteAttachment_Call struct {
128128 // - domain string
129129 // - service string
130130 // - key string
131-func (_e *MockSecretService_Expecter) DeleteAttachment(ctx interface{}, domain interface{}, service interface{}, key interface{}) *MockSecretService_DeleteAttachment_Call {
131+func (_e *MockSecretService_Expecter) DeleteAttachment(ctx any, domain any, service any, key any) *MockSecretService_DeleteAttachment_Call {
132132 return &MockSecretService_DeleteAttachment_Call{Call: _e.mock.On("DeleteAttachment", ctx, domain, service, key)}
133133 }
134134
@@ -206,7 +206,7 @@ type MockSecretService_GetAll_Call struct {
206206 // GetAll is a helper method to define mock.On call
207207 // - ctx context.Context
208208 // - domain string
209-func (_e *MockSecretService_Expecter) GetAll(ctx interface{}, domain interface{}) *MockSecretService_GetAll_Call {
209+func (_e *MockSecretService_Expecter) GetAll(ctx any, domain any) *MockSecretService_GetAll_Call {
210210 return &MockSecretService_GetAll_Call{Call: _e.mock.On("GetAll", ctx, domain)}
211211 }
212212
@@ -274,7 +274,7 @@ type MockSecretService_ListKeys_Call struct {
274274 // ListKeys is a helper method to define mock.On call
275275 // - ctx context.Context
276276 // - domain string
277-func (_e *MockSecretService_Expecter) ListKeys(ctx interface{}, domain interface{}) *MockSecretService_ListKeys_Call {
277+func (_e *MockSecretService_Expecter) ListKeys(ctx any, domain any) *MockSecretService_ListKeys_Call {
278278 return &MockSecretService_ListKeys_Call{Call: _e.mock.On("ListKeys", ctx, domain)}
279279 }
280280
@@ -350,7 +350,7 @@ type MockSecretService_ListKeysWithAttachments_Call struct {
350350 // ListKeysWithAttachments is a helper method to define mock.On call
351351 // - ctx context.Context
352352 // - domain string
353-func (_e *MockSecretService_Expecter) ListKeysWithAttachments(ctx interface{}, domain interface{}) *MockSecretService_ListKeysWithAttachments_Call {
353+func (_e *MockSecretService_Expecter) ListKeysWithAttachments(ctx any, domain any) *MockSecretService_ListKeysWithAttachments_Call {
354354 return &MockSecretService_ListKeysWithAttachments_Call{Call: _e.mock.On("ListKeysWithAttachments", ctx, domain)}
355355 }
356356
@@ -408,7 +408,7 @@ type MockSecretService_Set_Call struct {
408408 // - ctx context.Context
409409 // - domain string
410410 // - secrets map[string]string
411-func (_e *MockSecretService_Expecter) Set(ctx interface{}, domain interface{}, secrets interface{}) *MockSecretService_Set_Call {
411+func (_e *MockSecretService_Expecter) Set(ctx any, domain any, secrets any) *MockSecretService_Set_Call {
412412 return &MockSecretService_Set_Call{Call: _e.mock.On("Set", ctx, domain, secrets)}
413413 }
414414
@@ -472,7 +472,7 @@ type MockSecretService_SetAttachment_Call struct {
472472 // - domain string
473473 // - service string
474474 // - secrets map[string]string
475-func (_e *MockSecretService_Expecter) SetAttachment(ctx interface{}, domain interface{}, service interface{}, secrets interface{}) *MockSecretService_SetAttachment_Call {
475+func (_e *MockSecretService_Expecter) SetAttachment(ctx any, domain any, service any, secrets any) *MockSecretService_SetAttachment_Call {
476476 return &MockSecretService_SetAttachment_Call{Call: _e.mock.On("SetAttachment", ctx, domain, service, secrets)}
477477 }
478478
A internal/boundaries/in/mocks/mock_standalone_service_service.go +158 -0
@@ -0,0 +1,158 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockStandaloneServiceService creates a new instance of MockStandaloneServiceService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockStandaloneServiceService(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockStandaloneServiceService {
20+ mock := &MockStandaloneServiceService{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockStandaloneServiceService is an autogenerated mock type for the StandaloneServiceService type
29+type MockStandaloneServiceService struct {
30+ mock.Mock
31+}
32+
33+type MockStandaloneServiceService_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockStandaloneServiceService) EXPECT() *MockStandaloneServiceService_Expecter {
38+ return &MockStandaloneServiceService_Expecter{mock: &_m.Mock}
39+}
40+
41+// Reconcile provides a mock function for the type MockStandaloneServiceService
42+func (_mock *MockStandaloneServiceService) Reconcile(ctx context.Context, services []domain.StandaloneService) error {
43+ ret := _mock.Called(ctx, services)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for Reconcile")
47+ }
48+
49+ var r0 error
50+ if returnFunc, ok := ret.Get(0).(func(context.Context, []domain.StandaloneService) error); ok {
51+ r0 = returnFunc(ctx, services)
52+ } else {
53+ r0 = ret.Error(0)
54+ }
55+ return r0
56+}
57+
58+// MockStandaloneServiceService_Reconcile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reconcile'
59+type MockStandaloneServiceService_Reconcile_Call struct {
60+ *mock.Call
61+}
62+
63+// Reconcile is a helper method to define mock.On call
64+// - ctx context.Context
65+// - services []domain.StandaloneService
66+func (_e *MockStandaloneServiceService_Expecter) Reconcile(ctx any, services any) *MockStandaloneServiceService_Reconcile_Call {
67+ return &MockStandaloneServiceService_Reconcile_Call{Call: _e.mock.On("Reconcile", ctx, services)}
68+}
69+
70+func (_c *MockStandaloneServiceService_Reconcile_Call) Run(run func(ctx context.Context, services []domain.StandaloneService)) *MockStandaloneServiceService_Reconcile_Call {
71+ _c.Call.Run(func(args mock.Arguments) {
72+ var arg0 context.Context
73+ if args[0] != nil {
74+ arg0 = args[0].(context.Context)
75+ }
76+ var arg1 []domain.StandaloneService
77+ if args[1] != nil {
78+ arg1 = args[1].([]domain.StandaloneService)
79+ }
80+ run(
81+ arg0,
82+ arg1,
83+ )
84+ })
85+ return _c
86+}
87+
88+func (_c *MockStandaloneServiceService_Reconcile_Call) Return(err error) *MockStandaloneServiceService_Reconcile_Call {
89+ _c.Call.Return(err)
90+ return _c
91+}
92+
93+func (_c *MockStandaloneServiceService_Reconcile_Call) RunAndReturn(run func(ctx context.Context, services []domain.StandaloneService) error) *MockStandaloneServiceService_Reconcile_Call {
94+ _c.Call.Return(run)
95+ return _c
96+}
97+
98+// Status provides a mock function for the type MockStandaloneServiceService
99+func (_mock *MockStandaloneServiceService) Status(ctx context.Context) ([]domain.StandaloneServiceStatus, error) {
100+ ret := _mock.Called(ctx)
101+
102+ if len(ret) == 0 {
103+ panic("no return value specified for Status")
104+ }
105+
106+ var r0 []domain.StandaloneServiceStatus
107+ var r1 error
108+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.StandaloneServiceStatus, error)); ok {
109+ return returnFunc(ctx)
110+ }
111+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.StandaloneServiceStatus); ok {
112+ r0 = returnFunc(ctx)
113+ } else {
114+ if ret.Get(0) != nil {
115+ r0 = ret.Get(0).([]domain.StandaloneServiceStatus)
116+ }
117+ }
118+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
119+ r1 = returnFunc(ctx)
120+ } else {
121+ r1 = ret.Error(1)
122+ }
123+ return r0, r1
124+}
125+
126+// MockStandaloneServiceService_Status_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Status'
127+type MockStandaloneServiceService_Status_Call struct {
128+ *mock.Call
129+}
130+
131+// Status is a helper method to define mock.On call
132+// - ctx context.Context
133+func (_e *MockStandaloneServiceService_Expecter) Status(ctx any) *MockStandaloneServiceService_Status_Call {
134+ return &MockStandaloneServiceService_Status_Call{Call: _e.mock.On("Status", ctx)}
135+}
136+
137+func (_c *MockStandaloneServiceService_Status_Call) Run(run func(ctx context.Context)) *MockStandaloneServiceService_Status_Call {
138+ _c.Call.Run(func(args mock.Arguments) {
139+ var arg0 context.Context
140+ if args[0] != nil {
141+ arg0 = args[0].(context.Context)
142+ }
143+ run(
144+ arg0,
145+ )
146+ })
147+ return _c
148+}
149+
150+func (_c *MockStandaloneServiceService_Status_Call) Return(standaloneServiceStatuss []domain.StandaloneServiceStatus, err error) *MockStandaloneServiceService_Status_Call {
151+ _c.Call.Return(standaloneServiceStatuss, err)
152+ return _c
153+}
154+
155+func (_c *MockStandaloneServiceService_Status_Call) RunAndReturn(run func(ctx context.Context) ([]domain.StandaloneServiceStatus, error)) *MockStandaloneServiceService_Status_Call {
156+ _c.Call.Return(run)
157+ return _c
158+}
A internal/boundaries/in/mocks/mock_traffic_status_service.go +81 -0
@@ -0,0 +1,81 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "github.com/bnema/gordon/internal/domain"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// NewMockTrafficStatusService creates a new instance of MockTrafficStatusService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func NewMockTrafficStatusService(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *MockTrafficStatusService {
18+ mock := &MockTrafficStatusService{}
19+ mock.Mock.Test(t)
20+
21+ t.Cleanup(func() { mock.AssertExpectations(t) })
22+
23+ return mock
24+}
25+
26+// MockTrafficStatusService is an autogenerated mock type for the TrafficStatusService type
27+type MockTrafficStatusService struct {
28+ mock.Mock
29+}
30+
31+type MockTrafficStatusService_Expecter struct {
32+ mock *mock.Mock
33+}
34+
35+func (_m *MockTrafficStatusService) EXPECT() *MockTrafficStatusService_Expecter {
36+ return &MockTrafficStatusService_Expecter{mock: &_m.Mock}
37+}
38+
39+// Status provides a mock function for the type MockTrafficStatusService
40+func (_mock *MockTrafficStatusService) Status() domain.TrafficStatus {
41+ ret := _mock.Called()
42+
43+ if len(ret) == 0 {
44+ panic("no return value specified for Status")
45+ }
46+
47+ var r0 domain.TrafficStatus
48+ if returnFunc, ok := ret.Get(0).(func() domain.TrafficStatus); ok {
49+ r0 = returnFunc()
50+ } else {
51+ r0 = ret.Get(0).(domain.TrafficStatus)
52+ }
53+ return r0
54+}
55+
56+// MockTrafficStatusService_Status_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Status'
57+type MockTrafficStatusService_Status_Call struct {
58+ *mock.Call
59+}
60+
61+// Status is a helper method to define mock.On call
62+func (_e *MockTrafficStatusService_Expecter) Status() *MockTrafficStatusService_Status_Call {
63+ return &MockTrafficStatusService_Status_Call{Call: _e.mock.On("Status")}
64+}
65+
66+func (_c *MockTrafficStatusService_Status_Call) Run(run func()) *MockTrafficStatusService_Status_Call {
67+ _c.Call.Run(func(args mock.Arguments) {
68+ run()
69+ })
70+ return _c
71+}
72+
73+func (_c *MockTrafficStatusService_Status_Call) Return(trafficStatus domain.TrafficStatus) *MockTrafficStatusService_Status_Call {
74+ _c.Call.Return(trafficStatus)
75+ return _c
76+}
77+
78+func (_c *MockTrafficStatusService_Status_Call) RunAndReturn(run func() domain.TrafficStatus) *MockTrafficStatusService_Status_Call {
79+ _c.Call.Return(run)
80+ return _c
81+}
M internal/boundaries/in/mocks/mock_volume_backup_service.go +3 -3
@@ -74,7 +74,7 @@ type MockVolumeBackupService_ListVolumeBackups_Call struct {
7474 // ListVolumeBackups is a helper method to define mock.On call
7575 // - ctx context.Context
7676 // - domainName string
77-func (_e *MockVolumeBackupService_Expecter) ListVolumeBackups(ctx interface{}, domainName interface{}) *MockVolumeBackupService_ListVolumeBackups_Call {
77+func (_e *MockVolumeBackupService_Expecter) ListVolumeBackups(ctx any, domainName any) *MockVolumeBackupService_ListVolumeBackups_Call {
7878 return &MockVolumeBackupService_ListVolumeBackups_Call{Call: _e.mock.On("ListVolumeBackups", ctx, domainName)}
7979 }
8080
@@ -143,7 +143,7 @@ type MockVolumeBackupService_RunVolumeBackups_Call struct {
143143 // - ctx context.Context
144144 // - domainName string
145145 // - volumeName string
146-func (_e *MockVolumeBackupService_Expecter) RunVolumeBackups(ctx interface{}, domainName interface{}, volumeName interface{}) *MockVolumeBackupService_RunVolumeBackups_Call {
146+func (_e *MockVolumeBackupService_Expecter) RunVolumeBackups(ctx any, domainName any, volumeName any) *MockVolumeBackupService_RunVolumeBackups_Call {
147147 return &MockVolumeBackupService_RunVolumeBackups_Call{Call: _e.mock.On("RunVolumeBackups", ctx, domainName, volumeName)}
148148 }
149149
@@ -215,7 +215,7 @@ type MockVolumeBackupService_VolumeBackupStatus_Call struct {
215215
216216 // VolumeBackupStatus is a helper method to define mock.On call
217217 // - ctx context.Context
218-func (_e *MockVolumeBackupService_Expecter) VolumeBackupStatus(ctx interface{}) *MockVolumeBackupService_VolumeBackupStatus_Call {
218+func (_e *MockVolumeBackupService_Expecter) VolumeBackupStatus(ctx any) *MockVolumeBackupService_VolumeBackupStatus_Call {
219219 return &MockVolumeBackupService_VolumeBackupStatus_Call{Call: _e.mock.On("VolumeBackupStatus", ctx)}
220220 }
221221
M internal/boundaries/in/mocks/mock_volume_service.go +2 -2
@@ -73,7 +73,7 @@ type MockVolumeService_ListVolumes_Call struct {
7373
7474 // ListVolumes is a helper method to define mock.On call
7575 // - ctx context.Context
76-func (_e *MockVolumeService_Expecter) ListVolumes(ctx interface{}) *MockVolumeService_ListVolumes_Call {
76+func (_e *MockVolumeService_Expecter) ListVolumes(ctx any) *MockVolumeService_ListVolumes_Call {
7777 return &MockVolumeService_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)}
7878 }
7979
@@ -144,7 +144,7 @@ type MockVolumeService_PruneVolumes_Call struct {
144144 // PruneVolumes is a helper method to define mock.On call
145145 // - ctx context.Context
146146 // - dryRun bool
147-func (_e *MockVolumeService_Expecter) PruneVolumes(ctx interface{}, dryRun interface{}) *MockVolumeService_PruneVolumes_Call {
147+func (_e *MockVolumeService_Expecter) PruneVolumes(ctx any, dryRun any) *MockVolumeService_PruneVolumes_Call {
148148 return &MockVolumeService_PruneVolumes_Call{Call: _e.mock.On("PruneVolumes", ctx, dryRun)}
149149 }
150150
A internal/boundaries/in/services.go +16 -0
@@ -0,0 +1,16 @@
1+// Package in defines input ports (interfaces) for use cases.
2+// These interfaces define the contract between driving adapters (HTTP, CLI)
3+// and the business logic (use cases).
4+package in
5+
6+import (
7+ "context"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+// StandaloneServiceService defines standalone service lifecycle operations.
13+type StandaloneServiceService interface {
14+ Reconcile(ctx context.Context, services []domain.StandaloneService) error
15+ Status(ctx context.Context) ([]domain.StandaloneServiceStatus, error)
16+}
A internal/boundaries/in/traffic.go +8 -0
@@ -0,0 +1,8 @@
1+package in
2+
3+import "github.com/bnema/gordon/internal/domain"
4+
5+// TrafficStatusService exposes runtime traffic-plane status.
6+type TrafficStatusService interface {
7+ Status() domain.TrafficStatus
8+}
M internal/boundaries/out/mocks/mock_backup_storage.go +5 -5
@@ -75,7 +75,7 @@ type MockBackupStorage_ApplyRetention_Call struct {
7575 // - ctx context.Context
7676 // - domainName string
7777 // - policy domain.DatabaseBackupRetentionPolicy
78-func (_e *MockBackupStorage_Expecter) ApplyRetention(ctx interface{}, domainName interface{}, policy interface{}) *MockBackupStorage_ApplyRetention_Call {
78+func (_e *MockBackupStorage_Expecter) ApplyRetention(ctx any, domainName any, policy any) *MockBackupStorage_ApplyRetention_Call {
7979 return &MockBackupStorage_ApplyRetention_Call{Call: _e.mock.On("ApplyRetention", ctx, domainName, policy)}
8080 }
8181
@@ -137,7 +137,7 @@ type MockBackupStorage_Delete_Call struct {
137137 // Delete is a helper method to define mock.On call
138138 // - ctx context.Context
139139 // - path string
140-func (_e *MockBackupStorage_Expecter) Delete(ctx interface{}, path interface{}) *MockBackupStorage_Delete_Call {
140+func (_e *MockBackupStorage_Expecter) Delete(ctx any, path any) *MockBackupStorage_Delete_Call {
141141 return &MockBackupStorage_Delete_Call{Call: _e.mock.On("Delete", ctx, path)}
142142 }
143143
@@ -205,7 +205,7 @@ type MockBackupStorage_Get_Call struct {
205205 // Get is a helper method to define mock.On call
206206 // - ctx context.Context
207207 // - path string
208-func (_e *MockBackupStorage_Expecter) Get(ctx interface{}, path interface{}) *MockBackupStorage_Get_Call {
208+func (_e *MockBackupStorage_Expecter) Get(ctx any, path any) *MockBackupStorage_Get_Call {
209209 return &MockBackupStorage_Get_Call{Call: _e.mock.On("Get", ctx, path)}
210210 }
211211
@@ -274,7 +274,7 @@ type MockBackupStorage_List_Call struct {
274274 // - ctx context.Context
275275 // - domainName string
276276 // - schedule *domain.BackupSchedule
277-func (_e *MockBackupStorage_Expecter) List(ctx interface{}, domainName interface{}, schedule interface{}) *MockBackupStorage_List_Call {
277+func (_e *MockBackupStorage_Expecter) List(ctx any, domainName any, schedule any) *MockBackupStorage_List_Call {
278278 return &MockBackupStorage_List_Call{Call: _e.mock.On("List", ctx, domainName, schedule)}
279279 }
280280
@@ -349,7 +349,7 @@ type MockBackupStorage_Store_Call struct {
349349 // - schedule domain.BackupSchedule
350350 // - timestamp time.Time
351351 // - data io.Reader
352-func (_e *MockBackupStorage_Expecter) Store(ctx interface{}, domainName interface{}, dbName interface{}, schedule interface{}, timestamp interface{}, data interface{}) *MockBackupStorage_Store_Call {
352+func (_e *MockBackupStorage_Expecter) Store(ctx any, domainName any, dbName any, schedule any, timestamp any, data any) *MockBackupStorage_Store_Call {
353353 return &MockBackupStorage_Store_Call{Call: _e.mock.On("Store", ctx, domainName, dbName, schedule, timestamp, data)}
354354 }
355355
M internal/boundaries/out/mocks/mock_blob_storage.go +11 -11
@@ -75,7 +75,7 @@ type MockBlobStorage_AppendBlobChunk_Call struct {
7575 // - data io.Reader
7676 // - contentLength int64
7777 // - maxBlobSize int64
78-func (_e *MockBlobStorage_Expecter) AppendBlobChunk(name interface{}, uuid interface{}, data interface{}, contentLength interface{}, maxBlobSize interface{}) *MockBlobStorage_AppendBlobChunk_Call {
78+func (_e *MockBlobStorage_Expecter) AppendBlobChunk(name any, uuid any, data any, contentLength any, maxBlobSize any) *MockBlobStorage_AppendBlobChunk_Call {
7979 return &MockBlobStorage_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", name, uuid, data, contentLength, maxBlobSize)}
8080 }
8181
@@ -146,7 +146,7 @@ type MockBlobStorage_BlobExists_Call struct {
146146
147147 // BlobExists is a helper method to define mock.On call
148148 // - digest string
149-func (_e *MockBlobStorage_Expecter) BlobExists(digest interface{}) *MockBlobStorage_BlobExists_Call {
149+func (_e *MockBlobStorage_Expecter) BlobExists(digest any) *MockBlobStorage_BlobExists_Call {
150150 return &MockBlobStorage_BlobExists_Call{Call: _e.mock.On("BlobExists", digest)}
151151 }
152152
@@ -197,7 +197,7 @@ type MockBlobStorage_CancelBlobUpload_Call struct {
197197
198198 // CancelBlobUpload is a helper method to define mock.On call
199199 // - uuid string
200-func (_e *MockBlobStorage_Expecter) CancelBlobUpload(uuid interface{}) *MockBlobStorage_CancelBlobUpload_Call {
200+func (_e *MockBlobStorage_Expecter) CancelBlobUpload(uuid any) *MockBlobStorage_CancelBlobUpload_Call {
201201 return &MockBlobStorage_CancelBlobUpload_Call{Call: _e.mock.On("CancelBlobUpload", uuid)}
202202 }
203203
@@ -263,7 +263,7 @@ type MockBlobStorage_CleanupStaleUploads_Call struct {
263263
264264 // CleanupStaleUploads is a helper method to define mock.On call
265265 // - maxAge time.Duration
266-func (_e *MockBlobStorage_Expecter) CleanupStaleUploads(maxAge interface{}) *MockBlobStorage_CleanupStaleUploads_Call {
266+func (_e *MockBlobStorage_Expecter) CleanupStaleUploads(maxAge any) *MockBlobStorage_CleanupStaleUploads_Call {
267267 return &MockBlobStorage_CleanupStaleUploads_Call{Call: _e.mock.On("CleanupStaleUploads", maxAge)}
268268 }
269269
@@ -323,7 +323,7 @@ type MockBlobStorage_DeleteBlob_Call struct {
323323
324324 // DeleteBlob is a helper method to define mock.On call
325325 // - digest string
326-func (_e *MockBlobStorage_Expecter) DeleteBlob(digest interface{}) *MockBlobStorage_DeleteBlob_Call {
326+func (_e *MockBlobStorage_Expecter) DeleteBlob(digest any) *MockBlobStorage_DeleteBlob_Call {
327327 return &MockBlobStorage_DeleteBlob_Call{Call: _e.mock.On("DeleteBlob", digest)}
328328 }
329329
@@ -375,7 +375,7 @@ type MockBlobStorage_FinishBlobUpload_Call struct {
375375 // FinishBlobUpload is a helper method to define mock.On call
376376 // - uuid string
377377 // - digest string
378-func (_e *MockBlobStorage_Expecter) FinishBlobUpload(uuid interface{}, digest interface{}) *MockBlobStorage_FinishBlobUpload_Call {
378+func (_e *MockBlobStorage_Expecter) FinishBlobUpload(uuid any, digest any) *MockBlobStorage_FinishBlobUpload_Call {
379379 return &MockBlobStorage_FinishBlobUpload_Call{Call: _e.mock.On("FinishBlobUpload", uuid, digest)}
380380 }
381381
@@ -442,7 +442,7 @@ type MockBlobStorage_GetBlob_Call struct {
442442
443443 // GetBlob is a helper method to define mock.On call
444444 // - digest string
445-func (_e *MockBlobStorage_Expecter) GetBlob(digest interface{}) *MockBlobStorage_GetBlob_Call {
445+func (_e *MockBlobStorage_Expecter) GetBlob(digest any) *MockBlobStorage_GetBlob_Call {
446446 return &MockBlobStorage_GetBlob_Call{Call: _e.mock.On("GetBlob", digest)}
447447 }
448448
@@ -502,7 +502,7 @@ type MockBlobStorage_GetBlobPath_Call struct {
502502
503503 // GetBlobPath is a helper method to define mock.On call
504504 // - digest string
505-func (_e *MockBlobStorage_Expecter) GetBlobPath(digest interface{}) *MockBlobStorage_GetBlobPath_Call {
505+func (_e *MockBlobStorage_Expecter) GetBlobPath(digest any) *MockBlobStorage_GetBlobPath_Call {
506506 return &MockBlobStorage_GetBlobPath_Call{Call: _e.mock.On("GetBlobPath", digest)}
507507 }
508508
@@ -564,7 +564,7 @@ type MockBlobStorage_GetBlobUpload_Call struct {
564564
565565 // GetBlobUpload is a helper method to define mock.On call
566566 // - uuid string
567-func (_e *MockBlobStorage_Expecter) GetBlobUpload(uuid interface{}) *MockBlobStorage_GetBlobUpload_Call {
567+func (_e *MockBlobStorage_Expecter) GetBlobUpload(uuid any) *MockBlobStorage_GetBlobUpload_Call {
568568 return &MockBlobStorage_GetBlobUpload_Call{Call: _e.mock.On("GetBlobUpload", uuid)}
569569 }
570570
@@ -672,7 +672,7 @@ type MockBlobStorage_PutBlob_Call struct {
672672 // - digest string
673673 // - data io.Reader
674674 // - size int64
675-func (_e *MockBlobStorage_Expecter) PutBlob(digest interface{}, data interface{}, size interface{}) *MockBlobStorage_PutBlob_Call {
675+func (_e *MockBlobStorage_Expecter) PutBlob(digest any, data any, size any) *MockBlobStorage_PutBlob_Call {
676676 return &MockBlobStorage_PutBlob_Call{Call: _e.mock.On("PutBlob", digest, data, size)}
677677 }
678678
@@ -742,7 +742,7 @@ type MockBlobStorage_StartBlobUpload_Call struct {
742742
743743 // StartBlobUpload is a helper method to define mock.On call
744744 // - name string
745-func (_e *MockBlobStorage_Expecter) StartBlobUpload(name interface{}) *MockBlobStorage_StartBlobUpload_Call {
745+func (_e *MockBlobStorage_Expecter) StartBlobUpload(name any) *MockBlobStorage_StartBlobUpload_Call {
746746 return &MockBlobStorage_StartBlobUpload_Call{Call: _e.mock.On("StartBlobUpload", name)}
747747 }
748748
M internal/boundaries/out/mocks/mock_certificate_store.go +7 -7
@@ -73,7 +73,7 @@ type MockCertificateStore_LoadAccount_Call struct {
7373
7474 // LoadAccount is a helper method to define mock.On call
7575 // - ctx context.Context
76-func (_e *MockCertificateStore_Expecter) LoadAccount(ctx interface{}) *MockCertificateStore_LoadAccount_Call {
76+func (_e *MockCertificateStore_Expecter) LoadAccount(ctx any) *MockCertificateStore_LoadAccount_Call {
7777 return &MockCertificateStore_LoadAccount_Call{Call: _e.mock.On("LoadAccount", ctx)}
7878 }
7979
@@ -135,7 +135,7 @@ type MockCertificateStore_LoadAll_Call struct {
135135
136136 // LoadAll is a helper method to define mock.On call
137137 // - ctx context.Context
138-func (_e *MockCertificateStore_Expecter) LoadAll(ctx interface{}) *MockCertificateStore_LoadAll_Call {
138+func (_e *MockCertificateStore_Expecter) LoadAll(ctx any) *MockCertificateStore_LoadAll_Call {
139139 return &MockCertificateStore_LoadAll_Call{Call: _e.mock.On("LoadAll", ctx)}
140140 }
141141
@@ -195,7 +195,7 @@ type MockCertificateStore_LoadState_Call struct {
195195
196196 // LoadState is a helper method to define mock.On call
197197 // - ctx context.Context
198-func (_e *MockCertificateStore_Expecter) LoadState(ctx interface{}) *MockCertificateStore_LoadState_Call {
198+func (_e *MockCertificateStore_Expecter) LoadState(ctx any) *MockCertificateStore_LoadState_Call {
199199 return &MockCertificateStore_LoadState_Call{Call: _e.mock.On("LoadState", ctx)}
200200 }
201201
@@ -257,7 +257,7 @@ type MockCertificateStore_Lock_Call struct {
257257
258258 // Lock is a helper method to define mock.On call
259259 // - ctx context.Context
260-func (_e *MockCertificateStore_Expecter) Lock(ctx interface{}) *MockCertificateStore_Lock_Call {
260+func (_e *MockCertificateStore_Expecter) Lock(ctx any) *MockCertificateStore_Lock_Call {
261261 return &MockCertificateStore_Lock_Call{Call: _e.mock.On("Lock", ctx)}
262262 }
263263
@@ -309,7 +309,7 @@ type MockCertificateStore_Save_Call struct {
309309 // Save is a helper method to define mock.On call
310310 // - ctx context.Context
311311 // - cert out.StoredCertificate
312-func (_e *MockCertificateStore_Expecter) Save(ctx interface{}, cert interface{}) *MockCertificateStore_Save_Call {
312+func (_e *MockCertificateStore_Expecter) Save(ctx any, cert any) *MockCertificateStore_Save_Call {
313313 return &MockCertificateStore_Save_Call{Call: _e.mock.On("Save", ctx, cert)}
314314 }
315315
@@ -366,7 +366,7 @@ type MockCertificateStore_SaveAccount_Call struct {
366366 // SaveAccount is a helper method to define mock.On call
367367 // - ctx context.Context
368368 // - account out.ACMEAccount
369-func (_e *MockCertificateStore_Expecter) SaveAccount(ctx interface{}, account interface{}) *MockCertificateStore_SaveAccount_Call {
369+func (_e *MockCertificateStore_Expecter) SaveAccount(ctx any, account any) *MockCertificateStore_SaveAccount_Call {
370370 return &MockCertificateStore_SaveAccount_Call{Call: _e.mock.On("SaveAccount", ctx, account)}
371371 }
372372
@@ -423,7 +423,7 @@ type MockCertificateStore_SaveState_Call struct {
423423 // SaveState is a helper method to define mock.On call
424424 // - ctx context.Context
425425 // - state out.CertificateStoreState
426-func (_e *MockCertificateStore_Expecter) SaveState(ctx interface{}, state interface{}) *MockCertificateStore_SaveState_Call {
426+func (_e *MockCertificateStore_Expecter) SaveState(ctx any, state any) *MockCertificateStore_SaveState_Call {
427427 return &MockCertificateStore_SaveState_Call{Call: _e.mock.On("SaveState", ctx, state)}
428428 }
429429
M internal/boundaries/out/mocks/mock_container_log_writer.go +2 -2
@@ -109,7 +109,7 @@ type MockContainerLogWriter_StartLogging_Call struct {
109109 // - containerID string
110110 // - domain string
111111 // - logStream io.ReadCloser
112-func (_e *MockContainerLogWriter_Expecter) StartLogging(ctx interface{}, containerID interface{}, domain interface{}, logStream interface{}) *MockContainerLogWriter_StartLogging_Call {
112+func (_e *MockContainerLogWriter_Expecter) StartLogging(ctx any, containerID any, domain any, logStream any) *MockContainerLogWriter_StartLogging_Call {
113113 return &MockContainerLogWriter_StartLogging_Call{Call: _e.mock.On("StartLogging", ctx, containerID, domain, logStream)}
114114 }
115115
@@ -175,7 +175,7 @@ type MockContainerLogWriter_StopLogging_Call struct {
175175
176176 // StopLogging is a helper method to define mock.On call
177177 // - containerID string
178-func (_e *MockContainerLogWriter_Expecter) StopLogging(containerID interface{}) *MockContainerLogWriter_StopLogging_Call {
178+func (_e *MockContainerLogWriter_Expecter) StopLogging(containerID any) *MockContainerLogWriter_StopLogging_Call {
179179 return &MockContainerLogWriter_StopLogging_Call{Call: _e.mock.On("StopLogging", containerID)}
180180 }
181181
M internal/boundaries/out/mocks/mock_container_runtime.go +40 -40
@@ -66,7 +66,7 @@ type MockContainerRuntime_ConnectContainerToNetwork_Call struct {
6666 // - ctx context.Context
6767 // - containerName string
6868 // - networkName string
69-func (_e *MockContainerRuntime_Expecter) ConnectContainerToNetwork(ctx interface{}, containerName interface{}, networkName interface{}) *MockContainerRuntime_ConnectContainerToNetwork_Call {
69+func (_e *MockContainerRuntime_Expecter) ConnectContainerToNetwork(ctx any, containerName any, networkName any) *MockContainerRuntime_ConnectContainerToNetwork_Call {
7070 return &MockContainerRuntime_ConnectContainerToNetwork_Call{Call: _e.mock.On("ConnectContainerToNetwork", ctx, containerName, networkName)}
7171 }
7272
@@ -140,7 +140,7 @@ type MockContainerRuntime_CopyFromContainer_Call struct {
140140 // - ctx context.Context
141141 // - containerID string
142142 // - srcPath string
143-func (_e *MockContainerRuntime_Expecter) CopyFromContainer(ctx interface{}, containerID interface{}, srcPath interface{}) *MockContainerRuntime_CopyFromContainer_Call {
143+func (_e *MockContainerRuntime_Expecter) CopyFromContainer(ctx any, containerID any, srcPath any) *MockContainerRuntime_CopyFromContainer_Call {
144144 return &MockContainerRuntime_CopyFromContainer_Call{Call: _e.mock.On("CopyFromContainer", ctx, containerID, srcPath)}
145145 }
146146
@@ -213,7 +213,7 @@ type MockContainerRuntime_CreateContainer_Call struct {
213213 // CreateContainer is a helper method to define mock.On call
214214 // - ctx context.Context
215215 // - config *domain.ContainerConfig
216-func (_e *MockContainerRuntime_Expecter) CreateContainer(ctx interface{}, config interface{}) *MockContainerRuntime_CreateContainer_Call {
216+func (_e *MockContainerRuntime_Expecter) CreateContainer(ctx any, config any) *MockContainerRuntime_CreateContainer_Call {
217217 return &MockContainerRuntime_CreateContainer_Call{Call: _e.mock.On("CreateContainer", ctx, config)}
218218 }
219219
@@ -271,7 +271,7 @@ type MockContainerRuntime_CreateNetwork_Call struct {
271271 // - ctx context.Context
272272 // - name string
273273 // - config domain.NetworkConfig
274-func (_e *MockContainerRuntime_Expecter) CreateNetwork(ctx interface{}, name interface{}, config interface{}) *MockContainerRuntime_CreateNetwork_Call {
274+func (_e *MockContainerRuntime_Expecter) CreateNetwork(ctx any, name any, config any) *MockContainerRuntime_CreateNetwork_Call {
275275 return &MockContainerRuntime_CreateNetwork_Call{Call: _e.mock.On("CreateNetwork", ctx, name, config)}
276276 }
277277
@@ -333,7 +333,7 @@ type MockContainerRuntime_CreateVolume_Call struct {
333333 // CreateVolume is a helper method to define mock.On call
334334 // - ctx context.Context
335335 // - volumeName string
336-func (_e *MockContainerRuntime_Expecter) CreateVolume(ctx interface{}, volumeName interface{}) *MockContainerRuntime_CreateVolume_Call {
336+func (_e *MockContainerRuntime_Expecter) CreateVolume(ctx any, volumeName any) *MockContainerRuntime_CreateVolume_Call {
337337 return &MockContainerRuntime_CreateVolume_Call{Call: _e.mock.On("CreateVolume", ctx, volumeName)}
338338 }
339339
@@ -391,7 +391,7 @@ type MockContainerRuntime_DisconnectContainerFromNetwork_Call struct {
391391 // - ctx context.Context
392392 // - containerName string
393393 // - networkName string
394-func (_e *MockContainerRuntime_Expecter) DisconnectContainerFromNetwork(ctx interface{}, containerName interface{}, networkName interface{}) *MockContainerRuntime_DisconnectContainerFromNetwork_Call {
394+func (_e *MockContainerRuntime_Expecter) DisconnectContainerFromNetwork(ctx any, containerName any, networkName any) *MockContainerRuntime_DisconnectContainerFromNetwork_Call {
395395 return &MockContainerRuntime_DisconnectContainerFromNetwork_Call{Call: _e.mock.On("DisconnectContainerFromNetwork", ctx, containerName, networkName)}
396396 }
397397
@@ -465,7 +465,7 @@ type MockContainerRuntime_ExecInContainer_Call struct {
465465 // - ctx context.Context
466466 // - containerID string
467467 // - cmd []string
468-func (_e *MockContainerRuntime_Expecter) ExecInContainer(ctx interface{}, containerID interface{}, cmd interface{}) *MockContainerRuntime_ExecInContainer_Call {
468+func (_e *MockContainerRuntime_Expecter) ExecInContainer(ctx any, containerID any, cmd any) *MockContainerRuntime_ExecInContainer_Call {
469469 return &MockContainerRuntime_ExecInContainer_Call{Call: _e.mock.On("ExecInContainer", ctx, containerID, cmd)}
470470 }
471471
@@ -538,7 +538,7 @@ type MockContainerRuntime_GetContainerExposedPorts_Call struct {
538538 // GetContainerExposedPorts is a helper method to define mock.On call
539539 // - ctx context.Context
540540 // - containerID string
541-func (_e *MockContainerRuntime_Expecter) GetContainerExposedPorts(ctx interface{}, containerID interface{}) *MockContainerRuntime_GetContainerExposedPorts_Call {
541+func (_e *MockContainerRuntime_Expecter) GetContainerExposedPorts(ctx any, containerID any) *MockContainerRuntime_GetContainerExposedPorts_Call {
542542 return &MockContainerRuntime_GetContainerExposedPorts_Call{Call: _e.mock.On("GetContainerExposedPorts", ctx, containerID)}
543543 }
544544
@@ -610,7 +610,7 @@ type MockContainerRuntime_GetContainerHealthStatus_Call struct {
610610 // GetContainerHealthStatus is a helper method to define mock.On call
611611 // - ctx context.Context
612612 // - containerID string
613-func (_e *MockContainerRuntime_Expecter) GetContainerHealthStatus(ctx interface{}, containerID interface{}) *MockContainerRuntime_GetContainerHealthStatus_Call {
613+func (_e *MockContainerRuntime_Expecter) GetContainerHealthStatus(ctx any, containerID any) *MockContainerRuntime_GetContainerHealthStatus_Call {
614614 return &MockContainerRuntime_GetContainerHealthStatus_Call{Call: _e.mock.On("GetContainerHealthStatus", ctx, containerID)}
615615 }
616616
@@ -679,7 +679,7 @@ type MockContainerRuntime_GetContainerLogs_Call struct {
679679 // - ctx context.Context
680680 // - containerID string
681681 // - follow bool
682-func (_e *MockContainerRuntime_Expecter) GetContainerLogs(ctx interface{}, containerID interface{}, follow interface{}) *MockContainerRuntime_GetContainerLogs_Call {
682+func (_e *MockContainerRuntime_Expecter) GetContainerLogs(ctx any, containerID any, follow any) *MockContainerRuntime_GetContainerLogs_Call {
683683 return &MockContainerRuntime_GetContainerLogs_Call{Call: _e.mock.On("GetContainerLogs", ctx, containerID, follow)}
684684 }
685685
@@ -750,7 +750,7 @@ type MockContainerRuntime_GetContainerNetwork_Call struct {
750750 // GetContainerNetwork is a helper method to define mock.On call
751751 // - ctx context.Context
752752 // - containerID string
753-func (_e *MockContainerRuntime_Expecter) GetContainerNetwork(ctx interface{}, containerID interface{}) *MockContainerRuntime_GetContainerNetwork_Call {
753+func (_e *MockContainerRuntime_Expecter) GetContainerNetwork(ctx any, containerID any) *MockContainerRuntime_GetContainerNetwork_Call {
754754 return &MockContainerRuntime_GetContainerNetwork_Call{Call: _e.mock.On("GetContainerNetwork", ctx, containerID)}
755755 }
756756
@@ -822,7 +822,7 @@ type MockContainerRuntime_GetContainerNetworkInfo_Call struct {
822822 // GetContainerNetworkInfo is a helper method to define mock.On call
823823 // - ctx context.Context
824824 // - containerID string
825-func (_e *MockContainerRuntime_Expecter) GetContainerNetworkInfo(ctx interface{}, containerID interface{}) *MockContainerRuntime_GetContainerNetworkInfo_Call {
825+func (_e *MockContainerRuntime_Expecter) GetContainerNetworkInfo(ctx any, containerID any) *MockContainerRuntime_GetContainerNetworkInfo_Call {
826826 return &MockContainerRuntime_GetContainerNetworkInfo_Call{Call: _e.mock.On("GetContainerNetworkInfo", ctx, containerID)}
827827 }
828828
@@ -889,7 +889,7 @@ type MockContainerRuntime_GetContainerPort_Call struct {
889889 // - ctx context.Context
890890 // - containerID string
891891 // - internalPort int
892-func (_e *MockContainerRuntime_Expecter) GetContainerPort(ctx interface{}, containerID interface{}, internalPort interface{}) *MockContainerRuntime_GetContainerPort_Call {
892+func (_e *MockContainerRuntime_Expecter) GetContainerPort(ctx any, containerID any, internalPort any) *MockContainerRuntime_GetContainerPort_Call {
893893 return &MockContainerRuntime_GetContainerPort_Call{Call: _e.mock.On("GetContainerPort", ctx, containerID, internalPort)}
894894 }
895895
@@ -962,7 +962,7 @@ type MockContainerRuntime_GetImageExposedPorts_Call struct {
962962 // GetImageExposedPorts is a helper method to define mock.On call
963963 // - ctx context.Context
964964 // - imageRef string
965-func (_e *MockContainerRuntime_Expecter) GetImageExposedPorts(ctx interface{}, imageRef interface{}) *MockContainerRuntime_GetImageExposedPorts_Call {
965+func (_e *MockContainerRuntime_Expecter) GetImageExposedPorts(ctx any, imageRef any) *MockContainerRuntime_GetImageExposedPorts_Call {
966966 return &MockContainerRuntime_GetImageExposedPorts_Call{Call: _e.mock.On("GetImageExposedPorts", ctx, imageRef)}
967967 }
968968
@@ -1028,7 +1028,7 @@ type MockContainerRuntime_GetImageID_Call struct {
10281028 // GetImageID is a helper method to define mock.On call
10291029 // - ctx context.Context
10301030 // - imageRef string
1031-func (_e *MockContainerRuntime_Expecter) GetImageID(ctx interface{}, imageRef interface{}) *MockContainerRuntime_GetImageID_Call {
1031+func (_e *MockContainerRuntime_Expecter) GetImageID(ctx any, imageRef any) *MockContainerRuntime_GetImageID_Call {
10321032 return &MockContainerRuntime_GetImageID_Call{Call: _e.mock.On("GetImageID", ctx, imageRef)}
10331033 }
10341034
@@ -1096,7 +1096,7 @@ type MockContainerRuntime_GetImageLabels_Call struct {
10961096 // GetImageLabels is a helper method to define mock.On call
10971097 // - ctx context.Context
10981098 // - imageRef string
1099-func (_e *MockContainerRuntime_Expecter) GetImageLabels(ctx interface{}, imageRef interface{}) *MockContainerRuntime_GetImageLabels_Call {
1099+func (_e *MockContainerRuntime_Expecter) GetImageLabels(ctx any, imageRef any) *MockContainerRuntime_GetImageLabels_Call {
11001100 return &MockContainerRuntime_GetImageLabels_Call{Call: _e.mock.On("GetImageLabels", ctx, imageRef)}
11011101 }
11021102
@@ -1164,7 +1164,7 @@ type MockContainerRuntime_InspectContainer_Call struct {
11641164 // InspectContainer is a helper method to define mock.On call
11651165 // - ctx context.Context
11661166 // - containerID string
1167-func (_e *MockContainerRuntime_Expecter) InspectContainer(ctx interface{}, containerID interface{}) *MockContainerRuntime_InspectContainer_Call {
1167+func (_e *MockContainerRuntime_Expecter) InspectContainer(ctx any, containerID any) *MockContainerRuntime_InspectContainer_Call {
11681168 return &MockContainerRuntime_InspectContainer_Call{Call: _e.mock.On("InspectContainer", ctx, containerID)}
11691169 }
11701170
@@ -1232,7 +1232,7 @@ type MockContainerRuntime_InspectImageEnv_Call struct {
12321232 // InspectImageEnv is a helper method to define mock.On call
12331233 // - ctx context.Context
12341234 // - imageRef string
1235-func (_e *MockContainerRuntime_Expecter) InspectImageEnv(ctx interface{}, imageRef interface{}) *MockContainerRuntime_InspectImageEnv_Call {
1235+func (_e *MockContainerRuntime_Expecter) InspectImageEnv(ctx any, imageRef any) *MockContainerRuntime_InspectImageEnv_Call {
12361236 return &MockContainerRuntime_InspectImageEnv_Call{Call: _e.mock.On("InspectImageEnv", ctx, imageRef)}
12371237 }
12381238
@@ -1300,7 +1300,7 @@ type MockContainerRuntime_InspectImageVolumes_Call struct {
13001300 // InspectImageVolumes is a helper method to define mock.On call
13011301 // - ctx context.Context
13021302 // - imageRef string
1303-func (_e *MockContainerRuntime_Expecter) InspectImageVolumes(ctx interface{}, imageRef interface{}) *MockContainerRuntime_InspectImageVolumes_Call {
1303+func (_e *MockContainerRuntime_Expecter) InspectImageVolumes(ctx any, imageRef any) *MockContainerRuntime_InspectImageVolumes_Call {
13041304 return &MockContainerRuntime_InspectImageVolumes_Call{Call: _e.mock.On("InspectImageVolumes", ctx, imageRef)}
13051305 }
13061306
@@ -1366,7 +1366,7 @@ type MockContainerRuntime_IsContainerRunning_Call struct {
13661366 // IsContainerRunning is a helper method to define mock.On call
13671367 // - ctx context.Context
13681368 // - containerID string
1369-func (_e *MockContainerRuntime_Expecter) IsContainerRunning(ctx interface{}, containerID interface{}) *MockContainerRuntime_IsContainerRunning_Call {
1369+func (_e *MockContainerRuntime_Expecter) IsContainerRunning(ctx any, containerID any) *MockContainerRuntime_IsContainerRunning_Call {
13701370 return &MockContainerRuntime_IsContainerRunning_Call{Call: _e.mock.On("IsContainerRunning", ctx, containerID)}
13711371 }
13721372
@@ -1434,7 +1434,7 @@ type MockContainerRuntime_ListContainers_Call struct {
14341434 // ListContainers is a helper method to define mock.On call
14351435 // - ctx context.Context
14361436 // - all bool
1437-func (_e *MockContainerRuntime_Expecter) ListContainers(ctx interface{}, all interface{}) *MockContainerRuntime_ListContainers_Call {
1437+func (_e *MockContainerRuntime_Expecter) ListContainers(ctx any, all any) *MockContainerRuntime_ListContainers_Call {
14381438 return &MockContainerRuntime_ListContainers_Call{Call: _e.mock.On("ListContainers", ctx, all)}
14391439 }
14401440
@@ -1501,7 +1501,7 @@ type MockContainerRuntime_ListImages_Call struct {
15011501
15021502 // ListImages is a helper method to define mock.On call
15031503 // - ctx context.Context
1504-func (_e *MockContainerRuntime_Expecter) ListImages(ctx interface{}) *MockContainerRuntime_ListImages_Call {
1504+func (_e *MockContainerRuntime_Expecter) ListImages(ctx any) *MockContainerRuntime_ListImages_Call {
15051505 return &MockContainerRuntime_ListImages_Call{Call: _e.mock.On("ListImages", ctx)}
15061506 }
15071507
@@ -1563,7 +1563,7 @@ type MockContainerRuntime_ListNetworks_Call struct {
15631563
15641564 // ListNetworks is a helper method to define mock.On call
15651565 // - ctx context.Context
1566-func (_e *MockContainerRuntime_Expecter) ListNetworks(ctx interface{}) *MockContainerRuntime_ListNetworks_Call {
1566+func (_e *MockContainerRuntime_Expecter) ListNetworks(ctx any) *MockContainerRuntime_ListNetworks_Call {
15671567 return &MockContainerRuntime_ListNetworks_Call{Call: _e.mock.On("ListNetworks", ctx)}
15681568 }
15691569
@@ -1625,7 +1625,7 @@ type MockContainerRuntime_ListVolumes_Call struct {
16251625
16261626 // ListVolumes is a helper method to define mock.On call
16271627 // - ctx context.Context
1628-func (_e *MockContainerRuntime_Expecter) ListVolumes(ctx interface{}) *MockContainerRuntime_ListVolumes_Call {
1628+func (_e *MockContainerRuntime_Expecter) ListVolumes(ctx any) *MockContainerRuntime_ListVolumes_Call {
16291629 return &MockContainerRuntime_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)}
16301630 }
16311631
@@ -1686,7 +1686,7 @@ type MockContainerRuntime_NetworkExists_Call struct {
16861686 // NetworkExists is a helper method to define mock.On call
16871687 // - ctx context.Context
16881688 // - name string
1689-func (_e *MockContainerRuntime_Expecter) NetworkExists(ctx interface{}, name interface{}) *MockContainerRuntime_NetworkExists_Call {
1689+func (_e *MockContainerRuntime_Expecter) NetworkExists(ctx any, name any) *MockContainerRuntime_NetworkExists_Call {
16901690 return &MockContainerRuntime_NetworkExists_Call{Call: _e.mock.On("NetworkExists", ctx, name)}
16911691 }
16921692
@@ -1742,7 +1742,7 @@ type MockContainerRuntime_Ping_Call struct {
17421742
17431743 // Ping is a helper method to define mock.On call
17441744 // - ctx context.Context
1745-func (_e *MockContainerRuntime_Expecter) Ping(ctx interface{}) *MockContainerRuntime_Ping_Call {
1745+func (_e *MockContainerRuntime_Expecter) Ping(ctx any) *MockContainerRuntime_Ping_Call {
17461746 return &MockContainerRuntime_Ping_Call{Call: _e.mock.On("Ping", ctx)}
17471747 }
17481748
@@ -1794,7 +1794,7 @@ type MockContainerRuntime_PullImage_Call struct {
17941794 // PullImage is a helper method to define mock.On call
17951795 // - ctx context.Context
17961796 // - image string
1797-func (_e *MockContainerRuntime_Expecter) PullImage(ctx interface{}, image interface{}) *MockContainerRuntime_PullImage_Call {
1797+func (_e *MockContainerRuntime_Expecter) PullImage(ctx any, image any) *MockContainerRuntime_PullImage_Call {
17981798 return &MockContainerRuntime_PullImage_Call{Call: _e.mock.On("PullImage", ctx, image)}
17991799 }
18001800
@@ -1853,7 +1853,7 @@ type MockContainerRuntime_PullImageWithAuth_Call struct {
18531853 // - image string
18541854 // - username string
18551855 // - password string
1856-func (_e *MockContainerRuntime_Expecter) PullImageWithAuth(ctx interface{}, image interface{}, username interface{}, password interface{}) *MockContainerRuntime_PullImageWithAuth_Call {
1856+func (_e *MockContainerRuntime_Expecter) PullImageWithAuth(ctx any, image any, username any, password any) *MockContainerRuntime_PullImageWithAuth_Call {
18571857 return &MockContainerRuntime_PullImageWithAuth_Call{Call: _e.mock.On("PullImageWithAuth", ctx, image, username, password)}
18581858 }
18591859
@@ -1921,7 +1921,7 @@ type MockContainerRuntime_RemoveContainer_Call struct {
19211921 // - ctx context.Context
19221922 // - containerID string
19231923 // - force bool
1924-func (_e *MockContainerRuntime_Expecter) RemoveContainer(ctx interface{}, containerID interface{}, force interface{}) *MockContainerRuntime_RemoveContainer_Call {
1924+func (_e *MockContainerRuntime_Expecter) RemoveContainer(ctx any, containerID any, force any) *MockContainerRuntime_RemoveContainer_Call {
19251925 return &MockContainerRuntime_RemoveContainer_Call{Call: _e.mock.On("RemoveContainer", ctx, containerID, force)}
19261926 }
19271927
@@ -1984,7 +1984,7 @@ type MockContainerRuntime_RemoveImage_Call struct {
19841984 // - ctx context.Context
19851985 // - image string
19861986 // - force bool
1987-func (_e *MockContainerRuntime_Expecter) RemoveImage(ctx interface{}, image interface{}, force interface{}) *MockContainerRuntime_RemoveImage_Call {
1987+func (_e *MockContainerRuntime_Expecter) RemoveImage(ctx any, image any, force any) *MockContainerRuntime_RemoveImage_Call {
19881988 return &MockContainerRuntime_RemoveImage_Call{Call: _e.mock.On("RemoveImage", ctx, image, force)}
19891989 }
19901990
@@ -2046,7 +2046,7 @@ type MockContainerRuntime_RemoveNetwork_Call struct {
20462046 // RemoveNetwork is a helper method to define mock.On call
20472047 // - ctx context.Context
20482048 // - name string
2049-func (_e *MockContainerRuntime_Expecter) RemoveNetwork(ctx interface{}, name interface{}) *MockContainerRuntime_RemoveNetwork_Call {
2049+func (_e *MockContainerRuntime_Expecter) RemoveNetwork(ctx any, name any) *MockContainerRuntime_RemoveNetwork_Call {
20502050 return &MockContainerRuntime_RemoveNetwork_Call{Call: _e.mock.On("RemoveNetwork", ctx, name)}
20512051 }
20522052
@@ -2104,7 +2104,7 @@ type MockContainerRuntime_RemoveVolume_Call struct {
21042104 // - ctx context.Context
21052105 // - volumeName string
21062106 // - force bool
2107-func (_e *MockContainerRuntime_Expecter) RemoveVolume(ctx interface{}, volumeName interface{}, force interface{}) *MockContainerRuntime_RemoveVolume_Call {
2107+func (_e *MockContainerRuntime_Expecter) RemoveVolume(ctx any, volumeName any, force any) *MockContainerRuntime_RemoveVolume_Call {
21082108 return &MockContainerRuntime_RemoveVolume_Call{Call: _e.mock.On("RemoveVolume", ctx, volumeName, force)}
21092109 }
21102110
@@ -2167,7 +2167,7 @@ type MockContainerRuntime_RenameContainer_Call struct {
21672167 // - ctx context.Context
21682168 // - containerID string
21692169 // - newName string
2170-func (_e *MockContainerRuntime_Expecter) RenameContainer(ctx interface{}, containerID interface{}, newName interface{}) *MockContainerRuntime_RenameContainer_Call {
2170+func (_e *MockContainerRuntime_Expecter) RenameContainer(ctx any, containerID any, newName any) *MockContainerRuntime_RenameContainer_Call {
21712171 return &MockContainerRuntime_RenameContainer_Call{Call: _e.mock.On("RenameContainer", ctx, containerID, newName)}
21722172 }
21732173
@@ -2229,7 +2229,7 @@ type MockContainerRuntime_RestartContainer_Call struct {
22292229 // RestartContainer is a helper method to define mock.On call
22302230 // - ctx context.Context
22312231 // - containerID string
2232-func (_e *MockContainerRuntime_Expecter) RestartContainer(ctx interface{}, containerID interface{}) *MockContainerRuntime_RestartContainer_Call {
2232+func (_e *MockContainerRuntime_Expecter) RestartContainer(ctx any, containerID any) *MockContainerRuntime_RestartContainer_Call {
22332233 return &MockContainerRuntime_RestartContainer_Call{Call: _e.mock.On("RestartContainer", ctx, containerID)}
22342234 }
22352235
@@ -2286,7 +2286,7 @@ type MockContainerRuntime_StartContainer_Call struct {
22862286 // StartContainer is a helper method to define mock.On call
22872287 // - ctx context.Context
22882288 // - containerID string
2289-func (_e *MockContainerRuntime_Expecter) StartContainer(ctx interface{}, containerID interface{}) *MockContainerRuntime_StartContainer_Call {
2289+func (_e *MockContainerRuntime_Expecter) StartContainer(ctx any, containerID any) *MockContainerRuntime_StartContainer_Call {
22902290 return &MockContainerRuntime_StartContainer_Call{Call: _e.mock.On("StartContainer", ctx, containerID)}
22912291 }
22922292
@@ -2343,7 +2343,7 @@ type MockContainerRuntime_StopContainer_Call struct {
23432343 // StopContainer is a helper method to define mock.On call
23442344 // - ctx context.Context
23452345 // - containerID string
2346-func (_e *MockContainerRuntime_Expecter) StopContainer(ctx interface{}, containerID interface{}) *MockContainerRuntime_StopContainer_Call {
2346+func (_e *MockContainerRuntime_Expecter) StopContainer(ctx any, containerID any) *MockContainerRuntime_StopContainer_Call {
23472347 return &MockContainerRuntime_StopContainer_Call{Call: _e.mock.On("StopContainer", ctx, containerID)}
23482348 }
23492349
@@ -2401,7 +2401,7 @@ type MockContainerRuntime_TagImage_Call struct {
24012401 // - ctx context.Context
24022402 // - sourceRef string
24032403 // - targetRef string
2404-func (_e *MockContainerRuntime_Expecter) TagImage(ctx interface{}, sourceRef interface{}, targetRef interface{}) *MockContainerRuntime_TagImage_Call {
2404+func (_e *MockContainerRuntime_Expecter) TagImage(ctx any, sourceRef any, targetRef any) *MockContainerRuntime_TagImage_Call {
24052405 return &MockContainerRuntime_TagImage_Call{Call: _e.mock.On("TagImage", ctx, sourceRef, targetRef)}
24062406 }
24072407
@@ -2463,7 +2463,7 @@ type MockContainerRuntime_UntagImage_Call struct {
24632463 // UntagImage is a helper method to define mock.On call
24642464 // - ctx context.Context
24652465 // - imageRef string
2466-func (_e *MockContainerRuntime_Expecter) UntagImage(ctx interface{}, imageRef interface{}) *MockContainerRuntime_UntagImage_Call {
2466+func (_e *MockContainerRuntime_Expecter) UntagImage(ctx any, imageRef any) *MockContainerRuntime_UntagImage_Call {
24672467 return &MockContainerRuntime_UntagImage_Call{Call: _e.mock.On("UntagImage", ctx, imageRef)}
24682468 }
24692469
@@ -2528,7 +2528,7 @@ type MockContainerRuntime_Version_Call struct {
25282528
25292529 // Version is a helper method to define mock.On call
25302530 // - ctx context.Context
2531-func (_e *MockContainerRuntime_Expecter) Version(ctx interface{}) *MockContainerRuntime_Version_Call {
2531+func (_e *MockContainerRuntime_Expecter) Version(ctx any) *MockContainerRuntime_Version_Call {
25322532 return &MockContainerRuntime_Version_Call{Call: _e.mock.On("Version", ctx)}
25332533 }
25342534
@@ -2589,7 +2589,7 @@ type MockContainerRuntime_VolumeExists_Call struct {
25892589 // VolumeExists is a helper method to define mock.On call
25902590 // - ctx context.Context
25912591 // - volumeName string
2592-func (_e *MockContainerRuntime_Expecter) VolumeExists(ctx interface{}, volumeName interface{}) *MockContainerRuntime_VolumeExists_Call {
2592+func (_e *MockContainerRuntime_Expecter) VolumeExists(ctx any, volumeName any) *MockContainerRuntime_VolumeExists_Call {
25932593 return &MockContainerRuntime_VolumeExists_Call{Call: _e.mock.On("VolumeExists", ctx, volumeName)}
25942594 }
25952595
M internal/boundaries/out/mocks/mock_domain_secret_store.go +8 -8
@@ -61,7 +61,7 @@ type MockDomainSecretStore_Delete_Call struct {
6161 // Delete is a helper method to define mock.On call
6262 // - domain string
6363 // - key string
64-func (_e *MockDomainSecretStore_Expecter) Delete(domain interface{}, key interface{}) *MockDomainSecretStore_Delete_Call {
64+func (_e *MockDomainSecretStore_Expecter) Delete(domain any, key any) *MockDomainSecretStore_Delete_Call {
6565 return &MockDomainSecretStore_Delete_Call{Call: _e.mock.On("Delete", domain, key)}
6666 }
6767
@@ -118,7 +118,7 @@ type MockDomainSecretStore_DeleteAttachment_Call struct {
118118 // DeleteAttachment is a helper method to define mock.On call
119119 // - containerName string
120120 // - key string
121-func (_e *MockDomainSecretStore_Expecter) DeleteAttachment(containerName interface{}, key interface{}) *MockDomainSecretStore_DeleteAttachment_Call {
121+func (_e *MockDomainSecretStore_Expecter) DeleteAttachment(containerName any, key any) *MockDomainSecretStore_DeleteAttachment_Call {
122122 return &MockDomainSecretStore_DeleteAttachment_Call{Call: _e.mock.On("DeleteAttachment", containerName, key)}
123123 }
124124
@@ -185,7 +185,7 @@ type MockDomainSecretStore_GetAll_Call struct {
185185
186186 // GetAll is a helper method to define mock.On call
187187 // - domain string
188-func (_e *MockDomainSecretStore_Expecter) GetAll(domain interface{}) *MockDomainSecretStore_GetAll_Call {
188+func (_e *MockDomainSecretStore_Expecter) GetAll(domain any) *MockDomainSecretStore_GetAll_Call {
189189 return &MockDomainSecretStore_GetAll_Call{Call: _e.mock.On("GetAll", domain)}
190190 }
191191
@@ -247,7 +247,7 @@ type MockDomainSecretStore_GetAllAttachment_Call struct {
247247
248248 // GetAllAttachment is a helper method to define mock.On call
249249 // - containerName string
250-func (_e *MockDomainSecretStore_Expecter) GetAllAttachment(containerName interface{}) *MockDomainSecretStore_GetAllAttachment_Call {
250+func (_e *MockDomainSecretStore_Expecter) GetAllAttachment(containerName any) *MockDomainSecretStore_GetAllAttachment_Call {
251251 return &MockDomainSecretStore_GetAllAttachment_Call{Call: _e.mock.On("GetAllAttachment", containerName)}
252252 }
253253
@@ -309,7 +309,7 @@ type MockDomainSecretStore_ListAttachmentKeys_Call struct {
309309
310310 // ListAttachmentKeys is a helper method to define mock.On call
311311 // - domain string
312-func (_e *MockDomainSecretStore_Expecter) ListAttachmentKeys(domain interface{}) *MockDomainSecretStore_ListAttachmentKeys_Call {
312+func (_e *MockDomainSecretStore_Expecter) ListAttachmentKeys(domain any) *MockDomainSecretStore_ListAttachmentKeys_Call {
313313 return &MockDomainSecretStore_ListAttachmentKeys_Call{Call: _e.mock.On("ListAttachmentKeys", domain)}
314314 }
315315
@@ -371,7 +371,7 @@ type MockDomainSecretStore_ListKeys_Call struct {
371371
372372 // ListKeys is a helper method to define mock.On call
373373 // - domain string
374-func (_e *MockDomainSecretStore_Expecter) ListKeys(domain interface{}) *MockDomainSecretStore_ListKeys_Call {
374+func (_e *MockDomainSecretStore_Expecter) ListKeys(domain any) *MockDomainSecretStore_ListKeys_Call {
375375 return &MockDomainSecretStore_ListKeys_Call{Call: _e.mock.On("ListKeys", domain)}
376376 }
377377
@@ -423,7 +423,7 @@ type MockDomainSecretStore_Set_Call struct {
423423 // Set is a helper method to define mock.On call
424424 // - domain string
425425 // - secrets map[string]string
426-func (_e *MockDomainSecretStore_Expecter) Set(domain interface{}, secrets interface{}) *MockDomainSecretStore_Set_Call {
426+func (_e *MockDomainSecretStore_Expecter) Set(domain any, secrets any) *MockDomainSecretStore_Set_Call {
427427 return &MockDomainSecretStore_Set_Call{Call: _e.mock.On("Set", domain, secrets)}
428428 }
429429
@@ -480,7 +480,7 @@ type MockDomainSecretStore_SetAttachment_Call struct {
480480 // SetAttachment is a helper method to define mock.On call
481481 // - containerName string
482482 // - secrets map[string]string
483-func (_e *MockDomainSecretStore_Expecter) SetAttachment(containerName interface{}, secrets interface{}) *MockDomainSecretStore_SetAttachment_Call {
483+func (_e *MockDomainSecretStore_Expecter) SetAttachment(containerName any, secrets any) *MockDomainSecretStore_SetAttachment_Call {
484484 return &MockDomainSecretStore_SetAttachment_Call{Call: _e.mock.On("SetAttachment", containerName, secrets)}
485485 }
486486
M internal/boundaries/out/mocks/mock_env_loader.go +3 -3
@@ -62,7 +62,7 @@ type MockEnvLoader_CreateEnvFile_Call struct {
6262 // CreateEnvFile is a helper method to define mock.On call
6363 // - ctx context.Context
6464 // - domain string
65-func (_e *MockEnvLoader_Expecter) CreateEnvFile(ctx interface{}, domain interface{}) *MockEnvLoader_CreateEnvFile_Call {
65+func (_e *MockEnvLoader_Expecter) CreateEnvFile(ctx any, domain any) *MockEnvLoader_CreateEnvFile_Call {
6666 return &MockEnvLoader_CreateEnvFile_Call{Call: _e.mock.On("CreateEnvFile", ctx, domain)}
6767 }
6868
@@ -127,7 +127,7 @@ type MockEnvLoader_EnvFileExists_Call struct {
127127
128128 // EnvFileExists is a helper method to define mock.On call
129129 // - domain string
130-func (_e *MockEnvLoader_Expecter) EnvFileExists(domain interface{}) *MockEnvLoader_EnvFileExists_Call {
130+func (_e *MockEnvLoader_Expecter) EnvFileExists(domain any) *MockEnvLoader_EnvFileExists_Call {
131131 return &MockEnvLoader_EnvFileExists_Call{Call: _e.mock.On("EnvFileExists", domain)}
132132 }
133133
@@ -190,7 +190,7 @@ type MockEnvLoader_LoadEnv_Call struct {
190190 // LoadEnv is a helper method to define mock.On call
191191 // - ctx context.Context
192192 // - domain string
193-func (_e *MockEnvLoader_Expecter) LoadEnv(ctx interface{}, domain interface{}) *MockEnvLoader_LoadEnv_Call {
193+func (_e *MockEnvLoader_Expecter) LoadEnv(ctx any, domain any) *MockEnvLoader_LoadEnv_Call {
194194 return &MockEnvLoader_LoadEnv_Call{Call: _e.mock.On("LoadEnv", ctx, domain)}
195195 }
196196
M internal/boundaries/out/mocks/mock_event_bus.go +3 -3
@@ -62,7 +62,7 @@ type MockEventBus_Publish_Call struct {
6262 // Publish is a helper method to define mock.On call
6363 // - eventType domain.EventType
6464 // - payload any
65-func (_e *MockEventBus_Expecter) Publish(eventType interface{}, payload interface{}) *MockEventBus_Publish_Call {
65+func (_e *MockEventBus_Expecter) Publish(eventType any, payload any) *MockEventBus_Publish_Call {
6666 return &MockEventBus_Publish_Call{Call: _e.mock.On("Publish", eventType, payload)}
6767 }
6868
@@ -206,7 +206,7 @@ type MockEventBus_Subscribe_Call struct {
206206
207207 // Subscribe is a helper method to define mock.On call
208208 // - handler out.EventHandler
209-func (_e *MockEventBus_Expecter) Subscribe(handler interface{}) *MockEventBus_Subscribe_Call {
209+func (_e *MockEventBus_Expecter) Subscribe(handler any) *MockEventBus_Subscribe_Call {
210210 return &MockEventBus_Subscribe_Call{Call: _e.mock.On("Subscribe", handler)}
211211 }
212212
@@ -257,7 +257,7 @@ type MockEventBus_Unsubscribe_Call struct {
257257
258258 // Unsubscribe is a helper method to define mock.On call
259259 // - handler out.EventHandler
260-func (_e *MockEventBus_Expecter) Unsubscribe(handler interface{}) *MockEventBus_Unsubscribe_Call {
260+func (_e *MockEventBus_Expecter) Unsubscribe(handler any) *MockEventBus_Unsubscribe_Call {
261261 return &MockEventBus_Unsubscribe_Call{Call: _e.mock.On("Unsubscribe", handler)}
262262 }
263263
M internal/boundaries/out/mocks/mock_event_handler.go +2 -2
@@ -62,7 +62,7 @@ type MockEventHandler_CanHandle_Call struct {
6262
6363 // CanHandle is a helper method to define mock.On call
6464 // - eventType domain.EventType
65-func (_e *MockEventHandler_Expecter) CanHandle(eventType interface{}) *MockEventHandler_CanHandle_Call {
65+func (_e *MockEventHandler_Expecter) CanHandle(eventType any) *MockEventHandler_CanHandle_Call {
6666 return &MockEventHandler_CanHandle_Call{Call: _e.mock.On("CanHandle", eventType)}
6767 }
6868
@@ -114,7 +114,7 @@ type MockEventHandler_Handle_Call struct {
114114 // Handle is a helper method to define mock.On call
115115 // - ctx context.Context
116116 // - event domain.Event
117-func (_e *MockEventHandler_Expecter) Handle(ctx interface{}, event interface{}) *MockEventHandler_Handle_Call {
117+func (_e *MockEventHandler_Expecter) Handle(ctx any, event any) *MockEventHandler_Handle_Call {
118118 return &MockEventHandler_Handle_Call{Call: _e.mock.On("Handle", ctx, event)}
119119 }
120120
M internal/boundaries/out/mocks/mock_event_publisher.go +1 -1
@@ -61,7 +61,7 @@ type MockEventPublisher_Publish_Call struct {
6161 // Publish is a helper method to define mock.On call
6262 // - eventType domain.EventType
6363 // - payload any
64-func (_e *MockEventPublisher_Expecter) Publish(eventType interface{}, payload interface{}) *MockEventPublisher_Publish_Call {
64+func (_e *MockEventPublisher_Expecter) Publish(eventType any, payload any) *MockEventPublisher_Publish_Call {
6565 return &MockEventPublisher_Publish_Call{Call: _e.mock.On("Publish", eventType, payload)}
6666 }
6767
M internal/boundaries/out/mocks/mock_event_subscriber.go +2 -2
@@ -60,7 +60,7 @@ type MockEventSubscriber_Subscribe_Call struct {
6060
6161 // Subscribe is a helper method to define mock.On call
6262 // - handler out.EventHandler
63-func (_e *MockEventSubscriber_Expecter) Subscribe(handler interface{}) *MockEventSubscriber_Subscribe_Call {
63+func (_e *MockEventSubscriber_Expecter) Subscribe(handler any) *MockEventSubscriber_Subscribe_Call {
6464 return &MockEventSubscriber_Subscribe_Call{Call: _e.mock.On("Subscribe", handler)}
6565 }
6666
@@ -111,7 +111,7 @@ type MockEventSubscriber_Unsubscribe_Call struct {
111111
112112 // Unsubscribe is a helper method to define mock.On call
113113 // - handler out.EventHandler
114-func (_e *MockEventSubscriber_Expecter) Unsubscribe(handler interface{}) *MockEventSubscriber_Unsubscribe_Call {
114+func (_e *MockEventSubscriber_Expecter) Unsubscribe(handler any) *MockEventSubscriber_Unsubscribe_Call {
115115 return &MockEventSubscriber_Unsubscribe_Call{Call: _e.mock.On("Unsubscribe", handler)}
116116 }
117117
M internal/boundaries/out/mocks/mock_manifest_storage.go +5 -5
@@ -62,7 +62,7 @@ type MockManifestStorage_DeleteManifest_Call struct {
6262 // DeleteManifest is a helper method to define mock.On call
6363 // - name string
6464 // - reference string
65-func (_e *MockManifestStorage_Expecter) DeleteManifest(name interface{}, reference interface{}) *MockManifestStorage_DeleteManifest_Call {
65+func (_e *MockManifestStorage_Expecter) DeleteManifest(name any, reference any) *MockManifestStorage_DeleteManifest_Call {
6666 return &MockManifestStorage_DeleteManifest_Call{Call: _e.mock.On("DeleteManifest", name, reference)}
6767 }
6868
@@ -136,7 +136,7 @@ type MockManifestStorage_GetManifest_Call struct {
136136 // GetManifest is a helper method to define mock.On call
137137 // - name string
138138 // - reference string
139-func (_e *MockManifestStorage_Expecter) GetManifest(name interface{}, reference interface{}) *MockManifestStorage_GetManifest_Call {
139+func (_e *MockManifestStorage_Expecter) GetManifest(name any, reference any) *MockManifestStorage_GetManifest_Call {
140140 return &MockManifestStorage_GetManifest_Call{Call: _e.mock.On("GetManifest", name, reference)}
141141 }
142142
@@ -202,7 +202,7 @@ type MockManifestStorage_GetManifestModTime_Call struct {
202202 // GetManifestModTime is a helper method to define mock.On call
203203 // - name string
204204 // - reference string
205-func (_e *MockManifestStorage_Expecter) GetManifestModTime(name interface{}, reference interface{}) *MockManifestStorage_GetManifestModTime_Call {
205+func (_e *MockManifestStorage_Expecter) GetManifestModTime(name any, reference any) *MockManifestStorage_GetManifestModTime_Call {
206206 return &MockManifestStorage_GetManifestModTime_Call{Call: _e.mock.On("GetManifestModTime", name, reference)}
207207 }
208208
@@ -324,7 +324,7 @@ type MockManifestStorage_ListTags_Call struct {
324324
325325 // ListTags is a helper method to define mock.On call
326326 // - name string
327-func (_e *MockManifestStorage_Expecter) ListTags(name interface{}) *MockManifestStorage_ListTags_Call {
327+func (_e *MockManifestStorage_Expecter) ListTags(name any) *MockManifestStorage_ListTags_Call {
328328 return &MockManifestStorage_ListTags_Call{Call: _e.mock.On("ListTags", name)}
329329 }
330330
@@ -378,7 +378,7 @@ type MockManifestStorage_PutManifest_Call struct {
378378 // - reference string
379379 // - contentType string
380380 // - data []byte
381-func (_e *MockManifestStorage_Expecter) PutManifest(name interface{}, reference interface{}, contentType interface{}, data interface{}) *MockManifestStorage_PutManifest_Call {
381+func (_e *MockManifestStorage_Expecter) PutManifest(name any, reference any, contentType any, data any) *MockManifestStorage_PutManifest_Call {
382382 return &MockManifestStorage_PutManifest_Call{Call: _e.mock.On("PutManifest", name, reference, contentType, data)}
383383 }
384384
M internal/boundaries/out/mocks/mock_rate_limiter.go +2 -2
@@ -62,7 +62,7 @@ type MockRateLimiter_Allow_Call struct {
6262 // Allow is a helper method to define mock.On call
6363 // - ctx context.Context
6464 // - key string
65-func (_e *MockRateLimiter_Expecter) Allow(ctx interface{}, key interface{}) *MockRateLimiter_Allow_Call {
65+func (_e *MockRateLimiter_Expecter) Allow(ctx any, key any) *MockRateLimiter_Allow_Call {
6666 return &MockRateLimiter_Allow_Call{Call: _e.mock.On("Allow", ctx, key)}
6767 }
6868
@@ -120,7 +120,7 @@ type MockRateLimiter_AllowN_Call struct {
120120 // - ctx context.Context
121121 // - key string
122122 // - n int
123-func (_e *MockRateLimiter_Expecter) AllowN(ctx interface{}, key interface{}, n interface{}) *MockRateLimiter_AllowN_Call {
123+func (_e *MockRateLimiter_Expecter) AllowN(ctx any, key any, n any) *MockRateLimiter_AllowN_Call {
124124 return &MockRateLimiter_AllowN_Call{Call: _e.mock.On("AllowN", ctx, key, n)}
125125 }
126126
M internal/boundaries/out/mocks/mock_route_checker.go +1 -1
@@ -110,7 +110,7 @@ type MockRouteChecker_GetRoutes_Call struct {
110110
111111 // GetRoutes is a helper method to define mock.On call
112112 // - ctx context.Context
113-func (_e *MockRouteChecker_Expecter) GetRoutes(ctx interface{}) *MockRouteChecker_GetRoutes_Call {
113+func (_e *MockRouteChecker_Expecter) GetRoutes(ctx any) *MockRouteChecker_GetRoutes_Call {
114114 return &MockRouteChecker_GetRoutes_Call{Call: _e.mock.On("GetRoutes", ctx)}
115115 }
116116
M internal/boundaries/out/mocks/mock_secret_provider.go +1 -1
@@ -71,7 +71,7 @@ type MockSecretProvider_GetSecret_Call struct {
7171 // GetSecret is a helper method to define mock.On call
7272 // - ctx context.Context
7373 // - key string
74-func (_e *MockSecretProvider_Expecter) GetSecret(ctx interface{}, key interface{}) *MockSecretProvider_GetSecret_Call {
74+func (_e *MockSecretProvider_Expecter) GetSecret(ctx any, key any) *MockSecretProvider_GetSecret_Call {
7575 return &MockSecretProvider_GetSecret_Call{Call: _e.mock.On("GetSecret", ctx, key)}
7676 }
7777
M internal/boundaries/out/mocks/mock_token_store.go +7 -7
@@ -63,7 +63,7 @@ type MockTokenStore_DeleteToken_Call struct {
6363 // DeleteToken is a helper method to define mock.On call
6464 // - ctx context.Context
6565 // - subject string
66-func (_e *MockTokenStore_Expecter) DeleteToken(ctx interface{}, subject interface{}) *MockTokenStore_DeleteToken_Call {
66+func (_e *MockTokenStore_Expecter) DeleteToken(ctx any, subject any) *MockTokenStore_DeleteToken_Call {
6767 return &MockTokenStore_DeleteToken_Call{Call: _e.mock.On("DeleteToken", ctx, subject)}
6868 }
6969
@@ -137,7 +137,7 @@ type MockTokenStore_GetToken_Call struct {
137137 // GetToken is a helper method to define mock.On call
138138 // - ctx context.Context
139139 // - subject string
140-func (_e *MockTokenStore_Expecter) GetToken(ctx interface{}, subject interface{}) *MockTokenStore_GetToken_Call {
140+func (_e *MockTokenStore_Expecter) GetToken(ctx any, subject any) *MockTokenStore_GetToken_Call {
141141 return &MockTokenStore_GetToken_Call{Call: _e.mock.On("GetToken", ctx, subject)}
142142 }
143143
@@ -203,7 +203,7 @@ type MockTokenStore_IsRevoked_Call struct {
203203 // IsRevoked is a helper method to define mock.On call
204204 // - ctx context.Context
205205 // - tokenID string
206-func (_e *MockTokenStore_Expecter) IsRevoked(ctx interface{}, tokenID interface{}) *MockTokenStore_IsRevoked_Call {
206+func (_e *MockTokenStore_Expecter) IsRevoked(ctx any, tokenID any) *MockTokenStore_IsRevoked_Call {
207207 return &MockTokenStore_IsRevoked_Call{Call: _e.mock.On("IsRevoked", ctx, tokenID)}
208208 }
209209
@@ -270,7 +270,7 @@ type MockTokenStore_ListTokens_Call struct {
270270
271271 // ListTokens is a helper method to define mock.On call
272272 // - ctx context.Context
273-func (_e *MockTokenStore_Expecter) ListTokens(ctx interface{}) *MockTokenStore_ListTokens_Call {
273+func (_e *MockTokenStore_Expecter) ListTokens(ctx any) *MockTokenStore_ListTokens_Call {
274274 return &MockTokenStore_ListTokens_Call{Call: _e.mock.On("ListTokens", ctx)}
275275 }
276276
@@ -322,7 +322,7 @@ type MockTokenStore_Revoke_Call struct {
322322 // Revoke is a helper method to define mock.On call
323323 // - ctx context.Context
324324 // - tokenID string
325-func (_e *MockTokenStore_Expecter) Revoke(ctx interface{}, tokenID interface{}) *MockTokenStore_Revoke_Call {
325+func (_e *MockTokenStore_Expecter) Revoke(ctx any, tokenID any) *MockTokenStore_Revoke_Call {
326326 return &MockTokenStore_Revoke_Call{Call: _e.mock.On("Revoke", ctx, tokenID)}
327327 }
328328
@@ -380,7 +380,7 @@ type MockTokenStore_SaveToken_Call struct {
380380 // - ctx context.Context
381381 // - token *domain.Token
382382 // - jwt string
383-func (_e *MockTokenStore_Expecter) SaveToken(ctx interface{}, token interface{}, jwt interface{}) *MockTokenStore_SaveToken_Call {
383+func (_e *MockTokenStore_Expecter) SaveToken(ctx any, token any, jwt any) *MockTokenStore_SaveToken_Call {
384384 return &MockTokenStore_SaveToken_Call{Call: _e.mock.On("SaveToken", ctx, token, jwt)}
385385 }
386386
@@ -443,7 +443,7 @@ type MockTokenStore_UpdateTokenExpiry_Call struct {
443443 // - ctx context.Context
444444 // - token *domain.Token
445445 // - newJWT string
446-func (_e *MockTokenStore_Expecter) UpdateTokenExpiry(ctx interface{}, token interface{}, newJWT interface{}) *MockTokenStore_UpdateTokenExpiry_Call {
446+func (_e *MockTokenStore_Expecter) UpdateTokenExpiry(ctx any, token any, newJWT any) *MockTokenStore_UpdateTokenExpiry_Call {
447447 return &MockTokenStore_UpdateTokenExpiry_Call{Call: _e.mock.On("UpdateTokenExpiry", ctx, token, newJWT)}
448448 }
449449
M internal/domain/container.go +9 -0
@@ -63,12 +63,21 @@ type RouteInfo struct {
6363 Attachments []Attachment
6464 }
6565
66+// ContainerPortPublish describes an explicit host port binding for a container port.
67+type ContainerPortPublish struct {
68+ HostIP string
69+ HostPort int
70+ ContainerPort int
71+ Protocol NetworkProtocol
72+}
73+
6674 // ContainerConfig holds configuration for creating a container.
6775 type ContainerConfig struct {
6876 Image string
6977 Name string
7078 Env []string
7179 Ports []int
80+ PortPublishes []ContainerPortPublish
7281 Labels map[string]string
7382 WorkingDir string
7483 Cmd []string
M internal/domain/errors.go +5 -0
@@ -49,6 +49,8 @@ var (
4949 ErrPreviewNotFound = errors.New("preview not found")
5050
5151 // Config errors
52+ ErrTrafficGraphRequired = errors.New("traffic graph is required")
53+ ErrClientHelloTooLarge = errors.New("client hello exceeds maximum")
5254 ErrConfigNotFound = errors.New("configuration not found")
5355 ErrInvalidConfig = errors.New("invalid configuration")
5456 ErrConfigLoadFailed = errors.New("failed to load configuration")
@@ -104,4 +106,7 @@ var (
104106 ErrRouteSourceRequired = errors.New("route source required")
105107 ErrHTTPChallengeSinkRequired = errors.New("http challenge sink required")
106108 ErrTLSRouteNotCovered = errors.New("tls route not covered by public certificate")
109+
110+ // Traffic errors
111+ ErrTrafficStatusUnavailable = errors.New("traffic status unavailable")
107112 )
M internal/domain/labels.go +8 -0
@@ -15,6 +15,14 @@ const (
1515 // exposing secret values.
1616 LabelEnvHash = "gordon.env-hash"
1717
18+ // Standalone service labels identify Gordon-managed L4 service containers.
19+ LabelService = "gordon.service"
20+ LabelServiceName = "gordon.service.name"
21+ LabelServiceConfigHash = "gordon.service.config-hash"
22+ LabelServiceManagedVolumes = "gordon.service.managed-volumes"
23+ LabelServiceCleanupPreserveVolumes = "gordon.service.cleanup.preserve-volumes"
24+ LabelServiceCleanupRemoveContainer = "gordon.service.cleanup.remove-container"
25+
1826 // LabelProxyPort specifies the container port to proxy HTTP traffic to.
1927 LabelProxyPort = "gordon.proxy.port"
2028
A internal/domain/service.go +258 -0
@@ -0,0 +1,258 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "net"
6+ "path"
7+ "strconv"
8+ "strings"
9+ "time"
10+)
11+
12+const (
13+ StandaloneServiceReadinessNone = "none"
14+ StandaloneServiceReadinessTCP = "tcp"
15+ StandaloneServiceReadinessLog = "log"
16+)
17+
18+type StandaloneService struct {
19+ Name string
20+ Image string
21+ Enabled bool
22+ Env []string
23+ EnvFile string
24+ Secrets []StandaloneServiceSecretRef
25+ Readiness StandaloneServiceReadiness
26+ Cleanup StandaloneServiceCleanup
27+ Ports []StandaloneServicePort
28+ Volumes []StandaloneServiceVolume
29+}
30+
31+type StandaloneServiceStatus struct {
32+ Name string
33+ ContainerID string
34+ ContainerName string
35+ Status ContainerStatus
36+ ConfigHash string
37+}
38+
39+type StandaloneServicePort struct {
40+ Name string
41+ Container int
42+ Protocol NetworkProtocol
43+ Publish string
44+ Private bool
45+ Public bool
46+ TrustedCIDRs []string
47+}
48+
49+type StandaloneServiceVolume struct {
50+ Source string
51+ Target string
52+ ReadOnly bool
53+}
54+
55+type StandaloneServiceSecretRef struct {
56+ Name string
57+ Key string
58+}
59+
60+type StandaloneServiceReadiness struct {
61+ Type string
62+ Path string
63+ Contains string
64+ Timeout time.Duration
65+ TimeoutSet bool
66+}
67+
68+type StandaloneServiceCleanup struct {
69+ PreserveVolumes bool
70+ RemoveContainer bool
71+}
72+
73+func (s StandaloneService) WithDefaults() StandaloneService {
74+ if s.Readiness.Type == "" {
75+ s.Readiness.Type = StandaloneServiceReadinessNone
76+ }
77+ s.Cleanup = s.Cleanup.WithDefaults()
78+ return s
79+}
80+
81+func (c StandaloneServiceCleanup) WithDefaults() StandaloneServiceCleanup {
82+ if !c.PreserveVolumes && !c.RemoveContainer {
83+ return StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true}
84+ }
85+ return c
86+}
87+
88+func (s StandaloneService) Validate() error {
89+ s = s.WithDefaults()
90+ name := strings.TrimSpace(s.Name)
91+ if name == "" {
92+ return fmt.Errorf("standalone service name is required")
93+ }
94+ if s.Name != name {
95+ return fmt.Errorf("standalone service name %q must not include leading or trailing whitespace", s.Name)
96+ }
97+ if s.Enabled && strings.TrimSpace(s.Image) == "" {
98+ return fmt.Errorf("standalone service %q image is required when enabled", s.Name)
99+ }
100+ if s.EnvFile != "" && strings.TrimSpace(s.EnvFile) == "" {
101+ return fmt.Errorf("standalone service %q env_file must be non-empty when set", s.Name)
102+ }
103+ if err := validateStandaloneServicePorts(s); err != nil {
104+ return err
105+ }
106+ if err := validateStandaloneServiceVolumes(s); err != nil {
107+ return err
108+ }
109+ if err := validateStandaloneServiceSecrets(s); err != nil {
110+ return err
111+ }
112+ if err := validateStandaloneServiceReadiness(s); err != nil {
113+ return err
114+ }
115+ return nil
116+}
117+
118+func validateStandaloneServicePorts(s StandaloneService) error {
119+ seen := make(map[string]struct{}, len(s.Ports))
120+ for i, port := range s.Ports {
121+ name, err := validateStandaloneServicePort(s.Name, i, port)
122+ if err != nil {
123+ return err
124+ }
125+ if _, ok := seen[name]; ok {
126+ return fmt.Errorf("standalone service %q duplicate port name %q", s.Name, name)
127+ }
128+ seen[name] = struct{}{}
129+ }
130+ return nil
131+}
132+
133+func validateStandaloneServicePort(serviceName string, index int, port StandaloneServicePort) (string, error) {
134+ name := strings.TrimSpace(port.Name)
135+ if name == "" {
136+ return "", fmt.Errorf("standalone service %q port %d name is required", serviceName, index)
137+ }
138+ if port.Name != name {
139+ return "", fmt.Errorf("standalone service %q port %q name must not include leading or trailing whitespace", serviceName, port.Name)
140+ }
141+ if port.Container <= 0 || port.Container > 65535 {
142+ return "", fmt.Errorf("standalone service %q port %q container port must be 1-65535", serviceName, name)
143+ }
144+ if port.Protocol != NetworkProtocolTCP && port.Protocol != NetworkProtocolUDP {
145+ return "", fmt.Errorf("standalone service %q port %q protocol must be tcp or udp", serviceName, name)
146+ }
147+ if port.Private && port.Public {
148+ return "", fmt.Errorf("standalone service %q port %q cannot be both private and public", serviceName, name)
149+ }
150+ if err := validateStandaloneServicePublish(serviceName, name, port.Publish); err != nil {
151+ return "", err
152+ }
153+ if port.Private && port.Publish != "" {
154+ if err := validatePrivateStandaloneServicePublish(serviceName, name, port.Publish); err != nil {
155+ return "", err
156+ }
157+ }
158+ if err := validateStandaloneServiceTrustedCIDRs(serviceName, name, port.TrustedCIDRs); err != nil {
159+ return "", err
160+ }
161+ return name, nil
162+}
163+
164+func validateStandaloneServiceTrustedCIDRs(serviceName, portName string, cidrs []string) error {
165+ for _, cidr := range cidrs {
166+ if strings.TrimSpace(cidr) == "" {
167+ return fmt.Errorf("standalone service %q port %q trusted_cidrs must not contain empty values", serviceName, portName)
168+ }
169+ if _, _, err := net.ParseCIDR(cidr); err != nil {
170+ return fmt.Errorf("standalone service %q port %q trusted_cidr %q is invalid: %w", serviceName, portName, cidr, err)
171+ }
172+ }
173+ return nil
174+}
175+
176+func validateStandaloneServicePublish(serviceName, portName, publish string) error {
177+ if publish == "" {
178+ return nil
179+ }
180+ host, port, err := net.SplitHostPort(publish)
181+ if err != nil {
182+ return fmt.Errorf("standalone service %q port %q publish address %q is invalid: %w", serviceName, portName, publish, err)
183+ }
184+ if strings.TrimSpace(host) == "" || strings.TrimSpace(port) == "" {
185+ return fmt.Errorf("standalone service %q port %q publish address %q must include host and port", serviceName, portName, publish)
186+ }
187+ portNumber, err := strconv.Atoi(port)
188+ if err != nil || portNumber < 1 || portNumber > 65535 {
189+ return fmt.Errorf("standalone service %q port %q publish address %q must include a valid port", serviceName, portName, publish)
190+ }
191+ return nil
192+}
193+
194+func validatePrivateStandaloneServicePublish(serviceName, portName, publish string) error {
195+ host, _, err := net.SplitHostPort(publish)
196+ if err != nil {
197+ return err
198+ }
199+ ip := net.ParseIP(host)
200+ if ip == nil || !ip.IsLoopback() {
201+ return fmt.Errorf("standalone service %q private port %q publish host %q must be loopback", serviceName, portName, host)
202+ }
203+ return nil
204+}
205+
206+func validateStandaloneServiceVolumes(s StandaloneService) error {
207+ seenTargets := make(map[string]struct{}, len(s.Volumes))
208+ for i, volume := range s.Volumes {
209+ target := strings.TrimSpace(volume.Target)
210+ if target == "" {
211+ return fmt.Errorf("standalone service %q volume %d target is required", s.Name, i)
212+ }
213+ if !path.IsAbs(target) {
214+ return fmt.Errorf("standalone service %q volume %d target %q must be an absolute container path", s.Name, i, volume.Target)
215+ }
216+ if _, ok := seenTargets[target]; ok {
217+ return fmt.Errorf("standalone service %q duplicate volume target %q", s.Name, target)
218+ }
219+ seenTargets[target] = struct{}{}
220+ }
221+ return nil
222+}
223+
224+func validateStandaloneServiceSecrets(s StandaloneService) error {
225+ for i, secret := range s.Secrets {
226+ if strings.TrimSpace(secret.Name) == "" {
227+ return fmt.Errorf("standalone service %q secret %d name is required", s.Name, i)
228+ }
229+ if strings.TrimSpace(secret.Key) == "" {
230+ return fmt.Errorf("standalone service %q secret %d key is required", s.Name, i)
231+ }
232+ }
233+ return nil
234+}
235+
236+func validateStandaloneServiceReadiness(s StandaloneService) error {
237+ readinessType := s.Readiness.Type
238+ if readinessType == "" {
239+ readinessType = StandaloneServiceReadinessNone
240+ }
241+ if s.Readiness.TimeoutSet && s.Readiness.Timeout <= 0 {
242+ return fmt.Errorf("standalone service %q readiness timeout must be positive when set", s.Name)
243+ }
244+ switch readinessType {
245+ case StandaloneServiceReadinessNone, StandaloneServiceReadinessTCP:
246+ return nil
247+ case StandaloneServiceReadinessLog:
248+ if strings.TrimSpace(s.Readiness.Path) == "" {
249+ return fmt.Errorf("standalone service %q log readiness path is required", s.Name)
250+ }
251+ if strings.TrimSpace(s.Readiness.Contains) == "" {
252+ return fmt.Errorf("standalone service %q log readiness contains is required", s.Name)
253+ }
254+ return nil
255+ default:
256+ return fmt.Errorf("standalone service %q readiness type must be none, tcp, or log", s.Name)
257+ }
258+}
A internal/domain/service_test.go +135 -0
@@ -0,0 +1,135 @@
1+package domain
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestStandaloneServiceValidateRequiresNameAndImageWhenEnabled(t *testing.T) {
11+ svc := StandaloneService{Enabled: true, Image: "redis:7"}
12+ require.ErrorContains(t, svc.Validate(), "service name")
13+
14+ svc = StandaloneService{Enabled: true, Name: "cache"}
15+ require.ErrorContains(t, svc.Validate(), "image")
16+}
17+
18+func TestStandaloneServiceValidateRejectsPaddedNames(t *testing.T) {
19+ svc := StandaloneService{Name: " cache ", Image: "redis:7", Enabled: true}
20+ require.ErrorContains(t, svc.Validate(), "leading or trailing whitespace")
21+
22+ svc = StandaloneService{
23+ Name: "cache",
24+ Image: "redis:7",
25+ Enabled: true,
26+ Ports: []StandaloneServicePort{{Name: " admin ", Container: 6379, Protocol: NetworkProtocolTCP}},
27+ }
28+ require.ErrorContains(t, svc.Validate(), "leading or trailing whitespace")
29+}
30+
31+func TestStandaloneServiceValidateAllowsDisabledServiceWithoutImage(t *testing.T) {
32+ svc := StandaloneService{Name: "cache", Enabled: false}
33+ require.NoError(t, svc.Validate())
34+}
35+
36+func TestStandaloneServiceValidatePorts(t *testing.T) {
37+ valid := StandaloneService{
38+ Name: "game",
39+ Image: "game:latest",
40+ Enabled: true,
41+ Ports: []StandaloneServicePort{
42+ {Name: "game", Container: 28015, Protocol: NetworkProtocolUDP, Publish: "127.0.0.1:38015"},
43+ {Name: "rcon", Container: 28016, Protocol: NetworkProtocolTCP, Publish: "127.0.0.1:38016", Private: true},
44+ },
45+ }
46+ require.NoError(t, valid.Validate())
47+
48+ duplicate := valid
49+ duplicate.Ports = append(duplicate.Ports, StandaloneServicePort{Name: "game", Container: 28017, Protocol: NetworkProtocolUDP})
50+ require.ErrorContains(t, duplicate.Validate(), "duplicate port")
51+
52+ badProtocol := valid
53+ badProtocol.Ports = []StandaloneServicePort{{Name: "game", Container: 28015, Protocol: NetworkProtocol("sctp")}}
54+ require.ErrorContains(t, badProtocol.Validate(), "protocol")
55+
56+ badPublish := valid
57+ badPublish.Ports = []StandaloneServicePort{{Name: "game", Container: 28015, Protocol: NetworkProtocolUDP, Publish: "127.0.0.1:not-a-port"}}
58+ require.ErrorContains(t, badPublish.Validate(), "valid port")
59+
60+ conflictingVisibility := valid
61+ conflictingVisibility.Ports = []StandaloneServicePort{{Name: "rcon", Container: 28016, Protocol: NetworkProtocolTCP, Private: true, Public: true}}
62+ require.ErrorContains(t, conflictingVisibility.Validate(), "cannot be both private and public")
63+
64+ privateNonLoopback := valid
65+ privateNonLoopback.Ports = []StandaloneServicePort{{Name: "rcon", Container: 28016, Protocol: NetworkProtocolTCP, Private: true, Publish: "0.0.0.0:28016"}}
66+ require.ErrorContains(t, privateNonLoopback.Validate(), "must be loopback")
67+}
68+
69+func TestStandaloneServiceValidateVolumesAndEnvFile(t *testing.T) {
70+ svc := StandaloneService{Name: "cache", Image: "redis:7", Enabled: true}
71+ require.NoError(t, svc.Validate(), "volumes may be omitted")
72+
73+ svc.Volumes = []StandaloneServiceVolume{{Source: "cache-data", Target: "var/lib/redis"}}
74+ require.ErrorContains(t, svc.Validate(), "absolute")
75+
76+ svc.Volumes = []StandaloneServiceVolume{{Source: "cache-data", Target: `C:\\data`}}
77+ require.ErrorContains(t, svc.Validate(), "absolute")
78+
79+ svc.Volumes = nil
80+ svc.EnvFile = " "
81+ require.ErrorContains(t, svc.Validate(), "env_file")
82+}
83+
84+func TestStandaloneServiceValidateReadiness(t *testing.T) {
85+ svc := StandaloneService{Name: "game", Image: "game:latest", Enabled: true}
86+ for _, readinessType := range []string{"", "none", "tcp", "log"} {
87+ t.Run(readinessType, func(t *testing.T) {
88+ candidate := svc
89+ candidate.Readiness = StandaloneServiceReadiness{Type: readinessType, Path: "/tmp/server.log", Contains: "ready", Timeout: time.Minute}
90+ require.NoError(t, candidate.Validate())
91+ })
92+ }
93+
94+ svc.Readiness = StandaloneServiceReadiness{Type: "udp"}
95+ require.ErrorContains(t, svc.Validate(), "readiness")
96+
97+ for _, timeout := range []time.Duration{0, -time.Second} {
98+ svc.Readiness = StandaloneServiceReadiness{Type: "tcp", Timeout: timeout, TimeoutSet: true}
99+ require.ErrorContains(t, svc.Validate(), "readiness timeout")
100+ }
101+
102+ svc.Readiness = StandaloneServiceReadiness{Type: "log", Path: "/tmp/server.log"}
103+ require.ErrorContains(t, svc.Validate(), "contains")
104+
105+ svc.Readiness = StandaloneServiceReadiness{Type: "log", Contains: "ready"}
106+ require.ErrorContains(t, svc.Validate(), "path")
107+}
108+
109+func TestStandaloneServiceWithDefaultsCleanup(t *testing.T) {
110+ svc := StandaloneService{Name: "game", Image: "game:latest", Enabled: true}.WithDefaults()
111+ require.True(t, svc.Cleanup.PreserveVolumes)
112+ require.True(t, svc.Cleanup.RemoveContainer)
113+ require.NoError(t, svc.Validate())
114+
115+ svc = StandaloneService{Name: "game", Image: "game:latest", Enabled: true, Cleanup: StandaloneServiceCleanup{RemoveContainer: true}}.WithDefaults()
116+ require.False(t, svc.Cleanup.PreserveVolumes)
117+ require.True(t, svc.Cleanup.RemoveContainer)
118+
119+ svc = StandaloneService{Name: "game", Image: "game:latest", Enabled: true, Cleanup: StandaloneServiceCleanup{PreserveVolumes: true}}.WithDefaults()
120+ require.True(t, svc.Cleanup.PreserveVolumes)
121+ require.False(t, svc.Cleanup.RemoveContainer)
122+}
123+
124+func TestStandaloneServiceValidateRejectsDuplicateVolumeTargets(t *testing.T) {
125+ svc := StandaloneService{
126+ Name: "cache",
127+ Image: "redis:7",
128+ Enabled: true,
129+ Volumes: []StandaloneServiceVolume{
130+ {Source: "cache-data", Target: "/data"},
131+ {Source: "cache-other", Target: "/data"},
132+ },
133+ }
134+ require.ErrorContains(t, svc.Validate(), "duplicate volume target")
135+}
A internal/domain/traffic.go +784 -0
@@ -0,0 +1,784 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "net"
6+ "strconv"
7+ "strings"
8+ "time"
9+)
10+
11+type EntryPointProtocol string
12+
13+const (
14+ EntryPointProtocolHTTP EntryPointProtocol = "http"
15+ EntryPointProtocolTLSMux EntryPointProtocol = "tls_mux"
16+ EntryPointProtocolSmartTCP EntryPointProtocol = "smart_tcp"
17+ EntryPointProtocolTCP EntryPointProtocol = "tcp"
18+ EntryPointProtocolUDP EntryPointProtocol = "udp"
19+)
20+
21+type RouterProtocol string
22+
23+const (
24+ RouterProtocolHTTP RouterProtocol = "http"
25+ RouterProtocolTCP RouterProtocol = "tcp"
26+ RouterProtocolUDP RouterProtocol = "udp"
27+ RouterProtocolTLSPassthrough RouterProtocol = "tls_passthrough"
28+)
29+
30+type NetworkProtocol string
31+
32+const (
33+ NetworkProtocolTCP NetworkProtocol = "tcp"
34+ NetworkProtocolUDP NetworkProtocol = "udp"
35+)
36+
37+type TrafficGraph struct {
38+ Options TrafficOptions
39+ EntryPoints []EntryPoint
40+ Routers []TrafficRouter
41+ Services []TrafficService
42+}
43+
44+type TrafficOptions struct {
45+ TCP TCPOptions
46+ UDP UDPOptions
47+}
48+
49+type TCPOptions struct {
50+ DialTimeout time.Duration
51+ IdleTimeout time.Duration
52+ DrainTimeout time.Duration
53+ MaxConnections int
54+}
55+
56+type UDPOptions struct {
57+ IdleTimeout time.Duration
58+ DrainTimeout time.Duration
59+ MaxSessions int
60+}
61+
62+type EntryPoint struct {
63+ Name string
64+ Address string
65+ Protocol EntryPointProtocol
66+ TrustedCIDRs []string
67+ RawFallback string
68+ RawFallbackTrustedCIDRs []string
69+ AllowPublicRawFallback bool
70+}
71+
72+type TrafficRouter struct {
73+ Name string
74+ EntryPoint string
75+ Protocol RouterProtocol
76+ Rule TrafficRule
77+ Service string
78+}
79+
80+type TrafficRule struct {
81+ Host string
82+ SNI string
83+}
84+
85+type TrafficService struct {
86+ Name string
87+ Backends []TrafficBackend
88+}
89+
90+type TrafficBackend struct {
91+ Name string
92+ Host string
93+ Port int
94+ Protocol NetworkProtocol
95+}
96+
97+type TrafficStatus struct {
98+ LastReloadStatus string
99+ LastReloadError string
100+ EntryPoints []EntryPointStatus
101+ Routers []TrafficRouterStatus
102+ Services []TrafficServiceStatus
103+ Counters TrafficCounters
104+}
105+
106+type EntryPointStatus struct {
107+ Name string
108+ Address string
109+ Protocol EntryPointProtocol
110+ Active bool
111+ ActiveTCPConnections int64
112+ ActiveUDPSessions int64
113+ TotalAccepted int64
114+ TotalRefused int64
115+ TotalErrors int64
116+ BytesIn int64
117+ BytesOut int64
118+ SmartTCP SmartTCPCounters
119+}
120+
121+type TrafficRouterStatus struct {
122+ Name string
123+ EntryPoint string
124+ Protocol RouterProtocol
125+ Rule TrafficRule
126+ Service string
127+ Active bool
128+}
129+
130+type TrafficServiceStatus struct {
131+ Name string
132+ Active bool
133+ Backends []TrafficBackendStatus
134+}
135+
136+type TrafficBackendStatus struct {
137+ Name string
138+ Host string
139+ Port int
140+ Protocol NetworkProtocol
141+ Active bool
142+}
143+
144+type TrafficCounters struct {
145+ ActiveTCPConnections int64
146+ ActiveUDPSessions int64
147+ TotalAccepted int64
148+ TotalRefused int64
149+ TotalErrors int64
150+ BytesIn int64
151+ BytesOut int64
152+ SmartTCP SmartTCPCounters
153+}
154+
155+type SmartTCPCounters struct {
156+ HTTPAccepted int64
157+ H2CAccepted int64
158+ HTTPSFallbackAccepted int64
159+ TLSPassthroughAccepted int64
160+ RawFallbackAccepted int64
161+ EntrypointCIDRRefused int64
162+ RawFallbackCIDRRefused int64
163+ PROXYRefused int64
164+ UnknownNoFallbackRefused int64
165+ MalformedRejected int64
166+ SniffTimeout int64
167+ ClientHelloTooLarge int64
168+}
169+
170+type TrafficServiceRefKind string
171+
172+const (
173+ TrafficServiceRefRoute TrafficServiceRefKind = "route"
174+ TrafficServiceRefExternalRoute TrafficServiceRefKind = "external_route"
175+ TrafficServiceRefNetworkService TrafficServiceRefKind = "network_service"
176+ TrafficServiceRefService TrafficServiceRefKind = "service"
177+ TrafficServiceRefStatic TrafficServiceRefKind = "static"
178+)
179+
180+type TrafficServiceRef struct {
181+ Kind TrafficServiceRefKind
182+ Domain string
183+ Name string
184+ PortName string
185+ Reserved bool
186+}
187+
188+func ParseTrafficServiceRef(value string) (TrafficServiceRef, error) {
189+ kind, rest, ok := strings.Cut(strings.TrimSpace(value), ":")
190+ if !ok || rest == "" {
191+ return TrafficServiceRef{}, fmt.Errorf("invalid traffic service ref %q", value)
192+ }
193+ switch TrafficServiceRefKind(kind) {
194+ case TrafficServiceRefRoute:
195+ return TrafficServiceRef{Kind: TrafficServiceRefRoute, Domain: rest}, nil
196+ case TrafficServiceRefExternalRoute:
197+ return TrafficServiceRef{Kind: TrafficServiceRefExternalRoute, Domain: rest}, nil
198+ case TrafficServiceRefNetworkService:
199+ return parseNamedPortServiceRef(value, rest, TrafficServiceRefNetworkService, "network service")
200+ case TrafficServiceRefService:
201+ return parseNamedPortServiceRef(value, rest, TrafficServiceRefService, "service")
202+ case TrafficServiceRefStatic:
203+ return TrafficServiceRef{Kind: TrafficServiceRefStatic, Name: rest, Reserved: true}, nil
204+ default:
205+ return TrafficServiceRef{}, fmt.Errorf("invalid traffic service ref kind %q", kind)
206+ }
207+}
208+
209+func parseNamedPortServiceRef(value, rest string, kind TrafficServiceRefKind, label string) (TrafficServiceRef, error) {
210+ name, portName, ok := strings.Cut(rest, ":")
211+ if !ok || name == "" || portName == "" || strings.Contains(portName, ":") {
212+ return TrafficServiceRef{}, fmt.Errorf("invalid %s ref %q", label, value)
213+ }
214+ return TrafficServiceRef{Kind: kind, Name: name, PortName: portName}, nil
215+}
216+
217+func (g TrafficGraph) Validate() error {
218+ if err := validateTrafficOptions(g.Options); err != nil {
219+ return err
220+ }
221+ entryPoints, err := validateTrafficEntryPoints(g.EntryPoints)
222+ if err != nil {
223+ return err
224+ }
225+ services, err := validateTrafficServices(g.Services)
226+ if err != nil {
227+ return err
228+ }
229+ return g.validateRouters(entryPoints, services)
230+}
231+
232+func validateTrafficOptions(options TrafficOptions) error {
233+ if err := validateOptionalPositiveDuration(options.TCP.DialTimeout, "traffic.tcp.dial_timeout"); err != nil {
234+ return err
235+ }
236+ if err := validateOptionalPositiveDuration(options.TCP.IdleTimeout, "traffic.tcp.idle_timeout"); err != nil {
237+ return err
238+ }
239+ if err := validateOptionalPositiveDuration(options.TCP.DrainTimeout, "traffic.tcp.drain_timeout"); err != nil {
240+ return err
241+ }
242+ if options.TCP.MaxConnections < 0 {
243+ return fmt.Errorf("traffic.tcp.max_connections must be non-negative")
244+ }
245+ if err := validateOptionalPositiveDuration(options.UDP.IdleTimeout, "traffic.udp.idle_timeout"); err != nil {
246+ return err
247+ }
248+ if err := validateOptionalPositiveDuration(options.UDP.DrainTimeout, "traffic.udp.drain_timeout"); err != nil {
249+ return err
250+ }
251+ if options.UDP.MaxSessions < 0 {
252+ return fmt.Errorf("traffic.udp.max_sessions must be non-negative")
253+ }
254+ return nil
255+}
256+
257+func validateOptionalPositiveDuration(value time.Duration, name string) error {
258+ if value < 0 {
259+ return fmt.Errorf("%s must be positive", name)
260+ }
261+ return nil
262+}
263+
264+func validateTrafficEntryPoints(entries []EntryPoint) (map[string]EntryPoint, error) {
265+ entryPoints := make(map[string]EntryPoint, len(entries))
266+ listenAddresses := newProtocolListenAddressSet()
267+ for _, entryPoint := range entries {
268+ if err := validateTrafficEntryPoint(entryPoint, entryPoints, listenAddresses); err != nil {
269+ return nil, err
270+ }
271+ entryPoints[entryPoint.Name] = entryPoint
272+ }
273+ return entryPoints, nil
274+}
275+
276+type protocolListenAddressSet struct {
277+ tcp listenAddressSet
278+ udp listenAddressSet
279+}
280+
281+func newProtocolListenAddressSet() *protocolListenAddressSet {
282+ return &protocolListenAddressSet{tcp: newListenAddressSet(), udp: newListenAddressSet()}
283+}
284+
285+func (s *protocolListenAddressSet) add(entryPoint EntryPoint, addr listenAddress) error {
286+ if entryPoint.Protocol == EntryPointProtocolUDP {
287+ return s.udp.add(addr, entryPoint.Name, "udp")
288+ }
289+ return s.tcp.add(addr, entryPoint.Name, "tcp")
290+}
291+
292+func validateTrafficEntryPoint(entryPoint EntryPoint, existing map[string]EntryPoint, listenAddresses *protocolListenAddressSet) error {
293+ if entryPoint.Name == "" {
294+ return fmt.Errorf("entrypoint name is required")
295+ }
296+ if _, exists := existing[entryPoint.Name]; exists {
297+ return fmt.Errorf("duplicate entrypoint name %q", entryPoint.Name)
298+ }
299+ if err := validateEntryPointProtocol(entryPoint.Protocol); err != nil {
300+ return fmt.Errorf("invalid entrypoint protocol for %q: %w", entryPoint.Name, err)
301+ }
302+ if err := validateRawFallbackEntrypoint(entryPoint); err != nil {
303+ return err
304+ }
305+ if err := validateTrustedCIDRs(entryPoint); err != nil {
306+ return err
307+ }
308+ addr, err := parseListenAddress(entryPoint.Address)
309+ if err != nil {
310+ return fmt.Errorf("invalid entrypoint address for %q: %w", entryPoint.Name, err)
311+ }
312+ return listenAddresses.add(entryPoint, addr)
313+}
314+
315+func validateTrafficServices(values []TrafficService) (map[string]TrafficService, error) {
316+ services := make(map[string]TrafficService, len(values))
317+ for _, service := range values {
318+ if err := validateTrafficService(service, services); err != nil {
319+ return nil, err
320+ }
321+ services[service.Name] = service
322+ }
323+ return services, nil
324+}
325+
326+func validateTrafficService(service TrafficService, existing map[string]TrafficService) error {
327+ if service.Name == "" {
328+ return fmt.Errorf("traffic service name is required")
329+ }
330+ if _, exists := existing[service.Name]; exists {
331+ return fmt.Errorf("duplicate traffic service name %q", service.Name)
332+ }
333+ if _, err := ParseTrafficServiceRef(service.Name); err != nil {
334+ return fmt.Errorf("invalid traffic service ref %q: %w", service.Name, err)
335+ }
336+ for _, backend := range service.Backends {
337+ if err := validateTrafficBackend(service.Name, backend); err != nil {
338+ return err
339+ }
340+ }
341+ return nil
342+}
343+
344+func validateTrafficBackend(serviceName string, backend TrafficBackend) error {
345+ if strings.TrimSpace(backend.Host) == "" {
346+ return fmt.Errorf("backend host is required for service %q", serviceName)
347+ }
348+ if backend.Port < 1 || backend.Port > 65535 {
349+ return fmt.Errorf("invalid backend port %d for service %q", backend.Port, serviceName)
350+ }
351+ if err := validateNetworkProtocol(backend.Protocol); err != nil {
352+ return fmt.Errorf("invalid backend protocol for service %q: %w", serviceName, err)
353+ }
354+ return nil
355+}
356+
357+func (g TrafficGraph) validateRouters(entryPoints map[string]EntryPoint, services map[string]TrafficService) error {
358+ state := newRouterValidationState()
359+ for _, router := range g.Routers {
360+ if err := state.validateRouter(router, entryPoints, services); err != nil {
361+ return err
362+ }
363+ }
364+ return validateRawFallbackRouters(entryPoints, state.routersByName)
365+}
366+
367+type routerValidationState struct {
368+ routerNames map[string]struct{}
369+ routersByName map[string]TrafficRouter
370+ exactSNI map[string]map[string]string
371+ wildSNI map[string]map[string]string
372+ httpHosts map[string]map[string]string
373+ tcpRouters map[string]string
374+ udpRouters map[string]string
375+}
376+
377+func newRouterValidationState() *routerValidationState {
378+ return &routerValidationState{
379+ routerNames: map[string]struct{}{},
380+ routersByName: map[string]TrafficRouter{},
381+ exactSNI: map[string]map[string]string{},
382+ wildSNI: map[string]map[string]string{},
383+ httpHosts: map[string]map[string]string{},
384+ tcpRouters: map[string]string{},
385+ udpRouters: map[string]string{},
386+ }
387+}
388+
389+func (s *routerValidationState) validateRouter(router TrafficRouter, entryPoints map[string]EntryPoint, services map[string]TrafficService) error {
390+ if err := s.validateRouterName(router); err != nil {
391+ return err
392+ }
393+ entryPoint, serviceRef, service, err := resolveRouterTargets(router, entryPoints, services)
394+ if err != nil {
395+ return err
396+ }
397+ if err := validateRouterEntryPointProtocol(router, entryPoint); err != nil {
398+ return err
399+ }
400+ if err := validateRouterServiceRef(router, serviceRef, service); err != nil {
401+ return err
402+ }
403+ if err := s.validateRouterRule(router, entryPoint); err != nil {
404+ return err
405+ }
406+ return s.validateTLSPassthroughRule(router, entryPoint)
407+}
408+
409+func (s *routerValidationState) validateRouterName(router TrafficRouter) error {
410+ if router.Name == "" {
411+ return fmt.Errorf("traffic router name is required")
412+ }
413+ if _, exists := s.routerNames[router.Name]; exists {
414+ return fmt.Errorf("duplicate traffic router name %q", router.Name)
415+ }
416+ s.routerNames[router.Name] = struct{}{}
417+ s.routersByName[router.Name] = router
418+ return nil
419+}
420+
421+func resolveRouterTargets(router TrafficRouter, entryPoints map[string]EntryPoint, services map[string]TrafficService) (EntryPoint, TrafficServiceRef, TrafficService, error) {
422+ entryPoint, ok := entryPoints[router.EntryPoint]
423+ if !ok {
424+ return EntryPoint{}, TrafficServiceRef{}, TrafficService{}, fmt.Errorf("router %q references unknown entrypoint %q", router.Name, router.EntryPoint)
425+ }
426+ serviceRef, err := ParseTrafficServiceRef(router.Service)
427+ if err != nil {
428+ return EntryPoint{}, TrafficServiceRef{}, TrafficService{}, fmt.Errorf("router %q has invalid service ref: %w", router.Name, err)
429+ }
430+ service, ok := services[router.Service]
431+ if !ok {
432+ return EntryPoint{}, TrafficServiceRef{}, TrafficService{}, fmt.Errorf("router %q references unknown service %q", router.Name, router.Service)
433+ }
434+ if err := validateRouterProtocol(router.Protocol); err != nil {
435+ return EntryPoint{}, TrafficServiceRef{}, TrafficService{}, fmt.Errorf("invalid router protocol for %q: %w", router.Name, err)
436+ }
437+ return entryPoint, serviceRef, service, nil
438+}
439+
440+func (s *routerValidationState) validateRouterRule(router TrafficRouter, entryPoint EntryPoint) error {
441+ switch router.Protocol {
442+ case RouterProtocolTCP:
443+ return validateSingleL4Router(s.tcpRouters, "tcp", entryPoint.Name, router.Name)
444+ case RouterProtocolUDP:
445+ return validateSingleL4Router(s.udpRouters, "udp", entryPoint.Name, router.Name)
446+ case RouterProtocolHTTP:
447+ return s.validateHTTPRule(router, entryPoint)
448+ default:
449+ return nil
450+ }
451+}
452+
453+func validateSingleL4Router(routers map[string]string, protocol string, entryPoint string, routerName string) error {
454+ if existing := routers[entryPoint]; existing != "" {
455+ return fmt.Errorf("ambiguous %s routers on entrypoint %q: %q and %q", protocol, entryPoint, existing, routerName)
456+ }
457+ routers[entryPoint] = routerName
458+ return nil
459+}
460+
461+func (s *routerValidationState) validateHTTPRule(router TrafficRouter, entryPoint EntryPoint) error {
462+ if router.Rule.Host == "" {
463+ return nil
464+ }
465+ host := normalizeHost(router.Rule.Host)
466+ if existing := s.httpHosts[entryPoint.Name][host]; existing != "" {
467+ return fmt.Errorf("duplicate http host %q on entrypoint %q used by routers %q and %q", host, entryPoint.Name, existing, router.Name)
468+ }
469+ if err := s.validateHTTPRuleTLSConflicts(entryPoint.Name, host); err != nil {
470+ return err
471+ }
472+ putNested(s.httpHosts, entryPoint.Name, host, router.Name)
473+ return nil
474+}
475+
476+func (s *routerValidationState) validateHTTPRuleTLSConflicts(entryPoint string, host string) error {
477+ if s.exactSNI[entryPoint][host] != "" {
478+ return fmt.Errorf("http host conflicts with tls passthrough sni %q on entrypoint %q", host, entryPoint)
479+ }
480+ if wildcard := matchingWildcardSNI(host, s.wildSNI[entryPoint]); wildcard != "" {
481+ return fmt.Errorf("http host conflicts with wildcard tls passthrough sni %q on entrypoint %q", wildcard, entryPoint)
482+ }
483+ return nil
484+}
485+
486+func (s *routerValidationState) validateTLSPassthroughRule(router TrafficRouter, entryPoint EntryPoint) error {
487+ if router.Protocol != RouterProtocolTLSPassthrough {
488+ return nil
489+ }
490+ if strings.TrimSpace(router.Rule.SNI) == "" {
491+ return fmt.Errorf("tls passthrough router %q requires sni", router.Name)
492+ }
493+ sni := normalizeHost(router.Rule.SNI)
494+ if strings.HasPrefix(sni, "*.") {
495+ return s.validateWildcardSNI(entryPoint.Name, sni, router.Name)
496+ }
497+ return s.validateExactSNI(entryPoint.Name, sni, router.Name)
498+}
499+
500+func (s *routerValidationState) validateWildcardSNI(entryPoint string, sni string, routerName string) error {
501+ if s.wildSNI[entryPoint][sni] != "" {
502+ return fmt.Errorf("ambiguous wildcard tls sni %q on entrypoint %q", sni, entryPoint)
503+ }
504+ if wildcard := overlappingWildcardSNI(sni, s.wildSNI[entryPoint]); wildcard != "" {
505+ return fmt.Errorf("ambiguous wildcard tls sni %q overlaps %q on entrypoint %q", sni, wildcard, entryPoint)
506+ }
507+ if host := matchingHTTPHost(sni, s.httpHosts[entryPoint]); host != "" {
508+ return fmt.Errorf("http host conflicts with wildcard tls passthrough sni %q on entrypoint %q for host %q", sni, entryPoint, host)
509+ }
510+ putNested(s.wildSNI, entryPoint, sni, routerName)
511+ return nil
512+}
513+
514+func (s *routerValidationState) validateExactSNI(entryPoint string, sni string, routerName string) error {
515+ if s.exactSNI[entryPoint][sni] != "" {
516+ return fmt.Errorf("duplicate exact tls sni %q on entrypoint %q", sni, entryPoint)
517+ }
518+ if s.httpHosts[entryPoint][sni] != "" {
519+ return fmt.Errorf("http host conflicts with tls passthrough sni %q on entrypoint %q", sni, entryPoint)
520+ }
521+ putNested(s.exactSNI, entryPoint, sni, routerName)
522+ return nil
523+}
524+
525+func validateRouterEntryPointProtocol(router TrafficRouter, entryPoint EntryPoint) error {
526+ compatible := false
527+ switch router.Protocol {
528+ case RouterProtocolHTTP:
529+ compatible = entryPoint.Protocol == EntryPointProtocolHTTP || entryPoint.Protocol == EntryPointProtocolTLSMux || entryPoint.Protocol == EntryPointProtocolSmartTCP
530+ case RouterProtocolTLSPassthrough:
531+ compatible = entryPoint.Protocol == EntryPointProtocolTLSMux || entryPoint.Protocol == EntryPointProtocolSmartTCP
532+ case RouterProtocolTCP:
533+ compatible = entryPoint.Protocol == EntryPointProtocolTCP || (entryPoint.Protocol == EntryPointProtocolSmartTCP && entryPoint.RawFallback == router.Name)
534+ case RouterProtocolUDP:
535+ compatible = entryPoint.Protocol == EntryPointProtocolUDP
536+ }
537+ if compatible {
538+ return nil
539+ }
540+ return fmt.Errorf("router %q protocol %s is incompatible with entrypoint %q protocol %s", router.Name, router.Protocol, entryPoint.Name, entryPoint.Protocol)
541+}
542+
543+func validateRouterServiceRef(router TrafficRouter, ref TrafficServiceRef, service TrafficService) error {
544+ switch router.Protocol {
545+ case RouterProtocolHTTP:
546+ if ref.Kind == TrafficServiceRefRoute || ref.Kind == TrafficServiceRefExternalRoute {
547+ return nil
548+ }
549+ case RouterProtocolTCP, RouterProtocolUDP, RouterProtocolTLSPassthrough:
550+ return validateL4RouterService(router, ref, service)
551+ }
552+ if ref.Kind == TrafficServiceRefStatic {
553+ return fmt.Errorf("static traffic service ref %q is unsupported in traffic graph validation", router.Service)
554+ }
555+ return fmt.Errorf("router %q protocol %s does not support service ref kind %s", router.Name, router.Protocol, ref.Kind)
556+}
557+
558+func validateL4RouterService(router TrafficRouter, ref TrafficServiceRef, service TrafficService) error {
559+ want, ok := l4BackendProtocol(router.Protocol)
560+ if !ok {
561+ return nil
562+ }
563+ if ref.Kind != TrafficServiceRefNetworkService && ref.Kind != TrafficServiceRefService {
564+ if ref.Kind == TrafficServiceRefStatic {
565+ return fmt.Errorf("static traffic service ref %q is unsupported in traffic graph validation", router.Service)
566+ }
567+ return fmt.Errorf("l4 router %q requires network_service or service service ref", router.Name)
568+ }
569+ if len(service.Backends) != 1 {
570+ return fmt.Errorf("l4 router service must have exactly one backend for router %q", router.Name)
571+ }
572+ backend := service.Backends[0]
573+ if backend.Protocol != want {
574+ return fmt.Errorf("backend protocol %s does not match router protocol %s for router %q (expected backend protocol %s)", backend.Protocol, router.Protocol, router.Name, want)
575+ }
576+ return nil
577+}
578+
579+func l4BackendProtocol(protocol RouterProtocol) (NetworkProtocol, bool) {
580+ switch protocol {
581+ case RouterProtocolTCP:
582+ return NetworkProtocolTCP, true
583+ case RouterProtocolUDP:
584+ return NetworkProtocolUDP, true
585+ case RouterProtocolTLSPassthrough:
586+ return NetworkProtocolTCP, true
587+ default:
588+ return "", false
589+ }
590+}
591+
592+func validateRawFallbackEntrypoint(entryPoint EntryPoint) error {
593+ if entryPoint.Protocol == EntryPointProtocolSmartTCP {
594+ if entryPoint.RawFallback == "" && len(entryPoint.RawFallbackTrustedCIDRs) > 0 {
595+ return fmt.Errorf("raw_fallback_trusted_cidrs on entrypoint %q requires raw_fallback", entryPoint.Name)
596+ }
597+ if entryPoint.RawFallback == "" && entryPoint.AllowPublicRawFallback {
598+ return fmt.Errorf("allow_public_raw_fallback on entrypoint %q requires raw_fallback", entryPoint.Name)
599+ }
600+ return nil
601+ }
602+ if entryPoint.RawFallback != "" {
603+ return fmt.Errorf("raw_fallback is only supported on smart_tcp entrypoint %q", entryPoint.Name)
604+ }
605+ if len(entryPoint.RawFallbackTrustedCIDRs) > 0 {
606+ return fmt.Errorf("raw_fallback_trusted_cidrs is only supported on smart_tcp entrypoint %q", entryPoint.Name)
607+ }
608+ if entryPoint.AllowPublicRawFallback {
609+ return fmt.Errorf("allow_public_raw_fallback is only supported on smart_tcp entrypoint %q", entryPoint.Name)
610+ }
611+ return nil
612+}
613+
614+func validateTrustedCIDRs(entryPoint EntryPoint) error {
615+ for _, cidr := range entryPoint.TrustedCIDRs {
616+ if _, _, err := net.ParseCIDR(strings.TrimSpace(cidr)); err != nil {
617+ return fmt.Errorf("invalid trusted_cidrs entry %q for entrypoint %q: %w", cidr, entryPoint.Name, err)
618+ }
619+ }
620+ for _, cidr := range entryPoint.RawFallbackTrustedCIDRs {
621+ if _, _, err := net.ParseCIDR(strings.TrimSpace(cidr)); err != nil {
622+ return fmt.Errorf("invalid raw_fallback_trusted_cidrs entry %q for entrypoint %q: %w", cidr, entryPoint.Name, err)
623+ }
624+ }
625+ if entryPoint.RawFallback != "" && len(entryPoint.RawFallbackTrustedCIDRs) == 0 && !entryPoint.AllowPublicRawFallback {
626+ return fmt.Errorf("raw fallback %q on entrypoint %q requires raw_fallback_trusted_cidrs or allow_public_raw_fallback", entryPoint.RawFallback, entryPoint.Name)
627+ }
628+ return nil
629+}
630+
631+func validateRawFallbackRouters(entryPoints map[string]EntryPoint, routers map[string]TrafficRouter) error {
632+ for _, entryPoint := range entryPoints {
633+ if entryPoint.RawFallback == "" {
634+ continue
635+ }
636+ router, ok := routers[entryPoint.RawFallback]
637+ if !ok {
638+ return fmt.Errorf("raw fallback %q on entrypoint %q references unknown router", entryPoint.RawFallback, entryPoint.Name)
639+ }
640+ if router.EntryPoint != entryPoint.Name {
641+ return fmt.Errorf("raw fallback %q on entrypoint %q references router on entrypoint %q", entryPoint.RawFallback, entryPoint.Name, router.EntryPoint)
642+ }
643+ if router.Protocol != RouterProtocolTCP {
644+ return fmt.Errorf("raw fallback %q on entrypoint %q must reference tcp router", entryPoint.RawFallback, entryPoint.Name)
645+ }
646+ }
647+ return nil
648+}
649+
650+func validateEntryPointProtocol(protocol EntryPointProtocol) error {
651+ switch protocol {
652+ case EntryPointProtocolTLSMux, EntryPointProtocolSmartTCP, EntryPointProtocolTCP, EntryPointProtocolUDP:
653+ return nil
654+ default:
655+ return fmt.Errorf("unsupported traffic entrypoint protocol %q", protocol)
656+ }
657+}
658+
659+func validateRouterProtocol(protocol RouterProtocol) error {
660+ switch protocol {
661+ case RouterProtocolHTTP, RouterProtocolTCP, RouterProtocolUDP, RouterProtocolTLSPassthrough:
662+ return nil
663+ default:
664+ return fmt.Errorf("%q", protocol)
665+ }
666+}
667+
668+func validateNetworkProtocol(protocol NetworkProtocol) error {
669+ switch protocol {
670+ case NetworkProtocolTCP, NetworkProtocolUDP:
671+ return nil
672+ default:
673+ return fmt.Errorf("%q", protocol)
674+ }
675+}
676+
677+type listenAddress struct {
678+ key string
679+ port int
680+ wildcard bool
681+}
682+
683+type listenAddressSet struct {
684+ byKey map[string]string
685+ all []registeredListenAddress
686+}
687+
688+type registeredListenAddress struct {
689+ addr listenAddress
690+ name string
691+}
692+
693+func newListenAddressSet() listenAddressSet {
694+ return listenAddressSet{byKey: map[string]string{}}
695+}
696+
697+func (s *listenAddressSet) add(addr listenAddress, name string, protocol string) error {
698+ if other, exists := s.byKey[addr.key]; exists {
699+ return fmt.Errorf("duplicate %s entrypoint address %q used by %q and %q", protocol, addr.key, other, name)
700+ }
701+ for _, existing := range s.all {
702+ if existing.addr.port == addr.port && (existing.addr.wildcard || addr.wildcard) {
703+ return fmt.Errorf("conflicting %s entrypoint address %q used by %q and %q", protocol, addr.key, existing.name, name)
704+ }
705+ }
706+ s.byKey[addr.key] = name
707+ s.all = append(s.all, registeredListenAddress{addr: addr, name: name})
708+ return nil
709+}
710+
711+func parseListenAddress(address string) (listenAddress, error) {
712+ host, portValue, err := net.SplitHostPort(address)
713+ if err != nil {
714+ return listenAddress{}, err
715+ }
716+ port, err := strconv.Atoi(portValue)
717+ if err != nil || port < 1 || port > 65535 {
718+ return listenAddress{}, fmt.Errorf("invalid port %q", portValue)
719+ }
720+ return listenAddress{
721+ key: net.JoinHostPort(host, strconv.Itoa(port)),
722+ port: port,
723+ wildcard: isWildcardListenHost(host),
724+ }, nil
725+}
726+
727+func isWildcardListenHost(host string) bool {
728+ return host == "" || host == "0.0.0.0" || host == "::"
729+}
730+
731+func normalizeHost(host string) string {
732+ return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
733+}
734+
735+func matchingWildcardSNI(host string, wildcards map[string]string) string {
736+ for wildcard := range wildcards {
737+ if hostMatchesWildcard(host, wildcard) {
738+ return wildcard
739+ }
740+ }
741+ return ""
742+}
743+
744+func matchingHTTPHost(wildcard string, hosts map[string]string) string {
745+ for host := range hosts {
746+ if hostMatchesWildcard(host, wildcard) {
747+ return host
748+ }
749+ }
750+ return ""
751+}
752+
753+func overlappingWildcardSNI(wildcard string, wildcards map[string]string) string {
754+ for existing := range wildcards {
755+ if wildcardSNIOverlap(wildcard, existing) {
756+ return existing
757+ }
758+ }
759+ return ""
760+}
761+
762+func wildcardSNIOverlap(left string, right string) bool {
763+ leftSuffix, leftOK := strings.CutPrefix(left, "*.")
764+ rightSuffix, rightOK := strings.CutPrefix(right, "*.")
765+ if !leftOK || !rightOK {
766+ return false
767+ }
768+ return leftSuffix == rightSuffix || strings.HasSuffix(leftSuffix, "."+rightSuffix) || strings.HasSuffix(rightSuffix, "."+leftSuffix)
769+}
770+
771+func hostMatchesWildcard(host string, wildcard string) bool {
772+ suffix, ok := strings.CutPrefix(wildcard, "*.")
773+ if !ok || host == suffix {
774+ return false
775+ }
776+ return strings.HasSuffix(host, "."+suffix)
777+}
778+
779+func putNested(values map[string]map[string]string, key string, nestedKey string, value string) {
780+ if values[key] == nil {
781+ values[key] = map[string]string{}
782+ }
783+ values[key][nestedKey] = value
784+}
A internal/domain/traffic_test.go +719 -0
@@ -0,0 +1,719 @@
1+package domain
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestTrafficGraphSmartTCPAllowsHTTPTLSPassthroughAndExplicitRawFallback(t *testing.T) {
11+ graph := TrafficGraph{
12+ EntryPoints: []EntryPoint{{
13+ Name: "edge",
14+ Address: ":443",
15+ Protocol: EntryPointProtocolSmartTCP,
16+ RawFallback: "ssh-fallback",
17+ RawFallbackTrustedCIDRs: []string{"100.64.0.0/10"},
18+ }},
19+ Routers: []TrafficRouter{
20+ {Name: "route:app.example.com", EntryPoint: "edge", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
21+ {Name: "raw-tls", EntryPoint: "edge", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "raw.example.com"}, Service: "network_service:raw:tls"},
22+ {Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"},
23+ },
24+ Services: []TrafficService{
25+ {Name: "route:app.example.com"},
26+ {Name: "network_service:raw:tls", Backends: []TrafficBackend{{Name: "raw:tls", Host: "raw", Port: 443, Protocol: NetworkProtocolTCP}}},
27+ {Name: "network_service:ssh:ssh", Backends: []TrafficBackend{{Name: "ssh:ssh", Host: "ssh", Port: 22, Protocol: NetworkProtocolTCP}}},
28+ },
29+ }
30+ require.NoError(t, graph.Validate())
31+}
32+
33+func TestTrafficGraphSmartTCPRawFallbackValidation(t *testing.T) {
34+ baseServices := []TrafficService{
35+ {Name: "route:app.example.com"},
36+ {Name: "network_service:ssh:ssh", Backends: []TrafficBackend{{Name: "ssh:ssh", Host: "ssh", Port: 22, Protocol: NetworkProtocolTCP}}},
37+ }
38+
39+ tests := []struct {
40+ name string
41+ entryPoint EntryPoint
42+ routers []TrafficRouter
43+ wantErr string
44+ }{
45+ {
46+ name: "public raw fallback rejected by default",
47+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallback: "ssh-fallback"},
48+ routers: []TrafficRouter{{Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"}},
49+ wantErr: "raw fallback",
50+ },
51+ {
52+ name: "raw fallback cidrs require raw fallback on smart tcp",
53+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
54+ wantErr: "requires raw_fallback",
55+ },
56+ {
57+ name: "public raw fallback flag requires raw fallback on smart tcp",
58+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, AllowPublicRawFallback: true},
59+ wantErr: "requires raw_fallback",
60+ },
61+ {
62+ name: "missing raw fallback router rejected",
63+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallback: "missing", RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
64+ routers: nil,
65+ wantErr: "raw fallback",
66+ },
67+ {
68+ name: "raw fallback must reference tcp router",
69+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallback: "route:app.example.com", RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
70+ routers: []TrafficRouter{
71+ {Name: "route:app.example.com", EntryPoint: "edge", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
72+ },
73+ wantErr: "raw fallback",
74+ },
75+ {
76+ name: "invalid raw fallback trusted cidr rejected",
77+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallback: "ssh-fallback", RawFallbackTrustedCIDRs: []string{"not-a-cidr"}},
78+ routers: []TrafficRouter{{Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"}},
79+ wantErr: "invalid raw_fallback_trusted_cidrs",
80+ },
81+ {
82+ name: "raw fallback rejected on tcp entrypoint",
83+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolTCP, RawFallback: "ssh-fallback", RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
84+ routers: []TrafficRouter{{Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"}},
85+ wantErr: "raw_fallback is only supported on smart_tcp",
86+ },
87+ {
88+ name: "raw fallback cidrs rejected on tls mux entrypoint",
89+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolTLSMux, RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
90+ wantErr: "raw_fallback_trusted_cidrs is only supported on smart_tcp",
91+ },
92+ {
93+ name: "public raw fallback flag rejected on udp entrypoint",
94+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolUDP, AllowPublicRawFallback: true},
95+ wantErr: "allow_public_raw_fallback is only supported on smart_tcp",
96+ },
97+ }
98+
99+ for _, tt := range tests {
100+ t.Run(tt.name, func(t *testing.T) {
101+ graph := TrafficGraph{EntryPoints: []EntryPoint{tt.entryPoint}, Routers: tt.routers, Services: baseServices}
102+ require.ErrorContains(t, graph.Validate(), tt.wantErr)
103+ })
104+ }
105+}
106+
107+func TestTrafficGraphSmartTCPRejectsHTTPHostAndTLSSNIConflict(t *testing.T) {
108+ graph := TrafficGraph{
109+ EntryPoints: []EntryPoint{{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP}},
110+ Routers: []TrafficRouter{
111+ {Name: "route:app.example.com", EntryPoint: "edge", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
112+ {Name: "raw-tls", EntryPoint: "edge", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "app.example.com"}, Service: "network_service:raw:tls"},
113+ },
114+ Services: []TrafficService{
115+ {Name: "route:app.example.com"},
116+ {Name: "network_service:raw:tls", Backends: []TrafficBackend{{Name: "raw:tls", Host: "raw", Port: 443, Protocol: NetworkProtocolTCP}}},
117+ },
118+ }
119+ require.ErrorContains(t, graph.Validate(), "http host conflicts with tls passthrough sni")
120+}
121+
122+func TestTrafficGraphValidateEntryPoints(t *testing.T) {
123+ tests := []struct {
124+ name string
125+ graph TrafficGraph
126+ wantErr string
127+ }{
128+ {
129+ name: "duplicate entrypoint names",
130+ graph: TrafficGraph{EntryPoints: []EntryPoint{
131+ {Name: "web", Address: ":80", Protocol: EntryPointProtocolTLSMux},
132+ {Name: "web", Address: ":81", Protocol: EntryPointProtocolTLSMux},
133+ }},
134+ wantErr: "duplicate entrypoint name",
135+ },
136+ {
137+ name: "invalid entrypoint address",
138+ graph: TrafficGraph{EntryPoints: []EntryPoint{
139+ {Name: "web", Address: "not-a-listen-address", Protocol: EntryPointProtocolTLSMux},
140+ }},
141+ wantErr: "invalid entrypoint address",
142+ },
143+ {
144+ name: "http entrypoint protocol is unsupported",
145+ graph: TrafficGraph{EntryPoints: []EntryPoint{
146+ {Name: "web", Address: ":80", Protocol: EntryPointProtocolHTTP},
147+ }},
148+ wantErr: "unsupported traffic entrypoint protocol \"http\"",
149+ },
150+ {
151+ name: "invalid entrypoint protocol",
152+ graph: TrafficGraph{EntryPoints: []EntryPoint{
153+ {Name: "web", Address: ":80", Protocol: EntryPointProtocol("smtp")},
154+ }},
155+ wantErr: "unsupported traffic entrypoint protocol \"smtp\"",
156+ },
157+ {
158+ name: "duplicate tcp address rejected",
159+ graph: TrafficGraph{EntryPoints: []EntryPoint{
160+ {Name: "web", Address: ":443", Protocol: EntryPointProtocolTLSMux},
161+ {Name: "tcp", Address: ":443", Protocol: EntryPointProtocolTCP},
162+ }},
163+ wantErr: "duplicate tcp entrypoint address",
164+ },
165+ {
166+ name: "duplicate udp address rejected",
167+ graph: TrafficGraph{EntryPoints: []EntryPoint{
168+ {Name: "dns1", Address: ":53", Protocol: EntryPointProtocolUDP},
169+ {Name: "dns2", Address: ":53", Protocol: EntryPointProtocolUDP},
170+ }},
171+ wantErr: "duplicate udp entrypoint address",
172+ },
173+ {
174+ name: "tcp wildcard address conflicts with ipv4 specific bind",
175+ graph: TrafficGraph{EntryPoints: []EntryPoint{
176+ {Name: "wild", Address: ":443", Protocol: EntryPointProtocolTCP},
177+ {Name: "specific", Address: "127.0.0.1:443", Protocol: EntryPointProtocolTCP},
178+ }},
179+ wantErr: "conflicting tcp entrypoint address",
180+ },
181+ {
182+ name: "tcp wildcard address conflicts with ipv6 wildcard bind",
183+ graph: TrafficGraph{EntryPoints: []EntryPoint{
184+ {Name: "wild", Address: ":443", Protocol: EntryPointProtocolTCP},
185+ {Name: "specific", Address: "[::]:443", Protocol: EntryPointProtocolTCP},
186+ }},
187+ wantErr: "conflicting tcp entrypoint address",
188+ },
189+ {
190+ name: "tcp and udp same numeric port allowed",
191+ graph: TrafficGraph{EntryPoints: []EntryPoint{
192+ {Name: "tcp", Address: ":53", Protocol: EntryPointProtocolTCP},
193+ {Name: "udp", Address: ":53", Protocol: EntryPointProtocolUDP},
194+ }},
195+ },
196+ {
197+ name: "invalid trusted cidr rejected",
198+ graph: TrafficGraph{EntryPoints: []EntryPoint{
199+ {Name: "tcp", Address: ":5432", Protocol: EntryPointProtocolTCP, TrustedCIDRs: []string{"not-a-cidr"}},
200+ }},
201+ wantErr: "invalid trusted_cidrs",
202+ },
203+ }
204+
205+ for _, tt := range tests {
206+ t.Run(tt.name, func(t *testing.T) {
207+ err := tt.graph.Validate()
208+ if tt.wantErr == "" {
209+ require.NoError(t, err)
210+ return
211+ }
212+ require.ErrorContains(t, err, tt.wantErr)
213+ })
214+ }
215+}
216+
217+func TestTrafficGraphValidateRouterEntryPointProtocolCompatibility(t *testing.T) {
218+ tests := []struct {
219+ name string
220+ routerProtocol RouterProtocol
221+ entryProtocol EntryPointProtocol
222+ wantErr bool
223+ }{
224+ {name: "http router accepts tls mux entrypoint", routerProtocol: RouterProtocolHTTP, entryProtocol: EntryPointProtocolTLSMux},
225+ {name: "tls passthrough router accepts tls mux entrypoint", routerProtocol: RouterProtocolTLSPassthrough, entryProtocol: EntryPointProtocolTLSMux},
226+ {name: "tcp router accepts tcp entrypoint", routerProtocol: RouterProtocolTCP, entryProtocol: EntryPointProtocolTCP},
227+ {name: "udp router accepts udp entrypoint", routerProtocol: RouterProtocolUDP, entryProtocol: EntryPointProtocolUDP},
228+ {name: "http router rejects udp entrypoint", routerProtocol: RouterProtocolHTTP, entryProtocol: EntryPointProtocolUDP, wantErr: true},
229+ {name: "http router rejects tcp entrypoint", routerProtocol: RouterProtocolHTTP, entryProtocol: EntryPointProtocolTCP, wantErr: true},
230+ {name: "udp router rejects tcp entrypoint", routerProtocol: RouterProtocolUDP, entryProtocol: EntryPointProtocolTCP, wantErr: true},
231+ {name: "udp router rejects tls mux entrypoint", routerProtocol: RouterProtocolUDP, entryProtocol: EntryPointProtocolTLSMux, wantErr: true},
232+ {name: "tcp router rejects udp entrypoint", routerProtocol: RouterProtocolTCP, entryProtocol: EntryPointProtocolUDP, wantErr: true},
233+ {name: "tcp router rejects tls mux entrypoint", routerProtocol: RouterProtocolTCP, entryProtocol: EntryPointProtocolTLSMux, wantErr: true},
234+ {name: "tls passthrough router rejects udp entrypoint", routerProtocol: RouterProtocolTLSPassthrough, entryProtocol: EntryPointProtocolUDP, wantErr: true},
235+ {name: "tls passthrough router rejects tcp entrypoint per tls mux spec", routerProtocol: RouterProtocolTLSPassthrough, entryProtocol: EntryPointProtocolTCP, wantErr: true},
236+ }
237+
238+ for _, tt := range tests {
239+ t.Run(tt.name, func(t *testing.T) {
240+ serviceName := "route:app.example.com"
241+ backendProtocol := NetworkProtocolTCP
242+ rule := TrafficRule{Host: "app.example.com"}
243+ switch tt.routerProtocol {
244+ case RouterProtocolUDP:
245+ serviceName = "network_service:app:game"
246+ backendProtocol = NetworkProtocolUDP
247+ rule = TrafficRule{}
248+ case RouterProtocolTCP, RouterProtocolTLSPassthrough:
249+ serviceName = "network_service:app:tcp"
250+ rule = TrafficRule{SNI: "app.example.com"}
251+ }
252+
253+ graph := TrafficGraph{
254+ EntryPoints: []EntryPoint{{Name: "entry", Address: ":12345", Protocol: tt.entryProtocol}},
255+ Routers: []TrafficRouter{{Name: "router", EntryPoint: "entry", Protocol: tt.routerProtocol, Rule: rule, Service: serviceName}},
256+ Services: []TrafficService{{Name: serviceName, Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 8080, Protocol: backendProtocol}}}},
257+ }
258+ err := graph.Validate()
259+ if !tt.wantErr {
260+ require.NoError(t, err)
261+ return
262+ }
263+ require.ErrorContains(t, err, "router \"router\" protocol "+string(tt.routerProtocol)+" is incompatible with entrypoint \"entry\" protocol "+string(tt.entryProtocol))
264+ })
265+ }
266+}
267+
268+func TestTrafficGraphValidateRouterNames(t *testing.T) {
269+ service := TrafficService{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 8080, Protocol: NetworkProtocolTCP}}}
270+ tests := []struct {
271+ name string
272+ routers []TrafficRouter
273+ wantErr string
274+ }{
275+ {
276+ name: "router name is required",
277+ routers: []TrafficRouter{{EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: service.Name}},
278+ wantErr: "traffic router name is required",
279+ },
280+ {
281+ name: "duplicate router names rejected",
282+ routers: []TrafficRouter{
283+ {Name: "web", EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: service.Name},
284+ {Name: "web", EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "api.example.com"}, Service: service.Name},
285+ },
286+ wantErr: "duplicate traffic router name \"web\"",
287+ },
288+ }
289+
290+ for _, tt := range tests {
291+ t.Run(tt.name, func(t *testing.T) {
292+ graph := TrafficGraph{
293+ EntryPoints: []EntryPoint{{Name: "web", Address: ":443", Protocol: EntryPointProtocolTLSMux}},
294+ Routers: tt.routers,
295+ Services: []TrafficService{service},
296+ }
297+ require.ErrorContains(t, graph.Validate(), tt.wantErr)
298+ })
299+ }
300+}
301+
302+func TestTrafficGraphValidateDuplicateHTTPHosts(t *testing.T) {
303+ service := TrafficService{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 8080, Protocol: NetworkProtocolTCP}}}
304+ tests := []struct {
305+ name string
306+ entryProtocol EntryPointProtocol
307+ entryAddress string
308+ }{
309+ {name: "tls mux entrypoint", entryProtocol: EntryPointProtocolTLSMux, entryAddress: ":443"},
310+ }
311+
312+ for _, tt := range tests {
313+ t.Run(tt.name, func(t *testing.T) {
314+ graph := TrafficGraph{
315+ EntryPoints: []EntryPoint{{Name: "web", Address: tt.entryAddress, Protocol: tt.entryProtocol}},
316+ Routers: []TrafficRouter{
317+ {Name: "one", EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "App.Example.Com."}, Service: service.Name},
318+ {Name: "two", EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: service.Name},
319+ },
320+ Services: []TrafficService{service},
321+ }
322+ require.ErrorContains(t, graph.Validate(), "duplicate http host \"app.example.com\" on entrypoint \"web\"")
323+ })
324+ }
325+}
326+
327+func TestTrafficGraphValidateAmbiguousL4Routers(t *testing.T) {
328+ tests := []struct {
329+ name string
330+ entryProtocol EntryPointProtocol
331+ routerProtocol RouterProtocol
332+ service TrafficService
333+ wantErr string
334+ }{
335+ {
336+ name: "multiple tcp routers on same entrypoint rejected",
337+ entryProtocol: EntryPointProtocolTCP,
338+ routerProtocol: RouterProtocolTCP,
339+ service: TrafficService{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 5432, Protocol: NetworkProtocolTCP}}},
340+ wantErr: "ambiguous tcp routers on entrypoint \"l4\"",
341+ },
342+ {
343+ name: "multiple udp routers on same entrypoint rejected",
344+ entryProtocol: EntryPointProtocolUDP,
345+ routerProtocol: RouterProtocolUDP,
346+ service: TrafficService{Name: "network_service:app:game", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 25565, Protocol: NetworkProtocolUDP}}},
347+ wantErr: "ambiguous udp routers on entrypoint \"l4\"",
348+ },
349+ }
350+
351+ for _, tt := range tests {
352+ t.Run(tt.name, func(t *testing.T) {
353+ graph := TrafficGraph{
354+ EntryPoints: []EntryPoint{{Name: "l4", Address: ":12345", Protocol: tt.entryProtocol}},
355+ Routers: []TrafficRouter{
356+ {Name: "one", EntryPoint: "l4", Protocol: tt.routerProtocol, Service: tt.service.Name},
357+ {Name: "two", EntryPoint: "l4", Protocol: tt.routerProtocol, Service: tt.service.Name},
358+ },
359+ Services: []TrafficService{tt.service},
360+ }
361+ require.ErrorContains(t, graph.Validate(), tt.wantErr)
362+ })
363+ }
364+}
365+
366+func TestTrafficGraphValidateTLSRules(t *testing.T) {
367+ tests := []struct {
368+ name string
369+ routers []TrafficRouter
370+ wantErr string
371+ }{
372+ {
373+ name: "duplicate exact tls sni on same entrypoint rejected",
374+ routers: []TrafficRouter{
375+ {Name: "one", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "db.example.com"}, Service: "network_service:app:https"},
376+ {Name: "two", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "db.example.com"}, Service: "network_service:app:https"},
377+ },
378+ wantErr: "duplicate exact tls sni",
379+ },
380+ {
381+ name: "exact sni plus wildcard deterministic allowed",
382+ routers: []TrafficRouter{
383+ {Name: "exact", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "db.example.com"}, Service: "network_service:app:https"},
384+ {Name: "wild", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
385+ },
386+ },
387+ {
388+ name: "duplicate wildcard sni rejected",
389+ routers: []TrafficRouter{
390+ {Name: "wild1", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
391+ {Name: "wild2", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
392+ },
393+ wantErr: "ambiguous wildcard tls sni",
394+ },
395+ {
396+ name: "nested wildcard sni overlap rejected",
397+ routers: []TrafficRouter{
398+ {Name: "wild1", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
399+ {Name: "wild2", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.sub.example.com"}, Service: "network_service:app:https"},
400+ },
401+ wantErr: "ambiguous wildcard tls sni",
402+ },
403+ {
404+ name: "tls passthrough requires sni",
405+ routers: []TrafficRouter{
406+ {Name: "pass", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Service: "network_service:app:https"},
407+ },
408+ wantErr: "requires sni",
409+ },
410+ {
411+ name: "http host and tls passthrough sni conflict rejected",
412+ routers: []TrafficRouter{
413+ {Name: "http", EntryPoint: "tls", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
414+ {Name: "pass", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "app.example.com"}, Service: "network_service:app:https"},
415+ },
416+ wantErr: "http host conflicts with tls passthrough sni",
417+ },
418+ {
419+ name: "http host conflicts with later wildcard tls passthrough sni",
420+ routers: []TrafficRouter{
421+ {Name: "http", EntryPoint: "tls", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
422+ {Name: "pass", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
423+ },
424+ wantErr: "http host conflicts with wildcard tls passthrough sni",
425+ },
426+ {
427+ name: "http host conflicts with earlier wildcard tls passthrough sni",
428+ routers: []TrafficRouter{
429+ {Name: "pass", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "*.example.com"}, Service: "network_service:app:https"},
430+ {Name: "http", EntryPoint: "tls", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"},
431+ },
432+ wantErr: "http host conflicts with wildcard tls passthrough sni",
433+ },
434+ }
435+
436+ for _, tt := range tests {
437+ t.Run(tt.name, func(t *testing.T) {
438+ graph := TrafficGraph{
439+ EntryPoints: []EntryPoint{{Name: "tls", Address: ":443", Protocol: EntryPointProtocolTLSMux}},
440+ Routers: tt.routers,
441+ Services: []TrafficService{
442+ {Name: "network_service:app:https", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP}}},
443+ {Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.3", Port: 8080, Protocol: NetworkProtocolTCP}}},
444+ },
445+ }
446+ err := graph.Validate()
447+ if tt.wantErr == "" {
448+ require.NoError(t, err)
449+ return
450+ }
451+ require.ErrorContains(t, err, tt.wantErr)
452+ })
453+ }
454+}
455+
456+func TestTrafficGraphValidateBackendsAndL4Routers(t *testing.T) {
457+ tests := []struct {
458+ name string
459+ router TrafficRouter
460+ services []TrafficService
461+ wantErr string
462+ }{
463+ {
464+ name: "backend host required",
465+ router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"},
466+ services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Port: 5432, Protocol: NetworkProtocolTCP}}}},
467+ wantErr: "backend host is required",
468+ },
469+ {
470+ name: "backend ports outside range rejected low",
471+ router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"},
472+ services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 0, Protocol: NetworkProtocolTCP}}}},
473+ wantErr: "invalid backend port",
474+ },
475+ {
476+ name: "backend ports outside range rejected high",
477+ router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"},
478+ services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 65536, Protocol: NetworkProtocolTCP}}}},
479+ wantErr: "invalid backend port",
480+ },
481+ {
482+ name: "l4 routers reject services with more than one backend",
483+ router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"},
484+ services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{
485+ {Name: "app", Host: "10.0.0.2", Port: 5432, Protocol: NetworkProtocolTCP},
486+ {Name: "app2", Host: "10.0.0.3", Port: 5432, Protocol: NetworkProtocolTCP},
487+ }}},
488+ wantErr: "l4 router service must have exactly one backend",
489+ },
490+ {
491+ name: "tcp router requires tcp network backend",
492+ router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"},
493+ services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 53, Protocol: NetworkProtocolUDP}}}},
494+ wantErr: "backend protocol udp does not match router protocol tcp",
495+ },
496+ {
497+ name: "udp router requires udp network backend",
498+ router: TrafficRouter{Name: "udp", EntryPoint: "udp", Protocol: RouterProtocolUDP, Service: "network_service:app:game"},
499+ services: []TrafficService{{Name: "network_service:app:game", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 53, Protocol: NetworkProtocolTCP}}}},
500+ wantErr: "backend protocol tcp does not match router protocol udp",
501+ },
502+ {
503+ name: "tls passthrough requires tcp network backend",
504+ router: TrafficRouter{Name: "tls", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "db.example.com"}, Service: "network_service:app:https"},
505+ services: []TrafficService{{Name: "network_service:app:https", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolUDP}}}},
506+ wantErr: "backend protocol udp does not match router protocol tls_passthrough",
507+ },
508+ {
509+ name: "l4 router rejects route ref",
510+ router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "route:app.example.com"},
511+ services: []TrafficService{{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP}}}},
512+ wantErr: "requires network_service or service service ref",
513+ },
514+ {
515+ name: "static ref parses but graph validation rejects unsupported",
516+ router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "static:maintenance"},
517+ services: []TrafficService{{Name: "static:maintenance", Backends: []TrafficBackend{{Name: "maintenance", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP}}}},
518+ wantErr: "static traffic service ref \"static:maintenance\" is unsupported",
519+ },
520+ }
521+
522+ for _, tt := range tests {
523+ t.Run(tt.name, func(t *testing.T) {
524+ graph := TrafficGraph{
525+ EntryPoints: []EntryPoint{
526+ {Name: "tcp", Address: ":443", Protocol: EntryPointProtocolTCP},
527+ {Name: "udp", Address: ":443", Protocol: EntryPointProtocolUDP},
528+ {Name: "tls", Address: ":8443", Protocol: EntryPointProtocolTLSMux},
529+ },
530+ Routers: []TrafficRouter{tt.router},
531+ Services: tt.services,
532+ }
533+ require.ErrorContains(t, graph.Validate(), tt.wantErr)
534+ })
535+ }
536+}
537+
538+func TestTrafficGraphValidateL4ServiceRefs(t *testing.T) {
539+ tests := []struct {
540+ name string
541+ routerProtocol RouterProtocol
542+ entryPoint EntryPoint
543+ backend TrafficBackend
544+ }{
545+ {
546+ name: "tcp allows service ref with tcp backend",
547+ routerProtocol: RouterProtocolTCP,
548+ entryPoint: EntryPoint{Name: "tcp", Address: ":25565", Protocol: EntryPointProtocolTCP},
549+ backend: TrafficBackend{Name: "rust", Host: "10.0.0.2", Port: 25565, Protocol: NetworkProtocolTCP},
550+ },
551+ {
552+ name: "udp allows service ref with udp backend",
553+ routerProtocol: RouterProtocolUDP,
554+ entryPoint: EntryPoint{Name: "udp", Address: ":25565", Protocol: EntryPointProtocolUDP},
555+ backend: TrafficBackend{Name: "rust", Host: "10.0.0.2", Port: 25565, Protocol: NetworkProtocolUDP},
556+ },
557+ {
558+ name: "tls passthrough allows service ref with tcp backend",
559+ routerProtocol: RouterProtocolTLSPassthrough,
560+ entryPoint: EntryPoint{Name: "tls", Address: ":443", Protocol: EntryPointProtocolTLSMux},
561+ backend: TrafficBackend{Name: "rust", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP},
562+ },
563+ }
564+
565+ for _, tt := range tests {
566+ t.Run(tt.name, func(t *testing.T) {
567+ router := TrafficRouter{Name: "router", EntryPoint: tt.entryPoint.Name, Protocol: tt.routerProtocol, Service: "service:rust:game"}
568+ if tt.routerProtocol == RouterProtocolTLSPassthrough {
569+ router.Rule = TrafficRule{SNI: "rust.example.com"}
570+ }
571+ graph := TrafficGraph{
572+ EntryPoints: []EntryPoint{tt.entryPoint},
573+ Routers: []TrafficRouter{router},
574+ Services: []TrafficService{{Name: "service:rust:game", Backends: []TrafficBackend{tt.backend}}},
575+ }
576+ require.NoError(t, graph.Validate())
577+ })
578+ }
579+}
580+
581+func TestTrafficGraphValidateHTTPServiceRefs(t *testing.T) {
582+ tests := []struct {
583+ name string
584+ service string
585+ wantErr string
586+ }{
587+ {name: "http allows route", service: "route:app.example.com"},
588+ {name: "http allows external_route", service: "external_route:api.example.com"},
589+ {name: "http rejects network_service", service: "network_service:app:http", wantErr: "does not support service ref kind network_service"},
590+ {name: "http rejects service", service: "service:app:http", wantErr: "does not support service ref kind service"},
591+ {name: "http rejects static", service: "static:maintenance", wantErr: "static traffic service ref \"static:maintenance\" is unsupported"},
592+ }
593+
594+ for _, tt := range tests {
595+ t.Run(tt.name, func(t *testing.T) {
596+ graph := TrafficGraph{
597+ EntryPoints: []EntryPoint{{Name: "web", Address: ":443", Protocol: EntryPointProtocolTLSMux}},
598+ Routers: []TrafficRouter{{Name: "web", EntryPoint: "web", Protocol: RouterProtocolHTTP, Rule: TrafficRule{Host: "app.example.com"}, Service: tt.service}},
599+ Services: []TrafficService{{Name: tt.service, Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 8080, Protocol: NetworkProtocolTCP}}}},
600+ }
601+ err := graph.Validate()
602+ if tt.wantErr == "" {
603+ require.NoError(t, err)
604+ return
605+ }
606+ require.ErrorContains(t, err, tt.wantErr)
607+ })
608+ }
609+}
610+
611+func TestTrafficGraphValidateRouterServiceRefs(t *testing.T) {
612+ t.Run("router service ref is parsed", func(t *testing.T) {
613+ graph := TrafficGraph{
614+ EntryPoints: []EntryPoint{{Name: "tcp", Address: ":5432", Protocol: EntryPointProtocolTCP}},
615+ Routers: []TrafficRouter{{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:postgres:"}},
616+ Services: []TrafficService{{Name: "network_service:postgres:", Backends: []TrafficBackend{{Name: "postgres", Host: "10.0.0.4", Port: 5432, Protocol: NetworkProtocolTCP}}}},
617+ }
618+ require.ErrorContains(t, graph.Validate(), "invalid traffic service ref")
619+ })
620+
621+ t.Run("router service ref must exist as service name", func(t *testing.T) {
622+ graph := TrafficGraph{
623+ EntryPoints: []EntryPoint{{Name: "tcp", Address: ":5432", Protocol: EntryPointProtocolTCP}},
624+ Routers: []TrafficRouter{{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:postgres:db"}},
625+ }
626+ require.ErrorContains(t, graph.Validate(), "references unknown service")
627+ })
628+}
629+
630+func TestTrafficServiceRef(t *testing.T) {
631+ ref, err := ParseTrafficServiceRef("static:maintenance")
632+ require.NoError(t, err)
633+ require.Equal(t, TrafficServiceRefStatic, ref.Kind)
634+ require.True(t, ref.Reserved)
635+ require.Equal(t, "maintenance", ref.Name)
636+}
637+
638+func TestParseTrafficServiceRef(t *testing.T) {
639+ tests := []struct {
640+ input string
641+ want TrafficServiceRef
642+ }{
643+ {input: "route:app.example.com", want: TrafficServiceRef{Kind: TrafficServiceRefRoute, Domain: "app.example.com"}},
644+ {input: "external_route:api.example.com", want: TrafficServiceRef{Kind: TrafficServiceRefExternalRoute, Domain: "api.example.com"}},
645+ {input: "network_service:postgres:db", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "postgres", PortName: "db"}},
646+ {input: "network_service:web:http", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "web", PortName: "http"}},
647+ {input: "network_service:game:game", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "game", PortName: "game"}},
648+ {input: "service:rust:game", want: TrafficServiceRef{Kind: TrafficServiceRefService, Name: "rust", PortName: "game"}},
649+ {input: "static:maintenance", want: TrafficServiceRef{Kind: TrafficServiceRefStatic, Name: "maintenance", Reserved: true}},
650+ }
651+
652+ for _, tt := range tests {
653+ t.Run(tt.input, func(t *testing.T) {
654+ got, err := ParseTrafficServiceRef(tt.input)
655+ require.NoError(t, err)
656+ require.Equal(t, tt.want, got)
657+ })
658+ }
659+
660+ invalid := []string{"", "route:", "external_route:", "network_service:", "network_service:postgres", "network_service:postgres:", "network_service::db", "network_service:postgres:db:extra", "service:", "service:rust", "service:rust:", "service::game", "service:rust:game:extra", "static:", "unknown:value"}
661+ for _, input := range invalid {
662+ t.Run("invalid "+input, func(t *testing.T) {
663+ _, err := ParseTrafficServiceRef(input)
664+ require.Error(t, err)
665+ })
666+ }
667+}
668+
669+func TestTrafficGraphValidateRejectsInvalidOptions(t *testing.T) {
670+ base := TrafficGraph{}
671+ tests := []struct {
672+ name string
673+ mutate func(*TrafficGraph)
674+ wantErr string
675+ }{
676+ {name: "negative tcp dial timeout", mutate: func(g *TrafficGraph) { g.Options.TCP.DialTimeout = -time.Second }, wantErr: "traffic.tcp.dial_timeout must be positive"},
677+ {name: "negative tcp idle timeout", mutate: func(g *TrafficGraph) { g.Options.TCP.IdleTimeout = -time.Second }, wantErr: "traffic.tcp.idle_timeout must be positive"},
678+ {name: "negative tcp drain timeout", mutate: func(g *TrafficGraph) { g.Options.TCP.DrainTimeout = -time.Second }, wantErr: "traffic.tcp.drain_timeout must be positive"},
679+ {name: "negative tcp max connections", mutate: func(g *TrafficGraph) { g.Options.TCP.MaxConnections = -1 }, wantErr: "traffic.tcp.max_connections must be non-negative"},
680+ {name: "negative udp idle timeout", mutate: func(g *TrafficGraph) { g.Options.UDP.IdleTimeout = -time.Second }, wantErr: "traffic.udp.idle_timeout must be positive"},
681+ {name: "negative udp drain timeout", mutate: func(g *TrafficGraph) { g.Options.UDP.DrainTimeout = -time.Second }, wantErr: "traffic.udp.drain_timeout must be positive"},
682+ {name: "negative udp max sessions", mutate: func(g *TrafficGraph) { g.Options.UDP.MaxSessions = -1 }, wantErr: "traffic.udp.max_sessions must be non-negative"},
683+ }
684+ for _, tt := range tests {
685+ t.Run(tt.name, func(t *testing.T) {
686+ graph := base
687+ tt.mutate(&graph)
688+ require.ErrorContains(t, graph.Validate(), tt.wantErr)
689+ })
690+ }
691+}
692+
693+func TestTrafficDomainTypesMatchSpecFields(t *testing.T) {
694+ graph := TrafficGraph{
695+ Options: TrafficOptions{
696+ TCP: TCPOptions{DialTimeout: time.Second, IdleTimeout: time.Minute, DrainTimeout: 2 * time.Second, MaxConnections: 100},
697+ UDP: UDPOptions{IdleTimeout: time.Minute, DrainTimeout: 2 * time.Second, MaxSessions: 50},
698+ },
699+ EntryPoints: []EntryPoint{{Name: "tcp", Address: ":1234", Protocol: EntryPointProtocolTCP, TrustedCIDRs: []string{"10.0.0.0/8"}}},
700+ Routers: []TrafficRouter{{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:game"}},
701+ Services: []TrafficService{{Name: "network_service:app:game", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 1234, Protocol: NetworkProtocolTCP}}}},
702+ }
703+ require.NoError(t, graph.Validate())
704+
705+ status := TrafficStatus{
706+ LastReloadStatus: "ok",
707+ LastReloadError: "",
708+ EntryPoints: []EntryPointStatus{{Name: "tcp", ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7}},
709+ Routers: []TrafficRouterStatus{{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:game", Active: true}},
710+ Services: []TrafficServiceStatus{{Name: "network_service:app:game", Backends: []TrafficBackendStatus{{Name: "app", Host: "10.0.0.2", Port: 1234, Protocol: NetworkProtocolTCP, Active: true}}}},
711+ Counters: TrafficCounters{ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7},
712+ }
713+ require.Equal(t, "ok", status.LastReloadStatus)
714+ require.Empty(t, status.LastReloadError)
715+ require.Equal(t, int64(7), status.EntryPoints[0].BytesOut)
716+ require.True(t, status.Routers[0].Active)
717+ require.True(t, status.Services[0].Backends[0].Active)
718+ require.Equal(t, int64(7), status.Counters.BytesOut)
719+}
A internal/usecase/services/config.go +212 -0
@@ -0,0 +1,212 @@
1+package services
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+type Config struct {
12+ Name string `mapstructure:"name"`
13+ Image string `mapstructure:"image"`
14+ Enabled bool `mapstructure:"enabled"`
15+ Env []string `mapstructure:"env"`
16+ EnvFile string `mapstructure:"env_file"`
17+ Secrets []SecretRefConfig `mapstructure:"secrets"`
18+ Readiness ReadinessConfig `mapstructure:"readiness"`
19+ Cleanup CleanupConfig `mapstructure:"cleanup"`
20+ Ports []PortConfig `mapstructure:"ports"`
21+ Volumes []VolumeConfig `mapstructure:"volumes"`
22+}
23+
24+type PortConfig struct {
25+ Name string `mapstructure:"name"`
26+ Container int `mapstructure:"container"`
27+ Protocol domain.NetworkProtocol `mapstructure:"protocol"`
28+ Publish string `mapstructure:"publish"`
29+ Private bool `mapstructure:"private"`
30+ Public bool `mapstructure:"public"`
31+ TrustedCIDRs []string `mapstructure:"trusted_cidrs"`
32+}
33+
34+type VolumeConfig struct {
35+ Source string `mapstructure:"source"`
36+ Target string `mapstructure:"target"`
37+ ReadOnly bool `mapstructure:"read_only"`
38+}
39+
40+type ResolvedVolumeMount struct {
41+ Source string
42+ Target string
43+ ReadOnly bool
44+ Managed bool
45+}
46+
47+type SecretRefConfig struct {
48+ Name string `mapstructure:"name"`
49+ Key string `mapstructure:"key"`
50+}
51+
52+type ReadinessConfig struct {
53+ Type string `mapstructure:"type"`
54+ Path string `mapstructure:"path"`
55+ Contains string `mapstructure:"contains"`
56+ Timeout string `mapstructure:"timeout"`
57+}
58+
59+type CleanupConfig struct {
60+ PreserveVolumes *bool `mapstructure:"preserve_volumes"`
61+ RemoveContainer *bool `mapstructure:"remove_container"`
62+}
63+
64+func ToDomain(configs []Config) ([]domain.StandaloneService, error) {
65+ services := make([]domain.StandaloneService, 0, len(configs))
66+ seenNames := make(map[string]struct{}, len(configs))
67+ seenRuntimeNames := make(map[string]string, len(configs))
68+ for i, cfg := range configs {
69+ svc, err := cfg.ToDomain()
70+ if err != nil {
71+ return nil, fmt.Errorf("service config %d: %w", i, err)
72+ }
73+ name := strings.TrimSpace(svc.Name)
74+ if _, ok := seenNames[name]; ok {
75+ return nil, fmt.Errorf("service config %d: duplicate service name %q", i, name)
76+ }
77+ runtimeName := serviceRuntimeIdentifier(name)
78+ if previous, ok := seenRuntimeNames[runtimeName]; ok {
79+ return nil, fmt.Errorf("service config %d: duplicate service name %q normalizes to same runtime identifier as %q", i, name, previous)
80+ }
81+ seenNames[name] = struct{}{}
82+ seenRuntimeNames[runtimeName] = name
83+ services = append(services, svc)
84+ }
85+ return services, nil
86+}
87+
88+func (c Config) ToDomain() (domain.StandaloneService, error) {
89+ if err := c.Cleanup.validate(); err != nil {
90+ return domain.StandaloneService{}, err
91+ }
92+ readiness, err := c.readinessToDomain()
93+ if err != nil {
94+ return domain.StandaloneService{}, err
95+ }
96+ svc := domain.StandaloneService{
97+ Name: c.Name,
98+ Image: c.Image,
99+ Enabled: c.Enabled,
100+ Env: append([]string(nil), c.Env...),
101+ EnvFile: c.EnvFile,
102+ Secrets: secretRefsToDomain(c.Secrets),
103+ Readiness: readiness,
104+ Cleanup: c.Cleanup.toDomain(),
105+ Ports: portsToDomain(c.Ports),
106+ Volumes: volumesToDomain(c.Volumes),
107+ }
108+ if err := svc.Validate(); err != nil {
109+ return domain.StandaloneService{}, err
110+ }
111+ return svc, nil
112+}
113+
114+func (c Config) readinessToDomain() (domain.StandaloneServiceReadiness, error) {
115+ readinessType := c.Readiness.Type
116+ if readinessType == "" {
117+ readinessType = domain.StandaloneServiceReadinessNone
118+ }
119+ readiness := domain.StandaloneServiceReadiness{Type: readinessType, Path: c.Readiness.Path, Contains: c.Readiness.Contains}
120+ if c.Readiness.Timeout != "" {
121+ timeout, err := time.ParseDuration(c.Readiness.Timeout)
122+ if err != nil {
123+ return domain.StandaloneServiceReadiness{}, fmt.Errorf("readiness timeout %q is invalid: %w", c.Readiness.Timeout, err)
124+ }
125+ if timeout <= 0 {
126+ return domain.StandaloneServiceReadiness{}, fmt.Errorf("readiness timeout must be positive when set")
127+ }
128+ readiness.Timeout = timeout
129+ readiness.TimeoutSet = true
130+ }
131+ return readiness, nil
132+}
133+
134+func (c CleanupConfig) toDomain() domain.StandaloneServiceCleanup {
135+ cleanup := domain.StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true}
136+ if c.PreserveVolumes != nil {
137+ cleanup.PreserveVolumes = *c.PreserveVolumes
138+ }
139+ if c.RemoveContainer != nil {
140+ cleanup.RemoveContainer = *c.RemoveContainer
141+ }
142+ return cleanup
143+}
144+
145+func (c CleanupConfig) validate() error {
146+ if c.PreserveVolumes == nil || c.RemoveContainer == nil {
147+ return nil
148+ }
149+ if !*c.PreserveVolumes && !*c.RemoveContainer {
150+ return fmt.Errorf("cleanup cannot set preserve_volumes=false with remove_container=false")
151+ }
152+ return nil
153+}
154+
155+func portsToDomain(configs []PortConfig) []domain.StandaloneServicePort {
156+ ports := make([]domain.StandaloneServicePort, 0, len(configs))
157+ for _, cfg := range configs {
158+ ports = append(ports, domain.StandaloneServicePort{
159+ Name: cfg.Name,
160+ Container: cfg.Container,
161+ Protocol: cfg.Protocol,
162+ Publish: cfg.Publish,
163+ Private: cfg.Private,
164+ Public: cfg.Public,
165+ TrustedCIDRs: append([]string(nil), cfg.TrustedCIDRs...),
166+ })
167+ }
168+ return ports
169+}
170+
171+func volumesToDomain(configs []VolumeConfig) []domain.StandaloneServiceVolume {
172+ volumes := make([]domain.StandaloneServiceVolume, 0, len(configs))
173+ for _, cfg := range configs {
174+ volumes = append(volumes, domain.StandaloneServiceVolume{Source: cfg.Source, Target: cfg.Target, ReadOnly: cfg.ReadOnly})
175+ }
176+ return volumes
177+}
178+
179+func secretRefsToDomain(configs []SecretRefConfig) []domain.StandaloneServiceSecretRef {
180+ secrets := make([]domain.StandaloneServiceSecretRef, 0, len(configs))
181+ for _, cfg := range configs {
182+ secrets = append(secrets, domain.StandaloneServiceSecretRef{Name: cfg.Name, Key: cfg.Key})
183+ }
184+ return secrets
185+}
186+
187+func ResolveVolumeMounts(prefix, serviceName string, explicitVolumes []domain.StandaloneServiceVolume, imageVolumePaths []string) []ResolvedVolumeMount {
188+ if len(explicitVolumes) > 0 {
189+ mounts := make([]ResolvedVolumeMount, 0, len(explicitVolumes))
190+ for _, volume := range explicitVolumes {
191+ mounts = append(mounts, ResolvedVolumeMount{Source: volume.Source, Target: volume.Target, ReadOnly: volume.ReadOnly})
192+ }
193+ return mounts
194+ }
195+
196+ mounts := make([]ResolvedVolumeMount, 0, len(imageVolumePaths))
197+ for _, path := range imageVolumePaths {
198+ mounts = append(mounts, ResolvedVolumeMount{Source: ManagedServiceVolumeName(prefix, serviceName, path), Target: path, Managed: true})
199+ }
200+ return mounts
201+}
202+
203+func ManagedServiceVolumeName(prefix, serviceName, volumePath string) string {
204+ return fmt.Sprintf("%s-%s-%s",
205+ prefix,
206+ strings.ReplaceAll(serviceName, ".", "-"),
207+ strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
208+}
209+
210+func serviceRuntimeIdentifier(name string) string {
211+ return strings.NewReplacer(".", "-", "_", "-", "/", "-").Replace(name)
212+}
A internal/usecase/services/config_test.go +124 -0
@@ -0,0 +1,124 @@
1+package services
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+func TestToDomainRejectsDuplicateServiceNames(t *testing.T) {
13+ configs := []Config{
14+ {Name: "cache", Image: "redis:7", Enabled: true},
15+ {Name: "cache", Image: "redis:7", Enabled: true},
16+ }
17+
18+ _, err := ToDomain(configs)
19+
20+ require.ErrorContains(t, err, "duplicate service name")
21+}
22+
23+func TestToDomainRejectsServiceNamesWithDuplicateRuntimeIdentifier(t *testing.T) {
24+ configs := []Config{
25+ {Name: "cache.api", Image: "redis:7", Enabled: true},
26+ {Name: "cache_api", Image: "redis:7", Enabled: true},
27+ }
28+
29+ _, err := ToDomain(configs)
30+
31+ require.ErrorContains(t, err, "runtime identifier")
32+}
33+
34+func TestConfigToDomainRejectsNonPositiveReadinessTimeout(t *testing.T) {
35+ for _, timeout := range []string{"0s", "-1s"} {
36+ t.Run(timeout, func(t *testing.T) {
37+ cfg := Config{
38+ Name: "cache",
39+ Image: "redis:7",
40+ Enabled: true,
41+ Readiness: ReadinessConfig{
42+ Type: domain.StandaloneServiceReadinessTCP,
43+ Timeout: timeout,
44+ },
45+ }
46+
47+ _, err := cfg.ToDomain()
48+
49+ require.ErrorContains(t, err, "readiness timeout")
50+ require.ErrorContains(t, err, "positive")
51+ })
52+ }
53+}
54+
55+func TestConfigToDomainRejectsExplicitNoCleanupAction(t *testing.T) {
56+ preserveVolumes := false
57+ removeContainer := false
58+ cfg := Config{
59+ Name: "cache",
60+ Image: "redis:7",
61+ Enabled: true,
62+ Cleanup: CleanupConfig{PreserveVolumes: &preserveVolumes, RemoveContainer: &removeContainer},
63+ }
64+
65+ _, err := cfg.ToDomain()
66+
67+ require.ErrorContains(t, err, "preserve_volumes=false")
68+ require.ErrorContains(t, err, "remove_container=false")
69+}
70+
71+func TestConfigToDomainMapsExplicitPublicPorts(t *testing.T) {
72+ cfg := Config{
73+ Name: "game",
74+ Image: "game:latest",
75+ Enabled: true,
76+ Ports: []PortConfig{{
77+ Name: "rcon",
78+ Container: 28016,
79+ Protocol: domain.NetworkProtocolTCP,
80+ Publish: "127.0.0.1:38016",
81+ Public: true,
82+ }},
83+ }
84+
85+ svc, err := cfg.ToDomain()
86+
87+ require.NoError(t, err)
88+ require.Len(t, svc.Ports, 1)
89+ assert.True(t, svc.Ports[0].Public)
90+}
91+
92+func TestResolveVolumeMountsUsesExplicitVolumesAsIs(t *testing.T) {
93+ explicit := []domain.StandaloneServiceVolume{
94+ {Source: "rust-data", Target: "/steamcmd/rust", ReadOnly: false},
95+ {Source: "/srv/config", Target: "/config", ReadOnly: true},
96+ }
97+
98+ mounts := ResolveVolumeMounts("gordon", "rust.example.com", explicit, []string{"/ignored"})
99+
100+ assert.Equal(t, []ResolvedVolumeMount{
101+ {Source: "rust-data", Target: "/steamcmd/rust", ReadOnly: false},
102+ {Source: "/srv/config", Target: "/config", ReadOnly: true},
103+ }, mounts)
104+}
105+
106+func TestResolveVolumeMountsUsesImageDiscoveredVolumesWhenExplicitOmitted(t *testing.T) {
107+ mounts := ResolveVolumeMounts("gordon", "rust.example.com", nil, []string{"/steamcmd/rust", "/var/lib/data"})
108+
109+ assert.Equal(t, []ResolvedVolumeMount{
110+ {Source: "gordon-rust-example-com-steamcmd-rust", Target: "/steamcmd/rust", Managed: true},
111+ {Source: "gordon-rust-example-com-var-lib-data", Target: "/var/lib/data", Managed: true},
112+ }, mounts)
113+}
114+
115+func TestResolveVolumeMountsReturnsEmptyForStatelessImage(t *testing.T) {
116+ mounts := ResolveVolumeMounts("gordon", "stateless.example.com", nil, nil)
117+
118+ assert.Empty(t, mounts)
119+}
120+
121+func TestManagedServiceVolumeNameIsDeterministicAndGordonCompatible(t *testing.T) {
122+ assert.Equal(t, "gordon-rust-example-com-var-lib-data", ManagedServiceVolumeName("gordon", "rust.example.com", "/var/lib/data"))
123+ assert.Equal(t, "gordon-rust-example-com-data", ManagedServiceVolumeName("gordon", "rust.example.com", "/data"))
124+}
A internal/usecase/services/service.go +603 -0
@@ -0,0 +1,603 @@
1+package services
2+
3+import (
4+ "bufio"
5+ "context"
6+ "crypto/sha256"
7+ "encoding/hex"
8+ "encoding/json"
9+ "fmt"
10+ "io"
11+ "maps"
12+ "net"
13+ "os"
14+ "sort"
15+ "strconv"
16+ "strings"
17+ "time"
18+
19+ "github.com/bnema/gordon/internal/boundaries/out"
20+ "github.com/bnema/gordon/internal/domain"
21+)
22+
23+const defaultVolumePrefix = "gordon-service"
24+
25+type Service struct {
26+ runtime out.ContainerRuntime
27+ secretProvider out.SecretProvider
28+ volumePrefix string
29+}
30+
31+func NewService(runtime out.ContainerRuntime) *Service {
32+ return &Service{runtime: runtime, volumePrefix: defaultVolumePrefix}
33+}
34+
35+func NewServiceWithSecretProvider(runtime out.ContainerRuntime, secretProvider out.SecretProvider) *Service {
36+ return &Service{runtime: runtime, secretProvider: secretProvider, volumePrefix: defaultVolumePrefix}
37+}
38+
39+func NewServiceWithVolumePrefix(runtime out.ContainerRuntime, volumePrefix string) *Service {
40+ if volumePrefix == "" {
41+ volumePrefix = defaultVolumePrefix
42+ }
43+ return &Service{runtime: runtime, volumePrefix: volumePrefix}
44+}
45+
46+func (s *Service) Reconcile(ctx context.Context, configured []domain.StandaloneService) error {
47+ containers, err := s.runtime.ListContainers(ctx, true)
48+ if err != nil {
49+ return fmt.Errorf("list standalone service containers: %w", err)
50+ }
51+ existing := managedServiceContainers(containers)
52+ configuredNames := make(map[string]struct{}, len(configured))
53+ for _, svc := range configured {
54+ configuredNames[svc.Name] = struct{}{}
55+ if err := s.reconcileOne(ctx, svc, existing[svc.Name]); err != nil {
56+ return err
57+ }
58+ }
59+ for name, containers := range existing {
60+ if _, ok := configuredNames[name]; ok {
61+ continue
62+ }
63+ if err := s.stopRemoved(ctx, name, containers); err != nil {
64+ return err
65+ }
66+ }
67+ return nil
68+}
69+
70+func (s *Service) Status(ctx context.Context) ([]domain.StandaloneServiceStatus, error) {
71+ containers, err := s.runtime.ListContainers(ctx, true)
72+ if err != nil {
73+ return nil, fmt.Errorf("list standalone service containers: %w", err)
74+ }
75+ statuses := make([]domain.StandaloneServiceStatus, 0)
76+ for _, container := range containers {
77+ if container.Labels[domain.LabelService] != "true" {
78+ continue
79+ }
80+ statuses = append(statuses, domain.StandaloneServiceStatus{
81+ Name: container.Labels[domain.LabelServiceName],
82+ ContainerID: container.ID,
83+ ContainerName: container.Name,
84+ Status: containerStatus(container),
85+ ConfigHash: container.Labels[domain.LabelServiceConfigHash],
86+ })
87+ }
88+ sort.Slice(statuses, func(i, j int) bool { return statuses[i].Name < statuses[j].Name })
89+ return statuses, nil
90+}
91+
92+func (s *Service) reconcileOne(ctx context.Context, svc domain.StandaloneService, existing []*domain.Container) error {
93+ cleanup := normalizeCleanup(svc.Cleanup)
94+ if !svc.Enabled {
95+ return s.stopDisabled(ctx, svc.Name, cleanup, existing)
96+ }
97+ env, err := s.serviceEnv(ctx, svc)
98+ if err != nil {
99+ return fmt.Errorf("resolve standalone service %q environment: %w", svc.Name, err)
100+ }
101+ hash, err := serviceConfigHashWithEnv(svc, env)
102+ if err != nil {
103+ return fmt.Errorf("hash standalone service %q config: %w", svc.Name, err)
104+ }
105+ if len(existing) == 0 {
106+ return s.createAndStart(ctx, svc, hash, env)
107+ }
108+ sort.SliceStable(existing, func(i, j int) bool {
109+ leftRunning := containerStatus(existing[i]) == domain.ContainerStatusRunning
110+ rightRunning := containerStatus(existing[j]) == domain.ContainerStatusRunning
111+ if leftRunning != rightRunning {
112+ return leftRunning
113+ }
114+ return existing[i].ID < existing[j].ID
115+ })
116+ current := existing[0]
117+ if current.Labels[domain.LabelServiceConfigHash] != hash {
118+ if err := s.recreate(ctx, svc, cleanup, existing, hash, env); err != nil {
119+ return err
120+ }
121+ return nil
122+ }
123+ for _, duplicate := range existing[1:] {
124+ if err := s.cleanupContainer(ctx, svc.Name, "duplicate", cleanup, duplicate); err != nil {
125+ return err
126+ }
127+ }
128+ if containerStatus(current) != domain.ContainerStatusRunning {
129+ if err := s.runtime.StartContainer(ctx, current.ID); err != nil {
130+ return fmt.Errorf("start standalone service %q container: %w", svc.Name, err)
131+ }
132+ }
133+ if err := s.waitReadiness(ctx, current.ID, svc); err != nil {
134+ return err
135+ }
136+ return nil
137+}
138+
139+func (s *Service) recreate(ctx context.Context, svc domain.StandaloneService, cleanup domain.StandaloneServiceCleanup, existing []*domain.Container, hash string, env []string) error {
140+ for _, container := range existing {
141+ if containerStatus(container) == domain.ContainerStatusRunning {
142+ if err := s.runtime.StopContainer(ctx, container.ID); err != nil {
143+ return fmt.Errorf("stop stale standalone service %q container: %w", svc.Name, err)
144+ }
145+ }
146+ if cleanup.RemoveContainer {
147+ if err := s.runtime.RemoveContainer(ctx, container.ID, true); err != nil {
148+ return fmt.Errorf("remove stale standalone service %q container: %w", svc.Name, err)
149+ }
150+ }
151+ }
152+ return s.createAndStart(ctx, svc, hash, env)
153+}
154+
155+func (s *Service) stopDisabled(ctx context.Context, name string, cleanup domain.StandaloneServiceCleanup, existing []*domain.Container) error {
156+ for _, container := range existing {
157+ if err := s.cleanupContainer(ctx, name, "disabled", cleanup, container); err != nil {
158+ return err
159+ }
160+ }
161+ return nil
162+}
163+
164+func (s *Service) stopRemoved(ctx context.Context, name string, existing []*domain.Container) error {
165+ for _, container := range existing {
166+ cleanup := cleanupFromLabels(container.Labels)
167+ if err := s.cleanupContainer(ctx, name, "removed", cleanup, container); err != nil {
168+ return err
169+ }
170+ }
171+ return nil
172+}
173+
174+func (s *Service) cleanupContainer(ctx context.Context, name, reason string, cleanup domain.StandaloneServiceCleanup, container *domain.Container) error {
175+ if containerStatus(container) == domain.ContainerStatusRunning {
176+ if err := s.runtime.StopContainer(ctx, container.ID); err != nil {
177+ return fmt.Errorf("stop %s standalone service %q container: %w", reason, name, err)
178+ }
179+ }
180+ if cleanup.RemoveContainer {
181+ if err := s.runtime.RemoveContainer(ctx, container.ID, true); err != nil {
182+ return fmt.Errorf("remove %s standalone service %q container: %w", reason, name, err)
183+ }
184+ }
185+ if err := s.removeManagedVolumes(ctx, name, cleanup, container); err != nil {
186+ return err
187+ }
188+ return nil
189+}
190+
191+func (s *Service) removeManagedVolumes(ctx context.Context, name string, cleanup domain.StandaloneServiceCleanup, container *domain.Container) error {
192+ if cleanup.PreserveVolumes {
193+ return nil
194+ }
195+ managed := managedVolumeSet(container.Labels)
196+ for _, mount := range container.VolumeMounts {
197+ if mount.Type != "volume" || mount.Name == "" {
198+ continue
199+ }
200+ if _, ok := managed[mount.Name]; !ok {
201+ continue
202+ }
203+ if err := s.runtime.RemoveVolume(ctx, mount.Name, true); err != nil {
204+ return fmt.Errorf("remove standalone service %q volume %q: %w", name, mount.Name, err)
205+ }
206+ }
207+ return nil
208+}
209+
210+func managedVolumeSet(labels map[string]string) map[string]struct{} {
211+ values := strings.Split(labels[domain.LabelServiceManagedVolumes], ",")
212+ managed := make(map[string]struct{}, len(values))
213+ for _, value := range values {
214+ name := strings.TrimSpace(value)
215+ if name == "" {
216+ continue
217+ }
218+ managed[name] = struct{}{}
219+ }
220+ return managed
221+}
222+
223+func cleanupFromLabels(labels map[string]string) domain.StandaloneServiceCleanup {
224+ cleanup := domain.StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true}
225+ if value, ok := labels[domain.LabelServiceCleanupPreserveVolumes]; ok {
226+ if parsed, err := strconv.ParseBool(value); err == nil {
227+ cleanup.PreserveVolumes = parsed
228+ }
229+ }
230+ if value, ok := labels[domain.LabelServiceCleanupRemoveContainer]; ok {
231+ if parsed, err := strconv.ParseBool(value); err == nil {
232+ cleanup.RemoveContainer = parsed
233+ }
234+ }
235+ return cleanup
236+}
237+
238+func (s *Service) createAndStart(ctx context.Context, svc domain.StandaloneService, hash string, env []string) error {
239+ config, err := s.containerConfig(ctx, svc, hash, env)
240+ if err != nil {
241+ return err
242+ }
243+ container, err := s.runtime.CreateContainer(ctx, config)
244+ if err != nil {
245+ return fmt.Errorf("create standalone service %q container: %w", svc.Name, err)
246+ }
247+ if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
248+ return fmt.Errorf("start standalone service %q container: %w", svc.Name, err)
249+ }
250+ if err := s.waitReadiness(ctx, container.ID, svc); err != nil {
251+ return err
252+ }
253+ return nil
254+}
255+
256+func (s *Service) containerConfig(ctx context.Context, svc domain.StandaloneService, hash string, env []string) (*domain.ContainerConfig, error) {
257+ var imageVolumes []string
258+ if len(svc.Volumes) == 0 {
259+ var err error
260+ imageVolumes, err = s.runtime.InspectImageVolumes(ctx, svc.Image)
261+ if err != nil {
262+ return nil, fmt.Errorf("inspect standalone service %q image volumes: %w", svc.Name, err)
263+ }
264+ }
265+ mounts := ResolveVolumeMounts(s.volumePrefix, svc.Name, svc.Volumes, imageVolumes)
266+ volumes := make(map[string]string)
267+ readOnlyVolumes := make(map[string]string)
268+ managedVolumes := make([]string, 0)
269+ for _, mount := range mounts {
270+ if mount.Managed {
271+ managedVolumes = append(managedVolumes, mount.Source)
272+ }
273+ if mount.ReadOnly {
274+ readOnlyVolumes[mount.Target] = mount.Source
275+ continue
276+ }
277+ volumes[mount.Target] = mount.Source
278+ }
279+ publishes, err := portPublishes(svc)
280+ if err != nil {
281+ return nil, err
282+ }
283+ return &domain.ContainerConfig{
284+ Image: svc.Image,
285+ Name: serviceContainerName(svc.Name),
286+ Env: env,
287+ PortPublishes: publishes,
288+ Labels: serviceLabels(svc.Name, hash, normalizeCleanup(svc.Cleanup), managedVolumes),
289+ AutoRemove: false,
290+ RestartPolicy: domain.RestartPolicyAlways,
291+ Volumes: emptyToNil(volumes),
292+ ReadOnlyVolumes: emptyToNil(readOnlyVolumes),
293+ }, nil
294+}
295+
296+func managedServiceContainers(containers []*domain.Container) map[string][]*domain.Container {
297+ managed := make(map[string][]*domain.Container)
298+ for _, container := range containers {
299+ if container == nil || container.Labels[domain.LabelService] != "true" {
300+ continue
301+ }
302+ name := container.Labels[domain.LabelServiceName]
303+ if name == "" {
304+ continue
305+ }
306+ managed[name] = append(managed[name], container)
307+ }
308+ return managed
309+}
310+
311+func serviceLabels(name, hash string, cleanup domain.StandaloneServiceCleanup, managedVolumes []string) map[string]string {
312+ sort.Strings(managedVolumes)
313+ return map[string]string{
314+ domain.LabelManaged: "true",
315+ domain.LabelService: "true",
316+ domain.LabelServiceName: name,
317+ domain.LabelServiceConfigHash: hash,
318+ domain.LabelServiceManagedVolumes: strings.Join(managedVolumes, ","),
319+ domain.LabelServiceCleanupPreserveVolumes: strconv.FormatBool(cleanup.PreserveVolumes),
320+ domain.LabelServiceCleanupRemoveContainer: strconv.FormatBool(cleanup.RemoveContainer),
321+ }
322+}
323+
324+func portPublishes(svc domain.StandaloneService) ([]domain.ContainerPortPublish, error) {
325+ publishes := make([]domain.ContainerPortPublish, 0, len(svc.Ports))
326+ for _, port := range svc.Ports {
327+ if port.Publish == "" {
328+ continue
329+ }
330+ host, hostPort, err := net.SplitHostPort(port.Publish)
331+ if err != nil {
332+ return nil, fmt.Errorf("parse standalone service %q port %q publish: %w", svc.Name, port.Name, err)
333+ }
334+ hostPortNumber, err := strconv.Atoi(hostPort)
335+ if err != nil {
336+ return nil, fmt.Errorf("parse standalone service %q port %q publish port: %w", svc.Name, port.Name, err)
337+ }
338+ publishes = append(publishes, domain.ContainerPortPublish{
339+ HostIP: host, HostPort: hostPortNumber, ContainerPort: port.Container, Protocol: port.Protocol,
340+ })
341+ }
342+ return publishes, nil
343+}
344+
345+func serviceConfigHash(svc domain.StandaloneService) (string, error) {
346+ return NewService(nil).serviceConfigHash(context.Background(), svc)
347+}
348+
349+func (s *Service) serviceConfigHash(ctx context.Context, svc domain.StandaloneService) (string, error) {
350+ resolvedEnv, err := s.serviceEnv(ctx, svc)
351+ if err != nil {
352+ return "", err
353+ }
354+ return serviceConfigHashWithEnv(svc, resolvedEnv)
355+}
356+
357+func serviceConfigHashWithEnv(svc domain.StandaloneService, resolvedEnv []string) (string, error) {
358+ payload := struct {
359+ Image string
360+ ResolvedEnv []string
361+ Readiness domain.StandaloneServiceReadiness
362+ Ports []domain.StandaloneServicePort
363+ Volumes []domain.StandaloneServiceVolume
364+ Cleanup domain.StandaloneServiceCleanup
365+ }{svc.Image, append([]string(nil), resolvedEnv...), svc.Readiness, append([]domain.StandaloneServicePort(nil), svc.Ports...), append([]domain.StandaloneServiceVolume(nil), svc.Volumes...), normalizeCleanup(svc.Cleanup)}
366+ bytes, err := json.Marshal(payload)
367+ if err != nil {
368+ return "", err
369+ }
370+ sum := sha256.Sum256(bytes)
371+ return hex.EncodeToString(sum[:]), nil
372+}
373+
374+func (s *Service) serviceEnv(ctx context.Context, svc domain.StandaloneService) ([]string, error) {
375+ envMap := make(map[string]string)
376+ if svc.EnvFile != "" {
377+ fileEnv, err := loadEnvFile(ctx, svc.EnvFile)
378+ if err != nil {
379+ return nil, fmt.Errorf("load standalone service %q env file: %w", svc.Name, err)
380+ }
381+ maps.Copy(envMap, fileEnv)
382+ }
383+ for _, entry := range svc.Env {
384+ key, value, ok := strings.Cut(entry, "=")
385+ if !ok || key == "" {
386+ return nil, fmt.Errorf("parse standalone service %q env entry", svc.Name)
387+ }
388+ envMap[key] = value
389+ }
390+ if len(svc.Secrets) > 0 && s.secretProvider == nil {
391+ return nil, fmt.Errorf("resolve standalone service %q secrets: secret provider is not configured", svc.Name)
392+ }
393+ for _, secret := range svc.Secrets {
394+ value, err := s.secretProvider.GetSecret(ctx, serviceSecretPath(svc.Name, secret.Name))
395+ if err != nil {
396+ return nil, fmt.Errorf("resolve standalone service %q secret %q: %w", svc.Name, secret.Name, err)
397+ }
398+ envMap[secret.Key] = value
399+ }
400+ return envMapToList(envMap), nil
401+}
402+
403+func loadEnvFile(ctx context.Context, path string) (map[string]string, error) {
404+ if err := ctx.Err(); err != nil {
405+ return nil, err
406+ }
407+ file, err := os.Open(path)
408+ if err != nil {
409+ return nil, err
410+ }
411+ defer file.Close()
412+ values := make(map[string]string)
413+ scanner := bufio.NewScanner(file)
414+ for scanner.Scan() {
415+ if err := ctx.Err(); err != nil {
416+ return nil, err
417+ }
418+ line := strings.TrimSpace(scanner.Text())
419+ if line == "" || strings.HasPrefix(line, "#") {
420+ continue
421+ }
422+ key, value, ok := strings.Cut(line, "=")
423+ if !ok || strings.TrimSpace(key) == "" {
424+ return nil, fmt.Errorf("invalid env file line")
425+ }
426+ values[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"'`)
427+ }
428+ if err := scanner.Err(); err != nil {
429+ return nil, err
430+ }
431+ return values, nil
432+}
433+
434+func serviceSecretPath(serviceName, secretName string) string {
435+ if strings.Contains(secretName, ":") {
436+ return secretName
437+ }
438+ return "service/" + serviceName + "/" + secretName
439+}
440+
441+func envMapToList(values map[string]string) []string {
442+ if len(values) == 0 {
443+ return nil
444+ }
445+ keys := make([]string, 0, len(values))
446+ for key := range values {
447+ keys = append(keys, key)
448+ }
449+ sort.Strings(keys)
450+ env := make([]string, 0, len(keys))
451+ for _, key := range keys {
452+ env = append(env, key+"="+values[key])
453+ }
454+ return env
455+}
456+
457+func (s *Service) waitReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error {
458+ readiness := svc.Readiness
459+ if readiness.Type == "" || readiness.Type == domain.StandaloneServiceReadinessNone {
460+ return nil
461+ }
462+ readyCtx := ctx
463+ cancel := func() {}
464+ if readiness.Timeout > 0 {
465+ readyCtx, cancel = context.WithTimeout(ctx, readiness.Timeout)
466+ }
467+ defer cancel()
468+ switch readiness.Type {
469+ case domain.StandaloneServiceReadinessTCP:
470+ return waitTCPReadiness(readyCtx, svc)
471+ case domain.StandaloneServiceReadinessLog:
472+ return s.waitLogReadiness(readyCtx, containerID, svc)
473+ default:
474+ return fmt.Errorf("unsupported standalone service %q readiness type %q", svc.Name, readiness.Type)
475+ }
476+}
477+
478+func waitTCPReadiness(ctx context.Context, svc domain.StandaloneService) error {
479+ address, err := tcpReadinessAddress(svc)
480+ if err != nil {
481+ return err
482+ }
483+ ticker := time.NewTicker(10 * time.Millisecond)
484+ defer ticker.Stop()
485+ for {
486+ if err := ctx.Err(); err != nil {
487+ return fmt.Errorf("standalone service %q tcp readiness timed out: %w", svc.Name, err)
488+ }
489+ dialer := net.Dialer{Timeout: 100 * time.Millisecond}
490+ conn, err := dialer.DialContext(ctx, "tcp", address)
491+ if err == nil {
492+ _ = conn.Close()
493+ return nil
494+ }
495+ select {
496+ case <-ctx.Done():
497+ return fmt.Errorf("standalone service %q tcp readiness timed out: %w", svc.Name, ctx.Err())
498+ case <-ticker.C:
499+ }
500+ }
501+}
502+
503+func tcpReadinessAddress(svc domain.StandaloneService) (string, error) {
504+ for _, port := range svc.Ports {
505+ if port.Protocol != domain.NetworkProtocolTCP || port.Publish == "" {
506+ continue
507+ }
508+ host, hostPort, err := net.SplitHostPort(port.Publish)
509+ if err != nil {
510+ return "", fmt.Errorf("parse standalone service %q tcp readiness publish: %w", svc.Name, err)
511+ }
512+ switch host {
513+ case "::":
514+ host = "::1"
515+ case "0.0.0.0", "":
516+ host = "127.0.0.1"
517+ }
518+ return net.JoinHostPort(host, hostPort), nil
519+ }
520+ return "", fmt.Errorf("standalone service %q tcp readiness requires a published tcp port", svc.Name)
521+}
522+
523+func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error {
524+ ticker := time.NewTicker(10 * time.Millisecond)
525+ defer ticker.Stop()
526+ var lastErr error
527+ for {
528+ if err := ctx.Err(); err != nil {
529+ return logReadinessTimeoutError(svc.Name, err, lastErr)
530+ }
531+ found, err := s.logContains(ctx, containerID, svc.Readiness.Path, svc.Readiness.Contains)
532+ if err == nil && found {
533+ return nil
534+ }
535+ if err != nil {
536+ lastErr = err
537+ }
538+ // Continue polling until timeout; many services create the log after start.
539+ select {
540+ case <-ctx.Done():
541+ return logReadinessTimeoutError(svc.Name, ctx.Err(), lastErr)
542+ case <-ticker.C:
543+ }
544+ }
545+}
546+
547+func logReadinessTimeoutError(serviceName string, timeoutErr, lastErr error) error {
548+ if lastErr != nil {
549+ return fmt.Errorf("standalone service %q log readiness timed out: %w; last read error: %w", serviceName, timeoutErr, lastErr)
550+ }
551+ return fmt.Errorf("standalone service %q log readiness timed out: %w", serviceName, timeoutErr)
552+}
553+
554+func (s *Service) logContains(ctx context.Context, containerID, path, contains string) (bool, error) {
555+ reader, err := s.runtime.CopyFromContainer(ctx, containerID, path)
556+ if err != nil {
557+ return false, err
558+ }
559+ defer reader.Close()
560+ content, err := io.ReadAll(reader)
561+ if err != nil {
562+ return false, err
563+ }
564+ return strings.Contains(string(content), contains), nil
565+}
566+
567+func normalizeCleanup(cleanup domain.StandaloneServiceCleanup) domain.StandaloneServiceCleanup {
568+ if !cleanup.PreserveVolumes && !cleanup.RemoveContainer {
569+ return domain.StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true}
570+ }
571+ return cleanup
572+}
573+
574+func containerStatus(container *domain.Container) domain.ContainerStatus {
575+ status := strings.ToLower(container.Status)
576+ if strings.Contains(status, string(domain.ContainerStatusRunning)) {
577+ return domain.ContainerStatusRunning
578+ }
579+ if strings.Contains(status, string(domain.ContainerStatusExited)) {
580+ return domain.ContainerStatusExited
581+ }
582+ if strings.Contains(status, string(domain.ContainerStatusCreated)) {
583+ return domain.ContainerStatusCreated
584+ }
585+ if strings.Contains(status, string(domain.ContainerStatusPaused)) {
586+ return domain.ContainerStatusPaused
587+ }
588+ if strings.Contains(status, string(domain.ContainerStatusStopped)) {
589+ return domain.ContainerStatusStopped
590+ }
591+ return domain.ContainerStatusUnknown
592+}
593+
594+func serviceContainerName(name string) string {
595+ return "gordon-service-" + strings.NewReplacer(".", "-", "_", "-", "/", "-").Replace(name)
596+}
597+
598+func emptyToNil(values map[string]string) map[string]string {
599+ if len(values) == 0 {
600+ return nil
601+ }
602+ return values
603+}
A internal/usecase/services/service_test.go +456 -0
@@ -0,0 +1,456 @@
1+package services
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "net"
8+ "os"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "github.com/bnema/gordon/internal/adapters/out/secrets"
18+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
19+ "github.com/bnema/gordon/internal/domain"
20+)
21+
22+func TestService_ReconcileCreatesMissingEnabledService(t *testing.T) {
23+ rt := outmocks.NewMockContainerRuntime(t)
24+ svc := sampleService()
25+ var created *domain.ContainerConfig
26+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{}, nil).Once()
27+ rt.On("InspectImageVolumes", mock.Anything, svc.Image).Return([]string{"/data"}, nil).Once()
28+ rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Run(func(args mock.Arguments) {
29+ created = args.Get(1).(*domain.ContainerConfig)
30+ }).Return(&domain.Container{ID: "created-1"}, nil).Once()
31+ rt.On("StartContainer", mock.Anything, "created-1").Return(nil).Once()
32+
33+ err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
34+
35+ require.NoError(t, err)
36+ require.NotNil(t, created)
37+ assert.Equal(t, "game:latest", created.Image)
38+ assert.Equal(t, "gordon-service-game", created.Name)
39+ assert.Equal(t, []string{"PUBLIC=value"}, created.Env)
40+ assert.Equal(t, domain.RestartPolicyAlways, created.RestartPolicy)
41+ assert.Equal(t, "true", created.Labels[domain.LabelManaged])
42+ assert.Equal(t, "true", created.Labels[domain.LabelService])
43+ assert.Equal(t, "game", created.Labels[domain.LabelServiceName])
44+ assert.Equal(t, "true", created.Labels[domain.LabelServiceCleanupPreserveVolumes])
45+ assert.Equal(t, "true", created.Labels[domain.LabelServiceCleanupRemoveContainer])
46+ assert.NotEmpty(t, created.Labels[domain.LabelServiceConfigHash])
47+ assert.Equal(t, "gordon-service-game-data", created.Labels[domain.LabelServiceManagedVolumes])
48+ assert.Equal(t, []domain.ContainerPortPublish{{HostIP: "127.0.0.1", HostPort: 38015, ContainerPort: 28015, Protocol: domain.NetworkProtocolUDP}}, created.PortPublishes)
49+ assert.Equal(t, map[string]string{"/data": "gordon-service-game-data"}, created.Volumes)
50+}
51+
52+func TestService_ReconcileStartsStoppedExistingService(t *testing.T) {
53+ rt := outmocks.NewMockContainerRuntime(t)
54+ svc := sampleService()
55+ hash, err := serviceConfigHash(svc)
56+ require.NoError(t, err)
57+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{managedContainer("existing-1", svc.Name, hash, "exited")}, nil).Once()
58+ rt.On("StartContainer", mock.Anything, "existing-1").Return(nil).Once()
59+
60+ err = NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
61+
62+ require.NoError(t, err)
63+}
64+
65+func TestService_ReconcileChecksReadinessForExistingMatchingService(t *testing.T) {
66+ rt := outmocks.NewMockContainerRuntime(t)
67+ svc := sampleService()
68+ svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessLog, Path: "/logs/server.log", Contains: "ready", Timeout: time.Second}
69+ hash, err := serviceConfigHash(svc)
70+ require.NoError(t, err)
71+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{managedContainer("existing-1", svc.Name, hash, "running")}, nil).Once()
72+ rt.On("CopyFromContainer", mock.Anything, "existing-1", "/logs/server.log").Return(io.NopCloser(strings.NewReader("ready")), nil).Once()
73+
74+ err = NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
75+
76+ require.NoError(t, err)
77+}
78+
79+func TestService_ReconcileRecreatesStaleService(t *testing.T) {
80+ rt := outmocks.NewMockContainerRuntime(t)
81+ svc := sampleService()
82+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{managedContainer("old-1", svc.Name, "old-hash", "running")}, nil).Once()
83+ rt.On("StopContainer", mock.Anything, "old-1").Return(nil).Once()
84+ rt.On("RemoveContainer", mock.Anything, "old-1", true).Return(nil).Once()
85+ rt.On("InspectImageVolumes", mock.Anything, svc.Image).Return([]string{}, nil).Once()
86+ rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{ID: "new-1"}, nil).Once()
87+ rt.On("StartContainer", mock.Anything, "new-1").Return(nil).Once()
88+
89+ err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
90+
91+ require.NoError(t, err)
92+}
93+
94+func TestService_ReconcileRecreateDoesNotRemoveManagedVolumes(t *testing.T) {
95+ rt := outmocks.NewMockContainerRuntime(t)
96+ svc := sampleService()
97+ svc.Cleanup = domain.StandaloneServiceCleanup{PreserveVolumes: false, RemoveContainer: true}
98+ old := managedContainer("old-1", svc.Name, "old-hash", "running")
99+ old.Labels[domain.LabelServiceManagedVolumes] = "gordon-service-game-data"
100+ old.VolumeMounts = []domain.ContainerVolumeMount{{Name: "gordon-service-game-data", Type: "volume", Destination: "/data"}}
101+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{old}, nil).Once()
102+ rt.On("StopContainer", mock.Anything, "old-1").Return(nil).Once()
103+ rt.On("RemoveContainer", mock.Anything, "old-1", true).Return(nil).Once()
104+ rt.On("InspectImageVolumes", mock.Anything, svc.Image).Return([]string{}, nil).Once()
105+ rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{ID: "new-1"}, nil).Once()
106+ rt.On("StartContainer", mock.Anything, "new-1").Return(nil).Once()
107+
108+ err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
109+
110+ require.NoError(t, err)
111+}
112+
113+func TestService_ReconcileRemovesDuplicateMatchingServiceContainers(t *testing.T) {
114+ rt := outmocks.NewMockContainerRuntime(t)
115+ svc := sampleService()
116+ hash, err := serviceConfigHash(svc)
117+ require.NoError(t, err)
118+ older := managedContainer("existing-1", svc.Name, hash, "running")
119+ duplicate := managedContainer("existing-2", svc.Name, hash, "running")
120+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{duplicate, older}, nil).Once()
121+ rt.On("StopContainer", mock.Anything, "existing-2").Return(nil).Once()
122+ rt.On("RemoveContainer", mock.Anything, "existing-2", true).Return(nil).Once()
123+
124+ err = NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
125+
126+ require.NoError(t, err)
127+}
128+
129+func TestService_ReconcileKeepsRunningDuplicateBeforeLowerStaleID(t *testing.T) {
130+ rt := outmocks.NewMockContainerRuntime(t)
131+ svc := sampleService()
132+ hash, err := serviceConfigHash(svc)
133+ require.NoError(t, err)
134+ staleLowerID := managedContainer("existing-1", svc.Name, hash, "exited")
135+ runningPrimary := managedContainer("existing-2", svc.Name, hash, "running")
136+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{staleLowerID, runningPrimary}, nil).Once()
137+ rt.On("RemoveContainer", mock.Anything, "existing-1", true).Return(nil).Once()
138+
139+ err = NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
140+
141+ require.NoError(t, err)
142+}
143+
144+func TestService_ReconcileStopsAndRemovesDisabledService(t *testing.T) {
145+ rt := outmocks.NewMockContainerRuntime(t)
146+ svc := sampleService()
147+ svc.Enabled = false
148+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{managedContainer("disabled-1", svc.Name, "old-hash", "running")}, nil).Once()
149+ rt.On("StopContainer", mock.Anything, "disabled-1").Return(nil).Once()
150+ rt.On("RemoveContainer", mock.Anything, "disabled-1", true).Return(nil).Once()
151+
152+ err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
153+
154+ require.NoError(t, err)
155+}
156+
157+func TestService_ReconcileStopsAndRemovesOmittedManagedService(t *testing.T) {
158+ rt := outmocks.NewMockContainerRuntime(t)
159+ container := managedContainer("removed-1", "removed", "old-hash", "running")
160+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{container}, nil).Once()
161+ rt.On("StopContainer", mock.Anything, "removed-1").Return(nil).Once()
162+ rt.On("RemoveContainer", mock.Anything, "removed-1", true).Return(nil).Once()
163+
164+ err := NewService(rt).Reconcile(context.Background(), nil)
165+
166+ require.NoError(t, err)
167+}
168+
169+func TestService_ReconcileRemovesNamedVolumeMountsWhenCleanupDoesNotPreserveVolumes(t *testing.T) {
170+ rt := outmocks.NewMockContainerRuntime(t)
171+ svc := sampleService()
172+ svc.Enabled = false
173+ svc.Cleanup = domain.StandaloneServiceCleanup{PreserveVolumes: false, RemoveContainer: true}
174+ container := managedContainer("disabled-1", svc.Name, "old-hash", "running")
175+ container.Labels[domain.LabelServiceManagedVolumes] = "gordon-service-game-data"
176+ container.VolumeMounts = []domain.ContainerVolumeMount{
177+ {Name: "gordon-service-game-data", Type: "volume", Destination: "/data"},
178+ {Name: "user-shared-data", Type: "volume", Destination: "/shared"},
179+ {Name: "", Type: "volume", Destination: "/anonymous"},
180+ {Name: "host-data", Type: "bind", Destination: "/host"},
181+ }
182+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{container}, nil).Once()
183+ rt.On("StopContainer", mock.Anything, "disabled-1").Return(nil).Once()
184+ rt.On("RemoveContainer", mock.Anything, "disabled-1", true).Return(nil).Once()
185+ rt.On("RemoveVolume", mock.Anything, "gordon-service-game-data", true).Return(nil).Once()
186+
187+ err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
188+
189+ require.NoError(t, err)
190+}
191+
192+func TestService_ReconcileUsesCleanupLabelsForOmittedService(t *testing.T) {
193+ rt := outmocks.NewMockContainerRuntime(t)
194+ container := managedContainer("removed-1", "removed", "old-hash", "exited")
195+ container.Labels[domain.LabelServiceCleanupPreserveVolumes] = "false"
196+ container.Labels[domain.LabelServiceCleanupRemoveContainer] = "true"
197+ container.Labels[domain.LabelServiceManagedVolumes] = "gordon-service-removed-data"
198+ container.VolumeMounts = []domain.ContainerVolumeMount{{Name: "gordon-service-removed-data", Type: "volume", Destination: "/data"}}
199+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{container}, nil).Once()
200+ rt.On("RemoveContainer", mock.Anything, "removed-1", true).Return(nil).Once()
201+ rt.On("RemoveVolume", mock.Anything, "gordon-service-removed-data", true).Return(nil).Once()
202+
203+ err := NewService(rt).Reconcile(context.Background(), nil)
204+
205+ require.NoError(t, err)
206+}
207+
208+func TestService_ReconcileDefaultsOmittedServiceCleanupToPreserveVolumes(t *testing.T) {
209+ rt := outmocks.NewMockContainerRuntime(t)
210+ container := managedContainer("removed-1", "removed", "old-hash", "exited")
211+ container.VolumeMounts = []domain.ContainerVolumeMount{{Name: "gordon-service-removed-data", Type: "volume", Destination: "/data"}}
212+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{container}, nil).Once()
213+ rt.On("RemoveContainer", mock.Anything, "removed-1", true).Return(nil).Once()
214+
215+ err := NewService(rt).Reconcile(context.Background(), nil)
216+
217+ require.NoError(t, err)
218+}
219+
220+func TestService_ReconcileUsesExplicitReadOnlyVolumes(t *testing.T) {
221+ rt := outmocks.NewMockContainerRuntime(t)
222+ svc := sampleService()
223+ svc.Volumes = []domain.StandaloneServiceVolume{{Source: "cfg", Target: "/cfg", ReadOnly: true}}
224+ var created *domain.ContainerConfig
225+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{}, nil).Once()
226+ rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Run(func(args mock.Arguments) {
227+ created = args.Get(1).(*domain.ContainerConfig)
228+ }).Return(&domain.Container{ID: "created-1"}, nil).Once()
229+ rt.On("StartContainer", mock.Anything, "created-1").Return(nil).Once()
230+
231+ err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
232+
233+ require.NoError(t, err)
234+ assert.Nil(t, created.Volumes)
235+ assert.Equal(t, map[string]string{"/cfg": "cfg"}, created.ReadOnlyVolumes)
236+}
237+
238+func TestService_StatusReturnsManagedServices(t *testing.T) {
239+ rt := outmocks.NewMockContainerRuntime(t)
240+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{
241+ managedContainer("id-1", "alpha", "hash-1", "running"),
242+ {ID: "other", Labels: map[string]string{}},
243+ }, nil).Once()
244+
245+ statuses, err := NewService(rt).Status(context.Background())
246+
247+ require.NoError(t, err)
248+ require.Len(t, statuses, 1)
249+ assert.Equal(t, "alpha", statuses[0].Name)
250+ assert.Equal(t, "id-1", statuses[0].ContainerID)
251+ assert.Equal(t, domain.ContainerStatusRunning, statuses[0].Status)
252+ assert.Equal(t, "hash-1", statuses[0].ConfigHash)
253+}
254+
255+func TestServiceEnvLoadsEnvFileAndMergesInlineEnv(t *testing.T) {
256+ envFile := writeTempEnvFile(t, "FROM_FILE=file\nOVERRIDE=file\n# ignored\n")
257+ svc := sampleService()
258+ svc.EnvFile = envFile
259+ svc.Env = []string{"OVERRIDE=inline", "INLINE=value"}
260+
261+ env, err := NewService(nil).serviceEnv(context.Background(), svc)
262+
263+ require.NoError(t, err)
264+ assert.Equal(t, []string{"FROM_FILE=file", "INLINE=value", "OVERRIDE=inline"}, env)
265+}
266+
267+func TestServiceConfigHashIncludesResolvedEnvFileAndSecretValues(t *testing.T) {
268+ envFile := writeTempEnvFile(t, "FROM_FILE=one\n")
269+ provider := outmocks.NewMockSecretProvider(t)
270+ svc := sampleService()
271+ svc.EnvFile = envFile
272+ svc.Secrets = []domain.StandaloneServiceSecretRef{{Name: "rcon", Key: "RCON_PASSWORD"}}
273+ provider.On("GetSecret", mock.Anything, "service/game/rcon").Return("secret-one", nil).Once()
274+ first, err := NewServiceWithSecretProvider(nil, provider).serviceConfigHash(context.Background(), svc)
275+ require.NoError(t, err)
276+
277+ require.NoError(t, os.WriteFile(envFile, []byte("FROM_FILE=two\n"), 0o600))
278+ provider.On("GetSecret", mock.Anything, "service/game/rcon").Return("secret-two", nil).Once()
279+ second, err := NewServiceWithSecretProvider(nil, provider).serviceConfigHash(context.Background(), svc)
280+ require.NoError(t, err)
281+
282+ assert.NotEqual(t, first, second)
283+}
284+
285+func TestService_ReconcileResolvesSecretEnvOnceForHashAndContainer(t *testing.T) {
286+ rt := outmocks.NewMockContainerRuntime(t)
287+ provider := outmocks.NewMockSecretProvider(t)
288+ svc := sampleService()
289+ svc.Secrets = []domain.StandaloneServiceSecretRef{{Name: "rcon", Key: "RCON_PASSWORD"}}
290+ var created *domain.ContainerConfig
291+ provider.On("GetSecret", mock.Anything, "service/game/rcon").Return("secret-value", nil).Once()
292+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{}, nil).Once()
293+ rt.On("InspectImageVolumes", mock.Anything, svc.Image).Return([]string{}, nil).Once()
294+ rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Run(func(args mock.Arguments) {
295+ created = args.Get(1).(*domain.ContainerConfig)
296+ }).Return(&domain.Container{ID: "created-1"}, nil).Once()
297+ rt.On("StartContainer", mock.Anything, "created-1").Return(nil).Once()
298+
299+ err := NewServiceWithSecretProvider(rt, provider).Reconcile(context.Background(), []domain.StandaloneService{svc})
300+
301+ require.NoError(t, err)
302+ require.NotNil(t, created)
303+ assert.Contains(t, created.Env, "RCON_PASSWORD=secret-value")
304+ assert.NotEmpty(t, created.Labels[domain.LabelServiceConfigHash])
305+}
306+
307+func TestServiceEnvResolvesServiceScopedSecretsWithoutLeakingValues(t *testing.T) {
308+ rt := outmocks.NewMockContainerRuntime(t)
309+ provider := outmocks.NewMockSecretProvider(t)
310+ svc := sampleService()
311+ svc.Secrets = []domain.StandaloneServiceSecretRef{{Name: "rcon", Key: "RCON_PASSWORD"}}
312+ provider.On("GetSecret", mock.Anything, "service/game/rcon").Return("super-secret-value", errors.New("provider failed")).Once()
313+
314+ env, err := NewServiceWithSecretProvider(rt, provider).serviceEnv(context.Background(), svc)
315+
316+ require.Error(t, err)
317+ assert.Nil(t, env)
318+ assert.NotContains(t, err.Error(), "super-secret-value")
319+}
320+
321+func TestServiceSecretPathIsPassProviderSafe(t *testing.T) {
322+ path := serviceSecretPath("game", "rcon")
323+
324+ require.NoError(t, secrets.ValidatePath(path))
325+ assert.Equal(t, "service/game/rcon", path)
326+}
327+
328+func TestServiceSecretPathPreservesExplicitProviderPath(t *testing.T) {
329+ path := serviceSecretPath("game", "secrets/service.yaml:game.rcon")
330+
331+ assert.Equal(t, "secrets/service.yaml:game.rcon", path)
332+}
333+
334+func TestServiceEnvUsesExplicitProviderSecretPaths(t *testing.T) {
335+ provider := outmocks.NewMockSecretProvider(t)
336+ svc := sampleService()
337+ svc.Secrets = []domain.StandaloneServiceSecretRef{{Name: "secrets/service.yaml:game.rcon", Key: "RCON_PASSWORD"}}
338+ provider.On("GetSecret", mock.Anything, "secrets/service.yaml:game.rcon").Return("secret-value", nil).Once()
339+
340+ env, err := NewServiceWithSecretProvider(nil, provider).serviceEnv(context.Background(), svc)
341+
342+ require.NoError(t, err)
343+ assert.Contains(t, env, "RCON_PASSWORD=secret-value")
344+}
345+
346+func TestTCPReadinessAddressMapsIPv6WildcardToIPv6Loopback(t *testing.T) {
347+ svc := sampleService()
348+ svc.Ports = []domain.StandaloneServicePort{{Name: "admin", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: "[::]:38016"}}
349+
350+ address, err := tcpReadinessAddress(svc)
351+
352+ require.NoError(t, err)
353+ assert.Equal(t, "[::1]:38016", address)
354+}
355+
356+func TestWaitTCPReadinessDialsResolvedLoopbackPublish(t *testing.T) {
357+ listener, err := net.Listen("tcp", "127.0.0.1:0")
358+ require.NoError(t, err)
359+ defer listener.Close()
360+ accepted := make(chan struct{})
361+ go func() {
362+ conn, acceptErr := listener.Accept()
363+ if acceptErr == nil {
364+ _ = conn.Close()
365+ close(accepted)
366+ }
367+ }()
368+ svc := sampleService()
369+ svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessTCP, Timeout: time.Second}
370+ svc.Ports = []domain.StandaloneServicePort{{Name: "admin", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: listener.Addr().String()}}
371+
372+ err = NewService(nil).waitReadiness(context.Background(), "container-1", svc)
373+
374+ require.NoError(t, err)
375+ <-accepted
376+}
377+
378+func TestWaitLogReadinessReadsContainerFileUntilTextAppears(t *testing.T) {
379+ rt := outmocks.NewMockContainerRuntime(t)
380+ svc := sampleService()
381+ svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessLog, Path: "/logs/server.log", Contains: "ready", Timeout: time.Second}
382+ rt.On("CopyFromContainer", mock.Anything, "container-1", "/logs/server.log").Return(io.NopCloser(strings.NewReader("starting")), nil).Once()
383+ rt.On("CopyFromContainer", mock.Anything, "container-1", "/logs/server.log").Return(io.NopCloser(strings.NewReader("server ready")), nil).Once()
384+
385+ err := NewService(rt).waitReadiness(context.Background(), "container-1", svc)
386+
387+ require.NoError(t, err)
388+}
389+
390+func TestWaitReadinessHonorsContextTimeout(t *testing.T) {
391+ svc := sampleService()
392+ svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessTCP}
393+ svc.Ports = []domain.StandaloneServicePort{{Name: "admin", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: "127.0.0.1:1"}}
394+ ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
395+ defer cancel()
396+
397+ err := NewService(nil).waitReadiness(ctx, "container-1", svc)
398+
399+ require.Error(t, err)
400+ assert.ErrorIs(t, err, context.DeadlineExceeded)
401+}
402+
403+func TestWaitLogReadinessPreservesLastReadErrorOnTimeout(t *testing.T) {
404+ rt := outmocks.NewMockContainerRuntime(t)
405+ svc := sampleService()
406+ svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessLog, Path: "/logs/server.log", Contains: "ready", Timeout: 25 * time.Millisecond}
407+ readErr := errors.New("copy failed")
408+ rt.On("CopyFromContainer", mock.Anything, "container-1", "/logs/server.log").Return(nil, readErr)
409+
410+ err := NewService(rt).waitReadiness(context.Background(), "container-1", svc)
411+
412+ require.Error(t, err)
413+ assert.ErrorIs(t, err, readErr)
414+ assert.ErrorIs(t, err, context.DeadlineExceeded)
415+}
416+
417+func TestNormalizeCleanupDefaultsPreserveVolumes(t *testing.T) {
418+ cleanup := normalizeCleanup(domain.StandaloneServiceCleanup{})
419+
420+ assert.True(t, cleanup.PreserveVolumes)
421+ assert.True(t, cleanup.RemoveContainer)
422+}
423+
424+func sampleService() domain.StandaloneService {
425+ return domain.StandaloneService{
426+ Name: "game",
427+ Image: "game:latest",
428+ Enabled: true,
429+ Env: []string{"PUBLIC=value"},
430+ Cleanup: domain.StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true},
431+ Ports: []domain.StandaloneServicePort{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolUDP, Publish: "127.0.0.1:38015"}},
432+ }
433+}
434+
435+func managedContainer(id, name, hash, status string) *domain.Container {
436+ return &domain.Container{
437+ ID: id,
438+ Name: "gordon-service-" + name,
439+ Status: status,
440+ Labels: map[string]string{
441+ domain.LabelService: "true",
442+ domain.LabelServiceName: name,
443+ domain.LabelServiceConfigHash: hash,
444+ },
445+ }
446+}
447+
448+func writeTempEnvFile(t *testing.T, content string) string {
449+ t.Helper()
450+ file, err := os.CreateTemp(t.TempDir(), "service-*.env")
451+ require.NoError(t, err)
452+ _, err = file.WriteString(content)
453+ require.NoError(t, err)
454+ require.NoError(t, file.Close())
455+ return file.Name()
456+}
A internal/usecase/traffic/builder.go +530 -0
@@ -0,0 +1,530 @@
1+package traffic
2+
3+import (
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+
15+const DefaultEdgeEntryPointName = "edge"
16+
17+// Input is the complete, usecase-level input used to build a validated traffic graph.
18+type 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+
27+type 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.
37+type Config struct {
38+ TCP TCPConfig `mapstructure:"tcp"`
39+ UDP UDPConfig `mapstructure:"udp"`
40+ TLS TLSConfig `mapstructure:"tls"`
41+}
42+
43+type 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+
51+type 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+
58+type TLSConfig struct {
59+ Routers []RouterConfig `mapstructure:"routers"`
60+}
61+
62+type 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+
70+type NetworkServiceConfig struct {
71+ Name string `mapstructure:"name"`
72+ Ports []PortConfig `mapstructure:"ports"`
73+}
74+
75+type 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.
83+func 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+
121+type builder struct {
122+ input Input
123+ services map[string]domain.TrafficService
124+}
125+
126+func (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+
143+func (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+
161+func (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+
183+func (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+
204+func 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+
213+func (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+
223+func (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+
237+func (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+
254+func (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+
272+func (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+
296+func 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+
313+func 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+
330+func 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+
350+func 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+
359+func (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+
368+func (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+
377+func 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+
386+func 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+
395+func isPrivateStandalonePort(port domain.StandaloneServicePort) bool {
396+ if port.Public {
397+ return false
398+ }
399+ return port.Private || strings.EqualFold(port.Name, "rcon")
400+}
401+
402+func (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+
419+func 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+
439+func 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+
452+func (b *builder) addService(service domain.TrafficService) {
453+ if _, exists := b.services[service.Name]; !exists {
454+ b.services[service.Name] = service
455+ }
456+}
457+
458+func 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+
482+func 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+
493+func 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+
504+func 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+
523+func 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+}
A internal/usecase/traffic/builder_test.go +417 -0
@@ -0,0 +1,417 @@
1+package traffic
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+func TestBuildSmartTCPRoutesDefaultToEdge(t *testing.T) {
13+ graph, err := Build(Input{
14+ EntryPoints: map[string]EntryPointConfig{"edge": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}},
15+ Routes: []domain.Route{{Domain: "app.example.com"}},
16+ })
17+ require.NoError(t, err)
18+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "route:app.example.com", EntryPoint: "edge", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"})
19+}
20+
21+func TestBuildManagedRoutesUseOnlyCustomSmartTCPEntrypoint(t *testing.T) {
22+ graph, err := Build(Input{
23+ EntryPoints: map[string]EntryPointConfig{"public": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}},
24+ Routes: []domain.Route{{Domain: "app.example.com", HTTPS: true}},
25+ })
26+ require.NoError(t, err)
27+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "route:app.example.com", EntryPoint: "public", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"})
28+}
29+
30+func TestBuildRouteEntrypointPrefersEdge(t *testing.T) {
31+ graph, err := Build(Input{
32+ EntryPoints: map[string]EntryPointConfig{
33+ "edge": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
34+ "public": {Address: ":8443", Protocol: domain.EntryPointProtocolSmartTCP},
35+ },
36+ Routes: []domain.Route{{Domain: "app.example.com", HTTPS: true}},
37+ })
38+ require.NoError(t, err)
39+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "route:app.example.com", EntryPoint: "edge", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"})
40+}
41+
42+func TestBuildRoutesRejectAmbiguousRouteEntrypoints(t *testing.T) {
43+ _, err := Build(Input{
44+ EntryPoints: map[string]EntryPointConfig{
45+ "public-a": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
46+ "public-b": {Address: ":8443", Protocol: domain.EntryPointProtocolTLSMux},
47+ },
48+ Routes: []domain.Route{{Domain: "app.example.com", HTTPS: true}},
49+ })
50+ require.ErrorContains(t, err, "multiple route-capable entrypoints")
51+}
52+
53+func TestBuildExternalRoutesUseResolvedRouteEntryPoint(t *testing.T) {
54+ graph, err := Build(Input{
55+ EntryPoints: map[string]EntryPointConfig{"public": {Address: ":443", Protocol: domain.EntryPointProtocolTLSMux}},
56+ ExternalRoutes: map[string]string{"ext.example.com": "backend.local:8080"},
57+ })
58+ require.NoError(t, err)
59+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "external_route:ext.example.com", EntryPoint: "public", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "ext.example.com"}, Service: "external_route:ext.example.com"})
60+}
61+
62+func TestBuildRawFallbackConfigMapsToEntryPoint(t *testing.T) {
63+ graph, err := Build(Input{
64+ EntryPoints: map[string]EntryPointConfig{"edge": {
65+ Address: ":443",
66+ Protocol: domain.EntryPointProtocolSmartTCP,
67+ RawFallback: "ssh-fallback",
68+ RawFallbackTrustedCIDRs: []string{"100.64.0.0/10"},
69+ AllowPublicRawFallback: true,
70+ }},
71+ Traffic: Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "ssh-fallback", EntryPoint: "edge", Service: "network_service:ssh:ssh"}}}},
72+ NetworkServices: []NetworkServiceConfig{{Name: "ssh", Ports: []PortConfig{{Name: "ssh", Container: 22, Protocol: domain.NetworkProtocolTCP}}}},
73+ })
74+ require.NoError(t, err)
75+ require.Equal(t, []domain.EntryPoint{{
76+ Name: "edge",
77+ Address: ":443",
78+ Protocol: domain.EntryPointProtocolSmartTCP,
79+ RawFallback: "ssh-fallback",
80+ RawFallbackTrustedCIDRs: []string{"100.64.0.0/10"},
81+ AllowPublicRawFallback: true,
82+ }}, graph.EntryPoints)
83+}
84+
85+func TestBuildSmartTCPRoutesRequireCompatibleEdge(t *testing.T) {
86+ _, err := Build(Input{Routes: []domain.Route{{Domain: "app.example.com"}}})
87+ require.ErrorContains(t, err, "entrypoint")
88+}
89+
90+func TestBuildDefaultsAndHTTPRoutes(t *testing.T) {
91+ graph, err := Build(Input{
92+ EntryPoints: map[string]EntryPointConfig{"edge": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}},
93+ Routes: []domain.Route{{Domain: "app.example.com"}, {Domain: "secure.example.com", HTTPS: true}},
94+ ExternalRoutes: map[string]string{"ext.example.com": "backend.local:8080"},
95+ })
96+ require.NoError(t, err)
97+
98+ require.Equal(t, []domain.EntryPoint{{Name: "edge", Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}}, graph.EntryPoints)
99+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "route:app.example.com", EntryPoint: "edge", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"})
100+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "route:secure.example.com", EntryPoint: "edge", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "secure.example.com"}, Service: "route:secure.example.com"})
101+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "external_route:ext.example.com", EntryPoint: "edge", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "ext.example.com"}, Service: "external_route:ext.example.com"})
102+ require.Contains(t, graph.Services, domain.TrafficService{Name: "external_route:ext.example.com", Backends: []domain.TrafficBackend{{Name: "ext.example.com", Host: "backend.local", Port: 8080, Protocol: domain.NetworkProtocolTCP}}})
103+}
104+
105+func TestBuildCustomEntryPointTrustedCIDRs(t *testing.T) {
106+ graph, err := Build(Input{EntryPoints: map[string]EntryPointConfig{"tcp-public": {Address: ":5432", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"10.0.0.0/8"}}}})
107+ require.NoError(t, err)
108+ require.Equal(t, []domain.EntryPoint{{Name: "tcp-public", Address: ":5432", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"10.0.0.0/8"}}}, graph.EntryPoints)
109+}
110+
111+func TestBuildExplicitTCPUDPTLSRouterResolution(t *testing.T) {
112+ graph, err := Build(Input{
113+ EntryPoints: map[string]EntryPointConfig{
114+ "tcp-db": {Address: ":5432", Protocol: domain.EntryPointProtocolTCP},
115+ "udp-dns": {Address: ":5353", Protocol: domain.EntryPointProtocolUDP},
116+ "tls-db": {Address: ":15432", Protocol: domain.EntryPointProtocolTLSMux},
117+ },
118+ Traffic: Config{
119+ TCP: TCPConfig{Routers: []RouterConfig{{Name: "db", EntryPoint: "tcp-db", Service: "network_service:postgres:sql"}}},
120+ UDP: UDPConfig{Routers: []RouterConfig{{Name: "dns", EntryPoint: "udp-dns", Service: "network_service:coredns:dns"}}},
121+ TLS: TLSConfig{Routers: []RouterConfig{{Name: "tlsdb", EntryPoint: "tls-db", SNI: "db.example.com", Service: "network_service:postgres:sql"}}},
122+ },
123+ NetworkServices: []NetworkServiceConfig{
124+ {Name: "postgres", Ports: []PortConfig{{Name: "sql", Container: 5432, Protocol: domain.NetworkProtocolTCP}}},
125+ {Name: "coredns", Ports: []PortConfig{{Name: "dns", Container: 53, Protocol: domain.NetworkProtocolUDP}}},
126+ },
127+ })
128+ require.NoError(t, err)
129+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "db", EntryPoint: "tcp-db", Protocol: domain.RouterProtocolTCP, Service: "network_service:postgres:sql"})
130+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "dns", EntryPoint: "udp-dns", Protocol: domain.RouterProtocolUDP, Service: "network_service:coredns:dns"})
131+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "tlsdb", EntryPoint: "tls-db", Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: "db.example.com"}, Service: "network_service:postgres:sql"})
132+ require.Contains(t, graph.Services, domain.TrafficService{Name: "network_service:postgres:sql", Backends: []domain.TrafficBackend{{Name: "postgres:sql", Host: "postgres", Port: 5432, Protocol: domain.NetworkProtocolTCP}}})
133+}
134+
135+func TestBuildRejectsStaticServiceRef(t *testing.T) {
136+ _, err := Build(Input{
137+ EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP}},
138+ Traffic: Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "static:legacy"}}}},
139+ })
140+ require.ErrorContains(t, err, "static")
141+}
142+
143+func TestBuildRejectsTLSHTTPRouteConflict(t *testing.T) {
144+ _, err := Build(Input{
145+ EntryPoints: map[string]EntryPointConfig{"edge": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}},
146+ Routes: []domain.Route{{Domain: "app.example.com", HTTPS: true}},
147+ Traffic: Config{TLS: TLSConfig{Routers: []RouterConfig{{Name: "passthrough", EntryPoint: "edge", SNI: "app.example.com", Service: "network_service:app:https"}}}},
148+ NetworkServices: []NetworkServiceConfig{{Name: "app", Ports: []PortConfig{{Name: "https", Container: 443, Protocol: domain.NetworkProtocolTCP}}}},
149+ })
150+ require.ErrorContains(t, err, "http host conflicts")
151+}
152+
153+func TestBuildOptionsParsing(t *testing.T) {
154+ graph, err := Build(Input{Traffic: Config{TCP: TCPConfig{DialTimeout: "1s", IdleTimeout: "2m", DrainTimeout: "3s", MaxConnections: 12}, UDP: UDPConfig{IdleTimeout: "4s", DrainTimeout: "5s", MaxSessions: 34}}})
155+ require.NoError(t, err)
156+ require.Equal(t, time.Second, graph.Options.TCP.DialTimeout)
157+ require.Equal(t, 2*time.Minute, graph.Options.TCP.IdleTimeout)
158+ require.Equal(t, 3*time.Second, graph.Options.TCP.DrainTimeout)
159+ require.Equal(t, 12, graph.Options.TCP.MaxConnections)
160+ require.Equal(t, 4*time.Second, graph.Options.UDP.IdleTimeout)
161+ require.Equal(t, 5*time.Second, graph.Options.UDP.DrainTimeout)
162+ require.Equal(t, 34, graph.Options.UDP.MaxSessions)
163+
164+ defaults, err := Build(Input{})
165+ require.NoError(t, err)
166+ require.Equal(t, 10*time.Second, defaults.Options.TCP.DialTimeout)
167+ require.Equal(t, 5*time.Minute, defaults.Options.TCP.IdleTimeout)
168+ require.Equal(t, 30*time.Second, defaults.Options.TCP.DrainTimeout)
169+ require.Equal(t, 30*time.Second, defaults.Options.UDP.IdleTimeout)
170+ require.Equal(t, 30*time.Second, defaults.Options.UDP.DrainTimeout)
171+
172+ _, err = Build(Input{Traffic: Config{TCP: TCPConfig{DialTimeout: "bogus"}}})
173+ require.ErrorContains(t, err, "traffic.tcp.dial_timeout")
174+}
175+
176+func TestBuildRejectsAmbiguousNetworkServices(t *testing.T) {
177+ t.Run("duplicate service name", func(t *testing.T) {
178+ _, err := Build(Input{NetworkServices: []NetworkServiceConfig{
179+ {Name: "db", Ports: []PortConfig{{Name: "sql", Container: 5432, Protocol: domain.NetworkProtocolTCP}}},
180+ {Name: "db", Ports: []PortConfig{{Name: "sql", Container: 5433, Protocol: domain.NetworkProtocolTCP}}},
181+ }})
182+ require.ErrorContains(t, err, "duplicate network service")
183+ })
184+
185+ t.Run("duplicate port name", func(t *testing.T) {
186+ _, err := Build(Input{NetworkServices: []NetworkServiceConfig{
187+ {Name: "db", Ports: []PortConfig{
188+ {Name: "sql", Container: 5432, Protocol: domain.NetworkProtocolTCP},
189+ {Name: "sql", Container: 5433, Protocol: domain.NetworkProtocolTCP},
190+ }},
191+ }})
192+ require.ErrorContains(t, err, "duplicate port")
193+ })
194+
195+ t.Run("unused invalid port rejected", func(t *testing.T) {
196+ _, err := Build(Input{NetworkServices: []NetworkServiceConfig{
197+ {Name: "db", Ports: []PortConfig{{Name: "unused", Container: 0, Protocol: domain.NetworkProtocolTCP}}},
198+ }})
199+ require.ErrorContains(t, err, "invalid container port")
200+ })
201+
202+ t.Run("unused invalid protocol rejected", func(t *testing.T) {
203+ _, err := Build(Input{NetworkServices: []NetworkServiceConfig{
204+ {Name: "db", Ports: []PortConfig{{Name: "unused", Container: 5432, Protocol: domain.NetworkProtocol("sctp")}}},
205+ }})
206+ require.ErrorContains(t, err, "invalid protocol")
207+ })
208+}
209+
210+func TestBuildResolvesStandaloneServiceBackend(t *testing.T) {
211+ graph, err := Build(Input{
212+ EntryPoints: map[string]EntryPointConfig{"udp": {Address: ":28015", Protocol: domain.EntryPointProtocolUDP}},
213+ Traffic: Config{UDP: UDPConfig{Routers: []RouterConfig{{Name: "rust-game", EntryPoint: "udp", Service: "service:rust:game"}}}},
214+ Services: []domain.StandaloneService{{
215+ Name: "rust",
216+ Image: "localhost/rust:latest",
217+ Enabled: true,
218+ Ports: []domain.StandaloneServicePort{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolUDP, Publish: "127.0.0.1:38015"}},
219+ }},
220+ })
221+ require.NoError(t, err)
222+ require.Contains(t, graph.Services, domain.TrafficService{Name: "service:rust:game", Backends: []domain.TrafficBackend{{Name: "rust:game", Host: "127.0.0.1", Port: 38015, Protocol: domain.NetworkProtocolUDP}}})
223+}
224+
225+func TestBuildRejectsInvalidStandaloneServiceRefs(t *testing.T) {
226+ base := Input{
227+ EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP}},
228+ Services: []domain.StandaloneService{{
229+ Name: "rust",
230+ Image: "localhost/rust:latest",
231+ Enabled: true,
232+ Ports: []domain.StandaloneServicePort{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolUDP, Publish: "127.0.0.1:38015"}, {Name: "rcon", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: "127.0.0.1:38016", Private: true, TrustedCIDRs: []string{"100.64.0.0/10"}}},
233+ }},
234+ }
235+
236+ t.Run("unknown service", func(t *testing.T) {
237+ input := base
238+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:missing:rcon"}}}}
239+ _, err := Build(input)
240+ require.ErrorContains(t, err, "router \"bad\": unknown service \"missing\"")
241+ })
242+
243+ t.Run("unknown port", func(t *testing.T) {
244+ input := base
245+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:missing"}}}}
246+ _, err := Build(input)
247+ require.ErrorContains(t, err, "unknown port \"missing\" on service \"rust\"")
248+ })
249+
250+ t.Run("disabled service", func(t *testing.T) {
251+ input := base
252+ input.Services = append([]domain.StandaloneService(nil), base.Services...)
253+ input.Services[0].Enabled = false
254+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:rcon"}}}}
255+ _, err := Build(input)
256+ require.ErrorContains(t, err, "unknown service \"rust\"")
257+ })
258+
259+ t.Run("protocol mismatch", func(t *testing.T) {
260+ input := base
261+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:game"}}}}
262+ _, err := Build(input)
263+ require.ErrorContains(t, err, "service \"rust\" port \"game\" protocol udp does not match router protocol tcp")
264+ })
265+
266+ t.Run("missing publish", func(t *testing.T) {
267+ input := base
268+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"100.64.0.0/10"}}}
269+ input.Services = append([]domain.StandaloneService(nil), base.Services...)
270+ input.Services[0].Ports = append([]domain.StandaloneServicePort(nil), base.Services[0].Ports...)
271+ input.Services[0].Ports[1].Publish = ""
272+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:rcon"}}}}
273+ _, err := Build(input)
274+ require.ErrorContains(t, err, "service \"rust\" port \"rcon\" publish address")
275+ })
276+
277+ t.Run("invalid publish", func(t *testing.T) {
278+ input := base
279+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"100.64.0.0/10"}}}
280+ input.Services = append([]domain.StandaloneService(nil), base.Services...)
281+ input.Services[0].Ports = append([]domain.StandaloneServicePort(nil), base.Services[0].Ports...)
282+ input.Services[0].Ports[1].Publish = "127.0.0.1:notaport"
283+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:rcon"}}}}
284+ _, err := Build(input)
285+ require.ErrorContains(t, err, "publish address")
286+ })
287+}
288+
289+func TestBuildWrapsStandaloneServiceValidationErrorsWithServiceName(t *testing.T) {
290+ _, err := Build(Input{
291+ EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP}},
292+ Services: []domain.StandaloneService{{
293+ Name: "rust",
294+ Image: "localhost/rust:latest",
295+ Enabled: true,
296+ Ports: []domain.StandaloneServicePort{{Name: " admin ", Container: 28016, Protocol: domain.NetworkProtocolTCP}},
297+ }},
298+ })
299+
300+ require.ErrorContains(t, err, "standalone service \"rust\"")
301+ require.ErrorContains(t, err, "leading or trailing whitespace")
302+}
303+
304+func TestBuildEnforcesPrivateStandaloneServicePortRouting(t *testing.T) {
305+ base := func() Input {
306+ return Input{
307+ EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"100.64.0.0/10"}}},
308+ Traffic: Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "rust-rcon", EntryPoint: "tcp", Service: "service:rust:rcon"}}}},
309+ Services: []domain.StandaloneService{{
310+ Name: "rust",
311+ Image: "localhost/rust:latest",
312+ Enabled: true,
313+ Ports: []domain.StandaloneServicePort{{Name: "rcon", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: "127.0.0.1:38016", TrustedCIDRs: []string{"100.64.0.0/10"}}},
314+ }},
315+ }
316+ }
317+
318+ t.Run("rcon defaults private and allows exact trusted cidrs", func(t *testing.T) {
319+ _, err := Build(base())
320+ require.NoError(t, err)
321+ })
322+
323+ t.Run("private port rejects service port without trusted cidrs", func(t *testing.T) {
324+ input := base()
325+ input.Services[0].Ports[0].TrustedCIDRs = nil
326+ _, err := Build(input)
327+ require.ErrorContains(t, err, "requires non-empty service port trusted_cidrs")
328+ })
329+
330+ t.Run("rcon rejects both service and entrypoint without trusted cidrs", func(t *testing.T) {
331+ input := base()
332+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}}
333+ input.Services[0].Ports[0].TrustedCIDRs = nil
334+ _, err := Build(input)
335+ require.ErrorContains(t, err, "requires non-empty service port trusted_cidrs")
336+ })
337+
338+ t.Run("explicit private non-rcon rejects service port without trusted cidrs", func(t *testing.T) {
339+ input := base()
340+ input.Services[0].Ports[0].Name = "admin"
341+ input.Services[0].Ports[0].Private = true
342+ input.Services[0].Ports[0].TrustedCIDRs = nil
343+ input.Traffic.TCP.Routers[0].Service = "service:rust:admin"
344+ _, err := Build(input)
345+ require.ErrorContains(t, err, "requires non-empty service port trusted_cidrs")
346+ })
347+
348+ t.Run("private port rejects entrypoint without trusted cidrs", func(t *testing.T) {
349+ input := base()
350+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}}
351+ _, err := Build(input)
352+ require.ErrorContains(t, err, "entrypoint \"tcp\" requires non-empty trusted_cidrs")
353+ })
354+
355+ t.Run("private port rejects non-matching trusted cidrs", func(t *testing.T) {
356+ input := base()
357+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"10.0.0.0/8"}}}
358+ _, err := Build(input)
359+ require.ErrorContains(t, err, "must exactly match")
360+ })
361+
362+ t.Run("private port compares canonical trusted cidrs", func(t *testing.T) {
363+ input := base()
364+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"100.64.0.1/10"}}}
365+ _, err := Build(input)
366+ require.NoError(t, err)
367+ })
368+
369+ t.Run("explicit public rcon is allowed without trusted cidrs", func(t *testing.T) {
370+ input := base()
371+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}}
372+ input.Services[0].Ports[0].Public = true
373+ input.Services[0].Ports[0].TrustedCIDRs = nil
374+ _, err := Build(input)
375+ require.NoError(t, err)
376+ })
377+
378+ t.Run("explicit public non-rcon is allowed without trusted cidrs", func(t *testing.T) {
379+ input := base()
380+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}}
381+ input.Services[0].Ports[0].Name = "admin"
382+ input.Services[0].Ports[0].Private = false
383+ input.Services[0].Ports[0].Public = true
384+ input.Services[0].Ports[0].TrustedCIDRs = nil
385+ input.Traffic.TCP.Routers[0].Service = "service:rust:admin"
386+ _, err := Build(input)
387+ require.NoError(t, err)
388+ })
389+}
390+
391+func TestBuildRejectsInvalidServiceRefs(t *testing.T) {
392+ base := Input{
393+ EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP}},
394+ NetworkServices: []NetworkServiceConfig{{Name: "svc", Ports: []PortConfig{{Name: "udp", Container: 53, Protocol: domain.NetworkProtocolUDP}}}},
395+ }
396+
397+ t.Run("unknown service", func(t *testing.T) {
398+ input := base
399+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "network_service:missing:tcp"}}}}
400+ _, err := Build(input)
401+ require.ErrorContains(t, err, "unknown network service")
402+ })
403+
404+ t.Run("unknown port", func(t *testing.T) {
405+ input := base
406+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "network_service:svc:missing"}}}}
407+ _, err := Build(input)
408+ require.ErrorContains(t, err, "unknown port")
409+ })
410+
411+ t.Run("protocol mismatch", func(t *testing.T) {
412+ input := base
413+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "network_service:svc:udp"}}}}
414+ _, err := Build(input)
415+ require.ErrorContains(t, err, "does not match")
416+ })
417+}

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