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:
45 LogService:45 LogService:
46 VolumeService:46 VolumeService:
47 PublicTLSService:47 PublicTLSService:
48+ TrafficStatusService:
49+ StandaloneServiceService:
48 # Exception: pushImageOps is a CLI-local interface, not a boundary port.50 # Exception: pushImageOps is a CLI-local interface, not a boundary port.
49 # Mocked here because it abstracts Docker SDK calls that require a running51 # Mocked here because it abstracts Docker SDK calls that require a running
50 # daemon, making unit/integration tests impractical without a test double.52 # 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
127 ## Features127 ## Features
128 128
129 - Private Docker registry on your VPS129 - Private Docker registry on your VPS
130-- Domain-to-container routing via HTTP reverse proxy130+- Domain-to-container routing through a smart TCP edge reverse proxy
131 - Automatic deployment on image push131 - Automatic deployment on image push
132 - Auto-routing from image labels132 - Auto-routing from image labels
133 - Remote CLI management133 - Remote CLI management
@@ -138,7 +138,7 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf
138 - Single binary, ~15MB RAM138 - Single binary, ~15MB RAM
139 139
140 > [!NOTE]140 > [!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.
142 142
143 ## Documentation143 ## Documentation
144 144
M docs/cli/ca.md +2 -2
@@ -12,7 +12,7 @@ Manage Gordon's internal Certificate Authority.
12 | `info` | Show CA status (root CN, fingerprint, intermediate expiry) |12 | `info` | Show CA status (root CN, fingerprint, intermediate expiry) |
13 | `install` | Install/uninstall the root CA in system trust stores |13 | `install` | Install/uninstall the root CA in system trust stores |
14 14
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"`.
16 16
17 ---17 ---
18 18
@@ -120,5 +120,5 @@ Visit `http://<gordon-host>/.well-known/gordon/ca` from the device browser. The
120 120
121 ## Related121 ## Related
122 122
123-- [Server Configuration](../config/server.md) — `tls_port`, `force_https_redirect`, and internal CA settings123+- [Server Configuration](../config/server.md) — `force_https_redirect`, smart TCP edge, and internal CA settings
124 - [CLI Commands](./index.md)124 - [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
42 | `gordon routes` | Manage routes | [routes](./routes.md) |42 | `gordon routes` | Manage routes | [routes](./routes.md) |
43 | `gordon secrets` | Manage secrets | [secrets](./secrets.md) |43 | `gordon secrets` | Manage secrets | [secrets](./secrets.md) |
44 | `gordon status` | Show Gordon server status | [status](./status.md) |44 | `gordon status` | Show Gordon server status | [status](./status.md) |
45+| `gordon traffic` | Inspect traffic plane status | [traffic](./traffic.md) |
45 | `gordon volumes` | Manage volumes | - |46 | `gordon volumes` | Manage volumes | - |
46 47
47 ## Client Commands48 ## Client Commands
@@ -93,6 +94,10 @@ gordon logs myapp.example.com -f # Follow container logs
93 # Check version94 # Check version
94 gordon version95 gordon version
95 96
97+# Traffic plane
98+gordon traffic status --remote prod
99+gordon traffic status --remote prod --json
100+
96 # Backups101 # Backups
97 gordon backups list102 gordon backups list
98 gordon backups run app.example.com103 gordon backups run app.example.com
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
350 - `insecure_tls = true` on the remote — skips certificate verification entirely350 - `insecure_tls = true` on the remote — skips certificate verification entirely
351 - `sudo gordon ca install` — installs the root CA into system, Firefox, and Java trust stores351 - `sudo gordon ca install` — installs the root CA into system, Firefox, and Java trust stores
352 - `gordon ca export --out gordon-ca.crt` — exports the root CA PEM for manual installation352 - `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 Android353+- 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
354 354
355 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.355 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.
356 356
M docs/cli/tls.md +8 -0
@@ -2,6 +2,14 @@
2 2
3 Inspect public TLS/ACME certificate status.3 Inspect public TLS/ACME certificate status.
4 4
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+
5 Remote targeting uses client config or an active remote by default.13 Remote targeting uses client config or an active remote by default.
6 Use `--remote` and `--token` to override. See [CLI Overview](./index.md).14 Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
7 15
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
13 13
14 ```toml14 ```toml
15 [server]15 [server]
16-port = 8088
17-tls_port = 8443
18 registry_port = 500016 registry_port = 5000
19 gordon_domain = "gordon.mydomain.com"17 gordon_domain = "gordon.mydomain.com"
20 18
19+[entrypoints.edge]
20+address = ":443"
21+protocol = "smart_tcp"
22+
21 [routes]23 [routes]
22 "app.mydomain.com" = "myapp:latest"24 "app.mydomain.com" = "myapp:latest"
23 ```25 ```
@@ -35,11 +37,14 @@ For a complete list of all configuration options with their default values, see
35 ```toml37 ```toml
36 # Server settings38 # Server settings
37 [server]39 [server]
38-port = 8088 # HTTP proxy port (default: 8088)
39 registry_port = 5000 # Registry port (default: 5000)40 registry_port = 5000 # Registry port (default: 5000)
40 gordon_domain = "gordon.mydomain.com" # Required: Gordon domain (registry + API)41 gordon_domain = "gordon.mydomain.com" # Required: Gordon domain (registry + API)
41 # data_dir = "~/.gordon" # Default for user installations42 # data_dir = "~/.gordon" # Default for user installations
42 43
44+[entrypoints.edge]
45+address = ":443" # Deployment-selected public TCP socket
46+protocol = "smart_tcp"
47+
43 # Authentication (includes secrets backend)48 # Authentication (includes secrets backend)
44 [auth]49 [auth]
45 enabled = true # Enable registry authentication (default: true)50 enabled = true # Enable registry authentication (default: true)
@@ -172,6 +177,7 @@ keep_last = 3
172 | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) |177 | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) |
173 | `[routes]` | Domain to image mapping | [Routes](./routes.md) |178 | `[routes]` | Domain to image mapping | [Routes](./routes.md) |
174 | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |179 | `[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) |
175 | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |181 | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |
176 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |182 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |
177 | `[backups]` | Database backups | [Backups](./backups.md) |183 | `[backups]` | Database backups | [Backups](./backups.md) |
@@ -182,7 +188,6 @@ keep_last = 3
182 188
183 | Setting | Default |189 | Setting | Default |
184 |---------|---------|190 |---------|---------|
185-| `server.port` | `8088` |
186 | `server.registry_port` | `5000` |191 | `server.registry_port` | `5000` |
187 | `server.data_dir` | `~/.gordon` |192 | `server.data_dir` | `~/.gordon` |
188 | `server.max_blob_chunk_size` | `"95MB"` |193 | `server.max_blob_chunk_size` | `"95MB"` |
@@ -257,7 +262,7 @@ gordon reload
257 262
258 | Setting |263 | Setting |
259 |---------|264 |---------|
260-| `server.port` |265+| `entrypoints.edge.address` |
261 | `server.data_dir` |266 | `server.data_dir` |
262 | `server.max_blob_chunk_size` |267 | `server.max_blob_chunk_size` |
263 | `server.max_blob_size` |268 | `server.max_blob_size` |
@@ -284,6 +289,8 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
284 - [Server Configuration](./server.md)289 - [Server Configuration](./server.md)
285 - [Routes Configuration](./routes.md)290 - [Routes Configuration](./routes.md)
286 - [External Routes](./external-routes.md)291 - [External Routes](./external-routes.md)
292+- [Standalone Services](./services.md)
293+- [Traffic Plane](./traffic.md)
287 - [Authentication](./auth.md)294 - [Authentication](./auth.md)
288 - [Telemetry](./telemetry.md)295 - [Telemetry](./telemetry.md)
289 - [Backups](./backups.md)296 - [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
150 150
151 **Critical for correct IP detection in production.**151 **Critical for correct IP detection in production.**
152 152
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.
154 154
155 ```155 ```
156 Internet → [Cloudflare/nginx] → Gordon → Containers156 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.
9 # SERVER9 # SERVER
10 # =============================================================================10 # =============================================================================
11 [server]11 [server]
12-port = 8088 # HTTP proxy port
13 registry_port = 5000 # Container registry port12 registry_port = 5000 # Container registry port
14-tls_port = 8443 # HTTPS proxy port (0 = disabled, no internal CA)13+tls_cert_file = "" # PEM cert path (optional, for static TLS fallback)
15-tls_cert_file = "" # PEM cert path (optional, for static TLS alongside internal CA)
16 tls_key_file = "" # PEM key path (optional, must be set with tls_cert_file)14 tls_key_file = "" # PEM key path (optional, must be set with tls_cert_file)
17 force_https_redirect = false # Redirect all HTTP traffic to HTTPS (for direct-access setups)15 force_https_redirect = false # Redirect all HTTP traffic to HTTPS (for direct-access setups)
18 gordon_domain = "" # Required: Gordon domain (registry + API)16 gordon_domain = "" # Required: Gordon domain (registry + API)
@@ -20,9 +18,20 @@ data_dir = "~/.gordon" # Data directory (varies by install
20 max_blob_chunk_size = "95MB" # Max size per registry blob upload chunk18 max_blob_chunk_size = "95MB" # Max size per registry blob upload chunk
21 max_blob_size = "1GB" # Max cumulative size per registry blob/layer upload19 max_blob_size = "1GB" # Max cumulative size per registry blob/layer upload
22 registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all)20 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)
24 registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only)22 registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only)
25 23
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+
26 # =============================================================================35 # =============================================================================
27 # DNS36 # DNS
28 # =============================================================================37 # =============================================================================
@@ -45,7 +54,7 @@ access_token_ttl = "15m" # Ephemeral access token lifetime (
45 # PUBLIC TLS / ACME54 # PUBLIC TLS / ACME
46 # =============================================================================55 # =============================================================================
47 [tls.acme]56 [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)
49 email = "" # ACME account email when enabled58 email = "" # ACME account email when enabled
50 challenge = "auto" # "auto", "http-01", or "cloudflare-dns-01"59 challenge = "auto" # "auto", "http-01", or "cloudflare-dns-01"
51 obtain_batch_size = 1 # New certificate orders per reconcile run60 obtain_batch_size = 1 # New certificate orders per reconcile run
@@ -164,6 +173,28 @@ preserve = true # Keep volumes when containers are
164 [external_routes]173 [external_routes]
165 # "domain.com" = "host:port" # Proxy to non-container services174 # "domain.com" = "host:port" # Proxy to non-container services
166 175
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+
167 # =============================================================================198 # =============================================================================
168 # NETWORK GROUPS199 # NETWORK GROUPS
169 # =============================================================================200 # =============================================================================
@@ -210,9 +241,7 @@ keep_last = 3 # Keep N newest tags per repository
210 241
211 | Setting | Default | Description |242 | Setting | Default | Description |
212 |---------|---------|-------------|243 |---------|---------|-------------|
213-| `server.port` | `8088` | HTTP proxy port |
214 | `server.registry_port` | `5000` | Container registry port |244 | `server.registry_port` | `5000` | Container registry port |
215-| `server.tls_port` | `8443` | HTTPS listener port (0 = disabled) |
216 | `server.tls_cert_file` | `""` | PEM cert path for static TLS (optional) |245 | `server.tls_cert_file` | `""` | PEM cert path for static TLS (optional) |
217 | `server.tls_key_file` | `""` | PEM key path for static TLS (optional) |246 | `server.tls_key_file` | `""` | PEM key path for static TLS (optional) |
218 | `server.force_https_redirect` | `false` | Redirect all HTTP to HTTPS (for direct-access setups) |247 | `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
223 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |252 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
224 | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |253 | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
225 | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) |254 | `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 |
226 | `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 |261 | `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 |
227 | `dns.propagation_timeout` | `"5m"` | Maximum time to wait for DNS-01 TXT records to become visible through configured recursive resolvers |262 | `dns.propagation_timeout` | `"5m"` | Maximum time to wait for DNS-01 TXT records to become visible through configured recursive resolvers |
228 | `dns.polling_interval` | `"5s"` | Interval between DNS-01 propagation checks |263 | `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) |
230 | `tls.acme.email` | `""` | ACME account email when enabled |265 | `tls.acme.email` | `""` | ACME account email when enabled |
231 | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` |266 | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` |
232 | `tls.acme.obtain_batch_size` | `1` | Maximum new ACME certificate orders per reconcile run |267 | `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
279 | `volumes.auto_create` | `true` | Auto-create volumes |314 | `volumes.auto_create` | `true` | Auto-create volumes |
280 | `volumes.prefix` | `"gordon"` | Volume prefix |315 | `volumes.prefix` | `"gordon"` | Volume prefix |
281 | `volumes.preserve` | `true` | Keep volumes |316 | `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 |
282 | `backups.enabled` | `false` | Backup service disabled |338 | `backups.enabled` | `false` | Backup service disabled |
283 | `backups.schedule` | `"daily"` | Backup scheduler preset |339 | `backups.schedule` | `"daily"` | Backup scheduler preset |
284 | `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty |340 | `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty |
@@ -364,4 +420,5 @@ gordon serve
364 - [Telemetry](./telemetry.md)420 - [Telemetry](./telemetry.md)
365 - [Network Isolation](./network-isolation.md)421 - [Network Isolation](./network-isolation.md)
366 - [Volumes](./volumes.md)422 - [Volumes](./volumes.md)
423+- [Standalone Services](./services.md)
367 - [Images](./images.md)424 - [Images](./images.md)
M docs/config/security-hardening.md +29 -0
@@ -56,6 +56,35 @@ require_digest = true
56 - `require_digest = true` requires allowlisted external images to use `@sha256:<64 hex chars>`.56 - `require_digest = true` requires allowlisted external images to use `@sha256:<64 hex chars>`.
57 - Include ports in allowlist entries when the registry uses a non-default port.57 - Include ports in allowlist entries when the registry uses a non-default port.
58 58
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+
59 ## Docker network isolation88 ## Docker network isolation
60 89
61 Per-app Docker networks are enabled by default. To block direct external egress from isolated networks, opt into Docker internal networks:90 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.
6 6
7 ```toml7 ```toml
8 [server]8 [server]
9-port = 8088 # HTTP proxy port
10 registry_port = 5000 # Container registry port9 registry_port = 5000 # Container registry port
11-tls_port = 8443 # HTTPS listener port (0 = disabled)10+gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
12 # tls_cert_file = "" # Optional: PEM certificate for static TLS11 # tls_cert_file = "" # Optional: PEM certificate for static TLS
13 # tls_key_file = "" # Optional: PEM key for static TLS12 # tls_key_file = "" # Optional: PEM key for static TLS
14-# force_https_redirect = false # Redirect all HTTP traffic to HTTPS13+# force_https_redirect = false # Redirect cleartext HTTP requests to HTTPS
15-gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
16 # legacy_registry_domains = ["registry.example.com:5000"]14 # legacy_registry_domains = ["registry.example.com:5000"]
17 # data_dir = "~/.gordon" # Data storage directory (default)15 # data_dir = "~/.gordon" # Data storage directory (default)
18 max_blob_chunk_size = "95MB" # Max registry blob upload chunk16 max_blob_chunk_size = "95MB" # Max registry blob upload chunk
19 max_blob_size = "1GB" # Max cumulative registry blob/layer upload17 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"
20 ```22 ```
21 23
22 ## Options24 ## Options
23 25
24 | Option | Type | Default | Description |26 | Option | Type | Default | Description |
25 |--------|------|---------|-------------|27 |--------|------|---------|-------------|
26-| `port` | int | `8088` | HTTP proxy port for routing traffic to containers |
27 | `registry_port` | int | `5000` | Docker registry port for image push/pull |28 | `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`, matching SNI domains use this static cert before ACME or internal CA certs |
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 |
30 | `tls_key_file` | string | `""` | Path to PEM private key file. Must be set together with `tls_cert_file` |30 | `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 |
32 | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) |32 | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) |
33 | `registry_domain` | string | - | Deprecated migration key. Set `gordon_domain` instead. |33 | `registry_domain` | string | - | Deprecated migration key. Set `gordon_domain` instead. |
34 | `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). |34 | `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
37 | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk |37 | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk |
38 | `max_blob_size` | string | `"1GB"` | Maximum cumulative size for one registry blob/layer upload |38 | `max_blob_size` | string | `"1GB"` | Maximum cumulative size for one registry blob/layer upload |
39 | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |39 | `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) |
41 | `registry_listen_address` | string | `""` | Bind address for registry (empty = all interfaces) |41 | `registry_listen_address` | string | `""` | Bind address for registry (empty = all interfaces) |
42 42
43-## Port Configuration43+Public application traffic is configured under `[entrypoints]`. The standard edge listener is a `smart_tcp` socket:
44-
45-### Proxy Port
46-
47-The `port` setting controls where Gordon listens for HTTP traffic:
48 44
49 ```toml45 ```toml
50-[server]46+[entrypoints.edge]
51-port = 8088 # Default high port for rootless or forwarded setups47+address = ":443"
52-```48+protocol = "smart_tcp"
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
59 ```49 ```
60 50
61-### Internal CA and TLS51+`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.
62 52
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
64 54
65-```toml55+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`.
66-[server]
67-tls_port = 8443 # Default. Set to 0 to disable TLS entirely.
68-```
69 56
70-With TLS enabled:57+With TLS-capable edge traffic:
71 - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/`58 - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/`
72 - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory59 - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory
73 - The intermediate CA auto-renews before expiry60 - 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 certificate61+- An onboarding page at `https://<gordon-host>/.well-known/gordon/` lets clients download the root CA certificate
75 - The root CA is stable across restarts (generated once, persisted to disk)62 - The root CA is stable across restarts (generated once, persisted to disk)
76 63
77 #### Custom certificates64 #### Custom certificates
78 65
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:
80 67
81 ```toml68 ```toml
82 [server]69 [server]
@@ -84,15 +71,16 @@ tls_cert_file = "/etc/gordon/tls/cert.pem"
84 tls_key_file = "/etc/gordon/tls/key.pem"71 tls_key_file = "/etc/gordon/tls/key.pem"
85 ```72 ```
86 73
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.
88 75
89 #### Public ACME certificates76 #### Public ACME certificates
90 77
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.
92 79
93 ```toml80 ```toml
94-[server]81+[entrypoints.edge]
95-tls_port = 844382+address = ":443"
83+protocol = "smart_tcp"
96 84
97 [dns]85 [dns]
98 resolvers = ["1.1.1.1:53", "8.8.8.8:53"]86 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
107 ```95 ```
108 96
109 Challenge behavior:97 Challenge behavior:
110-- `http-01` serves `/.well-known/acme-challenge/<token>` on the HTTP proxy port.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.
111-- `cloudflare-dns-01` uses a Cloudflare API token from `pass`, `GORDON_CLOUDFLARE_API_TOKEN_FILE`, or `GORDON_CLOUDFLARE_API_TOKEN`.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.
112 - `auto` selects Cloudflare DNS-01 when a token is available, otherwise falls back to HTTP-01.100 - `auto` selects Cloudflare DNS-01 when a token is available, otherwise falls back to HTTP-01.
101+- `tls-alpn-01` is not supported.
113 102
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.
115 104
116 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.105 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.
117 106
@@ -127,9 +116,9 @@ The defaults avoid relying on host-local DNS. This matters on hosts using Tailsc
127 116
128 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.117 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.
129 118
130-#### Direct HTTP Onboarding119+#### Direct HTTP CA onboarding paths (`/.well-known/gordon/ca`)
131 120
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:
133 122
134 - `/.well-known/gordon/`, `/.well-known/gordon/ca`, `/.well-known/gordon/ca.crt`, `/.well-known/gordon/ca.mobileconfig` — serve the onboarding page and CA certificate downloads123 - `/.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
135 - `/.well-known/acme-challenge/*` — serves ACME HTTP-01 challenges when public ACME HTTP-01 is enabled, otherwise returns `404 Not Found`124 - `/.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
141 130
142 #### HTTP to HTTPS redirect131 #### HTTP to HTTPS redirect
143 132
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.
145 134
146 ```toml135 ```toml
147 [server]136 [server]
@@ -150,40 +139,22 @@ force_https_redirect = true
150 139
151 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.140 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.
152 141
153-#### Disabling TLS142+#### Firewall and Container Port Mapping
154 143
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:
156 145
157 ```toml146 ```toml
158-[server]147+[entrypoints.edge]
159-tls_port = 0 # No HTTPS listener, no internal CA generated148+address = ":9000"
149+protocol = "smart_tcp"
160 ```150 ```
161 151
162-#### Firewall port forwarding
163-
164-For rootless user services, forward 443 to the high port at the firewall:
165-
166 ```bash152 ```bash
167-# Forward 443 -> 8443153+sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=9000
168-sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443
169 sudo firewall-cmd --reload154 sudo firewall-cmd --reload
170 ```155 ```
171 156
172-With firewalld and Tailscale:157+`:443`, `:9000`, and container `-p 443:9000` mappings are deployment choices, not protocol modes.
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-```
187 158
188 ### Registry Port159 ### Registry Port
189 160
@@ -223,11 +194,11 @@ For a staged rename, set `gordon_domain` to the new public host and add any old
223 194
224 Without this migration, a Host/remote-target mismatch can break routing or remote CLI token exchange.195 Without this migration, a Host/remote-target mismatch can break routing or remote CLI token exchange.
225 196
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).
227 198
228 Security note:199 Security note:
229 - Direct access to `/admin/*` on `registry_port` is blocked for non-loopback clients.200 - 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"`.
231 202
232 > **Note:** `registry_domain` is a deprecated migration key; use `gordon_domain` for new and upgraded configs.203 > **Note:** `registry_domain` is a deprecated migration key; use `gordon_domain` for new and upgraded configs.
233 204
@@ -404,7 +375,7 @@ registry_listen_address = "127.0.0.1" # Loopback only
404 375
405 ## HSTS376 ## HSTS
406 377
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.
408 379
409 ## Examples380 ## Examples
410 381
@@ -412,35 +383,44 @@ HSTS headers are sent automatically on the HTTPS listener (when `r.TLS != nil`).
412 383
413 ```toml384 ```toml
414 [server]385 [server]
415-port = 8088
416 registry_port = 5000386 registry_port = 5000
417 gordon_domain = "gordon.local"387 gordon_domain = "gordon.local"
418 data_dir = "./dev-data"388 data_dir = "./dev-data"
389+
390+[entrypoints.edge]
391+address = ":9000"
392+protocol = "smart_tcp"
419 ```393 ```
420 394
421 ### Production Configuration395 ### Production Configuration
422 396
423 ```toml397 ```toml
424 [server]398 [server]
425-port = 8088
426 registry_port = 5000399 registry_port = 5000
427 gordon_domain = "gordon.company.com"400 gordon_domain = "gordon.company.com"
428 data_dir = "/var/lib/gordon"401 data_dir = "/var/lib/gordon"
402+
403+[entrypoints.edge]
404+address = ":443"
405+protocol = "smart_tcp"
429 ```406 ```
430 407
431 ### Rootless Container Setup408 ### Rootless Container Setup
432 409
433 ```toml410 ```toml
434 [server]411 [server]
435-port = 8088 # High port for rootless
436 registry_port = 5000412 registry_port = 5000
437 gordon_domain = "gordon.mydomain.com"413 gordon_domain = "gordon.mydomain.com"
414+
415+[entrypoints.edge]
416+address = ":9000" # High port for rootless
417+protocol = "smart_tcp"
438 ```418 ```
439 419
440 With firewall port forwarding:420 With firewall port forwarding:
441 421
442 ```bash422 ```bash
443-sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088423+sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=9000
444 sudo firewall-cmd --reload424 sudo firewall-cmd --reload
445 ```425 ```
446 426
@@ -449,3 +429,5 @@ sudo firewall-cmd --reload
449 - [Configuration Overview](./index.md)429 - [Configuration Overview](./index.md)
450 - [Installation Guide](../installation.md)430 - [Installation Guide](../installation.md)
451 - [Routes Configuration](./routes.md)431 - [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.
6 6
7 - A Linux VPS with Docker or Podman installed7 - A Linux VPS with Docker or Podman installed
8 - A domain pointing to your VPS (DNS A record)8 - 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`)
10 - [pass](https://www.passwordstore.org/) (password manager) with GPG key initialized10 - [pass](https://www.passwordstore.org/) (password manager) with GPG key initialized
11 11
12 ## 1. Install Gordon12 ## 1. Install Gordon
@@ -73,11 +73,13 @@ Edit `~/.config/gordon/gordon.toml`:
73 73
74 ```toml74 ```toml
75 [server]75 [server]
76-port = 8088 # HTTP proxy port (default)
77 registry_port = 5000 # Registry port76 registry_port = 5000 # Registry port
78-tls_port = 8443 # HTTPS listener (default)
79 gordon_domain = "gordon.mydomain.com" # Registry + Admin API domain77 gordon_domain = "gordon.mydomain.com" # Registry + Admin API domain
80 78
79+[entrypoints.edge]
80+address = ":443" # Public smart TCP edge (choose your bind/mapping)
81+protocol = "smart_tcp"
82+
81 [routes]83 [routes]
82 "app.mydomain.com" = "myapp:latest" # Domain → Image mapping84 "app.mydomain.com" = "myapp:latest" # Domain → Image mapping
83 ```85 ```
@@ -99,7 +101,7 @@ Why wildcard (`*`)?
99 101
100 If your DNS provider supports wildcard CNAME flattening (Cloudflare does), `* -> gordon.mydomain.com` is usually the cleanest option.102 If your DNS provider supports wildcard CNAME flattening (Cloudflare does), `* -> gordon.mydomain.com` is usually the cleanest option.
101 103
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).
103 105
104 ## 6. Start Gordon as a Service106 ## 6. Start Gordon as a Service
105 107
M docs/index.md +2 -1
@@ -47,6 +47,7 @@ Gordon runs on your VPS and provides:
47 - [Configuration Overview](./config/index.md) - All configuration options47 - [Configuration Overview](./config/index.md) - All configuration options
48 - [Server Settings](./config/server.md) - Ports, domains, and runtime48 - [Server Settings](./config/server.md) - Ports, domains, and runtime
49 - [Routes](./config/routes.md) - Domain to container mapping49 - [Routes](./config/routes.md) - Domain to container mapping
50+- [Traffic Plane](./config/traffic.md) - TCP, UDP, and TLS passthrough entrypoints
50 - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows51 - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows
51 - [Secrets](./config/secrets.md) - Secure credential storage52 - [Secrets](./config/secrets.md) - Secure credential storage
52 - [Network Isolation](./config/network-isolation.md) - Per-app network isolation53 - [Network Isolation](./config/network-isolation.md) - Per-app network isolation
@@ -80,7 +81,7 @@ Gordon runs on your VPS and provides:
80 - Domain pointing to your server81 - Domain pointing to your server
81 - Optional Cloudflare account or other reverse proxy for edge TLS82 - Optional Cloudflare account or other reverse proxy for edge TLS
82 83
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.
84 85
85 ## Related86 ## Related
86 87
M docs/installation.md +15 -11
@@ -115,7 +115,7 @@ EOF
115 115
116 ## Firewall Configuration116 ## Firewall Configuration
117 117
118-Gordon needs ports accessible for the registry and proxy.118+Gordon needs ports accessible for the registry and the public edge entrypoint.
119 119
120 ### Using firewalld120 ### Using firewalld
121 121
@@ -128,9 +128,10 @@ sudo systemctl enable --now firewalld
128 sudo firewall-cmd --permanent --add-service=http128 sudo firewall-cmd --permanent --add-service=http
129 sudo firewall-cmd --permanent --add-service=https129 sudo firewall-cmd --permanent --add-service=https
130 130
131-# For rootless containers, redirect privileged ports131+# For rootless services, bind entrypoints.edge.address to a high port (for example :9000)
132-sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088132+# and redirect the external TCP edge ports your deployment uses.
133-sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443133+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
134 135
135 # Apply changes136 # Apply changes
136 sudo firewall-cmd --reload137 sudo firewall-cmd --reload
@@ -165,10 +166,13 @@ Minimum required configuration:
165 166
166 ```toml167 ```toml
167 [server]168 [server]
168-port = 8088
169 registry_port = 5000169 registry_port = 5000
170 gordon_domain = "gordon.yourdomain.com"170 gordon_domain = "gordon.yourdomain.com"
171 171
172+[entrypoints.edge]
173+address = ":443"
174+protocol = "smart_tcp"
175+
172 [routes]176 [routes]
173 "app.yourdomain.com" = "myapp:latest"177 "app.yourdomain.com" = "myapp:latest"
174 ```178 ```
@@ -249,22 +253,22 @@ Point your domains to your server:
249 253
250 ## Cloudflare SSL Mode254 ## Cloudflare SSL Mode
251 255
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.
253 257
254 | Mode | How it works | Use when |258 | Mode | How it works | Use when |
255 |------|-------------|----------|259 |------|-------------|----------|
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 |
257 | **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`) |261 | **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`) |
258 262
259 Wrong mode causes: **521** (Cloudflare can't connect) or **525** (TLS handshake failed).263 Wrong mode causes: **521** (Cloudflare can't connect) or **525** (TLS handshake failed).
260 264
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.265+> **Important:** For Cloudflare-proxied HTTP paths, set `proxy_allowed_ips` with Cloudflare edge IPs — see [Proxy Origin Allowlist](#proxy-origin-allowlist) below.
262-266+>
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.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.
264 268
265 ## Proxy Origin Allowlist269 ## Proxy Origin Allowlist
266 270
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.
268 272
269 Add Cloudflare edge IPs to your `gordon.toml`:273 Add Cloudflare edge IPs to your `gordon.toml`:
270 274
M docs/upgrading.md +56 -3
@@ -6,6 +6,59 @@ This guide covers breaking changes and migration steps between major versions.
6 6
7 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.7 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.
8 8
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+
9 ## v2.30.0 to v2.31.062 ## v2.30.0 to v2.31.0
10 63
11 ### Breaking: Default Port Changes64 ### Breaking: Default Port Changes
@@ -117,9 +170,9 @@ If you have automation parsing status-like fields from `routes list`, migrate it
117 170
118 Gordon can now obtain public certificates with `[tls.acme]` using either `http-01` or `cloudflare-dns-01`.171 Gordon can now obtain public certificates with `[tls.acme]` using either `http-01` or `cloudflare-dns-01`.
119 172
120-- `server.tls_port` must stay enabled (`> 0`)173+- Normal HTTPS fallback must be available on a TLS-capable entrypoint
121-- `http-01` needs public HTTP reachability for each hostname being validated174+- `http-01` needs an HTTP-capable smart TCP entrypoint reachable on external port 80 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 routes175+- `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
123 - `obtain_batch_size` limits new ACME orders per reconcile pass to reduce rate-limit spikes176 - `obtain_batch_size` limits new ACME orders per reconcile pass to reduce rate-limit spikes
124 177
125 ### New: DNS Resolver Settings for ACME DNS-01178 ### New: DNS Resolver Settings for ACME DNS-01
M go.mod +1 -1
@@ -21,6 +21,7 @@ require (
21 github.com/mattn/go-runewidth v0.0.2321 github.com/mattn/go-runewidth v0.0.23
22 github.com/muesli/termenv v0.16.022 github.com/muesli/termenv v0.16.0
23 github.com/rivo/uniseg v0.4.723 github.com/rivo/uniseg v0.4.7
24+ github.com/rs/zerolog v1.35.0
24 github.com/smallstep/truststore v0.13.025 github.com/smallstep/truststore v0.13.0
25 github.com/spf13/cobra v1.10.226 github.com/spf13/cobra v1.10.2
26 github.com/spf13/viper v1.21.027 github.com/spf13/viper v1.21.0
@@ -77,7 +78,6 @@ require (
77 github.com/morikuni/aec v1.1.0 // indirect78 github.com/morikuni/aec v1.1.0 // indirect
78 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect79 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
79 github.com/muesli/cancelreader v0.2.2 // indirect80 github.com/muesli/cancelreader v0.2.2 // indirect
80- github.com/rs/zerolog v1.35.0 // indirect
81 github.com/sirupsen/logrus v1.9.4 // indirect81 github.com/sirupsen/logrus v1.9.4 // indirect
82 github.com/vbatts/tar-split v0.12.2 // indirect82 github.com/vbatts/tar-split v0.12.2 // indirect
83 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect83 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
M gordon.toml.example +50 -2
@@ -2,8 +2,6 @@
2 # Copy this file to ~/.config/gordon/gordon.toml and adjust for your domain.2 # Copy this file to ~/.config/gordon/gordon.toml and adjust for your domain.
3 3
4 [server]4 [server]
5-port = 8088
6-tls_port = 8443
7 registry_port = 50005 registry_port = 5000
8 gordon_domain = "gordon.example.com"6 gordon_domain = "gordon.example.com"
9 # legacy_registry_domains = ["registry.example.com:5000"]7 # legacy_registry_domains = ["registry.example.com:5000"]
@@ -20,6 +18,16 @@ gordon_domain = "gordon.example.com"
20 # max_proxy_response_size = "1GB"18 # max_proxy_response_size = "1GB"
21 # max_concurrent_connections = 1000019 # max_concurrent_connections = 10000
22 20
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+
23 [dns]31 [dns]
24 resolvers = ["1.1.1.1:53", "8.8.8.8:53"]32 resolvers = ["1.1.1.1:53", "8.8.8.8:53"]
25 propagation_timeout = "5m"33 propagation_timeout = "5m"
@@ -166,6 +174,46 @@ keep = 14
166 [external_routes]174 [external_routes]
167 # "status.example.com" = "127.0.0.1:9000"175 # "status.example.com" = "127.0.0.1:9000"
168 176
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+
169 [network_groups]217 [network_groups]
170 # "backend" = ["app.example.com", "api.example.com"]218 # "backend" = ["app.example.com", "api.example.com"]
171 219
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 {
41 41
42 GetStatus(ctx context.Context) (*remote.Status, error)42 GetStatus(ctx context.Context) (*remote.Status, error)
43 GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error)43 GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error)
44+ GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error)
44 Reload(ctx context.Context) error45 Reload(ctx context.Context) error
45 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)46 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)
46 GetConfig(ctx context.Context) (*remote.Config, error)47 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
445 return &result, nil445 return &result, nil
446 }446 }
447 447
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+
448 func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {452 func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
449 if l.configSvc == nil {453 if l.configSvc == nil {
450 return nil, fmt.Errorf("local config service unavailable")454 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
126 return r.client.GetTLSStatus(ctx)126 return r.client.GetTLSStatus(ctx)
127 }127 }
128 128
129+func (r *remoteControlPlane) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) {
130+ return r.client.GetTrafficStatus(ctx)
131+}
132+
129 func (r *remoteControlPlane) Reload(ctx context.Context) error {133 func (r *remoteControlPlane) Reload(ctx context.Context) error {
130 return r.client.Reload(ctx)134 return r.client.Reload(ctx)
131 }135 }
M internal/adapters/in/cli/mocks/mock_control_plane.go +107 -45
@@ -66,7 +66,7 @@ type MockControlPlane_AddAttachment_Call struct {
66 // - ctx context.Context66 // - ctx context.Context
67 // - domainOrGroup string67 // - domainOrGroup string
68 // - image string68 // - 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 {
70 return &MockControlPlane_AddAttachment_Call{Call: _e.mock.On("AddAttachment", ctx, domainOrGroup, image)}70 return &MockControlPlane_AddAttachment_Call{Call: _e.mock.On("AddAttachment", ctx, domainOrGroup, image)}
71 }71 }
72 72
@@ -128,7 +128,7 @@ type MockControlPlane_AddAutoRouteAllowedDomain_Call struct {
128 // AddAutoRouteAllowedDomain is a helper method to define mock.On call128 // AddAutoRouteAllowedDomain is a helper method to define mock.On call
129 // - ctx context.Context129 // - ctx context.Context
130 // - pattern string130 // - 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 {
132 return &MockControlPlane_AddAutoRouteAllowedDomain_Call{Call: _e.mock.On("AddAutoRouteAllowedDomain", ctx, pattern)}132 return &MockControlPlane_AddAutoRouteAllowedDomain_Call{Call: _e.mock.On("AddAutoRouteAllowedDomain", ctx, pattern)}
133 }133 }
134 134
@@ -185,7 +185,7 @@ type MockControlPlane_AddRoute_Call struct {
185 // AddRoute is a helper method to define mock.On call185 // AddRoute is a helper method to define mock.On call
186 // - ctx context.Context186 // - ctx context.Context
187 // - route domain.Route187 // - 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 {
189 return &MockControlPlane_AddRoute_Call{Call: _e.mock.On("AddRoute", ctx, route)}189 return &MockControlPlane_AddRoute_Call{Call: _e.mock.On("AddRoute", ctx, route)}
190 }190 }
191 191
@@ -252,7 +252,7 @@ type MockControlPlane_BackupStatus_Call struct {
252 252
253 // BackupStatus is a helper method to define mock.On call253 // BackupStatus is a helper method to define mock.On call
254 // - ctx context.Context254 // - 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 {
256 return &MockControlPlane_BackupStatus_Call{Call: _e.mock.On("BackupStatus", ctx)}256 return &MockControlPlane_BackupStatus_Call{Call: _e.mock.On("BackupStatus", ctx)}
257 }257 }
258 258
@@ -315,7 +315,7 @@ type MockControlPlane_Bootstrap_Call struct {
315 // Bootstrap is a helper method to define mock.On call315 // Bootstrap is a helper method to define mock.On call
316 // - ctx context.Context316 // - ctx context.Context
317 // - req dto.BootstrapRequest317 // - 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 {
319 return &MockControlPlane_Bootstrap_Call{Call: _e.mock.On("Bootstrap", ctx, req)}319 return &MockControlPlane_Bootstrap_Call{Call: _e.mock.On("Bootstrap", ctx, req)}
320 }320 }
321 321
@@ -384,7 +384,7 @@ type MockControlPlane_CleanupOrphanedAttachments_Call struct {
384 // - ctx context.Context384 // - ctx context.Context
385 // - owner string385 // - owner string
386 // - stop bool386 // - 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 {
388 return &MockControlPlane_CleanupOrphanedAttachments_Call{Call: _e.mock.On("CleanupOrphanedAttachments", ctx, owner, stop)}388 return &MockControlPlane_CleanupOrphanedAttachments_Call{Call: _e.mock.On("CleanupOrphanedAttachments", ctx, owner, stop)}
389 }389 }
390 390
@@ -448,7 +448,7 @@ type MockControlPlane_DeleteAttachmentSecret_Call struct {
448 // - domain1 string448 // - domain1 string
449 // - service string449 // - service string
450 // - key string450 // - 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 {
452 return &MockControlPlane_DeleteAttachmentSecret_Call{Call: _e.mock.On("DeleteAttachmentSecret", ctx, domain1, service, key)}452 return &MockControlPlane_DeleteAttachmentSecret_Call{Call: _e.mock.On("DeleteAttachmentSecret", ctx, domain1, service, key)}
453 }453 }
454 454
@@ -516,7 +516,7 @@ type MockControlPlane_DeleteSecret_Call struct {
516 // - ctx context.Context516 // - ctx context.Context
517 // - secretDomain string517 // - secretDomain string
518 // - key string518 // - 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 {
520 return &MockControlPlane_DeleteSecret_Call{Call: _e.mock.On("DeleteSecret", ctx, secretDomain, key)}520 return &MockControlPlane_DeleteSecret_Call{Call: _e.mock.On("DeleteSecret", ctx, secretDomain, key)}
521 }521 }
522 522
@@ -589,7 +589,7 @@ type MockControlPlane_Deploy_Call struct {
589 // Deploy is a helper method to define mock.On call589 // Deploy is a helper method to define mock.On call
590 // - ctx context.Context590 // - ctx context.Context
591 // - deployDomain string591 // - 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 {
593 return &MockControlPlane_Deploy_Call{Call: _e.mock.On("Deploy", ctx, deployDomain)}593 return &MockControlPlane_Deploy_Call{Call: _e.mock.On("Deploy", ctx, deployDomain)}
594 }594 }
595 595
@@ -646,7 +646,7 @@ type MockControlPlane_DeployIntent_Call struct {
646 // DeployIntent is a helper method to define mock.On call646 // DeployIntent is a helper method to define mock.On call
647 // - ctx context.Context647 // - ctx context.Context
648 // - imageName string648 // - 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 {
650 return &MockControlPlane_DeployIntent_Call{Call: _e.mock.On("DeployIntent", ctx, imageName)}650 return &MockControlPlane_DeployIntent_Call{Call: _e.mock.On("DeployIntent", ctx, imageName)}
651 }651 }
652 652
@@ -714,7 +714,7 @@ type MockControlPlane_DetectDatabases_Call struct {
714 // DetectDatabases is a helper method to define mock.On call714 // DetectDatabases is a helper method to define mock.On call
715 // - ctx context.Context715 // - ctx context.Context
716 // - backupDomain string716 // - 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 {
718 return &MockControlPlane_DetectDatabases_Call{Call: _e.mock.On("DetectDatabases", ctx, backupDomain)}718 return &MockControlPlane_DetectDatabases_Call{Call: _e.mock.On("DetectDatabases", ctx, backupDomain)}
719 }719 }
720 720
@@ -782,7 +782,7 @@ type MockControlPlane_FindAttachmentTargetsByImage_Call struct {
782 // FindAttachmentTargetsByImage is a helper method to define mock.On call782 // FindAttachmentTargetsByImage is a helper method to define mock.On call
783 // - ctx context.Context783 // - ctx context.Context
784 // - imageName string784 // - 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 {
786 return &MockControlPlane_FindAttachmentTargetsByImage_Call{Call: _e.mock.On("FindAttachmentTargetsByImage", ctx, imageName)}786 return &MockControlPlane_FindAttachmentTargetsByImage_Call{Call: _e.mock.On("FindAttachmentTargetsByImage", ctx, imageName)}
787 }787 }
788 788
@@ -850,7 +850,7 @@ type MockControlPlane_FindRoutesByImage_Call struct {
850 // FindRoutesByImage is a helper method to define mock.On call850 // FindRoutesByImage is a helper method to define mock.On call
851 // - ctx context.Context851 // - ctx context.Context
852 // - imageName string852 // - 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 {
854 return &MockControlPlane_FindRoutesByImage_Call{Call: _e.mock.On("FindRoutesByImage", ctx, imageName)}854 return &MockControlPlane_FindRoutesByImage_Call{Call: _e.mock.On("FindRoutesByImage", ctx, imageName)}
855 }855 }
856 856
@@ -917,7 +917,7 @@ type MockControlPlane_GetAllAttachmentsConfig_Call struct {
917 917
918 // GetAllAttachmentsConfig is a helper method to define mock.On call918 // GetAllAttachmentsConfig is a helper method to define mock.On call
919 // - ctx context.Context919 // - 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 {
921 return &MockControlPlane_GetAllAttachmentsConfig_Call{Call: _e.mock.On("GetAllAttachmentsConfig", ctx)}921 return &MockControlPlane_GetAllAttachmentsConfig_Call{Call: _e.mock.On("GetAllAttachmentsConfig", ctx)}
922 }922 }
923 923
@@ -980,7 +980,7 @@ type MockControlPlane_GetAttachmentsConfig_Call struct {
980 // GetAttachmentsConfig is a helper method to define mock.On call980 // GetAttachmentsConfig is a helper method to define mock.On call
981 // - ctx context.Context981 // - ctx context.Context
982 // - domainOrGroup string982 // - 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 {
984 return &MockControlPlane_GetAttachmentsConfig_Call{Call: _e.mock.On("GetAttachmentsConfig", ctx, domainOrGroup)}984 return &MockControlPlane_GetAttachmentsConfig_Call{Call: _e.mock.On("GetAttachmentsConfig", ctx, domainOrGroup)}
985 }985 }
986 986
@@ -1047,7 +1047,7 @@ type MockControlPlane_GetAutoRouteAllowedDomains_Call struct {
1047 1047
1048 // GetAutoRouteAllowedDomains is a helper method to define mock.On call1048 // GetAutoRouteAllowedDomains is a helper method to define mock.On call
1049 // - ctx context.Context1049 // - 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 {
1051 return &MockControlPlane_GetAutoRouteAllowedDomains_Call{Call: _e.mock.On("GetAutoRouteAllowedDomains", ctx)}1051 return &MockControlPlane_GetAutoRouteAllowedDomains_Call{Call: _e.mock.On("GetAutoRouteAllowedDomains", ctx)}
1052 }1052 }
1053 1053
@@ -1109,7 +1109,7 @@ type MockControlPlane_GetConfig_Call struct {
1109 1109
1110 // GetConfig is a helper method to define mock.On call1110 // GetConfig is a helper method to define mock.On call
1111 // - ctx context.Context1111 // - 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 {
1113 return &MockControlPlane_GetConfig_Call{Call: _e.mock.On("GetConfig", ctx)}1113 return &MockControlPlane_GetConfig_Call{Call: _e.mock.On("GetConfig", ctx)}
1114 }1114 }
1115 1115
@@ -1173,7 +1173,7 @@ type MockControlPlane_GetContainerLogs_Call struct {
1173 // - ctx context.Context1173 // - ctx context.Context
1174 // - logDomain string1174 // - logDomain string
1175 // - lines int1175 // - 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 {
1177 return &MockControlPlane_GetContainerLogs_Call{Call: _e.mock.On("GetContainerLogs", ctx, logDomain, lines)}1177 return &MockControlPlane_GetContainerLogs_Call{Call: _e.mock.On("GetContainerLogs", ctx, logDomain, lines)}
1178 }1178 }
1179 1179
@@ -1245,7 +1245,7 @@ type MockControlPlane_GetHealth_Call struct {
1245 1245
1246 // GetHealth is a helper method to define mock.On call1246 // GetHealth is a helper method to define mock.On call
1247 // - ctx context.Context1247 // - 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 {
1249 return &MockControlPlane_GetHealth_Call{Call: _e.mock.On("GetHealth", ctx)}1249 return &MockControlPlane_GetHealth_Call{Call: _e.mock.On("GetHealth", ctx)}
1250 }1250 }
1251 1251
@@ -1308,7 +1308,7 @@ type MockControlPlane_GetProcessLogs_Call struct {
1308 // GetProcessLogs is a helper method to define mock.On call1308 // GetProcessLogs is a helper method to define mock.On call
1309 // - ctx context.Context1309 // - ctx context.Context
1310 // - lines int1310 // - 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 {
1312 return &MockControlPlane_GetProcessLogs_Call{Call: _e.mock.On("GetProcessLogs", ctx, lines)}1312 return &MockControlPlane_GetProcessLogs_Call{Call: _e.mock.On("GetProcessLogs", ctx, lines)}
1313 }1313 }
1314 1314
@@ -1376,7 +1376,7 @@ type MockControlPlane_GetRoute_Call struct {
1376 // GetRoute is a helper method to define mock.On call1376 // GetRoute is a helper method to define mock.On call
1377 // - ctx context.Context1377 // - ctx context.Context
1378 // - routeDomain string1378 // - 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 {
1380 return &MockControlPlane_GetRoute_Call{Call: _e.mock.On("GetRoute", ctx, routeDomain)}1380 return &MockControlPlane_GetRoute_Call{Call: _e.mock.On("GetRoute", ctx, routeDomain)}
1381 }1381 }
1382 1382
@@ -1443,7 +1443,7 @@ type MockControlPlane_GetStatus_Call struct {
1443 1443
1444 // GetStatus is a helper method to define mock.On call1444 // GetStatus is a helper method to define mock.On call
1445 // - ctx context.Context1445 // - 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 {
1447 return &MockControlPlane_GetStatus_Call{Call: _e.mock.On("GetStatus", ctx)}1447 return &MockControlPlane_GetStatus_Call{Call: _e.mock.On("GetStatus", ctx)}
1448 }1448 }
1449 1449
@@ -1505,7 +1505,7 @@ type MockControlPlane_GetTLSStatus_Call struct {
1505 1505
1506 // GetTLSStatus is a helper method to define mock.On call1506 // GetTLSStatus is a helper method to define mock.On call
1507 // - ctx context.Context1507 // - 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 {
1509 return &MockControlPlane_GetTLSStatus_Call{Call: _e.mock.On("GetTLSStatus", ctx)}1509 return &MockControlPlane_GetTLSStatus_Call{Call: _e.mock.On("GetTLSStatus", ctx)}
1510 }1510 }
1511 1511
@@ -1532,6 +1532,68 @@ func (_c *MockControlPlane_GetTLSStatus_Call) RunAndReturn(run func(ctx context.
1532 return _c1532 return _c
1533 }1533 }
1534 1534
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+
1535 // ListBackups provides a mock function for the type MockControlPlane1597 // ListBackups provides a mock function for the type MockControlPlane
1536 func (_mock *MockControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {1598 func (_mock *MockControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
1537 ret := _mock.Called(ctx, backupDomain)1599 ret := _mock.Called(ctx, backupDomain)
@@ -1568,7 +1630,7 @@ type MockControlPlane_ListBackups_Call struct {
1568 // ListBackups is a helper method to define mock.On call1630 // ListBackups is a helper method to define mock.On call
1569 // - ctx context.Context1631 // - ctx context.Context
1570 // - backupDomain string1632 // - 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 {
1572 return &MockControlPlane_ListBackups_Call{Call: _e.mock.On("ListBackups", ctx, backupDomain)}1634 return &MockControlPlane_ListBackups_Call{Call: _e.mock.On("ListBackups", ctx, backupDomain)}
1573 }1635 }
1574 1636
@@ -1635,7 +1697,7 @@ type MockControlPlane_ListNetworks_Call struct {
1635 1697
1636 // ListNetworks is a helper method to define mock.On call1698 // ListNetworks is a helper method to define mock.On call
1637 // - ctx context.Context1699 // - 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 {
1639 return &MockControlPlane_ListNetworks_Call{Call: _e.mock.On("ListNetworks", ctx)}1701 return &MockControlPlane_ListNetworks_Call{Call: _e.mock.On("ListNetworks", ctx)}
1640 }1702 }
1641 1703
@@ -1697,7 +1759,7 @@ type MockControlPlane_ListOrphanedAttachments_Call struct {
1697 1759
1698 // ListOrphanedAttachments is a helper method to define mock.On call1760 // ListOrphanedAttachments is a helper method to define mock.On call
1699 // - ctx context.Context1761 // - 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 {
1701 return &MockControlPlane_ListOrphanedAttachments_Call{Call: _e.mock.On("ListOrphanedAttachments", ctx)}1763 return &MockControlPlane_ListOrphanedAttachments_Call{Call: _e.mock.On("ListOrphanedAttachments", ctx)}
1702 }1764 }
1703 1765
@@ -1759,7 +1821,7 @@ type MockControlPlane_ListRoutesWithDetails_Call struct {
1759 1821
1760 // ListRoutesWithDetails is a helper method to define mock.On call1822 // ListRoutesWithDetails is a helper method to define mock.On call
1761 // - ctx context.Context1823 // - 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 {
1763 return &MockControlPlane_ListRoutesWithDetails_Call{Call: _e.mock.On("ListRoutesWithDetails", ctx)}1825 return &MockControlPlane_ListRoutesWithDetails_Call{Call: _e.mock.On("ListRoutesWithDetails", ctx)}
1764 }1826 }
1765 1827
@@ -1822,7 +1884,7 @@ type MockControlPlane_ListSecretsWithAttachments_Call struct {
1822 // ListSecretsWithAttachments is a helper method to define mock.On call1884 // ListSecretsWithAttachments is a helper method to define mock.On call
1823 // - ctx context.Context1885 // - ctx context.Context
1824 // - secretDomain string1886 // - 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 {
1826 return &MockControlPlane_ListSecretsWithAttachments_Call{Call: _e.mock.On("ListSecretsWithAttachments", ctx, secretDomain)}1888 return &MockControlPlane_ListSecretsWithAttachments_Call{Call: _e.mock.On("ListSecretsWithAttachments", ctx, secretDomain)}
1827 }1889 }
1828 1890
@@ -1890,7 +1952,7 @@ type MockControlPlane_ListTags_Call struct {
1890 // ListTags is a helper method to define mock.On call1952 // ListTags is a helper method to define mock.On call
1891 // - ctx context.Context1953 // - ctx context.Context
1892 // - repository string1954 // - 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 {
1894 return &MockControlPlane_ListTags_Call{Call: _e.mock.On("ListTags", ctx, repository)}1956 return &MockControlPlane_ListTags_Call{Call: _e.mock.On("ListTags", ctx, repository)}
1895 }1957 }
1896 1958
@@ -1958,7 +2020,7 @@ type MockControlPlane_ListVolumeBackups_Call struct {
1958 // ListVolumeBackups is a helper method to define mock.On call2020 // ListVolumeBackups is a helper method to define mock.On call
1959 // - ctx context.Context2021 // - ctx context.Context
1960 // - backupDomain string2022 // - 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 {
1962 return &MockControlPlane_ListVolumeBackups_Call{Call: _e.mock.On("ListVolumeBackups", ctx, backupDomain)}2024 return &MockControlPlane_ListVolumeBackups_Call{Call: _e.mock.On("ListVolumeBackups", ctx, backupDomain)}
1963 }2025 }
1964 2026
@@ -2025,7 +2087,7 @@ type MockControlPlane_ListVolumes_Call struct {
2025 2087
2026 // ListVolumes is a helper method to define mock.On call2088 // ListVolumes is a helper method to define mock.On call
2027 // - ctx context.Context2089 // - 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 {
2029 return &MockControlPlane_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)}2091 return &MockControlPlane_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)}
2030 }2092 }
2031 2093
@@ -2088,7 +2150,7 @@ type MockControlPlane_PruneVolumes_Call struct {
2088 // PruneVolumes is a helper method to define mock.On call2150 // PruneVolumes is a helper method to define mock.On call
2089 // - ctx context.Context2151 // - ctx context.Context
2090 // - req dto.VolumePruneRequest2152 // - 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 {
2092 return &MockControlPlane_PruneVolumes_Call{Call: _e.mock.On("PruneVolumes", ctx, req)}2154 return &MockControlPlane_PruneVolumes_Call{Call: _e.mock.On("PruneVolumes", ctx, req)}
2093 }2155 }
2094 2156
@@ -2144,7 +2206,7 @@ type MockControlPlane_Reload_Call struct {
2144 2206
2145 // Reload is a helper method to define mock.On call2207 // Reload is a helper method to define mock.On call
2146 // - ctx context.Context2208 // - 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 {
2148 return &MockControlPlane_Reload_Call{Call: _e.mock.On("Reload", ctx)}2210 return &MockControlPlane_Reload_Call{Call: _e.mock.On("Reload", ctx)}
2149 }2211 }
2150 2212
@@ -2197,7 +2259,7 @@ type MockControlPlane_RemoveAttachment_Call struct {
2197 // - ctx context.Context2259 // - ctx context.Context
2198 // - domainOrGroup string2260 // - domainOrGroup string
2199 // - image string2261 // - 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 {
2201 return &MockControlPlane_RemoveAttachment_Call{Call: _e.mock.On("RemoveAttachment", ctx, domainOrGroup, image)}2263 return &MockControlPlane_RemoveAttachment_Call{Call: _e.mock.On("RemoveAttachment", ctx, domainOrGroup, image)}
2202 }2264 }
2203 2265
@@ -2259,7 +2321,7 @@ type MockControlPlane_RemoveAutoRouteAllowedDomain_Call struct {
2259 // RemoveAutoRouteAllowedDomain is a helper method to define mock.On call2321 // RemoveAutoRouteAllowedDomain is a helper method to define mock.On call
2260 // - ctx context.Context2322 // - ctx context.Context
2261 // - pattern string2323 // - 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 {
2263 return &MockControlPlane_RemoveAutoRouteAllowedDomain_Call{Call: _e.mock.On("RemoveAutoRouteAllowedDomain", ctx, pattern)}2325 return &MockControlPlane_RemoveAutoRouteAllowedDomain_Call{Call: _e.mock.On("RemoveAutoRouteAllowedDomain", ctx, pattern)}
2264 }2326 }
2265 2327
@@ -2316,7 +2378,7 @@ type MockControlPlane_RemoveRoute_Call struct {
2316 // RemoveRoute is a helper method to define mock.On call2378 // RemoveRoute is a helper method to define mock.On call
2317 // - ctx context.Context2379 // - ctx context.Context
2318 // - routeDomain string2380 // - 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 {
2320 return &MockControlPlane_RemoveRoute_Call{Call: _e.mock.On("RemoveRoute", ctx, routeDomain)}2382 return &MockControlPlane_RemoveRoute_Call{Call: _e.mock.On("RemoveRoute", ctx, routeDomain)}
2321 }2383 }
2322 2384
@@ -2385,7 +2447,7 @@ type MockControlPlane_Restart_Call struct {
2385 // - ctx context.Context2447 // - ctx context.Context
2386 // - restartDomain string2448 // - restartDomain string
2387 // - withAttachments bool2449 // - 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 {
2389 return &MockControlPlane_Restart_Call{Call: _e.mock.On("Restart", ctx, restartDomain, withAttachments)}2451 return &MockControlPlane_Restart_Call{Call: _e.mock.On("Restart", ctx, restartDomain, withAttachments)}
2390 }2452 }
2391 2453
@@ -2459,7 +2521,7 @@ type MockControlPlane_RunBackup_Call struct {
2459 // - ctx context.Context2521 // - ctx context.Context
2460 // - backupDomain string2522 // - backupDomain string
2461 // - dbName string2523 // - 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 {
2463 return &MockControlPlane_RunBackup_Call{Call: _e.mock.On("RunBackup", ctx, backupDomain, dbName)}2525 return &MockControlPlane_RunBackup_Call{Call: _e.mock.On("RunBackup", ctx, backupDomain, dbName)}
2464 }2526 }
2465 2527
@@ -2533,7 +2595,7 @@ type MockControlPlane_RunVolumeBackups_Call struct {
2533 // - ctx context.Context2595 // - ctx context.Context
2534 // - backupDomain string2596 // - backupDomain string
2535 // - volumeName string2597 // - 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 {
2537 return &MockControlPlane_RunVolumeBackups_Call{Call: _e.mock.On("RunVolumeBackups", ctx, backupDomain, volumeName)}2599 return &MockControlPlane_RunVolumeBackups_Call{Call: _e.mock.On("RunVolumeBackups", ctx, backupDomain, volumeName)}
2538 }2600 }
2539 2601
@@ -2597,7 +2659,7 @@ type MockControlPlane_SetAttachmentSecrets_Call struct {
2597 // - domain1 string2659 // - domain1 string
2598 // - service string2660 // - service string
2599 // - secrets map[string]string2661 // - 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 {
2601 return &MockControlPlane_SetAttachmentSecrets_Call{Call: _e.mock.On("SetAttachmentSecrets", ctx, domain1, service, secrets)}2663 return &MockControlPlane_SetAttachmentSecrets_Call{Call: _e.mock.On("SetAttachmentSecrets", ctx, domain1, service, secrets)}
2602 }2664 }
2603 2665
@@ -2665,7 +2727,7 @@ type MockControlPlane_SetSecrets_Call struct {
2665 // - ctx context.Context2727 // - ctx context.Context
2666 // - secretDomain string2728 // - secretDomain string
2667 // - secrets map[string]string2729 // - 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 {
2669 return &MockControlPlane_SetSecrets_Call{Call: _e.mock.On("SetSecrets", ctx, secretDomain, secrets)}2731 return &MockControlPlane_SetSecrets_Call{Call: _e.mock.On("SetSecrets", ctx, secretDomain, secrets)}
2670 }2732 }
2671 2733
@@ -2739,7 +2801,7 @@ type MockControlPlane_StreamContainerLogs_Call struct {
2739 // - ctx context.Context2801 // - ctx context.Context
2740 // - logDomain string2802 // - logDomain string
2741 // - lines int2803 // - 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 {
2743 return &MockControlPlane_StreamContainerLogs_Call{Call: _e.mock.On("StreamContainerLogs", ctx, logDomain, lines)}2805 return &MockControlPlane_StreamContainerLogs_Call{Call: _e.mock.On("StreamContainerLogs", ctx, logDomain, lines)}
2744 }2806 }
2745 2807
@@ -2812,7 +2874,7 @@ type MockControlPlane_StreamProcessLogs_Call struct {
2812 // StreamProcessLogs is a helper method to define mock.On call2874 // StreamProcessLogs is a helper method to define mock.On call
2813 // - ctx context.Context2875 // - ctx context.Context
2814 // - lines int2876 // - 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 {
2816 return &MockControlPlane_StreamProcessLogs_Call{Call: _e.mock.On("StreamProcessLogs", ctx, lines)}2878 return &MockControlPlane_StreamProcessLogs_Call{Call: _e.mock.On("StreamProcessLogs", ctx, lines)}
2817 }2879 }
2818 2880
@@ -2869,7 +2931,7 @@ type MockControlPlane_UpdateRoute_Call struct {
2869 // UpdateRoute is a helper method to define mock.On call2931 // UpdateRoute is a helper method to define mock.On call
2870 // - ctx context.Context2932 // - ctx context.Context
2871 // - route domain.Route2933 // - 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 {
2873 return &MockControlPlane_UpdateRoute_Call{Call: _e.mock.On("UpdateRoute", ctx, route)}2935 return &MockControlPlane_UpdateRoute_Call{Call: _e.mock.On("UpdateRoute", ctx, route)}
2874 }2936 }
2875 2937
@@ -2936,7 +2998,7 @@ type MockControlPlane_VolumeBackupStatus_Call struct {
2936 2998
2937 // VolumeBackupStatus is a helper method to define mock.On call2999 // VolumeBackupStatus is a helper method to define mock.On call
2938 // - ctx context.Context3000 // - 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 {
2940 return &MockControlPlane_VolumeBackupStatus_Call{Call: _e.mock.On("VolumeBackupStatus", ctx)}3002 return &MockControlPlane_VolumeBackupStatus_Call{Call: _e.mock.On("VolumeBackupStatus", ctx)}
2941 }3003 }
2942 3004
M internal/adapters/in/cli/mocks/mock_images_client.go +2 -2
@@ -73,7 +73,7 @@ type MockimagesClient_ListImages_Call struct {
73 73
74 // ListImages is a helper method to define mock.On call74 // ListImages is a helper method to define mock.On call
75 // - ctx context.Context75 // - 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 {
77 return &MockimagesClient_ListImages_Call{Call: _e.mock.On("ListImages", ctx)}77 return &MockimagesClient_ListImages_Call{Call: _e.mock.On("ListImages", ctx)}
78 }78 }
79 79
@@ -136,7 +136,7 @@ type MockimagesClient_PruneImages_Call struct {
136 // PruneImages is a helper method to define mock.On call136 // PruneImages is a helper method to define mock.On call
137 // - ctx context.Context137 // - ctx context.Context
138 // - req dto.ImagePruneRequest138 // - 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 {
140 return &MockimagesClient_PruneImages_Call{Call: _e.mock.On("PruneImages", ctx, req)}140 return &MockimagesClient_PruneImages_Call{Call: _e.mock.On("PruneImages", ctx, req)}
141 }141 }
142 142
M internal/adapters/in/cli/mocks/mock_push_image_ops.go +4 -4
@@ -62,7 +62,7 @@ type MockpushImageOps_Build_Call struct {
62 // Build is a helper method to define mock.On call62 // Build is a helper method to define mock.On call
63 // - ctx context.Context63 // - ctx context.Context
64 // - args []string64 // - 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 {
66 return &MockpushImageOps_Build_Call{Call: _e.mock.On("Build", ctx, args)}66 return &MockpushImageOps_Build_Call{Call: _e.mock.On("Build", ctx, args)}
67 }67 }
68 68
@@ -128,7 +128,7 @@ type MockpushImageOps_Exists_Call struct {
128 // Exists is a helper method to define mock.On call128 // Exists is a helper method to define mock.On call
129 // - ctx context.Context129 // - ctx context.Context
130 // - imageRef string130 // - 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 {
132 return &MockpushImageOps_Exists_Call{Call: _e.mock.On("Exists", ctx, imageRef)}132 return &MockpushImageOps_Exists_Call{Call: _e.mock.On("Exists", ctx, imageRef)}
133 }133 }
134 134
@@ -185,7 +185,7 @@ type MockpushImageOps_Push_Call struct {
185 // Push is a helper method to define mock.On call185 // Push is a helper method to define mock.On call
186 // - ctx context.Context186 // - ctx context.Context
187 // - ref string187 // - 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 {
189 return &MockpushImageOps_Push_Call{Call: _e.mock.On("Push", ctx, ref)}189 return &MockpushImageOps_Push_Call{Call: _e.mock.On("Push", ctx, ref)}
190 }190 }
191 191
@@ -243,7 +243,7 @@ type MockpushImageOps_Tag_Call struct {
243 // - ctx context.Context243 // - ctx context.Context
244 // - sourceRef string244 // - sourceRef string
245 // - targetRef string245 // - 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 {
247 return &MockpushImageOps_Tag_Call{Call: _e.mock.On("Tag", ctx, sourceRef, targetRef)}247 return &MockpushImageOps_Tag_Call{Call: _e.mock.On("Tag", ctx, sourceRef, targetRef)}
248 }248 }
249 249
M internal/adapters/in/cli/mocks/mock_volumes_client.go +2 -2
@@ -73,7 +73,7 @@ type MockvolumesClient_ListVolumes_Call struct {
73 73
74 // ListVolumes is a helper method to define mock.On call74 // ListVolumes is a helper method to define mock.On call
75 // - ctx context.Context75 // - 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 {
77 return &MockvolumesClient_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)}77 return &MockvolumesClient_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)}
78 }78 }
79 79
@@ -136,7 +136,7 @@ type MockvolumesClient_PruneVolumes_Call struct {
136 // PruneVolumes is a helper method to define mock.On call136 // PruneVolumes is a helper method to define mock.On call
137 // - ctx context.Context137 // - ctx context.Context
138 // - req dto.VolumePruneRequest138 // - 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 {
140 return &MockvolumesClient_PruneVolumes_Call{Call: _e.mock.On("PruneVolumes", ctx, req)}140 return &MockvolumesClient_PruneVolumes_Call{Call: _e.mock.On("PruneVolumes", ctx, req)}
141 }141 }
142 142
M internal/adapters/in/cli/remote/client.go +21 -7
@@ -701,6 +701,21 @@ func (c *Client) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, erro
701 return &status, nil701 return &status, nil
702 }702 }
703 703
704+// GetTrafficStatus returns the traffic plane status.
705+func (c *Client) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) {
706+ resp, err := c.request(ctx, http.MethodGet, "/traffic/status", nil)
707+ if err != nil {
708+ return nil, 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+
704 // GetStatus returns the Gordon server status.719 // GetStatus returns the Gordon server status.
705 func (c *Client) GetStatus(ctx context.Context) (*Status, error) {720 func (c *Client) GetStatus(ctx context.Context) (*Status, error) {
706 resp, err := c.request(ctx, http.MethodGet, "/status", nil)721 resp, err := c.request(ctx, http.MethodGet, "/status", nil)
@@ -1351,19 +1366,18 @@ func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, er
1351 // Process complete SSE events1366 // Process complete SSE events
1352 for {1367 for {
1353 data := lineBuffer.String()1368 data := lineBuffer.String()
1354- idx := strings.Index(data, "\n\n")1369+ event, remaining, ok := strings.Cut(data, "\n\n")
1355- if idx == -1 {1370+ if !ok {
1356 break1371 break
1357 }1372 }
1358 1373
1359- event := data[:idx]
1360 lineBuffer.Reset()1374 lineBuffer.Reset()
1361- lineBuffer.WriteString(data[idx+2:])1375+ lineBuffer.WriteString(remaining)
1362 1376
1363 // Parse SSE data lines1377 // Parse SSE data lines
1364- for _, line := range strings.Split(event, "\n") {1378+ for line := range strings.SplitSeq(event, "\n") {
1365- if strings.HasPrefix(line, "data: ") {1379+ logLine, ok := strings.CutPrefix(line, "data: ")
1366- logLine := strings.TrimPrefix(line, "data: ")1380+ if ok {
1367 select {1381 select {
1368 case ch <- logLine:1382 case ch <- logLine:
1369 case <-ctx.Done():1383 case <-ctx.Done():
M internal/adapters/in/cli/remote/client_test.go +17 -0
@@ -117,6 +117,23 @@ func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
117 require.NoError(t, err)117 require.NoError(t, err)
118 }118 }
119 119
120+func TestClientGetTrafficStatus(t *testing.T) {
121+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
122+ require.Equal(t, "/admin/traffic/status", r.URL.Path)
123+ require.Equal(t, http.MethodGet, r.Method)
124+ w.Header().Set("Content-Type", "application/json")
125+ _, _ = w.Write([]byte(`{"last_reload_status":"ok","counters":{"active_tcp_connections":1}}`))
126+ }))
127+ defer srv.Close()
128+
129+ client := NewClient(srv.URL)
130+ status, err := client.GetTrafficStatus(context.Background())
131+ require.NoError(t, err)
132+ require.NotNil(t, status)
133+ assert.Equal(t, "ok", status.LastReloadStatus)
134+ assert.Equal(t, int64(1), status.Counters.ActiveTCPConnections)
135+}
136+
120 func TestClientGetTLSStatus(t *testing.T) {137 func TestClientGetTLSStatus(t *testing.T) {
121 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {138 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
122 require.Equal(t, "/admin/tls/status", r.URL.Path)139 require.Equal(t, "/admin/tls/status", r.URL.Path)
M internal/adapters/in/cli/root.go +4 -0
@@ -154,6 +154,10 @@ Commands are organized by where they run:
154 tlsCmd.GroupID = groupManage154 tlsCmd.GroupID = groupManage
155 rootCmd.AddCommand(tlsCmd)155 rootCmd.AddCommand(tlsCmd)
156 156
157+ trafficCmd := newTrafficCmd()
158+ trafficCmd.GroupID = groupManage
159+ rootCmd.AddCommand(trafficCmd)
160+
157 // Client-only commands (no server needed)161 // Client-only commands (no server needed)
158 remotesCmd := newRemotesCmd()162 remotesCmd := newRemotesCmd()
159 remotesCmd.GroupID = groupClient163 remotesCmd.GroupID = groupClient
A internal/adapters/in/cli/traffic.go +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 {
54 previewSvc previewService54 previewSvc previewService
55 reloadTrigger reloadTrigger55 reloadTrigger reloadTrigger
56 publicTLSSvc in.PublicTLSService56 publicTLSSvc in.PublicTLSService
57+ trafficSvc in.TrafficStatusService
57 log zerowrap.Logger58 log zerowrap.Logger
58 }59 }
59 60
@@ -202,6 +203,7 @@ type HandlerDeps struct {
202 VolumeSvc in.VolumeService203 VolumeSvc in.VolumeService
203 ReloadTrigger reloadTrigger204 ReloadTrigger reloadTrigger
204 PublicTLSSvc in.PublicTLSService205 PublicTLSSvc in.PublicTLSService
206+ TrafficSvc in.TrafficStatusService
205 }207 }
206 208
207 // NewHandler creates a new admin HTTP handler.209 // NewHandler creates a new admin HTTP handler.
@@ -221,6 +223,7 @@ func NewHandler(deps HandlerDeps) *Handler {
221 previewSvc: deps.PreviewSvc,223 previewSvc: deps.PreviewSvc,
222 reloadTrigger: deps.ReloadTrigger,224 reloadTrigger: deps.ReloadTrigger,
223 publicTLSSvc: deps.PublicTLSSvc,225 publicTLSSvc: deps.PublicTLSSvc,
226+ trafficSvc: deps.TrafficSvc,
224 log: deps.Log,227 log: deps.Log,
225 }228 }
226 }229 }
@@ -274,6 +277,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
274 "/attachments/orphans": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentOrphans(w, r) },277 "/attachments/orphans": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentOrphans(w, r) },
275 "/attachments/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentPrune(w, r) },278 "/attachments/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentPrune(w, r) },
276 "/tls/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTLSStatus(w, r) },279 "/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) },
277 }281 }
278 if handler, ok := exactRoutes[path]; ok {282 if handler, ok := exactRoutes[path]; ok {
279 return handler, true283 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
141 })141 })
142 log := zerowrap.FromCtx(ctx)142 log := zerowrap.FromCtx(ctx)
143 143
144- // Convert ports to Docker format144+ exposedPorts, portBindings, err := buildPortBindings(config)
145- exposedPorts := make(nat.PortSet)145+ if err != nil {
146- portBindings := make(nat.PortMap)146+ return nil, err
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- }
179 }147 }
148+ binds := buildVolumeBinds(config, log)
180 149
181 // Create container configuration150 // Create container configuration
182 containerConfig := &container.Config{151 containerConfig := &container.Config{
@@ -250,6 +219,59 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
250 return r.InspectContainer(ctx, resp.ID)219 return r.InspectContainer(ctx, resp.ID)
251 }220 }
252 221
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+
253 // StartContainer starts a container.275 // StartContainer starts a container.
254 func (r *Runtime) StartContainer(ctx context.Context, containerID string) error {276 func (r *Runtime) StartContainer(ctx context.Context, containerID string) error {
255 ctx = zerowrap.CtxWithFields(ctx, map[string]any{277 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
M internal/adapters/out/docker/runtime_create_test.go +74 -11
@@ -15,7 +15,78 @@ import (
15 "github.com/bnema/gordon/internal/domain"15 "github.com/bnema/gordon/internal/domain"
16 )16 )
17 17
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+
18 func TestRuntime_CreateContainerAppliesRestartPolicy(t *testing.T) {73 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+
19 var createBody map[string]any90 var createBody map[string]any
20 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {91 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21 switch {92 switch {
@@ -45,18 +116,10 @@ func TestRuntime_CreateContainerAppliesRestartPolicy(t *testing.T) {
45 require.NoError(t, err)116 require.NoError(t, err)
46 117
47 runtime := NewRuntimeWithClient(cli)118 runtime := NewRuntimeWithClient(cli)
48- created, err := runtime.CreateContainer(context.Background(), &domain.ContainerConfig{119+ created, err := runtime.CreateContainer(context.Background(), config)
49- Image: "nginx:latest",
50- Name: "gordon-app.example.com",
51- RestartPolicy: domain.RestartPolicyAlways,
52- })
53 require.NoError(t, err)120 require.NoError(t, err)
54 assert.Equal(t, "abc123", created.ID)121 assert.Equal(t, "abc123", created.ID)
55-
56 require.NotNil(t, createBody)122 require.NotNil(t, createBody)
57- hostConfig, ok := createBody["HostConfig"].(map[string]any)123+
58- require.True(t, ok)124+ return createBody
59- restartPolicy, ok := hostConfig["RestartPolicy"].(map[string]any)
60- require.True(t, ok)
61- assert.Equal(t, domain.RestartPolicyAlways, restartPolicy["Name"])
62 }125 }
M internal/app/public_tls_wiring_test.go +154 -0
@@ -11,6 +11,7 @@ import (
11 "testing"11 "testing"
12 "time"12 "time"
13 13
14+ "github.com/spf13/viper"
14 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/mock"16 "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"17 "github.com/stretchr/testify/require"
@@ -19,10 +20,163 @@ import (
19 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"20 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
20 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"21 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
21 "github.com/bnema/gordon/internal/domain"22 "github.com/bnema/gordon/internal/domain"
23+ config "github.com/bnema/gordon/internal/usecase/config"
22 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"24 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"
23 "github.com/bnema/zerowrap"27 "github.com/bnema/zerowrap"
24 )28 )
25 29
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+
26 // generateTestCert creates a self-signed certificate for the given hostnames.180 // generateTestCert creates a self-signed certificate for the given hostnames.
27 func generateTestCert(t *testing.T, hosts ...string) tls.Certificate {181 func generateTestCert(t *testing.T, hosts ...string) tls.Certificate {
28 t.Helper()182 t.Helper()
M internal/app/run.go +403 -131
@@ -57,6 +57,7 @@ import (
57 "github.com/bnema/gordon/internal/adapters/in/http/onboarding"57 "github.com/bnema/gordon/internal/adapters/in/http/onboarding"
58 proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy"58 proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy"
59 "github.com/bnema/gordon/internal/adapters/in/http/registry"59 "github.com/bnema/gordon/internal/adapters/in/http/registry"
60+ trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
60 61
61 // Boundaries62 // Boundaries
62 "github.com/bnema/gordon/internal/boundaries/in"63 "github.com/bnema/gordon/internal/boundaries/in"
@@ -84,6 +85,8 @@ import (
84 "github.com/bnema/gordon/internal/usecase/publictls"85 "github.com/bnema/gordon/internal/usecase/publictls"
85 registrySvc "github.com/bnema/gordon/internal/usecase/registry"86 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
86 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"87 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"
87 volumesSvc "github.com/bnema/gordon/internal/usecase/volumes"90 volumesSvc "github.com/bnema/gordon/internal/usecase/volumes"
88 91
89 // Pkg92 // Pkg
@@ -174,6 +177,11 @@ type Config struct {
174 } `mapstructure:"rate_limit"`177 } `mapstructure:"rate_limit"`
175 } `mapstructure:"api"`178 } `mapstructure:"api"`
176 179
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+
177 Backups struct {185 Backups struct {
178 // Legacy database backup keys. Prefer backups.databases.* for new configs.186 // Legacy database backup keys. Prefer backups.databases.* for new configs.
179 Enabled bool `mapstructure:"enabled"`187 Enabled bool `mapstructure:"enabled"`
@@ -255,43 +263,50 @@ type Config struct {
255 263
256 // services holds all the services used by the application.264 // services holds all the services used by the application.
257 type services struct {265 type services struct {
258- runtime *docker.Runtime266+ runtime *docker.Runtime
259- eventBus *eventbus.InMemory267+ eventBus *eventbus.InMemory
260- blobStorage *filesystem.BlobStorage268+ blobStorage *filesystem.BlobStorage
261- manifestStorage *filesystem.ManifestStorage269+ manifestStorage *filesystem.ManifestStorage
262- backupStorage *filesystem.BackupStorage270+ backupStorage *filesystem.BackupStorage
263- volumeBackupStore out.VolumeBackupStorage271+ volumeBackupStore out.VolumeBackupStorage
264- volumeBackupCfg domain.VolumeBackupConfig272+ volumeBackupCfg domain.VolumeBackupConfig
265- envLoader out.EnvLoader273+ envLoader out.EnvLoader
266- logWriter *logwriter.LogWriter274+ logWriter *logwriter.LogWriter
267- tokenStore out.TokenStore275+ tokenStore out.TokenStore
268- configSvc *config.Service276+ configSvc *config.Service
269- secretSvc *secretsSvc.Service277+ secretSvc *secretsSvc.Service
270- containerSvc *container.Service278+ containerSvc *container.Service
271- backupSvc *backup.Service279+ backupSvc *backup.Service
272- volumeBackupSvc *backup.VolumeService280+ volumeBackupSvc *backup.VolumeService
273- registrySvc *registrySvc.Service281+ registrySvc *registrySvc.Service
274- healthSvc *health.Service282+ healthSvc *health.Service
275- logSvc *logs.Service283+ logSvc *logs.Service
276- imageSvc *images.Service284+ imageSvc *images.Service
277- volumeSvc *volumesSvc.Service285+ volumeSvc *volumesSvc.Service
278- proxySvc *proxy.Service286+ proxySvc *proxy.Service
279- authSvc *auth.Service287+ standaloneServiceSvc in.StandaloneServiceService
280- authHandler *authhandler.Handler288+ serviceSecretProvider out.SecretProvider
281- adminHandler *admin.Handler289+ authSvc *auth.Service
282- internalRegUser string290+ authHandler *authhandler.Handler
283- internalRegPass string291+ adminHandler *admin.Handler
284- previewStore *filesystem.PreviewStore292+ httpProxyHandler http.Handler
285- previewService *preview.Service293+ httpsProxyHandler http.Handler
286- envDir string294+ internalRegUser string
287- maxBlobChunkSize int64295+ internalRegPass string
288- maxBlobSize int64296+ previewStore *filesystem.PreviewStore
289- caAdapter *pkiadapter.CA297+ previewService *preview.Service
290- pkiSvc *pkiusecase.Service298+ envDir string
291- reloadCoordinator *reloadCoordinator299+ maxBlobChunkSize int64
292- publicTLSSvc in.PublicTLSService300+ maxBlobSize int64
293- publicTLSRuntime publicTLSRuntime301+ caAdapter *pkiadapter.CA
294- registryHandler interface {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 {
295 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)310 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
296 }311 }
297 }312 }
@@ -553,7 +568,7 @@ func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config,
553 return nil, err568 return nil, err
554 }569 }
555 570
556- if err := si.initRuntimeAndProxy(); err != nil {571+ if err := si.initRuntimeProxyAndTraffic(); err != nil {
557 return nil, err572 return nil, err
558 }573 }
559 574
@@ -567,8 +582,8 @@ func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config,
567 582
568 // initPKI initialises the internal CA and PKI service when TLS is enabled.583 // initPKI initialises the internal CA and PKI service when TLS is enabled.
569 func (si *serviceInit) initPKI() error {584 func (si *serviceInit) initPKI() error {
570- if si.cfg.Server.TLSPort == 0 {585+ if !hasTLSCapableEntrypoint(si.cfg) {
571- si.log.Info().Msg("internal CA disabled (server.tls_port=0)")586+ si.log.Info().Msg("internal CA disabled (no TLS-capable entrypoint configured)")
572 return nil587 return nil
573 }588 }
574 589
@@ -591,8 +606,8 @@ func (si *serviceInit) initPublicTLS() error {
591 return nil606 return nil
592 }607 }
593 608
594- if si.cfg.Server.TLSPort == 0 {609+ if err := validatePublicTLSReadiness(si.cfg); err != nil {
595- return fmt.Errorf("%w: tls.acme.enabled requires server.tls_port > 0", domain.ErrACMEChallengeInvalid)610+ return err
596 }611 }
597 612
598 ctx := si.ctx613 ctx := si.ctx
@@ -607,8 +622,8 @@ func (si *serviceInit) initPublicTLS() error {
607 Enabled: si.cfg.TLS.ACME.Enabled,622 Enabled: si.cfg.TLS.ACME.Enabled,
608 Email: si.cfg.TLS.ACME.Email,623 Email: si.cfg.TLS.ACME.Email,
609 Challenge: si.cfg.TLS.ACME.Challenge,624 Challenge: si.cfg.TLS.ACME.Challenge,
610- HTTPPort: si.cfg.Server.Port,625+ HTTPPort: effectiveHTTP01Port(si.cfg),
611- TLSPort: si.cfg.Server.TLSPort,626+ TLSPort: effectivePublicTLSPort(si.cfg),
612 DataDir: resolveDataDir(si.cfg.Server.DataDir),627 DataDir: resolveDataDir(si.cfg.Server.DataDir),
613 ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize,628 ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize,
614 DNS: dnsCfg,629 DNS: dnsCfg,
@@ -620,6 +635,9 @@ func (si *serviceInit) initPublicTLS() error {
620 if err != nil {635 if err != nil {
621 return log.WrapErr(err, "resolve ACME challenge")636 return log.WrapErr(err, "resolve ACME challenge")
622 }637 }
638+ if err := validateEffectivePublicTLSReadiness(si.cfg, effective); err != nil {
639+ return err
640+ }
623 641
624 store, err := acmestore.New(filepath.Join(resolveDataDir(si.cfg.Server.DataDir), "acme"))642 store, err := acmestore.New(filepath.Join(resolveDataDir(si.cfg.Server.DataDir), "acme"))
625 if err != nil {643 if err != nil {
@@ -700,11 +718,20 @@ func (si *serviceInit) initSecrets() error {
700 return err718 return err
701 }719 }
702 720
721+ si.svc.serviceSecretProvider = createStandaloneServiceSecretProvider(backend, resolveDataDir(si.cfg.Server.DataDir), si.log)
703 si.svc.secretSvc = secretsSvc.NewService(domainSecretStore, si.log, si.svc.eventBus)722 si.svc.secretSvc = secretsSvc.NewService(domainSecretStore, si.log, si.svc.eventBus)
704 return nil723 return nil
705 }724 }
706 725
707 // initRuntimeAndProxy creates container, backup, registry, image, volume, and proxy services.726 // 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+
708 func (si *serviceInit) initRuntimeAndProxy() error {735 func (si *serviceInit) initRuntimeAndProxy() error {
709 var err error736 var err error
710 737
@@ -732,6 +759,7 @@ func (si *serviceInit) initRuntimeAndProxy() error {
732 si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize759 si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
733 si.svc.maxBlobSize = proxyCfg.maxBlobSize760 si.svc.maxBlobSize = proxyCfg.maxBlobSize
734 si.svc.proxySvc = proxy.NewService(si.svc.runtime, si.svc.containerSvc, si.svc.configSvc, proxyCfg.proxyConfig)761 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)
735 763
736 // Wire synchronous proxy cache invalidation for zero-downtime deployments.764 // Wire synchronous proxy cache invalidation for zero-downtime deployments.
737 // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget().765 // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget().
@@ -751,6 +779,22 @@ func (si *serviceInit) registerReloadCoordinatorHooks() {
751 if err != nil {779 if err != nil {
752 return err780 return err
753 }781 }
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+ }
754 si.svc.containerSvc.UpdateConfig(containerCfg)798 si.svc.containerSvc.UpdateConfig(containerCfg)
755 return nil799 return nil
756 })800 })
@@ -771,6 +815,9 @@ func (si *serviceInit) initHandlers() {
771 si.svc.logSvc = logs.NewService(resolveLogFilePath(si.cfg), si.cfg.Logging.File.Enabled, si.svc.containerSvc, si.svc.runtime, si.log)815 si.svc.logSvc = logs.NewService(resolveLogFilePath(si.cfg), si.cfg.Logging.File.Enabled, si.svc.containerSvc, si.svc.runtime, si.log)
772 816
773 initPreviewService(si.ctx, si.cfg, si.svc, si.log)817 initPreviewService(si.ctx, si.cfg, si.svc, si.log)
818+ if si.svc.trafficManager == nil {
819+ si.svc.trafficManager = trafficadapter.NewManager()
820+ }
774 821
775 si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{822 si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{
776 ConfigSvc: si.svc.configSvc,823 ConfigSvc: si.svc.configSvc,
@@ -788,6 +835,7 @@ func (si *serviceInit) initHandlers() {
788 ImageSvc: si.svc.imageSvc,835 ImageSvc: si.svc.imageSvc,
789 VolumeSvc: si.svc.volumeSvc,836 VolumeSvc: si.svc.volumeSvc,
790 PublicTLSSvc: si.svc.publicTLSSvc,837 PublicTLSSvc: si.svc.publicTLSSvc,
838+ TrafficSvc: si.svc.trafficManager,
791 })839 })
792 }840 }
793 841
@@ -1616,6 +1664,31 @@ func readFileBeneath(root, cleanedRelPath string) ([]byte, error) {
1616 return nil, fmt.Errorf("invalid secret path: empty path")1664 return nil, fmt.Errorf("invalid secret path: empty path")
1617 }1665 }
1618 1666
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+
1619 func readUnsafeSecret(dataDir, secretPath string) (string, error) {1692 func readUnsafeSecret(dataDir, secretPath string) (string, error) {
1620 if filepath.IsAbs(secretPath) {1693 if filepath.IsAbs(secretPath) {
1621 return "", fmt.Errorf("invalid secret path: absolute paths are not allowed")1694 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
2342 middleware.PanicRecovery(log),2415 middleware.PanicRecovery(log),
2343 middleware.RequestLogger(log, trustedNets),2416 middleware.RequestLogger(log, trustedNets),
2344 middleware.SecurityHeaders,2417 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 {
2346 return svc.proxySvc.IsKnownHost(context.Background(), host)2419 return svc.proxySvc.IsKnownHost(context.Background(), host)
2347 }),2420 }),
2348 }2421 }
@@ -2357,7 +2430,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
2357 2430
2358 // Build the onboarding handler once if internal CA is available and TLS is enabled.2431 // Build the onboarding handler once if internal CA is available and TLS is enabled.
2359 var obHandler *onboarding.Handler2432 var obHandler *onboarding.Handler
2360- if svc.caAdapter != nil && cfg.Server.TLSPort != 0 {2433+ if svc.caAdapter != nil && effectiveProxyTLSPort(cfg) != 0 {
2361 mobileconfigBytes := pkiadapter.GenerateMobileconfig(2434 mobileconfigBytes := pkiadapter.GenerateMobileconfig(
2362 svc.caAdapter.RootCertificateDER(),2435 svc.caAdapter.RootCertificateDER(),
2363 svc.caAdapter.RootCommonName(),2436 svc.caAdapter.RootCommonName(),
@@ -2366,8 +2439,8 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
2366 svc.caAdapter.RootCertificate(),2439 svc.caAdapter.RootCertificate(),
2367 mobileconfigBytes,2440 mobileconfigBytes,
2368 svc.caAdapter.RootFingerprint(),2441 svc.caAdapter.RootFingerprint(),
2369- cfg.Server.Port,2442+ effectiveProxyHTTPPort(cfg),
2370- cfg.Server.TLSPort,2443+ effectiveProxyTLSPort(cfg),
2371 )2444 )
2372 }2445 }
2373 2446
@@ -2433,6 +2506,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
2433 proxyOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(proxyOut)2506 proxyOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(proxyOut)
2434 httpsOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(httpsOut)2507 httpsOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(httpsOut)
2435 }2508 }
2509+ svc.httpsProxyHandler = httpsOut
2436 2510
2437 return registryOut, proxyOut, httpsOut2511 return registryOut, proxyOut, httpsOut
2438 }2512 }
@@ -2780,6 +2854,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
2780 errChan := make(chan error, 3)2854 errChan := make(chan error, 3)
2781 2855
2782 registryHandler, httpProxyHandler, httpsProxyHandler := createHTTPHandlers(svc, cfg, log, accessWriter)2856 registryHandler, httpProxyHandler, httpsProxyHandler := createHTTPHandlers(svc, cfg, log, accessWriter)
2857+ svc.httpProxyHandler = httpProxyHandler
2858+ svc.httpsProxyHandler = httpsProxyHandler
2783 2859
2784 registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort))2860 registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort))
2785 registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log)2861 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,
2798 }2874 }
2799 }2875 }
2800 2876
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)
2802 if err != nil {2878 if err != nil {
2803 closeStarted(registrySrv)2879 closeStarted(registrySrv)
2804 return err2880 return err
2805 }2881 }
2882+ svc.tlsHTTPEntryPoints = tlsMuxHTTPServerNames(cfg)
2883+ svc.smartHTTPEntryPoints = smartTCPHTTPServerNames(cfg)
2806 2884
2807- // Wait for all servers to bind their ports before auto-starting containers.2885+ // Wait for the registry and HTTP proxy to bind before applying the traffic graph.
2808- // This prevents the race where auto-start pulls from the registry before it's listening.2886+ // This prevents auto-start races while keeping the TLS mux under one owner.
2809- if err := waitForServerReady(registryReady, errChan); err != nil {2887+ if err := waitForCoreProxyReadyAndApplyTraffic(ctx, cfg, svc, registryReady, proxyReady, errChan); err != nil {
2810- closeStarted(registrySrv, proxySrv, tlsSrv)
2811- return err
2812- }
2813- if err := waitForServerReady(proxyReady, errChan); err != nil {
2814 closeStarted(registrySrv, proxySrv, tlsSrv)2888 closeStarted(registrySrv, proxySrv, tlsSrv)
2815 return err2889 return err
2816 }2890 }
2817- if tlsReady != nil {
2818- if err := waitForServerReady(tlsReady, errChan); err != nil {
2819- closeStarted(registrySrv, proxySrv, tlsSrv)
2820- return err
2821- }
2822- }
2823 2891
2824 logEvent := log.Info().2892 logEvent := log.Info().
2825 Int("proxy_port", cfg.Server.Port).2893 Int("proxy_port", cfg.Server.Port).
2826 Int("registry_port", cfg.Server.RegistryPort)2894 Int("registry_port", cfg.Server.RegistryPort)
2827- if tlsSrv != nil {2895+ if cfg.Server.TLSPort != 0 {
2828 logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort)2896 logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort)
2829 }2897 }
2830 logEvent.Msg("Gordon is running")2898 logEvent.Msg("Gordon is running")
@@ -2833,6 +2901,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
2833 2901
2834 schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)2902 schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)
2835 if err != nil {2903 if err != nil {
2904+ shutdownTrafficManagerForStartupCleanup(svc.trafficManager, log)
2905+ closeStarted(registrySrv, proxySrv, tlsSrv)
2836 return err2906 return err
2837 }2907 }
2838 if schedulerCleanup != nil {2908 if schedulerCleanup != nil {
@@ -2844,7 +2914,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
2844 2914
2845 waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log)2915 waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log)
2846 cleanupHandlers() // Stop debounce timers before draining containers2916 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)
2848 return nil2918 return nil
2849 }2919 }
2850 2920
@@ -2854,7 +2924,48 @@ func startPublicTLSRuntimeWithWarning(ctx context.Context, svc publicTLSRuntime,
2854 }2924 }
2855 }2925 }
2856 2926
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+
2857 func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {2965 func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
2966+ if ready == nil {
2967+ return nil
2968+ }
2858 select {2969 select {
2859 case <-ready:2970 case <-ready:
2860 return nil2971 return nil
@@ -3096,7 +3207,7 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl
3096 3207
3097 // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down3208 // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
3098 // the container service and cleans up runtime files.3209 // 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) {
3100 log.Info().Msg("shutting down Gordon...")3211 log.Info().Msg("shutting down Gordon...")
3101 3212
3102 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)3213 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -3112,6 +3223,12 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *
3112 }3223 }
3113 }3224 }
3114 3225
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+
3115 // Stop PKI maintenance goroutines3232 // Stop PKI maintenance goroutines
3116 if pkiSvc != nil {3233 if pkiSvc != nil {
3117 pkiSvc.Stop()3234 pkiSvc.Stop()
@@ -3234,31 +3351,170 @@ func matchingStaticCert(certs []tls.Certificate, serverName string) *tls.Certifi
3234 return matchingPreparedStaticCert(prepareStaticTLSCertificates(certs), serverName)3351 return matchingPreparedStaticCert(prepareStaticTLSCertificates(certs), serverName)
3235 }3352 }
3236 3353
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) {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) {
3238- // HTTP listener (Cloudflare proxy + onboarding for direct clients)3355+ var httpSrv *http.Server
3239- var httpProtos http.Protocols3356+ var httpReady <-chan struct{}
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- )
3250 3357
3251- if cfg.Server.TLSPort == 0 {3358+ needsTLS := hasTLSCapableEntrypoint(cfg)
3359+ if !hasSmartTCPEntrypoint(cfg) && !needsTLS {
3252 return httpSrv, httpReady, nil, nil, nil3360 return httpSrv, httpReady, nil, nil, nil
3253 }3361 }
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+}
3254 3511
3255- // Load static cert into a local slice (not tls.Config.Certificates) so the3512+func proxyTLSConfig(cfg Config, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, log zerowrap.Logger) (*tls.Config, error) {
3256- // certificate selector has full control over priority ordering.
3257 var staticCerts []tls.Certificate3513 var staticCerts []tls.Certificate
3258 if cfg.Server.TLSCertFile != "" {3514 if cfg.Server.TLSCertFile != "" {
3259 staticCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile)3515 staticCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile)
3260 if err != nil {3516 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)
3262 }3518 }
3263 staticCerts = []tls.Certificate{staticCert}3519 staticCerts = []tls.Certificate{staticCert}
3264 log.Info().3520 log.Info().
@@ -3266,30 +3522,81 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv
3266 Str("key", cfg.Server.TLSKeyFile).3522 Str("key", cfg.Server.TLSKeyFile).
3267 Msg("loaded static TLS certificate (public ACME and internal CA handle remaining domains)")3523 Msg("loaded static TLS certificate (public ACME and internal CA handle remaining domains)")
3268 }3524 }
3269-
3270 selector := &certificateSelector{3525 selector := &certificateSelector{
3271 staticCerts: prepareStaticTLSCertificates(staticCerts),3526 staticCerts: prepareStaticTLSCertificates(staticCerts),
3272 publicTLS: publicTLS,3527 publicTLS: publicTLS,
3273 localPKI: pkiSvc,3528 localPKI: pkiSvc,
3274 }3529 }
3275-3530+ return &tls.Config{
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{
3279 MinVersion: tls.VersionTLS12,3531 MinVersion: tls.VersionTLS12,
3280 GetCertificate: selector.GetCertificate,3532 GetCertificate: selector.GetCertificate,
3281 NextProtos: []string{"h2", "http/1.1"},3533 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
3282 }3540 }
3283- tlsSrv, tlsReady := startTLSServerWithConfig(3541+ next := smartTCPHTTPServerNames(cfg)
3284- fmt.Sprintf(":%d", cfg.Server.TLSPort),3542+ for name := range previous {
3285- httpsHandler,3543+ if _, ok := next[name]; !ok {
3286- "proxy-tls",3544+ manager.SetSmartTCPHTTPServer(name, nil, nil)
3287- tlsConfig,3545+ manager.SetSmartTCPTLSServer(name, nil, nil)
3288- errChan,3546+ }
3289- log,3547+ }
3290- )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+}
3291 3571
3292- return httpSrv, httpReady, tlsSrv, tlsReady, nil3572+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
3293 }3600 }
3294 3601
3295 // startServer starts an HTTP server, returning the server instance and a channel3602 // 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
3329 return server, ready3636 return server, ready
3330 }3637 }
3331 3638
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-
3365 // SendReloadSignal sends SIGUSR1 to the running Gordon process.3639 // SendReloadSignal sends SIGUSR1 to the running Gordon process.
3366 func SendReloadSignal() error {3640 func SendReloadSignal() error {
3367 process, _, err := findRunningProcess()3641 process, _, err := findRunningProcess()
@@ -3613,10 +3887,8 @@ func isProcessAlive(pid int) bool {
3613 3887
3614 // loadConfig loads configuration from file and sets defaults.3888 // loadConfig loads configuration from file and sets defaults.
3615 func loadConfig(v *viper.Viper, configPath string) error {3889 func loadConfig(v *viper.Viper, configPath string) error {
3616- v.SetDefault("server.port", 8088)
3617 v.SetDefault("server.registry_port", 5000)3890 v.SetDefault("server.registry_port", 5000)
3618 v.SetDefault("server.legacy_registry_domains", []string{})3891 v.SetDefault("server.legacy_registry_domains", []string{})
3619- v.SetDefault("server.tls_port", 8443)
3620 v.SetDefault("server.tls_cert_file", "")3892 v.SetDefault("server.tls_cert_file", "")
3621 v.SetDefault("server.tls_key_file", "")3893 v.SetDefault("server.tls_key_file", "")
3622 v.SetDefault("tls.acme.enabled", false)3894 v.SetDefault("tls.acme.enabled", false)
M internal/app/run_http_handlers_test.go +55 -2
@@ -21,7 +21,9 @@ import (
21 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"21 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
22 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"22 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
23 out "github.com/bnema/gordon/internal/boundaries/out"23 out "github.com/bnema/gordon/internal/boundaries/out"
24+ "github.com/bnema/gordon/internal/domain"
24 proxyusecase "github.com/bnema/gordon/internal/usecase/proxy"25 proxyusecase "github.com/bnema/gordon/internal/usecase/proxy"
26+ traffic "github.com/bnema/gordon/internal/usecase/traffic"
25 )27 )
26 28
27 func newNotFoundProxyService(t *testing.T) *proxyusecase.Service {29 func newNotFoundProxyService(t *testing.T) *proxyusecase.Service {
@@ -126,6 +128,9 @@ func newOnboardingConfig() Config {
126 cfg.Server.Port = 8088128 cfg.Server.Port = 8088
127 cfg.Server.TLSPort = 8443129 cfg.Server.TLSPort = 8443
128 cfg.Server.ProxyAllowedIPs = []string{"173.245.48.0/20"} // Cloudflare sample130 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+ }
129 return cfg134 return cfg
130 }135 }
131 136
@@ -240,6 +245,52 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *
240 assert.Contains(t, rec.Body.String(), "Trust CA Certificate")245 assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
241 }246 }
242 247
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+
243 func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailableOnGordonDomain(t *testing.T) {294 func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailableOnGordonDomain(t *testing.T) {
244 t.Parallel()295 t.Parallel()
245 ca := newTestCA(t)296 ca := newTestCA(t)
@@ -401,7 +452,7 @@ func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T)
401 t.Parallel()452 t.Parallel()
402 ca := newTestCA(t)453 ca := newTestCA(t)
403 cfg := newOnboardingConfig()454 cfg := newOnboardingConfig()
404- cfg.Server.TLSPort = 0 // TLS disabled455+ cfg.EntryPoints = nil // TLS disabled: no TLS-capable traffic entrypoint
405 svc := &services{caAdapter: ca}456 svc := &services{caAdapter: ca}
406 457
407 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)458 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
@@ -473,7 +524,9 @@ func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) {
473 log: zerowrap.Default(),524 log: zerowrap.Default(),
474 svc: &services{},525 svc: &services{},
475 }526 }
476- si.cfg.Server.TLSPort = 8443527+ si.cfg.EntryPoints = map[string]traffic.EntryPointConfig{
528+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
529+ }
477 dataDir := t.TempDir()530 dataDir := t.TempDir()
478 blockingPath := filepath.Join(dataDir, "not-a-directory")531 blockingPath := filepath.Join(dataDir, "not-a-directory")
479 require.NoError(t, os.WriteFile(blockingPath, []byte("blocking file"), 0600))532 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
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "net"
7+ "net/http"
8+ "os"
9+ "path/filepath"
10+ "strconv"
11+ "strings"
6 "sync"12 "sync"
7 "testing"13 "testing"
8 "time"14 "time"
@@ -10,13 +16,18 @@ import (
10 "github.com/bnema/zerowrap"16 "github.com/bnema/zerowrap"
11 "github.com/spf13/viper"17 "github.com/spf13/viper"
12 "github.com/stretchr/testify/assert"18 "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"20 "github.com/stretchr/testify/require"
14 21
15 "github.com/bnema/gordon/internal/adapters/in/http/registry"22 "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"
16 "github.com/bnema/gordon/internal/domain"25 "github.com/bnema/gordon/internal/domain"
17 cfgusecase "github.com/bnema/gordon/internal/usecase/config"26 cfgusecase "github.com/bnema/gordon/internal/usecase/config"
18 "github.com/bnema/gordon/internal/usecase/container"27 "github.com/bnema/gordon/internal/usecase/container"
19 "github.com/bnema/gordon/internal/usecase/proxy"28 "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"
20 )31 )
21 32
22 type watchRecorder struct {33 type watchRecorder struct {
@@ -127,6 +138,37 @@ func (r *containerConfigApplyRecorder) Apply(ctx context.Context, cfg Config) er
127 return nil138 return nil
128 }139 }
129 140
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+
130 type eventBusRecorder struct {172 type eventBusRecorder struct {
131 mu sync.Mutex173 mu sync.Mutex
132 calls int174 calls int
@@ -227,6 +269,57 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven
227 }, proxySvc.config)269 }, proxySvc.config)
228 }270 }
229 271
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+
230 func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(t *testing.T) {323 func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(t *testing.T) {
231 ctx := context.Background()324 ctx := context.Background()
232 v := viper.New()325 v := viper.New()
@@ -260,6 +353,114 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(
260 353
261 require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))354 require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
262 }355 }
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+
263 func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {464 func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {
264 ctx := context.Background()465 ctx := context.Background()
265 v := viper.New()466 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 {
73 // ExtendToken is a helper method to define mock.On call73 // ExtendToken is a helper method to define mock.On call
74 // - ctx context.Context74 // - ctx context.Context
75 // - tokenString string75 // - 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 {
77 return &MockAuthService_ExtendToken_Call{Call: _e.mock.On("ExtendToken", ctx, tokenString)}77 return &MockAuthService_ExtendToken_Call{Call: _e.mock.On("ExtendToken", ctx, tokenString)}
78 }78 }
79 79
@@ -141,7 +141,7 @@ type MockAuthService_GenerateAccessToken_Call struct {
141 // - subject string141 // - subject string
142 // - scopes []string142 // - scopes []string
143 // - expiry time.Duration143 // - 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 {
145 return &MockAuthService_GenerateAccessToken_Call{Call: _e.mock.On("GenerateAccessToken", ctx, subject, scopes, expiry)}145 return &MockAuthService_GenerateAccessToken_Call{Call: _e.mock.On("GenerateAccessToken", ctx, subject, scopes, expiry)}
146 }146 }
147 147
@@ -219,7 +219,7 @@ type MockAuthService_GenerateToken_Call struct {
219 // - subject string219 // - subject string
220 // - scopes []string220 // - scopes []string
221 // - expiry time.Duration221 // - 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 {
223 return &MockAuthService_GenerateToken_Call{Call: _e.mock.On("GenerateToken", ctx, subject, scopes, expiry)}223 return &MockAuthService_GenerateToken_Call{Call: _e.mock.On("GenerateToken", ctx, subject, scopes, expiry)}
224 }224 }
225 225
@@ -340,7 +340,7 @@ type MockAuthService_GetAuthStatus_Call struct {
340 340
341 // GetAuthStatus is a helper method to define mock.On call341 // GetAuthStatus is a helper method to define mock.On call
342 // - ctx context.Context342 // - 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 {
344 return &MockAuthService_GetAuthStatus_Call{Call: _e.mock.On("GetAuthStatus", ctx)}344 return &MockAuthService_GetAuthStatus_Call{Call: _e.mock.On("GetAuthStatus", ctx)}
345 }345 }
346 346
@@ -490,7 +490,7 @@ type MockAuthService_ListTokens_Call struct {
490 490
491 // ListTokens is a helper method to define mock.On call491 // ListTokens is a helper method to define mock.On call
492 // - ctx context.Context492 // - 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 {
494 return &MockAuthService_ListTokens_Call{Call: _e.mock.On("ListTokens", ctx)}494 return &MockAuthService_ListTokens_Call{Call: _e.mock.On("ListTokens", ctx)}
495 }495 }
496 496
@@ -550,7 +550,7 @@ type MockAuthService_RevokeAllTokens_Call struct {
550 550
551 // RevokeAllTokens is a helper method to define mock.On call551 // RevokeAllTokens is a helper method to define mock.On call
552 // - ctx context.Context552 // - 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 {
554 return &MockAuthService_RevokeAllTokens_Call{Call: _e.mock.On("RevokeAllTokens", ctx)}554 return &MockAuthService_RevokeAllTokens_Call{Call: _e.mock.On("RevokeAllTokens", ctx)}
555 }555 }
556 556
@@ -602,7 +602,7 @@ type MockAuthService_RevokeToken_Call struct {
602 // RevokeToken is a helper method to define mock.On call602 // RevokeToken is a helper method to define mock.On call
603 // - ctx context.Context603 // - ctx context.Context
604 // - tokenID string604 // - 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 {
606 return &MockAuthService_RevokeToken_Call{Call: _e.mock.On("RevokeToken", ctx, tokenID)}606 return &MockAuthService_RevokeToken_Call{Call: _e.mock.On("RevokeToken", ctx, tokenID)}
607 }607 }
608 608
@@ -670,7 +670,7 @@ type MockAuthService_ValidateToken_Call struct {
670 // ValidateToken is a helper method to define mock.On call670 // ValidateToken is a helper method to define mock.On call
671 // - ctx context.Context671 // - ctx context.Context
672 // - tokenString string672 // - 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 {
674 return &MockAuthService_ValidateToken_Call{Call: _e.mock.On("ValidateToken", ctx, tokenString)}674 return &MockAuthService_ValidateToken_Call{Call: _e.mock.On("ValidateToken", ctx, tokenString)}
675 }675 }
676 676
M internal/boundaries/in/mocks/mock_backup_service.go +6 -6
@@ -75,7 +75,7 @@ type MockBackupService_DetectDatabases_Call struct {
75 // DetectDatabases is a helper method to define mock.On call75 // DetectDatabases is a helper method to define mock.On call
76 // - ctx context.Context76 // - ctx context.Context
77 // - domainName string77 // - 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 {
79 return &MockBackupService_DetectDatabases_Call{Call: _e.mock.On("DetectDatabases", ctx, domainName)}79 return &MockBackupService_DetectDatabases_Call{Call: _e.mock.On("DetectDatabases", ctx, domainName)}
80 }80 }
81 81
@@ -143,7 +143,7 @@ type MockBackupService_ListBackups_Call struct {
143 // ListBackups is a helper method to define mock.On call143 // ListBackups is a helper method to define mock.On call
144 // - ctx context.Context144 // - ctx context.Context
145 // - domainName string145 // - 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 {
147 return &MockBackupService_ListBackups_Call{Call: _e.mock.On("ListBackups", ctx, domainName)}147 return &MockBackupService_ListBackups_Call{Call: _e.mock.On("ListBackups", ctx, domainName)}
148 }148 }
149 149
@@ -201,7 +201,7 @@ type MockBackupService_Restore_Call struct {
201 // - ctx context.Context201 // - ctx context.Context
202 // - domainName string202 // - domainName string
203 // - backupID string203 // - 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 {
205 return &MockBackupService_Restore_Call{Call: _e.mock.On("Restore", ctx, domainName, backupID)}205 return &MockBackupService_Restore_Call{Call: _e.mock.On("Restore", ctx, domainName, backupID)}
206 }206 }
207 207
@@ -264,7 +264,7 @@ type MockBackupService_RestorePITR_Call struct {
264 // - ctx context.Context264 // - ctx context.Context
265 // - domainName string265 // - domainName string
266 // - targetTime time.Time266 // - 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 {
268 return &MockBackupService_RestorePITR_Call{Call: _e.mock.On("RestorePITR", ctx, domainName, targetTime)}268 return &MockBackupService_RestorePITR_Call{Call: _e.mock.On("RestorePITR", ctx, domainName, targetTime)}
269 }269 }
270 270
@@ -338,7 +338,7 @@ type MockBackupService_RunBackup_Call struct {
338 // - ctx context.Context338 // - ctx context.Context
339 // - domainName string339 // - domainName string
340 // - dbName string340 // - 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 {
342 return &MockBackupService_RunBackup_Call{Call: _e.mock.On("RunBackup", ctx, domainName, dbName)}342 return &MockBackupService_RunBackup_Call{Call: _e.mock.On("RunBackup", ctx, domainName, dbName)}
343 }343 }
344 344
@@ -410,7 +410,7 @@ type MockBackupService_Status_Call struct {
410 410
411 // Status is a helper method to define mock.On call411 // Status is a helper method to define mock.On call
412 // - ctx context.Context412 // - 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 {
414 return &MockBackupService_Status_Call{Call: _e.mock.On("Status", ctx)}414 return &MockBackupService_Status_Call{Call: _e.mock.On("Status", ctx)}
415 }415 }
416 416
M internal/boundaries/in/mocks/mock_config_service.go +18 -18
@@ -64,7 +64,7 @@ type MockConfigService_AddAttachment_Call struct {
64 // - ctx context.Context64 // - ctx context.Context
65 // - domainOrGroup string65 // - domainOrGroup string
66 // - image string66 // - 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 {
68 return &MockConfigService_AddAttachment_Call{Call: _e.mock.On("AddAttachment", ctx, domainOrGroup, image)}68 return &MockConfigService_AddAttachment_Call{Call: _e.mock.On("AddAttachment", ctx, domainOrGroup, image)}
69 }69 }
70 70
@@ -126,7 +126,7 @@ type MockConfigService_AddAutoRouteAllowedDomain_Call struct {
126 // AddAutoRouteAllowedDomain is a helper method to define mock.On call126 // AddAutoRouteAllowedDomain is a helper method to define mock.On call
127 // - ctx context.Context127 // - ctx context.Context
128 // - pattern string128 // - 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 {
130 return &MockConfigService_AddAutoRouteAllowedDomain_Call{Call: _e.mock.On("AddAutoRouteAllowedDomain", ctx, pattern)}130 return &MockConfigService_AddAutoRouteAllowedDomain_Call{Call: _e.mock.On("AddAutoRouteAllowedDomain", ctx, pattern)}
131 }131 }
132 132
@@ -183,7 +183,7 @@ type MockConfigService_AddRoute_Call struct {
183 // AddRoute is a helper method to define mock.On call183 // AddRoute is a helper method to define mock.On call
184 // - ctx context.Context184 // - ctx context.Context
185 // - route domain.Route185 // - 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 {
187 return &MockConfigService_AddRoute_Call{Call: _e.mock.On("AddRoute", ctx, route)}187 return &MockConfigService_AddRoute_Call{Call: _e.mock.On("AddRoute", ctx, route)}
188 }188 }
189 189
@@ -242,7 +242,7 @@ type MockConfigService_FindAttachmentTargetsByImage_Call struct {
242 // FindAttachmentTargetsByImage is a helper method to define mock.On call242 // FindAttachmentTargetsByImage is a helper method to define mock.On call
243 // - ctx context.Context243 // - ctx context.Context
244 // - imageName string244 // - 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 {
246 return &MockConfigService_FindAttachmentTargetsByImage_Call{Call: _e.mock.On("FindAttachmentTargetsByImage", ctx, imageName)}246 return &MockConfigService_FindAttachmentTargetsByImage_Call{Call: _e.mock.On("FindAttachmentTargetsByImage", ctx, imageName)}
247 }247 }
248 248
@@ -301,7 +301,7 @@ type MockConfigService_FindRoutesByImage_Call struct {
301 // FindRoutesByImage is a helper method to define mock.On call301 // FindRoutesByImage is a helper method to define mock.On call
302 // - ctx context.Context302 // - ctx context.Context
303 // - imageName string303 // - 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 {
305 return &MockConfigService_FindRoutesByImage_Call{Call: _e.mock.On("FindRoutesByImage", ctx, imageName)}305 return &MockConfigService_FindRoutesByImage_Call{Call: _e.mock.On("FindRoutesByImage", ctx, imageName)}
306 }306 }
307 307
@@ -359,7 +359,7 @@ type MockConfigService_GetAllAttachments_Call struct {
359 359
360 // GetAllAttachments is a helper method to define mock.On call360 // GetAllAttachments is a helper method to define mock.On call
361 // - ctx context.Context361 // - 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 {
363 return &MockConfigService_GetAllAttachments_Call{Call: _e.mock.On("GetAllAttachments", ctx)}363 return &MockConfigService_GetAllAttachments_Call{Call: _e.mock.On("GetAllAttachments", ctx)}
364 }364 }
365 365
@@ -468,7 +468,7 @@ type MockConfigService_GetAttachmentsFor_Call struct {
468 // GetAttachmentsFor is a helper method to define mock.On call468 // GetAttachmentsFor is a helper method to define mock.On call
469 // - ctx context.Context469 // - ctx context.Context
470 // - domainOrGroup string470 // - 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 {
472 return &MockConfigService_GetAttachmentsFor_Call{Call: _e.mock.On("GetAttachmentsFor", ctx, domainOrGroup)}472 return &MockConfigService_GetAttachmentsFor_Call{Call: _e.mock.On("GetAttachmentsFor", ctx, domainOrGroup)}
473 }473 }
474 474
@@ -535,7 +535,7 @@ type MockConfigService_GetAutoRouteAllowedDomains_Call struct {
535 535
536 // GetAutoRouteAllowedDomains is a helper method to define mock.On call536 // GetAutoRouteAllowedDomains is a helper method to define mock.On call
537 // - ctx context.Context537 // - 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 {
539 return &MockConfigService_GetAutoRouteAllowedDomains_Call{Call: _e.mock.On("GetAutoRouteAllowedDomains", ctx)}539 return &MockConfigService_GetAutoRouteAllowedDomains_Call{Call: _e.mock.On("GetAutoRouteAllowedDomains", ctx)}
540 }540 }
541 541
@@ -910,7 +910,7 @@ type MockConfigService_GetRoute_Call struct {
910 // GetRoute is a helper method to define mock.On call910 // GetRoute is a helper method to define mock.On call
911 // - ctx context.Context911 // - ctx context.Context
912 // - domain1 string912 // - 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 {
914 return &MockConfigService_GetRoute_Call{Call: _e.mock.On("GetRoute", ctx, domain1)}914 return &MockConfigService_GetRoute_Call{Call: _e.mock.On("GetRoute", ctx, domain1)}
915 }915 }
916 916
@@ -968,7 +968,7 @@ type MockConfigService_GetRoutes_Call struct {
968 968
969 // GetRoutes is a helper method to define mock.On call969 // GetRoutes is a helper method to define mock.On call
970 // - ctx context.Context970 // - 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 {
972 return &MockConfigService_GetRoutes_Call{Call: _e.mock.On("GetRoutes", ctx)}972 return &MockConfigService_GetRoutes_Call{Call: _e.mock.On("GetRoutes", ctx)}
973 }973 }
974 974
@@ -1239,7 +1239,7 @@ type MockConfigService_Load_Call struct {
1239 1239
1240 // Load is a helper method to define mock.On call1240 // Load is a helper method to define mock.On call
1241 // - ctx context.Context1241 // - 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 {
1243 return &MockConfigService_Load_Call{Call: _e.mock.On("Load", ctx)}1243 return &MockConfigService_Load_Call{Call: _e.mock.On("Load", ctx)}
1244 }1244 }
1245 1245
@@ -1290,7 +1290,7 @@ type MockConfigService_Reload_Call struct {
1290 1290
1291 // Reload is a helper method to define mock.On call1291 // Reload is a helper method to define mock.On call
1292 // - ctx context.Context1292 // - 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 {
1294 return &MockConfigService_Reload_Call{Call: _e.mock.On("Reload", ctx)}1294 return &MockConfigService_Reload_Call{Call: _e.mock.On("Reload", ctx)}
1295 }1295 }
1296 1296
@@ -1343,7 +1343,7 @@ type MockConfigService_RemoveAttachment_Call struct {
1343 // - ctx context.Context1343 // - ctx context.Context
1344 // - domainOrGroup string1344 // - domainOrGroup string
1345 // - image string1345 // - 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 {
1347 return &MockConfigService_RemoveAttachment_Call{Call: _e.mock.On("RemoveAttachment", ctx, domainOrGroup, image)}1347 return &MockConfigService_RemoveAttachment_Call{Call: _e.mock.On("RemoveAttachment", ctx, domainOrGroup, image)}
1348 }1348 }
1349 1349
@@ -1405,7 +1405,7 @@ type MockConfigService_RemoveAutoRouteAllowedDomain_Call struct {
1405 // RemoveAutoRouteAllowedDomain is a helper method to define mock.On call1405 // RemoveAutoRouteAllowedDomain is a helper method to define mock.On call
1406 // - ctx context.Context1406 // - ctx context.Context
1407 // - pattern string1407 // - 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 {
1409 return &MockConfigService_RemoveAutoRouteAllowedDomain_Call{Call: _e.mock.On("RemoveAutoRouteAllowedDomain", ctx, pattern)}1409 return &MockConfigService_RemoveAutoRouteAllowedDomain_Call{Call: _e.mock.On("RemoveAutoRouteAllowedDomain", ctx, pattern)}
1410 }1410 }
1411 1411
@@ -1462,7 +1462,7 @@ type MockConfigService_RemoveRoute_Call struct {
1462 // RemoveRoute is a helper method to define mock.On call1462 // RemoveRoute is a helper method to define mock.On call
1463 // - ctx context.Context1463 // - ctx context.Context
1464 // - domain1 string1464 // - 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 {
1466 return &MockConfigService_RemoveRoute_Call{Call: _e.mock.On("RemoveRoute", ctx, domain1)}1466 return &MockConfigService_RemoveRoute_Call{Call: _e.mock.On("RemoveRoute", ctx, domain1)}
1467 }1467 }
1468 1468
@@ -1518,7 +1518,7 @@ type MockConfigService_Save_Call struct {
1518 1518
1519 // Save is a helper method to define mock.On call1519 // Save is a helper method to define mock.On call
1520 // - ctx context.Context1520 // - 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 {
1522 return &MockConfigService_Save_Call{Call: _e.mock.On("Save", ctx)}1522 return &MockConfigService_Save_Call{Call: _e.mock.On("Save", ctx)}
1523 }1523 }
1524 1524
@@ -1570,7 +1570,7 @@ type MockConfigService_UpdateRoute_Call struct {
1570 // UpdateRoute is a helper method to define mock.On call1570 // UpdateRoute is a helper method to define mock.On call
1571 // - ctx context.Context1571 // - ctx context.Context
1572 // - route domain.Route1572 // - 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 {
1574 return &MockConfigService_UpdateRoute_Call{Call: _e.mock.On("UpdateRoute", ctx, route)}1574 return &MockConfigService_UpdateRoute_Call{Call: _e.mock.On("UpdateRoute", ctx, route)}
1575 }1575 }
1576 1576
@@ -1627,7 +1627,7 @@ type MockConfigService_Watch_Call struct {
1627 // Watch is a helper method to define mock.On call1627 // Watch is a helper method to define mock.On call
1628 // - ctx context.Context1628 // - ctx context.Context
1629 // - onChange func()1629 // - 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 {
1631 return &MockConfigService_Watch_Call{Call: _e.mock.On("Watch", ctx, onChange)}1631 return &MockConfigService_Watch_Call{Call: _e.mock.On("Watch", ctx, onChange)}
1632 }1632 }
1633 1633
M internal/boundaries/in/mocks/mock_container_service.go +17 -17
@@ -63,7 +63,7 @@ type MockContainerService_AutoStart_Call struct {
63 // AutoStart is a helper method to define mock.On call63 // AutoStart is a helper method to define mock.On call
64 // - ctx context.Context64 // - ctx context.Context
65 // - routes []domain.Route65 // - 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 {
67 return &MockContainerService_AutoStart_Call{Call: _e.mock.On("AutoStart", ctx, routes)}67 return &MockContainerService_AutoStart_Call{Call: _e.mock.On("AutoStart", ctx, routes)}
68 }68 }
69 69
@@ -132,7 +132,7 @@ type MockContainerService_CleanupOrphanedAttachments_Call struct {
132 // - ctx context.Context132 // - ctx context.Context
133 // - owner string133 // - owner string
134 // - stop bool134 // - 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 {
136 return &MockContainerService_CleanupOrphanedAttachments_Call{Call: _e.mock.On("CleanupOrphanedAttachments", ctx, owner, stop)}136 return &MockContainerService_CleanupOrphanedAttachments_Call{Call: _e.mock.On("CleanupOrphanedAttachments", ctx, owner, stop)}
137 }137 }
138 138
@@ -205,7 +205,7 @@ type MockContainerService_Deploy_Call struct {
205 // Deploy is a helper method to define mock.On call205 // Deploy is a helper method to define mock.On call
206 // - ctx context.Context206 // - ctx context.Context
207 // - route domain.Route207 // - 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 {
209 return &MockContainerService_Deploy_Call{Call: _e.mock.On("Deploy", ctx, route)}209 return &MockContainerService_Deploy_Call{Call: _e.mock.On("Deploy", ctx, route)}
210 }210 }
211 211
@@ -273,7 +273,7 @@ type MockContainerService_Get_Call struct {
273 // Get is a helper method to define mock.On call273 // Get is a helper method to define mock.On call
274 // - ctx context.Context274 // - ctx context.Context
275 // - domain1 string275 // - 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 {
277 return &MockContainerService_Get_Call{Call: _e.mock.On("Get", ctx, domain1)}277 return &MockContainerService_Get_Call{Call: _e.mock.On("Get", ctx, domain1)}
278 }278 }
279 279
@@ -331,7 +331,7 @@ type MockContainerService_HealthCheck_Call struct {
331 331
332 // HealthCheck is a helper method to define mock.On call332 // HealthCheck is a helper method to define mock.On call
333 // - ctx context.Context333 // - 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 {
335 return &MockContainerService_HealthCheck_Call{Call: _e.mock.On("HealthCheck", ctx)}335 return &MockContainerService_HealthCheck_Call{Call: _e.mock.On("HealthCheck", ctx)}
336 }336 }
337 337
@@ -384,7 +384,7 @@ type MockContainerService_List_Call struct {
384 384
385 // List is a helper method to define mock.On call385 // List is a helper method to define mock.On call
386 // - ctx context.Context386 // - 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 {
388 return &MockContainerService_List_Call{Call: _e.mock.On("List", ctx)}388 return &MockContainerService_List_Call{Call: _e.mock.On("List", ctx)}
389 }389 }
390 390
@@ -438,7 +438,7 @@ type MockContainerService_ListAttachments_Call struct {
438 // ListAttachments is a helper method to define mock.On call438 // ListAttachments is a helper method to define mock.On call
439 // - ctx context.Context439 // - ctx context.Context
440 // - domain1 string440 // - 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 {
442 return &MockContainerService_ListAttachments_Call{Call: _e.mock.On("ListAttachments", ctx, domain1)}442 return &MockContainerService_ListAttachments_Call{Call: _e.mock.On("ListAttachments", ctx, domain1)}
443 }443 }
444 444
@@ -505,7 +505,7 @@ type MockContainerService_ListNetworks_Call struct {
505 505
506 // ListNetworks is a helper method to define mock.On call506 // ListNetworks is a helper method to define mock.On call
507 // - ctx context.Context507 // - 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 {
509 return &MockContainerService_ListNetworks_Call{Call: _e.mock.On("ListNetworks", ctx)}509 return &MockContainerService_ListNetworks_Call{Call: _e.mock.On("ListNetworks", ctx)}
510 }510 }
511 511
@@ -567,7 +567,7 @@ type MockContainerService_ListOrphanedAttachments_Call struct {
567 567
568 // ListOrphanedAttachments is a helper method to define mock.On call568 // ListOrphanedAttachments is a helper method to define mock.On call
569 // - ctx context.Context569 // - 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 {
571 return &MockContainerService_ListOrphanedAttachments_Call{Call: _e.mock.On("ListOrphanedAttachments", ctx)}571 return &MockContainerService_ListOrphanedAttachments_Call{Call: _e.mock.On("ListOrphanedAttachments", ctx)}
572 }572 }
573 573
@@ -620,7 +620,7 @@ type MockContainerService_ListRoutesWithDetails_Call struct {
620 620
621 // ListRoutesWithDetails is a helper method to define mock.On call621 // ListRoutesWithDetails is a helper method to define mock.On call
622 // - ctx context.Context622 // - 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 {
624 return &MockContainerService_ListRoutesWithDetails_Call{Call: _e.mock.On("ListRoutesWithDetails", ctx)}624 return &MockContainerService_ListRoutesWithDetails_Call{Call: _e.mock.On("ListRoutesWithDetails", ctx)}
625 }625 }
626 626
@@ -683,7 +683,7 @@ type MockContainerService_ReconcileRemovedRoute_Call struct {
683 // ReconcileRemovedRoute is a helper method to define mock.On call683 // ReconcileRemovedRoute is a helper method to define mock.On call
684 // - ctx context.Context684 // - ctx context.Context
685 // - domain1 string685 // - 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 {
687 return &MockContainerService_ReconcileRemovedRoute_Call{Call: _e.mock.On("ReconcileRemovedRoute", ctx, domain1)}687 return &MockContainerService_ReconcileRemovedRoute_Call{Call: _e.mock.On("ReconcileRemovedRoute", ctx, domain1)}
688 }688 }
689 689
@@ -741,7 +741,7 @@ type MockContainerService_Remove_Call struct {
741 // - ctx context.Context741 // - ctx context.Context
742 // - containerID string742 // - containerID string
743 // - force bool743 // - 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 {
745 return &MockContainerService_Remove_Call{Call: _e.mock.On("Remove", ctx, containerID, force)}745 return &MockContainerService_Remove_Call{Call: _e.mock.On("Remove", ctx, containerID, force)}
746 }746 }
747 747
@@ -804,7 +804,7 @@ type MockContainerService_Restart_Call struct {
804 // - ctx context.Context804 // - ctx context.Context
805 // - domain1 string805 // - domain1 string
806 // - withAttachments bool806 // - 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 {
808 return &MockContainerService_Restart_Call{Call: _e.mock.On("Restart", ctx, domain1, withAttachments)}808 return &MockContainerService_Restart_Call{Call: _e.mock.On("Restart", ctx, domain1, withAttachments)}
809 }809 }
810 810
@@ -865,7 +865,7 @@ type MockContainerService_Shutdown_Call struct {
865 865
866 // Shutdown is a helper method to define mock.On call866 // Shutdown is a helper method to define mock.On call
867 // - ctx context.Context867 // - 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 {
869 return &MockContainerService_Shutdown_Call{Call: _e.mock.On("Shutdown", ctx)}869 return &MockContainerService_Shutdown_Call{Call: _e.mock.On("Shutdown", ctx)}
870 }870 }
871 871
@@ -917,7 +917,7 @@ type MockContainerService_Stop_Call struct {
917 // Stop is a helper method to define mock.On call917 // Stop is a helper method to define mock.On call
918 // - ctx context.Context918 // - ctx context.Context
919 // - containerID string919 // - 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 {
921 return &MockContainerService_Stop_Call{Call: _e.mock.On("Stop", ctx, containerID)}921 return &MockContainerService_Stop_Call{Call: _e.mock.On("Stop", ctx, containerID)}
922 }922 }
923 923
@@ -973,7 +973,7 @@ type MockContainerService_SyncContainers_Call struct {
973 973
974 // SyncContainers is a helper method to define mock.On call974 // SyncContainers is a helper method to define mock.On call
975 // - ctx context.Context975 // - 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 {
977 return &MockContainerService_SyncContainers_Call{Call: _e.mock.On("SyncContainers", ctx)}977 return &MockContainerService_SyncContainers_Call{Call: _e.mock.On("SyncContainers", ctx)}
978 }978 }
979 979
@@ -1013,7 +1013,7 @@ type MockContainerService_UpdateAttachments_Call struct {
1013 1013
1014 // UpdateAttachments is a helper method to define mock.On call1014 // UpdateAttachments is a helper method to define mock.On call
1015 // - attachments map[string][]string1015 // - 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 {
1017 return &MockContainerService_UpdateAttachments_Call{Call: _e.mock.On("UpdateAttachments", attachments)}1017 return &MockContainerService_UpdateAttachments_Call{Call: _e.mock.On("UpdateAttachments", attachments)}
1018 }1018 }
1019 1019
M internal/boundaries/in/mocks/mock_health_service.go +2 -2
@@ -64,7 +64,7 @@ type MockHealthService_CheckAllRoutes_Call struct {
64 64
65 // CheckAllRoutes is a helper method to define mock.On call65 // CheckAllRoutes is a helper method to define mock.On call
66 // - ctx context.Context66 // - 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 {
68 return &MockHealthService_CheckAllRoutes_Call{Call: _e.mock.On("CheckAllRoutes", ctx)}68 return &MockHealthService_CheckAllRoutes_Call{Call: _e.mock.On("CheckAllRoutes", ctx)}
69 }69 }
70 70
@@ -118,7 +118,7 @@ type MockHealthService_CheckRoute_Call struct {
118 // CheckRoute is a helper method to define mock.On call118 // CheckRoute is a helper method to define mock.On call
119 // - ctx context.Context119 // - ctx context.Context
120 // - route domain.Route120 // - 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 {
122 return &MockHealthService_CheckRoute_Call{Call: _e.mock.On("CheckRoute", ctx, route)}122 return &MockHealthService_CheckRoute_Call{Call: _e.mock.On("CheckRoute", ctx, route)}
123 }123 }
124 124
M internal/boundaries/in/mocks/mock_http_prober.go +1 -1
@@ -77,7 +77,7 @@ type MockHTTPProber_Probe_Call struct {
77 // Probe is a helper method to define mock.On call77 // Probe is a helper method to define mock.On call
78 // - ctx context.Context78 // - ctx context.Context
79 // - url string79 // - 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 {
81 return &MockHTTPProber_Probe_Call{Call: _e.mock.On("Probe", ctx, url)}81 return &MockHTTPProber_Probe_Call{Call: _e.mock.On("Probe", ctx, url)}
82 }82 }
83 83
M internal/boundaries/in/mocks/mock_log_service.go +4 -4
@@ -74,7 +74,7 @@ type MockLogService_FollowContainerLogs_Call struct {
74 // - ctx context.Context74 // - ctx context.Context
75 // - domain string75 // - domain string
76 // - initialLines int76 // - 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 {
78 return &MockLogService_FollowContainerLogs_Call{Call: _e.mock.On("FollowContainerLogs", ctx, domain, initialLines)}78 return &MockLogService_FollowContainerLogs_Call{Call: _e.mock.On("FollowContainerLogs", ctx, domain, initialLines)}
79 }79 }
80 80
@@ -147,7 +147,7 @@ type MockLogService_FollowProcessLogs_Call struct {
147 // FollowProcessLogs is a helper method to define mock.On call147 // FollowProcessLogs is a helper method to define mock.On call
148 // - ctx context.Context148 // - ctx context.Context
149 // - initialLines int149 // - 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 {
151 return &MockLogService_FollowProcessLogs_Call{Call: _e.mock.On("FollowProcessLogs", ctx, initialLines)}151 return &MockLogService_FollowProcessLogs_Call{Call: _e.mock.On("FollowProcessLogs", ctx, initialLines)}
152 }152 }
153 153
@@ -216,7 +216,7 @@ type MockLogService_GetContainerLogs_Call struct {
216 // - ctx context.Context216 // - ctx context.Context
217 // - domain string217 // - domain string
218 // - lines int218 // - 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 {
220 return &MockLogService_GetContainerLogs_Call{Call: _e.mock.On("GetContainerLogs", ctx, domain, lines)}220 return &MockLogService_GetContainerLogs_Call{Call: _e.mock.On("GetContainerLogs", ctx, domain, lines)}
221 }221 }
222 222
@@ -289,7 +289,7 @@ type MockLogService_GetProcessLogs_Call struct {
289 // GetProcessLogs is a helper method to define mock.On call289 // GetProcessLogs is a helper method to define mock.On call
290 // - ctx context.Context290 // - ctx context.Context
291 // - lines int291 // - 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 {
293 return &MockLogService_GetProcessLogs_Call{Call: _e.mock.On("GetProcessLogs", ctx, lines)}293 return &MockLogService_GetProcessLogs_Call{Call: _e.mock.On("GetProcessLogs", ctx, lines)}
294 }294 }
295 295
M internal/boundaries/in/mocks/mock_proxy_service.go +7 -7
@@ -75,7 +75,7 @@ type MockProxyService_GetTarget_Call struct {
75 // GetTarget is a helper method to define mock.On call75 // GetTarget is a helper method to define mock.On call
76 // - ctx context.Context76 // - ctx context.Context
77 // - domain1 string77 // - 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 {
79 return &MockProxyService_GetTarget_Call{Call: _e.mock.On("GetTarget", ctx, domain1)}79 return &MockProxyService_GetTarget_Call{Call: _e.mock.On("GetTarget", ctx, domain1)}
80 }80 }
81 81
@@ -132,7 +132,7 @@ type MockProxyService_IsKnownHost_Call struct {
132 // IsKnownHost is a helper method to define mock.On call132 // IsKnownHost is a helper method to define mock.On call
133 // - ctx context.Context133 // - ctx context.Context
134 // - host string134 // - 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 {
136 return &MockProxyService_IsKnownHost_Call{Call: _e.mock.On("IsKnownHost", ctx, host)}136 return &MockProxyService_IsKnownHost_Call{Call: _e.mock.On("IsKnownHost", ctx, host)}
137 }137 }
138 138
@@ -188,7 +188,7 @@ type MockProxyService_IsRegistryDomain_Call struct {
188 188
189 // IsRegistryDomain is a helper method to define mock.On call189 // IsRegistryDomain is a helper method to define mock.On call
190 // - host string190 // - host string
191-func (_e *MockProxyService_Expecter) IsRegistryDomain(host interface{}) *MockProxyService_IsRegistryDomain_Call {191+func (_e *MockProxyService_Expecter) IsRegistryDomain(host any) *MockProxyService_IsRegistryDomain_Call {
192 return &MockProxyService_IsRegistryDomain_Call{Call: _e.mock.On("IsRegistryDomain", host)}192 return &MockProxyService_IsRegistryDomain_Call{Call: _e.mock.On("IsRegistryDomain", host)}
193 }193 }
194 194
@@ -283,7 +283,7 @@ type MockProxyService_RefreshTargets_Call struct {
283 283
284 // RefreshTargets is a helper method to define mock.On call284 // RefreshTargets is a helper method to define mock.On call
285 // - ctx context.Context285 // - 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 {
287 return &MockProxyService_RefreshTargets_Call{Call: _e.mock.On("RefreshTargets", ctx)}287 return &MockProxyService_RefreshTargets_Call{Call: _e.mock.On("RefreshTargets", ctx)}
288 }288 }
289 289
@@ -336,7 +336,7 @@ type MockProxyService_RegisterTarget_Call struct {
336 // - ctx context.Context336 // - ctx context.Context
337 // - domain1 string337 // - domain1 string
338 // - target *domain.ProxyTarget338 // - 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 {
340 return &MockProxyService_RegisterTarget_Call{Call: _e.mock.On("RegisterTarget", ctx, domain1, target)}340 return &MockProxyService_RegisterTarget_Call{Call: _e.mock.On("RegisterTarget", ctx, domain1, target)}
341 }341 }
342 342
@@ -432,7 +432,7 @@ type MockProxyService_TrackInFlight_Call struct {
432 432
433 // TrackInFlight is a helper method to define mock.On call433 // TrackInFlight is a helper method to define mock.On call
434 // - containerID string434 // - containerID string
435-func (_e *MockProxyService_Expecter) TrackInFlight(containerID interface{}) *MockProxyService_TrackInFlight_Call {435+func (_e *MockProxyService_Expecter) TrackInFlight(containerID any) *MockProxyService_TrackInFlight_Call {
436 return &MockProxyService_TrackInFlight_Call{Call: _e.mock.On("TrackInFlight", containerID)}436 return &MockProxyService_TrackInFlight_Call{Call: _e.mock.On("TrackInFlight", containerID)}
437 }437 }
438 438
@@ -517,7 +517,7 @@ type MockProxyService_UnregisterTarget_Call struct {
517 // UnregisterTarget is a helper method to define mock.On call517 // UnregisterTarget is a helper method to define mock.On call
518 // - ctx context.Context518 // - ctx context.Context
519 // - domain1 string519 // - 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 {
521 return &MockProxyService_UnregisterTarget_Call{Call: _e.mock.On("UnregisterTarget", ctx, domain1)}521 return &MockProxyService_UnregisterTarget_Call{Call: _e.mock.On("UnregisterTarget", ctx, domain1)}
522 }522 }
523 523
M internal/boundaries/in/mocks/mock_registry_service.go +13 -13
@@ -77,7 +77,7 @@ type MockRegistryService_AppendBlobChunk_Call struct {
77 // - data io.Reader77 // - data io.Reader
78 // - contentLength int6478 // - contentLength int64
79 // - maxBlobSize int6479 // - 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 {
81 return &MockRegistryService_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", ctx, name, uuid, data, contentLength, maxBlobSize)}81 return &MockRegistryService_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", ctx, name, uuid, data, contentLength, maxBlobSize)}
82 }82 }
83 83
@@ -154,7 +154,7 @@ type MockRegistryService_BlobExists_Call struct {
154 // BlobExists is a helper method to define mock.On call154 // BlobExists is a helper method to define mock.On call
155 // - ctx context.Context155 // - ctx context.Context
156 // - digest string156 // - 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 {
158 return &MockRegistryService_BlobExists_Call{Call: _e.mock.On("BlobExists", ctx, digest)}158 return &MockRegistryService_BlobExists_Call{Call: _e.mock.On("BlobExists", ctx, digest)}
159 }159 }
160 160
@@ -211,7 +211,7 @@ type MockRegistryService_CancelUpload_Call struct {
211 // CancelUpload is a helper method to define mock.On call211 // CancelUpload is a helper method to define mock.On call
212 // - ctx context.Context212 // - ctx context.Context
213 // - uuid string213 // - 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 {
215 return &MockRegistryService_CancelUpload_Call{Call: _e.mock.On("CancelUpload", ctx, uuid)}215 return &MockRegistryService_CancelUpload_Call{Call: _e.mock.On("CancelUpload", ctx, uuid)}
216 }216 }
217 217
@@ -269,7 +269,7 @@ type MockRegistryService_DeleteManifest_Call struct {
269 // - ctx context.Context269 // - ctx context.Context
270 // - name string270 // - name string
271 // - reference string271 // - 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 {
273 return &MockRegistryService_DeleteManifest_Call{Call: _e.mock.On("DeleteManifest", ctx, name, reference)}273 return &MockRegistryService_DeleteManifest_Call{Call: _e.mock.On("DeleteManifest", ctx, name, reference)}
274 }274 }
275 275
@@ -332,7 +332,7 @@ type MockRegistryService_FinishUpload_Call struct {
332 // - ctx context.Context332 // - ctx context.Context
333 // - uuid string333 // - uuid string
334 // - digest string334 // - 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 {
336 return &MockRegistryService_FinishUpload_Call{Call: _e.mock.On("FinishUpload", ctx, uuid, digest)}336 return &MockRegistryService_FinishUpload_Call{Call: _e.mock.On("FinishUpload", ctx, uuid, digest)}
337 }337 }
338 338
@@ -405,7 +405,7 @@ type MockRegistryService_GetBlob_Call struct {
405 // GetBlob is a helper method to define mock.On call405 // GetBlob is a helper method to define mock.On call
406 // - ctx context.Context406 // - ctx context.Context
407 // - digest string407 // - 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 {
409 return &MockRegistryService_GetBlob_Call{Call: _e.mock.On("GetBlob", ctx, digest)}409 return &MockRegistryService_GetBlob_Call{Call: _e.mock.On("GetBlob", ctx, digest)}
410 }410 }
411 411
@@ -471,7 +471,7 @@ type MockRegistryService_GetBlobPath_Call struct {
471 // GetBlobPath is a helper method to define mock.On call471 // GetBlobPath is a helper method to define mock.On call
472 // - ctx context.Context472 // - ctx context.Context
473 // - digest string473 // - 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 {
475 return &MockRegistryService_GetBlobPath_Call{Call: _e.mock.On("GetBlobPath", ctx, digest)}475 return &MockRegistryService_GetBlobPath_Call{Call: _e.mock.On("GetBlobPath", ctx, digest)}
476 }476 }
477 477
@@ -540,7 +540,7 @@ type MockRegistryService_GetManifest_Call struct {
540 // - ctx context.Context540 // - ctx context.Context
541 // - name string541 // - name string
542 // - reference string542 // - 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 {
544 return &MockRegistryService_GetManifest_Call{Call: _e.mock.On("GetManifest", ctx, name, reference)}544 return &MockRegistryService_GetManifest_Call{Call: _e.mock.On("GetManifest", ctx, name, reference)}
545 }545 }
546 546
@@ -612,7 +612,7 @@ type MockRegistryService_ListRepositories_Call struct {
612 612
613 // ListRepositories is a helper method to define mock.On call613 // ListRepositories is a helper method to define mock.On call
614 // - ctx context.Context614 // - 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 {
616 return &MockRegistryService_ListRepositories_Call{Call: _e.mock.On("ListRepositories", ctx)}616 return &MockRegistryService_ListRepositories_Call{Call: _e.mock.On("ListRepositories", ctx)}
617 }617 }
618 618
@@ -675,7 +675,7 @@ type MockRegistryService_ListTags_Call struct {
675 // ListTags is a helper method to define mock.On call675 // ListTags is a helper method to define mock.On call
676 // - ctx context.Context676 // - ctx context.Context
677 // - name string677 // - 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 {
679 return &MockRegistryService_ListTags_Call{Call: _e.mock.On("ListTags", ctx, name)}679 return &MockRegistryService_ListTags_Call{Call: _e.mock.On("ListTags", ctx, name)}
680 }680 }
681 681
@@ -734,7 +734,7 @@ type MockRegistryService_PutBlob_Call struct {
734 // - digest string734 // - digest string
735 // - data io.Reader735 // - data io.Reader
736 // - size int64736 // - 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 {
738 return &MockRegistryService_PutBlob_Call{Call: _e.mock.On("PutBlob", ctx, digest, data, size)}738 return &MockRegistryService_PutBlob_Call{Call: _e.mock.On("PutBlob", ctx, digest, data, size)}
739 }739 }
740 740
@@ -810,7 +810,7 @@ type MockRegistryService_PutManifest_Call struct {
810 // PutManifest is a helper method to define mock.On call810 // PutManifest is a helper method to define mock.On call
811 // - ctx context.Context811 // - ctx context.Context
812 // - manifest *domain.Manifest812 // - 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 {
814 return &MockRegistryService_PutManifest_Call{Call: _e.mock.On("PutManifest", ctx, manifest)}814 return &MockRegistryService_PutManifest_Call{Call: _e.mock.On("PutManifest", ctx, manifest)}
815 }815 }
816 816
@@ -876,7 +876,7 @@ type MockRegistryService_StartUpload_Call struct {
876 // StartUpload is a helper method to define mock.On call876 // StartUpload is a helper method to define mock.On call
877 // - ctx context.Context877 // - ctx context.Context
878 // - name string878 // - 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 {
880 return &MockRegistryService_StartUpload_Call{Call: _e.mock.On("StartUpload", ctx, name)}880 return &MockRegistryService_StartUpload_Call{Call: _e.mock.On("StartUpload", ctx, name)}
881 }881 }
882 882
M internal/boundaries/in/mocks/mock_secret_service.go +7 -7
@@ -64,7 +64,7 @@ type MockSecretService_Delete_Call struct {
64 // - ctx context.Context64 // - ctx context.Context
65 // - domain string65 // - domain string
66 // - key string66 // - 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 {
68 return &MockSecretService_Delete_Call{Call: _e.mock.On("Delete", ctx, domain, key)}68 return &MockSecretService_Delete_Call{Call: _e.mock.On("Delete", ctx, domain, key)}
69 }69 }
70 70
@@ -128,7 +128,7 @@ type MockSecretService_DeleteAttachment_Call struct {
128 // - domain string128 // - domain string
129 // - service string129 // - service string
130 // - key string130 // - 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 {
132 return &MockSecretService_DeleteAttachment_Call{Call: _e.mock.On("DeleteAttachment", ctx, domain, service, key)}132 return &MockSecretService_DeleteAttachment_Call{Call: _e.mock.On("DeleteAttachment", ctx, domain, service, key)}
133 }133 }
134 134
@@ -206,7 +206,7 @@ type MockSecretService_GetAll_Call struct {
206 // GetAll is a helper method to define mock.On call206 // GetAll is a helper method to define mock.On call
207 // - ctx context.Context207 // - ctx context.Context
208 // - domain string208 // - 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 {
210 return &MockSecretService_GetAll_Call{Call: _e.mock.On("GetAll", ctx, domain)}210 return &MockSecretService_GetAll_Call{Call: _e.mock.On("GetAll", ctx, domain)}
211 }211 }
212 212
@@ -274,7 +274,7 @@ type MockSecretService_ListKeys_Call struct {
274 // ListKeys is a helper method to define mock.On call274 // ListKeys is a helper method to define mock.On call
275 // - ctx context.Context275 // - ctx context.Context
276 // - domain string276 // - 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 {
278 return &MockSecretService_ListKeys_Call{Call: _e.mock.On("ListKeys", ctx, domain)}278 return &MockSecretService_ListKeys_Call{Call: _e.mock.On("ListKeys", ctx, domain)}
279 }279 }
280 280
@@ -350,7 +350,7 @@ type MockSecretService_ListKeysWithAttachments_Call struct {
350 // ListKeysWithAttachments is a helper method to define mock.On call350 // ListKeysWithAttachments is a helper method to define mock.On call
351 // - ctx context.Context351 // - ctx context.Context
352 // - domain string352 // - 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 {
354 return &MockSecretService_ListKeysWithAttachments_Call{Call: _e.mock.On("ListKeysWithAttachments", ctx, domain)}354 return &MockSecretService_ListKeysWithAttachments_Call{Call: _e.mock.On("ListKeysWithAttachments", ctx, domain)}
355 }355 }
356 356
@@ -408,7 +408,7 @@ type MockSecretService_Set_Call struct {
408 // - ctx context.Context408 // - ctx context.Context
409 // - domain string409 // - domain string
410 // - secrets map[string]string410 // - 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 {
412 return &MockSecretService_Set_Call{Call: _e.mock.On("Set", ctx, domain, secrets)}412 return &MockSecretService_Set_Call{Call: _e.mock.On("Set", ctx, domain, secrets)}
413 }413 }
414 414
@@ -472,7 +472,7 @@ type MockSecretService_SetAttachment_Call struct {
472 // - domain string472 // - domain string
473 // - service string473 // - service string
474 // - secrets map[string]string474 // - 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 {
476 return &MockSecretService_SetAttachment_Call{Call: _e.mock.On("SetAttachment", ctx, domain, service, secrets)}476 return &MockSecretService_SetAttachment_Call{Call: _e.mock.On("SetAttachment", ctx, domain, service, secrets)}
477 }477 }
478 478
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 {
74 // ListVolumeBackups is a helper method to define mock.On call74 // ListVolumeBackups is a helper method to define mock.On call
75 // - ctx context.Context75 // - ctx context.Context
76 // - domainName string76 // - 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 {
78 return &MockVolumeBackupService_ListVolumeBackups_Call{Call: _e.mock.On("ListVolumeBackups", ctx, domainName)}78 return &MockVolumeBackupService_ListVolumeBackups_Call{Call: _e.mock.On("ListVolumeBackups", ctx, domainName)}
79 }79 }
80 80
@@ -143,7 +143,7 @@ type MockVolumeBackupService_RunVolumeBackups_Call struct {
143 // - ctx context.Context143 // - ctx context.Context
144 // - domainName string144 // - domainName string
145 // - volumeName string145 // - 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 {
147 return &MockVolumeBackupService_RunVolumeBackups_Call{Call: _e.mock.On("RunVolumeBackups", ctx, domainName, volumeName)}147 return &MockVolumeBackupService_RunVolumeBackups_Call{Call: _e.mock.On("RunVolumeBackups", ctx, domainName, volumeName)}
148 }148 }
149 149
@@ -215,7 +215,7 @@ type MockVolumeBackupService_VolumeBackupStatus_Call struct {
215 215
216 // VolumeBackupStatus is a helper method to define mock.On call216 // VolumeBackupStatus is a helper method to define mock.On call
217 // - ctx context.Context217 // - 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 {
219 return &MockVolumeBackupService_VolumeBackupStatus_Call{Call: _e.mock.On("VolumeBackupStatus", ctx)}219 return &MockVolumeBackupService_VolumeBackupStatus_Call{Call: _e.mock.On("VolumeBackupStatus", ctx)}
220 }220 }
221 221
M internal/boundaries/in/mocks/mock_volume_service.go +2 -2
@@ -73,7 +73,7 @@ type MockVolumeService_ListVolumes_Call struct {
73 73
74 // ListVolumes is a helper method to define mock.On call74 // ListVolumes is a helper method to define mock.On call
75 // - ctx context.Context75 // - 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 {
77 return &MockVolumeService_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)}77 return &MockVolumeService_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)}
78 }78 }
79 79
@@ -144,7 +144,7 @@ type MockVolumeService_PruneVolumes_Call struct {
144 // PruneVolumes is a helper method to define mock.On call144 // PruneVolumes is a helper method to define mock.On call
145 // - ctx context.Context145 // - ctx context.Context
146 // - dryRun bool146 // - 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 {
148 return &MockVolumeService_PruneVolumes_Call{Call: _e.mock.On("PruneVolumes", ctx, dryRun)}148 return &MockVolumeService_PruneVolumes_Call{Call: _e.mock.On("PruneVolumes", ctx, dryRun)}
149 }149 }
150 150
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 {
75 // - ctx context.Context75 // - ctx context.Context
76 // - domainName string76 // - domainName string
77 // - policy domain.DatabaseBackupRetentionPolicy77 // - 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 {
79 return &MockBackupStorage_ApplyRetention_Call{Call: _e.mock.On("ApplyRetention", ctx, domainName, policy)}79 return &MockBackupStorage_ApplyRetention_Call{Call: _e.mock.On("ApplyRetention", ctx, domainName, policy)}
80 }80 }
81 81
@@ -137,7 +137,7 @@ type MockBackupStorage_Delete_Call struct {
137 // Delete is a helper method to define mock.On call137 // Delete is a helper method to define mock.On call
138 // - ctx context.Context138 // - ctx context.Context
139 // - path string139 // - 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 {
141 return &MockBackupStorage_Delete_Call{Call: _e.mock.On("Delete", ctx, path)}141 return &MockBackupStorage_Delete_Call{Call: _e.mock.On("Delete", ctx, path)}
142 }142 }
143 143
@@ -205,7 +205,7 @@ type MockBackupStorage_Get_Call struct {
205 // Get is a helper method to define mock.On call205 // Get is a helper method to define mock.On call
206 // - ctx context.Context206 // - ctx context.Context
207 // - path string207 // - 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 {
209 return &MockBackupStorage_Get_Call{Call: _e.mock.On("Get", ctx, path)}209 return &MockBackupStorage_Get_Call{Call: _e.mock.On("Get", ctx, path)}
210 }210 }
211 211
@@ -274,7 +274,7 @@ type MockBackupStorage_List_Call struct {
274 // - ctx context.Context274 // - ctx context.Context
275 // - domainName string275 // - domainName string
276 // - schedule *domain.BackupSchedule276 // - 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 {
278 return &MockBackupStorage_List_Call{Call: _e.mock.On("List", ctx, domainName, schedule)}278 return &MockBackupStorage_List_Call{Call: _e.mock.On("List", ctx, domainName, schedule)}
279 }279 }
280 280
@@ -349,7 +349,7 @@ type MockBackupStorage_Store_Call struct {
349 // - schedule domain.BackupSchedule349 // - schedule domain.BackupSchedule
350 // - timestamp time.Time350 // - timestamp time.Time
351 // - data io.Reader351 // - 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 {
353 return &MockBackupStorage_Store_Call{Call: _e.mock.On("Store", ctx, domainName, dbName, schedule, timestamp, data)}353 return &MockBackupStorage_Store_Call{Call: _e.mock.On("Store", ctx, domainName, dbName, schedule, timestamp, data)}
354 }354 }
355 355
M internal/boundaries/out/mocks/mock_blob_storage.go +11 -11
@@ -75,7 +75,7 @@ type MockBlobStorage_AppendBlobChunk_Call struct {
75 // - data io.Reader75 // - data io.Reader
76 // - contentLength int6476 // - contentLength int64
77 // - maxBlobSize int6477 // - 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 {
79 return &MockBlobStorage_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", name, uuid, data, contentLength, maxBlobSize)}79 return &MockBlobStorage_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", name, uuid, data, contentLength, maxBlobSize)}
80 }80 }
81 81
@@ -146,7 +146,7 @@ type MockBlobStorage_BlobExists_Call struct {
146 146
147 // BlobExists is a helper method to define mock.On call147 // BlobExists is a helper method to define mock.On call
148 // - digest string148 // - digest string
149-func (_e *MockBlobStorage_Expecter) BlobExists(digest interface{}) *MockBlobStorage_BlobExists_Call {149+func (_e *MockBlobStorage_Expecter) BlobExists(digest any) *MockBlobStorage_BlobExists_Call {
150 return &MockBlobStorage_BlobExists_Call{Call: _e.mock.On("BlobExists", digest)}150 return &MockBlobStorage_BlobExists_Call{Call: _e.mock.On("BlobExists", digest)}
151 }151 }
152 152
@@ -197,7 +197,7 @@ type MockBlobStorage_CancelBlobUpload_Call struct {
197 197
198 // CancelBlobUpload is a helper method to define mock.On call198 // CancelBlobUpload is a helper method to define mock.On call
199 // - uuid string199 // - uuid string
200-func (_e *MockBlobStorage_Expecter) CancelBlobUpload(uuid interface{}) *MockBlobStorage_CancelBlobUpload_Call {200+func (_e *MockBlobStorage_Expecter) CancelBlobUpload(uuid any) *MockBlobStorage_CancelBlobUpload_Call {
201 return &MockBlobStorage_CancelBlobUpload_Call{Call: _e.mock.On("CancelBlobUpload", uuid)}201 return &MockBlobStorage_CancelBlobUpload_Call{Call: _e.mock.On("CancelBlobUpload", uuid)}
202 }202 }
203 203
@@ -263,7 +263,7 @@ type MockBlobStorage_CleanupStaleUploads_Call struct {
263 263
264 // CleanupStaleUploads is a helper method to define mock.On call264 // CleanupStaleUploads is a helper method to define mock.On call
265 // - maxAge time.Duration265 // - 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 {
267 return &MockBlobStorage_CleanupStaleUploads_Call{Call: _e.mock.On("CleanupStaleUploads", maxAge)}267 return &MockBlobStorage_CleanupStaleUploads_Call{Call: _e.mock.On("CleanupStaleUploads", maxAge)}
268 }268 }
269 269
@@ -323,7 +323,7 @@ type MockBlobStorage_DeleteBlob_Call struct {
323 323
324 // DeleteBlob is a helper method to define mock.On call324 // DeleteBlob is a helper method to define mock.On call
325 // - digest string325 // - digest string
326-func (_e *MockBlobStorage_Expecter) DeleteBlob(digest interface{}) *MockBlobStorage_DeleteBlob_Call {326+func (_e *MockBlobStorage_Expecter) DeleteBlob(digest any) *MockBlobStorage_DeleteBlob_Call {
327 return &MockBlobStorage_DeleteBlob_Call{Call: _e.mock.On("DeleteBlob", digest)}327 return &MockBlobStorage_DeleteBlob_Call{Call: _e.mock.On("DeleteBlob", digest)}
328 }328 }
329 329
@@ -375,7 +375,7 @@ type MockBlobStorage_FinishBlobUpload_Call struct {
375 // FinishBlobUpload is a helper method to define mock.On call375 // FinishBlobUpload is a helper method to define mock.On call
376 // - uuid string376 // - uuid string
377 // - digest string377 // - 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 {
379 return &MockBlobStorage_FinishBlobUpload_Call{Call: _e.mock.On("FinishBlobUpload", uuid, digest)}379 return &MockBlobStorage_FinishBlobUpload_Call{Call: _e.mock.On("FinishBlobUpload", uuid, digest)}
380 }380 }
381 381
@@ -442,7 +442,7 @@ type MockBlobStorage_GetBlob_Call struct {
442 442
443 // GetBlob is a helper method to define mock.On call443 // GetBlob is a helper method to define mock.On call
444 // - digest string444 // - digest string
445-func (_e *MockBlobStorage_Expecter) GetBlob(digest interface{}) *MockBlobStorage_GetBlob_Call {445+func (_e *MockBlobStorage_Expecter) GetBlob(digest any) *MockBlobStorage_GetBlob_Call {
446 return &MockBlobStorage_GetBlob_Call{Call: _e.mock.On("GetBlob", digest)}446 return &MockBlobStorage_GetBlob_Call{Call: _e.mock.On("GetBlob", digest)}
447 }447 }
448 448
@@ -502,7 +502,7 @@ type MockBlobStorage_GetBlobPath_Call struct {
502 502
503 // GetBlobPath is a helper method to define mock.On call503 // GetBlobPath is a helper method to define mock.On call
504 // - digest string504 // - digest string
505-func (_e *MockBlobStorage_Expecter) GetBlobPath(digest interface{}) *MockBlobStorage_GetBlobPath_Call {505+func (_e *MockBlobStorage_Expecter) GetBlobPath(digest any) *MockBlobStorage_GetBlobPath_Call {
506 return &MockBlobStorage_GetBlobPath_Call{Call: _e.mock.On("GetBlobPath", digest)}506 return &MockBlobStorage_GetBlobPath_Call{Call: _e.mock.On("GetBlobPath", digest)}
507 }507 }
508 508
@@ -564,7 +564,7 @@ type MockBlobStorage_GetBlobUpload_Call struct {
564 564
565 // GetBlobUpload is a helper method to define mock.On call565 // GetBlobUpload is a helper method to define mock.On call
566 // - uuid string566 // - uuid string
567-func (_e *MockBlobStorage_Expecter) GetBlobUpload(uuid interface{}) *MockBlobStorage_GetBlobUpload_Call {567+func (_e *MockBlobStorage_Expecter) GetBlobUpload(uuid any) *MockBlobStorage_GetBlobUpload_Call {
568 return &MockBlobStorage_GetBlobUpload_Call{Call: _e.mock.On("GetBlobUpload", uuid)}568 return &MockBlobStorage_GetBlobUpload_Call{Call: _e.mock.On("GetBlobUpload", uuid)}
569 }569 }
570 570
@@ -672,7 +672,7 @@ type MockBlobStorage_PutBlob_Call struct {
672 // - digest string672 // - digest string
673 // - data io.Reader673 // - data io.Reader
674 // - size int64674 // - 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 {
676 return &MockBlobStorage_PutBlob_Call{Call: _e.mock.On("PutBlob", digest, data, size)}676 return &MockBlobStorage_PutBlob_Call{Call: _e.mock.On("PutBlob", digest, data, size)}
677 }677 }
678 678
@@ -742,7 +742,7 @@ type MockBlobStorage_StartBlobUpload_Call struct {
742 742
743 // StartBlobUpload is a helper method to define mock.On call743 // StartBlobUpload is a helper method to define mock.On call
744 // - name string744 // - name string
745-func (_e *MockBlobStorage_Expecter) StartBlobUpload(name interface{}) *MockBlobStorage_StartBlobUpload_Call {745+func (_e *MockBlobStorage_Expecter) StartBlobUpload(name any) *MockBlobStorage_StartBlobUpload_Call {
746 return &MockBlobStorage_StartBlobUpload_Call{Call: _e.mock.On("StartBlobUpload", name)}746 return &MockBlobStorage_StartBlobUpload_Call{Call: _e.mock.On("StartBlobUpload", name)}
747 }747 }
748 748
M internal/boundaries/out/mocks/mock_certificate_store.go +7 -7
@@ -73,7 +73,7 @@ type MockCertificateStore_LoadAccount_Call struct {
73 73
74 // LoadAccount is a helper method to define mock.On call74 // LoadAccount is a helper method to define mock.On call
75 // - ctx context.Context75 // - 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 {
77 return &MockCertificateStore_LoadAccount_Call{Call: _e.mock.On("LoadAccount", ctx)}77 return &MockCertificateStore_LoadAccount_Call{Call: _e.mock.On("LoadAccount", ctx)}
78 }78 }
79 79
@@ -135,7 +135,7 @@ type MockCertificateStore_LoadAll_Call struct {
135 135
136 // LoadAll is a helper method to define mock.On call136 // LoadAll is a helper method to define mock.On call
137 // - ctx context.Context137 // - 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 {
139 return &MockCertificateStore_LoadAll_Call{Call: _e.mock.On("LoadAll", ctx)}139 return &MockCertificateStore_LoadAll_Call{Call: _e.mock.On("LoadAll", ctx)}
140 }140 }
141 141
@@ -195,7 +195,7 @@ type MockCertificateStore_LoadState_Call struct {
195 195
196 // LoadState is a helper method to define mock.On call196 // LoadState is a helper method to define mock.On call
197 // - ctx context.Context197 // - 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 {
199 return &MockCertificateStore_LoadState_Call{Call: _e.mock.On("LoadState", ctx)}199 return &MockCertificateStore_LoadState_Call{Call: _e.mock.On("LoadState", ctx)}
200 }200 }
201 201
@@ -257,7 +257,7 @@ type MockCertificateStore_Lock_Call struct {
257 257
258 // Lock is a helper method to define mock.On call258 // Lock is a helper method to define mock.On call
259 // - ctx context.Context259 // - 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 {
261 return &MockCertificateStore_Lock_Call{Call: _e.mock.On("Lock", ctx)}261 return &MockCertificateStore_Lock_Call{Call: _e.mock.On("Lock", ctx)}
262 }262 }
263 263
@@ -309,7 +309,7 @@ type MockCertificateStore_Save_Call struct {
309 // Save is a helper method to define mock.On call309 // Save is a helper method to define mock.On call
310 // - ctx context.Context310 // - ctx context.Context
311 // - cert out.StoredCertificate311 // - 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 {
313 return &MockCertificateStore_Save_Call{Call: _e.mock.On("Save", ctx, cert)}313 return &MockCertificateStore_Save_Call{Call: _e.mock.On("Save", ctx, cert)}
314 }314 }
315 315
@@ -366,7 +366,7 @@ type MockCertificateStore_SaveAccount_Call struct {
366 // SaveAccount is a helper method to define mock.On call366 // SaveAccount is a helper method to define mock.On call
367 // - ctx context.Context367 // - ctx context.Context
368 // - account out.ACMEAccount368 // - 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 {
370 return &MockCertificateStore_SaveAccount_Call{Call: _e.mock.On("SaveAccount", ctx, account)}370 return &MockCertificateStore_SaveAccount_Call{Call: _e.mock.On("SaveAccount", ctx, account)}
371 }371 }
372 372
@@ -423,7 +423,7 @@ type MockCertificateStore_SaveState_Call struct {
423 // SaveState is a helper method to define mock.On call423 // SaveState is a helper method to define mock.On call
424 // - ctx context.Context424 // - ctx context.Context
425 // - state out.CertificateStoreState425 // - 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 {
427 return &MockCertificateStore_SaveState_Call{Call: _e.mock.On("SaveState", ctx, state)}427 return &MockCertificateStore_SaveState_Call{Call: _e.mock.On("SaveState", ctx, state)}
428 }428 }
429 429
M internal/boundaries/out/mocks/mock_container_log_writer.go +2 -2
@@ -109,7 +109,7 @@ type MockContainerLogWriter_StartLogging_Call struct {
109 // - containerID string109 // - containerID string
110 // - domain string110 // - domain string
111 // - logStream io.ReadCloser111 // - 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 {
113 return &MockContainerLogWriter_StartLogging_Call{Call: _e.mock.On("StartLogging", ctx, containerID, domain, logStream)}113 return &MockContainerLogWriter_StartLogging_Call{Call: _e.mock.On("StartLogging", ctx, containerID, domain, logStream)}
114 }114 }
115 115
@@ -175,7 +175,7 @@ type MockContainerLogWriter_StopLogging_Call struct {
175 175
176 // StopLogging is a helper method to define mock.On call176 // StopLogging is a helper method to define mock.On call
177 // - containerID string177 // - containerID string
178-func (_e *MockContainerLogWriter_Expecter) StopLogging(containerID interface{}) *MockContainerLogWriter_StopLogging_Call {178+func (_e *MockContainerLogWriter_Expecter) StopLogging(containerID any) *MockContainerLogWriter_StopLogging_Call {
179 return &MockContainerLogWriter_StopLogging_Call{Call: _e.mock.On("StopLogging", containerID)}179 return &MockContainerLogWriter_StopLogging_Call{Call: _e.mock.On("StopLogging", containerID)}
180 }180 }
181 181
M internal/boundaries/out/mocks/mock_container_runtime.go +40 -40
@@ -66,7 +66,7 @@ type MockContainerRuntime_ConnectContainerToNetwork_Call struct {
66 // - ctx context.Context66 // - ctx context.Context
67 // - containerName string67 // - containerName string
68 // - networkName string68 // - 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 {
70 return &MockContainerRuntime_ConnectContainerToNetwork_Call{Call: _e.mock.On("ConnectContainerToNetwork", ctx, containerName, networkName)}70 return &MockContainerRuntime_ConnectContainerToNetwork_Call{Call: _e.mock.On("ConnectContainerToNetwork", ctx, containerName, networkName)}
71 }71 }
72 72
@@ -140,7 +140,7 @@ type MockContainerRuntime_CopyFromContainer_Call struct {
140 // - ctx context.Context140 // - ctx context.Context
141 // - containerID string141 // - containerID string
142 // - srcPath string142 // - 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 {
144 return &MockContainerRuntime_CopyFromContainer_Call{Call: _e.mock.On("CopyFromContainer", ctx, containerID, srcPath)}144 return &MockContainerRuntime_CopyFromContainer_Call{Call: _e.mock.On("CopyFromContainer", ctx, containerID, srcPath)}
145 }145 }
146 146
@@ -213,7 +213,7 @@ type MockContainerRuntime_CreateContainer_Call struct {
213 // CreateContainer is a helper method to define mock.On call213 // CreateContainer is a helper method to define mock.On call
214 // - ctx context.Context214 // - ctx context.Context
215 // - config *domain.ContainerConfig215 // - 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 {
217 return &MockContainerRuntime_CreateContainer_Call{Call: _e.mock.On("CreateContainer", ctx, config)}217 return &MockContainerRuntime_CreateContainer_Call{Call: _e.mock.On("CreateContainer", ctx, config)}
218 }218 }
219 219
@@ -271,7 +271,7 @@ type MockContainerRuntime_CreateNetwork_Call struct {
271 // - ctx context.Context271 // - ctx context.Context
272 // - name string272 // - name string
273 // - config domain.NetworkConfig273 // - 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 {
275 return &MockContainerRuntime_CreateNetwork_Call{Call: _e.mock.On("CreateNetwork", ctx, name, config)}275 return &MockContainerRuntime_CreateNetwork_Call{Call: _e.mock.On("CreateNetwork", ctx, name, config)}
276 }276 }
277 277
@@ -333,7 +333,7 @@ type MockContainerRuntime_CreateVolume_Call struct {
333 // CreateVolume is a helper method to define mock.On call333 // CreateVolume is a helper method to define mock.On call
334 // - ctx context.Context334 // - ctx context.Context
335 // - volumeName string335 // - 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 {
337 return &MockContainerRuntime_CreateVolume_Call{Call: _e.mock.On("CreateVolume", ctx, volumeName)}337 return &MockContainerRuntime_CreateVolume_Call{Call: _e.mock.On("CreateVolume", ctx, volumeName)}
338 }338 }
339 339
@@ -391,7 +391,7 @@ type MockContainerRuntime_DisconnectContainerFromNetwork_Call struct {
391 // - ctx context.Context391 // - ctx context.Context
392 // - containerName string392 // - containerName string
393 // - networkName string393 // - 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 {
395 return &MockContainerRuntime_DisconnectContainerFromNetwork_Call{Call: _e.mock.On("DisconnectContainerFromNetwork", ctx, containerName, networkName)}395 return &MockContainerRuntime_DisconnectContainerFromNetwork_Call{Call: _e.mock.On("DisconnectContainerFromNetwork", ctx, containerName, networkName)}
396 }396 }
397 397
@@ -465,7 +465,7 @@ type MockContainerRuntime_ExecInContainer_Call struct {
465 // - ctx context.Context465 // - ctx context.Context
466 // - containerID string466 // - containerID string
467 // - cmd []string467 // - 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 {
469 return &MockContainerRuntime_ExecInContainer_Call{Call: _e.mock.On("ExecInContainer", ctx, containerID, cmd)}469 return &MockContainerRuntime_ExecInContainer_Call{Call: _e.mock.On("ExecInContainer", ctx, containerID, cmd)}
470 }470 }
471 471
@@ -538,7 +538,7 @@ type MockContainerRuntime_GetContainerExposedPorts_Call struct {
538 // GetContainerExposedPorts is a helper method to define mock.On call538 // GetContainerExposedPorts is a helper method to define mock.On call
539 // - ctx context.Context539 // - ctx context.Context
540 // - containerID string540 // - 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 {
542 return &MockContainerRuntime_GetContainerExposedPorts_Call{Call: _e.mock.On("GetContainerExposedPorts", ctx, containerID)}542 return &MockContainerRuntime_GetContainerExposedPorts_Call{Call: _e.mock.On("GetContainerExposedPorts", ctx, containerID)}
543 }543 }
544 544
@@ -610,7 +610,7 @@ type MockContainerRuntime_GetContainerHealthStatus_Call struct {
610 // GetContainerHealthStatus is a helper method to define mock.On call610 // GetContainerHealthStatus is a helper method to define mock.On call
611 // - ctx context.Context611 // - ctx context.Context
612 // - containerID string612 // - 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 {
614 return &MockContainerRuntime_GetContainerHealthStatus_Call{Call: _e.mock.On("GetContainerHealthStatus", ctx, containerID)}614 return &MockContainerRuntime_GetContainerHealthStatus_Call{Call: _e.mock.On("GetContainerHealthStatus", ctx, containerID)}
615 }615 }
616 616
@@ -679,7 +679,7 @@ type MockContainerRuntime_GetContainerLogs_Call struct {
679 // - ctx context.Context679 // - ctx context.Context
680 // - containerID string680 // - containerID string
681 // - follow bool681 // - 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 {
683 return &MockContainerRuntime_GetContainerLogs_Call{Call: _e.mock.On("GetContainerLogs", ctx, containerID, follow)}683 return &MockContainerRuntime_GetContainerLogs_Call{Call: _e.mock.On("GetContainerLogs", ctx, containerID, follow)}
684 }684 }
685 685
@@ -750,7 +750,7 @@ type MockContainerRuntime_GetContainerNetwork_Call struct {
750 // GetContainerNetwork is a helper method to define mock.On call750 // GetContainerNetwork is a helper method to define mock.On call
751 // - ctx context.Context751 // - ctx context.Context
752 // - containerID string752 // - 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 {
754 return &MockContainerRuntime_GetContainerNetwork_Call{Call: _e.mock.On("GetContainerNetwork", ctx, containerID)}754 return &MockContainerRuntime_GetContainerNetwork_Call{Call: _e.mock.On("GetContainerNetwork", ctx, containerID)}
755 }755 }
756 756
@@ -822,7 +822,7 @@ type MockContainerRuntime_GetContainerNetworkInfo_Call struct {
822 // GetContainerNetworkInfo is a helper method to define mock.On call822 // GetContainerNetworkInfo is a helper method to define mock.On call
823 // - ctx context.Context823 // - ctx context.Context
824 // - containerID string824 // - 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 {
826 return &MockContainerRuntime_GetContainerNetworkInfo_Call{Call: _e.mock.On("GetContainerNetworkInfo", ctx, containerID)}826 return &MockContainerRuntime_GetContainerNetworkInfo_Call{Call: _e.mock.On("GetContainerNetworkInfo", ctx, containerID)}
827 }827 }
828 828
@@ -889,7 +889,7 @@ type MockContainerRuntime_GetContainerPort_Call struct {
889 // - ctx context.Context889 // - ctx context.Context
890 // - containerID string890 // - containerID string
891 // - internalPort int891 // - 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 {
893 return &MockContainerRuntime_GetContainerPort_Call{Call: _e.mock.On("GetContainerPort", ctx, containerID, internalPort)}893 return &MockContainerRuntime_GetContainerPort_Call{Call: _e.mock.On("GetContainerPort", ctx, containerID, internalPort)}
894 }894 }
895 895
@@ -962,7 +962,7 @@ type MockContainerRuntime_GetImageExposedPorts_Call struct {
962 // GetImageExposedPorts is a helper method to define mock.On call962 // GetImageExposedPorts is a helper method to define mock.On call
963 // - ctx context.Context963 // - ctx context.Context
964 // - imageRef string964 // - 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 {
966 return &MockContainerRuntime_GetImageExposedPorts_Call{Call: _e.mock.On("GetImageExposedPorts", ctx, imageRef)}966 return &MockContainerRuntime_GetImageExposedPorts_Call{Call: _e.mock.On("GetImageExposedPorts", ctx, imageRef)}
967 }967 }
968 968
@@ -1028,7 +1028,7 @@ type MockContainerRuntime_GetImageID_Call struct {
1028 // GetImageID is a helper method to define mock.On call1028 // GetImageID is a helper method to define mock.On call
1029 // - ctx context.Context1029 // - ctx context.Context
1030 // - imageRef string1030 // - 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 {
1032 return &MockContainerRuntime_GetImageID_Call{Call: _e.mock.On("GetImageID", ctx, imageRef)}1032 return &MockContainerRuntime_GetImageID_Call{Call: _e.mock.On("GetImageID", ctx, imageRef)}
1033 }1033 }
1034 1034
@@ -1096,7 +1096,7 @@ type MockContainerRuntime_GetImageLabels_Call struct {
1096 // GetImageLabels is a helper method to define mock.On call1096 // GetImageLabels is a helper method to define mock.On call
1097 // - ctx context.Context1097 // - ctx context.Context
1098 // - imageRef string1098 // - 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 {
1100 return &MockContainerRuntime_GetImageLabels_Call{Call: _e.mock.On("GetImageLabels", ctx, imageRef)}1100 return &MockContainerRuntime_GetImageLabels_Call{Call: _e.mock.On("GetImageLabels", ctx, imageRef)}
1101 }1101 }
1102 1102
@@ -1164,7 +1164,7 @@ type MockContainerRuntime_InspectContainer_Call struct {
1164 // InspectContainer is a helper method to define mock.On call1164 // InspectContainer is a helper method to define mock.On call
1165 // - ctx context.Context1165 // - ctx context.Context
1166 // - containerID string1166 // - 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 {
1168 return &MockContainerRuntime_InspectContainer_Call{Call: _e.mock.On("InspectContainer", ctx, containerID)}1168 return &MockContainerRuntime_InspectContainer_Call{Call: _e.mock.On("InspectContainer", ctx, containerID)}
1169 }1169 }
1170 1170
@@ -1232,7 +1232,7 @@ type MockContainerRuntime_InspectImageEnv_Call struct {
1232 // InspectImageEnv is a helper method to define mock.On call1232 // InspectImageEnv is a helper method to define mock.On call
1233 // - ctx context.Context1233 // - ctx context.Context
1234 // - imageRef string1234 // - 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 {
1236 return &MockContainerRuntime_InspectImageEnv_Call{Call: _e.mock.On("InspectImageEnv", ctx, imageRef)}1236 return &MockContainerRuntime_InspectImageEnv_Call{Call: _e.mock.On("InspectImageEnv", ctx, imageRef)}
1237 }1237 }
1238 1238
@@ -1300,7 +1300,7 @@ type MockContainerRuntime_InspectImageVolumes_Call struct {
1300 // InspectImageVolumes is a helper method to define mock.On call1300 // InspectImageVolumes is a helper method to define mock.On call
1301 // - ctx context.Context1301 // - ctx context.Context
1302 // - imageRef string1302 // - 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 {
1304 return &MockContainerRuntime_InspectImageVolumes_Call{Call: _e.mock.On("InspectImageVolumes", ctx, imageRef)}1304 return &MockContainerRuntime_InspectImageVolumes_Call{Call: _e.mock.On("InspectImageVolumes", ctx, imageRef)}
1305 }1305 }
1306 1306
@@ -1366,7 +1366,7 @@ type MockContainerRuntime_IsContainerRunning_Call struct {
1366 // IsContainerRunning is a helper method to define mock.On call1366 // IsContainerRunning is a helper method to define mock.On call
1367 // - ctx context.Context1367 // - ctx context.Context
1368 // - containerID string1368 // - 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 {
1370 return &MockContainerRuntime_IsContainerRunning_Call{Call: _e.mock.On("IsContainerRunning", ctx, containerID)}1370 return &MockContainerRuntime_IsContainerRunning_Call{Call: _e.mock.On("IsContainerRunning", ctx, containerID)}
1371 }1371 }
1372 1372
@@ -1434,7 +1434,7 @@ type MockContainerRuntime_ListContainers_Call struct {
1434 // ListContainers is a helper method to define mock.On call1434 // ListContainers is a helper method to define mock.On call
1435 // - ctx context.Context1435 // - ctx context.Context
1436 // - all bool1436 // - 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 {
1438 return &MockContainerRuntime_ListContainers_Call{Call: _e.mock.On("ListContainers", ctx, all)}1438 return &MockContainerRuntime_ListContainers_Call{Call: _e.mock.On("ListContainers", ctx, all)}
1439 }1439 }
1440 1440
@@ -1501,7 +1501,7 @@ type MockContainerRuntime_ListImages_Call struct {
1501 1501
1502 // ListImages is a helper method to define mock.On call1502 // ListImages is a helper method to define mock.On call
1503 // - ctx context.Context1503 // - 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 {
1505 return &MockContainerRuntime_ListImages_Call{Call: _e.mock.On("ListImages", ctx)}1505 return &MockContainerRuntime_ListImages_Call{Call: _e.mock.On("ListImages", ctx)}
1506 }1506 }
1507 1507
@@ -1563,7 +1563,7 @@ type MockContainerRuntime_ListNetworks_Call struct {
1563 1563
1564 // ListNetworks is a helper method to define mock.On call1564 // ListNetworks is a helper method to define mock.On call
1565 // - ctx context.Context1565 // - 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 {
1567 return &MockContainerRuntime_ListNetworks_Call{Call: _e.mock.On("ListNetworks", ctx)}1567 return &MockContainerRuntime_ListNetworks_Call{Call: _e.mock.On("ListNetworks", ctx)}
1568 }1568 }
1569 1569
@@ -1625,7 +1625,7 @@ type MockContainerRuntime_ListVolumes_Call struct {
1625 1625
1626 // ListVolumes is a helper method to define mock.On call1626 // ListVolumes is a helper method to define mock.On call
1627 // - ctx context.Context1627 // - 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 {
1629 return &MockContainerRuntime_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)}1629 return &MockContainerRuntime_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)}
1630 }1630 }
1631 1631
@@ -1686,7 +1686,7 @@ type MockContainerRuntime_NetworkExists_Call struct {
1686 // NetworkExists is a helper method to define mock.On call1686 // NetworkExists is a helper method to define mock.On call
1687 // - ctx context.Context1687 // - ctx context.Context
1688 // - name string1688 // - 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 {
1690 return &MockContainerRuntime_NetworkExists_Call{Call: _e.mock.On("NetworkExists", ctx, name)}1690 return &MockContainerRuntime_NetworkExists_Call{Call: _e.mock.On("NetworkExists", ctx, name)}
1691 }1691 }
1692 1692
@@ -1742,7 +1742,7 @@ type MockContainerRuntime_Ping_Call struct {
1742 1742
1743 // Ping is a helper method to define mock.On call1743 // Ping is a helper method to define mock.On call
1744 // - ctx context.Context1744 // - 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 {
1746 return &MockContainerRuntime_Ping_Call{Call: _e.mock.On("Ping", ctx)}1746 return &MockContainerRuntime_Ping_Call{Call: _e.mock.On("Ping", ctx)}
1747 }1747 }
1748 1748
@@ -1794,7 +1794,7 @@ type MockContainerRuntime_PullImage_Call struct {
1794 // PullImage is a helper method to define mock.On call1794 // PullImage is a helper method to define mock.On call
1795 // - ctx context.Context1795 // - ctx context.Context
1796 // - image string1796 // - 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 {
1798 return &MockContainerRuntime_PullImage_Call{Call: _e.mock.On("PullImage", ctx, image)}1798 return &MockContainerRuntime_PullImage_Call{Call: _e.mock.On("PullImage", ctx, image)}
1799 }1799 }
1800 1800
@@ -1853,7 +1853,7 @@ type MockContainerRuntime_PullImageWithAuth_Call struct {
1853 // - image string1853 // - image string
1854 // - username string1854 // - username string
1855 // - password string1855 // - 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 {
1857 return &MockContainerRuntime_PullImageWithAuth_Call{Call: _e.mock.On("PullImageWithAuth", ctx, image, username, password)}1857 return &MockContainerRuntime_PullImageWithAuth_Call{Call: _e.mock.On("PullImageWithAuth", ctx, image, username, password)}
1858 }1858 }
1859 1859
@@ -1921,7 +1921,7 @@ type MockContainerRuntime_RemoveContainer_Call struct {
1921 // - ctx context.Context1921 // - ctx context.Context
1922 // - containerID string1922 // - containerID string
1923 // - force bool1923 // - 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 {
1925 return &MockContainerRuntime_RemoveContainer_Call{Call: _e.mock.On("RemoveContainer", ctx, containerID, force)}1925 return &MockContainerRuntime_RemoveContainer_Call{Call: _e.mock.On("RemoveContainer", ctx, containerID, force)}
1926 }1926 }
1927 1927
@@ -1984,7 +1984,7 @@ type MockContainerRuntime_RemoveImage_Call struct {
1984 // - ctx context.Context1984 // - ctx context.Context
1985 // - image string1985 // - image string
1986 // - force bool1986 // - 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 {
1988 return &MockContainerRuntime_RemoveImage_Call{Call: _e.mock.On("RemoveImage", ctx, image, force)}1988 return &MockContainerRuntime_RemoveImage_Call{Call: _e.mock.On("RemoveImage", ctx, image, force)}
1989 }1989 }
1990 1990
@@ -2046,7 +2046,7 @@ type MockContainerRuntime_RemoveNetwork_Call struct {
2046 // RemoveNetwork is a helper method to define mock.On call2046 // RemoveNetwork is a helper method to define mock.On call
2047 // - ctx context.Context2047 // - ctx context.Context
2048 // - name string2048 // - 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 {
2050 return &MockContainerRuntime_RemoveNetwork_Call{Call: _e.mock.On("RemoveNetwork", ctx, name)}2050 return &MockContainerRuntime_RemoveNetwork_Call{Call: _e.mock.On("RemoveNetwork", ctx, name)}
2051 }2051 }
2052 2052
@@ -2104,7 +2104,7 @@ type MockContainerRuntime_RemoveVolume_Call struct {
2104 // - ctx context.Context2104 // - ctx context.Context
2105 // - volumeName string2105 // - volumeName string
2106 // - force bool2106 // - 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 {
2108 return &MockContainerRuntime_RemoveVolume_Call{Call: _e.mock.On("RemoveVolume", ctx, volumeName, force)}2108 return &MockContainerRuntime_RemoveVolume_Call{Call: _e.mock.On("RemoveVolume", ctx, volumeName, force)}
2109 }2109 }
2110 2110
@@ -2167,7 +2167,7 @@ type MockContainerRuntime_RenameContainer_Call struct {
2167 // - ctx context.Context2167 // - ctx context.Context
2168 // - containerID string2168 // - containerID string
2169 // - newName string2169 // - 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 {
2171 return &MockContainerRuntime_RenameContainer_Call{Call: _e.mock.On("RenameContainer", ctx, containerID, newName)}2171 return &MockContainerRuntime_RenameContainer_Call{Call: _e.mock.On("RenameContainer", ctx, containerID, newName)}
2172 }2172 }
2173 2173
@@ -2229,7 +2229,7 @@ type MockContainerRuntime_RestartContainer_Call struct {
2229 // RestartContainer is a helper method to define mock.On call2229 // RestartContainer is a helper method to define mock.On call
2230 // - ctx context.Context2230 // - ctx context.Context
2231 // - containerID string2231 // - 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 {
2233 return &MockContainerRuntime_RestartContainer_Call{Call: _e.mock.On("RestartContainer", ctx, containerID)}2233 return &MockContainerRuntime_RestartContainer_Call{Call: _e.mock.On("RestartContainer", ctx, containerID)}
2234 }2234 }
2235 2235
@@ -2286,7 +2286,7 @@ type MockContainerRuntime_StartContainer_Call struct {
2286 // StartContainer is a helper method to define mock.On call2286 // StartContainer is a helper method to define mock.On call
2287 // - ctx context.Context2287 // - ctx context.Context
2288 // - containerID string2288 // - 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 {
2290 return &MockContainerRuntime_StartContainer_Call{Call: _e.mock.On("StartContainer", ctx, containerID)}2290 return &MockContainerRuntime_StartContainer_Call{Call: _e.mock.On("StartContainer", ctx, containerID)}
2291 }2291 }
2292 2292
@@ -2343,7 +2343,7 @@ type MockContainerRuntime_StopContainer_Call struct {
2343 // StopContainer is a helper method to define mock.On call2343 // StopContainer is a helper method to define mock.On call
2344 // - ctx context.Context2344 // - ctx context.Context
2345 // - containerID string2345 // - 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 {
2347 return &MockContainerRuntime_StopContainer_Call{Call: _e.mock.On("StopContainer", ctx, containerID)}2347 return &MockContainerRuntime_StopContainer_Call{Call: _e.mock.On("StopContainer", ctx, containerID)}
2348 }2348 }
2349 2349
@@ -2401,7 +2401,7 @@ type MockContainerRuntime_TagImage_Call struct {
2401 // - ctx context.Context2401 // - ctx context.Context
2402 // - sourceRef string2402 // - sourceRef string
2403 // - targetRef string2403 // - 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 {
2405 return &MockContainerRuntime_TagImage_Call{Call: _e.mock.On("TagImage", ctx, sourceRef, targetRef)}2405 return &MockContainerRuntime_TagImage_Call{Call: _e.mock.On("TagImage", ctx, sourceRef, targetRef)}
2406 }2406 }
2407 2407
@@ -2463,7 +2463,7 @@ type MockContainerRuntime_UntagImage_Call struct {
2463 // UntagImage is a helper method to define mock.On call2463 // UntagImage is a helper method to define mock.On call
2464 // - ctx context.Context2464 // - ctx context.Context
2465 // - imageRef string2465 // - 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 {
2467 return &MockContainerRuntime_UntagImage_Call{Call: _e.mock.On("UntagImage", ctx, imageRef)}2467 return &MockContainerRuntime_UntagImage_Call{Call: _e.mock.On("UntagImage", ctx, imageRef)}
2468 }2468 }
2469 2469
@@ -2528,7 +2528,7 @@ type MockContainerRuntime_Version_Call struct {
2528 2528
2529 // Version is a helper method to define mock.On call2529 // Version is a helper method to define mock.On call
2530 // - ctx context.Context2530 // - 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 {
2532 return &MockContainerRuntime_Version_Call{Call: _e.mock.On("Version", ctx)}2532 return &MockContainerRuntime_Version_Call{Call: _e.mock.On("Version", ctx)}
2533 }2533 }
2534 2534
@@ -2589,7 +2589,7 @@ type MockContainerRuntime_VolumeExists_Call struct {
2589 // VolumeExists is a helper method to define mock.On call2589 // VolumeExists is a helper method to define mock.On call
2590 // - ctx context.Context2590 // - ctx context.Context
2591 // - volumeName string2591 // - 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 {
2593 return &MockContainerRuntime_VolumeExists_Call{Call: _e.mock.On("VolumeExists", ctx, volumeName)}2593 return &MockContainerRuntime_VolumeExists_Call{Call: _e.mock.On("VolumeExists", ctx, volumeName)}
2594 }2594 }
2595 2595
M internal/boundaries/out/mocks/mock_domain_secret_store.go +8 -8
@@ -61,7 +61,7 @@ type MockDomainSecretStore_Delete_Call struct {
61 // Delete is a helper method to define mock.On call61 // Delete is a helper method to define mock.On call
62 // - domain string62 // - domain string
63 // - key string63 // - 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 {
65 return &MockDomainSecretStore_Delete_Call{Call: _e.mock.On("Delete", domain, key)}65 return &MockDomainSecretStore_Delete_Call{Call: _e.mock.On("Delete", domain, key)}
66 }66 }
67 67
@@ -118,7 +118,7 @@ type MockDomainSecretStore_DeleteAttachment_Call struct {
118 // DeleteAttachment is a helper method to define mock.On call118 // DeleteAttachment is a helper method to define mock.On call
119 // - containerName string119 // - containerName string
120 // - key string120 // - 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 {
122 return &MockDomainSecretStore_DeleteAttachment_Call{Call: _e.mock.On("DeleteAttachment", containerName, key)}122 return &MockDomainSecretStore_DeleteAttachment_Call{Call: _e.mock.On("DeleteAttachment", containerName, key)}
123 }123 }
124 124
@@ -185,7 +185,7 @@ type MockDomainSecretStore_GetAll_Call struct {
185 185
186 // GetAll is a helper method to define mock.On call186 // GetAll is a helper method to define mock.On call
187 // - domain string187 // - domain string
188-func (_e *MockDomainSecretStore_Expecter) GetAll(domain interface{}) *MockDomainSecretStore_GetAll_Call {188+func (_e *MockDomainSecretStore_Expecter) GetAll(domain any) *MockDomainSecretStore_GetAll_Call {
189 return &MockDomainSecretStore_GetAll_Call{Call: _e.mock.On("GetAll", domain)}189 return &MockDomainSecretStore_GetAll_Call{Call: _e.mock.On("GetAll", domain)}
190 }190 }
191 191
@@ -247,7 +247,7 @@ type MockDomainSecretStore_GetAllAttachment_Call struct {
247 247
248 // GetAllAttachment is a helper method to define mock.On call248 // GetAllAttachment is a helper method to define mock.On call
249 // - containerName string249 // - containerName string
250-func (_e *MockDomainSecretStore_Expecter) GetAllAttachment(containerName interface{}) *MockDomainSecretStore_GetAllAttachment_Call {250+func (_e *MockDomainSecretStore_Expecter) GetAllAttachment(containerName any) *MockDomainSecretStore_GetAllAttachment_Call {
251 return &MockDomainSecretStore_GetAllAttachment_Call{Call: _e.mock.On("GetAllAttachment", containerName)}251 return &MockDomainSecretStore_GetAllAttachment_Call{Call: _e.mock.On("GetAllAttachment", containerName)}
252 }252 }
253 253
@@ -309,7 +309,7 @@ type MockDomainSecretStore_ListAttachmentKeys_Call struct {
309 309
310 // ListAttachmentKeys is a helper method to define mock.On call310 // ListAttachmentKeys is a helper method to define mock.On call
311 // - domain string311 // - domain string
312-func (_e *MockDomainSecretStore_Expecter) ListAttachmentKeys(domain interface{}) *MockDomainSecretStore_ListAttachmentKeys_Call {312+func (_e *MockDomainSecretStore_Expecter) ListAttachmentKeys(domain any) *MockDomainSecretStore_ListAttachmentKeys_Call {
313 return &MockDomainSecretStore_ListAttachmentKeys_Call{Call: _e.mock.On("ListAttachmentKeys", domain)}313 return &MockDomainSecretStore_ListAttachmentKeys_Call{Call: _e.mock.On("ListAttachmentKeys", domain)}
314 }314 }
315 315
@@ -371,7 +371,7 @@ type MockDomainSecretStore_ListKeys_Call struct {
371 371
372 // ListKeys is a helper method to define mock.On call372 // ListKeys is a helper method to define mock.On call
373 // - domain string373 // - domain string
374-func (_e *MockDomainSecretStore_Expecter) ListKeys(domain interface{}) *MockDomainSecretStore_ListKeys_Call {374+func (_e *MockDomainSecretStore_Expecter) ListKeys(domain any) *MockDomainSecretStore_ListKeys_Call {
375 return &MockDomainSecretStore_ListKeys_Call{Call: _e.mock.On("ListKeys", domain)}375 return &MockDomainSecretStore_ListKeys_Call{Call: _e.mock.On("ListKeys", domain)}
376 }376 }
377 377
@@ -423,7 +423,7 @@ type MockDomainSecretStore_Set_Call struct {
423 // Set is a helper method to define mock.On call423 // Set is a helper method to define mock.On call
424 // - domain string424 // - domain string
425 // - secrets map[string]string425 // - 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 {
427 return &MockDomainSecretStore_Set_Call{Call: _e.mock.On("Set", domain, secrets)}427 return &MockDomainSecretStore_Set_Call{Call: _e.mock.On("Set", domain, secrets)}
428 }428 }
429 429
@@ -480,7 +480,7 @@ type MockDomainSecretStore_SetAttachment_Call struct {
480 // SetAttachment is a helper method to define mock.On call480 // SetAttachment is a helper method to define mock.On call
481 // - containerName string481 // - containerName string
482 // - secrets map[string]string482 // - 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 {
484 return &MockDomainSecretStore_SetAttachment_Call{Call: _e.mock.On("SetAttachment", containerName, secrets)}484 return &MockDomainSecretStore_SetAttachment_Call{Call: _e.mock.On("SetAttachment", containerName, secrets)}
485 }485 }
486 486
M internal/boundaries/out/mocks/mock_env_loader.go +3 -3
@@ -62,7 +62,7 @@ type MockEnvLoader_CreateEnvFile_Call struct {
62 // CreateEnvFile is a helper method to define mock.On call62 // CreateEnvFile is a helper method to define mock.On call
63 // - ctx context.Context63 // - ctx context.Context
64 // - domain string64 // - 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 {
66 return &MockEnvLoader_CreateEnvFile_Call{Call: _e.mock.On("CreateEnvFile", ctx, domain)}66 return &MockEnvLoader_CreateEnvFile_Call{Call: _e.mock.On("CreateEnvFile", ctx, domain)}
67 }67 }
68 68
@@ -127,7 +127,7 @@ type MockEnvLoader_EnvFileExists_Call struct {
127 127
128 // EnvFileExists is a helper method to define mock.On call128 // EnvFileExists is a helper method to define mock.On call
129 // - domain string129 // - domain string
130-func (_e *MockEnvLoader_Expecter) EnvFileExists(domain interface{}) *MockEnvLoader_EnvFileExists_Call {130+func (_e *MockEnvLoader_Expecter) EnvFileExists(domain any) *MockEnvLoader_EnvFileExists_Call {
131 return &MockEnvLoader_EnvFileExists_Call{Call: _e.mock.On("EnvFileExists", domain)}131 return &MockEnvLoader_EnvFileExists_Call{Call: _e.mock.On("EnvFileExists", domain)}
132 }132 }
133 133
@@ -190,7 +190,7 @@ type MockEnvLoader_LoadEnv_Call struct {
190 // LoadEnv is a helper method to define mock.On call190 // LoadEnv is a helper method to define mock.On call
191 // - ctx context.Context191 // - ctx context.Context
192 // - domain string192 // - 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 {
194 return &MockEnvLoader_LoadEnv_Call{Call: _e.mock.On("LoadEnv", ctx, domain)}194 return &MockEnvLoader_LoadEnv_Call{Call: _e.mock.On("LoadEnv", ctx, domain)}
195 }195 }
196 196
M internal/boundaries/out/mocks/mock_event_bus.go +3 -3
@@ -62,7 +62,7 @@ type MockEventBus_Publish_Call struct {
62 // Publish is a helper method to define mock.On call62 // Publish is a helper method to define mock.On call
63 // - eventType domain.EventType63 // - eventType domain.EventType
64 // - payload any64 // - 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 {
66 return &MockEventBus_Publish_Call{Call: _e.mock.On("Publish", eventType, payload)}66 return &MockEventBus_Publish_Call{Call: _e.mock.On("Publish", eventType, payload)}
67 }67 }
68 68
@@ -206,7 +206,7 @@ type MockEventBus_Subscribe_Call struct {
206 206
207 // Subscribe is a helper method to define mock.On call207 // Subscribe is a helper method to define mock.On call
208 // - handler out.EventHandler208 // - 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 {
210 return &MockEventBus_Subscribe_Call{Call: _e.mock.On("Subscribe", handler)}210 return &MockEventBus_Subscribe_Call{Call: _e.mock.On("Subscribe", handler)}
211 }211 }
212 212
@@ -257,7 +257,7 @@ type MockEventBus_Unsubscribe_Call struct {
257 257
258 // Unsubscribe is a helper method to define mock.On call258 // Unsubscribe is a helper method to define mock.On call
259 // - handler out.EventHandler259 // - 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 {
261 return &MockEventBus_Unsubscribe_Call{Call: _e.mock.On("Unsubscribe", handler)}261 return &MockEventBus_Unsubscribe_Call{Call: _e.mock.On("Unsubscribe", handler)}
262 }262 }
263 263
M internal/boundaries/out/mocks/mock_event_handler.go +2 -2
@@ -62,7 +62,7 @@ type MockEventHandler_CanHandle_Call struct {
62 62
63 // CanHandle is a helper method to define mock.On call63 // CanHandle is a helper method to define mock.On call
64 // - eventType domain.EventType64 // - 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 {
66 return &MockEventHandler_CanHandle_Call{Call: _e.mock.On("CanHandle", eventType)}66 return &MockEventHandler_CanHandle_Call{Call: _e.mock.On("CanHandle", eventType)}
67 }67 }
68 68
@@ -114,7 +114,7 @@ type MockEventHandler_Handle_Call struct {
114 // Handle is a helper method to define mock.On call114 // Handle is a helper method to define mock.On call
115 // - ctx context.Context115 // - ctx context.Context
116 // - event domain.Event116 // - 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 {
118 return &MockEventHandler_Handle_Call{Call: _e.mock.On("Handle", ctx, event)}118 return &MockEventHandler_Handle_Call{Call: _e.mock.On("Handle", ctx, event)}
119 }119 }
120 120
M internal/boundaries/out/mocks/mock_event_publisher.go +1 -1
@@ -61,7 +61,7 @@ type MockEventPublisher_Publish_Call struct {
61 // Publish is a helper method to define mock.On call61 // Publish is a helper method to define mock.On call
62 // - eventType domain.EventType62 // - eventType domain.EventType
63 // - payload any63 // - 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 {
65 return &MockEventPublisher_Publish_Call{Call: _e.mock.On("Publish", eventType, payload)}65 return &MockEventPublisher_Publish_Call{Call: _e.mock.On("Publish", eventType, payload)}
66 }66 }
67 67
M internal/boundaries/out/mocks/mock_event_subscriber.go +2 -2
@@ -60,7 +60,7 @@ type MockEventSubscriber_Subscribe_Call struct {
60 60
61 // Subscribe is a helper method to define mock.On call61 // Subscribe is a helper method to define mock.On call
62 // - handler out.EventHandler62 // - 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 {
64 return &MockEventSubscriber_Subscribe_Call{Call: _e.mock.On("Subscribe", handler)}64 return &MockEventSubscriber_Subscribe_Call{Call: _e.mock.On("Subscribe", handler)}
65 }65 }
66 66
@@ -111,7 +111,7 @@ type MockEventSubscriber_Unsubscribe_Call struct {
111 111
112 // Unsubscribe is a helper method to define mock.On call112 // Unsubscribe is a helper method to define mock.On call
113 // - handler out.EventHandler113 // - 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 {
115 return &MockEventSubscriber_Unsubscribe_Call{Call: _e.mock.On("Unsubscribe", handler)}115 return &MockEventSubscriber_Unsubscribe_Call{Call: _e.mock.On("Unsubscribe", handler)}
116 }116 }
117 117
M internal/boundaries/out/mocks/mock_manifest_storage.go +5 -5
@@ -62,7 +62,7 @@ type MockManifestStorage_DeleteManifest_Call struct {
62 // DeleteManifest is a helper method to define mock.On call62 // DeleteManifest is a helper method to define mock.On call
63 // - name string63 // - name string
64 // - reference string64 // - 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 {
66 return &MockManifestStorage_DeleteManifest_Call{Call: _e.mock.On("DeleteManifest", name, reference)}66 return &MockManifestStorage_DeleteManifest_Call{Call: _e.mock.On("DeleteManifest", name, reference)}
67 }67 }
68 68
@@ -136,7 +136,7 @@ type MockManifestStorage_GetManifest_Call struct {
136 // GetManifest is a helper method to define mock.On call136 // GetManifest is a helper method to define mock.On call
137 // - name string137 // - name string
138 // - reference string138 // - 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 {
140 return &MockManifestStorage_GetManifest_Call{Call: _e.mock.On("GetManifest", name, reference)}140 return &MockManifestStorage_GetManifest_Call{Call: _e.mock.On("GetManifest", name, reference)}
141 }141 }
142 142
@@ -202,7 +202,7 @@ type MockManifestStorage_GetManifestModTime_Call struct {
202 // GetManifestModTime is a helper method to define mock.On call202 // GetManifestModTime is a helper method to define mock.On call
203 // - name string203 // - name string
204 // - reference string204 // - 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 {
206 return &MockManifestStorage_GetManifestModTime_Call{Call: _e.mock.On("GetManifestModTime", name, reference)}206 return &MockManifestStorage_GetManifestModTime_Call{Call: _e.mock.On("GetManifestModTime", name, reference)}
207 }207 }
208 208
@@ -324,7 +324,7 @@ type MockManifestStorage_ListTags_Call struct {
324 324
325 // ListTags is a helper method to define mock.On call325 // ListTags is a helper method to define mock.On call
326 // - name string326 // - name string
327-func (_e *MockManifestStorage_Expecter) ListTags(name interface{}) *MockManifestStorage_ListTags_Call {327+func (_e *MockManifestStorage_Expecter) ListTags(name any) *MockManifestStorage_ListTags_Call {
328 return &MockManifestStorage_ListTags_Call{Call: _e.mock.On("ListTags", name)}328 return &MockManifestStorage_ListTags_Call{Call: _e.mock.On("ListTags", name)}
329 }329 }
330 330
@@ -378,7 +378,7 @@ type MockManifestStorage_PutManifest_Call struct {
378 // - reference string378 // - reference string
379 // - contentType string379 // - contentType string
380 // - data []byte380 // - 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 {
382 return &MockManifestStorage_PutManifest_Call{Call: _e.mock.On("PutManifest", name, reference, contentType, data)}382 return &MockManifestStorage_PutManifest_Call{Call: _e.mock.On("PutManifest", name, reference, contentType, data)}
383 }383 }
384 384
M internal/boundaries/out/mocks/mock_rate_limiter.go +2 -2
@@ -62,7 +62,7 @@ type MockRateLimiter_Allow_Call struct {
62 // Allow is a helper method to define mock.On call62 // Allow is a helper method to define mock.On call
63 // - ctx context.Context63 // - ctx context.Context
64 // - key string64 // - 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 {
66 return &MockRateLimiter_Allow_Call{Call: _e.mock.On("Allow", ctx, key)}66 return &MockRateLimiter_Allow_Call{Call: _e.mock.On("Allow", ctx, key)}
67 }67 }
68 68
@@ -120,7 +120,7 @@ type MockRateLimiter_AllowN_Call struct {
120 // - ctx context.Context120 // - ctx context.Context
121 // - key string121 // - key string
122 // - n int122 // - 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 {
124 return &MockRateLimiter_AllowN_Call{Call: _e.mock.On("AllowN", ctx, key, n)}124 return &MockRateLimiter_AllowN_Call{Call: _e.mock.On("AllowN", ctx, key, n)}
125 }125 }
126 126
M internal/boundaries/out/mocks/mock_route_checker.go +1 -1
@@ -110,7 +110,7 @@ type MockRouteChecker_GetRoutes_Call struct {
110 110
111 // GetRoutes is a helper method to define mock.On call111 // GetRoutes is a helper method to define mock.On call
112 // - ctx context.Context112 // - 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 {
114 return &MockRouteChecker_GetRoutes_Call{Call: _e.mock.On("GetRoutes", ctx)}114 return &MockRouteChecker_GetRoutes_Call{Call: _e.mock.On("GetRoutes", ctx)}
115 }115 }
116 116
M internal/boundaries/out/mocks/mock_secret_provider.go +1 -1
@@ -71,7 +71,7 @@ type MockSecretProvider_GetSecret_Call struct {
71 // GetSecret is a helper method to define mock.On call71 // GetSecret is a helper method to define mock.On call
72 // - ctx context.Context72 // - ctx context.Context
73 // - key string73 // - 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 {
75 return &MockSecretProvider_GetSecret_Call{Call: _e.mock.On("GetSecret", ctx, key)}75 return &MockSecretProvider_GetSecret_Call{Call: _e.mock.On("GetSecret", ctx, key)}
76 }76 }
77 77
M internal/boundaries/out/mocks/mock_token_store.go +7 -7
@@ -63,7 +63,7 @@ type MockTokenStore_DeleteToken_Call struct {
63 // DeleteToken is a helper method to define mock.On call63 // DeleteToken is a helper method to define mock.On call
64 // - ctx context.Context64 // - ctx context.Context
65 // - subject string65 // - 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 {
67 return &MockTokenStore_DeleteToken_Call{Call: _e.mock.On("DeleteToken", ctx, subject)}67 return &MockTokenStore_DeleteToken_Call{Call: _e.mock.On("DeleteToken", ctx, subject)}
68 }68 }
69 69
@@ -137,7 +137,7 @@ type MockTokenStore_GetToken_Call struct {
137 // GetToken is a helper method to define mock.On call137 // GetToken is a helper method to define mock.On call
138 // - ctx context.Context138 // - ctx context.Context
139 // - subject string139 // - 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 {
141 return &MockTokenStore_GetToken_Call{Call: _e.mock.On("GetToken", ctx, subject)}141 return &MockTokenStore_GetToken_Call{Call: _e.mock.On("GetToken", ctx, subject)}
142 }142 }
143 143
@@ -203,7 +203,7 @@ type MockTokenStore_IsRevoked_Call struct {
203 // IsRevoked is a helper method to define mock.On call203 // IsRevoked is a helper method to define mock.On call
204 // - ctx context.Context204 // - ctx context.Context
205 // - tokenID string205 // - 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 {
207 return &MockTokenStore_IsRevoked_Call{Call: _e.mock.On("IsRevoked", ctx, tokenID)}207 return &MockTokenStore_IsRevoked_Call{Call: _e.mock.On("IsRevoked", ctx, tokenID)}
208 }208 }
209 209
@@ -270,7 +270,7 @@ type MockTokenStore_ListTokens_Call struct {
270 270
271 // ListTokens is a helper method to define mock.On call271 // ListTokens is a helper method to define mock.On call
272 // - ctx context.Context272 // - 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 {
274 return &MockTokenStore_ListTokens_Call{Call: _e.mock.On("ListTokens", ctx)}274 return &MockTokenStore_ListTokens_Call{Call: _e.mock.On("ListTokens", ctx)}
275 }275 }
276 276
@@ -322,7 +322,7 @@ type MockTokenStore_Revoke_Call struct {
322 // Revoke is a helper method to define mock.On call322 // Revoke is a helper method to define mock.On call
323 // - ctx context.Context323 // - ctx context.Context
324 // - tokenID string324 // - 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 {
326 return &MockTokenStore_Revoke_Call{Call: _e.mock.On("Revoke", ctx, tokenID)}326 return &MockTokenStore_Revoke_Call{Call: _e.mock.On("Revoke", ctx, tokenID)}
327 }327 }
328 328
@@ -380,7 +380,7 @@ type MockTokenStore_SaveToken_Call struct {
380 // - ctx context.Context380 // - ctx context.Context
381 // - token *domain.Token381 // - token *domain.Token
382 // - jwt string382 // - 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 {
384 return &MockTokenStore_SaveToken_Call{Call: _e.mock.On("SaveToken", ctx, token, jwt)}384 return &MockTokenStore_SaveToken_Call{Call: _e.mock.On("SaveToken", ctx, token, jwt)}
385 }385 }
386 386
@@ -443,7 +443,7 @@ type MockTokenStore_UpdateTokenExpiry_Call struct {
443 // - ctx context.Context443 // - ctx context.Context
444 // - token *domain.Token444 // - token *domain.Token
445 // - newJWT string445 // - 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 {
447 return &MockTokenStore_UpdateTokenExpiry_Call{Call: _e.mock.On("UpdateTokenExpiry", ctx, token, newJWT)}447 return &MockTokenStore_UpdateTokenExpiry_Call{Call: _e.mock.On("UpdateTokenExpiry", ctx, token, newJWT)}
448 }448 }
449 449
M internal/domain/container.go +9 -0
@@ -63,12 +63,21 @@ type RouteInfo struct {
63 Attachments []Attachment63 Attachments []Attachment
64 }64 }
65 65
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+
66 // ContainerConfig holds configuration for creating a container.74 // ContainerConfig holds configuration for creating a container.
67 type ContainerConfig struct {75 type ContainerConfig struct {
68 Image string76 Image string
69 Name string77 Name string
70 Env []string78 Env []string
71 Ports []int79 Ports []int
80+ PortPublishes []ContainerPortPublish
72 Labels map[string]string81 Labels map[string]string
73 WorkingDir string82 WorkingDir string
74 Cmd []string83 Cmd []string
M internal/domain/errors.go +5 -0
@@ -49,6 +49,8 @@ var (
49 ErrPreviewNotFound = errors.New("preview not found")49 ErrPreviewNotFound = errors.New("preview not found")
50 50
51 // Config errors51 // Config errors
52+ ErrTrafficGraphRequired = errors.New("traffic graph is required")
53+ ErrClientHelloTooLarge = errors.New("client hello exceeds maximum")
52 ErrConfigNotFound = errors.New("configuration not found")54 ErrConfigNotFound = errors.New("configuration not found")
53 ErrInvalidConfig = errors.New("invalid configuration")55 ErrInvalidConfig = errors.New("invalid configuration")
54 ErrConfigLoadFailed = errors.New("failed to load configuration")56 ErrConfigLoadFailed = errors.New("failed to load configuration")
@@ -104,4 +106,7 @@ var (
104 ErrRouteSourceRequired = errors.New("route source required")106 ErrRouteSourceRequired = errors.New("route source required")
105 ErrHTTPChallengeSinkRequired = errors.New("http challenge sink required")107 ErrHTTPChallengeSinkRequired = errors.New("http challenge sink required")
106 ErrTLSRouteNotCovered = errors.New("tls route not covered by public certificate")108 ErrTLSRouteNotCovered = errors.New("tls route not covered by public certificate")
109+
110+ // Traffic errors
111+ ErrTrafficStatusUnavailable = errors.New("traffic status unavailable")
107 )112 )
M internal/domain/labels.go +8 -0
@@ -15,6 +15,14 @@ const (
15 // exposing secret values.15 // exposing secret values.
16 LabelEnvHash = "gordon.env-hash"16 LabelEnvHash = "gordon.env-hash"
17 17
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+
18 // LabelProxyPort specifies the container port to proxy HTTP traffic to.26 // LabelProxyPort specifies the container port to proxy HTTP traffic to.
19 LabelProxyPort = "gordon.proxy.port"27 LabelProxyPort = "gordon.proxy.port"
20 28
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