Merge pull request #212 from bnema/feature/traffic-plane-l4
feat(traffic): add L4 traffic plane passthrough
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 | 45 | LogService: | |
| 46 | 46 | VolumeService: | |
| 47 | 47 | PublicTLSService: | |
| 48 | + | TrafficStatusService: | |
| 49 | + | StandaloneServiceService: | |
| 48 | 50 | # Exception: pushImageOps is a CLI-local interface, not a boundary port. | |
| 49 | 51 | # Mocked here because it abstracts Docker SDK calls that require a running | |
| 50 | 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 | 127 | ## Features | |
| 128 | 128 | ||
| 129 | 129 | - Private Docker registry on your VPS | |
| 130 | - | - Domain-to-container routing via HTTP reverse proxy | |
| 130 | + | - Domain-to-container routing through a smart TCP edge reverse proxy | |
| 131 | 131 | - Automatic deployment on image push | |
| 132 | 132 | - Auto-routing from image labels | |
| 133 | 133 | - Remote CLI management | |
| @@ -138,7 +138,7 @@ See the [Deploy Action README](.github/actions/deploy/README.md) for multi-platf | |||
| 138 | 138 | - Single binary, ~15MB RAM | |
| 139 | 139 | ||
| 140 | 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 | 143 | ## Documentation | |
| 144 | 144 | ||
M docs/cli/ca.md +2 -2
| @@ -12,7 +12,7 @@ Manage Gordon's internal Certificate Authority. | |||
| 12 | 12 | | `info` | Show CA status (root CN, fingerprint, intermediate expiry) | | |
| 13 | 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 | 121 | ## Related | |
| 122 | 122 | ||
| 123 | - | - [Server Configuration](../config/server.md) — `tls_port`, `force_https_redirect`, and internal CA settings | |
| 123 | + | - [Server Configuration](../config/server.md) — `force_https_redirect`, smart TCP edge, and internal CA settings | |
| 124 | 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 | 42 | | `gordon routes` | Manage routes | [routes](./routes.md) | | |
| 43 | 43 | | `gordon secrets` | Manage secrets | [secrets](./secrets.md) | | |
| 44 | 44 | | `gordon status` | Show Gordon server status | [status](./status.md) | | |
| 45 | + | | `gordon traffic` | Inspect traffic plane status | [traffic](./traffic.md) | | |
| 45 | 46 | | `gordon volumes` | Manage volumes | - | | |
| 46 | 47 | ||
| 47 | 48 | ## Client Commands | |
| @@ -93,6 +94,10 @@ gordon logs myapp.example.com -f # Follow container logs | |||
| 93 | 94 | # Check version | |
| 94 | 95 | gordon version | |
| 95 | 96 | ||
| 97 | + | # Traffic plane | |
| 98 | + | gordon traffic status --remote prod | |
| 99 | + | gordon traffic status --remote prod --json | |
| 100 | + | ||
| 96 | 101 | # Backups | |
| 97 | 102 | gordon backups list | |
| 98 | 103 | 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 | 350 | - `insecure_tls = true` on the remote — skips certificate verification entirely | |
| 351 | 351 | - `sudo gordon ca install` — installs the root CA into system, Firefox, and Java trust stores | |
| 352 | 352 | - `gordon ca export --out gordon-ca.crt` — exports the root CA PEM for manual installation | |
| 353 | - | - Visit `https://<gordon-host>:<tls_port>/ca` in a browser — onboarding page with downloads for macOS, Linux, Windows, iOS, and Android | |
| 353 | + | - Visit `https://<gordon-host>/.well-known/gordon/ca` (or your mapped edge address) in a browser — onboarding page with downloads for macOS, Linux, Windows, iOS, and Android | |
| 354 | 354 | ||
| 355 | 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 | 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 | 13 | Remote targeting uses client config or an active remote by default. | |
| 6 | 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 | 14 | ```toml | |
| 15 | 15 | [server] | |
| 16 | - | port = 8088 | |
| 17 | - | tls_port = 8443 | |
| 18 | 16 | registry_port = 5000 | |
| 19 | 17 | gordon_domain = "gordon.mydomain.com" | |
| 20 | 18 | ||
| 19 | + | [entrypoints.edge] | |
| 20 | + | address = ":443" | |
| 21 | + | protocol = "smart_tcp" | |
| 22 | + | ||
| 21 | 23 | [routes] | |
| 22 | 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 | 37 | ```toml | |
| 36 | 38 | # Server settings | |
| 37 | 39 | [server] | |
| 38 | - | port = 8088 # HTTP proxy port (default: 8088) | |
| 39 | 40 | registry_port = 5000 # Registry port (default: 5000) | |
| 40 | 41 | gordon_domain = "gordon.mydomain.com" # Required: Gordon domain (registry + API) | |
| 41 | 42 | # 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 | 48 | # Authentication (includes secrets backend) | |
| 44 | 49 | [auth] | |
| 45 | 50 | enabled = true # Enable registry authentication (default: true) | |
| @@ -172,6 +177,7 @@ keep_last = 3 | |||
| 172 | 177 | | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) | | |
| 173 | 178 | | `[routes]` | Domain to image mapping | [Routes](./routes.md) | | |
| 174 | 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 | 181 | | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) | | |
| 176 | 182 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | |
| 177 | 183 | | `[backups]` | Database backups | [Backups](./backups.md) | | |
| @@ -182,7 +188,6 @@ keep_last = 3 | |||
| 182 | 188 | ||
| 183 | 189 | | Setting | Default | | |
| 184 | 190 | |---------|---------| | |
| 185 | - | | `server.port` | `8088` | | |
| 186 | 191 | | `server.registry_port` | `5000` | | |
| 187 | 192 | | `server.data_dir` | `~/.gordon` | | |
| 188 | 193 | | `server.max_blob_chunk_size` | `"95MB"` | | |
| @@ -257,7 +262,7 @@ gordon reload | |||
| 257 | 262 | ||
| 258 | 263 | | Setting | | |
| 259 | 264 | |---------| | |
| 260 | - | | `server.port` | | |
| 265 | + | | `entrypoints.edge.address` | | |
| 261 | 266 | | `server.data_dir` | | |
| 262 | 267 | | `server.max_blob_chunk_size` | | |
| 263 | 268 | | `server.max_blob_size` | | |
| @@ -284,6 +289,8 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots) | |||
| 284 | 289 | - [Server Configuration](./server.md) | |
| 285 | 290 | - [Routes Configuration](./routes.md) | |
| 286 | 291 | - [External Routes](./external-routes.md) | |
| 292 | + | - [Standalone Services](./services.md) | |
| 293 | + | - [Traffic Plane](./traffic.md) | |
| 287 | 294 | - [Authentication](./auth.md) | |
| 288 | 295 | - [Telemetry](./telemetry.md) | |
| 289 | 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 | 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 | 156 | 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 | 9 | # SERVER | |
| 10 | 10 | # ============================================================================= | |
| 11 | 11 | [server] | |
| 12 | - | port = 8088 # HTTP proxy port | |
| 13 | 12 | registry_port = 5000 # Container registry port | |
| 14 | - | tls_port = 8443 # HTTPS proxy port (0 = disabled, no internal CA) | |
| 15 | - | tls_cert_file = "" # PEM cert path (optional, for static TLS alongside internal CA) | |
| 13 | + | tls_cert_file = "" # PEM cert path (optional, for static TLS fallback) | |
| 16 | 14 | tls_key_file = "" # PEM key path (optional, must be set with tls_cert_file) | |
| 17 | 15 | force_https_redirect = false # Redirect all HTTP traffic to HTTPS (for direct-access setups) | |
| 18 | 16 | gordon_domain = "" # Required: Gordon domain (registry + API) | |
| @@ -20,9 +18,20 @@ data_dir = "~/.gordon" # Data directory (varies by install | |||
| 20 | 18 | max_blob_chunk_size = "95MB" # Max size per registry blob upload chunk | |
| 21 | 19 | max_blob_size = "1GB" # Max cumulative size per registry blob/layer upload | |
| 22 | 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 | 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 | 36 | # DNS | |
| 28 | 37 | # ============================================================================= | |
| @@ -45,7 +54,7 @@ access_token_ttl = "15m" # Ephemeral access token lifetime ( | |||
| 45 | 54 | # PUBLIC TLS / ACME | |
| 46 | 55 | # ============================================================================= | |
| 47 | 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 | 58 | email = "" # ACME account email when enabled | |
| 50 | 59 | challenge = "auto" # "auto", "http-01", or "cloudflare-dns-01" | |
| 51 | 60 | obtain_batch_size = 1 # New certificate orders per reconcile run | |
| @@ -164,6 +173,28 @@ preserve = true # Keep volumes when containers are | |||
| 164 | 173 | [external_routes] | |
| 165 | 174 | # "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 | 199 | # NETWORK GROUPS | |
| 169 | 200 | # ============================================================================= | |
| @@ -210,9 +241,7 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 210 | 241 | ||
| 211 | 242 | | Setting | Default | Description | | |
| 212 | 243 | |---------|---------|-------------| | |
| 213 | - | | `server.port` | `8088` | HTTP proxy port | | |
| 214 | 244 | | `server.registry_port` | `5000` | Container registry port | | |
| 215 | - | | `server.tls_port` | `8443` | HTTPS listener port (0 = disabled) | | |
| 216 | 245 | | `server.tls_cert_file` | `""` | PEM cert path for static TLS (optional) | | |
| 217 | 246 | | `server.tls_key_file` | `""` | PEM key path for static TLS (optional) | | |
| 218 | 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 | 252 | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | |
| 224 | 253 | | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | |
| 225 | 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 | 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 | 262 | | `dns.propagation_timeout` | `"5m"` | Maximum time to wait for DNS-01 TXT records to become visible through configured recursive resolvers | | |
| 228 | 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 | 265 | | `tls.acme.email` | `""` | ACME account email when enabled | | |
| 231 | 266 | | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` | | |
| 232 | 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 | 314 | | `volumes.auto_create` | `true` | Auto-create volumes | | |
| 280 | 315 | | `volumes.prefix` | `"gordon"` | Volume prefix | | |
| 281 | 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 | 338 | | `backups.enabled` | `false` | Backup service disabled | | |
| 283 | 339 | | `backups.schedule` | `"daily"` | Backup scheduler preset | | |
| 284 | 340 | | `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty | | |
| @@ -364,4 +420,5 @@ gordon serve | |||
| 364 | 420 | - [Telemetry](./telemetry.md) | |
| 365 | 421 | - [Network Isolation](./network-isolation.md) | |
| 366 | 422 | - [Volumes](./volumes.md) | |
| 423 | + | - [Standalone Services](./services.md) | |
| 367 | 424 | - [Images](./images.md) | |
M docs/config/security-hardening.md +29 -0
| @@ -56,6 +56,35 @@ require_digest = true | |||
| 56 | 56 | - `require_digest = true` requires allowlisted external images to use `@sha256:<64 hex chars>`. | |
| 57 | 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 | 88 | ## Docker network isolation | |
| 60 | 89 | ||
| 61 | 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 | 7 | ```toml | |
| 8 | 8 | [server] | |
| 9 | - | port = 8088 # HTTP proxy port | |
| 10 | 9 | 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 | 11 | # tls_cert_file = "" # Optional: PEM certificate for static TLS | |
| 13 | 12 | # tls_key_file = "" # Optional: PEM key for static TLS | |
| 14 | - | # force_https_redirect = false # Redirect all HTTP traffic to HTTPS | |
| 15 | - | gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | |
| 13 | + | # force_https_redirect = false # Redirect cleartext HTTP requests to HTTPS | |
| 16 | 14 | # legacy_registry_domains = ["registry.example.com:5000"] | |
| 17 | 15 | # data_dir = "~/.gordon" # Data storage directory (default) | |
| 18 | 16 | max_blob_chunk_size = "95MB" # Max registry blob upload chunk | |
| 19 | 17 | 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 | 24 | ## Options | |
| 23 | 25 | ||
| 24 | 26 | | Option | Type | Default | Description | | |
| 25 | 27 | |--------|------|---------|-------------| | |
| 26 | - | | `port` | int | `8088` | HTTP proxy port for routing traffic to containers | | |
| 27 | 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`), this cert is served for matching SNI domains; unmatched domains fall through to the internal CA | | |
| 29 | + | | `tls_cert_file` | string | `""` | Path to PEM certificate file. When set with `tls_key_file`, matching SNI domains use this static cert before ACME or internal CA certs | | |
| 30 | 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 | 32 | | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) | | |
| 33 | 33 | | `registry_domain` | string | - | Deprecated migration key. Set `gordon_domain` instead. | | |
| 34 | 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 | 37 | | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk | | |
| 38 | 38 | | `max_blob_size` | string | `"1GB"` | Maximum cumulative size for one registry blob/layer upload | | |
| 39 | 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 | 41 | | `registry_listen_address` | string | `""` | Bind address for registry (empty = all interfaces) | | |
| 42 | 42 | ||
| 43 | - | ## Port Configuration | |
| 44 | - | ||
| 45 | - | ### Proxy Port | |
| 46 | - | ||
| 47 | - | The `port` setting controls where Gordon listens for HTTP traffic: | |
| 43 | + | Public application traffic is configured under `[entrypoints]`. The standard edge listener is a `smart_tcp` socket: | |
| 48 | 44 | ||
| 49 | 45 | ```toml | |
| 50 | - | [server] | |
| 51 | - | port = 8088 # Default high port for rootless or forwarded setups | |
| 52 | - | ``` | |
| 53 | - | ||
| 54 | - | For rootless containers, you'll typically use a high port and configure firewall port forwarding: | |
| 55 | - | ||
| 56 | - | ```bash | |
| 57 | - | # Forward port 80 to 8088 | |
| 58 | - | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088 | |
| 46 | + | [entrypoints.edge] | |
| 47 | + | address = ":443" | |
| 48 | + | protocol = "smart_tcp" | |
| 59 | 49 | ``` | |
| 60 | 50 | ||
| 61 | - | ### Internal CA and TLS | |
| 51 | + | `entrypoints.edge.address` is not an HTTP or HTTPS port. It is the TCP address Gordon sniffs for cleartext HTTP, h2c, TLS passthrough, normal HTTPS fallback, or explicit raw TCP fallback. Gordon does not provide a built-in default public port; choose `:443`, a high port such as `:9000`, or container/firewall mappings to match your deployment. | |
| 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 | - | ```toml | |
| 66 | - | [server] | |
| 67 | - | tls_port = 8443 # Default. Set to 0 to disable TLS entirely. | |
| 68 | - | ``` | |
| 55 | + | Gordon includes an internal certificate authority that issues on-demand TLS certificates for proxied domains served through HTTPS fallback on a TLS-capable entrypoint such as `smart_tcp`. | |
| 69 | 56 | ||
| 70 | - | With TLS enabled: | |
| 57 | + | With TLS-capable edge traffic: | |
| 71 | 58 | - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/` | |
| 72 | 59 | - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory | |
| 73 | 60 | - The intermediate CA auto-renews before expiry | |
| 74 | - | - An onboarding page at `https://<gordon-host>:<tls_port>/.well-known/gordon/` lets clients download the root CA certificate | |
| 61 | + | - An onboarding page at `https://<gordon-host>/.well-known/gordon/` lets clients download the root CA certificate | |
| 75 | 62 | - The root CA is stable across restarts (generated once, persisted to disk) | |
| 76 | 63 | ||
| 77 | 64 | #### 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 | 68 | ```toml | |
| 82 | 69 | [server] | |
| @@ -84,15 +71,16 @@ tls_cert_file = "/etc/gordon/tls/cert.pem" | |||
| 84 | 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 | 76 | #### 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 | 80 | ```toml | |
| 94 | - | [server] | |
| 95 | - | tls_port = 8443 | |
| 81 | + | [entrypoints.edge] | |
| 82 | + | address = ":443" | |
| 83 | + | protocol = "smart_tcp" | |
| 96 | 84 | ||
| 97 | 85 | [dns] | |
| 98 | 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 | 97 | Challenge behavior: | |
| 110 | - | - `http-01` serves `/.well-known/acme-challenge/<token>` on the HTTP proxy port. | |
| 111 | - | - `cloudflare-dns-01` uses a Cloudflare API token from `pass`, `GORDON_CLOUDFLARE_API_TOKEN_FILE`, or `GORDON_CLOUDFLARE_API_TOKEN`. | |
| 98 | + | - `cloudflare-dns-01` uses a Cloudflare API token from `pass`, `GORDON_CLOUDFLARE_API_TOKEN_FILE`, or `GORDON_CLOUDFLARE_API_TOKEN`; it does not need a special public port 80 edge. | |
| 99 | + | - `http-01` serves `/.well-known/acme-challenge/<token>` through Gordon's HTTP handler and requires an HTTP-capable `smart_tcp` entrypoint reachable on external port 80 for each hostname being validated. | |
| 112 | 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 | 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 | 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 Onboarding | |
| 119 | + | #### 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 | 123 | - `/.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 | 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 | 131 | #### 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 | 135 | ```toml | |
| 147 | 136 | [server] | |
| @@ -150,40 +139,22 @@ force_https_redirect = true | |||
| 150 | 139 | ||
| 151 | 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 TLS | |
| 142 | + | #### 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 | 146 | ```toml | |
| 158 | - | [server] | |
| 159 | - | tls_port = 0 # No HTTPS listener, no internal CA generated | |
| 147 | + | [entrypoints.edge] | |
| 148 | + | 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 | 152 | ```bash | |
| 167 | - | # Forward 443 -> 8443 | |
| 168 | - | sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443 | |
| 153 | + | sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=9000 | |
| 169 | 154 | sudo firewall-cmd --reload | |
| 170 | 155 | ``` | |
| 171 | 156 | ||
| 172 | - | With firewalld and Tailscale: | |
| 173 | - | ||
| 174 | - | ```bash | |
| 175 | - | # Verify tailscale0 is in trusted zone | |
| 176 | - | sudo firewall-cmd --get-zone-of-interface=tailscale0 | |
| 177 | - | ||
| 178 | - | # If needed, place tailscale0 in trusted | |
| 179 | - | sudo firewall-cmd --permanent --zone=trusted --add-interface=tailscale0 | |
| 180 | - | sudo firewall-cmd --reload | |
| 181 | - | ||
| 182 | - | # Forward 443 -> 8443 permanently (IPv4 + IPv6) | |
| 183 | - | sudo firewall-cmd --permanent --zone=trusted --add-rich-rule='rule family=ipv4 forward-port port=443 protocol=tcp to-port=8443' | |
| 184 | - | sudo firewall-cmd --permanent --zone=trusted --add-rich-rule='rule family=ipv6 forward-port port=443 protocol=tcp to-port=8443' | |
| 185 | - | sudo firewall-cmd --reload | |
| 186 | - | ``` | |
| 157 | + | `:443`, `:9000`, and container `-p 443:9000` mappings are deployment choices, not protocol modes. | |
| 187 | 158 | ||
| 188 | 159 | ### 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 | 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 | 199 | Security note: | |
| 229 | 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 | 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 | 376 | ## 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 | 380 | ## Examples | |
| 410 | 381 | ||
| @@ -412,35 +383,44 @@ HSTS headers are sent automatically on the HTTPS listener (when `r.TLS != nil`). | |||
| 412 | 383 | ||
| 413 | 384 | ```toml | |
| 414 | 385 | [server] | |
| 415 | - | port = 8088 | |
| 416 | 386 | registry_port = 5000 | |
| 417 | 387 | gordon_domain = "gordon.local" | |
| 418 | 388 | data_dir = "./dev-data" | |
| 389 | + | ||
| 390 | + | [entrypoints.edge] | |
| 391 | + | address = ":9000" | |
| 392 | + | protocol = "smart_tcp" | |
| 419 | 393 | ``` | |
| 420 | 394 | ||
| 421 | 395 | ### Production Configuration | |
| 422 | 396 | ||
| 423 | 397 | ```toml | |
| 424 | 398 | [server] | |
| 425 | - | port = 8088 | |
| 426 | 399 | registry_port = 5000 | |
| 427 | 400 | gordon_domain = "gordon.company.com" | |
| 428 | 401 | data_dir = "/var/lib/gordon" | |
| 402 | + | ||
| 403 | + | [entrypoints.edge] | |
| 404 | + | address = ":443" | |
| 405 | + | protocol = "smart_tcp" | |
| 429 | 406 | ``` | |
| 430 | 407 | ||
| 431 | 408 | ### Rootless Container Setup | |
| 432 | 409 | ||
| 433 | 410 | ```toml | |
| 434 | 411 | [server] | |
| 435 | - | port = 8088 # High port for rootless | |
| 436 | 412 | registry_port = 5000 | |
| 437 | 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 | 420 | With firewall port forwarding: | |
| 441 | 421 | ||
| 442 | 422 | ```bash | |
| 443 | - | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088 | |
| 423 | + | sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=9000 | |
| 444 | 424 | sudo firewall-cmd --reload | |
| 445 | 425 | ``` | |
| 446 | 426 | ||
| @@ -449,3 +429,5 @@ sudo firewall-cmd --reload | |||
| 449 | 429 | - [Configuration Overview](./index.md) | |
| 450 | 430 | - [Installation Guide](../installation.md) | |
| 451 | 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 | 7 | - A Linux VPS with Docker or Podman installed | |
| 8 | 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 | 10 | - [pass](https://www.passwordstore.org/) (password manager) with GPG key initialized | |
| 11 | 11 | ||
| 12 | 12 | ## 1. Install Gordon | |
| @@ -73,11 +73,13 @@ Edit `~/.config/gordon/gordon.toml`: | |||
| 73 | 73 | ||
| 74 | 74 | ```toml | |
| 75 | 75 | [server] | |
| 76 | - | port = 8088 # HTTP proxy port (default) | |
| 77 | 76 | registry_port = 5000 # Registry port | |
| 78 | - | tls_port = 8443 # HTTPS listener (default) | |
| 79 | 77 | 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 | 83 | [routes] | |
| 82 | 84 | "app.mydomain.com" = "myapp:latest" # Domain → Image mapping | |
| 83 | 85 | ``` | |
| @@ -99,7 +101,7 @@ Why wildcard (`*`)? | |||
| 99 | 101 | ||
| 100 | 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 | 106 | ## 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 | 47 | - [Configuration Overview](./config/index.md) - All configuration options | |
| 48 | 48 | - [Server Settings](./config/server.md) - Ports, domains, and runtime | |
| 49 | 49 | - [Routes](./config/routes.md) - Domain to container mapping | |
| 50 | + | - [Traffic Plane](./config/traffic.md) - TCP, UDP, and TLS passthrough entrypoints | |
| 50 | 51 | - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows | |
| 51 | 52 | - [Secrets](./config/secrets.md) - Secure credential storage | |
| 52 | 53 | - [Network Isolation](./config/network-isolation.md) - Per-app network isolation | |
| @@ -80,7 +81,7 @@ Gordon runs on your VPS and provides: | |||
| 80 | 81 | - Domain pointing to your server | |
| 81 | 82 | - 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 | 86 | ## Related | |
| 86 | 87 | ||
M docs/installation.md +15 -11
| @@ -115,7 +115,7 @@ EOF | |||
| 115 | 115 | ||
| 116 | 116 | ## 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 | 120 | ### Using firewalld | |
| 121 | 121 | ||
| @@ -128,9 +128,10 @@ sudo systemctl enable --now firewalld | |||
| 128 | 128 | sudo firewall-cmd --permanent --add-service=http | |
| 129 | 129 | sudo firewall-cmd --permanent --add-service=https | |
| 130 | 130 | ||
| 131 | - | # For rootless containers, redirect privileged ports | |
| 132 | - | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088 | |
| 133 | - | sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443 | |
| 131 | + | # For rootless services, bind entrypoints.edge.address to a high port (for example :9000) | |
| 132 | + | # and redirect the external TCP edge ports your deployment uses. | |
| 133 | + | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=9000 | |
| 134 | + | sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=9000 | |
| 134 | 135 | ||
| 135 | 136 | # Apply changes | |
| 136 | 137 | sudo firewall-cmd --reload | |
| @@ -165,10 +166,13 @@ Minimum required configuration: | |||
| 165 | 166 | ||
| 166 | 167 | ```toml | |
| 167 | 168 | [server] | |
| 168 | - | port = 8088 | |
| 169 | 169 | registry_port = 5000 | |
| 170 | 170 | gordon_domain = "gordon.yourdomain.com" | |
| 171 | 171 | ||
| 172 | + | [entrypoints.edge] | |
| 173 | + | address = ":443" | |
| 174 | + | protocol = "smart_tcp" | |
| 175 | + | ||
| 172 | 176 | [routes] | |
| 173 | 177 | "app.yourdomain.com" = "myapp:latest" | |
| 174 | 178 | ``` | |
| @@ -249,22 +253,22 @@ Point your domains to your server: | |||
| 249 | 253 | ||
| 250 | 254 | ## 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 | 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 | 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 | 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. | |
| 262 | - | ||
| 263 | - | > **Rootless note:** Unprivileged users can't bind port 80. Forward port 80→8088 (or your configured port) via firewall — see the firewall section above. | |
| 265 | + | > **Important:** For Cloudflare-proxied HTTP paths, set `proxy_allowed_ips` with Cloudflare edge IPs — see [Proxy Origin Allowlist](#proxy-origin-allowlist) below. | |
| 266 | + | > | |
| 267 | + | > **Rootless note:** Unprivileged users can't bind privileged ports. Bind `entrypoints.edge.address` to a high port (for example `:9000`) and forward/map external ports to it via firewall or container settings. | |
| 264 | 268 | ||
| 265 | 269 | ## 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 | 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 | 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 | 62 | ## v2.30.0 to v2.31.0 | |
| 10 | 63 | ||
| 11 | 64 | ### Breaking: Default Port Changes | |
| @@ -117,9 +170,9 @@ If you have automation parsing status-like fields from `routes list`, migrate it | |||
| 117 | 170 | ||
| 118 | 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`) | |
| 121 | - | - `http-01` needs public HTTP reachability for each hostname being validated | |
| 122 | - | - `cloudflare-dns-01` needs a Cloudflare token with zone-read and DNS-edit access for every zone used by HTTPS routes | |
| 173 | + | - Normal HTTPS fallback must be available on a TLS-capable entrypoint | |
| 174 | + | - `http-01` needs an HTTP-capable smart TCP entrypoint reachable on external port 80 for each hostname being validated | |
| 175 | + | - `cloudflare-dns-01` needs a Cloudflare token with zone-read and DNS-edit access for every zone used by HTTPS routes and does not require a special port-80 edge | |
| 123 | 176 | - `obtain_batch_size` limits new ACME orders per reconcile pass to reduce rate-limit spikes | |
| 124 | 177 | ||
| 125 | 178 | ### New: DNS Resolver Settings for ACME DNS-01 | |
M go.mod +1 -1
| @@ -21,6 +21,7 @@ require ( | |||
| 21 | 21 | github.com/mattn/go-runewidth v0.0.23 | |
| 22 | 22 | github.com/muesli/termenv v0.16.0 | |
| 23 | 23 | github.com/rivo/uniseg v0.4.7 | |
| 24 | + | github.com/rs/zerolog v1.35.0 | |
| 24 | 25 | github.com/smallstep/truststore v0.13.0 | |
| 25 | 26 | github.com/spf13/cobra v1.10.2 | |
| 26 | 27 | github.com/spf13/viper v1.21.0 | |
| @@ -77,7 +78,6 @@ require ( | |||
| 77 | 78 | github.com/morikuni/aec v1.1.0 // indirect | |
| 78 | 79 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | |
| 79 | 80 | github.com/muesli/cancelreader v0.2.2 // indirect | |
| 80 | - | github.com/rs/zerolog v1.35.0 // indirect | |
| 81 | 81 | github.com/sirupsen/logrus v1.9.4 // indirect | |
| 82 | 82 | github.com/vbatts/tar-split v0.12.2 // indirect | |
| 83 | 83 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect | |
M gordon.toml.example +50 -2
| @@ -2,8 +2,6 @@ | |||
| 2 | 2 | # Copy this file to ~/.config/gordon/gordon.toml and adjust for your domain. | |
| 3 | 3 | ||
| 4 | 4 | [server] | |
| 5 | - | port = 8088 | |
| 6 | - | tls_port = 8443 | |
| 7 | 5 | registry_port = 5000 | |
| 8 | 6 | gordon_domain = "gordon.example.com" | |
| 9 | 7 | # legacy_registry_domains = ["registry.example.com:5000"] | |
| @@ -20,6 +18,16 @@ gordon_domain = "gordon.example.com" | |||
| 20 | 18 | # max_proxy_response_size = "1GB" | |
| 21 | 19 | # 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 | 31 | [dns] | |
| 24 | 32 | resolvers = ["1.1.1.1:53", "8.8.8.8:53"] | |
| 25 | 33 | propagation_timeout = "5m" | |
| @@ -166,6 +174,46 @@ keep = 14 | |||
| 166 | 174 | [external_routes] | |
| 167 | 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 | 217 | [network_groups] | |
| 170 | 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 | 42 | GetStatus(ctx context.Context) (*remote.Status, error) | |
| 43 | 43 | GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) | |
| 44 | + | GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) | |
| 44 | 45 | Reload(ctx context.Context) error | |
| 45 | 46 | ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) | |
| 46 | 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 | 445 | 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 | 452 | func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | |
| 449 | 453 | if l.configSvc == nil { | |
| 450 | 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 | 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 | 133 | func (r *remoteControlPlane) Reload(ctx context.Context) error { | |
| 130 | 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 | 66 | // - ctx context.Context | |
| 67 | 67 | // - domainOrGroup string | |
| 68 | 68 | // - 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 | 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 | 128 | // AddAutoRouteAllowedDomain is a helper method to define mock.On call | |
| 129 | 129 | // - ctx context.Context | |
| 130 | 130 | // - 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 | 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 | 185 | // AddRoute is a helper method to define mock.On call | |
| 186 | 186 | // - ctx context.Context | |
| 187 | 187 | // - 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 | 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 | 253 | // BackupStatus is a helper method to define mock.On call | |
| 254 | 254 | // - 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 | 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 | 315 | // Bootstrap is a helper method to define mock.On call | |
| 316 | 316 | // - ctx context.Context | |
| 317 | 317 | // - 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 | 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 | 384 | // - ctx context.Context | |
| 385 | 385 | // - owner string | |
| 386 | 386 | // - 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 | 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 | 448 | // - domain1 string | |
| 449 | 449 | // - service string | |
| 450 | 450 | // - 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 | 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 | 516 | // - ctx context.Context | |
| 517 | 517 | // - secretDomain string | |
| 518 | 518 | // - 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 | 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 | 589 | // Deploy is a helper method to define mock.On call | |
| 590 | 590 | // - ctx context.Context | |
| 591 | 591 | // - 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 | 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 | 646 | // DeployIntent is a helper method to define mock.On call | |
| 647 | 647 | // - ctx context.Context | |
| 648 | 648 | // - 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 | 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 | 714 | // DetectDatabases is a helper method to define mock.On call | |
| 715 | 715 | // - ctx context.Context | |
| 716 | 716 | // - 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 | 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 | 782 | // FindAttachmentTargetsByImage is a helper method to define mock.On call | |
| 783 | 783 | // - ctx context.Context | |
| 784 | 784 | // - 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 | 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 | 850 | // FindRoutesByImage is a helper method to define mock.On call | |
| 851 | 851 | // - ctx context.Context | |
| 852 | 852 | // - 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 | 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 | 918 | // GetAllAttachmentsConfig is a helper method to define mock.On call | |
| 919 | 919 | // - 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 | 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 | 980 | // GetAttachmentsConfig is a helper method to define mock.On call | |
| 981 | 981 | // - ctx context.Context | |
| 982 | 982 | // - 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 | 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 | 1048 | // GetAutoRouteAllowedDomains is a helper method to define mock.On call | |
| 1049 | 1049 | // - 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 | 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 | 1110 | // GetConfig is a helper method to define mock.On call | |
| 1111 | 1111 | // - 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 | 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 | 1173 | // - ctx context.Context | |
| 1174 | 1174 | // - logDomain string | |
| 1175 | 1175 | // - 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 | 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 | 1246 | // GetHealth is a helper method to define mock.On call | |
| 1247 | 1247 | // - 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 | 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 | 1308 | // GetProcessLogs is a helper method to define mock.On call | |
| 1309 | 1309 | // - ctx context.Context | |
| 1310 | 1310 | // - 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 | 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 | 1376 | // GetRoute is a helper method to define mock.On call | |
| 1377 | 1377 | // - ctx context.Context | |
| 1378 | 1378 | // - 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 | 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 | 1444 | // GetStatus is a helper method to define mock.On call | |
| 1445 | 1445 | // - 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 | 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 | 1506 | // GetTLSStatus is a helper method to define mock.On call | |
| 1507 | 1507 | // - 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 | 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 | 1532 | 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 | 1597 | // ListBackups provides a mock function for the type MockControlPlane | |
| 1536 | 1598 | func (_mock *MockControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) { | |
| 1537 | 1599 | ret := _mock.Called(ctx, backupDomain) | |
| @@ -1568,7 +1630,7 @@ type MockControlPlane_ListBackups_Call struct { | |||
| 1568 | 1630 | // ListBackups is a helper method to define mock.On call | |
| 1569 | 1631 | // - ctx context.Context | |
| 1570 | 1632 | // - 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 | 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 | 1698 | // ListNetworks is a helper method to define mock.On call | |
| 1637 | 1699 | // - 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 | 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 | 1760 | // ListOrphanedAttachments is a helper method to define mock.On call | |
| 1699 | 1761 | // - 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 | 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 | 1822 | // ListRoutesWithDetails is a helper method to define mock.On call | |
| 1761 | 1823 | // - 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 | 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 | 1884 | // ListSecretsWithAttachments is a helper method to define mock.On call | |
| 1823 | 1885 | // - ctx context.Context | |
| 1824 | 1886 | // - 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 | 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 | 1952 | // ListTags is a helper method to define mock.On call | |
| 1891 | 1953 | // - ctx context.Context | |
| 1892 | 1954 | // - 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 | 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 | 2020 | // ListVolumeBackups is a helper method to define mock.On call | |
| 1959 | 2021 | // - ctx context.Context | |
| 1960 | 2022 | // - 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 | 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 | 2088 | // ListVolumes is a helper method to define mock.On call | |
| 2027 | 2089 | // - 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 | 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 | 2150 | // PruneVolumes is a helper method to define mock.On call | |
| 2089 | 2151 | // - ctx context.Context | |
| 2090 | 2152 | // - 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 | 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 | 2207 | // Reload is a helper method to define mock.On call | |
| 2146 | 2208 | // - 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 | 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 | 2259 | // - ctx context.Context | |
| 2198 | 2260 | // - domainOrGroup string | |
| 2199 | 2261 | // - 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 | 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 | 2321 | // RemoveAutoRouteAllowedDomain is a helper method to define mock.On call | |
| 2260 | 2322 | // - ctx context.Context | |
| 2261 | 2323 | // - 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 | 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 | 2378 | // RemoveRoute is a helper method to define mock.On call | |
| 2317 | 2379 | // - ctx context.Context | |
| 2318 | 2380 | // - 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 | 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 | 2447 | // - ctx context.Context | |
| 2386 | 2448 | // - restartDomain string | |
| 2387 | 2449 | // - 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 | 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 | 2521 | // - ctx context.Context | |
| 2460 | 2522 | // - backupDomain string | |
| 2461 | 2523 | // - 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 | 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 | 2595 | // - ctx context.Context | |
| 2534 | 2596 | // - backupDomain string | |
| 2535 | 2597 | // - 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 | 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 | 2659 | // - domain1 string | |
| 2598 | 2660 | // - service string | |
| 2599 | 2661 | // - 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 | 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 | 2727 | // - ctx context.Context | |
| 2666 | 2728 | // - secretDomain string | |
| 2667 | 2729 | // - 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 | 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 | 2801 | // - ctx context.Context | |
| 2740 | 2802 | // - logDomain string | |
| 2741 | 2803 | // - 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 | 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 | 2874 | // StreamProcessLogs is a helper method to define mock.On call | |
| 2813 | 2875 | // - ctx context.Context | |
| 2814 | 2876 | // - 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 | 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 | 2931 | // UpdateRoute is a helper method to define mock.On call | |
| 2870 | 2932 | // - ctx context.Context | |
| 2871 | 2933 | // - 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 | 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 | 2999 | // VolumeBackupStatus is a helper method to define mock.On call | |
| 2938 | 3000 | // - 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 | 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 | 74 | // ListImages is a helper method to define mock.On call | |
| 75 | 75 | // - 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 | 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 | 136 | // PruneImages is a helper method to define mock.On call | |
| 137 | 137 | // - ctx context.Context | |
| 138 | 138 | // - 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 | 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 | 62 | // Build is a helper method to define mock.On call | |
| 63 | 63 | // - ctx context.Context | |
| 64 | 64 | // - 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 | 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 | 128 | // Exists is a helper method to define mock.On call | |
| 129 | 129 | // - ctx context.Context | |
| 130 | 130 | // - 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 | 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 | 185 | // Push is a helper method to define mock.On call | |
| 186 | 186 | // - ctx context.Context | |
| 187 | 187 | // - 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 | 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 | 243 | // - ctx context.Context | |
| 244 | 244 | // - sourceRef string | |
| 245 | 245 | // - 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 | 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 | 74 | // ListVolumes is a helper method to define mock.On call | |
| 75 | 75 | // - 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 | 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 | 136 | // PruneVolumes is a helper method to define mock.On call | |
| 137 | 137 | // - ctx context.Context | |
| 138 | 138 | // - 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 | 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 | 701 | 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 | 719 | // GetStatus returns the Gordon server status. | |
| 705 | 720 | func (c *Client) GetStatus(ctx context.Context) (*Status, error) { | |
| 706 | 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 | 1366 | // Process complete SSE events | |
| 1352 | 1367 | for { | |
| 1353 | 1368 | data := lineBuffer.String() | |
| 1354 | - | idx := strings.Index(data, "\n\n") | |
| 1355 | - | if idx == -1 { | |
| 1369 | + | event, remaining, ok := strings.Cut(data, "\n\n") | |
| 1370 | + | if !ok { | |
| 1356 | 1371 | break | |
| 1357 | 1372 | } | |
| 1358 | 1373 | ||
| 1359 | - | event := data[:idx] | |
| 1360 | 1374 | lineBuffer.Reset() | |
| 1361 | - | lineBuffer.WriteString(data[idx+2:]) | |
| 1375 | + | lineBuffer.WriteString(remaining) | |
| 1362 | 1376 | ||
| 1363 | 1377 | // Parse SSE data lines | |
| 1364 | - | for _, line := range strings.Split(event, "\n") { | |
| 1365 | - | if strings.HasPrefix(line, "data: ") { | |
| 1366 | - | logLine := strings.TrimPrefix(line, "data: ") | |
| 1378 | + | for line := range strings.SplitSeq(event, "\n") { | |
| 1379 | + | logLine, ok := strings.CutPrefix(line, "data: ") | |
| 1380 | + | if ok { | |
| 1367 | 1381 | select { | |
| 1368 | 1382 | case ch <- logLine: | |
| 1369 | 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 | 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 | 137 | func TestClientGetTLSStatus(t *testing.T) { | |
| 121 | 138 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 122 | 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 | 154 | tlsCmd.GroupID = groupManage | |
| 155 | 155 | rootCmd.AddCommand(tlsCmd) | |
| 156 | 156 | ||
| 157 | + | trafficCmd := newTrafficCmd() | |
| 158 | + | trafficCmd.GroupID = groupManage | |
| 159 | + | rootCmd.AddCommand(trafficCmd) | |
| 160 | + | ||
| 157 | 161 | // Client-only commands (no server needed) | |
| 158 | 162 | remotesCmd := newRemotesCmd() | |
| 159 | 163 | 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 | 54 | previewSvc previewService | |
| 55 | 55 | reloadTrigger reloadTrigger | |
| 56 | 56 | publicTLSSvc in.PublicTLSService | |
| 57 | + | trafficSvc in.TrafficStatusService | |
| 57 | 58 | log zerowrap.Logger | |
| 58 | 59 | } | |
| 59 | 60 | ||
| @@ -202,6 +203,7 @@ type HandlerDeps struct { | |||
| 202 | 203 | VolumeSvc in.VolumeService | |
| 203 | 204 | ReloadTrigger reloadTrigger | |
| 204 | 205 | PublicTLSSvc in.PublicTLSService | |
| 206 | + | TrafficSvc in.TrafficStatusService | |
| 205 | 207 | } | |
| 206 | 208 | ||
| 207 | 209 | // NewHandler creates a new admin HTTP handler. | |
| @@ -221,6 +223,7 @@ func NewHandler(deps HandlerDeps) *Handler { | |||
| 221 | 223 | previewSvc: deps.PreviewSvc, | |
| 222 | 224 | reloadTrigger: deps.ReloadTrigger, | |
| 223 | 225 | publicTLSSvc: deps.PublicTLSSvc, | |
| 226 | + | trafficSvc: deps.TrafficSvc, | |
| 224 | 227 | log: deps.Log, | |
| 225 | 228 | } | |
| 226 | 229 | } | |
| @@ -274,6 +277,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 274 | 277 | "/attachments/orphans": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentOrphans(w, r) }, | |
| 275 | 278 | "/attachments/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentPrune(w, r) }, | |
| 276 | 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 | 282 | if handler, ok := exactRoutes[path]; ok { | |
| 279 | 283 | 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 | 142 | log := zerowrap.FromCtx(ctx) | |
| 143 | 143 | ||
| 144 | - | // Convert ports to Docker format | |
| 145 | - | exposedPorts := make(nat.PortSet) | |
| 146 | - | portBindings := make(nat.PortMap) | |
| 147 | - | ||
| 148 | - | for _, port := range config.Ports { | |
| 149 | - | containerPort := nat.Port(fmt.Sprintf("%d/tcp", port)) | |
| 150 | - | exposedPorts[containerPort] = struct{}{} | |
| 151 | - | ||
| 152 | - | // Bind to random available port on localhost only. | |
| 153 | - | // SECURITY: Using 127.0.0.1 prevents direct access from the network, | |
| 154 | - | // forcing all traffic through Gordon's reverse proxy where auth, | |
| 155 | - | // rate limiting, and security headers are applied. | |
| 156 | - | portBindings[containerPort] = []nat.PortBinding{ | |
| 157 | - | { | |
| 158 | - | HostIP: "127.0.0.1", | |
| 159 | - | HostPort: "0", // Docker will assign a random available port | |
| 160 | - | }, | |
| 161 | - | } | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | // Convert volumes to Docker format | |
| 165 | - | var binds []string | |
| 166 | - | if config.Volumes != nil { | |
| 167 | - | for containerPath, volumeName := range config.Volumes { | |
| 168 | - | bind := fmt.Sprintf("%s:%s", volumeName, containerPath) | |
| 169 | - | binds = append(binds, bind) | |
| 170 | - | log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding volume mount") | |
| 171 | - | } | |
| 172 | - | } | |
| 173 | - | if config.ReadOnlyVolumes != nil { | |
| 174 | - | for containerPath, volumeName := range config.ReadOnlyVolumes { | |
| 175 | - | bind := fmt.Sprintf("%s:%s:ro", volumeName, containerPath) | |
| 176 | - | binds = append(binds, bind) | |
| 177 | - | log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding read-only volume mount") | |
| 178 | - | } | |
| 144 | + | exposedPorts, portBindings, err := buildPortBindings(config) | |
| 145 | + | if err != nil { | |
| 146 | + | return nil, err | |
| 179 | 147 | } | |
| 148 | + | binds := buildVolumeBinds(config, log) | |
| 180 | 149 | ||
| 181 | 150 | // Create container configuration | |
| 182 | 151 | containerConfig := &container.Config{ | |
| @@ -250,6 +219,59 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC | |||
| 250 | 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 | 275 | // StartContainer starts a container. | |
| 254 | 276 | func (r *Runtime) StartContainer(ctx context.Context, containerID string) error { | |
| 255 | 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 | 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 | 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 | 90 | var createBody map[string]any | |
| 20 | 91 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 21 | 92 | switch { | |
| @@ -45,18 +116,10 @@ func TestRuntime_CreateContainerAppliesRestartPolicy(t *testing.T) { | |||
| 45 | 116 | require.NoError(t, err) | |
| 46 | 117 | ||
| 47 | 118 | runtime := NewRuntimeWithClient(cli) | |
| 48 | - | created, err := runtime.CreateContainer(context.Background(), &domain.ContainerConfig{ | |
| 49 | - | Image: "nginx:latest", | |
| 50 | - | Name: "gordon-app.example.com", | |
| 51 | - | RestartPolicy: domain.RestartPolicyAlways, | |
| 52 | - | }) | |
| 119 | + | created, err := runtime.CreateContainer(context.Background(), config) | |
| 53 | 120 | require.NoError(t, err) | |
| 54 | 121 | assert.Equal(t, "abc123", created.ID) | |
| 55 | - | ||
| 56 | 122 | require.NotNil(t, createBody) | |
| 57 | - | hostConfig, ok := createBody["HostConfig"].(map[string]any) | |
| 58 | - | require.True(t, ok) | |
| 59 | - | restartPolicy, ok := hostConfig["RestartPolicy"].(map[string]any) | |
| 60 | - | require.True(t, ok) | |
| 61 | - | assert.Equal(t, domain.RestartPolicyAlways, restartPolicy["Name"]) | |
| 123 | + | ||
| 124 | + | return createBody | |
| 62 | 125 | } | |
M internal/app/public_tls_wiring_test.go +154 -0
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "testing" | |
| 12 | 12 | "time" | |
| 13 | 13 | ||
| 14 | + | "github.com/spf13/viper" | |
| 14 | 15 | "github.com/stretchr/testify/assert" | |
| 15 | 16 | "github.com/stretchr/testify/mock" | |
| 16 | 17 | "github.com/stretchr/testify/require" | |
| @@ -19,10 +20,163 @@ import ( | |||
| 19 | 20 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 20 | 21 | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 21 | 22 | "github.com/bnema/gordon/internal/domain" | |
| 23 | + | config "github.com/bnema/gordon/internal/usecase/config" | |
| 22 | 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 | 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 | 180 | // generateTestCert creates a self-signed certificate for the given hostnames. | |
| 27 | 181 | func generateTestCert(t *testing.T, hosts ...string) tls.Certificate { | |
| 28 | 182 | t.Helper() | |
M internal/app/run.go +403 -131
| @@ -57,6 +57,7 @@ import ( | |||
| 57 | 57 | "github.com/bnema/gordon/internal/adapters/in/http/onboarding" | |
| 58 | 58 | proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy" | |
| 59 | 59 | "github.com/bnema/gordon/internal/adapters/in/http/registry" | |
| 60 | + | trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic" | |
| 60 | 61 | ||
| 61 | 62 | // Boundaries | |
| 62 | 63 | "github.com/bnema/gordon/internal/boundaries/in" | |
| @@ -84,6 +85,8 @@ import ( | |||
| 84 | 85 | "github.com/bnema/gordon/internal/usecase/publictls" | |
| 85 | 86 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | |
| 86 | 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 | 90 | volumesSvc "github.com/bnema/gordon/internal/usecase/volumes" | |
| 88 | 91 | ||
| 89 | 92 | // Pkg | |
| @@ -174,6 +177,11 @@ type Config struct { | |||
| 174 | 177 | } `mapstructure:"rate_limit"` | |
| 175 | 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 | 185 | Backups struct { | |
| 178 | 186 | // Legacy database backup keys. Prefer backups.databases.* for new configs. | |
| 179 | 187 | Enabled bool `mapstructure:"enabled"` | |
| @@ -255,43 +263,50 @@ type Config struct { | |||
| 255 | 263 | ||
| 256 | 264 | // services holds all the services used by the application. | |
| 257 | 265 | type services struct { | |
| 258 | - | runtime *docker.Runtime | |
| 259 | - | eventBus *eventbus.InMemory | |
| 260 | - | blobStorage *filesystem.BlobStorage | |
| 261 | - | manifestStorage *filesystem.ManifestStorage | |
| 262 | - | backupStorage *filesystem.BackupStorage | |
| 263 | - | volumeBackupStore out.VolumeBackupStorage | |
| 264 | - | volumeBackupCfg domain.VolumeBackupConfig | |
| 265 | - | envLoader out.EnvLoader | |
| 266 | - | logWriter *logwriter.LogWriter | |
| 267 | - | tokenStore out.TokenStore | |
| 268 | - | configSvc *config.Service | |
| 269 | - | secretSvc *secretsSvc.Service | |
| 270 | - | containerSvc *container.Service | |
| 271 | - | backupSvc *backup.Service | |
| 272 | - | volumeBackupSvc *backup.VolumeService | |
| 273 | - | registrySvc *registrySvc.Service | |
| 274 | - | healthSvc *health.Service | |
| 275 | - | logSvc *logs.Service | |
| 276 | - | imageSvc *images.Service | |
| 277 | - | volumeSvc *volumesSvc.Service | |
| 278 | - | proxySvc *proxy.Service | |
| 279 | - | authSvc *auth.Service | |
| 280 | - | authHandler *authhandler.Handler | |
| 281 | - | adminHandler *admin.Handler | |
| 282 | - | internalRegUser string | |
| 283 | - | internalRegPass string | |
| 284 | - | previewStore *filesystem.PreviewStore | |
| 285 | - | previewService *preview.Service | |
| 286 | - | envDir string | |
| 287 | - | maxBlobChunkSize int64 | |
| 288 | - | maxBlobSize int64 | |
| 289 | - | caAdapter *pkiadapter.CA | |
| 290 | - | pkiSvc *pkiusecase.Service | |
| 291 | - | reloadCoordinator *reloadCoordinator | |
| 292 | - | publicTLSSvc in.PublicTLSService | |
| 293 | - | publicTLSRuntime publicTLSRuntime | |
| 294 | - | registryHandler interface { | |
| 266 | + | runtime *docker.Runtime | |
| 267 | + | eventBus *eventbus.InMemory | |
| 268 | + | blobStorage *filesystem.BlobStorage | |
| 269 | + | manifestStorage *filesystem.ManifestStorage | |
| 270 | + | backupStorage *filesystem.BackupStorage | |
| 271 | + | volumeBackupStore out.VolumeBackupStorage | |
| 272 | + | volumeBackupCfg domain.VolumeBackupConfig | |
| 273 | + | envLoader out.EnvLoader | |
| 274 | + | logWriter *logwriter.LogWriter | |
| 275 | + | tokenStore out.TokenStore | |
| 276 | + | configSvc *config.Service | |
| 277 | + | secretSvc *secretsSvc.Service | |
| 278 | + | containerSvc *container.Service | |
| 279 | + | backupSvc *backup.Service | |
| 280 | + | volumeBackupSvc *backup.VolumeService | |
| 281 | + | registrySvc *registrySvc.Service | |
| 282 | + | healthSvc *health.Service | |
| 283 | + | logSvc *logs.Service | |
| 284 | + | imageSvc *images.Service | |
| 285 | + | volumeSvc *volumesSvc.Service | |
| 286 | + | proxySvc *proxy.Service | |
| 287 | + | standaloneServiceSvc in.StandaloneServiceService | |
| 288 | + | serviceSecretProvider out.SecretProvider | |
| 289 | + | authSvc *auth.Service | |
| 290 | + | authHandler *authhandler.Handler | |
| 291 | + | adminHandler *admin.Handler | |
| 292 | + | httpProxyHandler http.Handler | |
| 293 | + | httpsProxyHandler http.Handler | |
| 294 | + | internalRegUser string | |
| 295 | + | internalRegPass string | |
| 296 | + | previewStore *filesystem.PreviewStore | |
| 297 | + | previewService *preview.Service | |
| 298 | + | envDir string | |
| 299 | + | maxBlobChunkSize int64 | |
| 300 | + | maxBlobSize int64 | |
| 301 | + | caAdapter *pkiadapter.CA | |
| 302 | + | pkiSvc *pkiusecase.Service | |
| 303 | + | reloadCoordinator *reloadCoordinator | |
| 304 | + | publicTLSSvc in.PublicTLSService | |
| 305 | + | publicTLSRuntime publicTLSRuntime | |
| 306 | + | trafficManager *trafficadapter.Manager | |
| 307 | + | tlsHTTPEntryPoints map[string]struct{} | |
| 308 | + | smartHTTPEntryPoints map[string]struct{} | |
| 309 | + | registryHandler interface { | |
| 295 | 310 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | |
| 296 | 311 | } | |
| 297 | 312 | } | |
| @@ -553,7 +568,7 @@ func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config, | |||
| 553 | 568 | return nil, err | |
| 554 | 569 | } | |
| 555 | 570 | ||
| 556 | - | if err := si.initRuntimeAndProxy(); err != nil { | |
| 571 | + | if err := si.initRuntimeProxyAndTraffic(); err != nil { | |
| 557 | 572 | return nil, err | |
| 558 | 573 | } | |
| 559 | 574 | ||
| @@ -567,8 +582,8 @@ func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config, | |||
| 567 | 582 | ||
| 568 | 583 | // initPKI initialises the internal CA and PKI service when TLS is enabled. | |
| 569 | 584 | func (si *serviceInit) initPKI() error { | |
| 570 | - | if si.cfg.Server.TLSPort == 0 { | |
| 571 | - | si.log.Info().Msg("internal CA disabled (server.tls_port=0)") | |
| 585 | + | if !hasTLSCapableEntrypoint(si.cfg) { | |
| 586 | + | si.log.Info().Msg("internal CA disabled (no TLS-capable entrypoint configured)") | |
| 572 | 587 | return nil | |
| 573 | 588 | } | |
| 574 | 589 | ||
| @@ -591,8 +606,8 @@ func (si *serviceInit) initPublicTLS() error { | |||
| 591 | 606 | return nil | |
| 592 | 607 | } | |
| 593 | 608 | ||
| 594 | - | if si.cfg.Server.TLSPort == 0 { | |
| 595 | - | return fmt.Errorf("%w: tls.acme.enabled requires server.tls_port > 0", domain.ErrACMEChallengeInvalid) | |
| 609 | + | if err := validatePublicTLSReadiness(si.cfg); err != nil { | |
| 610 | + | return err | |
| 596 | 611 | } | |
| 597 | 612 | ||
| 598 | 613 | ctx := si.ctx | |
| @@ -607,8 +622,8 @@ func (si *serviceInit) initPublicTLS() error { | |||
| 607 | 622 | Enabled: si.cfg.TLS.ACME.Enabled, | |
| 608 | 623 | Email: si.cfg.TLS.ACME.Email, | |
| 609 | 624 | Challenge: si.cfg.TLS.ACME.Challenge, | |
| 610 | - | HTTPPort: si.cfg.Server.Port, | |
| 611 | - | TLSPort: si.cfg.Server.TLSPort, | |
| 625 | + | HTTPPort: effectiveHTTP01Port(si.cfg), | |
| 626 | + | TLSPort: effectivePublicTLSPort(si.cfg), | |
| 612 | 627 | DataDir: resolveDataDir(si.cfg.Server.DataDir), | |
| 613 | 628 | ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize, | |
| 614 | 629 | DNS: dnsCfg, | |
| @@ -620,6 +635,9 @@ func (si *serviceInit) initPublicTLS() error { | |||
| 620 | 635 | if err != nil { | |
| 621 | 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 | 642 | store, err := acmestore.New(filepath.Join(resolveDataDir(si.cfg.Server.DataDir), "acme")) | |
| 625 | 643 | if err != nil { | |
| @@ -700,11 +718,20 @@ func (si *serviceInit) initSecrets() error { | |||
| 700 | 718 | return err | |
| 701 | 719 | } | |
| 702 | 720 | ||
| 721 | + | si.svc.serviceSecretProvider = createStandaloneServiceSecretProvider(backend, resolveDataDir(si.cfg.Server.DataDir), si.log) | |
| 703 | 722 | si.svc.secretSvc = secretsSvc.NewService(domainSecretStore, si.log, si.svc.eventBus) | |
| 704 | 723 | return nil | |
| 705 | 724 | } | |
| 706 | 725 | ||
| 707 | 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 | 735 | func (si *serviceInit) initRuntimeAndProxy() error { | |
| 709 | 736 | var err error | |
| 710 | 737 | ||
| @@ -732,6 +759,7 @@ func (si *serviceInit) initRuntimeAndProxy() error { | |||
| 732 | 759 | si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize | |
| 733 | 760 | si.svc.maxBlobSize = proxyCfg.maxBlobSize | |
| 734 | 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 | 764 | // Wire synchronous proxy cache invalidation for zero-downtime deployments. | |
| 737 | 765 | // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget(). | |
| @@ -751,6 +779,22 @@ func (si *serviceInit) registerReloadCoordinatorHooks() { | |||
| 751 | 779 | if err != nil { | |
| 752 | 780 | 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 | 798 | si.svc.containerSvc.UpdateConfig(containerCfg) | |
| 755 | 799 | return nil | |
| 756 | 800 | }) | |
| @@ -771,6 +815,9 @@ func (si *serviceInit) initHandlers() { | |||
| 771 | 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 | 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 | 822 | si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{ | |
| 776 | 823 | ConfigSvc: si.svc.configSvc, | |
| @@ -788,6 +835,7 @@ func (si *serviceInit) initHandlers() { | |||
| 788 | 835 | ImageSvc: si.svc.imageSvc, | |
| 789 | 836 | VolumeSvc: si.svc.volumeSvc, | |
| 790 | 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 | 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 | 1692 | func readUnsafeSecret(dataDir, secretPath string) (string, error) { | |
| 1620 | 1693 | if filepath.IsAbs(secretPath) { | |
| 1621 | 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 | 2415 | middleware.PanicRecovery(log), | |
| 2343 | 2416 | middleware.RequestLogger(log, trustedNets), | |
| 2344 | 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 | 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 | 2431 | // Build the onboarding handler once if internal CA is available and TLS is enabled. | |
| 2359 | 2432 | var obHandler *onboarding.Handler | |
| 2360 | - | if svc.caAdapter != nil && cfg.Server.TLSPort != 0 { | |
| 2433 | + | if svc.caAdapter != nil && effectiveProxyTLSPort(cfg) != 0 { | |
| 2361 | 2434 | mobileconfigBytes := pkiadapter.GenerateMobileconfig( | |
| 2362 | 2435 | svc.caAdapter.RootCertificateDER(), | |
| 2363 | 2436 | svc.caAdapter.RootCommonName(), | |
| @@ -2366,8 +2439,8 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr | |||
| 2366 | 2439 | svc.caAdapter.RootCertificate(), | |
| 2367 | 2440 | mobileconfigBytes, | |
| 2368 | 2441 | svc.caAdapter.RootFingerprint(), | |
| 2369 | - | cfg.Server.Port, | |
| 2370 | - | cfg.Server.TLSPort, | |
| 2442 | + | effectiveProxyHTTPPort(cfg), | |
| 2443 | + | effectiveProxyTLSPort(cfg), | |
| 2371 | 2444 | ) | |
| 2372 | 2445 | } | |
| 2373 | 2446 | ||
| @@ -2433,6 +2506,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr | |||
| 2433 | 2506 | proxyOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(proxyOut) | |
| 2434 | 2507 | httpsOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(httpsOut) | |
| 2435 | 2508 | } | |
| 2509 | + | svc.httpsProxyHandler = httpsOut | |
| 2436 | 2510 | ||
| 2437 | 2511 | return registryOut, proxyOut, httpsOut | |
| 2438 | 2512 | } | |
| @@ -2780,6 +2854,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||
| 2780 | 2854 | errChan := make(chan error, 3) | |
| 2781 | 2855 | ||
| 2782 | 2856 | registryHandler, httpProxyHandler, httpsProxyHandler := createHTTPHandlers(svc, cfg, log, accessWriter) | |
| 2857 | + | svc.httpProxyHandler = httpProxyHandler | |
| 2858 | + | svc.httpsProxyHandler = httpsProxyHandler | |
| 2783 | 2859 | ||
| 2784 | 2860 | registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort)) | |
| 2785 | 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 | 2878 | if err != nil { | |
| 2803 | 2879 | closeStarted(registrySrv) | |
| 2804 | 2880 | 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. | |
| 2808 | - | // This prevents the race where auto-start pulls from the registry before it's listening. | |
| 2809 | - | if err := waitForServerReady(registryReady, errChan); err != nil { | |
| 2810 | - | closeStarted(registrySrv, proxySrv, tlsSrv) | |
| 2811 | - | return err | |
| 2812 | - | } | |
| 2813 | - | if err := waitForServerReady(proxyReady, errChan); err != nil { | |
| 2885 | + | // Wait for the registry and HTTP proxy to bind before applying the traffic graph. | |
| 2886 | + | // This prevents auto-start races while keeping the TLS mux under one owner. | |
| 2887 | + | if err := waitForCoreProxyReadyAndApplyTraffic(ctx, cfg, svc, registryReady, proxyReady, errChan); err != nil { | |
| 2814 | 2888 | closeStarted(registrySrv, proxySrv, tlsSrv) | |
| 2815 | 2889 | 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 | 2892 | logEvent := log.Info(). | |
| 2825 | 2893 | Int("proxy_port", cfg.Server.Port). | |
| 2826 | 2894 | Int("registry_port", cfg.Server.RegistryPort) | |
| 2827 | - | if tlsSrv != nil { | |
| 2895 | + | if cfg.Server.TLSPort != 0 { | |
| 2828 | 2896 | logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort) | |
| 2829 | 2897 | } | |
| 2830 | 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 | 2902 | schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v) | |
| 2835 | 2903 | if err != nil { | |
| 2904 | + | shutdownTrafficManagerForStartupCleanup(svc.trafficManager, log) | |
| 2905 | + | closeStarted(registrySrv, proxySrv, tlsSrv) | |
| 2836 | 2906 | return err | |
| 2837 | 2907 | } | |
| 2838 | 2908 | if schedulerCleanup != nil { | |
| @@ -2844,7 +2914,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||
| 2844 | 2914 | ||
| 2845 | 2915 | waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log) | |
| 2846 | 2916 | 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 | 2918 | 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 | 2965 | func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error { | |
| 2966 | + | if ready == nil { | |
| 2967 | + | return nil | |
| 2968 | + | } | |
| 2858 | 2969 | select { | |
| 2859 | 2970 | case <-ready: | |
| 2860 | 2971 | return nil | |
| @@ -3096,7 +3207,7 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl | |||
| 3096 | 3207 | ||
| 3097 | 3208 | // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down | |
| 3098 | 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 | 3211 | log.Info().Msg("shutting down Gordon...") | |
| 3101 | 3212 | ||
| 3102 | 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 | 3232 | // Stop PKI maintenance goroutines | |
| 3116 | 3233 | if pkiSvc != nil { | |
| 3117 | 3234 | pkiSvc.Stop() | |
| @@ -3234,31 +3351,170 @@ func matchingStaticCert(certs []tls.Certificate, serverName string) *tls.Certifi | |||
| 3234 | 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) { | |
| 3238 | - | // HTTP listener (Cloudflare proxy + onboarding for direct clients) | |
| 3239 | - | var httpProtos http.Protocols | |
| 3240 | - | httpProtos.SetHTTP1(true) | |
| 3241 | - | httpProtos.SetUnencryptedHTTP2(true) | |
| 3242 | - | httpSrv, httpReady := startServer( | |
| 3243 | - | fmt.Sprintf(":%d", cfg.Server.Port), | |
| 3244 | - | httpHandler, | |
| 3245 | - | "proxy-http", | |
| 3246 | - | &httpProtos, | |
| 3247 | - | errChan, | |
| 3248 | - | log, | |
| 3249 | - | ) | |
| 3354 | + | func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, trafficManager *trafficadapter.Manager, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { | |
| 3355 | + | var httpSrv *http.Server | |
| 3356 | + | var httpReady <-chan struct{} | |
| 3250 | 3357 | ||
| 3251 | - | if cfg.Server.TLSPort == 0 { | |
| 3358 | + | needsTLS := hasTLSCapableEntrypoint(cfg) | |
| 3359 | + | if !hasSmartTCPEntrypoint(cfg) && !needsTLS { | |
| 3252 | 3360 | 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 the | |
| 3256 | - | // certificate selector has full control over priority ordering. | |
| 3512 | + | func proxyTLSConfig(cfg Config, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, log zerowrap.Logger) (*tls.Config, error) { | |
| 3257 | 3513 | var staticCerts []tls.Certificate | |
| 3258 | 3514 | if cfg.Server.TLSCertFile != "" { | |
| 3259 | 3515 | staticCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile) | |
| 3260 | 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 | 3519 | staticCerts = []tls.Certificate{staticCert} | |
| 3264 | 3520 | log.Info(). | |
| @@ -3266,30 +3522,81 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv | |||
| 3266 | 3522 | Str("key", cfg.Server.TLSKeyFile). | |
| 3267 | 3523 | Msg("loaded static TLS certificate (public ACME and internal CA handle remaining domains)") | |
| 3268 | 3524 | } | |
| 3269 | - | ||
| 3270 | 3525 | selector := &certificateSelector{ | |
| 3271 | 3526 | staticCerts: prepareStaticTLSCertificates(staticCerts), | |
| 3272 | 3527 | publicTLS: publicTLS, | |
| 3273 | 3528 | localPKI: pkiSvc, | |
| 3274 | 3529 | } | |
| 3275 | - | ||
| 3276 | - | // HTTPS listener — static cert (if provided) takes priority by SNI match via | |
| 3277 | - | // certificateSelector, then public ACME TLS, then on-demand internal CA certs. | |
| 3278 | - | tlsConfig := &tls.Config{ | |
| 3530 | + | return &tls.Config{ | |
| 3279 | 3531 | MinVersion: tls.VersionTLS12, | |
| 3280 | 3532 | GetCertificate: selector.GetCertificate, | |
| 3281 | 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( | |
| 3284 | - | fmt.Sprintf(":%d", cfg.Server.TLSPort), | |
| 3285 | - | httpsHandler, | |
| 3286 | - | "proxy-tls", | |
| 3287 | - | tlsConfig, | |
| 3288 | - | errChan, | |
| 3289 | - | log, | |
| 3290 | - | ) | |
| 3541 | + | next := smartTCPHTTPServerNames(cfg) | |
| 3542 | + | for name := range previous { | |
| 3543 | + | if _, ok := next[name]; !ok { | |
| 3544 | + | manager.SetSmartTCPHTTPServer(name, nil, nil) | |
| 3545 | + | manager.SetSmartTCPTLSServer(name, nil, nil) | |
| 3546 | + | } | |
| 3547 | + | } | |
| 3548 | + | var httpProtos http.Protocols | |
| 3549 | + | httpProtos.SetHTTP1(true) | |
| 3550 | + | httpProtos.SetUnencryptedHTTP2(true) | |
| 3551 | + | for name := range next { | |
| 3552 | + | if httpHandler != nil { | |
| 3553 | + | manager.SetSmartTCPHTTPServer(name, httpHandler, &httpProtos) | |
| 3554 | + | } | |
| 3555 | + | if httpsHandler != nil && tlsConfig != nil { | |
| 3556 | + | manager.SetSmartTCPTLSServer(name, httpsHandler, tlsConfig) | |
| 3557 | + | } | |
| 3558 | + | } | |
| 3559 | + | return next | |
| 3560 | + | } | |
| 3561 | + | ||
| 3562 | + | func smartTCPHTTPServerNames(cfg Config) map[string]struct{} { | |
| 3563 | + | names := map[string]struct{}{} | |
| 3564 | + | for name, entryPoint := range cfg.EntryPoints { | |
| 3565 | + | if entryPoint.Protocol == domain.EntryPointProtocolSmartTCP { | |
| 3566 | + | names[name] = struct{}{} | |
| 3567 | + | } | |
| 3568 | + | } | |
| 3569 | + | return names | |
| 3570 | + | } | |
| 3291 | 3571 | ||
| 3292 | - | return httpSrv, httpReady, tlsSrv, tlsReady, nil | |
| 3572 | + | func registerTLSMuxHTTPServers(manager *trafficadapter.Manager, cfg Config, httpsHandler http.Handler, tlsConfig *tls.Config, previous map[string]struct{}) map[string]struct{} { | |
| 3573 | + | if manager == nil { | |
| 3574 | + | return previous | |
| 3575 | + | } | |
| 3576 | + | next := tlsMuxHTTPServerNames(cfg) | |
| 3577 | + | for name := range previous { | |
| 3578 | + | if _, ok := next[name]; !ok { | |
| 3579 | + | manager.SetTLSHTTPServer(name, nil, nil) | |
| 3580 | + | } | |
| 3581 | + | } | |
| 3582 | + | if httpsHandler == nil || tlsConfig == nil { | |
| 3583 | + | return next | |
| 3584 | + | } | |
| 3585 | + | for name := range next { | |
| 3586 | + | manager.SetTLSHTTPServer(name, httpsHandler, tlsConfig) | |
| 3587 | + | } | |
| 3588 | + | return next | |
| 3589 | + | } | |
| 3590 | + | ||
| 3591 | + | func tlsMuxHTTPServerNames(cfg Config) map[string]struct{} { | |
| 3592 | + | names := map[string]struct{}{} | |
| 3593 | + | for name, entryPoint := range cfg.EntryPoints { | |
| 3594 | + | domainEntryPoint := domain.EntryPoint{Name: name, Protocol: entryPoint.Protocol} | |
| 3595 | + | if trafficManagerOwnsEntryPoint(domainEntryPoint) && domainEntryPoint.Protocol == domain.EntryPointProtocolTLSMux { | |
| 3596 | + | names[name] = struct{}{} | |
| 3597 | + | } | |
| 3598 | + | } | |
| 3599 | + | return names | |
| 3293 | 3600 | } | |
| 3294 | 3601 | ||
| 3295 | 3602 | // 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 | 3636 | 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 | 3639 | // SendReloadSignal sends SIGUSR1 to the running Gordon process. | |
| 3366 | 3640 | func SendReloadSignal() error { | |
| 3367 | 3641 | process, _, err := findRunningProcess() | |
| @@ -3613,10 +3887,8 @@ func isProcessAlive(pid int) bool { | |||
| 3613 | 3887 | ||
| 3614 | 3888 | // loadConfig loads configuration from file and sets defaults. | |
| 3615 | 3889 | func loadConfig(v *viper.Viper, configPath string) error { | |
| 3616 | - | v.SetDefault("server.port", 8088) | |
| 3617 | 3890 | v.SetDefault("server.registry_port", 5000) | |
| 3618 | 3891 | v.SetDefault("server.legacy_registry_domains", []string{}) | |
| 3619 | - | v.SetDefault("server.tls_port", 8443) | |
| 3620 | 3892 | v.SetDefault("server.tls_cert_file", "") | |
| 3621 | 3893 | v.SetDefault("server.tls_key_file", "") | |
| 3622 | 3894 | v.SetDefault("tls.acme.enabled", false) | |
M internal/app/run_http_handlers_test.go +55 -2
| @@ -21,7 +21,9 @@ import ( | |||
| 21 | 21 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 22 | 22 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 23 | 23 | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 24 | + | "github.com/bnema/gordon/internal/domain" | |
| 24 | 25 | proxyusecase "github.com/bnema/gordon/internal/usecase/proxy" | |
| 26 | + | traffic "github.com/bnema/gordon/internal/usecase/traffic" | |
| 25 | 27 | ) | |
| 26 | 28 | ||
| 27 | 29 | func newNotFoundProxyService(t *testing.T) *proxyusecase.Service { | |
| @@ -126,6 +128,9 @@ func newOnboardingConfig() Config { | |||
| 126 | 128 | cfg.Server.Port = 8088 | |
| 127 | 129 | cfg.Server.TLSPort = 8443 | |
| 128 | 130 | 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 | 134 | return cfg | |
| 130 | 135 | } | |
| 131 | 136 | ||
| @@ -240,6 +245,52 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t * | |||
| 240 | 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 | 294 | func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailableOnGordonDomain(t *testing.T) { | |
| 244 | 295 | t.Parallel() | |
| 245 | 296 | ca := newTestCA(t) | |
| @@ -401,7 +452,7 @@ func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T) | |||
| 401 | 452 | t.Parallel() | |
| 402 | 453 | ca := newTestCA(t) | |
| 403 | 454 | cfg := newOnboardingConfig() | |
| 404 | - | cfg.Server.TLSPort = 0 // TLS disabled | |
| 455 | + | cfg.EntryPoints = nil // TLS disabled: no TLS-capable traffic entrypoint | |
| 405 | 456 | svc := &services{caAdapter: ca} | |
| 406 | 457 | ||
| 407 | 458 | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| @@ -473,7 +524,9 @@ func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) { | |||
| 473 | 524 | log: zerowrap.Default(), | |
| 474 | 525 | svc: &services{}, | |
| 475 | 526 | } | |
| 476 | - | si.cfg.Server.TLSPort = 8443 | |
| 527 | + | si.cfg.EntryPoints = map[string]traffic.EntryPointConfig{ | |
| 528 | + | traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}, | |
| 529 | + | } | |
| 477 | 530 | dataDir := t.TempDir() | |
| 478 | 531 | blockingPath := filepath.Join(dataDir, "not-a-directory") | |
| 479 | 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 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "net" | |
| 7 | + | "net/http" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strconv" | |
| 11 | + | "strings" | |
| 6 | 12 | "sync" | |
| 7 | 13 | "testing" | |
| 8 | 14 | "time" | |
| @@ -10,13 +16,18 @@ import ( | |||
| 10 | 16 | "github.com/bnema/zerowrap" | |
| 11 | 17 | "github.com/spf13/viper" | |
| 12 | 18 | "github.com/stretchr/testify/assert" | |
| 19 | + | "github.com/stretchr/testify/mock" | |
| 13 | 20 | "github.com/stretchr/testify/require" | |
| 14 | 21 | ||
| 15 | 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 | 25 | "github.com/bnema/gordon/internal/domain" | |
| 17 | 26 | cfgusecase "github.com/bnema/gordon/internal/usecase/config" | |
| 18 | 27 | "github.com/bnema/gordon/internal/usecase/container" | |
| 19 | 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 | 33 | type watchRecorder struct { | |
| @@ -127,6 +138,37 @@ func (r *containerConfigApplyRecorder) Apply(ctx context.Context, cfg Config) er | |||
| 127 | 138 | 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 | 172 | type eventBusRecorder struct { | |
| 131 | 173 | mu sync.Mutex | |
| 132 | 174 | calls int | |
| @@ -227,6 +269,57 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven | |||
| 227 | 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 | 323 | func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(t *testing.T) { | |
| 231 | 324 | ctx := context.Background() | |
| 232 | 325 | v := viper.New() | |
| @@ -260,6 +353,114 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier( | |||
| 260 | 353 | ||
| 261 | 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 | 464 | func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) { | |
| 264 | 465 | ctx := context.Background() | |
| 265 | 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 | 73 | // ExtendToken is a helper method to define mock.On call | |
| 74 | 74 | // - ctx context.Context | |
| 75 | 75 | // - 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 | 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 | 141 | // - subject string | |
| 142 | 142 | // - scopes []string | |
| 143 | 143 | // - 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 | 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 | 219 | // - subject string | |
| 220 | 220 | // - scopes []string | |
| 221 | 221 | // - 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 | 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 | 341 | // GetAuthStatus is a helper method to define mock.On call | |
| 342 | 342 | // - 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 | 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 | 491 | // ListTokens is a helper method to define mock.On call | |
| 492 | 492 | // - 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 | 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 | 551 | // RevokeAllTokens is a helper method to define mock.On call | |
| 552 | 552 | // - 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 | 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 | 602 | // RevokeToken is a helper method to define mock.On call | |
| 603 | 603 | // - ctx context.Context | |
| 604 | 604 | // - 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 | 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 | 670 | // ValidateToken is a helper method to define mock.On call | |
| 671 | 671 | // - ctx context.Context | |
| 672 | 672 | // - 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 | 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 | 75 | // DetectDatabases is a helper method to define mock.On call | |
| 76 | 76 | // - ctx context.Context | |
| 77 | 77 | // - 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 | 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 | 143 | // ListBackups is a helper method to define mock.On call | |
| 144 | 144 | // - ctx context.Context | |
| 145 | 145 | // - 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 | 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 | 201 | // - ctx context.Context | |
| 202 | 202 | // - domainName string | |
| 203 | 203 | // - 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 | 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 | 264 | // - ctx context.Context | |
| 265 | 265 | // - domainName string | |
| 266 | 266 | // - 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 | 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 | 338 | // - ctx context.Context | |
| 339 | 339 | // - domainName string | |
| 340 | 340 | // - 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 | 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 | 411 | // Status is a helper method to define mock.On call | |
| 412 | 412 | // - 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 | 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 | 64 | // - ctx context.Context | |
| 65 | 65 | // - domainOrGroup string | |
| 66 | 66 | // - 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 | 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 | 126 | // AddAutoRouteAllowedDomain is a helper method to define mock.On call | |
| 127 | 127 | // - ctx context.Context | |
| 128 | 128 | // - 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 | 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 | 183 | // AddRoute is a helper method to define mock.On call | |
| 184 | 184 | // - ctx context.Context | |
| 185 | 185 | // - 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 | 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 | 242 | // FindAttachmentTargetsByImage is a helper method to define mock.On call | |
| 243 | 243 | // - ctx context.Context | |
| 244 | 244 | // - 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 | 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 | 301 | // FindRoutesByImage is a helper method to define mock.On call | |
| 302 | 302 | // - ctx context.Context | |
| 303 | 303 | // - 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 | 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 | 360 | // GetAllAttachments is a helper method to define mock.On call | |
| 361 | 361 | // - 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 | 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 | 468 | // GetAttachmentsFor is a helper method to define mock.On call | |
| 469 | 469 | // - ctx context.Context | |
| 470 | 470 | // - 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 | 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 | 536 | // GetAutoRouteAllowedDomains is a helper method to define mock.On call | |
| 537 | 537 | // - 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 | 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 | 910 | // GetRoute is a helper method to define mock.On call | |
| 911 | 911 | // - ctx context.Context | |
| 912 | 912 | // - 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 | 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 | 969 | // GetRoutes is a helper method to define mock.On call | |
| 970 | 970 | // - 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 | 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 | 1240 | // Load is a helper method to define mock.On call | |
| 1241 | 1241 | // - 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 | 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 | 1291 | // Reload is a helper method to define mock.On call | |
| 1292 | 1292 | // - 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 | 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 | 1343 | // - ctx context.Context | |
| 1344 | 1344 | // - domainOrGroup string | |
| 1345 | 1345 | // - 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 | 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 | 1405 | // RemoveAutoRouteAllowedDomain is a helper method to define mock.On call | |
| 1406 | 1406 | // - ctx context.Context | |
| 1407 | 1407 | // - 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 | 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 | 1462 | // RemoveRoute is a helper method to define mock.On call | |
| 1463 | 1463 | // - ctx context.Context | |
| 1464 | 1464 | // - 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 | 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 | 1519 | // Save is a helper method to define mock.On call | |
| 1520 | 1520 | // - 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 | 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 | 1570 | // UpdateRoute is a helper method to define mock.On call | |
| 1571 | 1571 | // - ctx context.Context | |
| 1572 | 1572 | // - 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 | 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 | 1627 | // Watch is a helper method to define mock.On call | |
| 1628 | 1628 | // - ctx context.Context | |
| 1629 | 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 | 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 | 63 | // AutoStart is a helper method to define mock.On call | |
| 64 | 64 | // - ctx context.Context | |
| 65 | 65 | // - 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 | 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 | 132 | // - ctx context.Context | |
| 133 | 133 | // - owner string | |
| 134 | 134 | // - 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 | 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 | 205 | // Deploy is a helper method to define mock.On call | |
| 206 | 206 | // - ctx context.Context | |
| 207 | 207 | // - 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 | 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 | 273 | // Get is a helper method to define mock.On call | |
| 274 | 274 | // - ctx context.Context | |
| 275 | 275 | // - 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 | 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 | 332 | // HealthCheck is a helper method to define mock.On call | |
| 333 | 333 | // - 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 | 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 | 385 | // List is a helper method to define mock.On call | |
| 386 | 386 | // - 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 | 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 | 438 | // ListAttachments is a helper method to define mock.On call | |
| 439 | 439 | // - ctx context.Context | |
| 440 | 440 | // - 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 | 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 | 506 | // ListNetworks is a helper method to define mock.On call | |
| 507 | 507 | // - 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 | 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 | 568 | // ListOrphanedAttachments is a helper method to define mock.On call | |
| 569 | 569 | // - 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 | 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 | 621 | // ListRoutesWithDetails is a helper method to define mock.On call | |
| 622 | 622 | // - 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 | 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 | 683 | // ReconcileRemovedRoute is a helper method to define mock.On call | |
| 684 | 684 | // - ctx context.Context | |
| 685 | 685 | // - 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 | 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 | 741 | // - ctx context.Context | |
| 742 | 742 | // - containerID string | |
| 743 | 743 | // - 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 | 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 | 804 | // - ctx context.Context | |
| 805 | 805 | // - domain1 string | |
| 806 | 806 | // - 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 | 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 | 866 | // Shutdown is a helper method to define mock.On call | |
| 867 | 867 | // - 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 | 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 | 917 | // Stop is a helper method to define mock.On call | |
| 918 | 918 | // - ctx context.Context | |
| 919 | 919 | // - 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 | 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 | 974 | // SyncContainers is a helper method to define mock.On call | |
| 975 | 975 | // - 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 | 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 | 1014 | // UpdateAttachments is a helper method to define mock.On call | |
| 1015 | 1015 | // - 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 | 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 | 65 | // CheckAllRoutes is a helper method to define mock.On call | |
| 66 | 66 | // - 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 | 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 | 118 | // CheckRoute is a helper method to define mock.On call | |
| 119 | 119 | // - ctx context.Context | |
| 120 | 120 | // - 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 | 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 | 77 | // Probe is a helper method to define mock.On call | |
| 78 | 78 | // - ctx context.Context | |
| 79 | 79 | // - 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 | 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 | 74 | // - ctx context.Context | |
| 75 | 75 | // - domain string | |
| 76 | 76 | // - 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 | 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 | 147 | // FollowProcessLogs is a helper method to define mock.On call | |
| 148 | 148 | // - ctx context.Context | |
| 149 | 149 | // - 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 | 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 | 216 | // - ctx context.Context | |
| 217 | 217 | // - domain string | |
| 218 | 218 | // - 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 | 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 | 289 | // GetProcessLogs is a helper method to define mock.On call | |
| 290 | 290 | // - ctx context.Context | |
| 291 | 291 | // - 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 | 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 | 75 | // GetTarget is a helper method to define mock.On call | |
| 76 | 76 | // - ctx context.Context | |
| 77 | 77 | // - 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 | 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 | 132 | // IsKnownHost is a helper method to define mock.On call | |
| 133 | 133 | // - ctx context.Context | |
| 134 | 134 | // - 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 | 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 | 189 | // IsRegistryDomain is a helper method to define mock.On call | |
| 190 | 190 | // - 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 | 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 | 284 | // RefreshTargets is a helper method to define mock.On call | |
| 285 | 285 | // - 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 | 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 | 336 | // - ctx context.Context | |
| 337 | 337 | // - domain1 string | |
| 338 | 338 | // - 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 | 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 | 433 | // TrackInFlight is a helper method to define mock.On call | |
| 434 | 434 | // - 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 | 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 | 517 | // UnregisterTarget is a helper method to define mock.On call | |
| 518 | 518 | // - ctx context.Context | |
| 519 | 519 | // - 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 | 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 | 77 | // - data io.Reader | |
| 78 | 78 | // - contentLength int64 | |
| 79 | 79 | // - 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 | 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 | 154 | // BlobExists is a helper method to define mock.On call | |
| 155 | 155 | // - ctx context.Context | |
| 156 | 156 | // - 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 | 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 | 211 | // CancelUpload is a helper method to define mock.On call | |
| 212 | 212 | // - ctx context.Context | |
| 213 | 213 | // - 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 | 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 | 269 | // - ctx context.Context | |
| 270 | 270 | // - name string | |
| 271 | 271 | // - 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 | 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 | 332 | // - ctx context.Context | |
| 333 | 333 | // - uuid string | |
| 334 | 334 | // - 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 | 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 | 405 | // GetBlob is a helper method to define mock.On call | |
| 406 | 406 | // - ctx context.Context | |
| 407 | 407 | // - 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 | 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 | 471 | // GetBlobPath is a helper method to define mock.On call | |
| 472 | 472 | // - ctx context.Context | |
| 473 | 473 | // - 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 | 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 | 540 | // - ctx context.Context | |
| 541 | 541 | // - name string | |
| 542 | 542 | // - 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 | 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 | 613 | // ListRepositories is a helper method to define mock.On call | |
| 614 | 614 | // - 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 | 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 | 675 | // ListTags is a helper method to define mock.On call | |
| 676 | 676 | // - ctx context.Context | |
| 677 | 677 | // - 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 | 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 | 734 | // - digest string | |
| 735 | 735 | // - data io.Reader | |
| 736 | 736 | // - 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 | 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 | 810 | // PutManifest is a helper method to define mock.On call | |
| 811 | 811 | // - ctx context.Context | |
| 812 | 812 | // - 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 | 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 | 876 | // StartUpload is a helper method to define mock.On call | |
| 877 | 877 | // - ctx context.Context | |
| 878 | 878 | // - 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 | 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 | 64 | // - ctx context.Context | |
| 65 | 65 | // - domain string | |
| 66 | 66 | // - 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 | 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 | 128 | // - domain string | |
| 129 | 129 | // - service string | |
| 130 | 130 | // - 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 | 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 | 206 | // GetAll is a helper method to define mock.On call | |
| 207 | 207 | // - ctx context.Context | |
| 208 | 208 | // - 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 | 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 | 274 | // ListKeys is a helper method to define mock.On call | |
| 275 | 275 | // - ctx context.Context | |
| 276 | 276 | // - 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 | 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 | 350 | // ListKeysWithAttachments is a helper method to define mock.On call | |
| 351 | 351 | // - ctx context.Context | |
| 352 | 352 | // - 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 | 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 | 408 | // - ctx context.Context | |
| 409 | 409 | // - domain string | |
| 410 | 410 | // - 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 | 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 | 472 | // - domain string | |
| 473 | 473 | // - service string | |
| 474 | 474 | // - 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 | 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 | 74 | // ListVolumeBackups is a helper method to define mock.On call | |
| 75 | 75 | // - ctx context.Context | |
| 76 | 76 | // - 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 | 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 | 143 | // - ctx context.Context | |
| 144 | 144 | // - domainName string | |
| 145 | 145 | // - 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 | 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 | 216 | // VolumeBackupStatus is a helper method to define mock.On call | |
| 217 | 217 | // - 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 | 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 | 74 | // ListVolumes is a helper method to define mock.On call | |
| 75 | 75 | // - 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 | 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 | 144 | // PruneVolumes is a helper method to define mock.On call | |
| 145 | 145 | // - ctx context.Context | |
| 146 | 146 | // - 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 | 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 | 75 | // - ctx context.Context | |
| 76 | 76 | // - domainName string | |
| 77 | 77 | // - 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 | 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 | 137 | // Delete is a helper method to define mock.On call | |
| 138 | 138 | // - ctx context.Context | |
| 139 | 139 | // - 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 | 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 | 205 | // Get is a helper method to define mock.On call | |
| 206 | 206 | // - ctx context.Context | |
| 207 | 207 | // - 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 | 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 | 274 | // - ctx context.Context | |
| 275 | 275 | // - domainName string | |
| 276 | 276 | // - 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 | 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 | 349 | // - schedule domain.BackupSchedule | |
| 350 | 350 | // - timestamp time.Time | |
| 351 | 351 | // - 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 | 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 | 75 | // - data io.Reader | |
| 76 | 76 | // - contentLength int64 | |
| 77 | 77 | // - 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 | 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 | 147 | // BlobExists is a helper method to define mock.On call | |
| 148 | 148 | // - 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 | 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 | 198 | // CancelBlobUpload is a helper method to define mock.On call | |
| 199 | 199 | // - 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 | 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 | 264 | // CleanupStaleUploads is a helper method to define mock.On call | |
| 265 | 265 | // - 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 | 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 | 324 | // DeleteBlob is a helper method to define mock.On call | |
| 325 | 325 | // - 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 | 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 | 375 | // FinishBlobUpload is a helper method to define mock.On call | |
| 376 | 376 | // - uuid string | |
| 377 | 377 | // - 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 | 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 | 443 | // GetBlob is a helper method to define mock.On call | |
| 444 | 444 | // - 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 | 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 | 503 | // GetBlobPath is a helper method to define mock.On call | |
| 504 | 504 | // - 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 | 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 | 565 | // GetBlobUpload is a helper method to define mock.On call | |
| 566 | 566 | // - 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 | 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 | 672 | // - digest string | |
| 673 | 673 | // - data io.Reader | |
| 674 | 674 | // - 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 | 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 | 743 | // StartBlobUpload is a helper method to define mock.On call | |
| 744 | 744 | // - 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 | 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 | 74 | // LoadAccount is a helper method to define mock.On call | |
| 75 | 75 | // - 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 | 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 | 136 | // LoadAll is a helper method to define mock.On call | |
| 137 | 137 | // - 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 | 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 | 196 | // LoadState is a helper method to define mock.On call | |
| 197 | 197 | // - 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 | 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 | 258 | // Lock is a helper method to define mock.On call | |
| 259 | 259 | // - 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 | 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 | 309 | // Save is a helper method to define mock.On call | |
| 310 | 310 | // - ctx context.Context | |
| 311 | 311 | // - 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 | 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 | 366 | // SaveAccount is a helper method to define mock.On call | |
| 367 | 367 | // - ctx context.Context | |
| 368 | 368 | // - 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 | 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 | 423 | // SaveState is a helper method to define mock.On call | |
| 424 | 424 | // - ctx context.Context | |
| 425 | 425 | // - 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 | 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 | 109 | // - containerID string | |
| 110 | 110 | // - domain string | |
| 111 | 111 | // - 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 | 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 | 176 | // StopLogging is a helper method to define mock.On call | |
| 177 | 177 | // - 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 | 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 | 66 | // - ctx context.Context | |
| 67 | 67 | // - containerName string | |
| 68 | 68 | // - 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 | 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 | 140 | // - ctx context.Context | |
| 141 | 141 | // - containerID string | |
| 142 | 142 | // - 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 | 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 | 213 | // CreateContainer is a helper method to define mock.On call | |
| 214 | 214 | // - ctx context.Context | |
| 215 | 215 | // - 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 | 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 | 271 | // - ctx context.Context | |
| 272 | 272 | // - name string | |
| 273 | 273 | // - 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 | 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 | 333 | // CreateVolume is a helper method to define mock.On call | |
| 334 | 334 | // - ctx context.Context | |
| 335 | 335 | // - 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 | 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 | 391 | // - ctx context.Context | |
| 392 | 392 | // - containerName string | |
| 393 | 393 | // - 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 | 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 | 465 | // - ctx context.Context | |
| 466 | 466 | // - containerID string | |
| 467 | 467 | // - 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 | 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 | 538 | // GetContainerExposedPorts is a helper method to define mock.On call | |
| 539 | 539 | // - ctx context.Context | |
| 540 | 540 | // - 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 | 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 | 610 | // GetContainerHealthStatus is a helper method to define mock.On call | |
| 611 | 611 | // - ctx context.Context | |
| 612 | 612 | // - 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 | 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 | 679 | // - ctx context.Context | |
| 680 | 680 | // - containerID string | |
| 681 | 681 | // - 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 | 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 | 750 | // GetContainerNetwork is a helper method to define mock.On call | |
| 751 | 751 | // - ctx context.Context | |
| 752 | 752 | // - 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 | 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 | 822 | // GetContainerNetworkInfo is a helper method to define mock.On call | |
| 823 | 823 | // - ctx context.Context | |
| 824 | 824 | // - 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 | 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 | 889 | // - ctx context.Context | |
| 890 | 890 | // - containerID string | |
| 891 | 891 | // - 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 | 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 | 962 | // GetImageExposedPorts is a helper method to define mock.On call | |
| 963 | 963 | // - ctx context.Context | |
| 964 | 964 | // - 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 | 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 | 1028 | // GetImageID is a helper method to define mock.On call | |
| 1029 | 1029 | // - ctx context.Context | |
| 1030 | 1030 | // - 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 | 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 | 1096 | // GetImageLabels is a helper method to define mock.On call | |
| 1097 | 1097 | // - ctx context.Context | |
| 1098 | 1098 | // - 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 | 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 | 1164 | // InspectContainer is a helper method to define mock.On call | |
| 1165 | 1165 | // - ctx context.Context | |
| 1166 | 1166 | // - 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 | 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 | 1232 | // InspectImageEnv is a helper method to define mock.On call | |
| 1233 | 1233 | // - ctx context.Context | |
| 1234 | 1234 | // - 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 | 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 | 1300 | // InspectImageVolumes is a helper method to define mock.On call | |
| 1301 | 1301 | // - ctx context.Context | |
| 1302 | 1302 | // - 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 | 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 | 1366 | // IsContainerRunning is a helper method to define mock.On call | |
| 1367 | 1367 | // - ctx context.Context | |
| 1368 | 1368 | // - 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 | 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 | 1434 | // ListContainers is a helper method to define mock.On call | |
| 1435 | 1435 | // - ctx context.Context | |
| 1436 | 1436 | // - 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 | 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 | 1502 | // ListImages is a helper method to define mock.On call | |
| 1503 | 1503 | // - 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 | 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 | 1564 | // ListNetworks is a helper method to define mock.On call | |
| 1565 | 1565 | // - 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 | 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 | 1626 | // ListVolumes is a helper method to define mock.On call | |
| 1627 | 1627 | // - 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 | 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 | 1686 | // NetworkExists is a helper method to define mock.On call | |
| 1687 | 1687 | // - ctx context.Context | |
| 1688 | 1688 | // - 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 | 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 | 1743 | // Ping is a helper method to define mock.On call | |
| 1744 | 1744 | // - 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 | 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 | 1794 | // PullImage is a helper method to define mock.On call | |
| 1795 | 1795 | // - ctx context.Context | |
| 1796 | 1796 | // - 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 | 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 | 1853 | // - image string | |
| 1854 | 1854 | // - username string | |
| 1855 | 1855 | // - 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 | 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 | 1921 | // - ctx context.Context | |
| 1922 | 1922 | // - containerID string | |
| 1923 | 1923 | // - 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 | 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 | 1984 | // - ctx context.Context | |
| 1985 | 1985 | // - image string | |
| 1986 | 1986 | // - 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 | 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 | 2046 | // RemoveNetwork is a helper method to define mock.On call | |
| 2047 | 2047 | // - ctx context.Context | |
| 2048 | 2048 | // - 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 | 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 | 2104 | // - ctx context.Context | |
| 2105 | 2105 | // - volumeName string | |
| 2106 | 2106 | // - 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 | 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 | 2167 | // - ctx context.Context | |
| 2168 | 2168 | // - containerID string | |
| 2169 | 2169 | // - 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 | 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 | 2229 | // RestartContainer is a helper method to define mock.On call | |
| 2230 | 2230 | // - ctx context.Context | |
| 2231 | 2231 | // - 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 | 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 | 2286 | // StartContainer is a helper method to define mock.On call | |
| 2287 | 2287 | // - ctx context.Context | |
| 2288 | 2288 | // - 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 | 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 | 2343 | // StopContainer is a helper method to define mock.On call | |
| 2344 | 2344 | // - ctx context.Context | |
| 2345 | 2345 | // - 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 | 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 | 2401 | // - ctx context.Context | |
| 2402 | 2402 | // - sourceRef string | |
| 2403 | 2403 | // - 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 | 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 | 2463 | // UntagImage is a helper method to define mock.On call | |
| 2464 | 2464 | // - ctx context.Context | |
| 2465 | 2465 | // - 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 | 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 | 2529 | // Version is a helper method to define mock.On call | |
| 2530 | 2530 | // - 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 | 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 | 2589 | // VolumeExists is a helper method to define mock.On call | |
| 2590 | 2590 | // - ctx context.Context | |
| 2591 | 2591 | // - 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 | 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 | 61 | // Delete is a helper method to define mock.On call | |
| 62 | 62 | // - domain string | |
| 63 | 63 | // - 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 | 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 | 118 | // DeleteAttachment is a helper method to define mock.On call | |
| 119 | 119 | // - containerName string | |
| 120 | 120 | // - 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 | 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 | 186 | // GetAll is a helper method to define mock.On call | |
| 187 | 187 | // - 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 | 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 | 248 | // GetAllAttachment is a helper method to define mock.On call | |
| 249 | 249 | // - 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 | 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 | 310 | // ListAttachmentKeys is a helper method to define mock.On call | |
| 311 | 311 | // - 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 | 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 | 372 | // ListKeys is a helper method to define mock.On call | |
| 373 | 373 | // - 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 | 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 | 423 | // Set is a helper method to define mock.On call | |
| 424 | 424 | // - domain string | |
| 425 | 425 | // - 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 | 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 | 480 | // SetAttachment is a helper method to define mock.On call | |
| 481 | 481 | // - containerName string | |
| 482 | 482 | // - 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 | 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 | 62 | // CreateEnvFile is a helper method to define mock.On call | |
| 63 | 63 | // - ctx context.Context | |
| 64 | 64 | // - 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 | 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 | 128 | // EnvFileExists is a helper method to define mock.On call | |
| 129 | 129 | // - 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 | 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 | 190 | // LoadEnv is a helper method to define mock.On call | |
| 191 | 191 | // - ctx context.Context | |
| 192 | 192 | // - 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 | 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 | 62 | // Publish is a helper method to define mock.On call | |
| 63 | 63 | // - eventType domain.EventType | |
| 64 | 64 | // - 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 | 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 | 207 | // Subscribe is a helper method to define mock.On call | |
| 208 | 208 | // - 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 | 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 | 258 | // Unsubscribe is a helper method to define mock.On call | |
| 259 | 259 | // - 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 | 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 | 63 | // CanHandle is a helper method to define mock.On call | |
| 64 | 64 | // - 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 | 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 | 114 | // Handle is a helper method to define mock.On call | |
| 115 | 115 | // - ctx context.Context | |
| 116 | 116 | // - 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 | 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 | 61 | // Publish is a helper method to define mock.On call | |
| 62 | 62 | // - eventType domain.EventType | |
| 63 | 63 | // - 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 | 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 | 61 | // Subscribe is a helper method to define mock.On call | |
| 62 | 62 | // - 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 | 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 | 112 | // Unsubscribe is a helper method to define mock.On call | |
| 113 | 113 | // - 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 | 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 | 62 | // DeleteManifest is a helper method to define mock.On call | |
| 63 | 63 | // - name string | |
| 64 | 64 | // - 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 | 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 | 136 | // GetManifest is a helper method to define mock.On call | |
| 137 | 137 | // - name string | |
| 138 | 138 | // - 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 | 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 | 202 | // GetManifestModTime is a helper method to define mock.On call | |
| 203 | 203 | // - name string | |
| 204 | 204 | // - 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 | 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 | 325 | // ListTags is a helper method to define mock.On call | |
| 326 | 326 | // - 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 | 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 | 378 | // - reference string | |
| 379 | 379 | // - contentType string | |
| 380 | 380 | // - 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 | 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 | 62 | // Allow is a helper method to define mock.On call | |
| 63 | 63 | // - ctx context.Context | |
| 64 | 64 | // - 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 | 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 | 120 | // - ctx context.Context | |
| 121 | 121 | // - key string | |
| 122 | 122 | // - 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 | 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 | 111 | // GetRoutes is a helper method to define mock.On call | |
| 112 | 112 | // - 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 | 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 | 71 | // GetSecret is a helper method to define mock.On call | |
| 72 | 72 | // - ctx context.Context | |
| 73 | 73 | // - 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 | 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 | 63 | // DeleteToken is a helper method to define mock.On call | |
| 64 | 64 | // - ctx context.Context | |
| 65 | 65 | // - 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 | 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 | 137 | // GetToken is a helper method to define mock.On call | |
| 138 | 138 | // - ctx context.Context | |
| 139 | 139 | // - 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 | 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 | 203 | // IsRevoked is a helper method to define mock.On call | |
| 204 | 204 | // - ctx context.Context | |
| 205 | 205 | // - 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 | 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 | 271 | // ListTokens is a helper method to define mock.On call | |
| 272 | 272 | // - 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 | 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 | 322 | // Revoke is a helper method to define mock.On call | |
| 323 | 323 | // - ctx context.Context | |
| 324 | 324 | // - 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 | 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 | 380 | // - ctx context.Context | |
| 381 | 381 | // - token *domain.Token | |
| 382 | 382 | // - 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 | 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 | 443 | // - ctx context.Context | |
| 444 | 444 | // - token *domain.Token | |
| 445 | 445 | // - 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 | 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 | 63 | 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 | 74 | // ContainerConfig holds configuration for creating a container. | |
| 67 | 75 | type ContainerConfig struct { | |
| 68 | 76 | Image string | |
| 69 | 77 | Name string | |
| 70 | 78 | Env []string | |
| 71 | 79 | Ports []int | |
| 80 | + | PortPublishes []ContainerPortPublish | |
| 72 | 81 | Labels map[string]string | |
| 73 | 82 | WorkingDir string | |
| 74 | 83 | Cmd []string | |
M internal/domain/errors.go +5 -0
| @@ -49,6 +49,8 @@ var ( | |||
| 49 | 49 | ErrPreviewNotFound = errors.New("preview not found") | |
| 50 | 50 | ||
| 51 | 51 | // Config errors | |
| 52 | + | ErrTrafficGraphRequired = errors.New("traffic graph is required") | |
| 53 | + | ErrClientHelloTooLarge = errors.New("client hello exceeds maximum") | |
| 52 | 54 | ErrConfigNotFound = errors.New("configuration not found") | |
| 53 | 55 | ErrInvalidConfig = errors.New("invalid configuration") | |
| 54 | 56 | ErrConfigLoadFailed = errors.New("failed to load configuration") | |
| @@ -104,4 +106,7 @@ var ( | |||
| 104 | 106 | ErrRouteSourceRequired = errors.New("route source required") | |
| 105 | 107 | ErrHTTPChallengeSinkRequired = errors.New("http challenge sink required") | |
| 106 | 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 | 15 | // exposing secret values. | |
| 16 | 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 | 26 | // LabelProxyPort specifies the container port to proxy HTTP traffic to. | |
| 19 | 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 | + | } | |