detached 67f96d9

Merge pull request #153 from bnema/feat/internal-ca-ondemand-tls

feat(pki): internal CA with on-demand TLS
commit
67f96d9f42f63a2eee56cc65d9f533d3dfed241b
author
bnema · · 2026-04-01 04:31 UTC
committer
GitHub · · 2026-04-01 04:31 UTC
merge of
1983e1b + 937d56d · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.31.0-dev
33 files · +2302 -432
M .mockery.yaml +1 -0
@@ -24,6 +24,7 @@ packages:
2424 DomainSecretStore:
2525 RateLimiter:
2626 BackupStorage:
27+ RouteChecker:
2728 github.com/bnema/gordon/internal/boundaries/in:
2829 interfaces:
2930 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"
326326 token_env = "GORDON_TOKEN"
327327 ```
328328
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:
330330
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
333335
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.
349337
350338 `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`.
351339
@@ -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
3030 ```toml
3131 # Server settings
3232 [server]
33-port = 8080 # HTTP proxy port (default: 80)
33+port = 8088 # HTTP proxy port (default: 8088)
3434 registry_port = 5000 # Registry port (default: 5000)
3535 gordon_domain = "gordon.mydomain.com" # Required: Gordon domain (registry + API)
3636 # data_dir = "~/.gordon" # Default for user installations
@@ -167,7 +167,7 @@ keep_last = 3
167167
168168 | Setting | Default |
169169 |---------|---------|
170-| `server.port` | `80` |
170+| `server.port` | `8088` |
171171 | `server.registry_port` | `5000` |
172172 | `server.data_dir` | `~/.gordon` |
173173 | `auth.enabled` | `true` |
@@ -30,7 +30,7 @@ For a complete list of all configuration options with their default values, see
30 ```toml30 ```toml
31 # Server settings31 # 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 installations36 # 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
150150
151151 **Critical for correct IP detection in production.**
152152
153-Gordon is a reverse proxy that routes requests to your containers. In production you can either terminate TLS directly in Gordon (`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.
154154
155155 ```
156156 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 → Containers156 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.
99 # SERVER
1010 # =============================================================================
1111 [server]
12-port = 80 # HTTP proxy port
12+port = 8088 # HTTP proxy port
1313 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)
1818 gordon_domain = "" # Required: Gordon domain (registry + API)
1919 data_dir = "~/.gordon" # Data directory (varies by install type)
2020 max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk
2121 registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all)
2222 proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs)
2323 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)
2524
2625 # =============================================================================
2726 # AUTHENTICATION (required - Gordon won't start without credentials configured)
@@ -170,19 +169,18 @@ keep_last = 3 # Keep N newest tags per repository
170169
171170 | Setting | Default | Description |
172171 |---------|---------|-------------|
173-| `server.port` | `80` | HTTP proxy port |
172+| `server.port` | `8088` | HTTP proxy port |
174173 | `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) |
179178 | `server.gordon_domain` | `""` | **Required** - Gordon domain |
180179 | `server.data_dir` | `~/.gordon` | Data directory |
181180 | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk |
182181 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
183182 | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
184183 | `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) |
186184 | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) |
187185 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
188186 | `auth.token_expiry` | `"720h"` | 30 days |
@@ -9,19 +9,18 @@ Complete configuration reference with all options and their default values.
9 # SERVER9 # SERVER
10 # =============================================================================10 # =============================================================================
11 [server]11 [server]
12-port = 80 # HTTP proxy port12+port = 8088 # HTTP proxy port
13 registry_port = 5000 # Container registry port13 registry_port = 5000 # Container registry port
14-tls_enabled = false # Enable native HTTPS listener14+tls_port = 8443 # HTTPS proxy port (0 = disabled, no internal CA)
15-tls_port = 443 # HTTPS proxy port when enabled15+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 chunk20 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.
66
77 ```toml
88 [server]
9-port = 8080 # HTTP proxy port
9+port = 8088 # HTTP proxy port
1010 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
1515 gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
1616 # data_dir = "~/.gordon" # Data storage directory (default)
1717 ```
@@ -20,12 +20,12 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
2020
2121 | Option | Type | Default | Description |
2222 |--------|------|---------|-------------|
23-| `port` | int | `80` | HTTP proxy port for routing traffic to containers |
23+| `port` | int | `8088` | HTTP proxy port for routing traffic to containers |
2424 | `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 |
2929 | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) |
3030 | `registry_domain` | string | - | Deprecated alias for `gordon_domain` |
3131 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |
@@ -34,7 +34,6 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
3434 | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
3535 | `proxy_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
3636 | `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) |
3837
3938 ## Port Configuration
4039
@@ -54,30 +53,62 @@ For rootless containers, you'll typically use a high port and configure firewall
5453 sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8080
5554 ```
5655
57-### Native TLS Listener
56+### Internal CA and TLS
5857
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).
6059
6160 ```toml
6261 [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.
6763 ```
6864
6965 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)
7471
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:
7675
7776 ```toml
7877 [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
81112 ```
82113
83114 With firewalld and Tailscale:
@@ -291,14 +322,9 @@ The `registry_listen_address` controls which interface the registry server binds
291322 registry_listen_address = "127.0.0.1" # Loopback only
292323 ```
293324
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
297326
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.
302328
303329 ## Examples
304330
@@ -6,12 +6,12 @@ Core server settings for Gordon.
6 6
7 ```toml7 ```toml
8 [server]8 [server]
9-port = 8080 # HTTP proxy port9+port = 8088 # HTTP proxy port
10 registry_port = 5000 # Container registry port10 registry_port = 5000 # Container registry port
11-tls_enabled = false # Enable native TLS listener11+tls_port = 8443 # HTTPS listener port (0 = disabled)
12-tls_port = 443 # HTTPS listener port12+# 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 Configuration38 ## 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=808053 sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8080
55 ```54 ```
56 55
57-### Native TLS Listener56+### 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 ```toml60 ```toml
62 [server]61 [server]
63-tls_enabled = true62+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 externally68+- 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 ```toml76 ```toml
78 [server]77 [server]
79-tls_enabled = true78+tls_cert_file = "/etc/gordon/tls/cert.pem"
80-tls_port = 844379+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 only322 registry_listen_address = "127.0.0.1" # Loopback only
292 ```323 ```
293 324
294-## Force HSTS325+## 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-```toml327+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 ## Examples329 ## Examples
304 330
M docs/getting-started.md +1 -1
@@ -6,7 +6,7 @@ Deploy your first app with Gordon in under 5 minutes.
66
77 - A Linux VPS with Docker or Podman installed
88 - A domain pointing to your VPS (DNS A record)
9-- HTTPS setup: Cloudflare proxy **or** native TLS in Gordon (`server.tls_enabled = true`)
9+- HTTPS setup: Cloudflare proxy, Tailscale, or Gordon's internal CA (`server.tls_port = 8443`)
1010 - [pass](https://www.passwordstore.org/) (password manager) with GPG key initialized
1111
1212 ## 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 installed7 - A Linux VPS with Docker or Podman installed
8 - A domain pointing to your VPS (DNS A record)8 - A domain pointing to your VPS (DNS A record)
9-- HTTPS setup: Cloudflare proxy **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 initialized10 - [pass](https://www.passwordstore.org/) (password manager) with GPG key initialized
11 11
12 ## 1. Install Gordon12 ## 1. Install Gordon
M docs/index.md +1 -1
@@ -80,7 +80,7 @@ Gordon runs on your VPS and provides:
8080 - Domain pointing to your server
8181 - Cloudflare account for HTTPS (free tier works)
8282
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.
8484
8585 ## Related
8686
@@ -80,7 +80,7 @@ Gordon runs on your VPS and provides:
80 - Domain pointing to your server80 - 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 ## Related85 ## Related
86 86
M docs/upgrading.md +31 -0
@@ -2,6 +2,37 @@
22
33 This guide covers breaking changes and migration steps between major versions.
44
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+
536 ## v2.16.0 to v2.30.0
637
738 ### 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.036 ## v2.16.0 to v2.30.0
6 37
7 ### Breaking: Password Authentication Removed38 ### Breaking: Password Authentication Removed
M go.mod +8 -7
@@ -13,7 +13,9 @@ require (
1313 github.com/google/go-containerregistry v0.21.3
1414 github.com/google/uuid v1.6.0
1515 github.com/mattn/go-runewidth v0.0.21
16+ github.com/muesli/termenv v0.16.0
1617 github.com/rivo/uniseg v0.4.7
18+ github.com/smallstep/truststore v0.13.0
1719 github.com/spf13/cobra v1.10.2
1820 github.com/spf13/viper v1.21.0
1921 github.com/stretchr/testify v1.11.1
@@ -23,7 +25,6 @@ require (
2325 go.opentelemetry.io/otel/sdk/log v0.18.0
2426 go.opentelemetry.io/otel/sdk/metric v1.42.0
2527 golang.org/x/mod v0.34.0
26- golang.org/x/term v0.41.0
2728 gopkg.in/natefinch/lumberjack.v2 v2.2.1
2829 )
2930
@@ -53,7 +54,6 @@ require (
5354 github.com/morikuni/aec v1.1.0 // indirect
5455 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
5556 github.com/muesli/cancelreader v0.2.2 // indirect
56- github.com/muesli/termenv v0.16.0 // indirect
5757 github.com/rs/zerolog v1.34.0 // indirect
5858 github.com/sirupsen/logrus v1.9.4 // indirect
5959 github.com/vbatts/tar-split v0.12.2 // indirect
@@ -62,19 +62,20 @@ require (
6262 go.opentelemetry.io/otel/log v0.18.0 // indirect
6363 go.opentelemetry.io/proto/otlp v1.9.0 // indirect
6464 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
6666 golang.org/x/sync v0.20.0 // indirect
6767 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
6969 google.golang.org/grpc v1.79.3 // indirect
7070 google.golang.org/protobuf v1.36.11 // indirect
71+ howett.net/plist v1.0.0 // indirect
7172 )
7273
7374 require (
7475 github.com/davecgh/go-spew v1.1.1 // indirect
7576 github.com/pmezard/go-difflib v1.0.0 // indirect
7677 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
7879 golang.org/x/time v0.15.0
7980 )
8081
@@ -83,7 +84,7 @@ require (
8384 github.com/containerd/errdefs v1.0.0
8485 github.com/containerd/errdefs/pkg v0.3.0 // indirect
8586 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
8788 github.com/felixge/httpsnoop v1.0.4 // indirect
8889 github.com/fsnotify/fsnotify v1.9.0 // direct
8990 github.com/go-logr/logr v1.4.3 // indirect
@@ -91,7 +92,7 @@ require (
9192 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
9293 github.com/inconshreveable/mousetrap v1.1.0 // indirect
9394 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
9596 github.com/moby/docker-image-spec v1.3.1 // indirect
9697 github.com/moby/term v0.5.2 // indirect
9798 github.com/opencontainers/go-digest v1.0.0 // indirect
@@ -13,7 +13,9 @@ require (
13 github.com/google/go-containerregistry v0.21.313 github.com/google/go-containerregistry v0.21.3
14 github.com/google/uuid v1.6.014 github.com/google/uuid v1.6.0
15 github.com/mattn/go-runewidth v0.0.2115 github.com/mattn/go-runewidth v0.0.21
16+ github.com/muesli/termenv v0.16.0
16 github.com/rivo/uniseg v0.4.717 github.com/rivo/uniseg v0.4.7
18+ github.com/smallstep/truststore v0.13.0
17 github.com/spf13/cobra v1.10.219 github.com/spf13/cobra v1.10.2
18 github.com/spf13/viper v1.21.020 github.com/spf13/viper v1.21.0
19 github.com/stretchr/testify v1.11.121 github.com/stretchr/testify v1.11.1
@@ -23,7 +25,6 @@ require (
23 go.opentelemetry.io/otel/sdk/log v0.18.025 go.opentelemetry.io/otel/sdk/log v0.18.0
24 go.opentelemetry.io/otel/sdk/metric v1.42.026 go.opentelemetry.io/otel/sdk/metric v1.42.0
25 golang.org/x/mod v0.34.027 golang.org/x/mod v0.34.0
26- golang.org/x/term v0.41.0
27 gopkg.in/natefinch/lumberjack.v2 v2.2.128 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 // indirect54 github.com/morikuni/aec v1.1.0 // indirect
54 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect55 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
55 github.com/muesli/cancelreader v0.2.2 // indirect56 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 // indirect57 github.com/rs/zerolog v1.34.0 // indirect
58 github.com/sirupsen/logrus v1.9.4 // indirect58 github.com/sirupsen/logrus v1.9.4 // indirect
59 github.com/vbatts/tar-split v0.12.2 // indirect59 github.com/vbatts/tar-split v0.12.2 // indirect
@@ -62,19 +62,20 @@ require (
62 go.opentelemetry.io/otel/log v0.18.0 // indirect62 go.opentelemetry.io/otel/log v0.18.0 // indirect
63 go.opentelemetry.io/proto/otlp v1.9.0 // indirect63 go.opentelemetry.io/proto/otlp v1.9.0 // indirect
64 go.yaml.in/yaml/v3 v3.0.4 // indirect64 go.yaml.in/yaml/v3 v3.0.4 // indirect
65- golang.org/x/net v0.51.0 // indirect65+ golang.org/x/net v0.52.0 // indirect
66 golang.org/x/sync v0.20.0 // indirect66 golang.org/x/sync v0.20.0 // indirect
67 google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect67 google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
68- google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect68+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
69 google.golang.org/grpc v1.79.3 // indirect69 google.golang.org/grpc v1.79.3 // indirect
70 google.golang.org/protobuf v1.36.11 // indirect70 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 // indirect75 github.com/davecgh/go-spew v1.1.1 // indirect
75 github.com/pmezard/go-difflib v1.0.0 // indirect76 github.com/pmezard/go-difflib v1.0.0 // indirect
76 github.com/stretchr/objx v0.5.2 // indirect77 github.com/stretchr/objx v0.5.2 // indirect
77- go.uber.org/mock v0.5.2 // direct78+ go.uber.org/mock v0.6.0 // direct
78 golang.org/x/time v0.15.079 golang.org/x/time v0.15.0
79 )80 )
80 81
@@ -83,7 +84,7 @@ require (
83 github.com/containerd/errdefs v1.0.084 github.com/containerd/errdefs v1.0.0
84 github.com/containerd/errdefs/pkg v0.3.0 // indirect85 github.com/containerd/errdefs/pkg v0.3.0 // indirect
85 github.com/distribution/reference v0.6.0 // indirect86 github.com/distribution/reference v0.6.0 // indirect
86- github.com/docker/go-units v0.5.0 // indirect87+ github.com/docker/go-units v0.5.0
87 github.com/felixge/httpsnoop v1.0.4 // indirect88 github.com/felixge/httpsnoop v1.0.4 // indirect
88 github.com/fsnotify/fsnotify v1.9.0 // direct89 github.com/fsnotify/fsnotify v1.9.0 // direct
89 github.com/go-logr/logr v1.4.3 // indirect90 github.com/go-logr/logr v1.4.3 // indirect
@@ -91,7 +92,7 @@ require (
91 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect92 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
92 github.com/inconshreveable/mousetrap v1.1.0 // indirect93 github.com/inconshreveable/mousetrap v1.1.0 // indirect
93 github.com/mattn/go-colorable v0.1.14 // indirect94 github.com/mattn/go-colorable v0.1.14 // indirect
94- github.com/mattn/go-isatty v0.0.20 // indirect95+ github.com/mattn/go-isatty v0.0.20
95 github.com/moby/docker-image-spec v1.3.1 // indirect96 github.com/moby/docker-image-spec v1.3.1 // indirect
96 github.com/moby/term v0.5.2 // indirect97 github.com/moby/term v0.5.2 // indirect
97 github.com/opencontainers/go-digest v1.0.0 // indirect98 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
9292 github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
9393 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
9494 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=
9596 github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
9697 github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
9798 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -151,6 +152,8 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee
151152 github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
152153 github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
153154 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=
154157 github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
155158 github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
156159 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
204207 go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
205208 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
206209 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=
209212 go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
210213 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
211214 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
212215 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
213216 golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
214217 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=
217220 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
218221 golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
219222 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=
222225 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
223226 golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
224227 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=
227228 golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
228229 golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
229230 golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
@@ -232,8 +233,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
232233 gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
233234 google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
234235 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=
237238 google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
238239 google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
239240 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
243244 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
244245 gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
245246 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=
246248 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
247249 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
248250 gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
249251 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
33 import (
44 "fmt"
55
6- "github.com/bnema/zerowrap"
7-
86 "github.com/bnema/gordon/internal/app"
97 )
108
@@ -19,7 +17,7 @@ func (h *controlPlaneHandle) close() {
1917 return
2018 }
2119 if err := h.closeFn(); err != nil {
22- log := zerowrap.Default()
20+ log := cliLogger()
2321 log.Warn().Err(err).Msg("failed to close control-plane resources")
2422 }
2523 }
@@ -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 return17 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 {
2424 configSvc in.ConfigService
2525 secretSvc in.SecretService
2626 dataDir string
27+ tlsPort int
2728 }
2829
2930 // GetConfigService returns the config service.
@@ -41,12 +42,17 @@ func (l *LocalServices) GetDataDir() string {
4142 return l.dataDir
4243 }
4344
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+
4450 // GetLocalServices creates local services for CLI operations.
4551 // It loads the config and initializes services without starting the server.
4652 func GetLocalServices(configPath string) (*LocalServices, error) {
4753 // Set up viper with defaults
4854 v := viper.New()
49- v.SetDefault("server.port", 80)
55+ v.SetDefault("server.port", 8088)
5056 v.SetDefault("server.registry_port", 5000)
5157 v.SetDefault("server.data_dir", app.DefaultDataDir())
5258
@@ -61,11 +67,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
6167 // Config file not found is OK for some operations
6268 }
6369
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)
6971
7072 // Create config service (without event bus for CLI operations)
7173 configSvc := config.NewService(v, nil)
@@ -96,6 +98,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
9698 configSvc: configSvc,
9799 secretSvc: secretSvc,
98100 dataDir: dataDir,
101+ tlsPort: v.GetInt("server.tls_port"),
99102 }, nil
100103 }
101104
@@ -24,6 +24,7 @@ type LocalServices struct {
24 configSvc in.ConfigService24 configSvc in.ConfigService
25 secretSvc in.SecretService25 secretSvc in.SecretService
26 dataDir string26 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.dataDir42 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 defaults53 // 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 operations67 // Config file not found is OK for some operations
62 }68 }
63 69
64- // Create a minimal logger for CLI operations70+ 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 }, nil102 }, nil
100 }103 }
101 104
M internal/adapters/in/cli/presentation.go +14 -0
@@ -4,6 +4,8 @@ import (
44 "fmt"
55 "io"
66
7+ "github.com/bnema/zerowrap"
8+
79 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
810 )
911
@@ -17,6 +19,18 @@ var cliWritef = func(w io.Writer, format string, args ...any) error {
1719 return err
1820 }
1921
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+
2034 func cliRenderTitle(msg string) string {
2135 return styles.Theme.Title.Render(msg)
2236 }
@@ -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 err19 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:
7676 authCmd.GroupID = groupServer
7777 rootCmd.AddCommand(authCmd)
7878
79+ caCmd := newCACmd()
80+ caCmd.GroupID = groupServer
81+ rootCmd.AddCommand(caCmd)
82+
7983 // Management commands (work locally or via --remote)
8084 routesCmd := newRoutesCmd()
8185 routesCmd.GroupID = groupManage
@@ -76,6 +76,10 @@ Commands are organized by where they run:
76 authCmd.GroupID = groupServer76 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 = groupManage85 routesCmd.GroupID = groupManage
M internal/adapters/in/http/middleware/cidr.go +63 -5
@@ -2,8 +2,10 @@ package middleware
22
33 import (
44 "encoding/json"
5+ "fmt"
56 "net"
67 "net/http"
8+ "strings"
79
810 "github.com/bnema/zerowrap"
911
@@ -48,6 +50,15 @@ func cidrAllowlist(allowedNets []*net.IPNet, ipExtractor func(*http.Request) str
4850 }
4951 }
5052
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+
5162 // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges.
5263 // Localhost is always allowed so Gordon can pull from its own registry.
5364 // 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
5768 }, "registry", log)
5869 }
5970
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+
60122 // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.
61123 // This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only
62124 // trusted sources (e.g. Cloudflare edge IPs) can reach the proxy server.
63125 // An empty allowedNets slice is a no-op (all traffic passes through).
64126 func ProxyCIDRAllowlist(allowedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
65127 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)
71129 }, "proxy origin", log)
72130 }
@@ -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 only123 // 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 @@
11 package middleware
22
3-import (
4- "net/http"
5-)
3+import "net/http"
64
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).
87 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=()")
3014
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+ }
3418
35- next.ServeHTTP(w, r)
36- })
37- }
19+ next.ServeHTTP(w, r)
20+ })
3821 }
@@ -1,38 +1,21 @@
1 package middleware1 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 on16+ 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) {
5151 assert.Equal(t, "max-age=31536000; includeSubDomains", rec.Header().Get("Strict-Transport-Security"))
5252 }
5353
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-
7054 func TestSecurityHeaders_HSTS_WithForwardedProto_NotTrusted(t *testing.T) {
7155 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
7256 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
33
44 import (
55 "context"
6- "crypto/ecdsa"
7- "crypto/elliptic"
86 "crypto/rand"
97 "crypto/tls"
10- "crypto/x509"
11- "crypto/x509/pkix"
128 "encoding/hex"
139 "encoding/json"
14- "encoding/pem"
1510 "errors"
1611 "fmt"
17- "math/big"
1812 "net"
1913 "net/http"
2014 "os"
@@ -38,6 +32,7 @@ import (
3832 "github.com/bnema/gordon/internal/adapters/out/filesystem"
3933 "github.com/bnema/gordon/internal/adapters/out/httpprober"
4034 "github.com/bnema/gordon/internal/adapters/out/logwriter"
35+ pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
4136 "github.com/bnema/gordon/internal/adapters/out/ratelimit"
4237 "github.com/bnema/gordon/internal/adapters/out/secrets"
4338 "github.com/bnema/gordon/internal/adapters/out/telemetry"
@@ -51,6 +46,7 @@ import (
5146 "github.com/bnema/gordon/internal/adapters/in/http/admin"
5247 authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth"
5348 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
49+ "github.com/bnema/gordon/internal/adapters/in/http/onboarding"
5450 proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy"
5551 "github.com/bnema/gordon/internal/adapters/in/http/registry"
5652
@@ -74,6 +70,7 @@ import (
7470 "github.com/bnema/gordon/internal/usecase/health"
7571 "github.com/bnema/gordon/internal/usecase/images"
7672 "github.com/bnema/gordon/internal/usecase/logs"
73+ pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
7774 "github.com/bnema/gordon/internal/usecase/proxy"
7875 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
7976 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
@@ -91,10 +88,10 @@ type Config struct {
9188 RegistryPort int `mapstructure:"registry_port"`
9289 GordonDomain string `mapstructure:"gordon_domain"`
9390 RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
94- TLSEnabled bool `mapstructure:"tls_enabled"`
9591 TLSPort int `mapstructure:"tls_port"`
9692 TLSCertFile string `mapstructure:"tls_cert_file"`
9793 TLSKeyFile string `mapstructure:"tls_key_file"`
94+ ForceHTTPSRedirect bool `mapstructure:"force_https_redirect"`
9895 DataDir string `mapstructure:"data_dir"`
9996 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
10097 MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
@@ -103,7 +100,6 @@ type Config struct {
103100 RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`
104101 ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"`
105102 RegistryListenAddr string `mapstructure:"registry_listen_address"`
106- ForceHSTS bool `mapstructure:"force_hsts"`
107103 } `mapstructure:"server"`
108104
109105 Logging struct {
@@ -207,6 +203,8 @@ type services struct {
207203 previewService *preview.Service
208204 envDir string
209205 maxBlobChunkSize int64
206+ caAdapter *pkiadapter.CA
207+ pkiSvc *pkiusecase.Service
210208 }
211209
212210 // Run initializes and starts the Gordon application.
@@ -252,8 +250,11 @@ func Run(ctx context.Context, configPath string) error {
252250
253251 log.Info().Msg("Gordon starting")
254252
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+ }
257258 }
258259
259260 // Create PID file
@@ -285,128 +286,8 @@ func Run(ctx context.Context, configPath string) error {
285286 }
286287 defer svc.eventBus.Stop()
287288
288- // Create HTTP handlers
289- registryHandler, proxyHandler, proxyCIDRMiddleware := createHTTPHandlers(svc, cfg, log)
290-
291289 // 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)
410291 }
411292
412293 // initConfig loads configuration from file.
@@ -461,119 +342,179 @@ func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
461342 return log, cleanup, nil
462343 }
463344
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+
464354 // 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+ }()
467368 var err error
468369
469370 // Create output adapters
470371 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 {
472373 return nil, err
473374 }
474375
475376 // 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 {
477378 return nil, err
478379 }
479380
480381 // Create log writer
481- if svc.logWriter, err = createLogWriter(cfg, log); err != nil {
382+ if si.svc.logWriter, err = createLogWriter(cfg, log); err != nil {
482383 return nil, err
483384 }
484385
485386 // 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 {
487388 return nil, err
488389 }
489390
490- if err := setupInternalRegistryAuth(svc, log); err != nil {
391+ if err := setupInternalRegistryAuth(si.svc, log); err != nil {
491392 return nil, err
492393 }
493394
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 {
497398 return nil, log.WrapErr(err, "failed to load configuration")
498399 }
499400
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+ }
503404
504- svc.envDir, backend, passStore, domainSecretStore, err = createDomainSecretStore(cfg, log)
505- if err != nil {
405+ if err := si.initSecrets(); err != nil {
506406 return nil, err
507407 }
508408
509- if svc.envLoader, err = createEnvLoader(backend, svc.envDir, passStore, log); err != nil {
409+ if err := si.initRuntimeAndProxy(); err != nil {
510410 return nil, err
511411 }
512412
513- svc.secretSvc = secretsSvc.NewService(domainSecretStore, log, svc.eventBus)
413+ si.initHandlers()
514414
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
517423 }
518424
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")
521427 }
522428
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)
526469
527- injectTelemetryMetrics(cfg, svc, log)
470+ injectTelemetryMetrics(si.cfg, si.svc, si.log)
528471
529- proxyCfg, err := buildProxyConfig(cfg, log)
472+ proxyCfg, err := buildProxyConfig(si.cfg, si.log)
530473 if err != nil {
531- return nil, err
474+ return err
532475 }
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)
535478
536479 // Wire synchronous proxy cache invalidation for zero-downtime deployments.
537480 // 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+}
540485
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 {
543489 internalAuth := authhandler.InternalAuth{
544- Username: svc.internalRegUser,
545- Password: svc.internalRegPass,
490+ Username: si.svc.internalRegUser,
491+ Password: si.svc.internalRegPass,
546492 }
547- svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log)
493+ si.svc.authHandler = authhandler.NewHandler(si.svc.authSvc, internalAuth, si.log)
548494 }
549495
550- // Create health service for route health checking
551496 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,
574517 })
575-
576- return svc, nil
577518 }
578519
579520 // initPreviewService sets up the preview store, service, and TTL ticker.
@@ -1669,13 +1610,13 @@ func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler {
16691610 }
16701611
16711612 // 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) {
16751615 // Parse trusted proxies once for all middleware chains.
16761616 // This ensures consistent IP extraction across logging, rate limiting, and auth.
16771617 trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
16781618
1619+ // Registry handler (unchanged)
16791620 registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)
16801621 registryWithMiddleware, cidrAllowlistMiddleware, rateLimitMiddleware := buildRegistryHandlerWithMiddleware(
16811622 svc,
@@ -1688,36 +1629,65 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
16881629 registryMux := http.NewServeMux()
16891630 registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, cfg, log)
16901631 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)
16911639
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{
16971641 middleware.PanicRecovery(log),
16981642 middleware.RequestLogger(log, trustedNets),
1699- middleware.SecurityHeadersWithOptions(cfg.Server.ForceHSTS),
1643+ middleware.SecurityHeaders,
1644+ middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log),
17001645 }
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)
17061646 if proxyCIDRMiddleware != nil {
1707- proxyMiddlewares = append(proxyMiddlewares, proxyCIDRMiddleware)
1647+ httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware)
17081648 }
17091649
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),
17131652 "gordon.proxy",
17141653 )
1654+
1655+ // HTTP proxyMux: Cloudflare-proxied traffic only, no onboarding
17151656 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+ }
17171670
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")
17191689
1720- return registryMux, proxyMux, proxyCIDRMiddleware
1690+ return registryMux, proxyMux, httpsHandler
17211691 }
17221692
17231693 func buildRegistryHandlerWithMiddleware(
@@ -1801,20 +1771,20 @@ func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet,
18011771 return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)
18021772 }
18031773
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) {
18051775 allowedNets, allInvalid := parseCIDRAllowlist(cfg.Server.ProxyAllowedIPs, "proxy_allowed_ips", log)
18061776 if allInvalid {
1807- return denyAllHandler("proxy_allowed_ips", trustedNets, log)
1777+ return nil, denyAllHandler("proxy_allowed_ips", trustedNets, log)
18081778 }
18091779 if allowedNets == nil {
1810- return nil
1780+ return nil, nil
18111781 }
18121782
18131783 log.Info().
18141784 Strs("proxy_allowed_ips", cfg.Server.ProxyAllowedIPs).
18151785 Msg("proxy origin IP allowlist enabled")
18161786
1817- return middleware.ProxyCIDRAllowlist(allowedNets, log)
1787+ return allowedNets, middleware.ProxyCIDRAllowlist(allowedNets, log)
18181788 }
18191789
18201790 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,
19491919 // - SIGUSR2: Triggers manual deploy for a specific route
19501920 // The deferred signal.Stop calls ensure signal handlers are properly
19511921 // 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 {
19531923 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
19541924 defer cancel()
19551925
@@ -1967,24 +1937,44 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
19671937
19681938 errChan := make(chan error, 3)
19691939
1940+ registryHandler, httpProxyHandler, httpsProxyHandler := createHTTPHandlers(svc, cfg, log)
1941+
19701942 registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort))
19711943 registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log)
19721944
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)
19741960 if err != nil {
1961+ closeStarted(registrySrv)
19751962 return err
19761963 }
19771964
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.
19791966 // This prevents the race where auto-start pulls from the registry before it's listening.
19801967 if err := waitForServerReady(registryReady, errChan); err != nil {
1968+ closeStarted(registrySrv, proxySrv, tlsSrv)
19811969 return err
19821970 }
19831971 if err := waitForServerReady(proxyReady, errChan); err != nil {
1972+ closeStarted(registrySrv, proxySrv, tlsSrv)
19841973 return err
19851974 }
1986- if cfg.Server.TLSEnabled {
1975+ if tlsReady != nil {
19871976 if err := waitForServerReady(tlsReady, errChan); err != nil {
1977+ closeStarted(registrySrv, proxySrv, tlsSrv)
19881978 return err
19891979 }
19901980 }
@@ -1992,7 +1982,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
19921982 logEvent := log.Info().
19931983 Int("proxy_port", cfg.Server.Port).
19941984 Int("registry_port", cfg.Server.RegistryPort)
1995- if cfg.Server.TLSEnabled {
1985+ if tlsSrv != nil {
19961986 logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort)
19971987 }
19981988 logEvent.Msg("Gordon is running")
@@ -2010,7 +2000,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
20102000
20112001 waitForShutdown(ctx, errChan, reloadChan, deployChan, svc.eventBus, log)
20122002 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)
20142004 return nil
20152005 }
20162006
@@ -2212,7 +2202,7 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl
22122202
22132203 // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
22142204 // 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) {
22162206 log.Info().Msg("shutting down Gordon...")
22172207
22182208 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -2228,6 +2218,11 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *
22282218 }
22292219 }
22302220
2221+ // Stop PKI maintenance goroutines
2222+ if pkiSvc != nil {
2223+ pkiSvc.Stop()
2224+ }
2225+
22312226 // Phase 2: Drain in-flight registry push sessions before stopping the backend
22322227 if proxySvc != nil {
22332228 log.Info().Msg("draining in-flight registry requests...")
@@ -2253,56 +2248,54 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *
22532248 log.Info().Msg("Gordon stopped")
22542249 }
22552250
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+ )
22692266
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
22722269 }
22732270
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)
22862282 }
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)")
22942288 }
2295- proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), redirectWithMiddleware, "proxy-redirect", nil, errChan, log)
2296- tlsSrv, tlsReady := startTLSServer(
2289+ tlsSrv, tlsReady := startTLSServerWithConfig(
22972290 fmt.Sprintf(":%d", cfg.Server.TLSPort),
2298- proxyHandler,
2291+ httpsHandler,
22992292 "proxy-tls",
2300- cfg.Server.TLSCertFile,
2301- cfg.Server.TLSKeyFile,
2293+ tlsConfig,
23022294 errChan,
23032295 log,
23042296 )
2305- return proxySrv, proxyReady, tlsSrv, tlsReady, nil
2297+
2298+ return httpSrv, httpReady, tlsSrv, tlsReady, nil
23062299 }
23072300
23082301 // 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
23422335 return server, ready
23432336 }
23442337
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{
23512343 Addr: addr,
23522344 Handler: handler,
2345+ TLSConfig: tlsCfg,
23532346 ReadHeaderTimeout: 10 * time.Second,
23542347 ReadTimeout: 5 * time.Minute,
23552348 WriteTimeout: 5 * time.Minute,
23562349 IdleTimeout: 120 * time.Second,
23572350 MaxHeaderBytes: 1 << 20,
2358- TLSConfig: &tls.Config{
2359- MinVersion: tls.VersionTLS12,
2360- },
23612351 }
23622352
2353+ ready := make(chan struct{})
23632354 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-
23702355 ln, err := net.Listen("tcp", addr)
23712356 if err != nil {
2372- errChan <- fmt.Errorf("%s server error: %w", name, err)
2357+ errChan <- fmt.Errorf("%s listen: %w", name, err)
23732358 return
23742359 }
2360+ tlsLn := tls.NewListener(ln, tlsCfg)
2361+ log.Info().Str("addr", addr).Str("name", name).Msg("TLS server starting")
23752362 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)
23792365 }
23802366 }()
23812367
2382- return server, ready
2368+ return srv, ready
23832369 }
23842370
23852371 // SendReloadSignal sends SIGUSR1 to the running Gordon process.
@@ -2633,12 +2619,12 @@ func isProcessAlive(pid int) bool {
26332619
26342620 // loadConfig loads configuration from file and sets defaults.
26352621 func loadConfig(v *viper.Viper, configPath string) error {
2636- v.SetDefault("server.port", 80)
2622+ v.SetDefault("server.port", 8088)
26372623 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)
26402625 v.SetDefault("server.tls_cert_file", "")
26412626 v.SetDefault("server.tls_key_file", "")
2627+ v.SetDefault("server.force_https_redirect", false)
26422628 v.SetDefault("server.data_dir", DefaultDataDir())
26432629 v.SetDefault("server.runtime", "auto")
26442630 v.SetDefault("logging.level", "info")
@@ -2690,7 +2676,6 @@ func loadConfig(v *viper.Viper, configPath string) error {
26902676 v.SetDefault("server.registry_allowed_ips", []string{})
26912677 v.SetDefault("server.proxy_allowed_ips", []string{})
26922678 v.SetDefault("server.registry_listen_address", "")
2693- v.SetDefault("server.force_hsts", false)
26942679 v.SetDefault("deploy.readiness_delay", "5s")
26952680 v.SetDefault("deploy.readiness_mode", "auto")
26962681 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_domain90 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.Service203 previewService *preview.Service
208 envDir string204 envDir string
209 maxBlobChunkSize int64205 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 err254+ 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 file260 // 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, nil342 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 error368 var err error
468 369
469 // Create output adapters370 // 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, err373 return nil, err
473 }374 }
474 375
475 // Create storage376 // 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, err378 return nil, err
478 }379 }
479 380
480 // Create log writer381 // 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, err383 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, err388 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, err392 return nil, err
492 }393 }
493 394
494- // Create use case services395+ // 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.SecretsBackend401+ if err := si.initPKI(); err != nil {
501- var passStore *domainsecrets.PassStore402+ return nil, err
502- var domainSecretStore out.DomainSecretStore403+ }
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, err406 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, err410 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, err416+}
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, err426+ 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, err474+ return err
532 }475 }
533- svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize476+ 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 endpoint486+// 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 API499+ 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 API504+ 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 applications1640+ 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, proxyCIDRMiddleware1690+ 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 nil1780+ 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 route1919 // - SIGUSR2: Triggers manual deploy for a specific route
1950 // The deferred signal.Stop calls ensure signal handlers are properly1920 // 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 err1962 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 err1969 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 err1973 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 err1978 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 containers2002 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 nil2004 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 down2203 // 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 backend2226 // 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 listener2254+ // 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.Protocols2258+ 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, nil2262+ &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.TLSPort2271+ // 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.Host2273+ tlsConfig := &tls.Config{
2277- if h, _, err := net.SplitHostPort(host); err == nil {2274+ MinVersion: tls.VersionTLS12,
2278- host = h2275+ 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.RequestURI2283+ 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 = redirectHandler2287+ 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, nil2297+
2298+ return httpSrv, httpReady, tlsSrv, tlsReady, nil
2306 }2299 }
2307 2300
2308 // startServer starts an HTTP server, returning the server instance and a channel2301 // 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, ready2335 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 return2358 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, ready2368+ 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"]
14121412
14131413 v := viper.New()
14141414 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)
14171417 v.SetDefault("auth.secrets_backend", "")
14181418 v.SetDefault("auto_route.enabled", false)
14191419 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close