Merge pull request #153 from bnema/feat/internal-ca-ondemand-tls
feat(pki): internal CA with on-demand TLS
33 files · +2302 -432
M .mockery.yaml +1 -0
| @@ -24,6 +24,7 @@ packages: | |||
| 24 | 24 | DomainSecretStore: | |
| 25 | 25 | RateLimiter: | |
| 26 | 26 | BackupStorage: | |
| 27 | + | RouteChecker: | |
| 27 | 28 | github.com/bnema/gordon/internal/boundaries/in: | |
| 28 | 29 | interfaces: | |
| 29 | 30 | ContainerService: | |
| @@ -24,6 +24,7 @@ packages: | |||||
| 24 | DomainSecretStore: | 24 | DomainSecretStore: | ||
| 25 | RateLimiter: | 25 | RateLimiter: | ||
| 26 | BackupStorage: | 26 | BackupStorage: | ||
| 27 | + | RouteChecker: | |||
| 27 | github.com/bnema/gordon/internal/boundaries/in: | 28 | github.com/bnema/gordon/internal/boundaries/in: | ||
| 28 | interfaces: | 29 | interfaces: | ||
| 29 | ContainerService: | 30 | ContainerService: | ||
M docs/cli/remotes.md +6 -18
| @@ -326,26 +326,14 @@ url = "https://gordon.example.com" | |||
| 326 | 326 | token_env = "GORDON_TOKEN" | |
| 327 | 327 | ``` | |
| 328 | 328 | ||
| 329 | - | If your admin/API path is private over Tailscale, you have two options: | |
| 329 | + | A common Tailscale setup is to point your domain's DNS at the machine's Tailscale IP (Cloudflare DNS-only / grey cloud). Since the server isn't publicly reachable, there is no external CA to issue a trusted certificate — Gordon's internal CA handles TLS automatically. Clients need to trust the internal CA: | |
| 330 | 330 | ||
| 331 | - | 1. Use `insecure_tls = true` for that remote. | |
| 332 | - | 2. Prefer a Tailscale-issued cert on the machine's `*.ts.net` name and keep verification enabled. | |
| 331 | + | - `insecure_tls = true` on the remote — skips certificate verification entirely | |
| 332 | + | - `sudo gordon ca install` — installs the root CA into system, Firefox, and Java trust stores | |
| 333 | + | - `gordon ca export --out gordon-ca.crt` — exports the root CA PEM for manual installation | |
| 334 | + | - Visit `https://<gordon-host>:<tls_port>/ca` in a browser — onboarding page with downloads for macOS, Linux, Windows, iOS, and Android | |
| 333 | 335 | ||
| 334 | - | Tailscale certificates are issued for tailnet MagicDNS names (for example `host-name.tailnet.ts.net`), not your custom domain. Enable HTTPS certificates in the Tailscale admin DNS settings, then generate files on the host: | |
| 335 | - | ||
| 336 | - | ```bash | |
| 337 | - | sudo tailscale cert --cert-file /etc/gordon/tls/cert.pem --key-file /etc/gordon/tls/key.pem host-name.tailnet.ts.net | |
| 338 | - | ``` | |
| 339 | - | ||
| 340 | - | Configure Gordon to use them: | |
| 341 | - | ||
| 342 | - | ```toml | |
| 343 | - | [server] | |
| 344 | - | tls_enabled = true | |
| 345 | - | tls_port = 443 | |
| 346 | - | tls_cert_file = "/etc/gordon/tls/cert.pem" | |
| 347 | - | tls_key_file = "/etc/gordon/tls/key.pem" | |
| 348 | - | ``` | |
| 336 | + | 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. | |
| 349 | 337 | ||
| 350 | 338 | `insecure_tls` only affects CLI -> Gordon admin HTTPS verification. It does not change runtime routing: Gordon reverse proxy and container routes can still serve wildcard app domains like `*.example.com`. | |
| 351 | 339 | ||
| @@ -326,26 +326,14 @@ url = "https://gordon.example.com" | |||||
| 326 | token_env = "GORDON_TOKEN" | 326 | token_env = "GORDON_TOKEN" | ||
| 327 | ``` | 327 | ``` | ||
| 328 | 328 | ||||
| 329 | - | If your admin/API path is private over Tailscale, you have two options: | 329 | + | A common Tailscale setup is to point your domain's DNS at the machine's Tailscale IP (Cloudflare DNS-only / grey cloud). Since the server isn't publicly reachable, there is no external CA to issue a trusted certificate — Gordon's internal CA handles TLS automatically. Clients need to trust the internal CA: |
| 330 | 330 | ||||
| 331 | - | 1. Use `insecure_tls = true` for that remote. | 331 | + | - `insecure_tls = true` on the remote — skips certificate verification entirely |
| 332 | - | 2. Prefer a Tailscale-issued cert on the machine's `*.ts.net` name and keep verification enabled. | 332 | + | - `sudo gordon ca install` — installs the root CA into system, Firefox, and Java trust stores |
| 333 | + | - `gordon ca export --out gordon-ca.crt` — exports the root CA PEM for manual installation | |||
| 334 | + | - Visit `https://<gordon-host>:<tls_port>/ca` in a browser — onboarding page with downloads for macOS, Linux, Windows, iOS, and Android | |||
| 333 | 335 | ||||
| 334 | - | Tailscale certificates are issued for tailnet MagicDNS names (for example `host-name.tailnet.ts.net`), not your custom domain. Enable HTTPS certificates in the Tailscale admin DNS settings, then generate files on the host: | 336 | + | 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. |
| 335 | - | ||||
| 336 | - | ```bash | |||
| 337 | - | sudo tailscale cert --cert-file /etc/gordon/tls/cert.pem --key-file /etc/gordon/tls/key.pem host-name.tailnet.ts.net | |||
| 338 | - | ``` | |||
| 339 | - | ||||
| 340 | - | Configure Gordon to use them: | |||
| 341 | - | ||||
| 342 | - | ```toml | |||
| 343 | - | [server] | |||
| 344 | - | tls_enabled = true | |||
| 345 | - | tls_port = 443 | |||
| 346 | - | tls_cert_file = "/etc/gordon/tls/cert.pem" | |||
| 347 | - | tls_key_file = "/etc/gordon/tls/key.pem" | |||
| 348 | - | ``` | |||
| 349 | 337 | ||||
| 350 | `insecure_tls` only affects CLI -> Gordon admin HTTPS verification. It does not change runtime routing: Gordon reverse proxy and container routes can still serve wildcard app domains like `*.example.com`. | 338 | `insecure_tls` only affects CLI -> Gordon admin HTTPS verification. It does not change runtime routing: Gordon reverse proxy and container routes can still serve wildcard app domains like `*.example.com`. | ||
| 351 | 339 | ||||
M docs/config/index.md +2 -2
| @@ -30,7 +30,7 @@ For a complete list of all configuration options with their default values, see | |||
| 30 | 30 | ```toml | |
| 31 | 31 | # Server settings | |
| 32 | 32 | [server] | |
| 33 | - | port = 8080 # HTTP proxy port (default: 80) | |
| 33 | + | port = 8088 # HTTP proxy port (default: 8088) | |
| 34 | 34 | registry_port = 5000 # Registry port (default: 5000) | |
| 35 | 35 | gordon_domain = "gordon.mydomain.com" # Required: Gordon domain (registry + API) | |
| 36 | 36 | # data_dir = "~/.gordon" # Default for user installations | |
| @@ -167,7 +167,7 @@ keep_last = 3 | |||
| 167 | 167 | ||
| 168 | 168 | | Setting | Default | | |
| 169 | 169 | |---------|---------| | |
| 170 | - | | `server.port` | `80` | | |
| 170 | + | | `server.port` | `8088` | | |
| 171 | 171 | | `server.registry_port` | `5000` | | |
| 172 | 172 | | `server.data_dir` | `~/.gordon` | | |
| 173 | 173 | | `auth.enabled` | `true` | | |
| @@ -30,7 +30,7 @@ For a complete list of all configuration options with their default values, see | |||||
| 30 | ```toml | 30 | ```toml | ||
| 31 | # Server settings | 31 | # Server settings | ||
| 32 | [server] | 32 | [server] | ||
| 33 | - | port = 8080 # HTTP proxy port (default: 80) | 33 | + | port = 8088 # HTTP proxy port (default: 8088) |
| 34 | registry_port = 5000 # Registry port (default: 5000) | 34 | registry_port = 5000 # Registry port (default: 5000) | ||
| 35 | gordon_domain = "gordon.mydomain.com" # Required: Gordon domain (registry + API) | 35 | gordon_domain = "gordon.mydomain.com" # Required: Gordon domain (registry + API) | ||
| 36 | # data_dir = "~/.gordon" # Default for user installations | 36 | # data_dir = "~/.gordon" # Default for user installations | ||
| @@ -167,7 +167,7 @@ keep_last = 3 | |||||
| 167 | 167 | ||||
| 168 | | Setting | Default | | 168 | | Setting | Default | | ||
| 169 | |---------|---------| | 169 | |---------|---------| | ||
| 170 | - | | `server.port` | `80` | | 170 | + | | `server.port` | `8088` | |
| 171 | | `server.registry_port` | `5000` | | 171 | | `server.registry_port` | `5000` | | ||
| 172 | | `server.data_dir` | `~/.gordon` | | 172 | | `server.data_dir` | `~/.gordon` | | ||
| 173 | | `auth.enabled` | `true` | | 173 | | `auth.enabled` | `true` | | ||
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 (`server.tls_enabled = true`) 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 (via its internal CA on `server.tls_port`) 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 | |
| @@ -150,7 +150,7 @@ A burst of `100` with `per_ip_rps = 50` means a client can send 100 requests ins | |||||
| 150 | 150 | ||||
| 151 | **Critical for correct IP detection in production.** | 151 | **Critical for correct IP detection in production.** | ||
| 152 | 152 | ||||
| 153 | - | Gordon is a reverse proxy that routes requests to your containers. In production you can either terminate TLS directly in Gordon (`server.tls_enabled = true`) 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 (via its internal CA on `server.tls_port`) or place it behind a TLS-terminating proxy like Cloudflare, nginx, or a load balancer. |
| 154 | 154 | ||||
| 155 | ``` | 155 | ``` | ||
| 156 | Internet → [Cloudflare/nginx] → Gordon → Containers | 156 | Internet → [Cloudflare/nginx] → Gordon → Containers | ||
M docs/config/reference.md +10 -12
| @@ -9,19 +9,18 @@ Complete configuration reference with all options and their default values. | |||
| 9 | 9 | # SERVER | |
| 10 | 10 | # ============================================================================= | |
| 11 | 11 | [server] | |
| 12 | - | port = 80 # HTTP proxy port | |
| 12 | + | port = 8088 # HTTP proxy port | |
| 13 | 13 | registry_port = 5000 # Container registry port | |
| 14 | - | tls_enabled = false # Enable native HTTPS listener | |
| 15 | - | tls_port = 443 # HTTPS proxy port when enabled | |
| 16 | - | tls_cert_file = "" # PEM cert path (auto-generated if empty and TLS enabled) | |
| 17 | - | tls_key_file = "" # PEM key path (auto-generated if empty and TLS enabled) | |
| 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) | |
| 16 | + | tls_key_file = "" # PEM key path (optional, must be set with tls_cert_file) | |
| 17 | + | force_https_redirect = false # Redirect all HTTP traffic to HTTPS (for direct-access setups) | |
| 18 | 18 | gordon_domain = "" # Required: Gordon domain (registry + API) | |
| 19 | 19 | data_dir = "~/.gordon" # Data directory (varies by install type) | |
| 20 | 20 | max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk | |
| 21 | 21 | registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all) | |
| 22 | 22 | proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs) | |
| 23 | 23 | registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only) | |
| 24 | - | force_hsts = false # Always send HSTS header (enable when behind TLS-terminating proxy) | |
| 25 | 24 | ||
| 26 | 25 | # ============================================================================= | |
| 27 | 26 | # AUTHENTICATION (required - Gordon won't start without credentials configured) | |
| @@ -170,19 +169,18 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 170 | 169 | ||
| 171 | 170 | | Setting | Default | Description | | |
| 172 | 171 | |---------|---------|-------------| | |
| 173 | - | | `server.port` | `80` | HTTP proxy port | | |
| 172 | + | | `server.port` | `8088` | HTTP proxy port | | |
| 174 | 173 | | `server.registry_port` | `5000` | Container registry port | | |
| 175 | - | | `server.tls_enabled` | `false` | Enable native HTTPS listener | | |
| 176 | - | | `server.tls_port` | `443` | HTTPS listener port | | |
| 177 | - | | `server.tls_cert_file` | `""` | TLS cert path (auto-generated when empty) | | |
| 178 | - | | `server.tls_key_file` | `""` | TLS key path (auto-generated when empty) | | |
| 174 | + | | `server.tls_port` | `8443` | HTTPS listener port (0 = disabled) | | |
| 175 | + | | `server.tls_cert_file` | `""` | PEM cert path for static TLS (optional) | | |
| 176 | + | | `server.tls_key_file` | `""` | PEM key path for static TLS (optional) | | |
| 177 | + | | `server.force_https_redirect` | `false` | Redirect all HTTP to HTTPS (for direct-access setups) | | |
| 179 | 178 | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | |
| 180 | 179 | | `server.data_dir` | `~/.gordon` | Data directory | | |
| 181 | 180 | | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk | | |
| 182 | 181 | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | |
| 183 | 182 | | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | |
| 184 | 183 | | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) | | |
| 185 | - | | `server.force_hsts` | `false` | Always send HSTS header (for TLS-terminating proxy setups) | | |
| 186 | 184 | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | |
| 187 | 185 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | |
| 188 | 186 | | `auth.token_expiry` | `"720h"` | 30 days | | |
| @@ -9,19 +9,18 @@ Complete configuration reference with all options and their default values. | |||||
| 9 | # SERVER | 9 | # SERVER | ||
| 10 | # ============================================================================= | 10 | # ============================================================================= | ||
| 11 | [server] | 11 | [server] | ||
| 12 | - | port = 80 # HTTP proxy port | 12 | + | port = 8088 # HTTP proxy port |
| 13 | registry_port = 5000 # Container registry port | 13 | registry_port = 5000 # Container registry port | ||
| 14 | - | tls_enabled = false # Enable native HTTPS listener | 14 | + | tls_port = 8443 # HTTPS proxy port (0 = disabled, no internal CA) |
| 15 | - | tls_port = 443 # HTTPS proxy port when enabled | 15 | + | tls_cert_file = "" # PEM cert path (optional, for static TLS alongside internal CA) |
| 16 | - | tls_cert_file = "" # PEM cert path (auto-generated if empty and TLS enabled) | 16 | + | tls_key_file = "" # PEM key path (optional, must be set with tls_cert_file) |
| 17 | - | tls_key_file = "" # PEM key path (auto-generated if empty and TLS enabled) | 17 | + | force_https_redirect = false # Redirect all HTTP traffic to HTTPS (for direct-access setups) |
| 18 | gordon_domain = "" # Required: Gordon domain (registry + API) | 18 | gordon_domain = "" # Required: Gordon domain (registry + API) | ||
| 19 | data_dir = "~/.gordon" # Data directory (varies by install type) | 19 | data_dir = "~/.gordon" # Data directory (varies by install type) | ||
| 20 | max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk | 20 | max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk | ||
| 21 | registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all) | 21 | registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all) | ||
| 22 | proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs) | 22 | proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs) | ||
| 23 | registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only) | 23 | registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only) | ||
| 24 | - | force_hsts = false # Always send HSTS header (enable when behind TLS-terminating proxy) | |||
| 25 | 24 | ||||
| 26 | # ============================================================================= | 25 | # ============================================================================= | ||
| 27 | # AUTHENTICATION (required - Gordon won't start without credentials configured) | 26 | # AUTHENTICATION (required - Gordon won't start without credentials configured) | ||
| @@ -170,19 +169,18 @@ keep_last = 3 # Keep N newest tags per repository | |||||
| 170 | 169 | ||||
| 171 | | Setting | Default | Description | | 170 | | Setting | Default | Description | | ||
| 172 | |---------|---------|-------------| | 171 | |---------|---------|-------------| | ||
| 173 | - | | `server.port` | `80` | HTTP proxy port | | 172 | + | | `server.port` | `8088` | HTTP proxy port | |
| 174 | | `server.registry_port` | `5000` | Container registry port | | 173 | | `server.registry_port` | `5000` | Container registry port | | ||
| 175 | - | | `server.tls_enabled` | `false` | Enable native HTTPS listener | | 174 | + | | `server.tls_port` | `8443` | HTTPS listener port (0 = disabled) | |
| 176 | - | | `server.tls_port` | `443` | HTTPS listener port | | 175 | + | | `server.tls_cert_file` | `""` | PEM cert path for static TLS (optional) | |
| 177 | - | | `server.tls_cert_file` | `""` | TLS cert path (auto-generated when empty) | | 176 | + | | `server.tls_key_file` | `""` | PEM key path for static TLS (optional) | |
| 178 | - | | `server.tls_key_file` | `""` | TLS key path (auto-generated when empty) | | 177 | + | | `server.force_https_redirect` | `false` | Redirect all HTTP to HTTPS (for direct-access setups) | |
| 179 | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | 178 | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | ||
| 180 | | `server.data_dir` | `~/.gordon` | Data directory | | 179 | | `server.data_dir` | `~/.gordon` | Data directory | | ||
| 181 | | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk | | 180 | | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk | | ||
| 182 | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | 181 | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | ||
| 183 | | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | 182 | | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | ||
| 184 | | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) | | 183 | | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) | | ||
| 185 | - | | `server.force_hsts` | `false` | Always send HSTS header (for TLS-terminating proxy setups) | | |||
| 186 | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | 184 | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | ||
| 187 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | 185 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | ||
| 188 | | `auth.token_expiry` | `"720h"` | 30 days | | 186 | | `auth.token_expiry` | `"720h"` | 30 days | | ||
M docs/config/server.md +57 -31
| @@ -6,12 +6,12 @@ Core server settings for Gordon. | |||
| 6 | 6 | ||
| 7 | 7 | ```toml | |
| 8 | 8 | [server] | |
| 9 | - | port = 8080 # HTTP proxy port | |
| 9 | + | port = 8088 # HTTP proxy port | |
| 10 | 10 | registry_port = 5000 # Container registry port | |
| 11 | - | tls_enabled = false # Enable native TLS listener | |
| 12 | - | tls_port = 443 # HTTPS listener port | |
| 13 | - | tls_cert_file = "" # PEM certificate file (required when TLS enabled) | |
| 14 | - | tls_key_file = "" # PEM key file (required when TLS enabled) | |
| 11 | + | tls_port = 8443 # HTTPS listener port (0 = disabled) | |
| 12 | + | # tls_cert_file = "" # Optional: PEM certificate for static TLS | |
| 13 | + | # tls_key_file = "" # Optional: PEM key for static TLS | |
| 14 | + | # force_https_redirect = false # Redirect all HTTP traffic to HTTPS | |
| 15 | 15 | gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | |
| 16 | 16 | # data_dir = "~/.gordon" # Data storage directory (default) | |
| 17 | 17 | ``` | |
| @@ -20,12 +20,12 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | |||
| 20 | 20 | ||
| 21 | 21 | | Option | Type | Default | Description | | |
| 22 | 22 | |--------|------|---------|-------------| | |
| 23 | - | | `port` | int | `80` | HTTP proxy port for routing traffic to containers | | |
| 23 | + | | `port` | int | `8088` | HTTP proxy port for routing traffic to containers | | |
| 24 | 24 | | `registry_port` | int | `5000` | Docker registry port for image push/pull | | |
| 25 | - | | `tls_enabled` | bool | `false` | Enable native HTTPS listener for proxy traffic | | |
| 26 | - | | `tls_port` | int | `443` | HTTPS listener port when `tls_enabled` is true | | |
| 27 | - | | `tls_cert_file` | string | `""` | Path to PEM certificate file | | |
| 28 | - | | `tls_key_file` | string | `""` | Path to PEM private key file | | |
| 25 | + | | `tls_port` | int | `8443` | HTTPS listener port with internal CA. Set to `0` to disable TLS entirely | | |
| 26 | + | | `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 | | |
| 27 | + | | `tls_key_file` | string | `""` | Path to PEM private key file. Must be set together with `tls_cert_file` | | |
| 28 | + | | `force_https_redirect` | bool | `false` | Redirect all HTTP requests to the HTTPS port. For direct-access setups without a TLS-terminating proxy | | |
| 29 | 29 | | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) | | |
| 30 | 30 | | `registry_domain` | string | - | Deprecated alias for `gordon_domain` | | |
| 31 | 31 | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files | | |
| @@ -34,7 +34,6 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | |||
| 34 | 34 | | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | |
| 35 | 35 | | `proxy_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | |
| 36 | 36 | | `registry_listen_address` | string | `""` | Bind address for registry (empty = all interfaces) | | |
| 37 | - | | `force_hsts` | bool | `false` | Always send HSTS header (for TLS-terminating proxy setups) | | |
| 38 | 37 | ||
| 39 | 38 | ## Port Configuration | |
| 40 | 39 | ||
| @@ -54,30 +53,62 @@ For rootless containers, you'll typically use a high port and configure firewall | |||
| 54 | 53 | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8080 | |
| 55 | 54 | ``` | |
| 56 | 55 | ||
| 57 | - | ### Native TLS Listener | |
| 56 | + | ### Internal CA and TLS | |
| 58 | 57 | ||
| 59 | - | Enable native HTTPS directly in Gordon: | |
| 58 | + | 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). | |
| 60 | 59 | ||
| 61 | 60 | ```toml | |
| 62 | 61 | [server] | |
| 63 | - | tls_enabled = true | |
| 64 | - | tls_port = 443 | |
| 65 | - | tls_cert_file = "/etc/gordon/certs/fullchain.pem" | |
| 66 | - | tls_key_file = "/etc/gordon/certs/privkey.pem" | |
| 62 | + | tls_port = 8443 # Default. Set to 0 to disable TLS entirely. | |
| 67 | 63 | ``` | |
| 68 | 64 | ||
| 69 | 65 | With TLS enabled: | |
| 70 | - | - Gordon terminates HTTPS on `tls_port` | |
| 71 | - | - Host-based routing still works (apps, `/v2/*`, `/auth/*`, `/admin/*`) | |
| 72 | - | - `port` remains available for plain HTTP unless you restrict it externally | |
| 73 | - | - If `tls_cert_file`/`tls_key_file` are empty, Gordon auto-generates a self-signed pair in `{data_dir}/tls/` | |
| 66 | + | - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/` | |
| 67 | + | - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory | |
| 68 | + | - The intermediate CA auto-renews before expiry | |
| 69 | + | - An onboarding page at `https://<gordon-host>:<tls_port>/ca` lets clients download the root CA certificate | |
| 70 | + | - The root CA is stable across restarts (generated once, persisted to disk) | |
| 74 | 71 | ||
| 75 | - | For rootless user services, do not bind directly to `443`. Use an unprivileged TLS port (for example `8443`) and forward `443` to it at the firewall or edge proxy. | |
| 72 | + | #### Custom certificates | |
| 73 | + | ||
| 74 | + | To use your own certificate (e.g. from Tailscale, Let's Encrypt, or a corporate CA) alongside the internal CA: | |
| 76 | 75 | ||
| 77 | 76 | ```toml | |
| 78 | 77 | [server] | |
| 79 | - | tls_enabled = true | |
| 80 | - | tls_port = 8443 | |
| 78 | + | tls_cert_file = "/etc/gordon/tls/cert.pem" | |
| 79 | + | tls_key_file = "/etc/gordon/tls/key.pem" | |
| 80 | + | ``` | |
| 81 | + | ||
| 82 | + | The static certificate is served for SNI-matching domains (including wildcards). All other domains get on-demand certs from the internal CA. | |
| 83 | + | ||
| 84 | + | #### HTTP to HTTPS redirect | |
| 85 | + | ||
| 86 | + | When clients connect directly (no Cloudflare or reverse proxy in front), enable `force_https_redirect` to redirect all HTTP traffic to the HTTPS port: | |
| 87 | + | ||
| 88 | + | ```toml | |
| 89 | + | [server] | |
| 90 | + | force_https_redirect = true | |
| 91 | + | ``` | |
| 92 | + | ||
| 93 | + | 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. | |
| 94 | + | ||
| 95 | + | #### Disabling TLS | |
| 96 | + | ||
| 97 | + | For setups where TLS is handled entirely by an external proxy (Cloudflare, nginx, etc.): | |
| 98 | + | ||
| 99 | + | ```toml | |
| 100 | + | [server] | |
| 101 | + | tls_port = 0 # No HTTPS listener, no internal CA generated | |
| 102 | + | ``` | |
| 103 | + | ||
| 104 | + | #### Firewall port forwarding | |
| 105 | + | ||
| 106 | + | For rootless user services, forward 443 to the high port at the firewall: | |
| 107 | + | ||
| 108 | + | ```bash | |
| 109 | + | # Forward 443 -> 8443 | |
| 110 | + | sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443 | |
| 111 | + | sudo firewall-cmd --reload | |
| 81 | 112 | ``` | |
| 82 | 113 | ||
| 83 | 114 | With firewalld and Tailscale: | |
| @@ -291,14 +322,9 @@ The `registry_listen_address` controls which interface the registry server binds | |||
| 291 | 322 | registry_listen_address = "127.0.0.1" # Loopback only | |
| 292 | 323 | ``` | |
| 293 | 324 | ||
| 294 | - | ## Force HSTS | |
| 295 | - | ||
| 296 | - | When Gordon runs behind a TLS-terminating proxy (e.g. Cloudflare), `r.TLS` is nil so the HSTS header is not sent by default. Enable `force_hsts` to always include the `Strict-Transport-Security` header. | |
| 325 | + | ## HSTS | |
| 297 | 326 | ||
| 298 | - | ```toml | |
| 299 | - | [server] | |
| 300 | - | force_hsts = true | |
| 301 | - | ``` | |
| 327 | + | 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. | |
| 302 | 328 | ||
| 303 | 329 | ## Examples | |
| 304 | 330 | ||
| @@ -6,12 +6,12 @@ Core server settings for Gordon. | |||||
| 6 | 6 | ||||
| 7 | ```toml | 7 | ```toml | ||
| 8 | [server] | 8 | [server] | ||
| 9 | - | port = 8080 # HTTP proxy port | 9 | + | port = 8088 # HTTP proxy port |
| 10 | registry_port = 5000 # Container registry port | 10 | registry_port = 5000 # Container registry port | ||
| 11 | - | tls_enabled = false # Enable native TLS listener | 11 | + | tls_port = 8443 # HTTPS listener port (0 = disabled) |
| 12 | - | tls_port = 443 # HTTPS listener port | 12 | + | # tls_cert_file = "" # Optional: PEM certificate for static TLS |
| 13 | - | tls_cert_file = "" # PEM certificate file (required when TLS enabled) | 13 | + | # tls_key_file = "" # Optional: PEM key for static TLS |
| 14 | - | tls_key_file = "" # PEM key file (required when TLS enabled) | 14 | + | # force_https_redirect = false # Redirect all HTTP traffic to HTTPS |
| 15 | gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | 15 | gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | ||
| 16 | # data_dir = "~/.gordon" # Data storage directory (default) | 16 | # data_dir = "~/.gordon" # Data storage directory (default) | ||
| 17 | ``` | 17 | ``` | ||
| @@ -20,12 +20,12 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | |||||
| 20 | 20 | ||||
| 21 | | Option | Type | Default | Description | | 21 | | Option | Type | Default | Description | | ||
| 22 | |--------|------|---------|-------------| | 22 | |--------|------|---------|-------------| | ||
| 23 | - | | `port` | int | `80` | HTTP proxy port for routing traffic to containers | | 23 | + | | `port` | int | `8088` | HTTP proxy port for routing traffic to containers | |
| 24 | | `registry_port` | int | `5000` | Docker registry port for image push/pull | | 24 | | `registry_port` | int | `5000` | Docker registry port for image push/pull | | ||
| 25 | - | | `tls_enabled` | bool | `false` | Enable native HTTPS listener for proxy traffic | | 25 | + | | `tls_port` | int | `8443` | HTTPS listener port with internal CA. Set to `0` to disable TLS entirely | |
| 26 | - | | `tls_port` | int | `443` | HTTPS listener port when `tls_enabled` is true | | 26 | + | | `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 | |
| 27 | - | | `tls_cert_file` | string | `""` | Path to PEM certificate file | | 27 | + | | `tls_key_file` | string | `""` | Path to PEM private key file. Must be set together with `tls_cert_file` | |
| 28 | - | | `tls_key_file` | string | `""` | Path to PEM private key file | | 28 | + | | `force_https_redirect` | bool | `false` | Redirect all HTTP requests to the HTTPS port. For direct-access setups without a TLS-terminating proxy | |
| 29 | | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) | | 29 | | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) | | ||
| 30 | | `registry_domain` | string | - | Deprecated alias for `gordon_domain` | | 30 | | `registry_domain` | string | - | Deprecated alias for `gordon_domain` | | ||
| 31 | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files | | 31 | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files | | ||
| @@ -34,7 +34,6 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | |||||
| 34 | | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | 34 | | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | ||
| 35 | | `proxy_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | 35 | | `proxy_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | ||
| 36 | | `registry_listen_address` | string | `""` | Bind address for registry (empty = all interfaces) | | 36 | | `registry_listen_address` | string | `""` | Bind address for registry (empty = all interfaces) | | ||
| 37 | - | | `force_hsts` | bool | `false` | Always send HSTS header (for TLS-terminating proxy setups) | | |||
| 38 | 37 | ||||
| 39 | ## Port Configuration | 38 | ## Port Configuration | ||
| 40 | 39 | ||||
| @@ -54,30 +53,62 @@ For rootless containers, you'll typically use a high port and configure firewall | |||||
| 54 | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8080 | 53 | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8080 | ||
| 55 | ``` | 54 | ``` | ||
| 56 | 55 | ||||
| 57 | - | ### Native TLS Listener | 56 | + | ### Internal CA and TLS |
| 58 | 57 | ||||
| 59 | - | Enable native HTTPS directly in Gordon: | 58 | + | 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). |
| 60 | 59 | ||||
| 61 | ```toml | 60 | ```toml | ||
| 62 | [server] | 61 | [server] | ||
| 63 | - | tls_enabled = true | 62 | + | tls_port = 8443 # Default. Set to 0 to disable TLS entirely. |
| 64 | - | tls_port = 443 | |||
| 65 | - | tls_cert_file = "/etc/gordon/certs/fullchain.pem" | |||
| 66 | - | tls_key_file = "/etc/gordon/certs/privkey.pem" | |||
| 67 | ``` | 63 | ``` | ||
| 68 | 64 | ||||
| 69 | With TLS enabled: | 65 | With TLS enabled: | ||
| 70 | - | - Gordon terminates HTTPS on `tls_port` | 66 | + | - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/` |
| 71 | - | - Host-based routing still works (apps, `/v2/*`, `/auth/*`, `/admin/*`) | 67 | + | - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory |
| 72 | - | - `port` remains available for plain HTTP unless you restrict it externally | 68 | + | - The intermediate CA auto-renews before expiry |
| 73 | - | - If `tls_cert_file`/`tls_key_file` are empty, Gordon auto-generates a self-signed pair in `{data_dir}/tls/` | 69 | + | - An onboarding page at `https://<gordon-host>:<tls_port>/ca` lets clients download the root CA certificate |
| 70 | + | - The root CA is stable across restarts (generated once, persisted to disk) | |||
| 74 | 71 | ||||
| 75 | - | For rootless user services, do not bind directly to `443`. Use an unprivileged TLS port (for example `8443`) and forward `443` to it at the firewall or edge proxy. | 72 | + | #### Custom certificates |
| 73 | + | ||||
| 74 | + | To use your own certificate (e.g. from Tailscale, Let's Encrypt, or a corporate CA) alongside the internal CA: | |||
| 76 | 75 | ||||
| 77 | ```toml | 76 | ```toml | ||
| 78 | [server] | 77 | [server] | ||
| 79 | - | tls_enabled = true | 78 | + | tls_cert_file = "/etc/gordon/tls/cert.pem" |
| 80 | - | tls_port = 8443 | 79 | + | tls_key_file = "/etc/gordon/tls/key.pem" |
| 80 | + | ``` | |||
| 81 | + | ||||
| 82 | + | The static certificate is served for SNI-matching domains (including wildcards). All other domains get on-demand certs from the internal CA. | |||
| 83 | + | ||||
| 84 | + | #### HTTP to HTTPS redirect | |||
| 85 | + | ||||
| 86 | + | When clients connect directly (no Cloudflare or reverse proxy in front), enable `force_https_redirect` to redirect all HTTP traffic to the HTTPS port: | |||
| 87 | + | ||||
| 88 | + | ```toml | |||
| 89 | + | [server] | |||
| 90 | + | force_https_redirect = true | |||
| 91 | + | ``` | |||
| 92 | + | ||||
| 93 | + | 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. | |||
| 94 | + | ||||
| 95 | + | #### Disabling TLS | |||
| 96 | + | ||||
| 97 | + | For setups where TLS is handled entirely by an external proxy (Cloudflare, nginx, etc.): | |||
| 98 | + | ||||
| 99 | + | ```toml | |||
| 100 | + | [server] | |||
| 101 | + | tls_port = 0 # No HTTPS listener, no internal CA generated | |||
| 102 | + | ``` | |||
| 103 | + | ||||
| 104 | + | #### Firewall port forwarding | |||
| 105 | + | ||||
| 106 | + | For rootless user services, forward 443 to the high port at the firewall: | |||
| 107 | + | ||||
| 108 | + | ```bash | |||
| 109 | + | # Forward 443 -> 8443 | |||
| 110 | + | sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443 | |||
| 111 | + | sudo firewall-cmd --reload | |||
| 81 | ``` | 112 | ``` | ||
| 82 | 113 | ||||
| 83 | With firewalld and Tailscale: | 114 | With firewalld and Tailscale: | ||
| @@ -291,14 +322,9 @@ The `registry_listen_address` controls which interface the registry server binds | |||||
| 291 | registry_listen_address = "127.0.0.1" # Loopback only | 322 | registry_listen_address = "127.0.0.1" # Loopback only | ||
| 292 | ``` | 323 | ``` | ||
| 293 | 324 | ||||
| 294 | - | ## Force HSTS | 325 | + | ## HSTS |
| 295 | - | ||||
| 296 | - | When Gordon runs behind a TLS-terminating proxy (e.g. Cloudflare), `r.TLS` is nil so the HSTS header is not sent by default. Enable `force_hsts` to always include the `Strict-Transport-Security` header. | |||
| 297 | 326 | ||||
| 298 | - | ```toml | 327 | + | 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. |
| 299 | - | [server] | |||
| 300 | - | force_hsts = true | |||
| 301 | - | ``` | |||
| 302 | 328 | ||||
| 303 | ## Examples | 329 | ## Examples | ||
| 304 | 330 | ||||
M docs/getting-started.md +1 -1
| @@ -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 **or** native TLS in Gordon (`server.tls_enabled = true`) | |
| 9 | + | - HTTPS setup: Cloudflare proxy, Tailscale, or Gordon's internal CA (`server.tls_port = 8443`) | |
| 10 | 10 | - [pass](https://www.passwordstore.org/) (password manager) with GPG key initialized | |
| 11 | 11 | ||
| 12 | 12 | ## 1. Install Gordon | |
| @@ -6,7 +6,7 @@ Deploy your first app with Gordon in under 5 minutes. | |||||
| 6 | 6 | ||||
| 7 | - A Linux VPS with Docker or Podman installed | 7 | - A Linux VPS with Docker or Podman installed | ||
| 8 | - A domain pointing to your VPS (DNS A record) | 8 | - A domain pointing to your VPS (DNS A record) | ||
| 9 | - | - HTTPS setup: Cloudflare proxy **or** native TLS in Gordon (`server.tls_enabled = true`) | 9 | + | - HTTPS setup: Cloudflare proxy, Tailscale, or Gordon's internal CA (`server.tls_port = 8443`) |
| 10 | - [pass](https://www.passwordstore.org/) (password manager) with GPG key initialized | 10 | - [pass](https://www.passwordstore.org/) (password manager) with GPG key initialized | ||
| 11 | 11 | ||||
| 12 | ## 1. Install Gordon | 12 | ## 1. Install Gordon | ||
M docs/index.md +1 -1
| @@ -80,7 +80,7 @@ Gordon runs on your VPS and provides: | |||
| 80 | 80 | - Domain pointing to your server | |
| 81 | 81 | - Cloudflare account for HTTPS (free tier works) | |
| 82 | 82 | ||
| 83 | - | > **Note:** Gordon can run behind Cloudflare/nginx, or terminate TLS directly when `server.tls_enabled = true`. | |
| 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 | 84 | ||
| 85 | 85 | ## Related | |
| 86 | 86 | ||
| @@ -80,7 +80,7 @@ Gordon runs on your VPS and provides: | |||||
| 80 | - Domain pointing to your server | 80 | - Domain pointing to your server | ||
| 81 | - Cloudflare account for HTTPS (free tier works) | 81 | - Cloudflare account for HTTPS (free tier works) | ||
| 82 | 82 | ||||
| 83 | - | > **Note:** Gordon can run behind Cloudflare/nginx, or terminate TLS directly when `server.tls_enabled = true`. | 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 | 84 | ||||
| 85 | ## Related | 85 | ## Related | ||
| 86 | 86 | ||||
M docs/upgrading.md +31 -0
| @@ -2,6 +2,37 @@ | |||
| 2 | 2 | ||
| 3 | 3 | This guide covers breaking changes and migration steps between major versions. | |
| 4 | 4 | ||
| 5 | + | ## v2.30.0 to v2.31.0 | |
| 6 | + | ||
| 7 | + | ### Breaking: Default Port Changes | |
| 8 | + | ||
| 9 | + | Gordon now defaults to unprivileged ports that work without root or firewall rules: | |
| 10 | + | ||
| 11 | + | | Setting | Old default | New default | | |
| 12 | + | |---------|-------------|-------------| | |
| 13 | + | | `server.port` | `80` | `8088` | | |
| 14 | + | | `server.registry_port` | `5000` | `5000` (unchanged) | | |
| 15 | + | | `server.tls_port` | *(n/a)* | `8443` | | |
| 16 | + | ||
| 17 | + | **If you relied on the old defaults** (no explicit `port` in your config), add the port explicitly: | |
| 18 | + | ||
| 19 | + | ```toml | |
| 20 | + | [server] | |
| 21 | + | port = 80 | |
| 22 | + | ``` | |
| 23 | + | ||
| 24 | + | Or set up firewall port forwarding to the new defaults: | |
| 25 | + | ||
| 26 | + | ```bash | |
| 27 | + | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088 | |
| 28 | + | sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443 | |
| 29 | + | sudo firewall-cmd --reload | |
| 30 | + | ``` | |
| 31 | + | ||
| 32 | + | ### New: Internal Certificate Authority | |
| 33 | + | ||
| 34 | + | Gordon now includes an internal CA for automatic on-demand TLS. Set `tls_port = 0` to disable. See [Server Configuration](./config/server.md#internal-ca-and-tls) for details. | |
| 35 | + | ||
| 5 | 36 | ## v2.16.0 to v2.30.0 | |
| 6 | 37 | ||
| 7 | 38 | ### Breaking: Password Authentication Removed | |
| @@ -2,6 +2,37 @@ | |||||
| 2 | 2 | ||||
| 3 | This guide covers breaking changes and migration steps between major versions. | 3 | This guide covers breaking changes and migration steps between major versions. | ||
| 4 | 4 | ||||
| 5 | + | ## v2.30.0 to v2.31.0 | |||
| 6 | + | ||||
| 7 | + | ### Breaking: Default Port Changes | |||
| 8 | + | ||||
| 9 | + | Gordon now defaults to unprivileged ports that work without root or firewall rules: | |||
| 10 | + | ||||
| 11 | + | | Setting | Old default | New default | | |||
| 12 | + | |---------|-------------|-------------| | |||
| 13 | + | | `server.port` | `80` | `8088` | | |||
| 14 | + | | `server.registry_port` | `5000` | `5000` (unchanged) | | |||
| 15 | + | | `server.tls_port` | *(n/a)* | `8443` | | |||
| 16 | + | ||||
| 17 | + | **If you relied on the old defaults** (no explicit `port` in your config), add the port explicitly: | |||
| 18 | + | ||||
| 19 | + | ```toml | |||
| 20 | + | [server] | |||
| 21 | + | port = 80 | |||
| 22 | + | ``` | |||
| 23 | + | ||||
| 24 | + | Or set up firewall port forwarding to the new defaults: | |||
| 25 | + | ||||
| 26 | + | ```bash | |||
| 27 | + | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088 | |||
| 28 | + | sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443 | |||
| 29 | + | sudo firewall-cmd --reload | |||
| 30 | + | ``` | |||
| 31 | + | ||||
| 32 | + | ### New: Internal Certificate Authority | |||
| 33 | + | ||||
| 34 | + | Gordon now includes an internal CA for automatic on-demand TLS. Set `tls_port = 0` to disable. See [Server Configuration](./config/server.md#internal-ca-and-tls) for details. | |||
| 35 | + | ||||
| 5 | ## v2.16.0 to v2.30.0 | 36 | ## v2.16.0 to v2.30.0 | ||
| 6 | 37 | ||||
| 7 | ### Breaking: Password Authentication Removed | 38 | ### Breaking: Password Authentication Removed | ||
M go.mod +8 -7
| @@ -13,7 +13,9 @@ require ( | |||
| 13 | 13 | github.com/google/go-containerregistry v0.21.3 | |
| 14 | 14 | github.com/google/uuid v1.6.0 | |
| 15 | 15 | github.com/mattn/go-runewidth v0.0.21 | |
| 16 | + | github.com/muesli/termenv v0.16.0 | |
| 16 | 17 | github.com/rivo/uniseg v0.4.7 | |
| 18 | + | github.com/smallstep/truststore v0.13.0 | |
| 17 | 19 | github.com/spf13/cobra v1.10.2 | |
| 18 | 20 | github.com/spf13/viper v1.21.0 | |
| 19 | 21 | github.com/stretchr/testify v1.11.1 | |
| @@ -23,7 +25,6 @@ require ( | |||
| 23 | 25 | go.opentelemetry.io/otel/sdk/log v0.18.0 | |
| 24 | 26 | go.opentelemetry.io/otel/sdk/metric v1.42.0 | |
| 25 | 27 | golang.org/x/mod v0.34.0 | |
| 26 | - | golang.org/x/term v0.41.0 | |
| 27 | 28 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | |
| 28 | 29 | ) | |
| 29 | 30 | ||
| @@ -53,7 +54,6 @@ require ( | |||
| 53 | 54 | github.com/morikuni/aec v1.1.0 // indirect | |
| 54 | 55 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | |
| 55 | 56 | github.com/muesli/cancelreader v0.2.2 // indirect | |
| 56 | - | github.com/muesli/termenv v0.16.0 // indirect | |
| 57 | 57 | github.com/rs/zerolog v1.34.0 // indirect | |
| 58 | 58 | github.com/sirupsen/logrus v1.9.4 // indirect | |
| 59 | 59 | github.com/vbatts/tar-split v0.12.2 // indirect | |
| @@ -62,19 +62,20 @@ require ( | |||
| 62 | 62 | go.opentelemetry.io/otel/log v0.18.0 // indirect | |
| 63 | 63 | go.opentelemetry.io/proto/otlp v1.9.0 // indirect | |
| 64 | 64 | go.yaml.in/yaml/v3 v3.0.4 // indirect | |
| 65 | - | golang.org/x/net v0.51.0 // indirect | |
| 65 | + | golang.org/x/net v0.52.0 // indirect | |
| 66 | 66 | golang.org/x/sync v0.20.0 // indirect | |
| 67 | 67 | google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect | |
| 68 | - | google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect | |
| 68 | + | google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect | |
| 69 | 69 | google.golang.org/grpc v1.79.3 // indirect | |
| 70 | 70 | google.golang.org/protobuf v1.36.11 // indirect | |
| 71 | + | howett.net/plist v1.0.0 // indirect | |
| 71 | 72 | ) | |
| 72 | 73 | ||
| 73 | 74 | require ( | |
| 74 | 75 | github.com/davecgh/go-spew v1.1.1 // indirect | |
| 75 | 76 | github.com/pmezard/go-difflib v1.0.0 // indirect | |
| 76 | 77 | github.com/stretchr/objx v0.5.2 // indirect | |
| 77 | - | go.uber.org/mock v0.5.2 // direct | |
| 78 | + | go.uber.org/mock v0.6.0 // direct | |
| 78 | 79 | golang.org/x/time v0.15.0 | |
| 79 | 80 | ) | |
| 80 | 81 | ||
| @@ -83,7 +84,7 @@ require ( | |||
| 83 | 84 | github.com/containerd/errdefs v1.0.0 | |
| 84 | 85 | github.com/containerd/errdefs/pkg v0.3.0 // indirect | |
| 85 | 86 | github.com/distribution/reference v0.6.0 // indirect | |
| 86 | - | github.com/docker/go-units v0.5.0 // indirect | |
| 87 | + | github.com/docker/go-units v0.5.0 | |
| 87 | 88 | github.com/felixge/httpsnoop v1.0.4 // indirect | |
| 88 | 89 | github.com/fsnotify/fsnotify v1.9.0 // direct | |
| 89 | 90 | github.com/go-logr/logr v1.4.3 // indirect | |
| @@ -91,7 +92,7 @@ require ( | |||
| 91 | 92 | github.com/go-viper/mapstructure/v2 v2.5.0 // indirect | |
| 92 | 93 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 93 | 94 | github.com/mattn/go-colorable v0.1.14 // indirect | |
| 94 | - | github.com/mattn/go-isatty v0.0.20 // indirect | |
| 95 | + | github.com/mattn/go-isatty v0.0.20 | |
| 95 | 96 | github.com/moby/docker-image-spec v1.3.1 // indirect | |
| 96 | 97 | github.com/moby/term v0.5.2 // indirect | |
| 97 | 98 | github.com/opencontainers/go-digest v1.0.0 // indirect | |
| @@ -13,7 +13,9 @@ require ( | |||||
| 13 | github.com/google/go-containerregistry v0.21.3 | 13 | github.com/google/go-containerregistry v0.21.3 | ||
| 14 | github.com/google/uuid v1.6.0 | 14 | github.com/google/uuid v1.6.0 | ||
| 15 | github.com/mattn/go-runewidth v0.0.21 | 15 | github.com/mattn/go-runewidth v0.0.21 | ||
| 16 | + | github.com/muesli/termenv v0.16.0 | |||
| 16 | github.com/rivo/uniseg v0.4.7 | 17 | github.com/rivo/uniseg v0.4.7 | ||
| 18 | + | github.com/smallstep/truststore v0.13.0 | |||
| 17 | github.com/spf13/cobra v1.10.2 | 19 | github.com/spf13/cobra v1.10.2 | ||
| 18 | github.com/spf13/viper v1.21.0 | 20 | github.com/spf13/viper v1.21.0 | ||
| 19 | github.com/stretchr/testify v1.11.1 | 21 | github.com/stretchr/testify v1.11.1 | ||
| @@ -23,7 +25,6 @@ require ( | |||||
| 23 | go.opentelemetry.io/otel/sdk/log v0.18.0 | 25 | go.opentelemetry.io/otel/sdk/log v0.18.0 | ||
| 24 | go.opentelemetry.io/otel/sdk/metric v1.42.0 | 26 | go.opentelemetry.io/otel/sdk/metric v1.42.0 | ||
| 25 | golang.org/x/mod v0.34.0 | 27 | golang.org/x/mod v0.34.0 | ||
| 26 | - | golang.org/x/term v0.41.0 | |||
| 27 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | 28 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | ||
| 28 | ) | 29 | ) | ||
| 29 | 30 | ||||
| @@ -53,7 +54,6 @@ require ( | |||||
| 53 | github.com/morikuni/aec v1.1.0 // indirect | 54 | github.com/morikuni/aec v1.1.0 // indirect | ||
| 54 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | 55 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | ||
| 55 | github.com/muesli/cancelreader v0.2.2 // indirect | 56 | github.com/muesli/cancelreader v0.2.2 // indirect | ||
| 56 | - | github.com/muesli/termenv v0.16.0 // indirect | |||
| 57 | github.com/rs/zerolog v1.34.0 // indirect | 57 | github.com/rs/zerolog v1.34.0 // indirect | ||
| 58 | github.com/sirupsen/logrus v1.9.4 // indirect | 58 | github.com/sirupsen/logrus v1.9.4 // indirect | ||
| 59 | github.com/vbatts/tar-split v0.12.2 // indirect | 59 | github.com/vbatts/tar-split v0.12.2 // indirect | ||
| @@ -62,19 +62,20 @@ require ( | |||||
| 62 | go.opentelemetry.io/otel/log v0.18.0 // indirect | 62 | go.opentelemetry.io/otel/log v0.18.0 // indirect | ||
| 63 | go.opentelemetry.io/proto/otlp v1.9.0 // indirect | 63 | go.opentelemetry.io/proto/otlp v1.9.0 // indirect | ||
| 64 | go.yaml.in/yaml/v3 v3.0.4 // indirect | 64 | go.yaml.in/yaml/v3 v3.0.4 // indirect | ||
| 65 | - | golang.org/x/net v0.51.0 // indirect | 65 | + | golang.org/x/net v0.52.0 // indirect |
| 66 | golang.org/x/sync v0.20.0 // indirect | 66 | golang.org/x/sync v0.20.0 // indirect | ||
| 67 | google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect | 67 | google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect | ||
| 68 | - | google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect | 68 | + | google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect |
| 69 | google.golang.org/grpc v1.79.3 // indirect | 69 | google.golang.org/grpc v1.79.3 // indirect | ||
| 70 | google.golang.org/protobuf v1.36.11 // indirect | 70 | google.golang.org/protobuf v1.36.11 // indirect | ||
| 71 | + | howett.net/plist v1.0.0 // indirect | |||
| 71 | ) | 72 | ) | ||
| 72 | 73 | ||||
| 73 | require ( | 74 | require ( | ||
| 74 | github.com/davecgh/go-spew v1.1.1 // indirect | 75 | github.com/davecgh/go-spew v1.1.1 // indirect | ||
| 75 | github.com/pmezard/go-difflib v1.0.0 // indirect | 76 | github.com/pmezard/go-difflib v1.0.0 // indirect | ||
| 76 | github.com/stretchr/objx v0.5.2 // indirect | 77 | github.com/stretchr/objx v0.5.2 // indirect | ||
| 77 | - | go.uber.org/mock v0.5.2 // direct | 78 | + | go.uber.org/mock v0.6.0 // direct |
| 78 | golang.org/x/time v0.15.0 | 79 | golang.org/x/time v0.15.0 | ||
| 79 | ) | 80 | ) | ||
| 80 | 81 | ||||
| @@ -83,7 +84,7 @@ require ( | |||||
| 83 | github.com/containerd/errdefs v1.0.0 | 84 | github.com/containerd/errdefs v1.0.0 | ||
| 84 | github.com/containerd/errdefs/pkg v0.3.0 // indirect | 85 | github.com/containerd/errdefs/pkg v0.3.0 // indirect | ||
| 85 | github.com/distribution/reference v0.6.0 // indirect | 86 | github.com/distribution/reference v0.6.0 // indirect | ||
| 86 | - | github.com/docker/go-units v0.5.0 // indirect | 87 | + | github.com/docker/go-units v0.5.0 |
| 87 | github.com/felixge/httpsnoop v1.0.4 // indirect | 88 | github.com/felixge/httpsnoop v1.0.4 // indirect | ||
| 88 | github.com/fsnotify/fsnotify v1.9.0 // direct | 89 | github.com/fsnotify/fsnotify v1.9.0 // direct | ||
| 89 | github.com/go-logr/logr v1.4.3 // indirect | 90 | github.com/go-logr/logr v1.4.3 // indirect | ||
| @@ -91,7 +92,7 @@ require ( | |||||
| 91 | github.com/go-viper/mapstructure/v2 v2.5.0 // indirect | 92 | github.com/go-viper/mapstructure/v2 v2.5.0 // indirect | ||
| 92 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | 93 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||
| 93 | github.com/mattn/go-colorable v0.1.14 // indirect | 94 | github.com/mattn/go-colorable v0.1.14 // indirect | ||
| 94 | - | github.com/mattn/go-isatty v0.0.20 // indirect | 95 | + | github.com/mattn/go-isatty v0.0.20 |
| 95 | github.com/moby/docker-image-spec v1.3.1 // indirect | 96 | github.com/moby/docker-image-spec v1.3.1 // indirect | ||
| 96 | github.com/moby/term v0.5.2 // indirect | 97 | github.com/moby/term v0.5.2 // indirect | ||
| 97 | github.com/opencontainers/go-digest v1.0.0 // indirect | 98 | github.com/opencontainers/go-digest v1.0.0 // indirect | ||
M go.sum +12 -8
| @@ -92,6 +92,7 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz | |||
| 92 | 92 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= | |
| 93 | 93 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= | |
| 94 | 94 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= | |
| 95 | + | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= | |
| 95 | 96 | github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= | |
| 96 | 97 | github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= | |
| 97 | 98 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | |
| @@ -151,6 +152,8 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee | |||
| 151 | 152 | github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= | |
| 152 | 153 | github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= | |
| 153 | 154 | github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= | |
| 155 | + | github.com/smallstep/truststore v0.13.0 h1:90if9htAOblavbMeWlqNLnO9bsjjgVv2hQeQJCi/py4= | |
| 156 | + | github.com/smallstep/truststore v0.13.0/go.mod h1:3tmMp2aLKZ/OA/jnFUB0cYPcho402UG2knuJoPh4j7A= | |
| 154 | 157 | github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= | |
| 155 | 158 | github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= | |
| 156 | 159 | github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= | |
| @@ -204,16 +207,16 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce | |||
| 204 | 207 | go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= | |
| 205 | 208 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= | |
| 206 | 209 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= | |
| 207 | - | go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= | |
| 208 | - | go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= | |
| 210 | + | go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= | |
| 211 | + | go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= | |
| 209 | 212 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= | |
| 210 | 213 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= | |
| 211 | 214 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= | |
| 212 | 215 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= | |
| 213 | 216 | golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= | |
| 214 | 217 | golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= | |
| 215 | - | golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= | |
| 216 | - | golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= | |
| 218 | + | golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= | |
| 219 | + | golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= | |
| 217 | 220 | golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= | |
| 218 | 221 | golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= | |
| 219 | 222 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| @@ -222,8 +225,6 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |||
| 222 | 225 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| 223 | 226 | golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= | |
| 224 | 227 | golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= | |
| 225 | - | golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= | |
| 226 | - | golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= | |
| 227 | 228 | golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= | |
| 228 | 229 | golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= | |
| 229 | 230 | golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= | |
| @@ -232,8 +233,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= | |||
| 232 | 233 | gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= | |
| 233 | 234 | google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= | |
| 234 | 235 | google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= | |
| 235 | - | google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= | |
| 236 | - | google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= | |
| 236 | + | google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= | |
| 237 | + | google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= | |
| 237 | 238 | google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= | |
| 238 | 239 | google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= | |
| 239 | 240 | google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= | |
| @@ -243,7 +244,10 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN | |||
| 243 | 244 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= | |
| 244 | 245 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= | |
| 245 | 246 | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= | |
| 247 | + | gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= | |
| 246 | 248 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | |
| 247 | 249 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | |
| 248 | 250 | gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= | |
| 249 | 251 | gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= | |
| 252 | + | howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= | |
| 253 | + | howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= | |
| @@ -92,6 +92,7 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz | |||||
| 92 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= | 92 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= | ||
| 93 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= | 93 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= | ||
| 94 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= | 94 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= | ||
| 95 | + | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= | |||
| 95 | github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= | 96 | github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= | ||
| 96 | github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= | 97 | github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= | ||
| 97 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | 98 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | ||
| @@ -151,6 +152,8 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee | |||||
| 151 | github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= | 152 | github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= | ||
| 152 | github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= | 153 | github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= | ||
| 153 | github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= | 154 | github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= | ||
| 155 | + | github.com/smallstep/truststore v0.13.0 h1:90if9htAOblavbMeWlqNLnO9bsjjgVv2hQeQJCi/py4= | |||
| 156 | + | github.com/smallstep/truststore v0.13.0/go.mod h1:3tmMp2aLKZ/OA/jnFUB0cYPcho402UG2knuJoPh4j7A= | |||
| 154 | github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= | 157 | github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= | ||
| 155 | github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= | 158 | github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= | ||
| 156 | github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= | 159 | github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= | ||
| @@ -204,16 +207,16 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce | |||||
| 204 | go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= | 207 | go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= | ||
| 205 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= | 208 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= | ||
| 206 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= | 209 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= | ||
| 207 | - | go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= | 210 | + | go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= |
| 208 | - | go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= | 211 | + | go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= |
| 209 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= | 212 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= | ||
| 210 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= | 213 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= | ||
| 211 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= | 214 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= | ||
| 212 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= | 215 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= | ||
| 213 | golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= | 216 | golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= | ||
| 214 | golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= | 217 | golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= | ||
| 215 | - | golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= | 218 | + | golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= |
| 216 | - | golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= | 219 | + | golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= |
| 217 | golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= | 220 | golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= | ||
| 218 | golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= | 221 | golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= | ||
| 219 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | 222 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||
| @@ -222,8 +225,6 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |||||
| 222 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | 225 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||
| 223 | golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= | 226 | golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= | ||
| 224 | golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= | 227 | golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= | ||
| 225 | - | golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= | |||
| 226 | - | golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= | |||
| 227 | golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= | 228 | golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= | ||
| 228 | golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= | 229 | golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= | ||
| 229 | golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= | 230 | golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= | ||
| @@ -232,8 +233,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= | |||||
| 232 | gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= | 233 | gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= | ||
| 233 | google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= | 234 | google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= | ||
| 234 | google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= | 235 | google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= | ||
| 235 | - | google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= | 236 | + | google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= |
| 236 | - | google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= | 237 | + | google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= |
| 237 | google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= | 238 | google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= | ||
| 238 | google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= | 239 | google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= | ||
| 239 | google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= | 240 | google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= | ||
| @@ -243,7 +244,10 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN | |||||
| 243 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= | 244 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= | ||
| 244 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= | 245 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= | ||
| 245 | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= | 246 | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= | ||
| 247 | + | gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= | |||
| 246 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | 248 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||
| 247 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | 249 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||
| 248 | gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= | 250 | gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= | ||
| 249 | gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= | 251 | gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= | ||
| 252 | + | howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= | |||
| 253 | + | howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= | |||
A internal/adapters/in/cli/ca.go +201 -0
| @@ -0,0 +1,201 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/x509" | |
| 6 | + | "encoding/pem" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/spf13/cobra" | |
| 14 | + | ||
| 15 | + | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func newCACmd() *cobra.Command { | |
| 19 | + | cmd := &cobra.Command{ | |
| 20 | + | Use: "ca", | |
| 21 | + | Short: "Manage Gordon's internal Certificate Authority", | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | cmd.AddCommand(newCAExportCmd()) | |
| 25 | + | cmd.AddCommand(newCAInstallCmd()) | |
| 26 | + | cmd.AddCommand(newCAInfoCmd()) | |
| 27 | + | ||
| 28 | + | return cmd | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func newCAExportCmd() *cobra.Command { | |
| 32 | + | var outPath string | |
| 33 | + | ||
| 34 | + | cmd := &cobra.Command{ | |
| 35 | + | Use: "export", | |
| 36 | + | Short: "Export the root CA certificate", | |
| 37 | + | Long: "Export Gordon's root CA certificate in PEM format for manual trust installation.", | |
| 38 | + | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 39 | + | dataDir, err := resolveCADataDir() | |
| 40 | + | if err != nil { | |
| 41 | + | return err | |
| 42 | + | } | |
| 43 | + | return runCAExport(cmd.Context(), cmd.OutOrStdout(), dataDir, outPath) | |
| 44 | + | }, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | cmd.Flags().StringVar(&outPath, "out", "", "Write certificate to file instead of stdout") | |
| 48 | + | ||
| 49 | + | return cmd | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func newCAInstallCmd() *cobra.Command { | |
| 53 | + | var ( | |
| 54 | + | uninstall bool | |
| 55 | + | jsonOut bool | |
| 56 | + | ) | |
| 57 | + | ||
| 58 | + | cmd := &cobra.Command{ | |
| 59 | + | Use: "install", | |
| 60 | + | Short: "Install/uninstall the root CA in the system trust store", | |
| 61 | + | Long: "Install Gordon's root CA certificate into the system, Firefox, and Java trust stores. Requires running as root.", | |
| 62 | + | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 63 | + | dataDir, err := resolveCADataDir() | |
| 64 | + | if err != nil { | |
| 65 | + | return err | |
| 66 | + | } | |
| 67 | + | return runCAInstall(cmd.Context(), cmd.OutOrStdout(), dataDir, uninstall, jsonOut) | |
| 68 | + | }, | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | cmd.Flags().BoolVar(&uninstall, "uninstall", false, "Remove from trust stores instead of installing") | |
| 72 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 73 | + | ||
| 74 | + | return cmd | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func newCAInfoCmd() *cobra.Command { | |
| 78 | + | var jsonOut bool | |
| 79 | + | ||
| 80 | + | cmd := &cobra.Command{ | |
| 81 | + | Use: "info", | |
| 82 | + | Short: "Show CA status information", | |
| 83 | + | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 84 | + | dataDir, err := resolveCADataDir() | |
| 85 | + | if err != nil { | |
| 86 | + | return err | |
| 87 | + | } | |
| 88 | + | return runCAInfo(cmd.Context(), cmd.OutOrStdout(), dataDir, jsonOut) | |
| 89 | + | }, | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 93 | + | ||
| 94 | + | return cmd | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func resolveCADataDir() (string, error) { | |
| 98 | + | local, err := GetLocalServices(configPath) | |
| 99 | + | if err != nil { | |
| 100 | + | return "", err | |
| 101 | + | } | |
| 102 | + | if local.GetTLSPort() == 0 { | |
| 103 | + | return "", fmt.Errorf("internal TLS is disabled (server.tls_port=0); CA commands are not available — set server.tls_port to enable") | |
| 104 | + | } | |
| 105 | + | return local.GetDataDir(), nil | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // loadCAFromDataDir creates a CA adapter directly (bypassing the usecase layer). | |
| 109 | + | // This is intentional: CLI commands like 'ca export' and 'ca info' are standalone | |
| 110 | + | // admin utilities that run outside the server lifecycle and don't need business logic. | |
| 111 | + | func loadCAFromDataDir(dataDir string) (*pkiadapter.CA, error) { | |
| 112 | + | ca, err := pkiadapter.NewCA(dataDir, cliLogger()) | |
| 113 | + | if err != nil { | |
| 114 | + | return nil, fmt.Errorf("failed to load CA from %s: %w", dataDir, err) | |
| 115 | + | } | |
| 116 | + | return ca, nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func runCAExport(_ context.Context, out io.Writer, dataDir, outPath string) error { | |
| 120 | + | ca, err := loadCAFromDataDir(dataDir) | |
| 121 | + | if err != nil { | |
| 122 | + | return err | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | rootPEM := ca.RootCertificate() | |
| 126 | + | ||
| 127 | + | if outPath != "" { | |
| 128 | + | if err := os.MkdirAll(filepath.Dir(outPath), 0750); err != nil { | |
| 129 | + | return fmt.Errorf("create directory for %s: %w", outPath, err) | |
| 130 | + | } | |
| 131 | + | if err := os.WriteFile(outPath, rootPEM, 0600); err != nil { | |
| 132 | + | return fmt.Errorf("write certificate to %s: %w", outPath, err) | |
| 133 | + | } | |
| 134 | + | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Root CA certificate written to %s", outPath))) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | _, err = out.Write(rootPEM) | |
| 138 | + | return err | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func runCAInstall(_ context.Context, out io.Writer, dataDir string, uninstall, jsonOut bool) error { | |
| 142 | + | ca, err := loadCAFromDataDir(dataDir) | |
| 143 | + | if err != nil { | |
| 144 | + | return err | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | rootPEM := ca.RootCertificate() | |
| 148 | + | block, _ := pem.Decode(rootPEM) | |
| 149 | + | if block == nil { | |
| 150 | + | return fmt.Errorf("failed to decode root CA PEM") | |
| 151 | + | } | |
| 152 | + | cert, err := x509.ParseCertificate(block.Bytes) | |
| 153 | + | if err != nil { | |
| 154 | + | return fmt.Errorf("failed to parse root CA: %w", err) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | if uninstall { | |
| 158 | + | if err := pkiadapter.UninstallRoot(cert); err != nil { | |
| 159 | + | return fmt.Errorf("failed to uninstall root CA: %w", err) | |
| 160 | + | } | |
| 161 | + | if jsonOut { | |
| 162 | + | return writeJSON(out, map[string]string{"status": "uninstalled"}) | |
| 163 | + | } | |
| 164 | + | return cliWriteLine(out, cliRenderSuccess("Root CA removed from system trust stores")) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | if err := pkiadapter.InstallRoot(cert); err != nil { | |
| 168 | + | return fmt.Errorf("failed to install root CA: %w", err) | |
| 169 | + | } | |
| 170 | + | if jsonOut { | |
| 171 | + | return writeJSON(out, map[string]string{"status": "installed"}) | |
| 172 | + | } | |
| 173 | + | return cliWriteLine(out, cliRenderSuccess("Root CA installed in system trust stores (system, Firefox, Java)")) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func runCAInfo(_ context.Context, out io.Writer, dataDir string, jsonOut bool) error { | |
| 177 | + | ca, err := loadCAFromDataDir(dataDir) | |
| 178 | + | if err != nil { | |
| 179 | + | return err | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | interExpiry := ca.IntermediateExpiresAt() | |
| 183 | + | remaining := time.Until(interExpiry).Truncate(time.Minute) | |
| 184 | + | ||
| 185 | + | if jsonOut { | |
| 186 | + | return writeJSON(out, map[string]any{ | |
| 187 | + | "root_cn": ca.RootCommonName(), | |
| 188 | + | "fingerprint": "SHA256:" + ca.RootFingerprint(), | |
| 189 | + | "intermediate_expiry": interExpiry.Format(time.RFC3339), | |
| 190 | + | "intermediate_ttl": remaining.String(), | |
| 191 | + | }) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | if err := cliWriteLine(out, cliRenderMeta("Root CA:", ca.RootCommonName())); err != nil { | |
| 195 | + | return err | |
| 196 | + | } | |
| 197 | + | if err := cliWriteLine(out, cliRenderMeta("Fingerprint:", "SHA256:"+ca.RootFingerprint())); err != nil { | |
| 198 | + | return err | |
| 199 | + | } | |
| 200 | + | return cliWriteLine(out, cliRenderMeta("Intermediate:", fmt.Sprintf("expires in %s (auto-renews)", remaining))) | |
| 201 | + | } | |
| @@ -0,0 +1,201 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/x509" | |||
| 6 | + | "encoding/pem" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/spf13/cobra" | |||
| 14 | + | ||||
| 15 | + | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func newCACmd() *cobra.Command { | |||
| 19 | + | cmd := &cobra.Command{ | |||
| 20 | + | Use: "ca", | |||
| 21 | + | Short: "Manage Gordon's internal Certificate Authority", | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | cmd.AddCommand(newCAExportCmd()) | |||
| 25 | + | cmd.AddCommand(newCAInstallCmd()) | |||
| 26 | + | cmd.AddCommand(newCAInfoCmd()) | |||
| 27 | + | ||||
| 28 | + | return cmd | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func newCAExportCmd() *cobra.Command { | |||
| 32 | + | var outPath string | |||
| 33 | + | ||||
| 34 | + | cmd := &cobra.Command{ | |||
| 35 | + | Use: "export", | |||
| 36 | + | Short: "Export the root CA certificate", | |||
| 37 | + | Long: "Export Gordon's root CA certificate in PEM format for manual trust installation.", | |||
| 38 | + | RunE: func(cmd *cobra.Command, _ []string) error { | |||
| 39 | + | dataDir, err := resolveCADataDir() | |||
| 40 | + | if err != nil { | |||
| 41 | + | return err | |||
| 42 | + | } | |||
| 43 | + | return runCAExport(cmd.Context(), cmd.OutOrStdout(), dataDir, outPath) | |||
| 44 | + | }, | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | cmd.Flags().StringVar(&outPath, "out", "", "Write certificate to file instead of stdout") | |||
| 48 | + | ||||
| 49 | + | return cmd | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func newCAInstallCmd() *cobra.Command { | |||
| 53 | + | var ( | |||
| 54 | + | uninstall bool | |||
| 55 | + | jsonOut bool | |||
| 56 | + | ) | |||
| 57 | + | ||||
| 58 | + | cmd := &cobra.Command{ | |||
| 59 | + | Use: "install", | |||
| 60 | + | Short: "Install/uninstall the root CA in the system trust store", | |||
| 61 | + | Long: "Install Gordon's root CA certificate into the system, Firefox, and Java trust stores. Requires running as root.", | |||
| 62 | + | RunE: func(cmd *cobra.Command, _ []string) error { | |||
| 63 | + | dataDir, err := resolveCADataDir() | |||
| 64 | + | if err != nil { | |||
| 65 | + | return err | |||
| 66 | + | } | |||
| 67 | + | return runCAInstall(cmd.Context(), cmd.OutOrStdout(), dataDir, uninstall, jsonOut) | |||
| 68 | + | }, | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | cmd.Flags().BoolVar(&uninstall, "uninstall", false, "Remove from trust stores instead of installing") | |||
| 72 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 73 | + | ||||
| 74 | + | return cmd | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func newCAInfoCmd() *cobra.Command { | |||
| 78 | + | var jsonOut bool | |||
| 79 | + | ||||
| 80 | + | cmd := &cobra.Command{ | |||
| 81 | + | Use: "info", | |||
| 82 | + | Short: "Show CA status information", | |||
| 83 | + | RunE: func(cmd *cobra.Command, _ []string) error { | |||
| 84 | + | dataDir, err := resolveCADataDir() | |||
| 85 | + | if err != nil { | |||
| 86 | + | return err | |||
| 87 | + | } | |||
| 88 | + | return runCAInfo(cmd.Context(), cmd.OutOrStdout(), dataDir, jsonOut) | |||
| 89 | + | }, | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 93 | + | ||||
| 94 | + | return cmd | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func resolveCADataDir() (string, error) { | |||
| 98 | + | local, err := GetLocalServices(configPath) | |||
| 99 | + | if err != nil { | |||
| 100 | + | return "", err | |||
| 101 | + | } | |||
| 102 | + | if local.GetTLSPort() == 0 { | |||
| 103 | + | return "", fmt.Errorf("internal TLS is disabled (server.tls_port=0); CA commands are not available — set server.tls_port to enable") | |||
| 104 | + | } | |||
| 105 | + | return local.GetDataDir(), nil | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // loadCAFromDataDir creates a CA adapter directly (bypassing the usecase layer). | |||
| 109 | + | // This is intentional: CLI commands like 'ca export' and 'ca info' are standalone | |||
| 110 | + | // admin utilities that run outside the server lifecycle and don't need business logic. | |||
| 111 | + | func loadCAFromDataDir(dataDir string) (*pkiadapter.CA, error) { | |||
| 112 | + | ca, err := pkiadapter.NewCA(dataDir, cliLogger()) | |||
| 113 | + | if err != nil { | |||
| 114 | + | return nil, fmt.Errorf("failed to load CA from %s: %w", dataDir, err) | |||
| 115 | + | } | |||
| 116 | + | return ca, nil | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func runCAExport(_ context.Context, out io.Writer, dataDir, outPath string) error { | |||
| 120 | + | ca, err := loadCAFromDataDir(dataDir) | |||
| 121 | + | if err != nil { | |||
| 122 | + | return err | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | rootPEM := ca.RootCertificate() | |||
| 126 | + | ||||
| 127 | + | if outPath != "" { | |||
| 128 | + | if err := os.MkdirAll(filepath.Dir(outPath), 0750); err != nil { | |||
| 129 | + | return fmt.Errorf("create directory for %s: %w", outPath, err) | |||
| 130 | + | } | |||
| 131 | + | if err := os.WriteFile(outPath, rootPEM, 0600); err != nil { | |||
| 132 | + | return fmt.Errorf("write certificate to %s: %w", outPath, err) | |||
| 133 | + | } | |||
| 134 | + | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Root CA certificate written to %s", outPath))) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | _, err = out.Write(rootPEM) | |||
| 138 | + | return err | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func runCAInstall(_ context.Context, out io.Writer, dataDir string, uninstall, jsonOut bool) error { | |||
| 142 | + | ca, err := loadCAFromDataDir(dataDir) | |||
| 143 | + | if err != nil { | |||
| 144 | + | return err | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | rootPEM := ca.RootCertificate() | |||
| 148 | + | block, _ := pem.Decode(rootPEM) | |||
| 149 | + | if block == nil { | |||
| 150 | + | return fmt.Errorf("failed to decode root CA PEM") | |||
| 151 | + | } | |||
| 152 | + | cert, err := x509.ParseCertificate(block.Bytes) | |||
| 153 | + | if err != nil { | |||
| 154 | + | return fmt.Errorf("failed to parse root CA: %w", err) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | if uninstall { | |||
| 158 | + | if err := pkiadapter.UninstallRoot(cert); err != nil { | |||
| 159 | + | return fmt.Errorf("failed to uninstall root CA: %w", err) | |||
| 160 | + | } | |||
| 161 | + | if jsonOut { | |||
| 162 | + | return writeJSON(out, map[string]string{"status": "uninstalled"}) | |||
| 163 | + | } | |||
| 164 | + | return cliWriteLine(out, cliRenderSuccess("Root CA removed from system trust stores")) | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | if err := pkiadapter.InstallRoot(cert); err != nil { | |||
| 168 | + | return fmt.Errorf("failed to install root CA: %w", err) | |||
| 169 | + | } | |||
| 170 | + | if jsonOut { | |||
| 171 | + | return writeJSON(out, map[string]string{"status": "installed"}) | |||
| 172 | + | } | |||
| 173 | + | return cliWriteLine(out, cliRenderSuccess("Root CA installed in system trust stores (system, Firefox, Java)")) | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func runCAInfo(_ context.Context, out io.Writer, dataDir string, jsonOut bool) error { | |||
| 177 | + | ca, err := loadCAFromDataDir(dataDir) | |||
| 178 | + | if err != nil { | |||
| 179 | + | return err | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | interExpiry := ca.IntermediateExpiresAt() | |||
| 183 | + | remaining := time.Until(interExpiry).Truncate(time.Minute) | |||
| 184 | + | ||||
| 185 | + | if jsonOut { | |||
| 186 | + | return writeJSON(out, map[string]any{ | |||
| 187 | + | "root_cn": ca.RootCommonName(), | |||
| 188 | + | "fingerprint": "SHA256:" + ca.RootFingerprint(), | |||
| 189 | + | "intermediate_expiry": interExpiry.Format(time.RFC3339), | |||
| 190 | + | "intermediate_ttl": remaining.String(), | |||
| 191 | + | }) | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | if err := cliWriteLine(out, cliRenderMeta("Root CA:", ca.RootCommonName())); err != nil { | |||
| 195 | + | return err | |||
| 196 | + | } | |||
| 197 | + | if err := cliWriteLine(out, cliRenderMeta("Fingerprint:", "SHA256:"+ca.RootFingerprint())); err != nil { | |||
| 198 | + | return err | |||
| 199 | + | } | |||
| 200 | + | return cliWriteLine(out, cliRenderMeta("Intermediate:", fmt.Sprintf("expires in %s (auto-renews)", remaining))) | |||
| 201 | + | } | |||
M internal/adapters/in/cli/controlplane_resolver.go +1 -3
| @@ -3,8 +3,6 @@ package cli | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/zerowrap" | |
| 7 | - | ||
| 8 | 6 | "github.com/bnema/gordon/internal/app" | |
| 9 | 7 | ) | |
| 10 | 8 | ||
| @@ -19,7 +17,7 @@ func (h *controlPlaneHandle) close() { | |||
| 19 | 17 | return | |
| 20 | 18 | } | |
| 21 | 19 | if err := h.closeFn(); err != nil { | |
| 22 | - | log := zerowrap.Default() | |
| 20 | + | log := cliLogger() | |
| 23 | 21 | log.Warn().Err(err).Msg("failed to close control-plane resources") | |
| 24 | 22 | } | |
| 25 | 23 | } | |
| @@ -3,8 +3,6 @@ package cli | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | 5 | ||||
| 6 | - | "github.com/bnema/zerowrap" | |||
| 7 | - | ||||
| 8 | "github.com/bnema/gordon/internal/app" | 6 | "github.com/bnema/gordon/internal/app" | ||
| 9 | ) | 7 | ) | ||
| 10 | 8 | ||||
| @@ -19,7 +17,7 @@ func (h *controlPlaneHandle) close() { | |||||
| 19 | return | 17 | return | ||
| 20 | } | 18 | } | ||
| 21 | if err := h.closeFn(); err != nil { | 19 | if err := h.closeFn(); err != nil { | ||
| 22 | - | log := zerowrap.Default() | 20 | + | log := cliLogger() |
| 23 | log.Warn().Err(err).Msg("failed to close control-plane resources") | 21 | log.Warn().Err(err).Msg("failed to close control-plane resources") | ||
| 24 | } | 22 | } | ||
| 25 | } | 23 | } | ||
M internal/adapters/in/cli/local.go +9 -6
| @@ -24,6 +24,7 @@ type LocalServices struct { | |||
| 24 | 24 | configSvc in.ConfigService | |
| 25 | 25 | secretSvc in.SecretService | |
| 26 | 26 | dataDir string | |
| 27 | + | tlsPort int | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 29 | 30 | // GetConfigService returns the config service. | |
| @@ -41,12 +42,17 @@ func (l *LocalServices) GetDataDir() string { | |||
| 41 | 42 | return l.dataDir | |
| 42 | 43 | } | |
| 43 | 44 | ||
| 45 | + | // GetTLSPort returns the configured TLS port (0 means internal TLS is disabled). | |
| 46 | + | func (l *LocalServices) GetTLSPort() int { | |
| 47 | + | return l.tlsPort | |
| 48 | + | } | |
| 49 | + | ||
| 44 | 50 | // GetLocalServices creates local services for CLI operations. | |
| 45 | 51 | // It loads the config and initializes services without starting the server. | |
| 46 | 52 | func GetLocalServices(configPath string) (*LocalServices, error) { | |
| 47 | 53 | // Set up viper with defaults | |
| 48 | 54 | v := viper.New() | |
| 49 | - | v.SetDefault("server.port", 80) | |
| 55 | + | v.SetDefault("server.port", 8088) | |
| 50 | 56 | v.SetDefault("server.registry_port", 5000) | |
| 51 | 57 | v.SetDefault("server.data_dir", app.DefaultDataDir()) | |
| 52 | 58 | ||
| @@ -61,11 +67,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) { | |||
| 61 | 67 | // Config file not found is OK for some operations | |
| 62 | 68 | } | |
| 63 | 69 | ||
| 64 | - | // Create a minimal logger for CLI operations | |
| 65 | - | log := zerowrap.New(zerowrap.Config{ | |
| 66 | - | Level: "warn", | |
| 67 | - | Format: "console", | |
| 68 | - | }) | |
| 70 | + | log := zerowrap.New(cliLogConfig) | |
| 69 | 71 | ||
| 70 | 72 | // Create config service (without event bus for CLI operations) | |
| 71 | 73 | configSvc := config.NewService(v, nil) | |
| @@ -96,6 +98,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) { | |||
| 96 | 98 | configSvc: configSvc, | |
| 97 | 99 | secretSvc: secretSvc, | |
| 98 | 100 | dataDir: dataDir, | |
| 101 | + | tlsPort: v.GetInt("server.tls_port"), | |
| 99 | 102 | }, nil | |
| 100 | 103 | } | |
| 101 | 104 | ||
| @@ -24,6 +24,7 @@ type LocalServices struct { | |||||
| 24 | configSvc in.ConfigService | 24 | configSvc in.ConfigService | ||
| 25 | secretSvc in.SecretService | 25 | secretSvc in.SecretService | ||
| 26 | dataDir string | 26 | dataDir string | ||
| 27 | + | tlsPort int | |||
| 27 | } | 28 | } | ||
| 28 | 29 | ||||
| 29 | // GetConfigService returns the config service. | 30 | // GetConfigService returns the config service. | ||
| @@ -41,12 +42,17 @@ func (l *LocalServices) GetDataDir() string { | |||||
| 41 | return l.dataDir | 42 | return l.dataDir | ||
| 42 | } | 43 | } | ||
| 43 | 44 | ||||
| 45 | + | // GetTLSPort returns the configured TLS port (0 means internal TLS is disabled). | |||
| 46 | + | func (l *LocalServices) GetTLSPort() int { | |||
| 47 | + | return l.tlsPort | |||
| 48 | + | } | |||
| 49 | + | ||||
| 44 | // GetLocalServices creates local services for CLI operations. | 50 | // GetLocalServices creates local services for CLI operations. | ||
| 45 | // It loads the config and initializes services without starting the server. | 51 | // It loads the config and initializes services without starting the server. | ||
| 46 | func GetLocalServices(configPath string) (*LocalServices, error) { | 52 | func GetLocalServices(configPath string) (*LocalServices, error) { | ||
| 47 | // Set up viper with defaults | 53 | // Set up viper with defaults | ||
| 48 | v := viper.New() | 54 | v := viper.New() | ||
| 49 | - | v.SetDefault("server.port", 80) | 55 | + | v.SetDefault("server.port", 8088) |
| 50 | v.SetDefault("server.registry_port", 5000) | 56 | v.SetDefault("server.registry_port", 5000) | ||
| 51 | v.SetDefault("server.data_dir", app.DefaultDataDir()) | 57 | v.SetDefault("server.data_dir", app.DefaultDataDir()) | ||
| 52 | 58 | ||||
| @@ -61,11 +67,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) { | |||||
| 61 | // Config file not found is OK for some operations | 67 | // Config file not found is OK for some operations | ||
| 62 | } | 68 | } | ||
| 63 | 69 | ||||
| 64 | - | // Create a minimal logger for CLI operations | 70 | + | log := zerowrap.New(cliLogConfig) |
| 65 | - | log := zerowrap.New(zerowrap.Config{ | |||
| 66 | - | Level: "warn", | |||
| 67 | - | Format: "console", | |||
| 68 | - | }) | |||
| 69 | 71 | ||||
| 70 | // Create config service (without event bus for CLI operations) | 72 | // Create config service (without event bus for CLI operations) | ||
| 71 | configSvc := config.NewService(v, nil) | 73 | configSvc := config.NewService(v, nil) | ||
| @@ -96,6 +98,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) { | |||||
| 96 | configSvc: configSvc, | 98 | configSvc: configSvc, | ||
| 97 | secretSvc: secretSvc, | 99 | secretSvc: secretSvc, | ||
| 98 | dataDir: dataDir, | 100 | dataDir: dataDir, | ||
| 101 | + | tlsPort: v.GetInt("server.tls_port"), | |||
| 99 | }, nil | 102 | }, nil | ||
| 100 | } | 103 | } | ||
| 101 | 104 | ||||
M internal/adapters/in/cli/presentation.go +14 -0
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | 4 | "fmt" | |
| 5 | 5 | "io" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/zerowrap" | |
| 8 | + | ||
| 7 | 9 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 8 | 10 | ) | |
| 9 | 11 | ||
| @@ -17,6 +19,18 @@ var cliWritef = func(w io.Writer, format string, args ...any) error { | |||
| 17 | 19 | return err | |
| 18 | 20 | } | |
| 19 | 21 | ||
| 22 | + | // cliLogConfig is the shared logger configuration for CLI commands that run | |
| 23 | + | // outside the server lifecycle. Level "warn" keeps normal output clean while | |
| 24 | + | // still surfacing actionable warnings from adapters. | |
| 25 | + | var cliLogConfig = zerowrap.Config{ | |
| 26 | + | Level: "warn", | |
| 27 | + | Format: "console", | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func cliLogger() zerowrap.Logger { | |
| 31 | + | return zerowrap.New(cliLogConfig) | |
| 32 | + | } | |
| 33 | + | ||
| 20 | 34 | func cliRenderTitle(msg string) string { | |
| 21 | 35 | return styles.Theme.Title.Render(msg) | |
| 22 | 36 | } | |
| @@ -4,6 +4,8 @@ import ( | |||||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "io" | 5 | "io" | ||
| 6 | 6 | ||||
| 7 | + | "github.com/bnema/zerowrap" | |||
| 8 | + | ||||
| 7 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | 9 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | ||
| 8 | ) | 10 | ) | ||
| 9 | 11 | ||||
| @@ -17,6 +19,18 @@ var cliWritef = func(w io.Writer, format string, args ...any) error { | |||||
| 17 | return err | 19 | return err | ||
| 18 | } | 20 | } | ||
| 19 | 21 | ||||
| 22 | + | // cliLogConfig is the shared logger configuration for CLI commands that run | |||
| 23 | + | // outside the server lifecycle. Level "warn" keeps normal output clean while | |||
| 24 | + | // still surfacing actionable warnings from adapters. | |||
| 25 | + | var cliLogConfig = zerowrap.Config{ | |||
| 26 | + | Level: "warn", | |||
| 27 | + | Format: "console", | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func cliLogger() zerowrap.Logger { | |||
| 31 | + | return zerowrap.New(cliLogConfig) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 20 | func cliRenderTitle(msg string) string { | 34 | func cliRenderTitle(msg string) string { | ||
| 21 | return styles.Theme.Title.Render(msg) | 35 | return styles.Theme.Title.Render(msg) | ||
| 22 | } | 36 | } | ||
M internal/adapters/in/cli/root.go +4 -0
| @@ -76,6 +76,10 @@ Commands are organized by where they run: | |||
| 76 | 76 | authCmd.GroupID = groupServer | |
| 77 | 77 | rootCmd.AddCommand(authCmd) | |
| 78 | 78 | ||
| 79 | + | caCmd := newCACmd() | |
| 80 | + | caCmd.GroupID = groupServer | |
| 81 | + | rootCmd.AddCommand(caCmd) | |
| 82 | + | ||
| 79 | 83 | // Management commands (work locally or via --remote) | |
| 80 | 84 | routesCmd := newRoutesCmd() | |
| 81 | 85 | routesCmd.GroupID = groupManage | |
| @@ -76,6 +76,10 @@ Commands are organized by where they run: | |||||
| 76 | authCmd.GroupID = groupServer | 76 | authCmd.GroupID = groupServer | ||
| 77 | rootCmd.AddCommand(authCmd) | 77 | rootCmd.AddCommand(authCmd) | ||
| 78 | 78 | ||||
| 79 | + | caCmd := newCACmd() | |||
| 80 | + | caCmd.GroupID = groupServer | |||
| 81 | + | rootCmd.AddCommand(caCmd) | |||
| 82 | + | ||||
| 79 | // Management commands (work locally or via --remote) | 83 | // Management commands (work locally or via --remote) | ||
| 80 | routesCmd := newRoutesCmd() | 84 | routesCmd := newRoutesCmd() | ||
| 81 | routesCmd.GroupID = groupManage | 85 | routesCmd.GroupID = groupManage | ||
M internal/adapters/in/http/middleware/cidr.go +63 -5
| @@ -2,8 +2,10 @@ package middleware | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/json" | |
| 5 | + | "fmt" | |
| 5 | 6 | "net" | |
| 6 | 7 | "net/http" | |
| 8 | + | "strings" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/bnema/zerowrap" | |
| 9 | 11 | ||
| @@ -48,6 +50,15 @@ func cidrAllowlist(allowedNets []*net.IPNet, ipExtractor func(*http.Request) str | |||
| 48 | 50 | } | |
| 49 | 51 | } | |
| 50 | 52 | ||
| 53 | + | // extractRemoteIP returns the IP portion of r.RemoteAddr, stripping the port. | |
| 54 | + | func extractRemoteIP(remoteAddr string) string { | |
| 55 | + | host, _, err := net.SplitHostPort(remoteAddr) | |
| 56 | + | if err != nil { | |
| 57 | + | return remoteAddr | |
| 58 | + | } | |
| 59 | + | return host | |
| 60 | + | } | |
| 61 | + | ||
| 51 | 62 | // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges. | |
| 52 | 63 | // Localhost is always allowed so Gordon can pull from its own registry. | |
| 53 | 64 | // An empty allowedNets slice is a no-op (all traffic passes through). | |
| @@ -57,16 +68,63 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L | |||
| 57 | 68 | }, "registry", log) | |
| 58 | 69 | } | |
| 59 | 70 | ||
| 71 | + | // HTTPSRedirect redirects HTTP clients to the HTTPS port. | |
| 72 | + | // | |
| 73 | + | // When forceAll is true, all HTTP requests are redirected (for setups with no proxy). | |
| 74 | + | // Otherwise, only non-trusted clients are redirected — trusted proxy IPs and localhost | |
| 75 | + | // pass through since they deliver Cloudflare-proxied traffic. | |
| 76 | + | // When tlsPort is 0, this is always a no-op. | |
| 77 | + | func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 78 | + | return func(next http.Handler) http.Handler { | |
| 79 | + | if tlsPort == 0 { | |
| 80 | + | return next | |
| 81 | + | } | |
| 82 | + | if !forceAll && len(proxyNets) == 0 { | |
| 83 | + | log.Info(). | |
| 84 | + | Str(zerowrap.FieldLayer, "adapter"). | |
| 85 | + | Str(zerowrap.FieldAdapter, "http"). | |
| 86 | + | Msg("HTTP→HTTPS redirect disabled: proxy_allowed_ips is empty and force_https_redirect is false; set either to enable redirects") | |
| 87 | + | return next | |
| 88 | + | } | |
| 89 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 90 | + | if !forceAll { | |
| 91 | + | remoteIP := extractRemoteIP(r.RemoteAddr) | |
| 92 | + | if IsTrustedProxy(remoteIP, localhostNets) || IsTrustedProxy(remoteIP, proxyNets) { | |
| 93 | + | next.ServeHTTP(w, r) | |
| 94 | + | return | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | host := r.Host | |
| 99 | + | if h, _, err := net.SplitHostPort(host); err == nil { | |
| 100 | + | host = h | |
| 101 | + | } | |
| 102 | + | path := r.RequestURI | |
| 103 | + | if !strings.HasPrefix(path, "/") { | |
| 104 | + | path = "/" | |
| 105 | + | } | |
| 106 | + | var target string | |
| 107 | + | if tlsPort == 443 { | |
| 108 | + | target = fmt.Sprintf("https://%s%s", host, path) | |
| 109 | + | } else { | |
| 110 | + | target = fmt.Sprintf("https://%s:%d%s", host, tlsPort, path) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | log.Debug(). | |
| 114 | + | Str("target", target). | |
| 115 | + | Msg("redirecting HTTP client to HTTPS") | |
| 116 | + | ||
| 117 | + | http.Redirect(w, r, target, http.StatusPermanentRedirect) | |
| 118 | + | }) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 60 | 122 | // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges. | |
| 61 | 123 | // This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only | |
| 62 | 124 | // trusted sources (e.g. Cloudflare edge IPs) can reach the proxy server. | |
| 63 | 125 | // An empty allowedNets slice is a no-op (all traffic passes through). | |
| 64 | 126 | func ProxyCIDRAllowlist(allowedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 65 | 127 | return cidrAllowlist(allowedNets, func(r *http.Request) string { | |
| 66 | - | remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) | |
| 67 | - | if err != nil { | |
| 68 | - | return r.RemoteAddr | |
| 69 | - | } | |
| 70 | - | return remoteIP | |
| 128 | + | return extractRemoteIP(r.RemoteAddr) | |
| 71 | 129 | }, "proxy origin", log) | |
| 72 | 130 | } | |
| @@ -2,8 +2,10 @@ package middleware | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "encoding/json" | 4 | "encoding/json" | ||
| 5 | + | "fmt" | |||
| 5 | "net" | 6 | "net" | ||
| 6 | "net/http" | 7 | "net/http" | ||
| 8 | + | "strings" | |||
| 7 | 9 | ||||
| 8 | "github.com/bnema/zerowrap" | 10 | "github.com/bnema/zerowrap" | ||
| 9 | 11 | ||||
| @@ -48,6 +50,15 @@ func cidrAllowlist(allowedNets []*net.IPNet, ipExtractor func(*http.Request) str | |||||
| 48 | } | 50 | } | ||
| 49 | } | 51 | } | ||
| 50 | 52 | ||||
| 53 | + | // extractRemoteIP returns the IP portion of r.RemoteAddr, stripping the port. | |||
| 54 | + | func extractRemoteIP(remoteAddr string) string { | |||
| 55 | + | host, _, err := net.SplitHostPort(remoteAddr) | |||
| 56 | + | if err != nil { | |||
| 57 | + | return remoteAddr | |||
| 58 | + | } | |||
| 59 | + | return host | |||
| 60 | + | } | |||
| 61 | + | ||||
| 51 | // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges. | 62 | // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges. | ||
| 52 | // Localhost is always allowed so Gordon can pull from its own registry. | 63 | // Localhost is always allowed so Gordon can pull from its own registry. | ||
| 53 | // An empty allowedNets slice is a no-op (all traffic passes through). | 64 | // An empty allowedNets slice is a no-op (all traffic passes through). | ||
| @@ -57,16 +68,63 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L | |||||
| 57 | }, "registry", log) | 68 | }, "registry", log) | ||
| 58 | } | 69 | } | ||
| 59 | 70 | ||||
| 71 | + | // HTTPSRedirect redirects HTTP clients to the HTTPS port. | |||
| 72 | + | // | |||
| 73 | + | // When forceAll is true, all HTTP requests are redirected (for setups with no proxy). | |||
| 74 | + | // Otherwise, only non-trusted clients are redirected — trusted proxy IPs and localhost | |||
| 75 | + | // pass through since they deliver Cloudflare-proxied traffic. | |||
| 76 | + | // When tlsPort is 0, this is always a no-op. | |||
| 77 | + | func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerowrap.Logger) func(http.Handler) http.Handler { | |||
| 78 | + | return func(next http.Handler) http.Handler { | |||
| 79 | + | if tlsPort == 0 { | |||
| 80 | + | return next | |||
| 81 | + | } | |||
| 82 | + | if !forceAll && len(proxyNets) == 0 { | |||
| 83 | + | log.Info(). | |||
| 84 | + | Str(zerowrap.FieldLayer, "adapter"). | |||
| 85 | + | Str(zerowrap.FieldAdapter, "http"). | |||
| 86 | + | Msg("HTTP→HTTPS redirect disabled: proxy_allowed_ips is empty and force_https_redirect is false; set either to enable redirects") | |||
| 87 | + | return next | |||
| 88 | + | } | |||
| 89 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 90 | + | if !forceAll { | |||
| 91 | + | remoteIP := extractRemoteIP(r.RemoteAddr) | |||
| 92 | + | if IsTrustedProxy(remoteIP, localhostNets) || IsTrustedProxy(remoteIP, proxyNets) { | |||
| 93 | + | next.ServeHTTP(w, r) | |||
| 94 | + | return | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | host := r.Host | |||
| 99 | + | if h, _, err := net.SplitHostPort(host); err == nil { | |||
| 100 | + | host = h | |||
| 101 | + | } | |||
| 102 | + | path := r.RequestURI | |||
| 103 | + | if !strings.HasPrefix(path, "/") { | |||
| 104 | + | path = "/" | |||
| 105 | + | } | |||
| 106 | + | var target string | |||
| 107 | + | if tlsPort == 443 { | |||
| 108 | + | target = fmt.Sprintf("https://%s%s", host, path) | |||
| 109 | + | } else { | |||
| 110 | + | target = fmt.Sprintf("https://%s:%d%s", host, tlsPort, path) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | log.Debug(). | |||
| 114 | + | Str("target", target). | |||
| 115 | + | Msg("redirecting HTTP client to HTTPS") | |||
| 116 | + | ||||
| 117 | + | http.Redirect(w, r, target, http.StatusPermanentRedirect) | |||
| 118 | + | }) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 60 | // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges. | 122 | // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges. | ||
| 61 | // This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only | 123 | // This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only | ||
| 62 | // trusted sources (e.g. Cloudflare edge IPs) can reach the proxy server. | 124 | // trusted sources (e.g. Cloudflare edge IPs) can reach the proxy server. | ||
| 63 | // An empty allowedNets slice is a no-op (all traffic passes through). | 125 | // An empty allowedNets slice is a no-op (all traffic passes through). | ||
| 64 | func ProxyCIDRAllowlist(allowedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | 126 | func ProxyCIDRAllowlist(allowedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | ||
| 65 | return cidrAllowlist(allowedNets, func(r *http.Request) string { | 127 | return cidrAllowlist(allowedNets, func(r *http.Request) string { | ||
| 66 | - | remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) | 128 | + | return extractRemoteIP(r.RemoteAddr) |
| 67 | - | if err != nil { | |||
| 68 | - | return r.RemoteAddr | |||
| 69 | - | } | |||
| 70 | - | return remoteIP | |||
| 71 | }, "proxy origin", log) | 129 | }, "proxy origin", log) | ||
| 72 | } | 130 | } | ||
M internal/adapters/in/http/middleware/security.go +14 -31
| @@ -1,38 +1,21 @@ | |||
| 1 | 1 | package middleware | |
| 2 | 2 | ||
| 3 | - | import ( | |
| 4 | - | "net/http" | |
| 5 | - | ) | |
| 3 | + | import "net/http" | |
| 6 | 4 | ||
| 7 | - | // SecurityHeaders middleware adds standard security headers to HTTP responses. | |
| 5 | + | // SecurityHeaders adds standard security headers to HTTP responses. | |
| 6 | + | // HSTS is set automatically when the connection uses TLS (r.TLS != nil). | |
| 8 | 7 | func SecurityHeaders(next http.Handler) http.Handler { | |
| 9 | - | return SecurityHeadersWithOptions(false)(next) | |
| 10 | - | } | |
| 11 | - | ||
| 12 | - | // SecurityHeadersWithOptions returns a middleware that adds standard security headers. | |
| 13 | - | // When forceHSTS is true, the HSTS header is always set regardless of TLS state. | |
| 14 | - | // This is needed when Gordon runs behind a TLS-terminating proxy (e.g. Cloudflare) | |
| 15 | - | // where r.TLS is nil but the client connection is always HTTPS. | |
| 16 | - | func SecurityHeadersWithOptions(forceHSTS bool) func(http.Handler) http.Handler { | |
| 17 | - | return func(next http.Handler) http.Handler { | |
| 18 | - | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 19 | - | w.Header().Set("X-Content-Type-Options", "nosniff") | |
| 20 | - | w.Header().Set("X-Frame-Options", "DENY") | |
| 21 | - | w.Header().Set("X-XSS-Protection", "1; mode=block") | |
| 22 | - | w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") | |
| 23 | - | w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") | |
| 24 | - | ||
| 25 | - | // Only set when TLS is active or force_hsts is configured, | |
| 26 | - | // since HSTS over plain HTTP would lock out clients. | |
| 27 | - | if r.TLS != nil || forceHSTS { | |
| 28 | - | w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") | |
| 29 | - | } | |
| 8 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 9 | + | w.Header().Set("X-Content-Type-Options", "nosniff") | |
| 10 | + | w.Header().Set("X-Frame-Options", "DENY") | |
| 11 | + | w.Header().Set("X-XSS-Protection", "1; mode=block") | |
| 12 | + | w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") | |
| 13 | + | w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") | |
| 30 | 14 | ||
| 31 | - | // CSP is intentionally NOT set here — Gordon proxies arbitrary web apps, | |
| 32 | - | // so a blanket policy would break proxied sites. CSP is set only on | |
| 33 | - | // Gordon-generated error responses (see proxy error handlers). | |
| 15 | + | if r.TLS != nil { | |
| 16 | + | w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") | |
| 17 | + | } | |
| 34 | 18 | ||
| 35 | - | next.ServeHTTP(w, r) | |
| 36 | - | }) | |
| 37 | - | } | |
| 19 | + | next.ServeHTTP(w, r) | |
| 20 | + | }) | |
| 38 | 21 | } | |
| @@ -1,38 +1,21 @@ | |||||
| 1 | package middleware | 1 | package middleware | ||
| 2 | 2 | ||||
| 3 | - | import ( | 3 | + | import "net/http" |
| 4 | - | "net/http" | |||
| 5 | - | ) | |||
| 6 | 4 | ||||
| 7 | - | // SecurityHeaders middleware adds standard security headers to HTTP responses. | 5 | + | // SecurityHeaders adds standard security headers to HTTP responses. |
| 6 | + | // HSTS is set automatically when the connection uses TLS (r.TLS != nil). | |||
| 8 | func SecurityHeaders(next http.Handler) http.Handler { | 7 | func SecurityHeaders(next http.Handler) http.Handler { | ||
| 9 | - | return SecurityHeadersWithOptions(false)(next) | 8 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 10 | - | } | 9 | + | w.Header().Set("X-Content-Type-Options", "nosniff") |
| 11 | - | 10 | + | w.Header().Set("X-Frame-Options", "DENY") | |
| 12 | - | // SecurityHeadersWithOptions returns a middleware that adds standard security headers. | 11 | + | w.Header().Set("X-XSS-Protection", "1; mode=block") |
| 13 | - | // When forceHSTS is true, the HSTS header is always set regardless of TLS state. | 12 | + | w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") |
| 14 | - | // This is needed when Gordon runs behind a TLS-terminating proxy (e.g. Cloudflare) | 13 | + | w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") |
| 15 | - | // where r.TLS is nil but the client connection is always HTTPS. | |||
| 16 | - | func SecurityHeadersWithOptions(forceHSTS bool) func(http.Handler) http.Handler { | |||
| 17 | - | return func(next http.Handler) http.Handler { | |||
| 18 | - | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 19 | - | w.Header().Set("X-Content-Type-Options", "nosniff") | |||
| 20 | - | w.Header().Set("X-Frame-Options", "DENY") | |||
| 21 | - | w.Header().Set("X-XSS-Protection", "1; mode=block") | |||
| 22 | - | w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") | |||
| 23 | - | w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") | |||
| 24 | - | ||||
| 25 | - | // Only set when TLS is active or force_hsts is configured, | |||
| 26 | - | // since HSTS over plain HTTP would lock out clients. | |||
| 27 | - | if r.TLS != nil || forceHSTS { | |||
| 28 | - | w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") | |||
| 29 | - | } | |||
| 30 | 14 | ||||
| 31 | - | // CSP is intentionally NOT set here — Gordon proxies arbitrary web apps, | 15 | + | if r.TLS != nil { |
| 32 | - | // so a blanket policy would break proxied sites. CSP is set only on | 16 | + | w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") |
| 33 | - | // Gordon-generated error responses (see proxy error handlers). | 17 | + | } |
| 34 | 18 | ||||
| 35 | - | next.ServeHTTP(w, r) | 19 | + | next.ServeHTTP(w, r) |
| 36 | - | }) | 20 | + | }) |
| 37 | - | } | |||
| 38 | } | 21 | } | ||
M internal/adapters/in/http/middleware/security_test.go +0 -16
| @@ -51,22 +51,6 @@ func TestSecurityHeaders_HSTS_WithTLS(t *testing.T) { | |||
| 51 | 51 | assert.Equal(t, "max-age=31536000; includeSubDomains", rec.Header().Get("Strict-Transport-Security")) | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | - | func TestSecurityHeaders_HSTS_ForceWithoutTLS(t *testing.T) { | |
| 55 | - | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 56 | - | w.WriteHeader(http.StatusOK) | |
| 57 | - | }) | |
| 58 | - | ||
| 59 | - | wrapped := SecurityHeadersWithOptions(true)(handler) | |
| 60 | - | ||
| 61 | - | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 62 | - | // r.TLS is nil — no TLS on the connection, but forceHSTS is true | |
| 63 | - | rec := httptest.NewRecorder() | |
| 64 | - | wrapped.ServeHTTP(rec, req) | |
| 65 | - | ||
| 66 | - | assert.Equal(t, "max-age=31536000; includeSubDomains", rec.Header().Get("Strict-Transport-Security"), | |
| 67 | - | "HSTS should be set when forceHSTS is true even without TLS") | |
| 68 | - | } | |
| 69 | - | ||
| 70 | 54 | func TestSecurityHeaders_HSTS_WithForwardedProto_NotTrusted(t *testing.T) { | |
| 71 | 55 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 72 | 56 | w.WriteHeader(http.StatusOK) | |
| @@ -51,22 +51,6 @@ func TestSecurityHeaders_HSTS_WithTLS(t *testing.T) { | |||||
| 51 | assert.Equal(t, "max-age=31536000; includeSubDomains", rec.Header().Get("Strict-Transport-Security")) | 51 | assert.Equal(t, "max-age=31536000; includeSubDomains", rec.Header().Get("Strict-Transport-Security")) | ||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | - | func TestSecurityHeaders_HSTS_ForceWithoutTLS(t *testing.T) { | |||
| 55 | - | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 56 | - | w.WriteHeader(http.StatusOK) | |||
| 57 | - | }) | |||
| 58 | - | ||||
| 59 | - | wrapped := SecurityHeadersWithOptions(true)(handler) | |||
| 60 | - | ||||
| 61 | - | req := httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 62 | - | // r.TLS is nil — no TLS on the connection, but forceHSTS is true | |||
| 63 | - | rec := httptest.NewRecorder() | |||
| 64 | - | wrapped.ServeHTTP(rec, req) | |||
| 65 | - | ||||
| 66 | - | assert.Equal(t, "max-age=31536000; includeSubDomains", rec.Header().Get("Strict-Transport-Security"), | |||
| 67 | - | "HSTS should be set when forceHSTS is true even without TLS") | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | func TestSecurityHeaders_HSTS_WithForwardedProto_NotTrusted(t *testing.T) { | 54 | func TestSecurityHeaders_HSTS_WithForwardedProto_NotTrusted(t *testing.T) { | ||
| 71 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | 55 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | ||
| 72 | w.WriteHeader(http.StatusOK) | 56 | w.WriteHeader(http.StatusOK) | ||
A internal/adapters/in/http/onboarding/handler.go +109 -0
| @@ -0,0 +1,109 @@ | |||
| 1 | + | package onboarding | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "html" | |
| 6 | + | "net" | |
| 7 | + | "net/http" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Handler serves CA onboarding endpoints on the TLS port for direct/Tailnet clients. | |
| 11 | + | type Handler struct { | |
| 12 | + | rootPEM []byte | |
| 13 | + | mobileconfig []byte | |
| 14 | + | tlsPort int | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // NewHandler creates an onboarding handler. | |
| 18 | + | // tlsPort is the HTTPS port used to build redirect URLs on the onboarding page. | |
| 19 | + | func NewHandler(rootPEM, mobileconfig []byte, tlsPort int) *Handler { | |
| 20 | + | return &Handler{ | |
| 21 | + | rootPEM: rootPEM, | |
| 22 | + | mobileconfig: mobileconfig, | |
| 23 | + | tlsPort: tlsPort, | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // ServeCACert serves the root CA certificate as PEM. | |
| 28 | + | func (h *Handler) ServeCACert(w http.ResponseWriter, _ *http.Request) { | |
| 29 | + | w.Header().Set("Content-Type", "application/x-x509-ca-cert") | |
| 30 | + | w.Header().Set("Content-Disposition", "attachment; filename=gordon-ca.crt") | |
| 31 | + | _, _ = w.Write(h.rootPEM) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // ServeMobileconfig serves an iOS configuration profile with the root CA. | |
| 35 | + | func (h *Handler) ServeMobileconfig(w http.ResponseWriter, _ *http.Request) { | |
| 36 | + | w.Header().Set("Content-Type", "application/x-apple-aspen-config") | |
| 37 | + | w.Header().Set("Content-Disposition", "attachment; filename=gordon-ca.mobileconfig") | |
| 38 | + | _, _ = w.Write(h.mobileconfig) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // normalizeHTTPSURL builds an https:// URL from r.Host and the configured TLS port. | |
| 42 | + | // It strips any existing port, applies the configured TLS port (omitting :443), and | |
| 43 | + | // HTML-escapes the result to prevent XSS via a crafted Host header. | |
| 44 | + | func (h *Handler) normalizeHTTPSURL(r *http.Request) string { | |
| 45 | + | hostname := r.Host | |
| 46 | + | if host, _, err := net.SplitHostPort(hostname); err == nil { | |
| 47 | + | hostname = host | |
| 48 | + | } | |
| 49 | + | if h.tlsPort != 0 && h.tlsPort != 443 { | |
| 50 | + | hostname = fmt.Sprintf("%s:%d", hostname, h.tlsPort) | |
| 51 | + | } | |
| 52 | + | return html.EscapeString(fmt.Sprintf("https://%s/", hostname)) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // ServeOnboardingPage serves the CA trust onboarding HTML page. | |
| 56 | + | // Static onboarding page -- kept inline for simplicity. | |
| 57 | + | func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) { | |
| 58 | + | w.Header().Set("Content-Type", "text/html; charset=utf-8") | |
| 59 | + | ||
| 60 | + | redirectURL := h.normalizeHTTPSURL(r) | |
| 61 | + | ||
| 62 | + | // Cookie is set client-side on click (not on page load) to mark CA installation complete. | |
| 63 | + | // HttpOnly cannot be applied to JS-set cookies -- acceptable since this is a UI hint, not a session token. | |
| 64 | + | _, _ = fmt.Fprintf(w, `<!DOCTYPE html> | |
| 65 | + | <html lang="en"> | |
| 66 | + | <head> | |
| 67 | + | <meta charset="utf-8"> | |
| 68 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 69 | + | <title>Gordon — Install CA Certificate</title> | |
| 70 | + | <style> | |
| 71 | + | body { font-family: -apple-system, system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; color: #333; } | |
| 72 | + | h1 { font-size: 1.5em; } | |
| 73 | + | .platform { background: #f5f5f5; border-radius: 8px; padding: 16px; margin: 12px 0; } | |
| 74 | + | .platform h2 { font-size: 1.1em; margin: 0 0 8px; } | |
| 75 | + | a.btn { display: inline-block; background: #2563eb; color: white; padding: 10px 20px; border-radius: 6px; text-decoration: none; margin: 4px 0; } | |
| 76 | + | a.btn:hover { background: #1d4ed8; } | |
| 77 | + | .skip { margin-top: 24px; font-size: 0.9em; } | |
| 78 | + | .skip a { color: #666; } | |
| 79 | + | code { background: #e5e7eb; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; } | |
| 80 | + | </style> | |
| 81 | + | </head> | |
| 82 | + | <body> | |
| 83 | + | <h1>Gordon — Secure Access Setup</h1> | |
| 84 | + | <p>To access this service over HTTPS, install Gordon's CA certificate on your device. This is a one-time step.</p> | |
| 85 | + | ||
| 86 | + | <div class="platform" id="ios"> | |
| 87 | + | <h2>iOS / iPadOS</h2> | |
| 88 | + | <p><a class="btn" href="/ca.mobileconfig">Install Profile</a></p> | |
| 89 | + | <p>After downloading, go to <strong>Settings → General → VPN & Device Management</strong> to complete installation, then <strong>Settings → General → About → Certificate Trust Settings</strong> to enable full trust.</p> | |
| 90 | + | </div> | |
| 91 | + | ||
| 92 | + | <div class="platform" id="android"> | |
| 93 | + | <h2>Android</h2> | |
| 94 | + | <p><a class="btn" href="/ca.crt">Download Certificate</a></p> | |
| 95 | + | <p>After downloading, go to <strong>Settings → Security → Install a certificate → CA certificate</strong>.</p> | |
| 96 | + | </div> | |
| 97 | + | ||
| 98 | + | <div class="platform" id="desktop"> | |
| 99 | + | <h2>macOS / Linux / Windows</h2> | |
| 100 | + | <p><a class="btn" href="/ca.crt">Download Certificate</a></p> | |
| 101 | + | <p>Or run on the Gordon host: <code>sudo gordon ca install</code></p> | |
| 102 | + | </div> | |
| 103 | + | ||
| 104 | + | <div class="skip"> | |
| 105 | + | <p>Already installed? <a href="%s" onclick="document.cookie='gordon-ca-installed=1;path=/;max-age=315360000;SameSite=Lax;Secure'">Go to site →</a></p> | |
| 106 | + | </div> | |
| 107 | + | </body> | |
| 108 | + | </html>`, redirectURL) | |
| 109 | + | } | |
| @@ -0,0 +1,109 @@ | |||||
| 1 | + | package onboarding | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "html" | |||
| 6 | + | "net" | |||
| 7 | + | "net/http" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // Handler serves CA onboarding endpoints on the TLS port for direct/Tailnet clients. | |||
| 11 | + | type Handler struct { | |||
| 12 | + | rootPEM []byte | |||
| 13 | + | mobileconfig []byte | |||
| 14 | + | tlsPort int | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // NewHandler creates an onboarding handler. | |||
| 18 | + | // tlsPort is the HTTPS port used to build redirect URLs on the onboarding page. | |||
| 19 | + | func NewHandler(rootPEM, mobileconfig []byte, tlsPort int) *Handler { | |||
| 20 | + | return &Handler{ | |||
| 21 | + | rootPEM: rootPEM, | |||
| 22 | + | mobileconfig: mobileconfig, | |||
| 23 | + | tlsPort: tlsPort, | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // ServeCACert serves the root CA certificate as PEM. | |||
| 28 | + | func (h *Handler) ServeCACert(w http.ResponseWriter, _ *http.Request) { | |||
| 29 | + | w.Header().Set("Content-Type", "application/x-x509-ca-cert") | |||
| 30 | + | w.Header().Set("Content-Disposition", "attachment; filename=gordon-ca.crt") | |||
| 31 | + | _, _ = w.Write(h.rootPEM) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // ServeMobileconfig serves an iOS configuration profile with the root CA. | |||
| 35 | + | func (h *Handler) ServeMobileconfig(w http.ResponseWriter, _ *http.Request) { | |||
| 36 | + | w.Header().Set("Content-Type", "application/x-apple-aspen-config") | |||
| 37 | + | w.Header().Set("Content-Disposition", "attachment; filename=gordon-ca.mobileconfig") | |||
| 38 | + | _, _ = w.Write(h.mobileconfig) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // normalizeHTTPSURL builds an https:// URL from r.Host and the configured TLS port. | |||
| 42 | + | // It strips any existing port, applies the configured TLS port (omitting :443), and | |||
| 43 | + | // HTML-escapes the result to prevent XSS via a crafted Host header. | |||
| 44 | + | func (h *Handler) normalizeHTTPSURL(r *http.Request) string { | |||
| 45 | + | hostname := r.Host | |||
| 46 | + | if host, _, err := net.SplitHostPort(hostname); err == nil { | |||
| 47 | + | hostname = host | |||
| 48 | + | } | |||
| 49 | + | if h.tlsPort != 0 && h.tlsPort != 443 { | |||
| 50 | + | hostname = fmt.Sprintf("%s:%d", hostname, h.tlsPort) | |||
| 51 | + | } | |||
| 52 | + | return html.EscapeString(fmt.Sprintf("https://%s/", hostname)) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // ServeOnboardingPage serves the CA trust onboarding HTML page. | |||
| 56 | + | // Static onboarding page -- kept inline for simplicity. | |||
| 57 | + | func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) { | |||
| 58 | + | w.Header().Set("Content-Type", "text/html; charset=utf-8") | |||
| 59 | + | ||||
| 60 | + | redirectURL := h.normalizeHTTPSURL(r) | |||
| 61 | + | ||||
| 62 | + | // Cookie is set client-side on click (not on page load) to mark CA installation complete. | |||
| 63 | + | // HttpOnly cannot be applied to JS-set cookies -- acceptable since this is a UI hint, not a session token. | |||
| 64 | + | _, _ = fmt.Fprintf(w, `<!DOCTYPE html> | |||
| 65 | + | <html lang="en"> | |||
| 66 | + | <head> | |||
| 67 | + | <meta charset="utf-8"> | |||
| 68 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| 69 | + | <title>Gordon — Install CA Certificate</title> | |||
| 70 | + | <style> | |||
| 71 | + | body { font-family: -apple-system, system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; color: #333; } | |||
| 72 | + | h1 { font-size: 1.5em; } | |||
| 73 | + | .platform { background: #f5f5f5; border-radius: 8px; padding: 16px; margin: 12px 0; } | |||
| 74 | + | .platform h2 { font-size: 1.1em; margin: 0 0 8px; } | |||
| 75 | + | a.btn { display: inline-block; background: #2563eb; color: white; padding: 10px 20px; border-radius: 6px; text-decoration: none; margin: 4px 0; } | |||
| 76 | + | a.btn:hover { background: #1d4ed8; } | |||
| 77 | + | .skip { margin-top: 24px; font-size: 0.9em; } | |||
| 78 | + | .skip a { color: #666; } | |||
| 79 | + | code { background: #e5e7eb; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; } | |||
| 80 | + | </style> | |||
| 81 | + | </head> | |||
| 82 | + | <body> | |||
| 83 | + | <h1>Gordon — Secure Access Setup</h1> | |||
| 84 | + | <p>To access this service over HTTPS, install Gordon's CA certificate on your device. This is a one-time step.</p> | |||
| 85 | + | ||||
| 86 | + | <div class="platform" id="ios"> | |||
| 87 | + | <h2>iOS / iPadOS</h2> | |||
| 88 | + | <p><a class="btn" href="/ca.mobileconfig">Install Profile</a></p> | |||
| 89 | + | <p>After downloading, go to <strong>Settings → General → VPN & Device Management</strong> to complete installation, then <strong>Settings → General → About → Certificate Trust Settings</strong> to enable full trust.</p> | |||
| 90 | + | </div> | |||
| 91 | + | ||||
| 92 | + | <div class="platform" id="android"> | |||
| 93 | + | <h2>Android</h2> | |||
| 94 | + | <p><a class="btn" href="/ca.crt">Download Certificate</a></p> | |||
| 95 | + | <p>After downloading, go to <strong>Settings → Security → Install a certificate → CA certificate</strong>.</p> | |||
| 96 | + | </div> | |||
| 97 | + | ||||
| 98 | + | <div class="platform" id="desktop"> | |||
| 99 | + | <h2>macOS / Linux / Windows</h2> | |||
| 100 | + | <p><a class="btn" href="/ca.crt">Download Certificate</a></p> | |||
| 101 | + | <p>Or run on the Gordon host: <code>sudo gordon ca install</code></p> | |||
| 102 | + | </div> | |||
| 103 | + | ||||
| 104 | + | <div class="skip"> | |||
| 105 | + | <p>Already installed? <a href="%s" onclick="document.cookie='gordon-ca-installed=1;path=/;max-age=315360000;SameSite=Lax;Secure'">Go to site →</a></p> | |||
| 106 | + | </div> | |||
| 107 | + | </body> | |||
| 108 | + | </html>`, redirectURL) | |||
| 109 | + | } | |||
A internal/adapters/in/http/onboarding/handler_test.go +76 -0
| @@ -0,0 +1,76 @@ | |||
| 1 | + | package onboarding_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "io" | |
| 5 | + | "net/http" | |
| 6 | + | "net/http/httptest" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/gordon/internal/adapters/in/http/onboarding" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func newTestServer(t *testing.T, tlsPort int) (*httptest.Server, []byte, []byte) { | |
| 16 | + | t.Helper() | |
| 17 | + | rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n") | |
| 18 | + | mobileconfig := []byte("<plist>com.apple.security.root</plist>") | |
| 19 | + | ||
| 20 | + | h := onboarding.NewHandler(rootPEM, mobileconfig, tlsPort) | |
| 21 | + | mux := http.NewServeMux() | |
| 22 | + | mux.HandleFunc("GET /ca", h.ServeOnboardingPage) | |
| 23 | + | mux.HandleFunc("GET /ca.crt", h.ServeCACert) | |
| 24 | + | mux.HandleFunc("GET /ca.mobileconfig", h.ServeMobileconfig) | |
| 25 | + | ||
| 26 | + | srv := httptest.NewServer(mux) | |
| 27 | + | t.Cleanup(srv.Close) | |
| 28 | + | return srv, rootPEM, mobileconfig | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestHandler_CACert(t *testing.T) { | |
| 32 | + | srv, rootPEM, _ := newTestServer(t, 443) | |
| 33 | + | ||
| 34 | + | resp, err := http.Get(srv.URL + "/ca.crt") | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | defer resp.Body.Close() | |
| 37 | + | ||
| 38 | + | body, err := io.ReadAll(resp.Body) | |
| 39 | + | require.NoError(t, err) | |
| 40 | + | ||
| 41 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 42 | + | assert.Equal(t, "application/x-x509-ca-cert", resp.Header.Get("Content-Type")) | |
| 43 | + | assert.Equal(t, rootPEM, body) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestHandler_Mobileconfig(t *testing.T) { | |
| 47 | + | srv, _, _ := newTestServer(t, 443) | |
| 48 | + | ||
| 49 | + | resp, err := http.Get(srv.URL + "/ca.mobileconfig") | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | defer resp.Body.Close() | |
| 52 | + | ||
| 53 | + | body, err := io.ReadAll(resp.Body) | |
| 54 | + | require.NoError(t, err) | |
| 55 | + | ||
| 56 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 57 | + | assert.Equal(t, "application/x-apple-aspen-config", resp.Header.Get("Content-Type")) | |
| 58 | + | assert.Contains(t, string(body), "com.apple.security.root") | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestHandler_OnboardingPage(t *testing.T) { | |
| 62 | + | srv, _, _ := newTestServer(t, 443) | |
| 63 | + | ||
| 64 | + | resp, err := http.Get(srv.URL + "/ca") | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | defer resp.Body.Close() | |
| 67 | + | ||
| 68 | + | body, err := io.ReadAll(resp.Body) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | ||
| 71 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 72 | + | assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) | |
| 73 | + | assert.Contains(t, string(body), "Gordon") | |
| 74 | + | assert.Contains(t, string(body), "/ca.crt") | |
| 75 | + | assert.Contains(t, string(body), "/ca.mobileconfig") | |
| 76 | + | } | |
| @@ -0,0 +1,76 @@ | |||||
| 1 | + | package onboarding_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "io" | |||
| 5 | + | "net/http" | |||
| 6 | + | "net/http/httptest" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/adapters/in/http/onboarding" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func newTestServer(t *testing.T, tlsPort int) (*httptest.Server, []byte, []byte) { | |||
| 16 | + | t.Helper() | |||
| 17 | + | rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n") | |||
| 18 | + | mobileconfig := []byte("<plist>com.apple.security.root</plist>") | |||
| 19 | + | ||||
| 20 | + | h := onboarding.NewHandler(rootPEM, mobileconfig, tlsPort) | |||
| 21 | + | mux := http.NewServeMux() | |||
| 22 | + | mux.HandleFunc("GET /ca", h.ServeOnboardingPage) | |||
| 23 | + | mux.HandleFunc("GET /ca.crt", h.ServeCACert) | |||
| 24 | + | mux.HandleFunc("GET /ca.mobileconfig", h.ServeMobileconfig) | |||
| 25 | + | ||||
| 26 | + | srv := httptest.NewServer(mux) | |||
| 27 | + | t.Cleanup(srv.Close) | |||
| 28 | + | return srv, rootPEM, mobileconfig | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestHandler_CACert(t *testing.T) { | |||
| 32 | + | srv, rootPEM, _ := newTestServer(t, 443) | |||
| 33 | + | ||||
| 34 | + | resp, err := http.Get(srv.URL + "/ca.crt") | |||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | defer resp.Body.Close() | |||
| 37 | + | ||||
| 38 | + | body, err := io.ReadAll(resp.Body) | |||
| 39 | + | require.NoError(t, err) | |||
| 40 | + | ||||
| 41 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 42 | + | assert.Equal(t, "application/x-x509-ca-cert", resp.Header.Get("Content-Type")) | |||
| 43 | + | assert.Equal(t, rootPEM, body) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestHandler_Mobileconfig(t *testing.T) { | |||
| 47 | + | srv, _, _ := newTestServer(t, 443) | |||
| 48 | + | ||||
| 49 | + | resp, err := http.Get(srv.URL + "/ca.mobileconfig") | |||
| 50 | + | require.NoError(t, err) | |||
| 51 | + | defer resp.Body.Close() | |||
| 52 | + | ||||
| 53 | + | body, err := io.ReadAll(resp.Body) | |||
| 54 | + | require.NoError(t, err) | |||
| 55 | + | ||||
| 56 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 57 | + | assert.Equal(t, "application/x-apple-aspen-config", resp.Header.Get("Content-Type")) | |||
| 58 | + | assert.Contains(t, string(body), "com.apple.security.root") | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestHandler_OnboardingPage(t *testing.T) { | |||
| 62 | + | srv, _, _ := newTestServer(t, 443) | |||
| 63 | + | ||||
| 64 | + | resp, err := http.Get(srv.URL + "/ca") | |||
| 65 | + | require.NoError(t, err) | |||
| 66 | + | defer resp.Body.Close() | |||
| 67 | + | ||||
| 68 | + | body, err := io.ReadAll(resp.Body) | |||
| 69 | + | require.NoError(t, err) | |||
| 70 | + | ||||
| 71 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 72 | + | assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) | |||
| 73 | + | assert.Contains(t, string(body), "Gordon") | |||
| 74 | + | assert.Contains(t, string(body), "/ca.crt") | |||
| 75 | + | assert.Contains(t, string(body), "/ca.mobileconfig") | |||
| 76 | + | } | |||
A internal/adapters/out/pki/ca.go +546 -0
| @@ -0,0 +1,546 @@ | |||
| 1 | + | // Package pki implements the certificate authority adapter for internal PKI operations. | |
| 2 | + | package pki | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "crypto" | |
| 6 | + | "crypto/ecdsa" | |
| 7 | + | "crypto/elliptic" | |
| 8 | + | "crypto/rand" | |
| 9 | + | "crypto/sha256" | |
| 10 | + | "crypto/tls" | |
| 11 | + | "crypto/x509" | |
| 12 | + | "crypto/x509/pkix" | |
| 13 | + | "encoding/pem" | |
| 14 | + | "errors" | |
| 15 | + | "fmt" | |
| 16 | + | "math/big" | |
| 17 | + | "net" | |
| 18 | + | "os" | |
| 19 | + | "path/filepath" | |
| 20 | + | "strings" | |
| 21 | + | "sync" | |
| 22 | + | "time" | |
| 23 | + | ||
| 24 | + | "github.com/bnema/zerowrap" | |
| 25 | + | ||
| 26 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 27 | + | "github.com/bnema/gordon/pkg/validation" | |
| 28 | + | ) | |
| 29 | + | ||
| 30 | + | // Compile-time check: *CA satisfies the CertificateAuthority boundary. | |
| 31 | + | var _ out.CertificateAuthority = (*CA)(nil) | |
| 32 | + | ||
| 33 | + | // Certificate lifetimes balance security and operational convenience: | |
| 34 | + | // - Root: long-lived to minimise key ceremony frequency; stored offline after bootstrap. | |
| 35 | + | // - Intermediate: short-lived to limit blast radius if compromised; auto-renewed. | |
| 36 | + | // - Leaf: very short-lived to reduce exposure; issued on-demand and cached in memory. | |
| 37 | + | const ( | |
| 38 | + | rootLifetime = 10 * 365 * 24 * time.Hour // ~10 years | |
| 39 | + | intermediateLifetime = 7 * 24 * time.Hour // 7 days | |
| 40 | + | leafLifetime = 12 * time.Hour // 12 hours | |
| 41 | + | ||
| 42 | + | pkiDir = "pki" | |
| 43 | + | ) | |
| 44 | + | ||
| 45 | + | // CA implements the CertificateAuthority boundary interface. | |
| 46 | + | type CA struct { | |
| 47 | + | dataDir string | |
| 48 | + | log zerowrap.Logger | |
| 49 | + | ||
| 50 | + | rootCert *x509.Certificate | |
| 51 | + | rootKey crypto.Signer | |
| 52 | + | rootPEM []byte | |
| 53 | + | ||
| 54 | + | // mu guards interCert/interKey for concurrent reads during cert issuance. | |
| 55 | + | // renewMu serialises intermediate CA renewal. | |
| 56 | + | // Lock ordering: renewMu → mu (never acquire mu before renewMu). | |
| 57 | + | mu sync.RWMutex | |
| 58 | + | interCert *x509.Certificate | |
| 59 | + | interKey crypto.Signer | |
| 60 | + | ||
| 61 | + | renewMu sync.Mutex | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // NewCA loads or generates the root and intermediate CA certificates. | |
| 65 | + | func NewCA(dataDir string, log zerowrap.Logger) (*CA, error) { | |
| 66 | + | ca := &CA{ | |
| 67 | + | dataDir: dataDir, | |
| 68 | + | log: log, | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | dir := filepath.Join(dataDir, pkiDir) | |
| 72 | + | if err := os.MkdirAll(dir, 0700); err != nil { | |
| 73 | + | return nil, fmt.Errorf("create pki dir: %w", err) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | if err := ca.loadOrGenerateRoot(); err != nil { | |
| 77 | + | return nil, fmt.Errorf("root CA: %w", err) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | if err := ca.loadOrGenerateIntermediate(); err != nil { | |
| 81 | + | return nil, fmt.Errorf("intermediate CA: %w", err) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | return ca, nil | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // RootCertificate returns the root CA certificate in PEM format. | |
| 88 | + | func (ca *CA) RootCertificate() []byte { return ca.rootPEM } | |
| 89 | + | ||
| 90 | + | // RootCertificateDER returns the root CA certificate in DER format. | |
| 91 | + | func (ca *CA) RootCertificateDER() []byte { return ca.rootCert.Raw } | |
| 92 | + | ||
| 93 | + | // RootFingerprint returns the SHA-256 fingerprint of the root CA cert | |
| 94 | + | // formatted as colon-separated hex. | |
| 95 | + | func (ca *CA) RootFingerprint() string { | |
| 96 | + | sum := sha256.Sum256(ca.rootCert.Raw) | |
| 97 | + | parts := make([]string, 0, sha256.Size) | |
| 98 | + | for _, b := range sum { | |
| 99 | + | parts = append(parts, fmt.Sprintf("%02X", b)) | |
| 100 | + | } | |
| 101 | + | return strings.Join(parts, ":") | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // RootCommonName returns the CN of the root CA certificate. | |
| 105 | + | func (ca *CA) RootCommonName() string { | |
| 106 | + | return ca.rootCert.Subject.CommonName | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // IntermediateExpiresAt returns the intermediate CA certificate expiry. | |
| 110 | + | func (ca *CA) IntermediateExpiresAt() time.Time { | |
| 111 | + | ca.mu.RLock() | |
| 112 | + | defer ca.mu.RUnlock() | |
| 113 | + | return ca.interCert.NotAfter | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // RenewIntermediate regenerates the intermediate CA certificate, | |
| 117 | + | // signed by the root CA. Existing leaf certs remain valid. | |
| 118 | + | func (ca *CA) RenewIntermediate() error { | |
| 119 | + | ca.renewMu.Lock() | |
| 120 | + | defer ca.renewMu.Unlock() | |
| 121 | + | ||
| 122 | + | cert, key, err := ca.generateIntermediate() | |
| 123 | + | if err != nil { | |
| 124 | + | return err | |
| 125 | + | } | |
| 126 | + | if err := ca.storeIntermediate(cert, key); err != nil { | |
| 127 | + | return err | |
| 128 | + | } | |
| 129 | + | ca.mu.Lock() | |
| 130 | + | ca.interCert = cert | |
| 131 | + | ca.interKey = key | |
| 132 | + | ca.mu.Unlock() | |
| 133 | + | ca.log.Info().Time("expires", cert.NotAfter).Msg("intermediate CA renewed") | |
| 134 | + | return nil | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func (ca *CA) loadOrGenerateRoot() error { | |
| 138 | + | certPath := filepath.Join(ca.dataDir, pkiDir, "root.crt") | |
| 139 | + | keyPath := filepath.Join(ca.dataDir, pkiDir, "root.key") | |
| 140 | + | ||
| 141 | + | certPEM, certErr := os.ReadFile(certPath) | |
| 142 | + | keyPEM, keyErr := os.ReadFile(keyPath) | |
| 143 | + | ||
| 144 | + | certMissing := certErr != nil && errors.Is(certErr, os.ErrNotExist) | |
| 145 | + | keyMissing := keyErr != nil && errors.Is(keyErr, os.ErrNotExist) | |
| 146 | + | ||
| 147 | + | switch { | |
| 148 | + | case certErr == nil && keyErr == nil: | |
| 149 | + | cert, key, err := parseCertAndKey(certPEM, keyPEM) | |
| 150 | + | if err != nil { | |
| 151 | + | return fmt.Errorf("corrupt root CA files: %w", err) | |
| 152 | + | } | |
| 153 | + | if err := verifyKeyPair(cert, key); err != nil { | |
| 154 | + | return fmt.Errorf("root CA cert/key mismatch: %w", err) | |
| 155 | + | } | |
| 156 | + | ca.rootCert = cert | |
| 157 | + | ca.rootKey = key | |
| 158 | + | ca.rootPEM = certPEM | |
| 159 | + | ca.log.Info().Str("cn", cert.Subject.CommonName).Msg("loaded existing root CA") | |
| 160 | + | return nil | |
| 161 | + | ||
| 162 | + | case certMissing && keyMissing: | |
| 163 | + | return ca.bootstrapRoot(certPath, keyPath) | |
| 164 | + | ||
| 165 | + | case certMissing || keyMissing: | |
| 166 | + | return fmt.Errorf("incomplete root CA: cert missing=%v, key missing=%v", certMissing, keyMissing) | |
| 167 | + | ||
| 168 | + | default: | |
| 169 | + | if certErr != nil { | |
| 170 | + | return fmt.Errorf("read root cert: %w", certErr) | |
| 171 | + | } | |
| 172 | + | return fmt.Errorf("read root key: %w", keyErr) | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func (ca *CA) bootstrapRoot(certPath, keyPath string) error { | |
| 177 | + | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| 178 | + | if err != nil { | |
| 179 | + | return fmt.Errorf("generate root key: %w", err) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | serial, err := randomSerial() | |
| 183 | + | if err != nil { | |
| 184 | + | return err | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | now := time.Now() | |
| 188 | + | template := &x509.Certificate{ | |
| 189 | + | SerialNumber: serial, | |
| 190 | + | Subject: pkix.Name{ | |
| 191 | + | CommonName: fmt.Sprintf("Gordon Internal CA - %d ECC Root", now.Year()), | |
| 192 | + | Organization: []string{"Gordon"}, | |
| 193 | + | }, | |
| 194 | + | NotBefore: now.Add(-5 * time.Minute), | |
| 195 | + | NotAfter: now.Add(rootLifetime), | |
| 196 | + | KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, | |
| 197 | + | BasicConstraintsValid: true, | |
| 198 | + | IsCA: true, | |
| 199 | + | MaxPathLen: 1, | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | certDER, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key) | |
| 203 | + | if err != nil { | |
| 204 | + | return fmt.Errorf("create root cert: %w", err) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | cert, err := x509.ParseCertificate(certDER) | |
| 208 | + | if err != nil { | |
| 209 | + | return fmt.Errorf("parse root cert: %w", err) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) | |
| 213 | + | keyDER, err := x509.MarshalECPrivateKey(key) | |
| 214 | + | if err != nil { | |
| 215 | + | return fmt.Errorf("marshal root key: %w", err) | |
| 216 | + | } | |
| 217 | + | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) | |
| 218 | + | ||
| 219 | + | // Write key first — orphaned key is harmless; missing cert triggers regeneration. | |
| 220 | + | if err := writeSecure(keyPath, keyPEM, 0600); err != nil { | |
| 221 | + | return err | |
| 222 | + | } | |
| 223 | + | if err := writeSecure(certPath, certPEM, 0644); err != nil { | |
| 224 | + | return err | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | ca.rootCert = cert | |
| 228 | + | ca.rootKey = key | |
| 229 | + | ca.rootPEM = certPEM | |
| 230 | + | ca.log.Info().Str("cn", cert.Subject.CommonName).Msg("generated new root CA") | |
| 231 | + | return nil | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // validateLoadedIntermediate parses, verifies, and checks the expiry of a loaded | |
| 235 | + | // intermediate CA cert/key pair. Returns (cert, key, true) on success, or | |
| 236 | + | // (nil, nil, false) if the pair should be regenerated (parse error, key mismatch, | |
| 237 | + | // wrong signer, or expired). Logging is done inside this function. | |
| 238 | + | func (ca *CA) validateLoadedIntermediate(certPEM, keyPEM []byte) (*x509.Certificate, crypto.Signer, bool) { | |
| 239 | + | cert, key, err := parseCertAndKey(certPEM, keyPEM) | |
| 240 | + | if err != nil { | |
| 241 | + | ca.log.Warn().Err(err).Msg("failed to parse intermediate CA, regenerating") | |
| 242 | + | return nil, nil, false | |
| 243 | + | } | |
| 244 | + | if err := verifyKeyPair(cert, key); err != nil { | |
| 245 | + | ca.log.Warn().Err(err).Msg("intermediate CA cert/key mismatch, regenerating") | |
| 246 | + | return nil, nil, false | |
| 247 | + | } | |
| 248 | + | if err := cert.CheckSignatureFrom(ca.rootCert); err != nil { | |
| 249 | + | ca.log.Warn().Err(err).Msg("intermediate CA not signed by current root, regenerating") | |
| 250 | + | return nil, nil, false | |
| 251 | + | } | |
| 252 | + | if !time.Now().Before(cert.NotAfter) { | |
| 253 | + | ca.log.Warn().Msg("intermediate CA expired, regenerating") | |
| 254 | + | return nil, nil, false | |
| 255 | + | } | |
| 256 | + | return cert, key, true | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func (ca *CA) loadOrGenerateIntermediate() error { | |
| 260 | + | certPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.crt") | |
| 261 | + | keyPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.key") | |
| 262 | + | ||
| 263 | + | certPEM, certErr := os.ReadFile(certPath) | |
| 264 | + | keyPEM, keyErr := os.ReadFile(keyPath) | |
| 265 | + | ||
| 266 | + | certMissing := certErr != nil && errors.Is(certErr, os.ErrNotExist) | |
| 267 | + | keyMissing := keyErr != nil && errors.Is(keyErr, os.ErrNotExist) | |
| 268 | + | ||
| 269 | + | switch { | |
| 270 | + | case certErr == nil && keyErr == nil: | |
| 271 | + | if cert, key, ok := ca.validateLoadedIntermediate(certPEM, keyPEM); ok { | |
| 272 | + | ca.interCert = cert | |
| 273 | + | ca.interKey = key | |
| 274 | + | ca.log.Info(). | |
| 275 | + | Str("cn", cert.Subject.CommonName). | |
| 276 | + | Time("expires", cert.NotAfter). | |
| 277 | + | Msg("loaded existing intermediate CA") | |
| 278 | + | return nil | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | case certMissing && keyMissing: | |
| 282 | + | // Both files absent — generate fresh intermediate below. | |
| 283 | + | ||
| 284 | + | case certMissing || keyMissing: | |
| 285 | + | ca.log.Warn(). | |
| 286 | + | Bool("cert_missing", certMissing). | |
| 287 | + | Bool("key_missing", keyMissing). | |
| 288 | + | Msg("intermediate CA files incomplete, regenerating") | |
| 289 | + | ||
| 290 | + | default: | |
| 291 | + | // At least one ReadFile returned a non-NotExist error (e.g. EPERM, EIO). | |
| 292 | + | if certErr != nil { | |
| 293 | + | return fmt.Errorf("read intermediate cert: %w", certErr) | |
| 294 | + | } | |
| 295 | + | return fmt.Errorf("read intermediate key: %w", keyErr) | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | cert, key, err := ca.generateIntermediate() | |
| 299 | + | if err != nil { | |
| 300 | + | return err | |
| 301 | + | } | |
| 302 | + | if err := ca.storeIntermediate(cert, key); err != nil { | |
| 303 | + | return err | |
| 304 | + | } | |
| 305 | + | ca.interCert = cert | |
| 306 | + | ca.interKey = key | |
| 307 | + | return nil | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | func (ca *CA) generateIntermediate() (*x509.Certificate, *ecdsa.PrivateKey, error) { | |
| 311 | + | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| 312 | + | if err != nil { | |
| 313 | + | return nil, nil, fmt.Errorf("generate intermediate key: %w", err) | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | serial, err := randomSerial() | |
| 317 | + | if err != nil { | |
| 318 | + | return nil, nil, err | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | now := time.Now() | |
| 322 | + | template := &x509.Certificate{ | |
| 323 | + | SerialNumber: serial, | |
| 324 | + | Subject: pkix.Name{ | |
| 325 | + | CommonName: "Gordon Internal CA - ECC Intermediate", | |
| 326 | + | Organization: []string{"Gordon"}, | |
| 327 | + | }, | |
| 328 | + | NotBefore: now.Add(-5 * time.Minute), | |
| 329 | + | NotAfter: now.Add(intermediateLifetime), | |
| 330 | + | KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, | |
| 331 | + | BasicConstraintsValid: true, | |
| 332 | + | IsCA: true, | |
| 333 | + | MaxPathLen: 0, | |
| 334 | + | MaxPathLenZero: true, | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | certDER, err := x509.CreateCertificate(rand.Reader, template, ca.rootCert, key.Public(), ca.rootKey) | |
| 338 | + | if err != nil { | |
| 339 | + | return nil, nil, fmt.Errorf("create intermediate cert: %w", err) | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | cert, err := x509.ParseCertificate(certDER) | |
| 343 | + | if err != nil { | |
| 344 | + | return nil, nil, fmt.Errorf("parse intermediate cert: %w", err) | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | ca.log.Info(). | |
| 348 | + | Str("cn", cert.Subject.CommonName). | |
| 349 | + | Time("expires", cert.NotAfter). | |
| 350 | + | Msg("generated new intermediate CA") | |
| 351 | + | return cert, key, nil | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | func (ca *CA) storeIntermediate(cert *x509.Certificate, key *ecdsa.PrivateKey) error { | |
| 355 | + | certPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.crt") | |
| 356 | + | keyPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.key") | |
| 357 | + | ||
| 358 | + | certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}) | |
| 359 | + | keyDER, err := x509.MarshalECPrivateKey(key) | |
| 360 | + | if err != nil { | |
| 361 | + | return fmt.Errorf("marshal intermediate key: %w", err) | |
| 362 | + | } | |
| 363 | + | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) | |
| 364 | + | ||
| 365 | + | // Write key first — if cert write fails afterward, the orphaned key | |
| 366 | + | // is harmless: loadOrGenerateIntermediate requires both files and | |
| 367 | + | // regenerates when either is missing. | |
| 368 | + | if err := writeSecure(keyPath, keyPEM, 0600); err != nil { | |
| 369 | + | return err | |
| 370 | + | } | |
| 371 | + | return writeSecure(certPath, certPEM, 0644) | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | // IssueCertificate generates a leaf cert for the given domain, | |
| 375 | + | // signed by the current intermediate CA. | |
| 376 | + | func (ca *CA) IssueCertificate(domain string) (*tls.Certificate, error) { | |
| 377 | + | if err := validateDomain(domain); err != nil { | |
| 378 | + | return nil, fmt.Errorf("invalid domain: %w", err) | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | ca.mu.RLock() | |
| 382 | + | interCert := ca.interCert | |
| 383 | + | interKey := ca.interKey | |
| 384 | + | ca.mu.RUnlock() | |
| 385 | + | ||
| 386 | + | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| 387 | + | if err != nil { | |
| 388 | + | return nil, fmt.Errorf("generate leaf key: %w", err) | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | serial, err := randomSerial() | |
| 392 | + | if err != nil { | |
| 393 | + | return nil, err | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | now := time.Now() | |
| 397 | + | expiry := now.Add(leafLifetime) | |
| 398 | + | if expiry.After(interCert.NotAfter) { | |
| 399 | + | expiry = interCert.NotAfter.Add(-1 * time.Minute) | |
| 400 | + | if !expiry.After(now) { | |
| 401 | + | return nil, fmt.Errorf("intermediate CA too close to expiry; renewal required") | |
| 402 | + | } | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | template := &x509.Certificate{ | |
| 406 | + | SerialNumber: serial, | |
| 407 | + | Subject: pkix.Name{ | |
| 408 | + | CommonName: domain, | |
| 409 | + | }, | |
| 410 | + | DNSNames: []string{domain}, | |
| 411 | + | NotBefore: now.Add(-5 * time.Minute), | |
| 412 | + | NotAfter: expiry, | |
| 413 | + | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, | |
| 414 | + | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | certDER, err := x509.CreateCertificate(rand.Reader, template, interCert, key.Public(), interKey) | |
| 418 | + | if err != nil { | |
| 419 | + | return nil, fmt.Errorf("create leaf cert: %w", err) | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | leafPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) | |
| 423 | + | interPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: interCert.Raw}) | |
| 424 | + | chainPEM := append(leafPEM, interPEM...) | |
| 425 | + | ||
| 426 | + | keyDER, err := x509.MarshalECPrivateKey(key) | |
| 427 | + | if err != nil { | |
| 428 | + | return nil, fmt.Errorf("marshal leaf key: %w", err) | |
| 429 | + | } | |
| 430 | + | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) | |
| 431 | + | ||
| 432 | + | tlsCert, err := tls.X509KeyPair(chainPEM, keyPEM) | |
| 433 | + | clear(keyDER) | |
| 434 | + | clear(keyPEM) | |
| 435 | + | if err != nil { | |
| 436 | + | return nil, fmt.Errorf("create TLS cert: %w", err) | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | return &tlsCert, nil | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | // LeafLifetime returns the configured leaf certificate lifetime. | |
| 443 | + | func (ca *CA) LeafLifetime() time.Duration { | |
| 444 | + | return leafLifetime | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | // IntermediateLifetime returns the configured intermediate CA lifetime. | |
| 448 | + | func (ca *CA) IntermediateLifetime() time.Duration { | |
| 449 | + | return intermediateLifetime | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | func randomSerial() (*big.Int, error) { | |
| 453 | + | serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) | |
| 454 | + | if err != nil { | |
| 455 | + | return nil, fmt.Errorf("generate serial: %w", err) | |
| 456 | + | } | |
| 457 | + | return serial, nil | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | func parseCertAndKey(certPEM, keyPEM []byte) (*x509.Certificate, crypto.Signer, error) { | |
| 461 | + | block, _ := pem.Decode(certPEM) | |
| 462 | + | if block == nil { | |
| 463 | + | return nil, nil, fmt.Errorf("failed to decode certificate PEM") | |
| 464 | + | } | |
| 465 | + | cert, err := x509.ParseCertificate(block.Bytes) | |
| 466 | + | if err != nil { | |
| 467 | + | return nil, nil, fmt.Errorf("parse certificate: %w", err) | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | keyBlock, _ := pem.Decode(keyPEM) | |
| 471 | + | if keyBlock == nil { | |
| 472 | + | return nil, nil, fmt.Errorf("failed to decode key PEM") | |
| 473 | + | } | |
| 474 | + | key, err := x509.ParseECPrivateKey(keyBlock.Bytes) | |
| 475 | + | if err != nil { | |
| 476 | + | return nil, nil, fmt.Errorf("parse private key (only ECDSA P-256 is supported): %w", err) | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | return cert, key, nil | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | // validateDomain performs defense-in-depth validation of a domain name | |
| 483 | + | // before issuing a certificate. | |
| 484 | + | func validateDomain(domain string) error { | |
| 485 | + | if err := validation.ValidateDomainParam(domain); err != nil { | |
| 486 | + | return err | |
| 487 | + | } | |
| 488 | + | if len(domain) > 253 { | |
| 489 | + | return fmt.Errorf("domain too long (%d chars), limit is 253", len(domain)) | |
| 490 | + | } | |
| 491 | + | if strings.HasPrefix(domain, "*") { | |
| 492 | + | return fmt.Errorf("wildcard domains not allowed") | |
| 493 | + | } | |
| 494 | + | if net.ParseIP(domain) != nil { | |
| 495 | + | return fmt.Errorf("IP addresses not allowed, must be a domain name") | |
| 496 | + | } | |
| 497 | + | return nil | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | func writeSecure(path string, data []byte, perm os.FileMode) (retErr error) { | |
| 501 | + | dir := filepath.Dir(path) | |
| 502 | + | if err := os.MkdirAll(dir, 0700); err != nil { | |
| 503 | + | return fmt.Errorf("create dir for %s: %w", path, err) | |
| 504 | + | } | |
| 505 | + | // Atomic write: temp file → chmod → rename to avoid TOCTOU on permissions. | |
| 506 | + | tmp, err := os.CreateTemp(dir, ".tmp-*") | |
| 507 | + | if err != nil { | |
| 508 | + | return fmt.Errorf("create temp file for %s: %w", path, err) | |
| 509 | + | } | |
| 510 | + | tmpName := tmp.Name() | |
| 511 | + | defer func() { | |
| 512 | + | if retErr != nil { | |
| 513 | + | os.Remove(tmpName) | |
| 514 | + | } | |
| 515 | + | }() | |
| 516 | + | if _, err := tmp.Write(data); err != nil { | |
| 517 | + | tmp.Close() | |
| 518 | + | return fmt.Errorf("write temp file for %s: %w", path, err) | |
| 519 | + | } | |
| 520 | + | if err := tmp.Close(); err != nil { | |
| 521 | + | return fmt.Errorf("close temp file for %s: %w", path, err) | |
| 522 | + | } | |
| 523 | + | if err := os.Chmod(tmpName, perm); err != nil { | |
| 524 | + | return fmt.Errorf("chmod temp file for %s: %w", path, err) | |
| 525 | + | } | |
| 526 | + | if err := os.Rename(tmpName, path); err != nil { | |
| 527 | + | return fmt.Errorf("rename temp file to %s: %w", path, err) | |
| 528 | + | } | |
| 529 | + | return nil | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | // verifyKeyPair checks that a certificate's public key matches the private key. | |
| 533 | + | func verifyKeyPair(cert *x509.Certificate, key crypto.Signer) error { | |
| 534 | + | certPub, ok := cert.PublicKey.(*ecdsa.PublicKey) | |
| 535 | + | if !ok { | |
| 536 | + | return fmt.Errorf("certificate has non-ECDSA public key") | |
| 537 | + | } | |
| 538 | + | keyPub, ok := key.Public().(*ecdsa.PublicKey) | |
| 539 | + | if !ok { | |
| 540 | + | return fmt.Errorf("signer has non-ECDSA public key") | |
| 541 | + | } | |
| 542 | + | if !certPub.Equal(keyPub) { | |
| 543 | + | return fmt.Errorf("public keys do not match") | |
| 544 | + | } | |
| 545 | + | return nil | |
| 546 | + | } | |
| @@ -0,0 +1,546 @@ | |||||
| 1 | + | // Package pki implements the certificate authority adapter for internal PKI operations. | |||
| 2 | + | package pki | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "crypto" | |||
| 6 | + | "crypto/ecdsa" | |||
| 7 | + | "crypto/elliptic" | |||
| 8 | + | "crypto/rand" | |||
| 9 | + | "crypto/sha256" | |||
| 10 | + | "crypto/tls" | |||
| 11 | + | "crypto/x509" | |||
| 12 | + | "crypto/x509/pkix" | |||
| 13 | + | "encoding/pem" | |||
| 14 | + | "errors" | |||
| 15 | + | "fmt" | |||
| 16 | + | "math/big" | |||
| 17 | + | "net" | |||
| 18 | + | "os" | |||
| 19 | + | "path/filepath" | |||
| 20 | + | "strings" | |||
| 21 | + | "sync" | |||
| 22 | + | "time" | |||
| 23 | + | ||||
| 24 | + | "github.com/bnema/zerowrap" | |||
| 25 | + | ||||
| 26 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |||
| 27 | + | "github.com/bnema/gordon/pkg/validation" | |||
| 28 | + | ) | |||
| 29 | + | ||||
| 30 | + | // Compile-time check: *CA satisfies the CertificateAuthority boundary. | |||
| 31 | + | var _ out.CertificateAuthority = (*CA)(nil) | |||
| 32 | + | ||||
| 33 | + | // Certificate lifetimes balance security and operational convenience: | |||
| 34 | + | // - Root: long-lived to minimise key ceremony frequency; stored offline after bootstrap. | |||
| 35 | + | // - Intermediate: short-lived to limit blast radius if compromised; auto-renewed. | |||
| 36 | + | // - Leaf: very short-lived to reduce exposure; issued on-demand and cached in memory. | |||
| 37 | + | const ( | |||
| 38 | + | rootLifetime = 10 * 365 * 24 * time.Hour // ~10 years | |||
| 39 | + | intermediateLifetime = 7 * 24 * time.Hour // 7 days | |||
| 40 | + | leafLifetime = 12 * time.Hour // 12 hours | |||
| 41 | + | ||||
| 42 | + | pkiDir = "pki" | |||
| 43 | + | ) | |||
| 44 | + | ||||
| 45 | + | // CA implements the CertificateAuthority boundary interface. | |||
| 46 | + | type CA struct { | |||
| 47 | + | dataDir string | |||
| 48 | + | log zerowrap.Logger | |||
| 49 | + | ||||
| 50 | + | rootCert *x509.Certificate | |||
| 51 | + | rootKey crypto.Signer | |||
| 52 | + | rootPEM []byte | |||
| 53 | + | ||||
| 54 | + | // mu guards interCert/interKey for concurrent reads during cert issuance. | |||
| 55 | + | // renewMu serialises intermediate CA renewal. | |||
| 56 | + | // Lock ordering: renewMu → mu (never acquire mu before renewMu). | |||
| 57 | + | mu sync.RWMutex | |||
| 58 | + | interCert *x509.Certificate | |||
| 59 | + | interKey crypto.Signer | |||
| 60 | + | ||||
| 61 | + | renewMu sync.Mutex | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // NewCA loads or generates the root and intermediate CA certificates. | |||
| 65 | + | func NewCA(dataDir string, log zerowrap.Logger) (*CA, error) { | |||
| 66 | + | ca := &CA{ | |||
| 67 | + | dataDir: dataDir, | |||
| 68 | + | log: log, | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | dir := filepath.Join(dataDir, pkiDir) | |||
| 72 | + | if err := os.MkdirAll(dir, 0700); err != nil { | |||
| 73 | + | return nil, fmt.Errorf("create pki dir: %w", err) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | if err := ca.loadOrGenerateRoot(); err != nil { | |||
| 77 | + | return nil, fmt.Errorf("root CA: %w", err) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | if err := ca.loadOrGenerateIntermediate(); err != nil { | |||
| 81 | + | return nil, fmt.Errorf("intermediate CA: %w", err) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | return ca, nil | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // RootCertificate returns the root CA certificate in PEM format. | |||
| 88 | + | func (ca *CA) RootCertificate() []byte { return ca.rootPEM } | |||
| 89 | + | ||||
| 90 | + | // RootCertificateDER returns the root CA certificate in DER format. | |||
| 91 | + | func (ca *CA) RootCertificateDER() []byte { return ca.rootCert.Raw } | |||
| 92 | + | ||||
| 93 | + | // RootFingerprint returns the SHA-256 fingerprint of the root CA cert | |||
| 94 | + | // formatted as colon-separated hex. | |||
| 95 | + | func (ca *CA) RootFingerprint() string { | |||
| 96 | + | sum := sha256.Sum256(ca.rootCert.Raw) | |||
| 97 | + | parts := make([]string, 0, sha256.Size) | |||
| 98 | + | for _, b := range sum { | |||
| 99 | + | parts = append(parts, fmt.Sprintf("%02X", b)) | |||
| 100 | + | } | |||
| 101 | + | return strings.Join(parts, ":") | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // RootCommonName returns the CN of the root CA certificate. | |||
| 105 | + | func (ca *CA) RootCommonName() string { | |||
| 106 | + | return ca.rootCert.Subject.CommonName | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // IntermediateExpiresAt returns the intermediate CA certificate expiry. | |||
| 110 | + | func (ca *CA) IntermediateExpiresAt() time.Time { | |||
| 111 | + | ca.mu.RLock() | |||
| 112 | + | defer ca.mu.RUnlock() | |||
| 113 | + | return ca.interCert.NotAfter | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // RenewIntermediate regenerates the intermediate CA certificate, | |||
| 117 | + | // signed by the root CA. Existing leaf certs remain valid. | |||
| 118 | + | func (ca *CA) RenewIntermediate() error { | |||
| 119 | + | ca.renewMu.Lock() | |||
| 120 | + | defer ca.renewMu.Unlock() | |||
| 121 | + | ||||
| 122 | + | cert, key, err := ca.generateIntermediate() | |||
| 123 | + | if err != nil { | |||
| 124 | + | return err | |||
| 125 | + | } | |||
| 126 | + | if err := ca.storeIntermediate(cert, key); err != nil { | |||
| 127 | + | return err | |||
| 128 | + | } | |||
| 129 | + | ca.mu.Lock() | |||
| 130 | + | ca.interCert = cert | |||
| 131 | + | ca.interKey = key | |||
| 132 | + | ca.mu.Unlock() | |||
| 133 | + | ca.log.Info().Time("expires", cert.NotAfter).Msg("intermediate CA renewed") | |||
| 134 | + | return nil | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func (ca *CA) loadOrGenerateRoot() error { | |||
| 138 | + | certPath := filepath.Join(ca.dataDir, pkiDir, "root.crt") | |||
| 139 | + | keyPath := filepath.Join(ca.dataDir, pkiDir, "root.key") | |||
| 140 | + | ||||
| 141 | + | certPEM, certErr := os.ReadFile(certPath) | |||
| 142 | + | keyPEM, keyErr := os.ReadFile(keyPath) | |||
| 143 | + | ||||
| 144 | + | certMissing := certErr != nil && errors.Is(certErr, os.ErrNotExist) | |||
| 145 | + | keyMissing := keyErr != nil && errors.Is(keyErr, os.ErrNotExist) | |||
| 146 | + | ||||
| 147 | + | switch { | |||
| 148 | + | case certErr == nil && keyErr == nil: | |||
| 149 | + | cert, key, err := parseCertAndKey(certPEM, keyPEM) | |||
| 150 | + | if err != nil { | |||
| 151 | + | return fmt.Errorf("corrupt root CA files: %w", err) | |||
| 152 | + | } | |||
| 153 | + | if err := verifyKeyPair(cert, key); err != nil { | |||
| 154 | + | return fmt.Errorf("root CA cert/key mismatch: %w", err) | |||
| 155 | + | } | |||
| 156 | + | ca.rootCert = cert | |||
| 157 | + | ca.rootKey = key | |||
| 158 | + | ca.rootPEM = certPEM | |||
| 159 | + | ca.log.Info().Str("cn", cert.Subject.CommonName).Msg("loaded existing root CA") | |||
| 160 | + | return nil | |||
| 161 | + | ||||
| 162 | + | case certMissing && keyMissing: | |||
| 163 | + | return ca.bootstrapRoot(certPath, keyPath) | |||
| 164 | + | ||||
| 165 | + | case certMissing || keyMissing: | |||
| 166 | + | return fmt.Errorf("incomplete root CA: cert missing=%v, key missing=%v", certMissing, keyMissing) | |||
| 167 | + | ||||
| 168 | + | default: | |||
| 169 | + | if certErr != nil { | |||
| 170 | + | return fmt.Errorf("read root cert: %w", certErr) | |||
| 171 | + | } | |||
| 172 | + | return fmt.Errorf("read root key: %w", keyErr) | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func (ca *CA) bootstrapRoot(certPath, keyPath string) error { | |||
| 177 | + | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |||
| 178 | + | if err != nil { | |||
| 179 | + | return fmt.Errorf("generate root key: %w", err) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | serial, err := randomSerial() | |||
| 183 | + | if err != nil { | |||
| 184 | + | return err | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | now := time.Now() | |||
| 188 | + | template := &x509.Certificate{ | |||
| 189 | + | SerialNumber: serial, | |||
| 190 | + | Subject: pkix.Name{ | |||
| 191 | + | CommonName: fmt.Sprintf("Gordon Internal CA - %d ECC Root", now.Year()), | |||
| 192 | + | Organization: []string{"Gordon"}, | |||
| 193 | + | }, | |||
| 194 | + | NotBefore: now.Add(-5 * time.Minute), | |||
| 195 | + | NotAfter: now.Add(rootLifetime), | |||
| 196 | + | KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, | |||
| 197 | + | BasicConstraintsValid: true, | |||
| 198 | + | IsCA: true, | |||
| 199 | + | MaxPathLen: 1, | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | certDER, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key) | |||
| 203 | + | if err != nil { | |||
| 204 | + | return fmt.Errorf("create root cert: %w", err) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | cert, err := x509.ParseCertificate(certDER) | |||
| 208 | + | if err != nil { | |||
| 209 | + | return fmt.Errorf("parse root cert: %w", err) | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) | |||
| 213 | + | keyDER, err := x509.MarshalECPrivateKey(key) | |||
| 214 | + | if err != nil { | |||
| 215 | + | return fmt.Errorf("marshal root key: %w", err) | |||
| 216 | + | } | |||
| 217 | + | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) | |||
| 218 | + | ||||
| 219 | + | // Write key first — orphaned key is harmless; missing cert triggers regeneration. | |||
| 220 | + | if err := writeSecure(keyPath, keyPEM, 0600); err != nil { | |||
| 221 | + | return err | |||
| 222 | + | } | |||
| 223 | + | if err := writeSecure(certPath, certPEM, 0644); err != nil { | |||
| 224 | + | return err | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | ca.rootCert = cert | |||
| 228 | + | ca.rootKey = key | |||
| 229 | + | ca.rootPEM = certPEM | |||
| 230 | + | ca.log.Info().Str("cn", cert.Subject.CommonName).Msg("generated new root CA") | |||
| 231 | + | return nil | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | // validateLoadedIntermediate parses, verifies, and checks the expiry of a loaded | |||
| 235 | + | // intermediate CA cert/key pair. Returns (cert, key, true) on success, or | |||
| 236 | + | // (nil, nil, false) if the pair should be regenerated (parse error, key mismatch, | |||
| 237 | + | // wrong signer, or expired). Logging is done inside this function. | |||
| 238 | + | func (ca *CA) validateLoadedIntermediate(certPEM, keyPEM []byte) (*x509.Certificate, crypto.Signer, bool) { | |||
| 239 | + | cert, key, err := parseCertAndKey(certPEM, keyPEM) | |||
| 240 | + | if err != nil { | |||
| 241 | + | ca.log.Warn().Err(err).Msg("failed to parse intermediate CA, regenerating") | |||
| 242 | + | return nil, nil, false | |||
| 243 | + | } | |||
| 244 | + | if err := verifyKeyPair(cert, key); err != nil { | |||
| 245 | + | ca.log.Warn().Err(err).Msg("intermediate CA cert/key mismatch, regenerating") | |||
| 246 | + | return nil, nil, false | |||
| 247 | + | } | |||
| 248 | + | if err := cert.CheckSignatureFrom(ca.rootCert); err != nil { | |||
| 249 | + | ca.log.Warn().Err(err).Msg("intermediate CA not signed by current root, regenerating") | |||
| 250 | + | return nil, nil, false | |||
| 251 | + | } | |||
| 252 | + | if !time.Now().Before(cert.NotAfter) { | |||
| 253 | + | ca.log.Warn().Msg("intermediate CA expired, regenerating") | |||
| 254 | + | return nil, nil, false | |||
| 255 | + | } | |||
| 256 | + | return cert, key, true | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | func (ca *CA) loadOrGenerateIntermediate() error { | |||
| 260 | + | certPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.crt") | |||
| 261 | + | keyPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.key") | |||
| 262 | + | ||||
| 263 | + | certPEM, certErr := os.ReadFile(certPath) | |||
| 264 | + | keyPEM, keyErr := os.ReadFile(keyPath) | |||
| 265 | + | ||||
| 266 | + | certMissing := certErr != nil && errors.Is(certErr, os.ErrNotExist) | |||
| 267 | + | keyMissing := keyErr != nil && errors.Is(keyErr, os.ErrNotExist) | |||
| 268 | + | ||||
| 269 | + | switch { | |||
| 270 | + | case certErr == nil && keyErr == nil: | |||
| 271 | + | if cert, key, ok := ca.validateLoadedIntermediate(certPEM, keyPEM); ok { | |||
| 272 | + | ca.interCert = cert | |||
| 273 | + | ca.interKey = key | |||
| 274 | + | ca.log.Info(). | |||
| 275 | + | Str("cn", cert.Subject.CommonName). | |||
| 276 | + | Time("expires", cert.NotAfter). | |||
| 277 | + | Msg("loaded existing intermediate CA") | |||
| 278 | + | return nil | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | case certMissing && keyMissing: | |||
| 282 | + | // Both files absent — generate fresh intermediate below. | |||
| 283 | + | ||||
| 284 | + | case certMissing || keyMissing: | |||
| 285 | + | ca.log.Warn(). | |||
| 286 | + | Bool("cert_missing", certMissing). | |||
| 287 | + | Bool("key_missing", keyMissing). | |||
| 288 | + | Msg("intermediate CA files incomplete, regenerating") | |||
| 289 | + | ||||
| 290 | + | default: | |||
| 291 | + | // At least one ReadFile returned a non-NotExist error (e.g. EPERM, EIO). | |||
| 292 | + | if certErr != nil { | |||
| 293 | + | return fmt.Errorf("read intermediate cert: %w", certErr) | |||
| 294 | + | } | |||
| 295 | + | return fmt.Errorf("read intermediate key: %w", keyErr) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | cert, key, err := ca.generateIntermediate() | |||
| 299 | + | if err != nil { | |||
| 300 | + | return err | |||
| 301 | + | } | |||
| 302 | + | if err := ca.storeIntermediate(cert, key); err != nil { | |||
| 303 | + | return err | |||
| 304 | + | } | |||
| 305 | + | ca.interCert = cert | |||
| 306 | + | ca.interKey = key | |||
| 307 | + | return nil | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | func (ca *CA) generateIntermediate() (*x509.Certificate, *ecdsa.PrivateKey, error) { | |||
| 311 | + | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |||
| 312 | + | if err != nil { | |||
| 313 | + | return nil, nil, fmt.Errorf("generate intermediate key: %w", err) | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | serial, err := randomSerial() | |||
| 317 | + | if err != nil { | |||
| 318 | + | return nil, nil, err | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | now := time.Now() | |||
| 322 | + | template := &x509.Certificate{ | |||
| 323 | + | SerialNumber: serial, | |||
| 324 | + | Subject: pkix.Name{ | |||
| 325 | + | CommonName: "Gordon Internal CA - ECC Intermediate", | |||
| 326 | + | Organization: []string{"Gordon"}, | |||
| 327 | + | }, | |||
| 328 | + | NotBefore: now.Add(-5 * time.Minute), | |||
| 329 | + | NotAfter: now.Add(intermediateLifetime), | |||
| 330 | + | KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, | |||
| 331 | + | BasicConstraintsValid: true, | |||
| 332 | + | IsCA: true, | |||
| 333 | + | MaxPathLen: 0, | |||
| 334 | + | MaxPathLenZero: true, | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | certDER, err := x509.CreateCertificate(rand.Reader, template, ca.rootCert, key.Public(), ca.rootKey) | |||
| 338 | + | if err != nil { | |||
| 339 | + | return nil, nil, fmt.Errorf("create intermediate cert: %w", err) | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | cert, err := x509.ParseCertificate(certDER) | |||
| 343 | + | if err != nil { | |||
| 344 | + | return nil, nil, fmt.Errorf("parse intermediate cert: %w", err) | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | ca.log.Info(). | |||
| 348 | + | Str("cn", cert.Subject.CommonName). | |||
| 349 | + | Time("expires", cert.NotAfter). | |||
| 350 | + | Msg("generated new intermediate CA") | |||
| 351 | + | return cert, key, nil | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | func (ca *CA) storeIntermediate(cert *x509.Certificate, key *ecdsa.PrivateKey) error { | |||
| 355 | + | certPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.crt") | |||
| 356 | + | keyPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.key") | |||
| 357 | + | ||||
| 358 | + | certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}) | |||
| 359 | + | keyDER, err := x509.MarshalECPrivateKey(key) | |||
| 360 | + | if err != nil { | |||
| 361 | + | return fmt.Errorf("marshal intermediate key: %w", err) | |||
| 362 | + | } | |||
| 363 | + | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) | |||
| 364 | + | ||||
| 365 | + | // Write key first — if cert write fails afterward, the orphaned key | |||
| 366 | + | // is harmless: loadOrGenerateIntermediate requires both files and | |||
| 367 | + | // regenerates when either is missing. | |||
| 368 | + | if err := writeSecure(keyPath, keyPEM, 0600); err != nil { | |||
| 369 | + | return err | |||
| 370 | + | } | |||
| 371 | + | return writeSecure(certPath, certPEM, 0644) | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | // IssueCertificate generates a leaf cert for the given domain, | |||
| 375 | + | // signed by the current intermediate CA. | |||
| 376 | + | func (ca *CA) IssueCertificate(domain string) (*tls.Certificate, error) { | |||
| 377 | + | if err := validateDomain(domain); err != nil { | |||
| 378 | + | return nil, fmt.Errorf("invalid domain: %w", err) | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | ca.mu.RLock() | |||
| 382 | + | interCert := ca.interCert | |||
| 383 | + | interKey := ca.interKey | |||
| 384 | + | ca.mu.RUnlock() | |||
| 385 | + | ||||
| 386 | + | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |||
| 387 | + | if err != nil { | |||
| 388 | + | return nil, fmt.Errorf("generate leaf key: %w", err) | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | serial, err := randomSerial() | |||
| 392 | + | if err != nil { | |||
| 393 | + | return nil, err | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | now := time.Now() | |||
| 397 | + | expiry := now.Add(leafLifetime) | |||
| 398 | + | if expiry.After(interCert.NotAfter) { | |||
| 399 | + | expiry = interCert.NotAfter.Add(-1 * time.Minute) | |||
| 400 | + | if !expiry.After(now) { | |||
| 401 | + | return nil, fmt.Errorf("intermediate CA too close to expiry; renewal required") | |||
| 402 | + | } | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | template := &x509.Certificate{ | |||
| 406 | + | SerialNumber: serial, | |||
| 407 | + | Subject: pkix.Name{ | |||
| 408 | + | CommonName: domain, | |||
| 409 | + | }, | |||
| 410 | + | DNSNames: []string{domain}, | |||
| 411 | + | NotBefore: now.Add(-5 * time.Minute), | |||
| 412 | + | NotAfter: expiry, | |||
| 413 | + | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, | |||
| 414 | + | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | certDER, err := x509.CreateCertificate(rand.Reader, template, interCert, key.Public(), interKey) | |||
| 418 | + | if err != nil { | |||
| 419 | + | return nil, fmt.Errorf("create leaf cert: %w", err) | |||
| 420 | + | } | |||
| 421 | + | ||||
| 422 | + | leafPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) | |||
| 423 | + | interPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: interCert.Raw}) | |||
| 424 | + | chainPEM := append(leafPEM, interPEM...) | |||
| 425 | + | ||||
| 426 | + | keyDER, err := x509.MarshalECPrivateKey(key) | |||
| 427 | + | if err != nil { | |||
| 428 | + | return nil, fmt.Errorf("marshal leaf key: %w", err) | |||
| 429 | + | } | |||
| 430 | + | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) | |||
| 431 | + | ||||
| 432 | + | tlsCert, err := tls.X509KeyPair(chainPEM, keyPEM) | |||
| 433 | + | clear(keyDER) | |||
| 434 | + | clear(keyPEM) | |||
| 435 | + | if err != nil { | |||
| 436 | + | return nil, fmt.Errorf("create TLS cert: %w", err) | |||
| 437 | + | } | |||
| 438 | + | ||||
| 439 | + | return &tlsCert, nil | |||
| 440 | + | } | |||
| 441 | + | ||||
| 442 | + | // LeafLifetime returns the configured leaf certificate lifetime. | |||
| 443 | + | func (ca *CA) LeafLifetime() time.Duration { | |||
| 444 | + | return leafLifetime | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | // IntermediateLifetime returns the configured intermediate CA lifetime. | |||
| 448 | + | func (ca *CA) IntermediateLifetime() time.Duration { | |||
| 449 | + | return intermediateLifetime | |||
| 450 | + | } | |||
| 451 | + | ||||
| 452 | + | func randomSerial() (*big.Int, error) { | |||
| 453 | + | serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) | |||
| 454 | + | if err != nil { | |||
| 455 | + | return nil, fmt.Errorf("generate serial: %w", err) | |||
| 456 | + | } | |||
| 457 | + | return serial, nil | |||
| 458 | + | } | |||
| 459 | + | ||||
| 460 | + | func parseCertAndKey(certPEM, keyPEM []byte) (*x509.Certificate, crypto.Signer, error) { | |||
| 461 | + | block, _ := pem.Decode(certPEM) | |||
| 462 | + | if block == nil { | |||
| 463 | + | return nil, nil, fmt.Errorf("failed to decode certificate PEM") | |||
| 464 | + | } | |||
| 465 | + | cert, err := x509.ParseCertificate(block.Bytes) | |||
| 466 | + | if err != nil { | |||
| 467 | + | return nil, nil, fmt.Errorf("parse certificate: %w", err) | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | keyBlock, _ := pem.Decode(keyPEM) | |||
| 471 | + | if keyBlock == nil { | |||
| 472 | + | return nil, nil, fmt.Errorf("failed to decode key PEM") | |||
| 473 | + | } | |||
| 474 | + | key, err := x509.ParseECPrivateKey(keyBlock.Bytes) | |||
| 475 | + | if err != nil { | |||
| 476 | + | return nil, nil, fmt.Errorf("parse private key (only ECDSA P-256 is supported): %w", err) | |||
| 477 | + | } | |||
| 478 | + | ||||
| 479 | + | return cert, key, nil | |||
| 480 | + | } | |||
| 481 | + | ||||
| 482 | + | // validateDomain performs defense-in-depth validation of a domain name | |||
| 483 | + | // before issuing a certificate. | |||
| 484 | + | func validateDomain(domain string) error { | |||
| 485 | + | if err := validation.ValidateDomainParam(domain); err != nil { | |||
| 486 | + | return err | |||
| 487 | + | } | |||
| 488 | + | if len(domain) > 253 { | |||
| 489 | + | return fmt.Errorf("domain too long (%d chars), limit is 253", len(domain)) | |||
| 490 | + | } | |||
| 491 | + | if strings.HasPrefix(domain, "*") { | |||
| 492 | + | return fmt.Errorf("wildcard domains not allowed") | |||
| 493 | + | } | |||
| 494 | + | if net.ParseIP(domain) != nil { | |||
| 495 | + | return fmt.Errorf("IP addresses not allowed, must be a domain name") | |||
| 496 | + | } | |||
| 497 | + | return nil | |||
| 498 | + | } | |||
| 499 | + | ||||
| 500 | + | func writeSecure(path string, data []byte, perm os.FileMode) (retErr error) { | |||
| 501 | + | dir := filepath.Dir(path) | |||
| 502 | + | if err := os.MkdirAll(dir, 0700); err != nil { | |||
| 503 | + | return fmt.Errorf("create dir for %s: %w", path, err) | |||
| 504 | + | } | |||
| 505 | + | // Atomic write: temp file → chmod → rename to avoid TOCTOU on permissions. | |||
| 506 | + | tmp, err := os.CreateTemp(dir, ".tmp-*") | |||
| 507 | + | if err != nil { | |||
| 508 | + | return fmt.Errorf("create temp file for %s: %w", path, err) | |||
| 509 | + | } | |||
| 510 | + | tmpName := tmp.Name() | |||
| 511 | + | defer func() { | |||
| 512 | + | if retErr != nil { | |||
| 513 | + | os.Remove(tmpName) | |||
| 514 | + | } | |||
| 515 | + | }() | |||
| 516 | + | if _, err := tmp.Write(data); err != nil { | |||
| 517 | + | tmp.Close() | |||
| 518 | + | return fmt.Errorf("write temp file for %s: %w", path, err) | |||
| 519 | + | } | |||
| 520 | + | if err := tmp.Close(); err != nil { | |||
| 521 | + | return fmt.Errorf("close temp file for %s: %w", path, err) | |||
| 522 | + | } | |||
| 523 | + | if err := os.Chmod(tmpName, perm); err != nil { | |||
| 524 | + | return fmt.Errorf("chmod temp file for %s: %w", path, err) | |||
| 525 | + | } | |||
| 526 | + | if err := os.Rename(tmpName, path); err != nil { | |||
| 527 | + | return fmt.Errorf("rename temp file to %s: %w", path, err) | |||
| 528 | + | } | |||
| 529 | + | return nil | |||
| 530 | + | } | |||
| 531 | + | ||||
| 532 | + | // verifyKeyPair checks that a certificate's public key matches the private key. | |||
| 533 | + | func verifyKeyPair(cert *x509.Certificate, key crypto.Signer) error { | |||
| 534 | + | certPub, ok := cert.PublicKey.(*ecdsa.PublicKey) | |||
| 535 | + | if !ok { | |||
| 536 | + | return fmt.Errorf("certificate has non-ECDSA public key") | |||
| 537 | + | } | |||
| 538 | + | keyPub, ok := key.Public().(*ecdsa.PublicKey) | |||
| 539 | + | if !ok { | |||
| 540 | + | return fmt.Errorf("signer has non-ECDSA public key") | |||
| 541 | + | } | |||
| 542 | + | if !certPub.Equal(keyPub) { | |||
| 543 | + | return fmt.Errorf("public keys do not match") | |||
| 544 | + | } | |||
| 545 | + | return nil | |||
| 546 | + | } | |||
A internal/adapters/out/pki/ca_test.go +122 -0
| @@ -0,0 +1,122 @@ | |||
| 1 | + | package pki_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/x509" | |
| 5 | + | "encoding/pem" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 12 | + | "github.com/bnema/zerowrap" | |
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func testLogger() zerowrap.Logger { | |
| 18 | + | return zerowrap.Default() | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestNewCA_GeneratesRootAndIntermediate(t *testing.T) { | |
| 22 | + | dir := t.TempDir() | |
| 23 | + | ca, err := pki.NewCA(dir, testLogger()) | |
| 24 | + | require.NoError(t, err) | |
| 25 | + | ||
| 26 | + | // Root cert exists on disk | |
| 27 | + | rootPEM, err := os.ReadFile(filepath.Join(dir, "pki", "root.crt")) | |
| 28 | + | require.NoError(t, err) | |
| 29 | + | block, _ := pem.Decode(rootPEM) | |
| 30 | + | require.NotNil(t, block) | |
| 31 | + | rootCert, err := x509.ParseCertificate(block.Bytes) | |
| 32 | + | require.NoError(t, err) | |
| 33 | + | assert.True(t, rootCert.IsCA) | |
| 34 | + | assert.Equal(t, rootCert.Issuer.CommonName, rootCert.Subject.CommonName) // self-signed | |
| 35 | + | ||
| 36 | + | // Intermediate cert exists on disk | |
| 37 | + | interPEM, err := os.ReadFile(filepath.Join(dir, "pki", "intermediate.crt")) | |
| 38 | + | require.NoError(t, err) | |
| 39 | + | block, _ = pem.Decode(interPEM) | |
| 40 | + | require.NotNil(t, block) | |
| 41 | + | interCert, err := x509.ParseCertificate(block.Bytes) | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | assert.True(t, interCert.IsCA) | |
| 44 | + | assert.NotEqual(t, interCert.Issuer.CommonName, interCert.Subject.CommonName) // signed by root | |
| 45 | + | ||
| 46 | + | // Interface methods work | |
| 47 | + | assert.NotEmpty(t, ca.RootCertificate()) | |
| 48 | + | assert.NotEmpty(t, ca.RootCertificateDER()) | |
| 49 | + | assert.NotEmpty(t, ca.RootFingerprint()) | |
| 50 | + | assert.NotEmpty(t, ca.RootCommonName()) | |
| 51 | + | assert.False(t, ca.IntermediateExpiresAt().IsZero()) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestNewCA_LoadsExistingOnRestart(t *testing.T) { | |
| 55 | + | dir := t.TempDir() | |
| 56 | + | ||
| 57 | + | // First boot: generate | |
| 58 | + | ca1, err := pki.NewCA(dir, testLogger()) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | fp1 := ca1.RootFingerprint() | |
| 61 | + | ||
| 62 | + | // Second boot: load | |
| 63 | + | ca2, err := pki.NewCA(dir, testLogger()) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | fp2 := ca2.RootFingerprint() | |
| 66 | + | ||
| 67 | + | assert.Equal(t, fp1, fp2, "root CA should be the same across restarts") | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestCA_IssueCertificate(t *testing.T) { | |
| 71 | + | dir := t.TempDir() | |
| 72 | + | ca, err := pki.NewCA(dir, testLogger()) | |
| 73 | + | require.NoError(t, err) | |
| 74 | + | ||
| 75 | + | cert, err := ca.IssueCertificate("test.example.com") | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | require.NotNil(t, cert) | |
| 78 | + | ||
| 79 | + | // Parse the leaf cert | |
| 80 | + | leaf, err := x509.ParseCertificate(cert.Certificate[0]) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | assert.Equal(t, "test.example.com", leaf.Subject.CommonName) | |
| 83 | + | assert.Contains(t, leaf.DNSNames, "test.example.com") | |
| 84 | + | assert.False(t, leaf.IsCA) | |
| 85 | + | ||
| 86 | + | // Verify chain: leaf -> intermediate -> root | |
| 87 | + | rootPool := x509.NewCertPool() | |
| 88 | + | rootPool.AppendCertsFromPEM(ca.RootCertificate()) | |
| 89 | + | interPool := x509.NewCertPool() | |
| 90 | + | inter, err := x509.ParseCertificate(cert.Certificate[1]) | |
| 91 | + | require.NoError(t, err) | |
| 92 | + | interPool.AddCert(inter) | |
| 93 | + | ||
| 94 | + | _, err = leaf.Verify(x509.VerifyOptions{ | |
| 95 | + | Roots: rootPool, | |
| 96 | + | Intermediates: interPool, | |
| 97 | + | }) | |
| 98 | + | assert.NoError(t, err, "leaf cert should verify against root CA") | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestCA_RenewIntermediate(t *testing.T) { | |
| 102 | + | dir := t.TempDir() | |
| 103 | + | ca, err := pki.NewCA(dir, testLogger()) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | ||
| 106 | + | oldExpiry := ca.IntermediateExpiresAt() | |
| 107 | + | ||
| 108 | + | // x509 timestamps have second precision, so we must cross | |
| 109 | + | // a second boundary to observe a different NotAfter. | |
| 110 | + | time.Sleep(1100 * time.Millisecond) | |
| 111 | + | ||
| 112 | + | err = ca.RenewIntermediate() | |
| 113 | + | require.NoError(t, err) | |
| 114 | + | ||
| 115 | + | newExpiry := ca.IntermediateExpiresAt() | |
| 116 | + | assert.True(t, newExpiry.After(oldExpiry), "renewed intermediate should have later expiry") | |
| 117 | + | ||
| 118 | + | // Leaf certs should still work after renewal | |
| 119 | + | cert, err := ca.IssueCertificate("after-renewal.example.com") | |
| 120 | + | require.NoError(t, err) | |
| 121 | + | require.NotNil(t, cert) | |
| 122 | + | } | |
| @@ -0,0 +1,122 @@ | |||||
| 1 | + | package pki_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/x509" | |||
| 5 | + | "encoding/pem" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/adapters/out/pki" | |||
| 12 | + | "github.com/bnema/zerowrap" | |||
| 13 | + | "github.com/stretchr/testify/assert" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func testLogger() zerowrap.Logger { | |||
| 18 | + | return zerowrap.Default() | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func TestNewCA_GeneratesRootAndIntermediate(t *testing.T) { | |||
| 22 | + | dir := t.TempDir() | |||
| 23 | + | ca, err := pki.NewCA(dir, testLogger()) | |||
| 24 | + | require.NoError(t, err) | |||
| 25 | + | ||||
| 26 | + | // Root cert exists on disk | |||
| 27 | + | rootPEM, err := os.ReadFile(filepath.Join(dir, "pki", "root.crt")) | |||
| 28 | + | require.NoError(t, err) | |||
| 29 | + | block, _ := pem.Decode(rootPEM) | |||
| 30 | + | require.NotNil(t, block) | |||
| 31 | + | rootCert, err := x509.ParseCertificate(block.Bytes) | |||
| 32 | + | require.NoError(t, err) | |||
| 33 | + | assert.True(t, rootCert.IsCA) | |||
| 34 | + | assert.Equal(t, rootCert.Issuer.CommonName, rootCert.Subject.CommonName) // self-signed | |||
| 35 | + | ||||
| 36 | + | // Intermediate cert exists on disk | |||
| 37 | + | interPEM, err := os.ReadFile(filepath.Join(dir, "pki", "intermediate.crt")) | |||
| 38 | + | require.NoError(t, err) | |||
| 39 | + | block, _ = pem.Decode(interPEM) | |||
| 40 | + | require.NotNil(t, block) | |||
| 41 | + | interCert, err := x509.ParseCertificate(block.Bytes) | |||
| 42 | + | require.NoError(t, err) | |||
| 43 | + | assert.True(t, interCert.IsCA) | |||
| 44 | + | assert.NotEqual(t, interCert.Issuer.CommonName, interCert.Subject.CommonName) // signed by root | |||
| 45 | + | ||||
| 46 | + | // Interface methods work | |||
| 47 | + | assert.NotEmpty(t, ca.RootCertificate()) | |||
| 48 | + | assert.NotEmpty(t, ca.RootCertificateDER()) | |||
| 49 | + | assert.NotEmpty(t, ca.RootFingerprint()) | |||
| 50 | + | assert.NotEmpty(t, ca.RootCommonName()) | |||
| 51 | + | assert.False(t, ca.IntermediateExpiresAt().IsZero()) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestNewCA_LoadsExistingOnRestart(t *testing.T) { | |||
| 55 | + | dir := t.TempDir() | |||
| 56 | + | ||||
| 57 | + | // First boot: generate | |||
| 58 | + | ca1, err := pki.NewCA(dir, testLogger()) | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | fp1 := ca1.RootFingerprint() | |||
| 61 | + | ||||
| 62 | + | // Second boot: load | |||
| 63 | + | ca2, err := pki.NewCA(dir, testLogger()) | |||
| 64 | + | require.NoError(t, err) | |||
| 65 | + | fp2 := ca2.RootFingerprint() | |||
| 66 | + | ||||
| 67 | + | assert.Equal(t, fp1, fp2, "root CA should be the same across restarts") | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestCA_IssueCertificate(t *testing.T) { | |||
| 71 | + | dir := t.TempDir() | |||
| 72 | + | ca, err := pki.NewCA(dir, testLogger()) | |||
| 73 | + | require.NoError(t, err) | |||
| 74 | + | ||||
| 75 | + | cert, err := ca.IssueCertificate("test.example.com") | |||
| 76 | + | require.NoError(t, err) | |||
| 77 | + | require.NotNil(t, cert) | |||
| 78 | + | ||||
| 79 | + | // Parse the leaf cert | |||
| 80 | + | leaf, err := x509.ParseCertificate(cert.Certificate[0]) | |||
| 81 | + | require.NoError(t, err) | |||
| 82 | + | assert.Equal(t, "test.example.com", leaf.Subject.CommonName) | |||
| 83 | + | assert.Contains(t, leaf.DNSNames, "test.example.com") | |||
| 84 | + | assert.False(t, leaf.IsCA) | |||
| 85 | + | ||||
| 86 | + | // Verify chain: leaf -> intermediate -> root | |||
| 87 | + | rootPool := x509.NewCertPool() | |||
| 88 | + | rootPool.AppendCertsFromPEM(ca.RootCertificate()) | |||
| 89 | + | interPool := x509.NewCertPool() | |||
| 90 | + | inter, err := x509.ParseCertificate(cert.Certificate[1]) | |||
| 91 | + | require.NoError(t, err) | |||
| 92 | + | interPool.AddCert(inter) | |||
| 93 | + | ||||
| 94 | + | _, err = leaf.Verify(x509.VerifyOptions{ | |||
| 95 | + | Roots: rootPool, | |||
| 96 | + | Intermediates: interPool, | |||
| 97 | + | }) | |||
| 98 | + | assert.NoError(t, err, "leaf cert should verify against root CA") | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestCA_RenewIntermediate(t *testing.T) { | |||
| 102 | + | dir := t.TempDir() | |||
| 103 | + | ca, err := pki.NewCA(dir, testLogger()) | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | ||||
| 106 | + | oldExpiry := ca.IntermediateExpiresAt() | |||
| 107 | + | ||||
| 108 | + | // x509 timestamps have second precision, so we must cross | |||
| 109 | + | // a second boundary to observe a different NotAfter. | |||
| 110 | + | time.Sleep(1100 * time.Millisecond) | |||
| 111 | + | ||||
| 112 | + | err = ca.RenewIntermediate() | |||
| 113 | + | require.NoError(t, err) | |||
| 114 | + | ||||
| 115 | + | newExpiry := ca.IntermediateExpiresAt() | |||
| 116 | + | assert.True(t, newExpiry.After(oldExpiry), "renewed intermediate should have later expiry") | |||
| 117 | + | ||||
| 118 | + | // Leaf certs should still work after renewal | |||
| 119 | + | cert, err := ca.IssueCertificate("after-renewal.example.com") | |||
| 120 | + | require.NoError(t, err) | |||
| 121 | + | require.NotNil(t, cert) | |||
| 122 | + | } | |||
A internal/adapters/out/pki/mobileconfig.go +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | package pki | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/base64" | |
| 5 | + | "fmt" | |
| 6 | + | "html" | |
| 7 | + | ||
| 8 | + | "github.com/google/uuid" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // GenerateMobileconfig generates an iOS configuration profile containing | |
| 12 | + | // the root CA certificate for one-tap trust installation. | |
| 13 | + | func GenerateMobileconfig(rootDER []byte, rootCN string) []byte { | |
| 14 | + | b64Cert := base64.StdEncoding.EncodeToString(rootDER) | |
| 15 | + | profileUUID := uuid.New().String() | |
| 16 | + | certUUID := uuid.New().String() | |
| 17 | + | ||
| 18 | + | safeCN := html.EscapeString(rootCN) | |
| 19 | + | profile := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?> | |
| 20 | + | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| 21 | + | <plist version="1.0"> | |
| 22 | + | <dict> | |
| 23 | + | <key>PayloadContent</key> | |
| 24 | + | <array> | |
| 25 | + | <dict> | |
| 26 | + | <key>PayloadCertificateFileName</key> | |
| 27 | + | <string>gordon-ca.crt</string> | |
| 28 | + | <key>PayloadContent</key> | |
| 29 | + | <data>%s</data> | |
| 30 | + | <key>PayloadDescription</key> | |
| 31 | + | <string>Installs the Gordon internal CA root certificate</string> | |
| 32 | + | <key>PayloadDisplayName</key> | |
| 33 | + | <string>%s</string> | |
| 34 | + | <key>PayloadIdentifier</key> | |
| 35 | + | <string>dev.gordon.ca.%s</string> | |
| 36 | + | <key>PayloadType</key> | |
| 37 | + | <string>com.apple.security.root</string> | |
| 38 | + | <key>PayloadUUID</key> | |
| 39 | + | <string>%s</string> | |
| 40 | + | <key>PayloadVersion</key> | |
| 41 | + | <integer>1</integer> | |
| 42 | + | </dict> | |
| 43 | + | </array> | |
| 44 | + | <key>PayloadDescription</key> | |
| 45 | + | <string>Trust Gordon's internal Certificate Authority for HTTPS access</string> | |
| 46 | + | <key>PayloadDisplayName</key> | |
| 47 | + | <string>Gordon CA Trust</string> | |
| 48 | + | <key>PayloadIdentifier</key> | |
| 49 | + | <string>dev.gordon.ca-profile</string> | |
| 50 | + | <key>PayloadType</key> | |
| 51 | + | <string>Configuration</string> | |
| 52 | + | <key>PayloadUUID</key> | |
| 53 | + | <string>%s</string> | |
| 54 | + | <key>PayloadVersion</key> | |
| 55 | + | <integer>1</integer> | |
| 56 | + | </dict> | |
| 57 | + | </plist>`, b64Cert, safeCN, certUUID, certUUID, profileUUID) | |
| 58 | + | ||
| 59 | + | return []byte(profile) | |
| 60 | + | } | |
| @@ -0,0 +1,60 @@ | |||||
| 1 | + | package pki | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/base64" | |||
| 5 | + | "fmt" | |||
| 6 | + | "html" | |||
| 7 | + | ||||
| 8 | + | "github.com/google/uuid" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // GenerateMobileconfig generates an iOS configuration profile containing | |||
| 12 | + | // the root CA certificate for one-tap trust installation. | |||
| 13 | + | func GenerateMobileconfig(rootDER []byte, rootCN string) []byte { | |||
| 14 | + | b64Cert := base64.StdEncoding.EncodeToString(rootDER) | |||
| 15 | + | profileUUID := uuid.New().String() | |||
| 16 | + | certUUID := uuid.New().String() | |||
| 17 | + | ||||
| 18 | + | safeCN := html.EscapeString(rootCN) | |||
| 19 | + | profile := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?> | |||
| 20 | + | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |||
| 21 | + | <plist version="1.0"> | |||
| 22 | + | <dict> | |||
| 23 | + | <key>PayloadContent</key> | |||
| 24 | + | <array> | |||
| 25 | + | <dict> | |||
| 26 | + | <key>PayloadCertificateFileName</key> | |||
| 27 | + | <string>gordon-ca.crt</string> | |||
| 28 | + | <key>PayloadContent</key> | |||
| 29 | + | <data>%s</data> | |||
| 30 | + | <key>PayloadDescription</key> | |||
| 31 | + | <string>Installs the Gordon internal CA root certificate</string> | |||
| 32 | + | <key>PayloadDisplayName</key> | |||
| 33 | + | <string>%s</string> | |||
| 34 | + | <key>PayloadIdentifier</key> | |||
| 35 | + | <string>dev.gordon.ca.%s</string> | |||
| 36 | + | <key>PayloadType</key> | |||
| 37 | + | <string>com.apple.security.root</string> | |||
| 38 | + | <key>PayloadUUID</key> | |||
| 39 | + | <string>%s</string> | |||
| 40 | + | <key>PayloadVersion</key> | |||
| 41 | + | <integer>1</integer> | |||
| 42 | + | </dict> | |||
| 43 | + | </array> | |||
| 44 | + | <key>PayloadDescription</key> | |||
| 45 | + | <string>Trust Gordon's internal Certificate Authority for HTTPS access</string> | |||
| 46 | + | <key>PayloadDisplayName</key> | |||
| 47 | + | <string>Gordon CA Trust</string> | |||
| 48 | + | <key>PayloadIdentifier</key> | |||
| 49 | + | <string>dev.gordon.ca-profile</string> | |||
| 50 | + | <key>PayloadType</key> | |||
| 51 | + | <string>Configuration</string> | |||
| 52 | + | <key>PayloadUUID</key> | |||
| 53 | + | <string>%s</string> | |||
| 54 | + | <key>PayloadVersion</key> | |||
| 55 | + | <integer>1</integer> | |||
| 56 | + | </dict> | |||
| 57 | + | </plist>`, b64Cert, safeCN, certUUID, certUUID, profileUUID) | |||
| 58 | + | ||||
| 59 | + | return []byte(profile) | |||
| 60 | + | } | |||
A internal/adapters/out/pki/mobileconfig_test.go +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | package pki_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestGenerateMobileconfig(t *testing.T) { | |
| 14 | + | dir := t.TempDir() | |
| 15 | + | ca, err := pki.NewCA(dir, testLogger()) | |
| 16 | + | require.NoError(t, err) | |
| 17 | + | ||
| 18 | + | mc := pki.GenerateMobileconfig(ca.RootCertificateDER(), ca.RootCommonName()) | |
| 19 | + | assert.True(t, strings.HasPrefix(string(mc), "<?xml")) | |
| 20 | + | assert.Contains(t, string(mc), "Gordon Internal CA") | |
| 21 | + | assert.Contains(t, string(mc), "<key>PayloadType</key>") | |
| 22 | + | assert.Contains(t, string(mc), "com.apple.security.root") | |
| 23 | + | } | |
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | package pki_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/adapters/out/pki" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestGenerateMobileconfig(t *testing.T) { | |||
| 14 | + | dir := t.TempDir() | |||
| 15 | + | ca, err := pki.NewCA(dir, testLogger()) | |||
| 16 | + | require.NoError(t, err) | |||
| 17 | + | ||||
| 18 | + | mc := pki.GenerateMobileconfig(ca.RootCertificateDER(), ca.RootCommonName()) | |||
| 19 | + | assert.True(t, strings.HasPrefix(string(mc), "<?xml")) | |||
| 20 | + | assert.Contains(t, string(mc), "Gordon Internal CA") | |||
| 21 | + | assert.Contains(t, string(mc), "<key>PayloadType</key>") | |||
| 22 | + | assert.Contains(t, string(mc), "com.apple.security.root") | |||
| 23 | + | } | |||
A internal/adapters/out/pki/trust.go +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | package pki | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/x509" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/smallstep/truststore" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // InstallRoot installs the root CA certificate into the system trust store. | |
| 11 | + | func InstallRoot(cert *x509.Certificate) error { | |
| 12 | + | if err := truststore.Install(cert, | |
| 13 | + | truststore.WithFirefox(), | |
| 14 | + | truststore.WithJava(), | |
| 15 | + | ); err != nil { | |
| 16 | + | return fmt.Errorf("install root certificate: %w", err) | |
| 17 | + | } | |
| 18 | + | return nil | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // UninstallRoot removes the root CA certificate from the system trust store. | |
| 22 | + | func UninstallRoot(cert *x509.Certificate) error { | |
| 23 | + | if err := truststore.Uninstall(cert, | |
| 24 | + | truststore.WithFirefox(), | |
| 25 | + | truststore.WithJava(), | |
| 26 | + | ); err != nil { | |
| 27 | + | return fmt.Errorf("uninstall root certificate: %w", err) | |
| 28 | + | } | |
| 29 | + | return nil | |
| 30 | + | } | |
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | package pki | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/x509" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | "github.com/smallstep/truststore" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // InstallRoot installs the root CA certificate into the system trust store. | |||
| 11 | + | func InstallRoot(cert *x509.Certificate) error { | |||
| 12 | + | if err := truststore.Install(cert, | |||
| 13 | + | truststore.WithFirefox(), | |||
| 14 | + | truststore.WithJava(), | |||
| 15 | + | ); err != nil { | |||
| 16 | + | return fmt.Errorf("install root certificate: %w", err) | |||
| 17 | + | } | |||
| 18 | + | return nil | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // UninstallRoot removes the root CA certificate from the system trust store. | |||
| 22 | + | func UninstallRoot(cert *x509.Certificate) error { | |||
| 23 | + | if err := truststore.Uninstall(cert, | |||
| 24 | + | truststore.WithFirefox(), | |||
| 25 | + | truststore.WithJava(), | |||
| 26 | + | ); err != nil { | |||
| 27 | + | return fmt.Errorf("uninstall root certificate: %w", err) | |||
| 28 | + | } | |||
| 29 | + | return nil | |||
| 30 | + | } | |||
A internal/app/pki_integration_test.go +128 -0
| @@ -0,0 +1,128 @@ | |||
| 1 | + | package app_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/tls" | |
| 6 | + | "crypto/x509" | |
| 7 | + | "net/http" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 12 | + | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 13 | + | "github.com/bnema/gordon/internal/domain" | |
| 14 | + | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | |
| 15 | + | "github.com/bnema/zerowrap" | |
| 16 | + | "github.com/stretchr/testify/assert" | |
| 17 | + | "github.com/stretchr/testify/mock" | |
| 18 | + | "github.com/stretchr/testify/require" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | func newRoutesMock(t *testing.T, domains ...string) *mocks.MockRouteChecker { | |
| 22 | + | m := mocks.NewMockRouteChecker(t) | |
| 23 | + | routes := make([]domain.Route, len(domains)) | |
| 24 | + | for i, d := range domains { | |
| 25 | + | routes[i] = domain.Route{Domain: d} | |
| 26 | + | } | |
| 27 | + | m.EXPECT().GetRoutes(mock.Anything).Return(routes).Maybe() | |
| 28 | + | m.EXPECT().GetExternalRoutes().Return(nil).Maybe() | |
| 29 | + | return m | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestTLSHandshake_OnDemandCert(t *testing.T) { | |
| 33 | + | dir := t.TempDir() | |
| 34 | + | log := zerowrap.Default() | |
| 35 | + | ||
| 36 | + | ca, err := pkiadapter.NewCA(dir, log) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | ||
| 39 | + | routes := newRoutesMock(t, "test.local") | |
| 40 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 41 | + | defer cancel() | |
| 42 | + | ||
| 43 | + | svc := pkiusecase.NewService(ctx, ca, routes, log) | |
| 44 | + | defer svc.Stop() | |
| 45 | + | ||
| 46 | + | // Start a TLS server using the PKI service | |
| 47 | + | tlsCfg := &tls.Config{ | |
| 48 | + | MinVersion: tls.VersionTLS12, | |
| 49 | + | GetCertificate: svc.GetCertificate, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | srv := &http.Server{ | |
| 53 | + | Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("ok")) }), | |
| 54 | + | TLSConfig: tlsCfg, | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | ln, err := tls.Listen("tcp", "127.0.0.1:0", tlsCfg) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | defer ln.Close() | |
| 60 | + | ||
| 61 | + | go srv.Serve(ln) | |
| 62 | + | defer srv.Close() | |
| 63 | + | ||
| 64 | + | // Build a client that trusts Gordon's root CA | |
| 65 | + | rootPool := x509.NewCertPool() | |
| 66 | + | rootPool.AppendCertsFromPEM(ca.RootCertificate()) | |
| 67 | + | ||
| 68 | + | client := &http.Client{ | |
| 69 | + | Transport: &http.Transport{ | |
| 70 | + | TLSClientConfig: &tls.Config{ | |
| 71 | + | MinVersion: tls.VersionTLS12, | |
| 72 | + | RootCAs: rootPool, | |
| 73 | + | ServerName: "test.local", | |
| 74 | + | }, | |
| 75 | + | }, | |
| 76 | + | Timeout: 5 * time.Second, | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | resp, err := client.Get("https://" + ln.Addr().String() + "/") | |
| 80 | + | require.NoError(t, err) | |
| 81 | + | defer resp.Body.Close() | |
| 82 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestTLSHandshake_UnknownDomain_Rejected(t *testing.T) { | |
| 86 | + | dir := t.TempDir() | |
| 87 | + | log := zerowrap.Default() | |
| 88 | + | ||
| 89 | + | ca, err := pkiadapter.NewCA(dir, log) | |
| 90 | + | require.NoError(t, err) | |
| 91 | + | ||
| 92 | + | routes := newRoutesMock(t, "allowed.local") | |
| 93 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 94 | + | defer cancel() | |
| 95 | + | ||
| 96 | + | svc := pkiusecase.NewService(ctx, ca, routes, log) | |
| 97 | + | defer svc.Stop() | |
| 98 | + | ||
| 99 | + | tlsCfg := &tls.Config{ | |
| 100 | + | MinVersion: tls.VersionTLS12, | |
| 101 | + | GetCertificate: svc.GetCertificate, | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | ln, err := tls.Listen("tcp", "127.0.0.1:0", tlsCfg) | |
| 105 | + | require.NoError(t, err) | |
| 106 | + | defer ln.Close() | |
| 107 | + | ||
| 108 | + | srv := &http.Server{Handler: http.NotFoundHandler(), TLSConfig: tlsCfg} | |
| 109 | + | go srv.Serve(ln) | |
| 110 | + | defer srv.Close() | |
| 111 | + | ||
| 112 | + | rootPool := x509.NewCertPool() | |
| 113 | + | rootPool.AppendCertsFromPEM(ca.RootCertificate()) | |
| 114 | + | ||
| 115 | + | client := &http.Client{ | |
| 116 | + | Transport: &http.Transport{ | |
| 117 | + | TLSClientConfig: &tls.Config{ | |
| 118 | + | MinVersion: tls.VersionTLS12, | |
| 119 | + | RootCAs: rootPool, | |
| 120 | + | ServerName: "unknown.local", // not in route table | |
| 121 | + | }, | |
| 122 | + | }, | |
| 123 | + | Timeout: 5 * time.Second, | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | _, err = client.Get("https://" + ln.Addr().String() + "/") | |
| 127 | + | assert.Error(t, err, "TLS handshake should fail for unknown domain") | |
| 128 | + | } | |
| @@ -0,0 +1,128 @@ | |||||
| 1 | + | package app_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/tls" | |||
| 6 | + | "crypto/x509" | |||
| 7 | + | "net/http" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |||
| 12 | + | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 13 | + | "github.com/bnema/gordon/internal/domain" | |||
| 14 | + | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | |||
| 15 | + | "github.com/bnema/zerowrap" | |||
| 16 | + | "github.com/stretchr/testify/assert" | |||
| 17 | + | "github.com/stretchr/testify/mock" | |||
| 18 | + | "github.com/stretchr/testify/require" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | func newRoutesMock(t *testing.T, domains ...string) *mocks.MockRouteChecker { | |||
| 22 | + | m := mocks.NewMockRouteChecker(t) | |||
| 23 | + | routes := make([]domain.Route, len(domains)) | |||
| 24 | + | for i, d := range domains { | |||
| 25 | + | routes[i] = domain.Route{Domain: d} | |||
| 26 | + | } | |||
| 27 | + | m.EXPECT().GetRoutes(mock.Anything).Return(routes).Maybe() | |||
| 28 | + | m.EXPECT().GetExternalRoutes().Return(nil).Maybe() | |||
| 29 | + | return m | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestTLSHandshake_OnDemandCert(t *testing.T) { | |||
| 33 | + | dir := t.TempDir() | |||
| 34 | + | log := zerowrap.Default() | |||
| 35 | + | ||||
| 36 | + | ca, err := pkiadapter.NewCA(dir, log) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | ||||
| 39 | + | routes := newRoutesMock(t, "test.local") | |||
| 40 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 41 | + | defer cancel() | |||
| 42 | + | ||||
| 43 | + | svc := pkiusecase.NewService(ctx, ca, routes, log) | |||
| 44 | + | defer svc.Stop() | |||
| 45 | + | ||||
| 46 | + | // Start a TLS server using the PKI service | |||
| 47 | + | tlsCfg := &tls.Config{ | |||
| 48 | + | MinVersion: tls.VersionTLS12, | |||
| 49 | + | GetCertificate: svc.GetCertificate, | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | srv := &http.Server{ | |||
| 53 | + | Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("ok")) }), | |||
| 54 | + | TLSConfig: tlsCfg, | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | ln, err := tls.Listen("tcp", "127.0.0.1:0", tlsCfg) | |||
| 58 | + | require.NoError(t, err) | |||
| 59 | + | defer ln.Close() | |||
| 60 | + | ||||
| 61 | + | go srv.Serve(ln) | |||
| 62 | + | defer srv.Close() | |||
| 63 | + | ||||
| 64 | + | // Build a client that trusts Gordon's root CA | |||
| 65 | + | rootPool := x509.NewCertPool() | |||
| 66 | + | rootPool.AppendCertsFromPEM(ca.RootCertificate()) | |||
| 67 | + | ||||
| 68 | + | client := &http.Client{ | |||
| 69 | + | Transport: &http.Transport{ | |||
| 70 | + | TLSClientConfig: &tls.Config{ | |||
| 71 | + | MinVersion: tls.VersionTLS12, | |||
| 72 | + | RootCAs: rootPool, | |||
| 73 | + | ServerName: "test.local", | |||
| 74 | + | }, | |||
| 75 | + | }, | |||
| 76 | + | Timeout: 5 * time.Second, | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | resp, err := client.Get("https://" + ln.Addr().String() + "/") | |||
| 80 | + | require.NoError(t, err) | |||
| 81 | + | defer resp.Body.Close() | |||
| 82 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestTLSHandshake_UnknownDomain_Rejected(t *testing.T) { | |||
| 86 | + | dir := t.TempDir() | |||
| 87 | + | log := zerowrap.Default() | |||
| 88 | + | ||||
| 89 | + | ca, err := pkiadapter.NewCA(dir, log) | |||
| 90 | + | require.NoError(t, err) | |||
| 91 | + | ||||
| 92 | + | routes := newRoutesMock(t, "allowed.local") | |||
| 93 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 94 | + | defer cancel() | |||
| 95 | + | ||||
| 96 | + | svc := pkiusecase.NewService(ctx, ca, routes, log) | |||
| 97 | + | defer svc.Stop() | |||
| 98 | + | ||||
| 99 | + | tlsCfg := &tls.Config{ | |||
| 100 | + | MinVersion: tls.VersionTLS12, | |||
| 101 | + | GetCertificate: svc.GetCertificate, | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | ln, err := tls.Listen("tcp", "127.0.0.1:0", tlsCfg) | |||
| 105 | + | require.NoError(t, err) | |||
| 106 | + | defer ln.Close() | |||
| 107 | + | ||||
| 108 | + | srv := &http.Server{Handler: http.NotFoundHandler(), TLSConfig: tlsCfg} | |||
| 109 | + | go srv.Serve(ln) | |||
| 110 | + | defer srv.Close() | |||
| 111 | + | ||||
| 112 | + | rootPool := x509.NewCertPool() | |||
| 113 | + | rootPool.AppendCertsFromPEM(ca.RootCertificate()) | |||
| 114 | + | ||||
| 115 | + | client := &http.Client{ | |||
| 116 | + | Transport: &http.Transport{ | |||
| 117 | + | TLSClientConfig: &tls.Config{ | |||
| 118 | + | MinVersion: tls.VersionTLS12, | |||
| 119 | + | RootCAs: rootPool, | |||
| 120 | + | ServerName: "unknown.local", // not in route table | |||
| 121 | + | }, | |||
| 122 | + | }, | |||
| 123 | + | Timeout: 5 * time.Second, | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | _, err = client.Get("https://" + ln.Addr().String() + "/") | |||
| 127 | + | assert.Error(t, err, "TLS handshake should fail for unknown domain") | |||
| 128 | + | } | |||
M internal/app/run.go +273 -288
| @@ -3,18 +3,12 @@ package app | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | - | "crypto/ecdsa" | |
| 7 | - | "crypto/elliptic" | |
| 8 | 6 | "crypto/rand" | |
| 9 | 7 | "crypto/tls" | |
| 10 | - | "crypto/x509" | |
| 11 | - | "crypto/x509/pkix" | |
| 12 | 8 | "encoding/hex" | |
| 13 | 9 | "encoding/json" | |
| 14 | - | "encoding/pem" | |
| 15 | 10 | "errors" | |
| 16 | 11 | "fmt" | |
| 17 | - | "math/big" | |
| 18 | 12 | "net" | |
| 19 | 13 | "net/http" | |
| 20 | 14 | "os" | |
| @@ -38,6 +32,7 @@ import ( | |||
| 38 | 32 | "github.com/bnema/gordon/internal/adapters/out/filesystem" | |
| 39 | 33 | "github.com/bnema/gordon/internal/adapters/out/httpprober" | |
| 40 | 34 | "github.com/bnema/gordon/internal/adapters/out/logwriter" | |
| 35 | + | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 41 | 36 | "github.com/bnema/gordon/internal/adapters/out/ratelimit" | |
| 42 | 37 | "github.com/bnema/gordon/internal/adapters/out/secrets" | |
| 43 | 38 | "github.com/bnema/gordon/internal/adapters/out/telemetry" | |
| @@ -51,6 +46,7 @@ import ( | |||
| 51 | 46 | "github.com/bnema/gordon/internal/adapters/in/http/admin" | |
| 52 | 47 | authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth" | |
| 53 | 48 | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |
| 49 | + | "github.com/bnema/gordon/internal/adapters/in/http/onboarding" | |
| 54 | 50 | proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy" | |
| 55 | 51 | "github.com/bnema/gordon/internal/adapters/in/http/registry" | |
| 56 | 52 | ||
| @@ -74,6 +70,7 @@ import ( | |||
| 74 | 70 | "github.com/bnema/gordon/internal/usecase/health" | |
| 75 | 71 | "github.com/bnema/gordon/internal/usecase/images" | |
| 76 | 72 | "github.com/bnema/gordon/internal/usecase/logs" | |
| 73 | + | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | |
| 77 | 74 | "github.com/bnema/gordon/internal/usecase/proxy" | |
| 78 | 75 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | |
| 79 | 76 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | |
| @@ -91,10 +88,10 @@ type Config struct { | |||
| 91 | 88 | RegistryPort int `mapstructure:"registry_port"` | |
| 92 | 89 | GordonDomain string `mapstructure:"gordon_domain"` | |
| 93 | 90 | RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain | |
| 94 | - | TLSEnabled bool `mapstructure:"tls_enabled"` | |
| 95 | 91 | TLSPort int `mapstructure:"tls_port"` | |
| 96 | 92 | TLSCertFile string `mapstructure:"tls_cert_file"` | |
| 97 | 93 | TLSKeyFile string `mapstructure:"tls_key_file"` | |
| 94 | + | ForceHTTPSRedirect bool `mapstructure:"force_https_redirect"` | |
| 98 | 95 | DataDir string `mapstructure:"data_dir"` | |
| 99 | 96 | MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" | |
| 100 | 97 | MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB" | |
| @@ -103,7 +100,6 @@ type Config struct { | |||
| 103 | 100 | RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"` | |
| 104 | 101 | ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"` | |
| 105 | 102 | RegistryListenAddr string `mapstructure:"registry_listen_address"` | |
| 106 | - | ForceHSTS bool `mapstructure:"force_hsts"` | |
| 107 | 103 | } `mapstructure:"server"` | |
| 108 | 104 | ||
| 109 | 105 | Logging struct { | |
| @@ -207,6 +203,8 @@ type services struct { | |||
| 207 | 203 | previewService *preview.Service | |
| 208 | 204 | envDir string | |
| 209 | 205 | maxBlobChunkSize int64 | |
| 206 | + | caAdapter *pkiadapter.CA | |
| 207 | + | pkiSvc *pkiusecase.Service | |
| 210 | 208 | } | |
| 211 | 209 | ||
| 212 | 210 | // Run initializes and starts the Gordon application. | |
| @@ -252,8 +250,11 @@ func Run(ctx context.Context, configPath string) error { | |||
| 252 | 250 | ||
| 253 | 251 | log.Info().Msg("Gordon starting") | |
| 254 | 252 | ||
| 255 | - | if err := ensureTLSConfig(&cfg, log); err != nil { | |
| 256 | - | return err | |
| 253 | + | // Deprecation warnings for removed TLS config fields | |
| 254 | + | for _, key := range []string{"server.tls_enabled", "server.force_hsts"} { | |
| 255 | + | if v.IsSet(key) { | |
| 256 | + | log.Warn().Str("key", key).Msg("deprecated config key — Gordon now uses an internal CA with automatic TLS; remove this from your config") | |
| 257 | + | } | |
| 257 | 258 | } | |
| 258 | 259 | ||
| 259 | 260 | // Create PID file | |
| @@ -285,128 +286,8 @@ func Run(ctx context.Context, configPath string) error { | |||
| 285 | 286 | } | |
| 286 | 287 | defer svc.eventBus.Stop() | |
| 287 | 288 | ||
| 288 | - | // Create HTTP handlers | |
| 289 | - | registryHandler, proxyHandler, proxyCIDRMiddleware := createHTTPHandlers(svc, cfg, log) | |
| 290 | - | ||
| 291 | 289 | // Start servers, wait for listeners to bind, then sync/auto-start containers. | |
| 292 | - | return runServers(ctx, v, cfg, registryHandler, proxyHandler, proxyCIDRMiddleware, svc, cleanupHandlers, log) | |
| 293 | - | } | |
| 294 | - | ||
| 295 | - | func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error { | |
| 296 | - | if !cfg.Server.TLSEnabled { | |
| 297 | - | return nil | |
| 298 | - | } | |
| 299 | - | ||
| 300 | - | if cfg.Server.TLSCertFile != "" && cfg.Server.TLSKeyFile != "" { | |
| 301 | - | return nil | |
| 302 | - | } | |
| 303 | - | ||
| 304 | - | dataDir := resolveDataDir(cfg.Server.DataDir) | |
| 305 | - | tlsDir := filepath.Join(dataDir, "tls") | |
| 306 | - | certPath := filepath.Join(tlsDir, "cert.pem") | |
| 307 | - | keyPath := filepath.Join(tlsDir, "key.pem") | |
| 308 | - | ||
| 309 | - | domainName := cfg.Server.GordonDomain | |
| 310 | - | if domainName == "" { | |
| 311 | - | domainName = cfg.Server.RegistryDomain | |
| 312 | - | } | |
| 313 | - | if domainName == "" { | |
| 314 | - | domainName = "localhost" | |
| 315 | - | } | |
| 316 | - | ||
| 317 | - | if err := generateSelfSignedTLSCert(certPath, keyPath, domainName); err != nil { | |
| 318 | - | return fmt.Errorf("failed to generate self-signed TLS certificate: %w", err) | |
| 319 | - | } | |
| 320 | - | ||
| 321 | - | cfg.Server.TLSCertFile = certPath | |
| 322 | - | cfg.Server.TLSKeyFile = keyPath | |
| 323 | - | ||
| 324 | - | log.Warn(). | |
| 325 | - | Str("cert_file", certPath). | |
| 326 | - | Str("key_file", keyPath). | |
| 327 | - | Str("domain", domainName). | |
| 328 | - | Msg("server.tls_enabled=true with empty tls_cert_file/tls_key_file; using auto-generated self-signed certificate") | |
| 329 | - | ||
| 330 | - | return nil | |
| 331 | - | } | |
| 332 | - | ||
| 333 | - | func generateSelfSignedTLSCert(certPath, keyPath, domainName string) error { | |
| 334 | - | // Reuse existing pair if already generated. | |
| 335 | - | if _, err := os.Stat(certPath); err == nil { | |
| 336 | - | if _, keyErr := os.Stat(keyPath); keyErr == nil { | |
| 337 | - | return nil | |
| 338 | - | } | |
| 339 | - | } | |
| 340 | - | ||
| 341 | - | if err := os.MkdirAll(filepath.Dir(certPath), 0700); err != nil { | |
| 342 | - | return err | |
| 343 | - | } | |
| 344 | - | if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil { | |
| 345 | - | return err | |
| 346 | - | } | |
| 347 | - | ||
| 348 | - | priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| 349 | - | if err != nil { | |
| 350 | - | return err | |
| 351 | - | } | |
| 352 | - | ||
| 353 | - | now := time.Now() | |
| 354 | - | serialLimit := new(big.Int).Lsh(big.NewInt(1), 128) | |
| 355 | - | serialNumber, err := rand.Int(rand.Reader, serialLimit) | |
| 356 | - | if err != nil { | |
| 357 | - | return err | |
| 358 | - | } | |
| 359 | - | ||
| 360 | - | template := x509.Certificate{ | |
| 361 | - | SerialNumber: serialNumber, | |
| 362 | - | Subject: pkix.Name{ | |
| 363 | - | CommonName: domainName, | |
| 364 | - | Organization: []string{"Gordon Auto-Generated TLS"}, | |
| 365 | - | }, | |
| 366 | - | NotBefore: now.Add(-5 * time.Minute), | |
| 367 | - | NotAfter: now.AddDate(1, 0, 0), | |
| 368 | - | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, | |
| 369 | - | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, | |
| 370 | - | BasicConstraintsValid: true, | |
| 371 | - | } | |
| 372 | - | ||
| 373 | - | if ip := net.ParseIP(domainName); ip != nil { | |
| 374 | - | template.IPAddresses = []net.IP{ip} | |
| 375 | - | } else { | |
| 376 | - | template.DNSNames = []string{domainName} | |
| 377 | - | } | |
| 378 | - | ||
| 379 | - | derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) | |
| 380 | - | if err != nil { | |
| 381 | - | return err | |
| 382 | - | } | |
| 383 | - | ||
| 384 | - | certOut, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | |
| 385 | - | if err != nil { | |
| 386 | - | return err | |
| 387 | - | } | |
| 388 | - | defer certOut.Close() | |
| 389 | - | ||
| 390 | - | if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { | |
| 391 | - | return err | |
| 392 | - | } | |
| 393 | - | ||
| 394 | - | keyBytes, err := x509.MarshalECPrivateKey(priv) | |
| 395 | - | if err != nil { | |
| 396 | - | return err | |
| 397 | - | } | |
| 398 | - | ||
| 399 | - | keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | |
| 400 | - | if err != nil { | |
| 401 | - | return err | |
| 402 | - | } | |
| 403 | - | defer keyOut.Close() | |
| 404 | - | ||
| 405 | - | if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}); err != nil { | |
| 406 | - | return err | |
| 407 | - | } | |
| 408 | - | ||
| 409 | - | return nil | |
| 290 | + | return runServers(ctx, v, cfg, svc, cleanupHandlers, log) | |
| 410 | 291 | } | |
| 411 | 292 | ||
| 412 | 293 | // initConfig loads configuration from file. | |
| @@ -461,119 +342,179 @@ func initLogger(cfg Config) (zerowrap.Logger, func(), error) { | |||
| 461 | 342 | return log, cleanup, nil | |
| 462 | 343 | } | |
| 463 | 344 | ||
| 345 | + | // serviceInit holds the shared context for service initialization helpers. | |
| 346 | + | type serviceInit struct { | |
| 347 | + | ctx context.Context | |
| 348 | + | v *viper.Viper | |
| 349 | + | cfg Config | |
| 350 | + | log zerowrap.Logger | |
| 351 | + | svc *services | |
| 352 | + | } | |
| 353 | + | ||
| 464 | 354 | // createServices creates all the application services. | |
| 465 | - | func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (*services, error) { | |
| 466 | - | svc := &services{} | |
| 355 | + | func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) { | |
| 356 | + | si := &serviceInit{ | |
| 357 | + | ctx: ctx, | |
| 358 | + | v: v, | |
| 359 | + | cfg: cfg, | |
| 360 | + | log: log, | |
| 361 | + | svc: &services{}, | |
| 362 | + | } | |
| 363 | + | defer func() { | |
| 364 | + | if retErr != nil && si.svc.pkiSvc != nil { | |
| 365 | + | si.svc.pkiSvc.Stop() | |
| 366 | + | } | |
| 367 | + | }() | |
| 467 | 368 | var err error | |
| 468 | 369 | ||
| 469 | 370 | // Create output adapters | |
| 470 | 371 | runtimeSocket := resolveRuntimeConfig(v.GetString("server.runtime")) | |
| 471 | - | if svc.runtime, svc.eventBus, err = createOutputAdapters(ctx, log, runtimeSocket); err != nil { | |
| 372 | + | if si.svc.runtime, si.svc.eventBus, err = createOutputAdapters(ctx, log, runtimeSocket); err != nil { | |
| 472 | 373 | return nil, err | |
| 473 | 374 | } | |
| 474 | 375 | ||
| 475 | 376 | // Create storage | |
| 476 | - | if svc.blobStorage, svc.manifestStorage, err = createStorage(cfg, log); err != nil { | |
| 377 | + | if si.svc.blobStorage, si.svc.manifestStorage, err = createStorage(cfg, log); err != nil { | |
| 477 | 378 | return nil, err | |
| 478 | 379 | } | |
| 479 | 380 | ||
| 480 | 381 | // Create log writer | |
| 481 | - | if svc.logWriter, err = createLogWriter(cfg, log); err != nil { | |
| 382 | + | if si.svc.logWriter, err = createLogWriter(cfg, log); err != nil { | |
| 482 | 383 | return nil, err | |
| 483 | 384 | } | |
| 484 | 385 | ||
| 485 | 386 | // Create auth service (if enabled) | |
| 486 | - | if svc.tokenStore, svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil { | |
| 387 | + | if si.svc.tokenStore, si.svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil { | |
| 487 | 388 | return nil, err | |
| 488 | 389 | } | |
| 489 | 390 | ||
| 490 | - | if err := setupInternalRegistryAuth(svc, log); err != nil { | |
| 391 | + | if err := setupInternalRegistryAuth(si.svc, log); err != nil { | |
| 491 | 392 | return nil, err | |
| 492 | 393 | } | |
| 493 | 394 | ||
| 494 | - | // Create use case services | |
| 495 | - | svc.configSvc = config.NewService(v, svc.eventBus) | |
| 496 | - | if err := svc.configSvc.Load(ctx); err != nil { | |
| 395 | + | // Create config service | |
| 396 | + | si.svc.configSvc = config.NewService(v, si.svc.eventBus) | |
| 397 | + | if err := si.svc.configSvc.Load(ctx); err != nil { | |
| 497 | 398 | return nil, log.WrapErr(err, "failed to load configuration") | |
| 498 | 399 | } | |
| 499 | 400 | ||
| 500 | - | var backend domain.SecretsBackend | |
| 501 | - | var passStore *domainsecrets.PassStore | |
| 502 | - | var domainSecretStore out.DomainSecretStore | |
| 401 | + | if err := si.initPKI(); err != nil { | |
| 402 | + | return nil, err | |
| 403 | + | } | |
| 503 | 404 | ||
| 504 | - | svc.envDir, backend, passStore, domainSecretStore, err = createDomainSecretStore(cfg, log) | |
| 505 | - | if err != nil { | |
| 405 | + | if err := si.initSecrets(); err != nil { | |
| 506 | 406 | return nil, err | |
| 507 | 407 | } | |
| 508 | 408 | ||
| 509 | - | if svc.envLoader, err = createEnvLoader(backend, svc.envDir, passStore, log); err != nil { | |
| 409 | + | if err := si.initRuntimeAndProxy(); err != nil { | |
| 510 | 410 | return nil, err | |
| 511 | 411 | } | |
| 512 | 412 | ||
| 513 | - | svc.secretSvc = secretsSvc.NewService(domainSecretStore, log, svc.eventBus) | |
| 413 | + | si.initHandlers() | |
| 514 | 414 | ||
| 515 | - | if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil { | |
| 516 | - | return nil, err | |
| 415 | + | return si.svc, nil | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | // initPKI initialises the internal CA and PKI service when TLS is enabled. | |
| 419 | + | func (si *serviceInit) initPKI() error { | |
| 420 | + | if si.cfg.Server.TLSPort == 0 { | |
| 421 | + | si.log.Info().Msg("internal CA disabled (server.tls_port=0)") | |
| 422 | + | return nil | |
| 517 | 423 | } | |
| 518 | 424 | ||
| 519 | - | if svc.backupStorage, svc.backupSvc, err = createBackupService(cfg, svc, log); err != nil { | |
| 520 | - | return nil, err | |
| 425 | + | if (si.cfg.Server.TLSCertFile == "") != (si.cfg.Server.TLSKeyFile == "") { | |
| 426 | + | return fmt.Errorf("both tls_cert_file and tls_key_file must be set, or neither") | |
| 521 | 427 | } | |
| 522 | 428 | ||
| 523 | - | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | |
| 524 | - | svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log) | |
| 525 | - | svc.volumeSvc = volumesSvc.NewService(svc.runtime) | |
| 429 | + | caAdapter, err := pkiadapter.NewCA(resolveDataDir(si.cfg.Server.DataDir), si.log) | |
| 430 | + | if err != nil { | |
| 431 | + | return si.log.WrapErr(err, "failed to initialize internal CA") | |
| 432 | + | } | |
| 433 | + | si.svc.caAdapter = caAdapter | |
| 434 | + | si.svc.pkiSvc = pkiusecase.NewService(si.ctx, caAdapter, si.svc.configSvc, si.log) | |
| 435 | + | return nil | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | // initSecrets creates the domain secret store, env loader, and secret service. | |
| 439 | + | func (si *serviceInit) initSecrets() error { | |
| 440 | + | envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log) | |
| 441 | + | if err != nil { | |
| 442 | + | return err | |
| 443 | + | } | |
| 444 | + | si.svc.envDir = envDir | |
| 445 | + | ||
| 446 | + | if si.svc.envLoader, err = createEnvLoader(backend, envDir, passStore, si.log); err != nil { | |
| 447 | + | return err | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | si.svc.secretSvc = secretsSvc.NewService(domainSecretStore, si.log, si.svc.eventBus) | |
| 451 | + | return nil | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | // initRuntimeAndProxy creates container, backup, registry, image, volume, and proxy services. | |
| 455 | + | func (si *serviceInit) initRuntimeAndProxy() error { | |
| 456 | + | var err error | |
| 457 | + | ||
| 458 | + | if si.svc.containerSvc, err = createContainerService(si.ctx, si.v, si.cfg, si.svc, si.log); err != nil { | |
| 459 | + | return err | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | if si.svc.backupStorage, si.svc.backupSvc, err = createBackupService(si.cfg, si.svc, si.log); err != nil { | |
| 463 | + | return err | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus) | |
| 467 | + | si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log) | |
| 468 | + | si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime) | |
| 526 | 469 | ||
| 527 | - | injectTelemetryMetrics(cfg, svc, log) | |
| 470 | + | injectTelemetryMetrics(si.cfg, si.svc, si.log) | |
| 528 | 471 | ||
| 529 | - | proxyCfg, err := buildProxyConfig(cfg, log) | |
| 472 | + | proxyCfg, err := buildProxyConfig(si.cfg, si.log) | |
| 530 | 473 | if err != nil { | |
| 531 | - | return nil, err | |
| 474 | + | return err | |
| 532 | 475 | } | |
| 533 | - | svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize | |
| 534 | - | svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxyCfg.proxyConfig) | |
| 476 | + | si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize | |
| 477 | + | si.svc.proxySvc = proxy.NewService(si.svc.runtime, si.svc.containerSvc, si.svc.configSvc, proxyCfg.proxyConfig) | |
| 535 | 478 | ||
| 536 | 479 | // Wire synchronous proxy cache invalidation for zero-downtime deployments. | |
| 537 | 480 | // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget(). | |
| 538 | - | svc.containerSvc.SetProxyCacheInvalidator(svc.proxySvc) | |
| 539 | - | svc.containerSvc.SetProxyDrainWaiter(svc.proxySvc) | |
| 481 | + | si.svc.containerSvc.SetProxyCacheInvalidator(si.svc.proxySvc) | |
| 482 | + | si.svc.containerSvc.SetProxyDrainWaiter(si.svc.proxySvc) | |
| 483 | + | return nil | |
| 484 | + | } | |
| 540 | 485 | ||
| 541 | - | // Create token handler for registry token endpoint | |
| 542 | - | if svc.authSvc != nil { | |
| 486 | + | // initHandlers creates the auth, health, log, preview, and admin handlers. | |
| 487 | + | func (si *serviceInit) initHandlers() { | |
| 488 | + | if si.svc.authSvc != nil { | |
| 543 | 489 | internalAuth := authhandler.InternalAuth{ | |
| 544 | - | Username: svc.internalRegUser, | |
| 545 | - | Password: svc.internalRegPass, | |
| 490 | + | Username: si.svc.internalRegUser, | |
| 491 | + | Password: si.svc.internalRegPass, | |
| 546 | 492 | } | |
| 547 | - | svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log) | |
| 493 | + | si.svc.authHandler = authhandler.NewHandler(si.svc.authSvc, internalAuth, si.log) | |
| 548 | 494 | } | |
| 549 | 495 | ||
| 550 | - | // Create health service for route health checking | |
| 551 | 496 | prober := httpprober.New() | |
| 552 | - | svc.healthSvc = health.NewService(svc.configSvc, svc.containerSvc, prober, log) | |
| 553 | - | ||
| 554 | - | // Create log service for accessing logs via admin API | |
| 555 | - | svc.logSvc = logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log) | |
| 556 | - | ||
| 557 | - | initPreviewService(ctx, cfg, svc, log) | |
| 558 | - | ||
| 559 | - | // Create admin handler for admin API | |
| 560 | - | svc.adminHandler = admin.NewHandler(admin.HandlerDeps{ | |
| 561 | - | ConfigSvc: svc.configSvc, | |
| 562 | - | AuthSvc: svc.authSvc, | |
| 563 | - | ContainerSvc: svc.containerSvc, | |
| 564 | - | HealthSvc: svc.healthSvc, | |
| 565 | - | SecretSvc: svc.secretSvc, | |
| 566 | - | LogSvc: svc.logSvc, | |
| 567 | - | RegistrySvc: svc.registrySvc, | |
| 568 | - | EventBus: svc.eventBus, | |
| 569 | - | Log: log, | |
| 570 | - | BackupSvc: svc.backupSvc, | |
| 571 | - | PreviewSvc: svc.previewService, | |
| 572 | - | ImageSvc: svc.imageSvc, | |
| 573 | - | VolumeSvc: svc.volumeSvc, | |
| 497 | + | si.svc.healthSvc = health.NewService(si.svc.configSvc, si.svc.containerSvc, prober, si.log) | |
| 498 | + | ||
| 499 | + | si.svc.logSvc = logs.NewService(resolveLogFilePath(si.cfg), si.cfg.Logging.File.Enabled, si.svc.containerSvc, si.svc.runtime, si.log) | |
| 500 | + | ||
| 501 | + | initPreviewService(si.ctx, si.cfg, si.svc, si.log) | |
| 502 | + | ||
| 503 | + | si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{ | |
| 504 | + | ConfigSvc: si.svc.configSvc, | |
| 505 | + | AuthSvc: si.svc.authSvc, | |
| 506 | + | ContainerSvc: si.svc.containerSvc, | |
| 507 | + | HealthSvc: si.svc.healthSvc, | |
| 508 | + | SecretSvc: si.svc.secretSvc, | |
| 509 | + | LogSvc: si.svc.logSvc, | |
| 510 | + | RegistrySvc: si.svc.registrySvc, | |
| 511 | + | EventBus: si.svc.eventBus, | |
| 512 | + | Log: si.log, | |
| 513 | + | BackupSvc: si.svc.backupSvc, | |
| 514 | + | PreviewSvc: si.svc.previewService, | |
| 515 | + | ImageSvc: si.svc.imageSvc, | |
| 516 | + | VolumeSvc: si.svc.volumeSvc, | |
| 574 | 517 | }) | |
| 575 | - | ||
| 576 | - | return svc, nil | |
| 577 | 518 | } | |
| 578 | 519 | ||
| 579 | 520 | // initPreviewService sets up the preview store, service, and TTL ticker. | |
| @@ -1669,13 +1610,13 @@ func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler { | |||
| 1669 | 1610 | } | |
| 1670 | 1611 | ||
| 1671 | 1612 | // createHTTPHandlers creates HTTP handlers with middleware. | |
| 1672 | - | // The returned proxyCIDRMiddleware is non-nil when proxy_allowed_ips is configured; | |
| 1673 | - | // callers must also apply it to the HTTP-to-HTTPS redirect listener. | |
| 1674 | - | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler, func(http.Handler) http.Handler) { | |
| 1613 | + | // Returns three handlers: registry, HTTP proxy (with CIDR + onboarding), and HTTPS proxy. | |
| 1614 | + | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler, http.Handler) { | |
| 1675 | 1615 | // Parse trusted proxies once for all middleware chains. | |
| 1676 | 1616 | // This ensures consistent IP extraction across logging, rate limiting, and auth. | |
| 1677 | 1617 | trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | |
| 1678 | 1618 | ||
| 1619 | + | // Registry handler (unchanged) | |
| 1679 | 1620 | registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize) | |
| 1680 | 1621 | registryWithMiddleware, cidrAllowlistMiddleware, rateLimitMiddleware := buildRegistryHandlerWithMiddleware( | |
| 1681 | 1622 | svc, | |
| @@ -1688,36 +1629,65 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1688 | 1629 | registryMux := http.NewServeMux() | |
| 1689 | 1630 | registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, cfg, log) | |
| 1690 | 1631 | registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log)) | |
| 1632 | + | registerAdminRoutes(registryMux, svc, cfg, trustedNets, log) | |
| 1633 | + | ||
| 1634 | + | // Proxy handler | |
| 1635 | + | proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log) | |
| 1636 | + | ||
| 1637 | + | // HTTP proxy handler chain: HTTPS redirect for non-proxy clients, then CIDR allowlist | |
| 1638 | + | proxyAllowedNets, proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log) | |
| 1691 | 1639 | ||
| 1692 | - | // SECURITY: No CORS middleware on the proxy chain. Backend applications | |
| 1693 | - | // should control their own CORS policies. A blanket Access-Control-Allow-Origin: * | |
| 1694 | - | // would override backend CORS settings and allow any website to make | |
| 1695 | - | // cross-origin authenticated requests to proxied applications. | |
| 1696 | - | proxyMiddlewares := []func(http.Handler) http.Handler{ | |
| 1640 | + | httpProxyMiddlewares := []func(http.Handler) http.Handler{ | |
| 1697 | 1641 | middleware.PanicRecovery(log), | |
| 1698 | 1642 | middleware.RequestLogger(log, trustedNets), | |
| 1699 | - | middleware.SecurityHeadersWithOptions(cfg.Server.ForceHSTS), | |
| 1643 | + | middleware.SecurityHeaders, | |
| 1644 | + | middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log), | |
| 1700 | 1645 | } | |
| 1701 | - | ||
| 1702 | - | // SECURITY: When proxy_allowed_ips is set, only connections from those CIDRs | |
| 1703 | - | // can reach the proxy. This prevents direct-to-origin attacks that bypass | |
| 1704 | - | // CDN protections (e.g. Cloudflare WAF, DDoS mitigation). | |
| 1705 | - | proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log) | |
| 1706 | 1646 | if proxyCIDRMiddleware != nil { | |
| 1707 | - | proxyMiddlewares = append(proxyMiddlewares, proxyCIDRMiddleware) | |
| 1647 | + | httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware) | |
| 1708 | 1648 | } | |
| 1709 | 1649 | ||
| 1710 | - | proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log) | |
| 1711 | - | proxyWithMiddleware := otelhttp.NewHandler( | |
| 1712 | - | middleware.Chain(proxyMiddlewares...)(proxyHandler), | |
| 1650 | + | httpProxyWithMiddleware := otelhttp.NewHandler( | |
| 1651 | + | middleware.Chain(httpProxyMiddlewares...)(proxyHandler), | |
| 1713 | 1652 | "gordon.proxy", | |
| 1714 | 1653 | ) | |
| 1654 | + | ||
| 1655 | + | // HTTP proxyMux: Cloudflare-proxied traffic only, no onboarding | |
| 1715 | 1656 | proxyMux := http.NewServeMux() | |
| 1716 | - | proxyMux.Handle("/", proxyWithMiddleware) | |
| 1657 | + | proxyMux.Handle("/", httpProxyWithMiddleware) | |
| 1658 | + | ||
| 1659 | + | // HTTPS proxy handler chain: security headers + proxy + CA onboarding | |
| 1660 | + | // Onboarding routes live on the TLS port so Tailnet / direct clients | |
| 1661 | + | // can click through the initial cert warning, install the CA, and | |
| 1662 | + | // then trust all subsequent connections. | |
| 1663 | + | // The middleware chain wraps the entire mux so onboarding routes also | |
| 1664 | + | // get PanicRecovery, RequestLogger, and SecurityHeaders. | |
| 1665 | + | httpsProxyMiddlewares := []func(http.Handler) http.Handler{ | |
| 1666 | + | middleware.PanicRecovery(log), | |
| 1667 | + | middleware.RequestLogger(log, trustedNets), | |
| 1668 | + | middleware.SecurityHeaders, | |
| 1669 | + | } | |
| 1717 | 1670 | ||
| 1718 | - | registerAdminRoutes(registryMux, svc, cfg, trustedNets, log) | |
| 1671 | + | httpsMux := http.NewServeMux() | |
| 1672 | + | if svc.caAdapter != nil { | |
| 1673 | + | mobileconfigBytes := pkiadapter.GenerateMobileconfig( | |
| 1674 | + | svc.caAdapter.RootCertificateDER(), | |
| 1675 | + | svc.caAdapter.RootCommonName(), | |
| 1676 | + | ) | |
| 1677 | + | onboardingHandler := onboarding.NewHandler( | |
| 1678 | + | svc.caAdapter.RootCertificate(), | |
| 1679 | + | mobileconfigBytes, | |
| 1680 | + | cfg.Server.TLSPort, | |
| 1681 | + | ) | |
| 1682 | + | httpsMux.HandleFunc("GET /ca", onboardingHandler.ServeOnboardingPage) | |
| 1683 | + | httpsMux.HandleFunc("GET /ca.crt", onboardingHandler.ServeCACert) | |
| 1684 | + | httpsMux.HandleFunc("GET /ca.mobileconfig", onboardingHandler.ServeMobileconfig) | |
| 1685 | + | } | |
| 1686 | + | httpsMux.Handle("/", proxyHandler) | |
| 1687 | + | ||
| 1688 | + | httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls") | |
| 1719 | 1689 | ||
| 1720 | - | return registryMux, proxyMux, proxyCIDRMiddleware | |
| 1690 | + | return registryMux, proxyMux, httpsHandler | |
| 1721 | 1691 | } | |
| 1722 | 1692 | ||
| 1723 | 1693 | func buildRegistryHandlerWithMiddleware( | |
| @@ -1801,20 +1771,20 @@ func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, | |||
| 1801 | 1771 | return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log) | |
| 1802 | 1772 | } | |
| 1803 | 1773 | ||
| 1804 | - | func buildProxyCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 1774 | + | func buildProxyCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) ([]*net.IPNet, func(http.Handler) http.Handler) { | |
| 1805 | 1775 | allowedNets, allInvalid := parseCIDRAllowlist(cfg.Server.ProxyAllowedIPs, "proxy_allowed_ips", log) | |
| 1806 | 1776 | if allInvalid { | |
| 1807 | - | return denyAllHandler("proxy_allowed_ips", trustedNets, log) | |
| 1777 | + | return nil, denyAllHandler("proxy_allowed_ips", trustedNets, log) | |
| 1808 | 1778 | } | |
| 1809 | 1779 | if allowedNets == nil { | |
| 1810 | - | return nil | |
| 1780 | + | return nil, nil | |
| 1811 | 1781 | } | |
| 1812 | 1782 | ||
| 1813 | 1783 | log.Info(). | |
| 1814 | 1784 | Strs("proxy_allowed_ips", cfg.Server.ProxyAllowedIPs). | |
| 1815 | 1785 | Msg("proxy origin IP allowlist enabled") | |
| 1816 | 1786 | ||
| 1817 | - | return middleware.ProxyCIDRAllowlist(allowedNets, log) | |
| 1787 | + | return allowedNets, middleware.ProxyCIDRAllowlist(allowedNets, log) | |
| 1818 | 1788 | } | |
| 1819 | 1789 | ||
| 1820 | 1790 | func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| @@ -1949,7 +1919,7 @@ func registerAdminRoutes(registryMux *http.ServeMux, svc *services, cfg Config, | |||
| 1949 | 1919 | // - SIGUSR2: Triggers manual deploy for a specific route | |
| 1950 | 1920 | // The deferred signal.Stop calls ensure signal handlers are properly | |
| 1951 | 1921 | // cleaned up before program exit, preventing signal handler leaks. | |
| 1952 | - | func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler, proxyHandler http.Handler, proxyCIDRMiddleware func(http.Handler) http.Handler, svc *services, cleanupHandlers func(), log zerowrap.Logger) error { | |
| 1922 | + | func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, cleanupHandlers func(), log zerowrap.Logger) error { | |
| 1953 | 1923 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |
| 1954 | 1924 | defer cancel() | |
| 1955 | 1925 | ||
| @@ -1967,24 +1937,44 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||
| 1967 | 1937 | ||
| 1968 | 1938 | errChan := make(chan error, 3) | |
| 1969 | 1939 | ||
| 1940 | + | registryHandler, httpProxyHandler, httpsProxyHandler := createHTTPHandlers(svc, cfg, log) | |
| 1941 | + | ||
| 1970 | 1942 | registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort)) | |
| 1971 | 1943 | registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log) | |
| 1972 | 1944 | ||
| 1973 | - | proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, proxyHandler, proxyCIDRMiddleware, errChan, log) | |
| 1945 | + | // closeStarted shuts down any servers that were started before an error occurred, | |
| 1946 | + | // preventing leaked listeners during partial startup failures. | |
| 1947 | + | closeStarted := func(servers ...*http.Server) { | |
| 1948 | + | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 1949 | + | defer shutdownCancel() | |
| 1950 | + | for _, srv := range servers { | |
| 1951 | + | if srv != nil { | |
| 1952 | + | if err := srv.Shutdown(shutdownCtx); err != nil { | |
| 1953 | + | log.Error().Err(err).Msg("failed to shut down server during startup cleanup") | |
| 1954 | + | } | |
| 1955 | + | } | |
| 1956 | + | } | |
| 1957 | + | } | |
| 1958 | + | ||
| 1959 | + | proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, errChan, log) | |
| 1974 | 1960 | if err != nil { | |
| 1961 | + | closeStarted(registrySrv) | |
| 1975 | 1962 | return err | |
| 1976 | 1963 | } | |
| 1977 | 1964 | ||
| 1978 | - | // Wait for all enabled servers to bind their ports before auto-starting containers. | |
| 1965 | + | // Wait for all servers to bind their ports before auto-starting containers. | |
| 1979 | 1966 | // This prevents the race where auto-start pulls from the registry before it's listening. | |
| 1980 | 1967 | if err := waitForServerReady(registryReady, errChan); err != nil { | |
| 1968 | + | closeStarted(registrySrv, proxySrv, tlsSrv) | |
| 1981 | 1969 | return err | |
| 1982 | 1970 | } | |
| 1983 | 1971 | if err := waitForServerReady(proxyReady, errChan); err != nil { | |
| 1972 | + | closeStarted(registrySrv, proxySrv, tlsSrv) | |
| 1984 | 1973 | return err | |
| 1985 | 1974 | } | |
| 1986 | - | if cfg.Server.TLSEnabled { | |
| 1975 | + | if tlsReady != nil { | |
| 1987 | 1976 | if err := waitForServerReady(tlsReady, errChan); err != nil { | |
| 1977 | + | closeStarted(registrySrv, proxySrv, tlsSrv) | |
| 1988 | 1978 | return err | |
| 1989 | 1979 | } | |
| 1990 | 1980 | } | |
| @@ -1992,7 +1982,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||
| 1992 | 1982 | logEvent := log.Info(). | |
| 1993 | 1983 | Int("proxy_port", cfg.Server.Port). | |
| 1994 | 1984 | Int("registry_port", cfg.Server.RegistryPort) | |
| 1995 | - | if cfg.Server.TLSEnabled { | |
| 1985 | + | if tlsSrv != nil { | |
| 1996 | 1986 | logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort) | |
| 1997 | 1987 | } | |
| 1998 | 1988 | logEvent.Msg("Gordon is running") | |
| @@ -2010,7 +2000,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||
| 2010 | 2000 | ||
| 2011 | 2001 | waitForShutdown(ctx, errChan, reloadChan, deployChan, svc.eventBus, log) | |
| 2012 | 2002 | cleanupHandlers() // Stop debounce timers before draining containers | |
| 2013 | - | gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, log) | |
| 2003 | + | gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, log) | |
| 2014 | 2004 | return nil | |
| 2015 | 2005 | } | |
| 2016 | 2006 | ||
| @@ -2212,7 +2202,7 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl | |||
| 2212 | 2202 | ||
| 2213 | 2203 | // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down | |
| 2214 | 2204 | // the container service and cleans up runtime files. | |
| 2215 | - | func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, log zerowrap.Logger) { | |
| 2205 | + | func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, log zerowrap.Logger) { | |
| 2216 | 2206 | log.Info().Msg("shutting down Gordon...") | |
| 2217 | 2207 | ||
| 2218 | 2208 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| @@ -2228,6 +2218,11 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc * | |||
| 2228 | 2218 | } | |
| 2229 | 2219 | } | |
| 2230 | 2220 | ||
| 2221 | + | // Stop PKI maintenance goroutines | |
| 2222 | + | if pkiSvc != nil { | |
| 2223 | + | pkiSvc.Stop() | |
| 2224 | + | } | |
| 2225 | + | ||
| 2231 | 2226 | // Phase 2: Drain in-flight registry push sessions before stopping the backend | |
| 2232 | 2227 | if proxySvc != nil { | |
| 2233 | 2228 | log.Info().Msg("draining in-flight registry requests...") | |
| @@ -2253,56 +2248,54 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc * | |||
| 2253 | 2248 | log.Info().Msg("Gordon stopped") | |
| 2254 | 2249 | } | |
| 2255 | 2250 | ||
| 2256 | - | // startProxyServers sets up proxy server(s) depending on TLS configuration. | |
| 2257 | - | // With TLS enabled, the HTTP port becomes a redirect-only server and a separate TLS server handles traffic. | |
| 2258 | - | // Without TLS, a single server handles both HTTP/1.1 and cleartext HTTP/2. | |
| 2259 | - | // proxyCIDRMiddleware, when non-nil, is also applied to the HTTP redirect listener | |
| 2260 | - | // so that proxy_allowed_ips cannot be bypassed via the plain-HTTP port. | |
| 2261 | - | func startProxyServers(cfg Config, proxyHandler http.Handler, proxyCIDRMiddleware func(http.Handler) http.Handler, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { | |
| 2262 | - | if !cfg.Server.TLSEnabled { | |
| 2263 | - | var proxyProtos http.Protocols | |
| 2264 | - | proxyProtos.SetHTTP1(true) | |
| 2265 | - | proxyProtos.SetUnencryptedHTTP2(true) | |
| 2266 | - | srv, ready := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", &proxyProtos, errChan, log) | |
| 2267 | - | return srv, ready, nil, nil, nil | |
| 2268 | - | } | |
| 2251 | + | // startProxyServers sets up the HTTP proxy server and, when tls_port != 0, | |
| 2252 | + | // an HTTPS proxy server with on-demand TLS certificates from the internal CA. | |
| 2253 | + | func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { | |
| 2254 | + | // HTTP listener (Cloudflare proxy + onboarding for direct clients) | |
| 2255 | + | var httpProtos http.Protocols | |
| 2256 | + | httpProtos.SetHTTP1(true) | |
| 2257 | + | httpProtos.SetUnencryptedHTTP2(true) | |
| 2258 | + | httpSrv, httpReady := startServer( | |
| 2259 | + | fmt.Sprintf(":%d", cfg.Server.Port), | |
| 2260 | + | httpHandler, | |
| 2261 | + | "proxy-http", | |
| 2262 | + | &httpProtos, | |
| 2263 | + | errChan, | |
| 2264 | + | log, | |
| 2265 | + | ) | |
| 2269 | 2266 | ||
| 2270 | - | if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" { | |
| 2271 | - | return nil, nil, nil, nil, fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file") | |
| 2267 | + | if cfg.Server.TLSPort == 0 { | |
| 2268 | + | return httpSrv, httpReady, nil, nil, nil | |
| 2272 | 2269 | } | |
| 2273 | 2270 | ||
| 2274 | - | tlsPort := cfg.Server.TLSPort | |
| 2275 | - | redirectHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 2276 | - | host := r.Host | |
| 2277 | - | if h, _, err := net.SplitHostPort(host); err == nil { | |
| 2278 | - | host = h | |
| 2279 | - | } else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { | |
| 2280 | - | host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") | |
| 2281 | - | } | |
| 2282 | - | if tlsPort != 443 { | |
| 2283 | - | host = net.JoinHostPort(host, strconv.Itoa(tlsPort)) | |
| 2284 | - | } else if strings.Contains(host, ":") { | |
| 2285 | - | host = "[" + host + "]" | |
| 2271 | + | // HTTPS listener — static cert (if provided) takes priority by SNI match, | |
| 2272 | + | // unmatched domains fall through to on-demand internal CA certs. | |
| 2273 | + | tlsConfig := &tls.Config{ | |
| 2274 | + | MinVersion: tls.VersionTLS12, | |
| 2275 | + | GetCertificate: pkiSvc.GetCertificate, | |
| 2276 | + | NextProtos: []string{"h2", "http/1.1"}, | |
| 2277 | + | } | |
| 2278 | + | if cfg.Server.TLSCertFile != "" { | |
| 2279 | + | staticCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile) | |
| 2280 | + | if err != nil { | |
| 2281 | + | return nil, nil, nil, nil, fmt.Errorf("load TLS keypair: %w", err) | |
| 2286 | 2282 | } | |
| 2287 | - | target := "https://" + host + r.RequestURI | |
| 2288 | - | http.Redirect(w, r, target, http.StatusPermanentRedirect) | |
| 2289 | - | }) | |
| 2290 | - | ||
| 2291 | - | var redirectWithMiddleware http.Handler = redirectHandler | |
| 2292 | - | if proxyCIDRMiddleware != nil { | |
| 2293 | - | redirectWithMiddleware = proxyCIDRMiddleware(redirectHandler) | |
| 2283 | + | tlsConfig.Certificates = []tls.Certificate{staticCert} | |
| 2284 | + | log.Info(). | |
| 2285 | + | Str("cert", cfg.Server.TLSCertFile). | |
| 2286 | + | Str("key", cfg.Server.TLSKeyFile). | |
| 2287 | + | Msg("loaded static TLS certificate (internal CA handles remaining domains)") | |
| 2294 | 2288 | } | |
| 2295 | - | proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), redirectWithMiddleware, "proxy-redirect", nil, errChan, log) | |
| 2296 | - | tlsSrv, tlsReady := startTLSServer( | |
| 2289 | + | tlsSrv, tlsReady := startTLSServerWithConfig( | |
| 2297 | 2290 | fmt.Sprintf(":%d", cfg.Server.TLSPort), | |
| 2298 | - | proxyHandler, | |
| 2291 | + | httpsHandler, | |
| 2299 | 2292 | "proxy-tls", | |
| 2300 | - | cfg.Server.TLSCertFile, | |
| 2301 | - | cfg.Server.TLSKeyFile, | |
| 2293 | + | tlsConfig, | |
| 2302 | 2294 | errChan, | |
| 2303 | 2295 | log, | |
| 2304 | 2296 | ) | |
| 2305 | - | return proxySrv, proxyReady, tlsSrv, tlsReady, nil | |
| 2297 | + | ||
| 2298 | + | return httpSrv, httpReady, tlsSrv, tlsReady, nil | |
| 2306 | 2299 | } | |
| 2307 | 2300 | ||
| 2308 | 2301 | // startServer starts an HTTP server, returning the server instance and a channel | |
| @@ -2342,44 +2335,37 @@ func startServer(addr string, handler http.Handler, name string, protocols *http | |||
| 2342 | 2335 | return server, ready | |
| 2343 | 2336 | } | |
| 2344 | 2337 | ||
| 2345 | - | // startTLSServer starts an HTTPS server with the provided certificate and key. | |
| 2346 | - | // It returns the server instance (for graceful shutdown) and a channel that closes once the port is bound. | |
| 2347 | - | func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile string, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) { | |
| 2348 | - | ready := make(chan struct{}) | |
| 2349 | - | ||
| 2350 | - | server := &http.Server{ | |
| 2338 | + | // startTLSServerWithConfig starts an HTTPS server using a pre-built tls.Config | |
| 2339 | + | // (e.g. with GetCertificate for on-demand issuance via the internal CA). | |
| 2340 | + | // Returns the server instance and a channel that closes once the port is bound. | |
| 2341 | + | func startTLSServerWithConfig(addr string, handler http.Handler, name string, tlsCfg *tls.Config, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) { | |
| 2342 | + | srv := &http.Server{ | |
| 2351 | 2343 | Addr: addr, | |
| 2352 | 2344 | Handler: handler, | |
| 2345 | + | TLSConfig: tlsCfg, | |
| 2353 | 2346 | ReadHeaderTimeout: 10 * time.Second, | |
| 2354 | 2347 | ReadTimeout: 5 * time.Minute, | |
| 2355 | 2348 | WriteTimeout: 5 * time.Minute, | |
| 2356 | 2349 | IdleTimeout: 120 * time.Second, | |
| 2357 | 2350 | MaxHeaderBytes: 1 << 20, | |
| 2358 | - | TLSConfig: &tls.Config{ | |
| 2359 | - | MinVersion: tls.VersionTLS12, | |
| 2360 | - | }, | |
| 2361 | 2351 | } | |
| 2362 | 2352 | ||
| 2353 | + | ready := make(chan struct{}) | |
| 2363 | 2354 | go func() { | |
| 2364 | - | log.Info(). | |
| 2365 | - | Str("address", addr). | |
| 2366 | - | Str("cert_file", certFile). | |
| 2367 | - | Str("key_file", keyFile). | |
| 2368 | - | Msgf("%s server starting", name) | |
| 2369 | - | ||
| 2370 | 2355 | ln, err := net.Listen("tcp", addr) | |
| 2371 | 2356 | if err != nil { | |
| 2372 | - | errChan <- fmt.Errorf("%s server error: %w", name, err) | |
| 2357 | + | errChan <- fmt.Errorf("%s listen: %w", name, err) | |
| 2373 | 2358 | return | |
| 2374 | 2359 | } | |
| 2360 | + | tlsLn := tls.NewListener(ln, tlsCfg) | |
| 2361 | + | log.Info().Str("addr", addr).Str("name", name).Msg("TLS server starting") | |
| 2375 | 2362 | close(ready) | |
| 2376 | - | ||
| 2377 | - | if err := server.ServeTLS(ln, certFile, keyFile); err != nil && err != http.ErrServerClosed { | |
| 2378 | - | errChan <- fmt.Errorf("%s server error: %w", name, err) | |
| 2363 | + | if err := srv.Serve(tlsLn); err != nil && err != http.ErrServerClosed { | |
| 2364 | + | errChan <- fmt.Errorf("%s: %w", name, err) | |
| 2379 | 2365 | } | |
| 2380 | 2366 | }() | |
| 2381 | 2367 | ||
| 2382 | - | return server, ready | |
| 2368 | + | return srv, ready | |
| 2383 | 2369 | } | |
| 2384 | 2370 | ||
| 2385 | 2371 | // SendReloadSignal sends SIGUSR1 to the running Gordon process. | |
| @@ -2633,12 +2619,12 @@ func isProcessAlive(pid int) bool { | |||
| 2633 | 2619 | ||
| 2634 | 2620 | // loadConfig loads configuration from file and sets defaults. | |
| 2635 | 2621 | func loadConfig(v *viper.Viper, configPath string) error { | |
| 2636 | - | v.SetDefault("server.port", 80) | |
| 2622 | + | v.SetDefault("server.port", 8088) | |
| 2637 | 2623 | v.SetDefault("server.registry_port", 5000) | |
| 2638 | - | v.SetDefault("server.tls_enabled", false) | |
| 2639 | - | v.SetDefault("server.tls_port", 443) | |
| 2624 | + | v.SetDefault("server.tls_port", 8443) | |
| 2640 | 2625 | v.SetDefault("server.tls_cert_file", "") | |
| 2641 | 2626 | v.SetDefault("server.tls_key_file", "") | |
| 2627 | + | v.SetDefault("server.force_https_redirect", false) | |
| 2642 | 2628 | v.SetDefault("server.data_dir", DefaultDataDir()) | |
| 2643 | 2629 | v.SetDefault("server.runtime", "auto") | |
| 2644 | 2630 | v.SetDefault("logging.level", "info") | |
| @@ -2690,7 +2676,6 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 2690 | 2676 | v.SetDefault("server.registry_allowed_ips", []string{}) | |
| 2691 | 2677 | v.SetDefault("server.proxy_allowed_ips", []string{}) | |
| 2692 | 2678 | v.SetDefault("server.registry_listen_address", "") | |
| 2693 | - | v.SetDefault("server.force_hsts", false) | |
| 2694 | 2679 | v.SetDefault("deploy.readiness_delay", "5s") | |
| 2695 | 2680 | v.SetDefault("deploy.readiness_mode", "auto") | |
| 2696 | 2681 | v.SetDefault("deploy.health_timeout", "90s") | |
| @@ -3,18 +3,12 @@ package app | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | - | "crypto/ecdsa" | |||
| 7 | - | "crypto/elliptic" | |||
| 8 | "crypto/rand" | 6 | "crypto/rand" | ||
| 9 | "crypto/tls" | 7 | "crypto/tls" | ||
| 10 | - | "crypto/x509" | |||
| 11 | - | "crypto/x509/pkix" | |||
| 12 | "encoding/hex" | 8 | "encoding/hex" | ||
| 13 | "encoding/json" | 9 | "encoding/json" | ||
| 14 | - | "encoding/pem" | |||
| 15 | "errors" | 10 | "errors" | ||
| 16 | "fmt" | 11 | "fmt" | ||
| 17 | - | "math/big" | |||
| 18 | "net" | 12 | "net" | ||
| 19 | "net/http" | 13 | "net/http" | ||
| 20 | "os" | 14 | "os" | ||
| @@ -38,6 +32,7 @@ import ( | |||||
| 38 | "github.com/bnema/gordon/internal/adapters/out/filesystem" | 32 | "github.com/bnema/gordon/internal/adapters/out/filesystem" | ||
| 39 | "github.com/bnema/gordon/internal/adapters/out/httpprober" | 33 | "github.com/bnema/gordon/internal/adapters/out/httpprober" | ||
| 40 | "github.com/bnema/gordon/internal/adapters/out/logwriter" | 34 | "github.com/bnema/gordon/internal/adapters/out/logwriter" | ||
| 35 | + | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |||
| 41 | "github.com/bnema/gordon/internal/adapters/out/ratelimit" | 36 | "github.com/bnema/gordon/internal/adapters/out/ratelimit" | ||
| 42 | "github.com/bnema/gordon/internal/adapters/out/secrets" | 37 | "github.com/bnema/gordon/internal/adapters/out/secrets" | ||
| 43 | "github.com/bnema/gordon/internal/adapters/out/telemetry" | 38 | "github.com/bnema/gordon/internal/adapters/out/telemetry" | ||
| @@ -51,6 +46,7 @@ import ( | |||||
| 51 | "github.com/bnema/gordon/internal/adapters/in/http/admin" | 46 | "github.com/bnema/gordon/internal/adapters/in/http/admin" | ||
| 52 | authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth" | 47 | authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth" | ||
| 53 | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | 48 | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | ||
| 49 | + | "github.com/bnema/gordon/internal/adapters/in/http/onboarding" | |||
| 54 | proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy" | 50 | proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy" | ||
| 55 | "github.com/bnema/gordon/internal/adapters/in/http/registry" | 51 | "github.com/bnema/gordon/internal/adapters/in/http/registry" | ||
| 56 | 52 | ||||
| @@ -74,6 +70,7 @@ import ( | |||||
| 74 | "github.com/bnema/gordon/internal/usecase/health" | 70 | "github.com/bnema/gordon/internal/usecase/health" | ||
| 75 | "github.com/bnema/gordon/internal/usecase/images" | 71 | "github.com/bnema/gordon/internal/usecase/images" | ||
| 76 | "github.com/bnema/gordon/internal/usecase/logs" | 72 | "github.com/bnema/gordon/internal/usecase/logs" | ||
| 73 | + | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | |||
| 77 | "github.com/bnema/gordon/internal/usecase/proxy" | 74 | "github.com/bnema/gordon/internal/usecase/proxy" | ||
| 78 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | 75 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | ||
| 79 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | 76 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | ||
| @@ -91,10 +88,10 @@ type Config struct { | |||||
| 91 | RegistryPort int `mapstructure:"registry_port"` | 88 | RegistryPort int `mapstructure:"registry_port"` | ||
| 92 | GordonDomain string `mapstructure:"gordon_domain"` | 89 | GordonDomain string `mapstructure:"gordon_domain"` | ||
| 93 | RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain | 90 | RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain | ||
| 94 | - | TLSEnabled bool `mapstructure:"tls_enabled"` | |||
| 95 | TLSPort int `mapstructure:"tls_port"` | 91 | TLSPort int `mapstructure:"tls_port"` | ||
| 96 | TLSCertFile string `mapstructure:"tls_cert_file"` | 92 | TLSCertFile string `mapstructure:"tls_cert_file"` | ||
| 97 | TLSKeyFile string `mapstructure:"tls_key_file"` | 93 | TLSKeyFile string `mapstructure:"tls_key_file"` | ||
| 94 | + | ForceHTTPSRedirect bool `mapstructure:"force_https_redirect"` | |||
| 98 | DataDir string `mapstructure:"data_dir"` | 95 | DataDir string `mapstructure:"data_dir"` | ||
| 99 | MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" | 96 | MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" | ||
| 100 | MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB" | 97 | MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB" | ||
| @@ -103,7 +100,6 @@ type Config struct { | |||||
| 103 | RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"` | 100 | RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"` | ||
| 104 | ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"` | 101 | ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"` | ||
| 105 | RegistryListenAddr string `mapstructure:"registry_listen_address"` | 102 | RegistryListenAddr string `mapstructure:"registry_listen_address"` | ||
| 106 | - | ForceHSTS bool `mapstructure:"force_hsts"` | |||
| 107 | } `mapstructure:"server"` | 103 | } `mapstructure:"server"` | ||
| 108 | 104 | ||||
| 109 | Logging struct { | 105 | Logging struct { | ||
| @@ -207,6 +203,8 @@ type services struct { | |||||
| 207 | previewService *preview.Service | 203 | previewService *preview.Service | ||
| 208 | envDir string | 204 | envDir string | ||
| 209 | maxBlobChunkSize int64 | 205 | maxBlobChunkSize int64 | ||
| 206 | + | caAdapter *pkiadapter.CA | |||
| 207 | + | pkiSvc *pkiusecase.Service | |||
| 210 | } | 208 | } | ||
| 211 | 209 | ||||
| 212 | // Run initializes and starts the Gordon application. | 210 | // Run initializes and starts the Gordon application. | ||
| @@ -252,8 +250,11 @@ func Run(ctx context.Context, configPath string) error { | |||||
| 252 | 250 | ||||
| 253 | log.Info().Msg("Gordon starting") | 251 | log.Info().Msg("Gordon starting") | ||
| 254 | 252 | ||||
| 255 | - | if err := ensureTLSConfig(&cfg, log); err != nil { | 253 | + | // Deprecation warnings for removed TLS config fields |
| 256 | - | return err | 254 | + | for _, key := range []string{"server.tls_enabled", "server.force_hsts"} { |
| 255 | + | if v.IsSet(key) { | |||
| 256 | + | log.Warn().Str("key", key).Msg("deprecated config key — Gordon now uses an internal CA with automatic TLS; remove this from your config") | |||
| 257 | + | } | |||
| 257 | } | 258 | } | ||
| 258 | 259 | ||||
| 259 | // Create PID file | 260 | // Create PID file | ||
| @@ -285,128 +286,8 @@ func Run(ctx context.Context, configPath string) error { | |||||
| 285 | } | 286 | } | ||
| 286 | defer svc.eventBus.Stop() | 287 | defer svc.eventBus.Stop() | ||
| 287 | 288 | ||||
| 288 | - | // Create HTTP handlers | |||
| 289 | - | registryHandler, proxyHandler, proxyCIDRMiddleware := createHTTPHandlers(svc, cfg, log) | |||
| 290 | - | ||||
| 291 | // Start servers, wait for listeners to bind, then sync/auto-start containers. | 289 | // Start servers, wait for listeners to bind, then sync/auto-start containers. | ||
| 292 | - | return runServers(ctx, v, cfg, registryHandler, proxyHandler, proxyCIDRMiddleware, svc, cleanupHandlers, log) | 290 | + | return runServers(ctx, v, cfg, svc, cleanupHandlers, log) |
| 293 | - | } | |||
| 294 | - | ||||
| 295 | - | func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error { | |||
| 296 | - | if !cfg.Server.TLSEnabled { | |||
| 297 | - | return nil | |||
| 298 | - | } | |||
| 299 | - | ||||
| 300 | - | if cfg.Server.TLSCertFile != "" && cfg.Server.TLSKeyFile != "" { | |||
| 301 | - | return nil | |||
| 302 | - | } | |||
| 303 | - | ||||
| 304 | - | dataDir := resolveDataDir(cfg.Server.DataDir) | |||
| 305 | - | tlsDir := filepath.Join(dataDir, "tls") | |||
| 306 | - | certPath := filepath.Join(tlsDir, "cert.pem") | |||
| 307 | - | keyPath := filepath.Join(tlsDir, "key.pem") | |||
| 308 | - | ||||
| 309 | - | domainName := cfg.Server.GordonDomain | |||
| 310 | - | if domainName == "" { | |||
| 311 | - | domainName = cfg.Server.RegistryDomain | |||
| 312 | - | } | |||
| 313 | - | if domainName == "" { | |||
| 314 | - | domainName = "localhost" | |||
| 315 | - | } | |||
| 316 | - | ||||
| 317 | - | if err := generateSelfSignedTLSCert(certPath, keyPath, domainName); err != nil { | |||
| 318 | - | return fmt.Errorf("failed to generate self-signed TLS certificate: %w", err) | |||
| 319 | - | } | |||
| 320 | - | ||||
| 321 | - | cfg.Server.TLSCertFile = certPath | |||
| 322 | - | cfg.Server.TLSKeyFile = keyPath | |||
| 323 | - | ||||
| 324 | - | log.Warn(). | |||
| 325 | - | Str("cert_file", certPath). | |||
| 326 | - | Str("key_file", keyPath). | |||
| 327 | - | Str("domain", domainName). | |||
| 328 | - | Msg("server.tls_enabled=true with empty tls_cert_file/tls_key_file; using auto-generated self-signed certificate") | |||
| 329 | - | ||||
| 330 | - | return nil | |||
| 331 | - | } | |||
| 332 | - | ||||
| 333 | - | func generateSelfSignedTLSCert(certPath, keyPath, domainName string) error { | |||
| 334 | - | // Reuse existing pair if already generated. | |||
| 335 | - | if _, err := os.Stat(certPath); err == nil { | |||
| 336 | - | if _, keyErr := os.Stat(keyPath); keyErr == nil { | |||
| 337 | - | return nil | |||
| 338 | - | } | |||
| 339 | - | } | |||
| 340 | - | ||||
| 341 | - | if err := os.MkdirAll(filepath.Dir(certPath), 0700); err != nil { | |||
| 342 | - | return err | |||
| 343 | - | } | |||
| 344 | - | if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil { | |||
| 345 | - | return err | |||
| 346 | - | } | |||
| 347 | - | ||||
| 348 | - | priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |||
| 349 | - | if err != nil { | |||
| 350 | - | return err | |||
| 351 | - | } | |||
| 352 | - | ||||
| 353 | - | now := time.Now() | |||
| 354 | - | serialLimit := new(big.Int).Lsh(big.NewInt(1), 128) | |||
| 355 | - | serialNumber, err := rand.Int(rand.Reader, serialLimit) | |||
| 356 | - | if err != nil { | |||
| 357 | - | return err | |||
| 358 | - | } | |||
| 359 | - | ||||
| 360 | - | template := x509.Certificate{ | |||
| 361 | - | SerialNumber: serialNumber, | |||
| 362 | - | Subject: pkix.Name{ | |||
| 363 | - | CommonName: domainName, | |||
| 364 | - | Organization: []string{"Gordon Auto-Generated TLS"}, | |||
| 365 | - | }, | |||
| 366 | - | NotBefore: now.Add(-5 * time.Minute), | |||
| 367 | - | NotAfter: now.AddDate(1, 0, 0), | |||
| 368 | - | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, | |||
| 369 | - | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, | |||
| 370 | - | BasicConstraintsValid: true, | |||
| 371 | - | } | |||
| 372 | - | ||||
| 373 | - | if ip := net.ParseIP(domainName); ip != nil { | |||
| 374 | - | template.IPAddresses = []net.IP{ip} | |||
| 375 | - | } else { | |||
| 376 | - | template.DNSNames = []string{domainName} | |||
| 377 | - | } | |||
| 378 | - | ||||
| 379 | - | derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) | |||
| 380 | - | if err != nil { | |||
| 381 | - | return err | |||
| 382 | - | } | |||
| 383 | - | ||||
| 384 | - | certOut, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | |||
| 385 | - | if err != nil { | |||
| 386 | - | return err | |||
| 387 | - | } | |||
| 388 | - | defer certOut.Close() | |||
| 389 | - | ||||
| 390 | - | if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { | |||
| 391 | - | return err | |||
| 392 | - | } | |||
| 393 | - | ||||
| 394 | - | keyBytes, err := x509.MarshalECPrivateKey(priv) | |||
| 395 | - | if err != nil { | |||
| 396 | - | return err | |||
| 397 | - | } | |||
| 398 | - | ||||
| 399 | - | keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | |||
| 400 | - | if err != nil { | |||
| 401 | - | return err | |||
| 402 | - | } | |||
| 403 | - | defer keyOut.Close() | |||
| 404 | - | ||||
| 405 | - | if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}); err != nil { | |||
| 406 | - | return err | |||
| 407 | - | } | |||
| 408 | - | ||||
| 409 | - | return nil | |||
| 410 | } | 291 | } | ||
| 411 | 292 | ||||
| 412 | // initConfig loads configuration from file. | 293 | // initConfig loads configuration from file. | ||
| @@ -461,119 +342,179 @@ func initLogger(cfg Config) (zerowrap.Logger, func(), error) { | |||||
| 461 | return log, cleanup, nil | 342 | return log, cleanup, nil | ||
| 462 | } | 343 | } | ||
| 463 | 344 | ||||
| 345 | + | // serviceInit holds the shared context for service initialization helpers. | |||
| 346 | + | type serviceInit struct { | |||
| 347 | + | ctx context.Context | |||
| 348 | + | v *viper.Viper | |||
| 349 | + | cfg Config | |||
| 350 | + | log zerowrap.Logger | |||
| 351 | + | svc *services | |||
| 352 | + | } | |||
| 353 | + | ||||
| 464 | // createServices creates all the application services. | 354 | // createServices creates all the application services. | ||
| 465 | - | func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (*services, error) { | 355 | + | func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) { |
| 466 | - | svc := &services{} | 356 | + | si := &serviceInit{ |
| 357 | + | ctx: ctx, | |||
| 358 | + | v: v, | |||
| 359 | + | cfg: cfg, | |||
| 360 | + | log: log, | |||
| 361 | + | svc: &services{}, | |||
| 362 | + | } | |||
| 363 | + | defer func() { | |||
| 364 | + | if retErr != nil && si.svc.pkiSvc != nil { | |||
| 365 | + | si.svc.pkiSvc.Stop() | |||
| 366 | + | } | |||
| 367 | + | }() | |||
| 467 | var err error | 368 | var err error | ||
| 468 | 369 | ||||
| 469 | // Create output adapters | 370 | // Create output adapters | ||
| 470 | runtimeSocket := resolveRuntimeConfig(v.GetString("server.runtime")) | 371 | runtimeSocket := resolveRuntimeConfig(v.GetString("server.runtime")) | ||
| 471 | - | if svc.runtime, svc.eventBus, err = createOutputAdapters(ctx, log, runtimeSocket); err != nil { | 372 | + | if si.svc.runtime, si.svc.eventBus, err = createOutputAdapters(ctx, log, runtimeSocket); err != nil { |
| 472 | return nil, err | 373 | return nil, err | ||
| 473 | } | 374 | } | ||
| 474 | 375 | ||||
| 475 | // Create storage | 376 | // Create storage | ||
| 476 | - | if svc.blobStorage, svc.manifestStorage, err = createStorage(cfg, log); err != nil { | 377 | + | if si.svc.blobStorage, si.svc.manifestStorage, err = createStorage(cfg, log); err != nil { |
| 477 | return nil, err | 378 | return nil, err | ||
| 478 | } | 379 | } | ||
| 479 | 380 | ||||
| 480 | // Create log writer | 381 | // Create log writer | ||
| 481 | - | if svc.logWriter, err = createLogWriter(cfg, log); err != nil { | 382 | + | if si.svc.logWriter, err = createLogWriter(cfg, log); err != nil { |
| 482 | return nil, err | 383 | return nil, err | ||
| 483 | } | 384 | } | ||
| 484 | 385 | ||||
| 485 | // Create auth service (if enabled) | 386 | // Create auth service (if enabled) | ||
| 486 | - | if svc.tokenStore, svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil { | 387 | + | if si.svc.tokenStore, si.svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil { |
| 487 | return nil, err | 388 | return nil, err | ||
| 488 | } | 389 | } | ||
| 489 | 390 | ||||
| 490 | - | if err := setupInternalRegistryAuth(svc, log); err != nil { | 391 | + | if err := setupInternalRegistryAuth(si.svc, log); err != nil { |
| 491 | return nil, err | 392 | return nil, err | ||
| 492 | } | 393 | } | ||
| 493 | 394 | ||||
| 494 | - | // Create use case services | 395 | + | // Create config service |
| 495 | - | svc.configSvc = config.NewService(v, svc.eventBus) | 396 | + | si.svc.configSvc = config.NewService(v, si.svc.eventBus) |
| 496 | - | if err := svc.configSvc.Load(ctx); err != nil { | 397 | + | if err := si.svc.configSvc.Load(ctx); err != nil { |
| 497 | return nil, log.WrapErr(err, "failed to load configuration") | 398 | return nil, log.WrapErr(err, "failed to load configuration") | ||
| 498 | } | 399 | } | ||
| 499 | 400 | ||||
| 500 | - | var backend domain.SecretsBackend | 401 | + | if err := si.initPKI(); err != nil { |
| 501 | - | var passStore *domainsecrets.PassStore | 402 | + | return nil, err |
| 502 | - | var domainSecretStore out.DomainSecretStore | 403 | + | } |
| 503 | 404 | ||||
| 504 | - | svc.envDir, backend, passStore, domainSecretStore, err = createDomainSecretStore(cfg, log) | 405 | + | if err := si.initSecrets(); err != nil { |
| 505 | - | if err != nil { | |||
| 506 | return nil, err | 406 | return nil, err | ||
| 507 | } | 407 | } | ||
| 508 | 408 | ||||
| 509 | - | if svc.envLoader, err = createEnvLoader(backend, svc.envDir, passStore, log); err != nil { | 409 | + | if err := si.initRuntimeAndProxy(); err != nil { |
| 510 | return nil, err | 410 | return nil, err | ||
| 511 | } | 411 | } | ||
| 512 | 412 | ||||
| 513 | - | svc.secretSvc = secretsSvc.NewService(domainSecretStore, log, svc.eventBus) | 413 | + | si.initHandlers() |
| 514 | 414 | ||||
| 515 | - | if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil { | 415 | + | return si.svc, nil |
| 516 | - | return nil, err | 416 | + | } |
| 417 | + | ||||
| 418 | + | // initPKI initialises the internal CA and PKI service when TLS is enabled. | |||
| 419 | + | func (si *serviceInit) initPKI() error { | |||
| 420 | + | if si.cfg.Server.TLSPort == 0 { | |||
| 421 | + | si.log.Info().Msg("internal CA disabled (server.tls_port=0)") | |||
| 422 | + | return nil | |||
| 517 | } | 423 | } | ||
| 518 | 424 | ||||
| 519 | - | if svc.backupStorage, svc.backupSvc, err = createBackupService(cfg, svc, log); err != nil { | 425 | + | if (si.cfg.Server.TLSCertFile == "") != (si.cfg.Server.TLSKeyFile == "") { |
| 520 | - | return nil, err | 426 | + | return fmt.Errorf("both tls_cert_file and tls_key_file must be set, or neither") |
| 521 | } | 427 | } | ||
| 522 | 428 | ||||
| 523 | - | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | 429 | + | caAdapter, err := pkiadapter.NewCA(resolveDataDir(si.cfg.Server.DataDir), si.log) |
| 524 | - | svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log) | 430 | + | if err != nil { |
| 525 | - | svc.volumeSvc = volumesSvc.NewService(svc.runtime) | 431 | + | return si.log.WrapErr(err, "failed to initialize internal CA") |
| 432 | + | } | |||
| 433 | + | si.svc.caAdapter = caAdapter | |||
| 434 | + | si.svc.pkiSvc = pkiusecase.NewService(si.ctx, caAdapter, si.svc.configSvc, si.log) | |||
| 435 | + | return nil | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | // initSecrets creates the domain secret store, env loader, and secret service. | |||
| 439 | + | func (si *serviceInit) initSecrets() error { | |||
| 440 | + | envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log) | |||
| 441 | + | if err != nil { | |||
| 442 | + | return err | |||
| 443 | + | } | |||
| 444 | + | si.svc.envDir = envDir | |||
| 445 | + | ||||
| 446 | + | if si.svc.envLoader, err = createEnvLoader(backend, envDir, passStore, si.log); err != nil { | |||
| 447 | + | return err | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | si.svc.secretSvc = secretsSvc.NewService(domainSecretStore, si.log, si.svc.eventBus) | |||
| 451 | + | return nil | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | // initRuntimeAndProxy creates container, backup, registry, image, volume, and proxy services. | |||
| 455 | + | func (si *serviceInit) initRuntimeAndProxy() error { | |||
| 456 | + | var err error | |||
| 457 | + | ||||
| 458 | + | if si.svc.containerSvc, err = createContainerService(si.ctx, si.v, si.cfg, si.svc, si.log); err != nil { | |||
| 459 | + | return err | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | if si.svc.backupStorage, si.svc.backupSvc, err = createBackupService(si.cfg, si.svc, si.log); err != nil { | |||
| 463 | + | return err | |||
| 464 | + | } | |||
| 465 | + | ||||
| 466 | + | si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus) | |||
| 467 | + | si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log) | |||
| 468 | + | si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime) | |||
| 526 | 469 | ||||
| 527 | - | injectTelemetryMetrics(cfg, svc, log) | 470 | + | injectTelemetryMetrics(si.cfg, si.svc, si.log) |
| 528 | 471 | ||||
| 529 | - | proxyCfg, err := buildProxyConfig(cfg, log) | 472 | + | proxyCfg, err := buildProxyConfig(si.cfg, si.log) |
| 530 | if err != nil { | 473 | if err != nil { | ||
| 531 | - | return nil, err | 474 | + | return err |
| 532 | } | 475 | } | ||
| 533 | - | svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize | 476 | + | si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize |
| 534 | - | svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxyCfg.proxyConfig) | 477 | + | si.svc.proxySvc = proxy.NewService(si.svc.runtime, si.svc.containerSvc, si.svc.configSvc, proxyCfg.proxyConfig) |
| 535 | 478 | ||||
| 536 | // Wire synchronous proxy cache invalidation for zero-downtime deployments. | 479 | // Wire synchronous proxy cache invalidation for zero-downtime deployments. | ||
| 537 | // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget(). | 480 | // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget(). | ||
| 538 | - | svc.containerSvc.SetProxyCacheInvalidator(svc.proxySvc) | 481 | + | si.svc.containerSvc.SetProxyCacheInvalidator(si.svc.proxySvc) |
| 539 | - | svc.containerSvc.SetProxyDrainWaiter(svc.proxySvc) | 482 | + | si.svc.containerSvc.SetProxyDrainWaiter(si.svc.proxySvc) |
| 483 | + | return nil | |||
| 484 | + | } | |||
| 540 | 485 | ||||
| 541 | - | // Create token handler for registry token endpoint | 486 | + | // initHandlers creates the auth, health, log, preview, and admin handlers. |
| 542 | - | if svc.authSvc != nil { | 487 | + | func (si *serviceInit) initHandlers() { |
| 488 | + | if si.svc.authSvc != nil { | |||
| 543 | internalAuth := authhandler.InternalAuth{ | 489 | internalAuth := authhandler.InternalAuth{ | ||
| 544 | - | Username: svc.internalRegUser, | 490 | + | Username: si.svc.internalRegUser, |
| 545 | - | Password: svc.internalRegPass, | 491 | + | Password: si.svc.internalRegPass, |
| 546 | } | 492 | } | ||
| 547 | - | svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log) | 493 | + | si.svc.authHandler = authhandler.NewHandler(si.svc.authSvc, internalAuth, si.log) |
| 548 | } | 494 | } | ||
| 549 | 495 | ||||
| 550 | - | // Create health service for route health checking | |||
| 551 | prober := httpprober.New() | 496 | prober := httpprober.New() | ||
| 552 | - | svc.healthSvc = health.NewService(svc.configSvc, svc.containerSvc, prober, log) | 497 | + | si.svc.healthSvc = health.NewService(si.svc.configSvc, si.svc.containerSvc, prober, si.log) |
| 553 | - | 498 | + | ||
| 554 | - | // Create log service for accessing logs via admin API | 499 | + | si.svc.logSvc = logs.NewService(resolveLogFilePath(si.cfg), si.cfg.Logging.File.Enabled, si.svc.containerSvc, si.svc.runtime, si.log) |
| 555 | - | svc.logSvc = logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log) | 500 | + | |
| 556 | - | 501 | + | initPreviewService(si.ctx, si.cfg, si.svc, si.log) | |
| 557 | - | initPreviewService(ctx, cfg, svc, log) | 502 | + | |
| 558 | - | 503 | + | si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{ | |
| 559 | - | // Create admin handler for admin API | 504 | + | ConfigSvc: si.svc.configSvc, |
| 560 | - | svc.adminHandler = admin.NewHandler(admin.HandlerDeps{ | 505 | + | AuthSvc: si.svc.authSvc, |
| 561 | - | ConfigSvc: svc.configSvc, | 506 | + | ContainerSvc: si.svc.containerSvc, |
| 562 | - | AuthSvc: svc.authSvc, | 507 | + | HealthSvc: si.svc.healthSvc, |
| 563 | - | ContainerSvc: svc.containerSvc, | 508 | + | SecretSvc: si.svc.secretSvc, |
| 564 | - | HealthSvc: svc.healthSvc, | 509 | + | LogSvc: si.svc.logSvc, |
| 565 | - | SecretSvc: svc.secretSvc, | 510 | + | RegistrySvc: si.svc.registrySvc, |
| 566 | - | LogSvc: svc.logSvc, | 511 | + | EventBus: si.svc.eventBus, |
| 567 | - | RegistrySvc: svc.registrySvc, | 512 | + | Log: si.log, |
| 568 | - | EventBus: svc.eventBus, | 513 | + | BackupSvc: si.svc.backupSvc, |
| 569 | - | Log: log, | 514 | + | PreviewSvc: si.svc.previewService, |
| 570 | - | BackupSvc: svc.backupSvc, | 515 | + | ImageSvc: si.svc.imageSvc, |
| 571 | - | PreviewSvc: svc.previewService, | 516 | + | VolumeSvc: si.svc.volumeSvc, |
| 572 | - | ImageSvc: svc.imageSvc, | |||
| 573 | - | VolumeSvc: svc.volumeSvc, | |||
| 574 | }) | 517 | }) | ||
| 575 | - | ||||
| 576 | - | return svc, nil | |||
| 577 | } | 518 | } | ||
| 578 | 519 | ||||
| 579 | // initPreviewService sets up the preview store, service, and TTL ticker. | 520 | // initPreviewService sets up the preview store, service, and TTL ticker. | ||
| @@ -1669,13 +1610,13 @@ func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler { | |||||
| 1669 | } | 1610 | } | ||
| 1670 | 1611 | ||||
| 1671 | // createHTTPHandlers creates HTTP handlers with middleware. | 1612 | // createHTTPHandlers creates HTTP handlers with middleware. | ||
| 1672 | - | // The returned proxyCIDRMiddleware is non-nil when proxy_allowed_ips is configured; | 1613 | + | // Returns three handlers: registry, HTTP proxy (with CIDR + onboarding), and HTTPS proxy. |
| 1673 | - | // callers must also apply it to the HTTP-to-HTTPS redirect listener. | 1614 | + | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler, http.Handler) { |
| 1674 | - | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler, func(http.Handler) http.Handler) { | |||
| 1675 | // Parse trusted proxies once for all middleware chains. | 1615 | // Parse trusted proxies once for all middleware chains. | ||
| 1676 | // This ensures consistent IP extraction across logging, rate limiting, and auth. | 1616 | // This ensures consistent IP extraction across logging, rate limiting, and auth. | ||
| 1677 | trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | 1617 | trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | ||
| 1678 | 1618 | ||||
| 1619 | + | // Registry handler (unchanged) | |||
| 1679 | registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize) | 1620 | registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize) | ||
| 1680 | registryWithMiddleware, cidrAllowlistMiddleware, rateLimitMiddleware := buildRegistryHandlerWithMiddleware( | 1621 | registryWithMiddleware, cidrAllowlistMiddleware, rateLimitMiddleware := buildRegistryHandlerWithMiddleware( | ||
| 1681 | svc, | 1622 | svc, | ||
| @@ -1688,36 +1629,65 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1688 | registryMux := http.NewServeMux() | 1629 | registryMux := http.NewServeMux() | ||
| 1689 | registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, cfg, log) | 1630 | registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, cfg, log) | ||
| 1690 | registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log)) | 1631 | registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log)) | ||
| 1632 | + | registerAdminRoutes(registryMux, svc, cfg, trustedNets, log) | |||
| 1633 | + | ||||
| 1634 | + | // Proxy handler | |||
| 1635 | + | proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log) | |||
| 1636 | + | ||||
| 1637 | + | // HTTP proxy handler chain: HTTPS redirect for non-proxy clients, then CIDR allowlist | |||
| 1638 | + | proxyAllowedNets, proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log) | |||
| 1691 | 1639 | ||||
| 1692 | - | // SECURITY: No CORS middleware on the proxy chain. Backend applications | 1640 | + | httpProxyMiddlewares := []func(http.Handler) http.Handler{ |
| 1693 | - | // should control their own CORS policies. A blanket Access-Control-Allow-Origin: * | |||
| 1694 | - | // would override backend CORS settings and allow any website to make | |||
| 1695 | - | // cross-origin authenticated requests to proxied applications. | |||
| 1696 | - | proxyMiddlewares := []func(http.Handler) http.Handler{ | |||
| 1697 | middleware.PanicRecovery(log), | 1641 | middleware.PanicRecovery(log), | ||
| 1698 | middleware.RequestLogger(log, trustedNets), | 1642 | middleware.RequestLogger(log, trustedNets), | ||
| 1699 | - | middleware.SecurityHeadersWithOptions(cfg.Server.ForceHSTS), | 1643 | + | middleware.SecurityHeaders, |
| 1644 | + | middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log), | |||
| 1700 | } | 1645 | } | ||
| 1701 | - | ||||
| 1702 | - | // SECURITY: When proxy_allowed_ips is set, only connections from those CIDRs | |||
| 1703 | - | // can reach the proxy. This prevents direct-to-origin attacks that bypass | |||
| 1704 | - | // CDN protections (e.g. Cloudflare WAF, DDoS mitigation). | |||
| 1705 | - | proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log) | |||
| 1706 | if proxyCIDRMiddleware != nil { | 1646 | if proxyCIDRMiddleware != nil { | ||
| 1707 | - | proxyMiddlewares = append(proxyMiddlewares, proxyCIDRMiddleware) | 1647 | + | httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware) |
| 1708 | } | 1648 | } | ||
| 1709 | 1649 | ||||
| 1710 | - | proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log) | 1650 | + | httpProxyWithMiddleware := otelhttp.NewHandler( |
| 1711 | - | proxyWithMiddleware := otelhttp.NewHandler( | 1651 | + | middleware.Chain(httpProxyMiddlewares...)(proxyHandler), |
| 1712 | - | middleware.Chain(proxyMiddlewares...)(proxyHandler), | |||
| 1713 | "gordon.proxy", | 1652 | "gordon.proxy", | ||
| 1714 | ) | 1653 | ) | ||
| 1654 | + | ||||
| 1655 | + | // HTTP proxyMux: Cloudflare-proxied traffic only, no onboarding | |||
| 1715 | proxyMux := http.NewServeMux() | 1656 | proxyMux := http.NewServeMux() | ||
| 1716 | - | proxyMux.Handle("/", proxyWithMiddleware) | 1657 | + | proxyMux.Handle("/", httpProxyWithMiddleware) |
| 1658 | + | ||||
| 1659 | + | // HTTPS proxy handler chain: security headers + proxy + CA onboarding | |||
| 1660 | + | // Onboarding routes live on the TLS port so Tailnet / direct clients | |||
| 1661 | + | // can click through the initial cert warning, install the CA, and | |||
| 1662 | + | // then trust all subsequent connections. | |||
| 1663 | + | // The middleware chain wraps the entire mux so onboarding routes also | |||
| 1664 | + | // get PanicRecovery, RequestLogger, and SecurityHeaders. | |||
| 1665 | + | httpsProxyMiddlewares := []func(http.Handler) http.Handler{ | |||
| 1666 | + | middleware.PanicRecovery(log), | |||
| 1667 | + | middleware.RequestLogger(log, trustedNets), | |||
| 1668 | + | middleware.SecurityHeaders, | |||
| 1669 | + | } | |||
| 1717 | 1670 | ||||
| 1718 | - | registerAdminRoutes(registryMux, svc, cfg, trustedNets, log) | 1671 | + | httpsMux := http.NewServeMux() |
| 1672 | + | if svc.caAdapter != nil { | |||
| 1673 | + | mobileconfigBytes := pkiadapter.GenerateMobileconfig( | |||
| 1674 | + | svc.caAdapter.RootCertificateDER(), | |||
| 1675 | + | svc.caAdapter.RootCommonName(), | |||
| 1676 | + | ) | |||
| 1677 | + | onboardingHandler := onboarding.NewHandler( | |||
| 1678 | + | svc.caAdapter.RootCertificate(), | |||
| 1679 | + | mobileconfigBytes, | |||
| 1680 | + | cfg.Server.TLSPort, | |||
| 1681 | + | ) | |||
| 1682 | + | httpsMux.HandleFunc("GET /ca", onboardingHandler.ServeOnboardingPage) | |||
| 1683 | + | httpsMux.HandleFunc("GET /ca.crt", onboardingHandler.ServeCACert) | |||
| 1684 | + | httpsMux.HandleFunc("GET /ca.mobileconfig", onboardingHandler.ServeMobileconfig) | |||
| 1685 | + | } | |||
| 1686 | + | httpsMux.Handle("/", proxyHandler) | |||
| 1687 | + | ||||
| 1688 | + | httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls") | |||
| 1719 | 1689 | ||||
| 1720 | - | return registryMux, proxyMux, proxyCIDRMiddleware | 1690 | + | return registryMux, proxyMux, httpsHandler |
| 1721 | } | 1691 | } | ||
| 1722 | 1692 | ||||
| 1723 | func buildRegistryHandlerWithMiddleware( | 1693 | func buildRegistryHandlerWithMiddleware( | ||
| @@ -1801,20 +1771,20 @@ func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, | |||||
| 1801 | return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log) | 1771 | return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log) | ||
| 1802 | } | 1772 | } | ||
| 1803 | 1773 | ||||
| 1804 | - | func buildProxyCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | 1774 | + | func buildProxyCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) ([]*net.IPNet, func(http.Handler) http.Handler) { |
| 1805 | allowedNets, allInvalid := parseCIDRAllowlist(cfg.Server.ProxyAllowedIPs, "proxy_allowed_ips", log) | 1775 | allowedNets, allInvalid := parseCIDRAllowlist(cfg.Server.ProxyAllowedIPs, "proxy_allowed_ips", log) | ||
| 1806 | if allInvalid { | 1776 | if allInvalid { | ||
| 1807 | - | return denyAllHandler("proxy_allowed_ips", trustedNets, log) | 1777 | + | return nil, denyAllHandler("proxy_allowed_ips", trustedNets, log) |
| 1808 | } | 1778 | } | ||
| 1809 | if allowedNets == nil { | 1779 | if allowedNets == nil { | ||
| 1810 | - | return nil | 1780 | + | return nil, nil |
| 1811 | } | 1781 | } | ||
| 1812 | 1782 | ||||
| 1813 | log.Info(). | 1783 | log.Info(). | ||
| 1814 | Strs("proxy_allowed_ips", cfg.Server.ProxyAllowedIPs). | 1784 | Strs("proxy_allowed_ips", cfg.Server.ProxyAllowedIPs). | ||
| 1815 | Msg("proxy origin IP allowlist enabled") | 1785 | Msg("proxy origin IP allowlist enabled") | ||
| 1816 | 1786 | ||||
| 1817 | - | return middleware.ProxyCIDRAllowlist(allowedNets, log) | 1787 | + | return allowedNets, middleware.ProxyCIDRAllowlist(allowedNets, log) |
| 1818 | } | 1788 | } | ||
| 1819 | 1789 | ||||
| 1820 | func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler { | 1790 | func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler { | ||
| @@ -1949,7 +1919,7 @@ func registerAdminRoutes(registryMux *http.ServeMux, svc *services, cfg Config, | |||||
| 1949 | // - SIGUSR2: Triggers manual deploy for a specific route | 1919 | // - SIGUSR2: Triggers manual deploy for a specific route | ||
| 1950 | // The deferred signal.Stop calls ensure signal handlers are properly | 1920 | // The deferred signal.Stop calls ensure signal handlers are properly | ||
| 1951 | // cleaned up before program exit, preventing signal handler leaks. | 1921 | // cleaned up before program exit, preventing signal handler leaks. | ||
| 1952 | - | func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler, proxyHandler http.Handler, proxyCIDRMiddleware func(http.Handler) http.Handler, svc *services, cleanupHandlers func(), log zerowrap.Logger) error { | 1922 | + | func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, cleanupHandlers func(), log zerowrap.Logger) error { |
| 1953 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | 1923 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | ||
| 1954 | defer cancel() | 1924 | defer cancel() | ||
| 1955 | 1925 | ||||
| @@ -1967,24 +1937,44 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||||
| 1967 | 1937 | ||||
| 1968 | errChan := make(chan error, 3) | 1938 | errChan := make(chan error, 3) | ||
| 1969 | 1939 | ||||
| 1940 | + | registryHandler, httpProxyHandler, httpsProxyHandler := createHTTPHandlers(svc, cfg, log) | |||
| 1941 | + | ||||
| 1970 | registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort)) | 1942 | registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort)) | ||
| 1971 | registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log) | 1943 | registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log) | ||
| 1972 | 1944 | ||||
| 1973 | - | proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, proxyHandler, proxyCIDRMiddleware, errChan, log) | 1945 | + | // closeStarted shuts down any servers that were started before an error occurred, |
| 1946 | + | // preventing leaked listeners during partial startup failures. | |||
| 1947 | + | closeStarted := func(servers ...*http.Server) { | |||
| 1948 | + | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 1949 | + | defer shutdownCancel() | |||
| 1950 | + | for _, srv := range servers { | |||
| 1951 | + | if srv != nil { | |||
| 1952 | + | if err := srv.Shutdown(shutdownCtx); err != nil { | |||
| 1953 | + | log.Error().Err(err).Msg("failed to shut down server during startup cleanup") | |||
| 1954 | + | } | |||
| 1955 | + | } | |||
| 1956 | + | } | |||
| 1957 | + | } | |||
| 1958 | + | ||||
| 1959 | + | proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, errChan, log) | |||
| 1974 | if err != nil { | 1960 | if err != nil { | ||
| 1961 | + | closeStarted(registrySrv) | |||
| 1975 | return err | 1962 | return err | ||
| 1976 | } | 1963 | } | ||
| 1977 | 1964 | ||||
| 1978 | - | // Wait for all enabled servers to bind their ports before auto-starting containers. | 1965 | + | // Wait for all servers to bind their ports before auto-starting containers. |
| 1979 | // This prevents the race where auto-start pulls from the registry before it's listening. | 1966 | // This prevents the race where auto-start pulls from the registry before it's listening. | ||
| 1980 | if err := waitForServerReady(registryReady, errChan); err != nil { | 1967 | if err := waitForServerReady(registryReady, errChan); err != nil { | ||
| 1968 | + | closeStarted(registrySrv, proxySrv, tlsSrv) | |||
| 1981 | return err | 1969 | return err | ||
| 1982 | } | 1970 | } | ||
| 1983 | if err := waitForServerReady(proxyReady, errChan); err != nil { | 1971 | if err := waitForServerReady(proxyReady, errChan); err != nil { | ||
| 1972 | + | closeStarted(registrySrv, proxySrv, tlsSrv) | |||
| 1984 | return err | 1973 | return err | ||
| 1985 | } | 1974 | } | ||
| 1986 | - | if cfg.Server.TLSEnabled { | 1975 | + | if tlsReady != nil { |
| 1987 | if err := waitForServerReady(tlsReady, errChan); err != nil { | 1976 | if err := waitForServerReady(tlsReady, errChan); err != nil { | ||
| 1977 | + | closeStarted(registrySrv, proxySrv, tlsSrv) | |||
| 1988 | return err | 1978 | return err | ||
| 1989 | } | 1979 | } | ||
| 1990 | } | 1980 | } | ||
| @@ -1992,7 +1982,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||||
| 1992 | logEvent := log.Info(). | 1982 | logEvent := log.Info(). | ||
| 1993 | Int("proxy_port", cfg.Server.Port). | 1983 | Int("proxy_port", cfg.Server.Port). | ||
| 1994 | Int("registry_port", cfg.Server.RegistryPort) | 1984 | Int("registry_port", cfg.Server.RegistryPort) | ||
| 1995 | - | if cfg.Server.TLSEnabled { | 1985 | + | if tlsSrv != nil { |
| 1996 | logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort) | 1986 | logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort) | ||
| 1997 | } | 1987 | } | ||
| 1998 | logEvent.Msg("Gordon is running") | 1988 | logEvent.Msg("Gordon is running") | ||
| @@ -2010,7 +2000,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||||
| 2010 | 2000 | ||||
| 2011 | waitForShutdown(ctx, errChan, reloadChan, deployChan, svc.eventBus, log) | 2001 | waitForShutdown(ctx, errChan, reloadChan, deployChan, svc.eventBus, log) | ||
| 2012 | cleanupHandlers() // Stop debounce timers before draining containers | 2002 | cleanupHandlers() // Stop debounce timers before draining containers | ||
| 2013 | - | gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, log) | 2003 | + | gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, log) |
| 2014 | return nil | 2004 | return nil | ||
| 2015 | } | 2005 | } | ||
| 2016 | 2006 | ||||
| @@ -2212,7 +2202,7 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl | |||||
| 2212 | 2202 | ||||
| 2213 | // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down | 2203 | // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down | ||
| 2214 | // the container service and cleans up runtime files. | 2204 | // the container service and cleans up runtime files. | ||
| 2215 | - | func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, log zerowrap.Logger) { | 2205 | + | func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, log zerowrap.Logger) { |
| 2216 | log.Info().Msg("shutting down Gordon...") | 2206 | log.Info().Msg("shutting down Gordon...") | ||
| 2217 | 2207 | ||||
| 2218 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) | 2208 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) | ||
| @@ -2228,6 +2218,11 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc * | |||||
| 2228 | } | 2218 | } | ||
| 2229 | } | 2219 | } | ||
| 2230 | 2220 | ||||
| 2221 | + | // Stop PKI maintenance goroutines | |||
| 2222 | + | if pkiSvc != nil { | |||
| 2223 | + | pkiSvc.Stop() | |||
| 2224 | + | } | |||
| 2225 | + | ||||
| 2231 | // Phase 2: Drain in-flight registry push sessions before stopping the backend | 2226 | // Phase 2: Drain in-flight registry push sessions before stopping the backend | ||
| 2232 | if proxySvc != nil { | 2227 | if proxySvc != nil { | ||
| 2233 | log.Info().Msg("draining in-flight registry requests...") | 2228 | log.Info().Msg("draining in-flight registry requests...") | ||
| @@ -2253,56 +2248,54 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc * | |||||
| 2253 | log.Info().Msg("Gordon stopped") | 2248 | log.Info().Msg("Gordon stopped") | ||
| 2254 | } | 2249 | } | ||
| 2255 | 2250 | ||||
| 2256 | - | // startProxyServers sets up proxy server(s) depending on TLS configuration. | 2251 | + | // startProxyServers sets up the HTTP proxy server and, when tls_port != 0, |
| 2257 | - | // With TLS enabled, the HTTP port becomes a redirect-only server and a separate TLS server handles traffic. | 2252 | + | // an HTTPS proxy server with on-demand TLS certificates from the internal CA. |
| 2258 | - | // Without TLS, a single server handles both HTTP/1.1 and cleartext HTTP/2. | 2253 | + | func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { |
| 2259 | - | // proxyCIDRMiddleware, when non-nil, is also applied to the HTTP redirect listener | 2254 | + | // HTTP listener (Cloudflare proxy + onboarding for direct clients) |
| 2260 | - | // so that proxy_allowed_ips cannot be bypassed via the plain-HTTP port. | 2255 | + | var httpProtos http.Protocols |
| 2261 | - | func startProxyServers(cfg Config, proxyHandler http.Handler, proxyCIDRMiddleware func(http.Handler) http.Handler, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { | 2256 | + | httpProtos.SetHTTP1(true) |
| 2262 | - | if !cfg.Server.TLSEnabled { | 2257 | + | httpProtos.SetUnencryptedHTTP2(true) |
| 2263 | - | var proxyProtos http.Protocols | 2258 | + | httpSrv, httpReady := startServer( |
| 2264 | - | proxyProtos.SetHTTP1(true) | 2259 | + | fmt.Sprintf(":%d", cfg.Server.Port), |
| 2265 | - | proxyProtos.SetUnencryptedHTTP2(true) | 2260 | + | httpHandler, |
| 2266 | - | srv, ready := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", &proxyProtos, errChan, log) | 2261 | + | "proxy-http", |
| 2267 | - | return srv, ready, nil, nil, nil | 2262 | + | &httpProtos, |
| 2268 | - | } | 2263 | + | errChan, |
| 2264 | + | log, | |||
| 2265 | + | ) | |||
| 2269 | 2266 | ||||
| 2270 | - | if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" { | 2267 | + | if cfg.Server.TLSPort == 0 { |
| 2271 | - | return nil, nil, nil, nil, fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file") | 2268 | + | return httpSrv, httpReady, nil, nil, nil |
| 2272 | } | 2269 | } | ||
| 2273 | 2270 | ||||
| 2274 | - | tlsPort := cfg.Server.TLSPort | 2271 | + | // HTTPS listener — static cert (if provided) takes priority by SNI match, |
| 2275 | - | redirectHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 2272 | + | // unmatched domains fall through to on-demand internal CA certs. |
| 2276 | - | host := r.Host | 2273 | + | tlsConfig := &tls.Config{ |
| 2277 | - | if h, _, err := net.SplitHostPort(host); err == nil { | 2274 | + | MinVersion: tls.VersionTLS12, |
| 2278 | - | host = h | 2275 | + | GetCertificate: pkiSvc.GetCertificate, |
| 2279 | - | } else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { | 2276 | + | NextProtos: []string{"h2", "http/1.1"}, |
| 2280 | - | host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") | 2277 | + | } |
| 2281 | - | } | 2278 | + | if cfg.Server.TLSCertFile != "" { |
| 2282 | - | if tlsPort != 443 { | 2279 | + | staticCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile) |
| 2283 | - | host = net.JoinHostPort(host, strconv.Itoa(tlsPort)) | 2280 | + | if err != nil { |
| 2284 | - | } else if strings.Contains(host, ":") { | 2281 | + | return nil, nil, nil, nil, fmt.Errorf("load TLS keypair: %w", err) |
| 2285 | - | host = "[" + host + "]" | |||
| 2286 | } | 2282 | } | ||
| 2287 | - | target := "https://" + host + r.RequestURI | 2283 | + | tlsConfig.Certificates = []tls.Certificate{staticCert} |
| 2288 | - | http.Redirect(w, r, target, http.StatusPermanentRedirect) | 2284 | + | log.Info(). |
| 2289 | - | }) | 2285 | + | Str("cert", cfg.Server.TLSCertFile). |
| 2290 | - | 2286 | + | Str("key", cfg.Server.TLSKeyFile). | |
| 2291 | - | var redirectWithMiddleware http.Handler = redirectHandler | 2287 | + | Msg("loaded static TLS certificate (internal CA handles remaining domains)") |
| 2292 | - | if proxyCIDRMiddleware != nil { | |||
| 2293 | - | redirectWithMiddleware = proxyCIDRMiddleware(redirectHandler) | |||
| 2294 | } | 2288 | } | ||
| 2295 | - | proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), redirectWithMiddleware, "proxy-redirect", nil, errChan, log) | 2289 | + | tlsSrv, tlsReady := startTLSServerWithConfig( |
| 2296 | - | tlsSrv, tlsReady := startTLSServer( | |||
| 2297 | fmt.Sprintf(":%d", cfg.Server.TLSPort), | 2290 | fmt.Sprintf(":%d", cfg.Server.TLSPort), | ||
| 2298 | - | proxyHandler, | 2291 | + | httpsHandler, |
| 2299 | "proxy-tls", | 2292 | "proxy-tls", | ||
| 2300 | - | cfg.Server.TLSCertFile, | 2293 | + | tlsConfig, |
| 2301 | - | cfg.Server.TLSKeyFile, | |||
| 2302 | errChan, | 2294 | errChan, | ||
| 2303 | log, | 2295 | log, | ||
| 2304 | ) | 2296 | ) | ||
| 2305 | - | return proxySrv, proxyReady, tlsSrv, tlsReady, nil | 2297 | + | |
| 2298 | + | return httpSrv, httpReady, tlsSrv, tlsReady, nil | |||
| 2306 | } | 2299 | } | ||
| 2307 | 2300 | ||||
| 2308 | // startServer starts an HTTP server, returning the server instance and a channel | 2301 | // startServer starts an HTTP server, returning the server instance and a channel | ||
| @@ -2342,44 +2335,37 @@ func startServer(addr string, handler http.Handler, name string, protocols *http | |||||
| 2342 | return server, ready | 2335 | return server, ready | ||
| 2343 | } | 2336 | } | ||
| 2344 | 2337 | ||||
| 2345 | - | // startTLSServer starts an HTTPS server with the provided certificate and key. | 2338 | + | // startTLSServerWithConfig starts an HTTPS server using a pre-built tls.Config |
| 2346 | - | // It returns the server instance (for graceful shutdown) and a channel that closes once the port is bound. | 2339 | + | // (e.g. with GetCertificate for on-demand issuance via the internal CA). |
| 2347 | - | func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile string, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) { | 2340 | + | // Returns the server instance and a channel that closes once the port is bound. |
| 2348 | - | ready := make(chan struct{}) | 2341 | + | func startTLSServerWithConfig(addr string, handler http.Handler, name string, tlsCfg *tls.Config, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) { |
| 2349 | - | 2342 | + | srv := &http.Server{ | |
| 2350 | - | server := &http.Server{ | |||
| 2351 | Addr: addr, | 2343 | Addr: addr, | ||
| 2352 | Handler: handler, | 2344 | Handler: handler, | ||
| 2345 | + | TLSConfig: tlsCfg, | |||
| 2353 | ReadHeaderTimeout: 10 * time.Second, | 2346 | ReadHeaderTimeout: 10 * time.Second, | ||
| 2354 | ReadTimeout: 5 * time.Minute, | 2347 | ReadTimeout: 5 * time.Minute, | ||
| 2355 | WriteTimeout: 5 * time.Minute, | 2348 | WriteTimeout: 5 * time.Minute, | ||
| 2356 | IdleTimeout: 120 * time.Second, | 2349 | IdleTimeout: 120 * time.Second, | ||
| 2357 | MaxHeaderBytes: 1 << 20, | 2350 | MaxHeaderBytes: 1 << 20, | ||
| 2358 | - | TLSConfig: &tls.Config{ | |||
| 2359 | - | MinVersion: tls.VersionTLS12, | |||
| 2360 | - | }, | |||
| 2361 | } | 2351 | } | ||
| 2362 | 2352 | ||||
| 2353 | + | ready := make(chan struct{}) | |||
| 2363 | go func() { | 2354 | go func() { | ||
| 2364 | - | log.Info(). | |||
| 2365 | - | Str("address", addr). | |||
| 2366 | - | Str("cert_file", certFile). | |||
| 2367 | - | Str("key_file", keyFile). | |||
| 2368 | - | Msgf("%s server starting", name) | |||
| 2369 | - | ||||
| 2370 | ln, err := net.Listen("tcp", addr) | 2355 | ln, err := net.Listen("tcp", addr) | ||
| 2371 | if err != nil { | 2356 | if err != nil { | ||
| 2372 | - | errChan <- fmt.Errorf("%s server error: %w", name, err) | 2357 | + | errChan <- fmt.Errorf("%s listen: %w", name, err) |
| 2373 | return | 2358 | return | ||
| 2374 | } | 2359 | } | ||
| 2360 | + | tlsLn := tls.NewListener(ln, tlsCfg) | |||
| 2361 | + | log.Info().Str("addr", addr).Str("name", name).Msg("TLS server starting") | |||
| 2375 | close(ready) | 2362 | close(ready) | ||
| 2376 | - | 2363 | + | if err := srv.Serve(tlsLn); err != nil && err != http.ErrServerClosed { | |
| 2377 | - | if err := server.ServeTLS(ln, certFile, keyFile); err != nil && err != http.ErrServerClosed { | 2364 | + | errChan <- fmt.Errorf("%s: %w", name, err) |
| 2378 | - | errChan <- fmt.Errorf("%s server error: %w", name, err) | |||
| 2379 | } | 2365 | } | ||
| 2380 | }() | 2366 | }() | ||
| 2381 | 2367 | ||||
| 2382 | - | return server, ready | 2368 | + | return srv, ready |
| 2383 | } | 2369 | } | ||
| 2384 | 2370 | ||||
| 2385 | // SendReloadSignal sends SIGUSR1 to the running Gordon process. | 2371 | // SendReloadSignal sends SIGUSR1 to the running Gordon process. | ||
| @@ -2633,12 +2619,12 @@ func isProcessAlive(pid int) bool { | |||||
| 2633 | 2619 | ||||
| 2634 | // loadConfig loads configuration from file and sets defaults. | 2620 | // loadConfig loads configuration from file and sets defaults. | ||
| 2635 | func loadConfig(v *viper.Viper, configPath string) error { | 2621 | func loadConfig(v *viper.Viper, configPath string) error { | ||
| 2636 | - | v.SetDefault("server.port", 80) | 2622 | + | v.SetDefault("server.port", 8088) |
| 2637 | v.SetDefault("server.registry_port", 5000) | 2623 | v.SetDefault("server.registry_port", 5000) | ||
| 2638 | - | v.SetDefault("server.tls_enabled", false) | 2624 | + | v.SetDefault("server.tls_port", 8443) |
| 2639 | - | v.SetDefault("server.tls_port", 443) | |||
| 2640 | v.SetDefault("server.tls_cert_file", "") | 2625 | v.SetDefault("server.tls_cert_file", "") | ||
| 2641 | v.SetDefault("server.tls_key_file", "") | 2626 | v.SetDefault("server.tls_key_file", "") | ||
| 2627 | + | v.SetDefault("server.force_https_redirect", false) | |||
| 2642 | v.SetDefault("server.data_dir", DefaultDataDir()) | 2628 | v.SetDefault("server.data_dir", DefaultDataDir()) | ||
| 2643 | v.SetDefault("server.runtime", "auto") | 2629 | v.SetDefault("server.runtime", "auto") | ||
| 2644 | v.SetDefault("logging.level", "info") | 2630 | v.SetDefault("logging.level", "info") | ||
| @@ -2690,7 +2676,6 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 2690 | v.SetDefault("server.registry_allowed_ips", []string{}) | 2676 | v.SetDefault("server.registry_allowed_ips", []string{}) | ||
| 2691 | v.SetDefault("server.proxy_allowed_ips", []string{}) | 2677 | v.SetDefault("server.proxy_allowed_ips", []string{}) | ||
| 2692 | v.SetDefault("server.registry_listen_address", "") | 2678 | v.SetDefault("server.registry_listen_address", "") | ||
| 2693 | - | v.SetDefault("server.force_hsts", false) | |||
| 2694 | v.SetDefault("deploy.readiness_delay", "5s") | 2679 | v.SetDefault("deploy.readiness_delay", "5s") | ||
| 2695 | v.SetDefault("deploy.readiness_mode", "auto") | 2680 | v.SetDefault("deploy.readiness_mode", "auto") | ||
| 2696 | v.SetDefault("deploy.health_timeout", "90s") | 2681 | v.SetDefault("deploy.health_timeout", "90s") | ||
A internal/boundaries/out/mocks/mock_route_checker.go +138 -0
| @@ -0,0 +1,138 @@ | |||
| 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 | + | // NewMockRouteChecker creates a new instance of MockRouteChecker. 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 NewMockRouteChecker(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *MockRouteChecker { | |
| 20 | + | mock := &MockRouteChecker{} | |
| 21 | + | mock.Mock.Test(t) | |
| 22 | + | ||
| 23 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 24 | + | ||
| 25 | + | return mock | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // MockRouteChecker is an autogenerated mock type for the RouteChecker type | |
| 29 | + | type MockRouteChecker struct { | |
| 30 | + | mock.Mock | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | type MockRouteChecker_Expecter struct { | |
| 34 | + | mock *mock.Mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (_m *MockRouteChecker) EXPECT() *MockRouteChecker_Expecter { | |
| 38 | + | return &MockRouteChecker_Expecter{mock: &_m.Mock} | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // GetExternalRoutes provides a mock function for the type MockRouteChecker | |
| 42 | + | func (_mock *MockRouteChecker) GetExternalRoutes() map[string]string { | |
| 43 | + | ret := _mock.Called() | |
| 44 | + | ||
| 45 | + | if len(ret) == 0 { | |
| 46 | + | panic("no return value specified for GetExternalRoutes") | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | var r0 map[string]string | |
| 50 | + | if returnFunc, ok := ret.Get(0).(func() map[string]string); ok { | |
| 51 | + | r0 = returnFunc() | |
| 52 | + | } else { | |
| 53 | + | if ret.Get(0) != nil { | |
| 54 | + | r0 = ret.Get(0).(map[string]string) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | return r0 | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // MockRouteChecker_GetExternalRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetExternalRoutes' | |
| 61 | + | type MockRouteChecker_GetExternalRoutes_Call struct { | |
| 62 | + | *mock.Call | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // GetExternalRoutes is a helper method to define mock.On call | |
| 66 | + | func (_e *MockRouteChecker_Expecter) GetExternalRoutes() *MockRouteChecker_GetExternalRoutes_Call { | |
| 67 | + | return &MockRouteChecker_GetExternalRoutes_Call{Call: _e.mock.On("GetExternalRoutes")} | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (_c *MockRouteChecker_GetExternalRoutes_Call) Run(run func()) *MockRouteChecker_GetExternalRoutes_Call { | |
| 71 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 72 | + | run() | |
| 73 | + | }) | |
| 74 | + | return _c | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (_c *MockRouteChecker_GetExternalRoutes_Call) Return(stringToString map[string]string) *MockRouteChecker_GetExternalRoutes_Call { | |
| 78 | + | _c.Call.Return(stringToString) | |
| 79 | + | return _c | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (_c *MockRouteChecker_GetExternalRoutes_Call) RunAndReturn(run func() map[string]string) *MockRouteChecker_GetExternalRoutes_Call { | |
| 83 | + | _c.Call.Return(run) | |
| 84 | + | return _c | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // GetRoutes provides a mock function for the type MockRouteChecker | |
| 88 | + | func (_mock *MockRouteChecker) GetRoutes(ctx context.Context) []domain.Route { | |
| 89 | + | ret := _mock.Called(ctx) | |
| 90 | + | ||
| 91 | + | if len(ret) == 0 { | |
| 92 | + | panic("no return value specified for GetRoutes") | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | var r0 []domain.Route | |
| 96 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.Route); ok { | |
| 97 | + | r0 = returnFunc(ctx) | |
| 98 | + | } else { | |
| 99 | + | if ret.Get(0) != nil { | |
| 100 | + | r0 = ret.Get(0).([]domain.Route) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | return r0 | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // MockRouteChecker_GetRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRoutes' | |
| 107 | + | type MockRouteChecker_GetRoutes_Call struct { | |
| 108 | + | *mock.Call | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // GetRoutes is a helper method to define mock.On call | |
| 112 | + | // - ctx context.Context | |
| 113 | + | func (_e *MockRouteChecker_Expecter) GetRoutes(ctx interface{}) *MockRouteChecker_GetRoutes_Call { | |
| 114 | + | return &MockRouteChecker_GetRoutes_Call{Call: _e.mock.On("GetRoutes", ctx)} | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func (_c *MockRouteChecker_GetRoutes_Call) Run(run func(ctx context.Context)) *MockRouteChecker_GetRoutes_Call { | |
| 118 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 119 | + | var arg0 context.Context | |
| 120 | + | if args[0] != nil { | |
| 121 | + | arg0 = args[0].(context.Context) | |
| 122 | + | } | |
| 123 | + | run( | |
| 124 | + | arg0, | |
| 125 | + | ) | |
| 126 | + | }) | |
| 127 | + | return _c | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func (_c *MockRouteChecker_GetRoutes_Call) Return(routes []domain.Route) *MockRouteChecker_GetRoutes_Call { | |
| 131 | + | _c.Call.Return(routes) | |
| 132 | + | return _c | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func (_c *MockRouteChecker_GetRoutes_Call) RunAndReturn(run func(ctx context.Context) []domain.Route) *MockRouteChecker_GetRoutes_Call { | |
| 136 | + | _c.Call.Return(run) | |
| 137 | + | return _c | |
| 138 | + | } | |
| @@ -0,0 +1,138 @@ | |||||
| 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 | + | // NewMockRouteChecker creates a new instance of MockRouteChecker. 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 NewMockRouteChecker(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *MockRouteChecker { | |||
| 20 | + | mock := &MockRouteChecker{} | |||
| 21 | + | mock.Mock.Test(t) | |||
| 22 | + | ||||
| 23 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 24 | + | ||||
| 25 | + | return mock | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // MockRouteChecker is an autogenerated mock type for the RouteChecker type | |||
| 29 | + | type MockRouteChecker struct { | |||
| 30 | + | mock.Mock | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | type MockRouteChecker_Expecter struct { | |||
| 34 | + | mock *mock.Mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func (_m *MockRouteChecker) EXPECT() *MockRouteChecker_Expecter { | |||
| 38 | + | return &MockRouteChecker_Expecter{mock: &_m.Mock} | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // GetExternalRoutes provides a mock function for the type MockRouteChecker | |||
| 42 | + | func (_mock *MockRouteChecker) GetExternalRoutes() map[string]string { | |||
| 43 | + | ret := _mock.Called() | |||
| 44 | + | ||||
| 45 | + | if len(ret) == 0 { | |||
| 46 | + | panic("no return value specified for GetExternalRoutes") | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | var r0 map[string]string | |||
| 50 | + | if returnFunc, ok := ret.Get(0).(func() map[string]string); ok { | |||
| 51 | + | r0 = returnFunc() | |||
| 52 | + | } else { | |||
| 53 | + | if ret.Get(0) != nil { | |||
| 54 | + | r0 = ret.Get(0).(map[string]string) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | return r0 | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // MockRouteChecker_GetExternalRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetExternalRoutes' | |||
| 61 | + | type MockRouteChecker_GetExternalRoutes_Call struct { | |||
| 62 | + | *mock.Call | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // GetExternalRoutes is a helper method to define mock.On call | |||
| 66 | + | func (_e *MockRouteChecker_Expecter) GetExternalRoutes() *MockRouteChecker_GetExternalRoutes_Call { | |||
| 67 | + | return &MockRouteChecker_GetExternalRoutes_Call{Call: _e.mock.On("GetExternalRoutes")} | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (_c *MockRouteChecker_GetExternalRoutes_Call) Run(run func()) *MockRouteChecker_GetExternalRoutes_Call { | |||
| 71 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 72 | + | run() | |||
| 73 | + | }) | |||
| 74 | + | return _c | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func (_c *MockRouteChecker_GetExternalRoutes_Call) Return(stringToString map[string]string) *MockRouteChecker_GetExternalRoutes_Call { | |||
| 78 | + | _c.Call.Return(stringToString) | |||
| 79 | + | return _c | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (_c *MockRouteChecker_GetExternalRoutes_Call) RunAndReturn(run func() map[string]string) *MockRouteChecker_GetExternalRoutes_Call { | |||
| 83 | + | _c.Call.Return(run) | |||
| 84 | + | return _c | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // GetRoutes provides a mock function for the type MockRouteChecker | |||
| 88 | + | func (_mock *MockRouteChecker) GetRoutes(ctx context.Context) []domain.Route { | |||
| 89 | + | ret := _mock.Called(ctx) | |||
| 90 | + | ||||
| 91 | + | if len(ret) == 0 { | |||
| 92 | + | panic("no return value specified for GetRoutes") | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | var r0 []domain.Route | |||
| 96 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.Route); ok { | |||
| 97 | + | r0 = returnFunc(ctx) | |||
| 98 | + | } else { | |||
| 99 | + | if ret.Get(0) != nil { | |||
| 100 | + | r0 = ret.Get(0).([]domain.Route) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | return r0 | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // MockRouteChecker_GetRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRoutes' | |||
| 107 | + | type MockRouteChecker_GetRoutes_Call struct { | |||
| 108 | + | *mock.Call | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // GetRoutes is a helper method to define mock.On call | |||
| 112 | + | // - ctx context.Context | |||
| 113 | + | func (_e *MockRouteChecker_Expecter) GetRoutes(ctx interface{}) *MockRouteChecker_GetRoutes_Call { | |||
| 114 | + | return &MockRouteChecker_GetRoutes_Call{Call: _e.mock.On("GetRoutes", ctx)} | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func (_c *MockRouteChecker_GetRoutes_Call) Run(run func(ctx context.Context)) *MockRouteChecker_GetRoutes_Call { | |||
| 118 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 119 | + | var arg0 context.Context | |||
| 120 | + | if args[0] != nil { | |||
| 121 | + | arg0 = args[0].(context.Context) | |||
| 122 | + | } | |||
| 123 | + | run( | |||
| 124 | + | arg0, | |||
| 125 | + | ) | |||
| 126 | + | }) | |||
| 127 | + | return _c | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func (_c *MockRouteChecker_GetRoutes_Call) Return(routes []domain.Route) *MockRouteChecker_GetRoutes_Call { | |||
| 131 | + | _c.Call.Return(routes) | |||
| 132 | + | return _c | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func (_c *MockRouteChecker_GetRoutes_Call) RunAndReturn(run func(ctx context.Context) []domain.Route) *MockRouteChecker_GetRoutes_Call { | |||
| 136 | + | _c.Call.Return(run) | |||
| 137 | + | return _c | |||
| 138 | + | } | |||
A internal/boundaries/out/pki.go +52 -0
| @@ -0,0 +1,52 @@ | |||
| 1 | + | package out | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/tls" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/gordon/internal/domain" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // RouteChecker provides route lookup for domain validation. | |
| 12 | + | type RouteChecker interface { | |
| 13 | + | GetRoutes(ctx context.Context) []domain.Route | |
| 14 | + | GetExternalRoutes() map[string]string | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // CertificateAuthority provides internal PKI operations. | |
| 18 | + | // The adapter handles only cryptography — allowlist checks and caching | |
| 19 | + | // belong in the use case layer. | |
| 20 | + | type CertificateAuthority interface { | |
| 21 | + | // RootCertificate returns the root CA certificate in PEM format. | |
| 22 | + | RootCertificate() []byte | |
| 23 | + | ||
| 24 | + | // RootCertificateDER returns the root CA certificate in DER format | |
| 25 | + | // (for embedding in iOS .mobileconfig profiles). | |
| 26 | + | RootCertificateDER() []byte | |
| 27 | + | ||
| 28 | + | // IssueCertificate generates a leaf certificate for the given domain, | |
| 29 | + | // signed by the current intermediate CA. The caller must validate the | |
| 30 | + | // domain against the route table before calling this. | |
| 31 | + | IssueCertificate(domain string) (*tls.Certificate, error) | |
| 32 | + | ||
| 33 | + | // IntermediateExpiresAt returns the intermediate CA certificate expiry. | |
| 34 | + | IntermediateExpiresAt() time.Time | |
| 35 | + | ||
| 36 | + | // RenewIntermediate regenerates the intermediate CA certificate, | |
| 37 | + | // signed by the root CA. Existing leaf certs remain valid. | |
| 38 | + | RenewIntermediate() error | |
| 39 | + | ||
| 40 | + | // RootFingerprint returns the SHA-256 fingerprint of the root CA cert | |
| 41 | + | // formatted as colon-separated hex (e.g. "AB:CD:EF:..."). | |
| 42 | + | RootFingerprint() string | |
| 43 | + | ||
| 44 | + | // RootCommonName returns the CN of the root CA certificate. | |
| 45 | + | RootCommonName() string | |
| 46 | + | ||
| 47 | + | // LeafLifetime returns the configured leaf certificate lifetime. | |
| 48 | + | LeafLifetime() time.Duration | |
| 49 | + | ||
| 50 | + | // IntermediateLifetime returns the configured intermediate CA lifetime. | |
| 51 | + | IntermediateLifetime() time.Duration | |
| 52 | + | } | |
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | package out | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/tls" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/gordon/internal/domain" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // RouteChecker provides route lookup for domain validation. | |||
| 12 | + | type RouteChecker interface { | |||
| 13 | + | GetRoutes(ctx context.Context) []domain.Route | |||
| 14 | + | GetExternalRoutes() map[string]string | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // CertificateAuthority provides internal PKI operations. | |||
| 18 | + | // The adapter handles only cryptography — allowlist checks and caching | |||
| 19 | + | // belong in the use case layer. | |||
| 20 | + | type CertificateAuthority interface { | |||
| 21 | + | // RootCertificate returns the root CA certificate in PEM format. | |||
| 22 | + | RootCertificate() []byte | |||
| 23 | + | ||||
| 24 | + | // RootCertificateDER returns the root CA certificate in DER format | |||
| 25 | + | // (for embedding in iOS .mobileconfig profiles). | |||
| 26 | + | RootCertificateDER() []byte | |||
| 27 | + | ||||
| 28 | + | // IssueCertificate generates a leaf certificate for the given domain, | |||
| 29 | + | // signed by the current intermediate CA. The caller must validate the | |||
| 30 | + | // domain against the route table before calling this. | |||
| 31 | + | IssueCertificate(domain string) (*tls.Certificate, error) | |||
| 32 | + | ||||
| 33 | + | // IntermediateExpiresAt returns the intermediate CA certificate expiry. | |||
| 34 | + | IntermediateExpiresAt() time.Time | |||
| 35 | + | ||||
| 36 | + | // RenewIntermediate regenerates the intermediate CA certificate, | |||
| 37 | + | // signed by the root CA. Existing leaf certs remain valid. | |||
| 38 | + | RenewIntermediate() error | |||
| 39 | + | ||||
| 40 | + | // RootFingerprint returns the SHA-256 fingerprint of the root CA cert | |||
| 41 | + | // formatted as colon-separated hex (e.g. "AB:CD:EF:..."). | |||
| 42 | + | RootFingerprint() string | |||
| 43 | + | ||||
| 44 | + | // RootCommonName returns the CN of the root CA certificate. | |||
| 45 | + | RootCommonName() string | |||
| 46 | + | ||||
| 47 | + | // LeafLifetime returns the configured leaf certificate lifetime. | |||
| 48 | + | LeafLifetime() time.Duration | |||
| 49 | + | ||||
| 50 | + | // IntermediateLifetime returns the configured intermediate CA lifetime. | |||
| 51 | + | IntermediateLifetime() time.Duration | |||
| 52 | + | } | |||
M internal/usecase/config/service_test.go +2 -2
| @@ -1412,8 +1412,8 @@ backend = ["app.example.com"] | |||
| 1412 | 1412 | ||
| 1413 | 1413 | v := viper.New() | |
| 1414 | 1414 | v.SetConfigFile(configFile) | |
| 1415 | - | v.SetDefault("server.port", 80) | |
| 1416 | - | v.SetDefault("server.tls_port", 443) | |
| 1415 | + | v.SetDefault("server.port", 8088) | |
| 1416 | + | v.SetDefault("server.tls_port", 8443) | |
| 1417 | 1417 | v.SetDefault("auth.secrets_backend", "") | |
| 1418 | 1418 | v.SetDefault("auto_route.enabled", false) | |
| 1419 | 1419 | v.SetDefault("network_isolation.enabled", false) | |
| @@ -1412,8 +1412,8 @@ backend = ["app.example.com"] | |||||
| 1412 | 1412 | ||||
| 1413 | v := viper.New() | 1413 | v := viper.New() | ||
| 1414 | v.SetConfigFile(configFile) | 1414 | v.SetConfigFile(configFile) | ||
| 1415 | - | v.SetDefault("server.port", 80) | 1415 | + | v.SetDefault("server.port", 8088) |
| 1416 | - | v.SetDefault("server.tls_port", 443) | 1416 | + | v.SetDefault("server.tls_port", 8443) |
| 1417 | v.SetDefault("auth.secrets_backend", "") | 1417 | v.SetDefault("auth.secrets_backend", "") | ||
| 1418 | v.SetDefault("auto_route.enabled", false) | 1418 | v.SetDefault("auto_route.enabled", false) | ||
| 1419 | v.SetDefault("network_isolation.enabled", false) | 1419 | v.SetDefault("network_isolation.enabled", false) | ||
A internal/usecase/pki/service.go +215 -0
| @@ -0,0 +1,215 @@ | |||
| 1 | + | package pki | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/tls" | |
| 6 | + | "crypto/x509" | |
| 7 | + | "sync" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zerowrap" | |
| 11 | + | "golang.org/x/sync/singleflight" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | const ( | |
| 17 | + | renewalWindowRatio = 0.2 | |
| 18 | + | checkInterval = 10 * time.Minute | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | type cachedCert struct { | |
| 22 | + | cert *tls.Certificate | |
| 23 | + | expiresAt time.Time | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // Service provides on-demand TLS certificate issuance with caching | |
| 27 | + | // and automatic intermediate CA renewal. | |
| 28 | + | // | |
| 29 | + | // TODO: consider adding CRL/OCSP support for certificate revocation, | |
| 30 | + | // especially given the 10-year root CA lifetime. | |
| 31 | + | type Service struct { | |
| 32 | + | ca out.CertificateAuthority | |
| 33 | + | routes out.RouteChecker | |
| 34 | + | log zerowrap.Logger | |
| 35 | + | ||
| 36 | + | cache sync.Map // map[string]*cachedCert | |
| 37 | + | flight singleflight.Group | |
| 38 | + | cancel context.CancelFunc | |
| 39 | + | done chan struct{} | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // NewService creates a PKI service and starts background maintenance goroutines. | |
| 43 | + | // It performs an initial intermediate renewal check synchronously so the first | |
| 44 | + | // TLS handshakes never use a nearly-expired intermediate. | |
| 45 | + | func NewService(ctx context.Context, ca out.CertificateAuthority, routes out.RouteChecker, log zerowrap.Logger) *Service { | |
| 46 | + | ctx, cancel := context.WithCancel(ctx) | |
| 47 | + | svc := &Service{ | |
| 48 | + | ca: ca, | |
| 49 | + | routes: routes, | |
| 50 | + | log: log, | |
| 51 | + | cancel: cancel, | |
| 52 | + | done: make(chan struct{}), | |
| 53 | + | } | |
| 54 | + | svc.renewIntermediateIfNeeded() | |
| 55 | + | go svc.maintenanceLoop(ctx) | |
| 56 | + | return svc | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // Stop cancels background goroutines and waits for them to finish. | |
| 60 | + | func (s *Service) Stop() { | |
| 61 | + | s.cancel() | |
| 62 | + | <-s.done | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // CachedCertCount returns the number of cached leaf certificates. | |
| 66 | + | func (s *Service) CachedCertCount() int { | |
| 67 | + | count := 0 | |
| 68 | + | s.cache.Range(func(_, _ any) bool { | |
| 69 | + | count++ | |
| 70 | + | return true | |
| 71 | + | }) | |
| 72 | + | return count | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // GetCertificate is the tls.Config.GetCertificate callback. | |
| 76 | + | // It returns (nil, nil) for unknown domains so Go's TLS stack | |
| 77 | + | // falls back to tls.Config.Certificates. | |
| 78 | + | func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { | |
| 79 | + | domain := hello.ServerName | |
| 80 | + | if domain == "" { | |
| 81 | + | return nil, nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | if entry, ok := s.cache.Load(domain); ok { | |
| 85 | + | if cached, ok := entry.(*cachedCert); ok { | |
| 86 | + | if time.Now().Before(cached.expiresAt) { | |
| 87 | + | if !s.isDomainAllowed(hello.Context(), domain) { | |
| 88 | + | s.cache.Delete(domain) | |
| 89 | + | return nil, nil | |
| 90 | + | } | |
| 91 | + | return cached.cert, nil | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | s.cache.Delete(domain) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | if !s.isDomainAllowed(hello.Context(), domain) { | |
| 98 | + | return nil, nil | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | result, err, _ := s.flight.Do(domain, func() (any, error) { | |
| 102 | + | // Re-check cache; another goroutine in the same flight may have populated it. | |
| 103 | + | if entry, ok := s.cache.Load(domain); ok { | |
| 104 | + | if cached, ok := entry.(*cachedCert); ok { | |
| 105 | + | if time.Now().Before(cached.expiresAt) { | |
| 106 | + | return cached.cert, nil | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | s.cache.Delete(domain) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | cert, err := s.ca.IssueCertificate(domain) | |
| 113 | + | if err != nil { | |
| 114 | + | s.log.Error().Err(err).Str("domain", domain).Msg("failed to issue leaf cert") | |
| 115 | + | return nil, err | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | expiresAt := time.Now().Add(s.leafLifetime()) | |
| 119 | + | if len(cert.Certificate) > 0 { | |
| 120 | + | if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil { | |
| 121 | + | expiresAt = leaf.NotAfter | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | s.cache.Store(domain, &cachedCert{ | |
| 125 | + | cert: cert, | |
| 126 | + | expiresAt: expiresAt, | |
| 127 | + | }) | |
| 128 | + | ||
| 129 | + | s.log.Debug().Str("domain", domain).Msg("issued new leaf certificate") | |
| 130 | + | return cert, nil | |
| 131 | + | }) | |
| 132 | + | if err != nil { | |
| 133 | + | return nil, err | |
| 134 | + | } | |
| 135 | + | if result == nil { | |
| 136 | + | return nil, nil | |
| 137 | + | } | |
| 138 | + | return result.(*tls.Certificate), nil | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func (s *Service) isDomainAllowed(ctx context.Context, domain string) bool { | |
| 142 | + | for _, r := range s.routes.GetRoutes(ctx) { | |
| 143 | + | if r.Domain == domain { | |
| 144 | + | return true | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | if _, ok := s.routes.GetExternalRoutes()[domain]; ok { | |
| 148 | + | return true | |
| 149 | + | } | |
| 150 | + | return false | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func (s *Service) leafLifetime() time.Duration { | |
| 154 | + | return s.ca.LeafLifetime() | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func (s *Service) maintenanceLoop(ctx context.Context) { | |
| 158 | + | defer close(s.done) | |
| 159 | + | ticker := time.NewTicker(checkInterval) | |
| 160 | + | defer ticker.Stop() | |
| 161 | + | ||
| 162 | + | for { | |
| 163 | + | select { | |
| 164 | + | case <-ctx.Done(): | |
| 165 | + | return | |
| 166 | + | case <-ticker.C: | |
| 167 | + | s.renewIntermediateIfNeeded() | |
| 168 | + | s.sweepExpiredCerts(ctx) | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func (s *Service) renewIntermediateIfNeeded() { | |
| 174 | + | expiresAt := s.ca.IntermediateExpiresAt() | |
| 175 | + | remaining := time.Until(expiresAt) | |
| 176 | + | lifetime := s.ca.IntermediateLifetime() | |
| 177 | + | window := time.Duration(float64(lifetime) * renewalWindowRatio) | |
| 178 | + | if remaining < window { | |
| 179 | + | s.log.Info().Time("expires", expiresAt).Msg("renewing intermediate CA") | |
| 180 | + | if err := s.ca.RenewIntermediate(); err != nil { | |
| 181 | + | s.log.Error().Err(err).Msg("intermediate CA renewal failed") | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func (s *Service) sweepExpiredCerts(ctx context.Context) { | |
| 187 | + | now := time.Now() | |
| 188 | + | ||
| 189 | + | // Fetch routes once for all cache entries. | |
| 190 | + | routes := s.routes.GetRoutes(ctx) | |
| 191 | + | extRoutes := s.routes.GetExternalRoutes() | |
| 192 | + | ||
| 193 | + | allowed := make(map[string]struct{}, len(routes)+len(extRoutes)) | |
| 194 | + | for _, r := range routes { | |
| 195 | + | allowed[r.Domain] = struct{}{} | |
| 196 | + | } | |
| 197 | + | for d := range extRoutes { | |
| 198 | + | allowed[d] = struct{}{} | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | swept := 0 | |
| 202 | + | s.cache.Range(func(key, value any) bool { | |
| 203 | + | domain := key.(string) | |
| 204 | + | cached := value.(*cachedCert) | |
| 205 | + | _, ok := allowed[domain] | |
| 206 | + | if now.After(cached.expiresAt) || !ok { | |
| 207 | + | s.cache.Delete(key) | |
| 208 | + | swept++ | |
| 209 | + | } | |
| 210 | + | return true | |
| 211 | + | }) | |
| 212 | + | if swept > 0 { | |
| 213 | + | s.log.Debug().Int("swept", swept).Msg("cleaned expired/unauthorized leaf certs from cache") | |
| 214 | + | } | |
| 215 | + | } | |
| @@ -0,0 +1,215 @@ | |||||
| 1 | + | package pki | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/tls" | |||
| 6 | + | "crypto/x509" | |||
| 7 | + | "sync" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zerowrap" | |||
| 11 | + | "golang.org/x/sync/singleflight" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | const ( | |||
| 17 | + | renewalWindowRatio = 0.2 | |||
| 18 | + | checkInterval = 10 * time.Minute | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | type cachedCert struct { | |||
| 22 | + | cert *tls.Certificate | |||
| 23 | + | expiresAt time.Time | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // Service provides on-demand TLS certificate issuance with caching | |||
| 27 | + | // and automatic intermediate CA renewal. | |||
| 28 | + | // | |||
| 29 | + | // TODO: consider adding CRL/OCSP support for certificate revocation, | |||
| 30 | + | // especially given the 10-year root CA lifetime. | |||
| 31 | + | type Service struct { | |||
| 32 | + | ca out.CertificateAuthority | |||
| 33 | + | routes out.RouteChecker | |||
| 34 | + | log zerowrap.Logger | |||
| 35 | + | ||||
| 36 | + | cache sync.Map // map[string]*cachedCert | |||
| 37 | + | flight singleflight.Group | |||
| 38 | + | cancel context.CancelFunc | |||
| 39 | + | done chan struct{} | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // NewService creates a PKI service and starts background maintenance goroutines. | |||
| 43 | + | // It performs an initial intermediate renewal check synchronously so the first | |||
| 44 | + | // TLS handshakes never use a nearly-expired intermediate. | |||
| 45 | + | func NewService(ctx context.Context, ca out.CertificateAuthority, routes out.RouteChecker, log zerowrap.Logger) *Service { | |||
| 46 | + | ctx, cancel := context.WithCancel(ctx) | |||
| 47 | + | svc := &Service{ | |||
| 48 | + | ca: ca, | |||
| 49 | + | routes: routes, | |||
| 50 | + | log: log, | |||
| 51 | + | cancel: cancel, | |||
| 52 | + | done: make(chan struct{}), | |||
| 53 | + | } | |||
| 54 | + | svc.renewIntermediateIfNeeded() | |||
| 55 | + | go svc.maintenanceLoop(ctx) | |||
| 56 | + | return svc | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // Stop cancels background goroutines and waits for them to finish. | |||
| 60 | + | func (s *Service) Stop() { | |||
| 61 | + | s.cancel() | |||
| 62 | + | <-s.done | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // CachedCertCount returns the number of cached leaf certificates. | |||
| 66 | + | func (s *Service) CachedCertCount() int { | |||
| 67 | + | count := 0 | |||
| 68 | + | s.cache.Range(func(_, _ any) bool { | |||
| 69 | + | count++ | |||
| 70 | + | return true | |||
| 71 | + | }) | |||
| 72 | + | return count | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // GetCertificate is the tls.Config.GetCertificate callback. | |||
| 76 | + | // It returns (nil, nil) for unknown domains so Go's TLS stack | |||
| 77 | + | // falls back to tls.Config.Certificates. | |||
| 78 | + | func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { | |||
| 79 | + | domain := hello.ServerName | |||
| 80 | + | if domain == "" { | |||
| 81 | + | return nil, nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | if entry, ok := s.cache.Load(domain); ok { | |||
| 85 | + | if cached, ok := entry.(*cachedCert); ok { | |||
| 86 | + | if time.Now().Before(cached.expiresAt) { | |||
| 87 | + | if !s.isDomainAllowed(hello.Context(), domain) { | |||
| 88 | + | s.cache.Delete(domain) | |||
| 89 | + | return nil, nil | |||
| 90 | + | } | |||
| 91 | + | return cached.cert, nil | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | s.cache.Delete(domain) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | if !s.isDomainAllowed(hello.Context(), domain) { | |||
| 98 | + | return nil, nil | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | result, err, _ := s.flight.Do(domain, func() (any, error) { | |||
| 102 | + | // Re-check cache; another goroutine in the same flight may have populated it. | |||
| 103 | + | if entry, ok := s.cache.Load(domain); ok { | |||
| 104 | + | if cached, ok := entry.(*cachedCert); ok { | |||
| 105 | + | if time.Now().Before(cached.expiresAt) { | |||
| 106 | + | return cached.cert, nil | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | s.cache.Delete(domain) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | cert, err := s.ca.IssueCertificate(domain) | |||
| 113 | + | if err != nil { | |||
| 114 | + | s.log.Error().Err(err).Str("domain", domain).Msg("failed to issue leaf cert") | |||
| 115 | + | return nil, err | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | expiresAt := time.Now().Add(s.leafLifetime()) | |||
| 119 | + | if len(cert.Certificate) > 0 { | |||
| 120 | + | if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil { | |||
| 121 | + | expiresAt = leaf.NotAfter | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | s.cache.Store(domain, &cachedCert{ | |||
| 125 | + | cert: cert, | |||
| 126 | + | expiresAt: expiresAt, | |||
| 127 | + | }) | |||
| 128 | + | ||||
| 129 | + | s.log.Debug().Str("domain", domain).Msg("issued new leaf certificate") | |||
| 130 | + | return cert, nil | |||
| 131 | + | }) | |||
| 132 | + | if err != nil { | |||
| 133 | + | return nil, err | |||
| 134 | + | } | |||
| 135 | + | if result == nil { | |||
| 136 | + | return nil, nil | |||
| 137 | + | } | |||
| 138 | + | return result.(*tls.Certificate), nil | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func (s *Service) isDomainAllowed(ctx context.Context, domain string) bool { | |||
| 142 | + | for _, r := range s.routes.GetRoutes(ctx) { | |||
| 143 | + | if r.Domain == domain { | |||
| 144 | + | return true | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | if _, ok := s.routes.GetExternalRoutes()[domain]; ok { | |||
| 148 | + | return true | |||
| 149 | + | } | |||
| 150 | + | return false | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func (s *Service) leafLifetime() time.Duration { | |||
| 154 | + | return s.ca.LeafLifetime() | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func (s *Service) maintenanceLoop(ctx context.Context) { | |||
| 158 | + | defer close(s.done) | |||
| 159 | + | ticker := time.NewTicker(checkInterval) | |||
| 160 | + | defer ticker.Stop() | |||
| 161 | + | ||||
| 162 | + | for { | |||
| 163 | + | select { | |||
| 164 | + | case <-ctx.Done(): | |||
| 165 | + | return | |||
| 166 | + | case <-ticker.C: | |||
| 167 | + | s.renewIntermediateIfNeeded() | |||
| 168 | + | s.sweepExpiredCerts(ctx) | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func (s *Service) renewIntermediateIfNeeded() { | |||
| 174 | + | expiresAt := s.ca.IntermediateExpiresAt() | |||
| 175 | + | remaining := time.Until(expiresAt) | |||
| 176 | + | lifetime := s.ca.IntermediateLifetime() | |||
| 177 | + | window := time.Duration(float64(lifetime) * renewalWindowRatio) | |||
| 178 | + | if remaining < window { | |||
| 179 | + | s.log.Info().Time("expires", expiresAt).Msg("renewing intermediate CA") | |||
| 180 | + | if err := s.ca.RenewIntermediate(); err != nil { | |||
| 181 | + | s.log.Error().Err(err).Msg("intermediate CA renewal failed") | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func (s *Service) sweepExpiredCerts(ctx context.Context) { | |||
| 187 | + | now := time.Now() | |||
| 188 | + | ||||
| 189 | + | // Fetch routes once for all cache entries. | |||
| 190 | + | routes := s.routes.GetRoutes(ctx) | |||
| 191 | + | extRoutes := s.routes.GetExternalRoutes() | |||
| 192 | + | ||||
| 193 | + | allowed := make(map[string]struct{}, len(routes)+len(extRoutes)) | |||
| 194 | + | for _, r := range routes { | |||
| 195 | + | allowed[r.Domain] = struct{}{} | |||
| 196 | + | } | |||
| 197 | + | for d := range extRoutes { | |||
| 198 | + | allowed[d] = struct{}{} | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | swept := 0 | |||
| 202 | + | s.cache.Range(func(key, value any) bool { | |||
| 203 | + | domain := key.(string) | |||
| 204 | + | cached := value.(*cachedCert) | |||
| 205 | + | _, ok := allowed[domain] | |||
| 206 | + | if now.After(cached.expiresAt) || !ok { | |||
| 207 | + | s.cache.Delete(key) | |||
| 208 | + | swept++ | |||
| 209 | + | } | |||
| 210 | + | return true | |||
| 211 | + | }) | |||
| 212 | + | if swept > 0 { | |||
| 213 | + | s.log.Debug().Int("swept", swept).Msg("cleaned expired/unauthorized leaf certs from cache") | |||
| 214 | + | } | |||
| 215 | + | } | |||
A internal/usecase/pki/service_test.go +92 -0
| @@ -0,0 +1,92 @@ | |||
| 1 | + | package pki_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/tls" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zerowrap" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 14 | + | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 15 | + | "github.com/bnema/gordon/internal/domain" | |
| 16 | + | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func testLogger() zerowrap.Logger { return zerowrap.Default() } | |
| 20 | + | ||
| 21 | + | func newRouteCheckerMock(t *testing.T, domains ...string) *mocks.MockRouteChecker { | |
| 22 | + | m := mocks.NewMockRouteChecker(t) | |
| 23 | + | routes := make([]domain.Route, len(domains)) | |
| 24 | + | for i, d := range domains { | |
| 25 | + | routes[i] = domain.Route{Domain: d} | |
| 26 | + | } | |
| 27 | + | m.EXPECT().GetRoutes(mock.Anything).Return(routes).Maybe() | |
| 28 | + | m.EXPECT().GetExternalRoutes().Return(nil).Maybe() | |
| 29 | + | return m | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestService_GetCertificate_KnownDomain(t *testing.T) { | |
| 33 | + | dir := t.TempDir() | |
| 34 | + | ca, err := pkiadapter.NewCA(dir, testLogger()) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | ||
| 37 | + | cfg := newRouteCheckerMock(t, "app.example.com") | |
| 38 | + | ||
| 39 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 40 | + | defer cancel() | |
| 41 | + | ||
| 42 | + | svc := pkiusecase.NewService(ctx, ca, cfg, testLogger()) | |
| 43 | + | defer svc.Stop() | |
| 44 | + | ||
| 45 | + | hello := &tls.ClientHelloInfo{ServerName: "app.example.com"} | |
| 46 | + | cert, err := svc.GetCertificate(hello) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | require.NotNil(t, cert) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestService_GetCertificate_UnknownDomain(t *testing.T) { | |
| 52 | + | dir := t.TempDir() | |
| 53 | + | ca, err := pkiadapter.NewCA(dir, testLogger()) | |
| 54 | + | require.NoError(t, err) | |
| 55 | + | ||
| 56 | + | cfg := newRouteCheckerMock(t, "app.example.com") | |
| 57 | + | ||
| 58 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 59 | + | defer cancel() | |
| 60 | + | ||
| 61 | + | svc := pkiusecase.NewService(ctx, ca, cfg, testLogger()) | |
| 62 | + | defer svc.Stop() | |
| 63 | + | ||
| 64 | + | hello := &tls.ClientHelloInfo{ServerName: "unknown.example.com"} | |
| 65 | + | cert, err := svc.GetCertificate(hello) | |
| 66 | + | assert.NoError(t, err) | |
| 67 | + | assert.Nil(t, cert) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestService_GetCertificate_Caching(t *testing.T) { | |
| 71 | + | dir := t.TempDir() | |
| 72 | + | ca, err := pkiadapter.NewCA(dir, testLogger()) | |
| 73 | + | require.NoError(t, err) | |
| 74 | + | ||
| 75 | + | cfg := newRouteCheckerMock(t, "cached.example.com") | |
| 76 | + | ||
| 77 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 78 | + | defer cancel() | |
| 79 | + | ||
| 80 | + | svc := pkiusecase.NewService(ctx, ca, cfg, testLogger()) | |
| 81 | + | defer svc.Stop() | |
| 82 | + | ||
| 83 | + | hello := &tls.ClientHelloInfo{ServerName: "cached.example.com"} | |
| 84 | + | cert1, err := svc.GetCertificate(hello) | |
| 85 | + | require.NoError(t, err) | |
| 86 | + | ||
| 87 | + | cert2, err := svc.GetCertificate(hello) | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | ||
| 90 | + | // Same pointer = served from cache | |
| 91 | + | assert.Same(t, cert1, cert2, "second call should return cached cert") | |
| 92 | + | } | |
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | package pki_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/tls" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zerowrap" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |||
| 14 | + | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 15 | + | "github.com/bnema/gordon/internal/domain" | |||
| 16 | + | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func testLogger() zerowrap.Logger { return zerowrap.Default() } | |||
| 20 | + | ||||
| 21 | + | func newRouteCheckerMock(t *testing.T, domains ...string) *mocks.MockRouteChecker { | |||
| 22 | + | m := mocks.NewMockRouteChecker(t) | |||
| 23 | + | routes := make([]domain.Route, len(domains)) | |||
| 24 | + | for i, d := range domains { | |||
| 25 | + | routes[i] = domain.Route{Domain: d} | |||
| 26 | + | } | |||
| 27 | + | m.EXPECT().GetRoutes(mock.Anything).Return(routes).Maybe() | |||
| 28 | + | m.EXPECT().GetExternalRoutes().Return(nil).Maybe() | |||
| 29 | + | return m | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestService_GetCertificate_KnownDomain(t *testing.T) { | |||
| 33 | + | dir := t.TempDir() | |||
| 34 | + | ca, err := pkiadapter.NewCA(dir, testLogger()) | |||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | ||||
| 37 | + | cfg := newRouteCheckerMock(t, "app.example.com") | |||
| 38 | + | ||||
| 39 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 40 | + | defer cancel() | |||
| 41 | + | ||||
| 42 | + | svc := pkiusecase.NewService(ctx, ca, cfg, testLogger()) | |||
| 43 | + | defer svc.Stop() | |||
| 44 | + | ||||
| 45 | + | hello := &tls.ClientHelloInfo{ServerName: "app.example.com"} | |||
| 46 | + | cert, err := svc.GetCertificate(hello) | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | require.NotNil(t, cert) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestService_GetCertificate_UnknownDomain(t *testing.T) { | |||
| 52 | + | dir := t.TempDir() | |||
| 53 | + | ca, err := pkiadapter.NewCA(dir, testLogger()) | |||
| 54 | + | require.NoError(t, err) | |||
| 55 | + | ||||
| 56 | + | cfg := newRouteCheckerMock(t, "app.example.com") | |||
| 57 | + | ||||
| 58 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 59 | + | defer cancel() | |||
| 60 | + | ||||
| 61 | + | svc := pkiusecase.NewService(ctx, ca, cfg, testLogger()) | |||
| 62 | + | defer svc.Stop() | |||
| 63 | + | ||||
| 64 | + | hello := &tls.ClientHelloInfo{ServerName: "unknown.example.com"} | |||
| 65 | + | cert, err := svc.GetCertificate(hello) | |||
| 66 | + | assert.NoError(t, err) | |||
| 67 | + | assert.Nil(t, cert) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestService_GetCertificate_Caching(t *testing.T) { | |||
| 71 | + | dir := t.TempDir() | |||
| 72 | + | ca, err := pkiadapter.NewCA(dir, testLogger()) | |||
| 73 | + | require.NoError(t, err) | |||
| 74 | + | ||||
| 75 | + | cfg := newRouteCheckerMock(t, "cached.example.com") | |||
| 76 | + | ||||
| 77 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 78 | + | defer cancel() | |||
| 79 | + | ||||
| 80 | + | svc := pkiusecase.NewService(ctx, ca, cfg, testLogger()) | |||
| 81 | + | defer svc.Stop() | |||
| 82 | + | ||||
| 83 | + | hello := &tls.ClientHelloInfo{ServerName: "cached.example.com"} | |||
| 84 | + | cert1, err := svc.GetCertificate(hello) | |||
| 85 | + | require.NoError(t, err) | |||
| 86 | + | ||||
| 87 | + | cert2, err := svc.GetCertificate(hello) | |||
| 88 | + | require.NoError(t, err) | |||
| 89 | + | ||||
| 90 | + | // Same pointer = served from cache | |||
| 91 | + | assert.Same(t, cert1, cert2, "second call should return cached cert") | |||
| 92 | + | } | |||