feat(server): add custom TLS certs, HTTPS redirect, and atomic intermediate store
- Restore tls_cert_file/tls_key_file support alongside the internal CA; static certs take priority by SNI match, unmatched domains fall through to on-demand internal CA certs - Add force_https_redirect option for direct-access setups (no proxy); when proxy_allowed_ips is configured, non-proxy clients are redirected automatically - Make storeIntermediate atomic: write cert+key to temp files first, then rename both, so a crash cannot leave a dangling cert without its key - Fix onboarding URL in docs (http -> https) and rewrite Tailscale section to reflect actual deployment patterns
- commit
- a6947b27de23f18a019d6814c256dd64492dc55f
- author
- brice · · 2026-03-31 03:14 UTC
- parent
- 5bf3a89
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +134 -23
M docs/cli/remotes.md +6 -15
| @@ -326,23 +326,14 @@ url = "https://gordon.example.com" | |||
| 326 | 326 | token_env = "GORDON_TOKEN" | |
| 327 | 327 | ``` | |
| 328 | 328 | ||
| 329 | - | If your admin/API path is private over Tailscale, you have two options: | |
| 329 | + | A common Tailscale setup is to point your domain's DNS at the machine's Tailscale IP (Cloudflare DNS-only / grey cloud). Since the server isn't publicly reachable, there is no external CA to issue a trusted certificate — Gordon's internal CA handles TLS automatically. Clients need to trust the internal CA: | |
| 330 | 330 | ||
| 331 | - | 1. Use `insecure_tls = true` for that remote. | |
| 332 | - | 2. Prefer a Tailscale-issued cert on the machine's `*.ts.net` name and keep verification enabled. | |
| 331 | + | - `insecure_tls = true` on the remote — skips certificate verification entirely | |
| 332 | + | - `sudo gordon ca install` — installs the root CA into system, Firefox, and Java trust stores | |
| 333 | + | - `gordon ca export --out gordon-ca.crt` — exports the root CA PEM for manual installation | |
| 334 | + | - Visit `https://<gordon-host>:<tls_port>/ca` in a browser — onboarding page with downloads for macOS, Linux, Windows, iOS, and Android | |
| 333 | 335 | ||
| 334 | - | Tailscale certificates are issued for tailnet MagicDNS names (for example `host-name.tailnet.ts.net`), not your custom domain. Enable HTTPS certificates in the Tailscale admin DNS settings, then generate files on the host: | |
| 335 | - | ||
| 336 | - | ```bash | |
| 337 | - | sudo tailscale cert --cert-file /etc/gordon/tls/cert.pem --key-file /etc/gordon/tls/key.pem host-name.tailnet.ts.net | |
| 338 | - | ``` | |
| 339 | - | ||
| 340 | - | Configure Gordon to use them: | |
| 341 | - | ||
| 342 | - | ```toml | |
| 343 | - | [server] | |
| 344 | - | tls_port = 8443 # Gordon's internal CA handles certificates automatically | |
| 345 | - | ``` | |
| 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. | |
| 346 | 337 | ||
| 347 | 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`. | |
| 348 | 339 | ||
| @@ -326,23 +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_port = 8443 # Gordon's internal CA handles certificates automatically | |||
| 345 | - | ``` | |||
| 346 | 337 | ||||
| 347 | `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`. | ||
| 348 | 339 | ||||
M docs/config/reference.md +6 -0
| @@ -12,6 +12,9 @@ Complete configuration reference with all options and their default values. | |||
| 12 | 12 | port = 8088 # HTTP proxy port | |
| 13 | 13 | registry_port = 5000 # Container registry port | |
| 14 | 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) | |
| 15 | 18 | gordon_domain = "" # Required: Gordon domain (registry + API) | |
| 16 | 19 | data_dir = "~/.gordon" # Data directory (varies by install type) | |
| 17 | 20 | max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk | |
| @@ -169,6 +172,9 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 169 | 172 | | `server.port` | `8088` | HTTP proxy port | | |
| 170 | 173 | | `server.registry_port` | `5000` | Container registry port | | |
| 171 | 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) | | |
| 172 | 178 | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | |
| 173 | 179 | | `server.data_dir` | `~/.gordon` | Data directory | | |
| 174 | 180 | | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk | | |
| @@ -12,6 +12,9 @@ Complete configuration reference with all options and their default values. | |||||
| 12 | port = 8088 # HTTP proxy port | 12 | port = 8088 # HTTP proxy port | ||
| 13 | registry_port = 5000 # Container registry port | 13 | registry_port = 5000 # Container registry port | ||
| 14 | tls_port = 8443 # HTTPS proxy port (0 = disabled, no internal CA) | 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) | |||
| 15 | gordon_domain = "" # Required: Gordon domain (registry + API) | 18 | gordon_domain = "" # Required: Gordon domain (registry + API) | ||
| 16 | data_dir = "~/.gordon" # Data directory (varies by install type) | 19 | data_dir = "~/.gordon" # Data directory (varies by install type) | ||
| 17 | max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk | 20 | max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk | ||
| @@ -169,6 +172,9 @@ keep_last = 3 # Keep N newest tags per repository | |||||
| 169 | | `server.port` | `8088` | HTTP proxy port | | 172 | | `server.port` | `8088` | HTTP proxy port | | ||
| 170 | | `server.registry_port` | `5000` | Container registry port | | 173 | | `server.registry_port` | `5000` | Container registry port | | ||
| 171 | | `server.tls_port` | `8443` | HTTPS listener port (0 = disabled) | | 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) | | |||
| 172 | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | 178 | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | ||
| 173 | | `server.data_dir` | `~/.gordon` | Data directory | | 179 | | `server.data_dir` | `~/.gordon` | Data directory | | ||
| 174 | | `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 | | ||
M docs/config/server.md +31 -2
| @@ -9,6 +9,9 @@ Core server settings for Gordon. | |||
| 9 | 9 | port = 8088 # HTTP proxy port | |
| 10 | 10 | registry_port = 5000 # Container registry port | |
| 11 | 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 | |
| 12 | 15 | gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | |
| 13 | 16 | # data_dir = "~/.gordon" # Data storage directory (default) | |
| 14 | 17 | ``` | |
| @@ -20,6 +23,9 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | |||
| 20 | 23 | | `port` | int | `8088` | HTTP proxy port for routing traffic to containers | | |
| 21 | 24 | | `registry_port` | int | `5000` | Docker registry port for image push/pull | | |
| 22 | 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 | | |
| 23 | 29 | | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) | | |
| 24 | 30 | | `registry_domain` | string | - | Deprecated alias for `gordon_domain` | | |
| 25 | 31 | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files | | |
| @@ -60,12 +66,35 @@ With TLS enabled: | |||
| 60 | 66 | - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/` | |
| 61 | 67 | - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory | |
| 62 | 68 | - The intermediate CA auto-renews before expiry | |
| 63 | - | - An onboarding page at `http://{host}/ca` lets clients download the root CA certificate | |
| 69 | + | - An onboarding page at `https://<gordon-host>:<tls_port>/ca` lets clients download the root CA certificate | |
| 64 | 70 | - The root CA is stable across restarts (generated once, persisted to disk) | |
| 65 | 71 | ||
| 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: | |
| 75 | + | ||
| 76 | + | ```toml | |
| 77 | + | [server] | |
| 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 | + | ||
| 66 | 95 | #### Disabling TLS | |
| 67 | 96 | ||
| 68 | - | For setups where TLS is handled externally (Tailscale, Cloudflare, etc.): | |
| 97 | + | For setups where TLS is handled entirely by an external proxy (Cloudflare, nginx, etc.): | |
| 69 | 98 | ||
| 70 | 99 | ```toml | |
| 71 | 100 | [server] | |
| @@ -9,6 +9,9 @@ Core server settings for Gordon. | |||||
| 9 | port = 8088 # HTTP proxy port | 9 | port = 8088 # HTTP proxy port | ||
| 10 | registry_port = 5000 # Container registry port | 10 | registry_port = 5000 # Container registry port | ||
| 11 | tls_port = 8443 # HTTPS listener port (0 = disabled) | 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 | |||
| 12 | gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | 15 | gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | ||
| 13 | # data_dir = "~/.gordon" # Data storage directory (default) | 16 | # data_dir = "~/.gordon" # Data storage directory (default) | ||
| 14 | ``` | 17 | ``` | ||
| @@ -20,6 +23,9 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | |||||
| 20 | | `port` | int | `8088` | HTTP proxy port for routing traffic to containers | | 23 | | `port` | int | `8088` | HTTP proxy port for routing traffic to containers | | ||
| 21 | | `registry_port` | int | `5000` | Docker registry port for image push/pull | | 24 | | `registry_port` | int | `5000` | Docker registry port for image push/pull | | ||
| 22 | | `tls_port` | int | `8443` | HTTPS listener port with internal CA. Set to `0` to disable TLS entirely | | 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 | | |||
| 23 | | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) | | 29 | | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) | | ||
| 24 | | `registry_domain` | string | - | Deprecated alias for `gordon_domain` | | 30 | | `registry_domain` | string | - | Deprecated alias for `gordon_domain` | | ||
| 25 | | `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 | | ||
| @@ -60,12 +66,35 @@ With TLS enabled: | |||||
| 60 | - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/` | 66 | - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/` | ||
| 61 | - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory | 67 | - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory | ||
| 62 | - The intermediate CA auto-renews before expiry | 68 | - The intermediate CA auto-renews before expiry | ||
| 63 | - | - An onboarding page at `http://{host}/ca` lets clients download the root CA certificate | 69 | + | - An onboarding page at `https://<gordon-host>:<tls_port>/ca` lets clients download the root CA certificate |
| 64 | - The root CA is stable across restarts (generated once, persisted to disk) | 70 | - The root CA is stable across restarts (generated once, persisted to disk) | ||
| 65 | 71 | ||||
| 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: | |||
| 75 | + | ||||
| 76 | + | ```toml | |||
| 77 | + | [server] | |||
| 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 | + | ||||
| 66 | #### Disabling TLS | 95 | #### Disabling TLS | ||
| 67 | 96 | ||||
| 68 | - | For setups where TLS is handled externally (Tailscale, Cloudflare, etc.): | 97 | + | For setups where TLS is handled entirely by an external proxy (Cloudflare, nginx, etc.): |
| 69 | 98 | ||||
| 70 | ```toml | 99 | ```toml | ||
| 71 | [server] | 100 | [server] | ||
M internal/adapters/in/http/middleware/cidr.go +42 -0
| @@ -2,6 +2,7 @@ package middleware | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/json" | |
| 5 | + | "fmt" | |
| 5 | 6 | "net" | |
| 6 | 7 | "net/http" | |
| 7 | 8 | ||
| @@ -57,6 +58,47 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L | |||
| 57 | 58 | }, "registry", log) | |
| 58 | 59 | } | |
| 59 | 60 | ||
| 61 | + | // HTTPSRedirect redirects HTTP clients to the HTTPS port. | |
| 62 | + | // | |
| 63 | + | // When forceAll is true, all HTTP requests are redirected (for setups with no proxy). | |
| 64 | + | // Otherwise, only non-trusted clients are redirected — trusted proxy IPs and localhost | |
| 65 | + | // pass through since they deliver Cloudflare-proxied traffic. | |
| 66 | + | // When tlsPort is 0, this is always a no-op. | |
| 67 | + | func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 68 | + | return func(next http.Handler) http.Handler { | |
| 69 | + | if tlsPort == 0 { | |
| 70 | + | return next | |
| 71 | + | } | |
| 72 | + | if !forceAll && len(proxyNets) == 0 { | |
| 73 | + | return next | |
| 74 | + | } | |
| 75 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 76 | + | if !forceAll { | |
| 77 | + | remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) | |
| 78 | + | if err != nil { | |
| 79 | + | remoteIP = r.RemoteAddr | |
| 80 | + | } | |
| 81 | + | if IsTrustedProxy(remoteIP, localhostNets) || IsTrustedProxy(remoteIP, proxyNets) { | |
| 82 | + | next.ServeHTTP(w, r) | |
| 83 | + | return | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | host := r.Host | |
| 88 | + | if h, _, err := net.SplitHostPort(host); err == nil { | |
| 89 | + | host = h | |
| 90 | + | } | |
| 91 | + | target := fmt.Sprintf("https://%s:%d%s", host, tlsPort, r.RequestURI) | |
| 92 | + | ||
| 93 | + | log.Debug(). | |
| 94 | + | Str("target", target). | |
| 95 | + | Msg("redirecting HTTP client to HTTPS") | |
| 96 | + | ||
| 97 | + | http.Redirect(w, r, target, http.StatusMovedPermanently) | |
| 98 | + | }) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 60 | 102 | // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges. | |
| 61 | 103 | // This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only | |
| 62 | 104 | // trusted sources (e.g. Cloudflare edge IPs) can reach the proxy server. | |
| @@ -2,6 +2,7 @@ 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" | ||
| 7 | 8 | ||||
| @@ -57,6 +58,47 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L | |||||
| 57 | }, "registry", log) | 58 | }, "registry", log) | ||
| 58 | } | 59 | } | ||
| 59 | 60 | ||||
| 61 | + | // HTTPSRedirect redirects HTTP clients to the HTTPS port. | |||
| 62 | + | // | |||
| 63 | + | // When forceAll is true, all HTTP requests are redirected (for setups with no proxy). | |||
| 64 | + | // Otherwise, only non-trusted clients are redirected — trusted proxy IPs and localhost | |||
| 65 | + | // pass through since they deliver Cloudflare-proxied traffic. | |||
| 66 | + | // When tlsPort is 0, this is always a no-op. | |||
| 67 | + | func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerowrap.Logger) func(http.Handler) http.Handler { | |||
| 68 | + | return func(next http.Handler) http.Handler { | |||
| 69 | + | if tlsPort == 0 { | |||
| 70 | + | return next | |||
| 71 | + | } | |||
| 72 | + | if !forceAll && len(proxyNets) == 0 { | |||
| 73 | + | return next | |||
| 74 | + | } | |||
| 75 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 76 | + | if !forceAll { | |||
| 77 | + | remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) | |||
| 78 | + | if err != nil { | |||
| 79 | + | remoteIP = r.RemoteAddr | |||
| 80 | + | } | |||
| 81 | + | if IsTrustedProxy(remoteIP, localhostNets) || IsTrustedProxy(remoteIP, proxyNets) { | |||
| 82 | + | next.ServeHTTP(w, r) | |||
| 83 | + | return | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | host := r.Host | |||
| 88 | + | if h, _, err := net.SplitHostPort(host); err == nil { | |||
| 89 | + | host = h | |||
| 90 | + | } | |||
| 91 | + | target := fmt.Sprintf("https://%s:%d%s", host, tlsPort, r.RequestURI) | |||
| 92 | + | ||||
| 93 | + | log.Debug(). | |||
| 94 | + | Str("target", target). | |||
| 95 | + | Msg("redirecting HTTP client to HTTPS") | |||
| 96 | + | ||||
| 97 | + | http.Redirect(w, r, target, http.StatusMovedPermanently) | |||
| 98 | + | }) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 60 | // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges. | 102 | // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges. | ||
| 61 | // This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only | 103 | // 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. | 104 | // trusted sources (e.g. Cloudflare edge IPs) can reach the proxy server. | ||
M internal/adapters/out/pki/ca.go +22 -2
| @@ -323,10 +323,30 @@ func (ca *CA) storeIntermediate(cert *x509.Certificate, key *ecdsa.PrivateKey) e | |||
| 323 | 323 | } | |
| 324 | 324 | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) | |
| 325 | 325 | ||
| 326 | - | if err := writeSecure(certPath, certPEM, 0644); err != nil { | |
| 326 | + | // Write both files to temps first, then atomically rename together | |
| 327 | + | // so a crash between writes cannot leave a dangling cert without its key. | |
| 328 | + | certTmp := certPath + ".tmp" | |
| 329 | + | keyTmp := keyPath + ".tmp" | |
| 330 | + | ||
| 331 | + | if err := writeSecure(certTmp, certPEM, 0644); err != nil { | |
| 327 | 332 | return err | |
| 328 | 333 | } | |
| 329 | - | return writeSecure(keyPath, keyPEM, 0600) | |
| 334 | + | if err := writeSecure(keyTmp, keyPEM, 0600); err != nil { | |
| 335 | + | os.Remove(certTmp) | |
| 336 | + | return err | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | if err := os.Rename(certTmp, certPath); err != nil { | |
| 340 | + | os.Remove(certTmp) | |
| 341 | + | os.Remove(keyTmp) | |
| 342 | + | return fmt.Errorf("rename intermediate cert: %w", err) | |
| 343 | + | } | |
| 344 | + | if err := os.Rename(keyTmp, keyPath); err != nil { | |
| 345 | + | os.Remove(keyTmp) | |
| 346 | + | return fmt.Errorf("rename intermediate key: %w", err) | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | return nil | |
| 330 | 350 | } | |
| 331 | 351 | ||
| 332 | 352 | // IssueCertificate generates a leaf cert for the given domain, | |
| @@ -323,10 +323,30 @@ func (ca *CA) storeIntermediate(cert *x509.Certificate, key *ecdsa.PrivateKey) e | |||||
| 323 | } | 323 | } | ||
| 324 | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) | 324 | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) | ||
| 325 | 325 | ||||
| 326 | - | if err := writeSecure(certPath, certPEM, 0644); err != nil { | 326 | + | // Write both files to temps first, then atomically rename together |
| 327 | + | // so a crash between writes cannot leave a dangling cert without its key. | |||
| 328 | + | certTmp := certPath + ".tmp" | |||
| 329 | + | keyTmp := keyPath + ".tmp" | |||
| 330 | + | ||||
| 331 | + | if err := writeSecure(certTmp, certPEM, 0644); err != nil { | |||
| 327 | return err | 332 | return err | ||
| 328 | } | 333 | } | ||
| 329 | - | return writeSecure(keyPath, keyPEM, 0600) | 334 | + | if err := writeSecure(keyTmp, keyPEM, 0600); err != nil { |
| 335 | + | os.Remove(certTmp) | |||
| 336 | + | return err | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | if err := os.Rename(certTmp, certPath); err != nil { | |||
| 340 | + | os.Remove(certTmp) | |||
| 341 | + | os.Remove(keyTmp) | |||
| 342 | + | return fmt.Errorf("rename intermediate cert: %w", err) | |||
| 343 | + | } | |||
| 344 | + | if err := os.Rename(keyTmp, keyPath); err != nil { | |||
| 345 | + | os.Remove(keyTmp) | |||
| 346 | + | return fmt.Errorf("rename intermediate key: %w", err) | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | return nil | |||
| 330 | } | 350 | } | ||
| 331 | 351 | ||||
| 332 | // IssueCertificate generates a leaf cert for the given domain, | 352 | // IssueCertificate generates a leaf cert for the given domain, | ||
M internal/app/run.go +27 -4
| @@ -89,6 +89,9 @@ type Config struct { | |||
| 89 | 89 | GordonDomain string `mapstructure:"gordon_domain"` | |
| 90 | 90 | RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain | |
| 91 | 91 | TLSPort int `mapstructure:"tls_port"` | |
| 92 | + | TLSCertFile string `mapstructure:"tls_cert_file"` | |
| 93 | + | TLSKeyFile string `mapstructure:"tls_key_file"` | |
| 94 | + | ForceHTTPSRedirect bool `mapstructure:"force_https_redirect"` | |
| 92 | 95 | DataDir string `mapstructure:"data_dir"` | |
| 93 | 96 | MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" | |
| 94 | 97 | MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB" | |
| @@ -248,7 +251,7 @@ func Run(ctx context.Context, configPath string) error { | |||
| 248 | 251 | log.Info().Msg("Gordon starting") | |
| 249 | 252 | ||
| 250 | 253 | // Deprecation warnings for removed TLS config fields | |
| 251 | - | for _, key := range []string{"server.tls_enabled", "server.tls_cert_file", "server.tls_key_file", "server.force_hsts"} { | |
| 254 | + | for _, key := range []string{"server.tls_enabled", "server.force_hsts"} { | |
| 252 | 255 | if v.IsSet(key) { | |
| 253 | 256 | log.Warn().Str("key", key).Msg("deprecated config key — Gordon now uses an internal CA with automatic TLS; remove this from your config") | |
| 254 | 257 | } | |
| @@ -375,6 +378,10 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 375 | 378 | return nil, log.WrapErr(err, "failed to load configuration") | |
| 376 | 379 | } | |
| 377 | 380 | ||
| 381 | + | if (cfg.Server.TLSCertFile == "") != (cfg.Server.TLSKeyFile == "") { | |
| 382 | + | return nil, fmt.Errorf("both tls_cert_file and tls_key_file must be set, or neither") | |
| 383 | + | } | |
| 384 | + | ||
| 378 | 385 | if cfg.Server.TLSPort != 0 { | |
| 379 | 386 | caAdapter, err := pkiadapter.NewCA(resolveDataDir(cfg.Server.DataDir), log) | |
| 380 | 387 | if err != nil { | |
| @@ -1582,13 +1589,14 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1582 | 1589 | // Proxy handler | |
| 1583 | 1590 | proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log) | |
| 1584 | 1591 | ||
| 1585 | - | // HTTP proxy handler chain: CIDR + onboarding routes + proxy | |
| 1586 | - | _, proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log) | |
| 1592 | + | // HTTP proxy handler chain: HTTPS redirect for non-proxy clients, then CIDR allowlist | |
| 1593 | + | proxyAllowedNets, proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log) | |
| 1587 | 1594 | ||
| 1588 | 1595 | httpProxyMiddlewares := []func(http.Handler) http.Handler{ | |
| 1589 | 1596 | middleware.PanicRecovery(log), | |
| 1590 | 1597 | middleware.RequestLogger(log, trustedNets), | |
| 1591 | 1598 | middleware.SecurityHeaders, | |
| 1599 | + | middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log), | |
| 1592 | 1600 | } | |
| 1593 | 1601 | if proxyCIDRMiddleware != nil { | |
| 1594 | 1602 | httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware) | |
| @@ -2193,12 +2201,24 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv | |||
| 2193 | 2201 | return httpSrv, httpReady, nil, nil, nil | |
| 2194 | 2202 | } | |
| 2195 | 2203 | ||
| 2196 | - | // HTTPS listener (on-demand TLS via internal CA) | |
| 2204 | + | // HTTPS listener — static cert (if provided) takes priority by SNI match, | |
| 2205 | + | // unmatched domains fall through to on-demand internal CA certs. | |
| 2197 | 2206 | tlsConfig := &tls.Config{ | |
| 2198 | 2207 | MinVersion: tls.VersionTLS12, | |
| 2199 | 2208 | GetCertificate: pkiSvc.GetCertificate, | |
| 2200 | 2209 | NextProtos: []string{"h2", "http/1.1"}, | |
| 2201 | 2210 | } | |
| 2211 | + | if cfg.Server.TLSCertFile != "" { | |
| 2212 | + | staticCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile) | |
| 2213 | + | if err != nil { | |
| 2214 | + | return nil, nil, nil, nil, fmt.Errorf("load TLS keypair: %w", err) | |
| 2215 | + | } | |
| 2216 | + | tlsConfig.Certificates = []tls.Certificate{staticCert} | |
| 2217 | + | log.Info(). | |
| 2218 | + | Str("cert", cfg.Server.TLSCertFile). | |
| 2219 | + | Str("key", cfg.Server.TLSKeyFile). | |
| 2220 | + | Msg("loaded static TLS certificate (internal CA handles remaining domains)") | |
| 2221 | + | } | |
| 2202 | 2222 | tlsSrv, tlsReady := startTLSServerWithConfig( | |
| 2203 | 2223 | fmt.Sprintf(":%d", cfg.Server.TLSPort), | |
| 2204 | 2224 | httpsHandler, | |
| @@ -2535,6 +2555,9 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 2535 | 2555 | v.SetDefault("server.port", 8088) | |
| 2536 | 2556 | v.SetDefault("server.registry_port", 5000) | |
| 2537 | 2557 | v.SetDefault("server.tls_port", 8443) | |
| 2558 | + | v.SetDefault("server.tls_cert_file", "") | |
| 2559 | + | v.SetDefault("server.tls_key_file", "") | |
| 2560 | + | v.SetDefault("server.force_https_redirect", false) | |
| 2538 | 2561 | v.SetDefault("server.data_dir", DefaultDataDir()) | |
| 2539 | 2562 | v.SetDefault("server.runtime", "auto") | |
| 2540 | 2563 | v.SetDefault("logging.level", "info") | |
| @@ -89,6 +89,9 @@ type Config struct { | |||||
| 89 | GordonDomain string `mapstructure:"gordon_domain"` | 89 | GordonDomain string `mapstructure:"gordon_domain"` | ||
| 90 | RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain | 90 | RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain | ||
| 91 | TLSPort int `mapstructure:"tls_port"` | 91 | TLSPort int `mapstructure:"tls_port"` | ||
| 92 | + | TLSCertFile string `mapstructure:"tls_cert_file"` | |||
| 93 | + | TLSKeyFile string `mapstructure:"tls_key_file"` | |||
| 94 | + | ForceHTTPSRedirect bool `mapstructure:"force_https_redirect"` | |||
| 92 | DataDir string `mapstructure:"data_dir"` | 95 | DataDir string `mapstructure:"data_dir"` | ||
| 93 | MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" | 96 | MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" | ||
| 94 | MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB" | 97 | MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB" | ||
| @@ -248,7 +251,7 @@ func Run(ctx context.Context, configPath string) error { | |||||
| 248 | log.Info().Msg("Gordon starting") | 251 | log.Info().Msg("Gordon starting") | ||
| 249 | 252 | ||||
| 250 | // Deprecation warnings for removed TLS config fields | 253 | // Deprecation warnings for removed TLS config fields | ||
| 251 | - | for _, key := range []string{"server.tls_enabled", "server.tls_cert_file", "server.tls_key_file", "server.force_hsts"} { | 254 | + | for _, key := range []string{"server.tls_enabled", "server.force_hsts"} { |
| 252 | if v.IsSet(key) { | 255 | if v.IsSet(key) { | ||
| 253 | log.Warn().Str("key", key).Msg("deprecated config key — Gordon now uses an internal CA with automatic TLS; remove this from your config") | 256 | log.Warn().Str("key", key).Msg("deprecated config key — Gordon now uses an internal CA with automatic TLS; remove this from your config") | ||
| 254 | } | 257 | } | ||
| @@ -375,6 +378,10 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 375 | return nil, log.WrapErr(err, "failed to load configuration") | 378 | return nil, log.WrapErr(err, "failed to load configuration") | ||
| 376 | } | 379 | } | ||
| 377 | 380 | ||||
| 381 | + | if (cfg.Server.TLSCertFile == "") != (cfg.Server.TLSKeyFile == "") { | |||
| 382 | + | return nil, fmt.Errorf("both tls_cert_file and tls_key_file must be set, or neither") | |||
| 383 | + | } | |||
| 384 | + | ||||
| 378 | if cfg.Server.TLSPort != 0 { | 385 | if cfg.Server.TLSPort != 0 { | ||
| 379 | caAdapter, err := pkiadapter.NewCA(resolveDataDir(cfg.Server.DataDir), log) | 386 | caAdapter, err := pkiadapter.NewCA(resolveDataDir(cfg.Server.DataDir), log) | ||
| 380 | if err != nil { | 387 | if err != nil { | ||
| @@ -1582,13 +1589,14 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1582 | // Proxy handler | 1589 | // Proxy handler | ||
| 1583 | proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log) | 1590 | proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log) | ||
| 1584 | 1591 | ||||
| 1585 | - | // HTTP proxy handler chain: CIDR + onboarding routes + proxy | 1592 | + | // HTTP proxy handler chain: HTTPS redirect for non-proxy clients, then CIDR allowlist |
| 1586 | - | _, proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log) | 1593 | + | proxyAllowedNets, proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log) |
| 1587 | 1594 | ||||
| 1588 | httpProxyMiddlewares := []func(http.Handler) http.Handler{ | 1595 | httpProxyMiddlewares := []func(http.Handler) http.Handler{ | ||
| 1589 | middleware.PanicRecovery(log), | 1596 | middleware.PanicRecovery(log), | ||
| 1590 | middleware.RequestLogger(log, trustedNets), | 1597 | middleware.RequestLogger(log, trustedNets), | ||
| 1591 | middleware.SecurityHeaders, | 1598 | middleware.SecurityHeaders, | ||
| 1599 | + | middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log), | |||
| 1592 | } | 1600 | } | ||
| 1593 | if proxyCIDRMiddleware != nil { | 1601 | if proxyCIDRMiddleware != nil { | ||
| 1594 | httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware) | 1602 | httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware) | ||
| @@ -2193,12 +2201,24 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv | |||||
| 2193 | return httpSrv, httpReady, nil, nil, nil | 2201 | return httpSrv, httpReady, nil, nil, nil | ||
| 2194 | } | 2202 | } | ||
| 2195 | 2203 | ||||
| 2196 | - | // HTTPS listener (on-demand TLS via internal CA) | 2204 | + | // HTTPS listener — static cert (if provided) takes priority by SNI match, |
| 2205 | + | // unmatched domains fall through to on-demand internal CA certs. | |||
| 2197 | tlsConfig := &tls.Config{ | 2206 | tlsConfig := &tls.Config{ | ||
| 2198 | MinVersion: tls.VersionTLS12, | 2207 | MinVersion: tls.VersionTLS12, | ||
| 2199 | GetCertificate: pkiSvc.GetCertificate, | 2208 | GetCertificate: pkiSvc.GetCertificate, | ||
| 2200 | NextProtos: []string{"h2", "http/1.1"}, | 2209 | NextProtos: []string{"h2", "http/1.1"}, | ||
| 2201 | } | 2210 | } | ||
| 2211 | + | if cfg.Server.TLSCertFile != "" { | |||
| 2212 | + | staticCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile) | |||
| 2213 | + | if err != nil { | |||
| 2214 | + | return nil, nil, nil, nil, fmt.Errorf("load TLS keypair: %w", err) | |||
| 2215 | + | } | |||
| 2216 | + | tlsConfig.Certificates = []tls.Certificate{staticCert} | |||
| 2217 | + | log.Info(). | |||
| 2218 | + | Str("cert", cfg.Server.TLSCertFile). | |||
| 2219 | + | Str("key", cfg.Server.TLSKeyFile). | |||
| 2220 | + | Msg("loaded static TLS certificate (internal CA handles remaining domains)") | |||
| 2221 | + | } | |||
| 2202 | tlsSrv, tlsReady := startTLSServerWithConfig( | 2222 | tlsSrv, tlsReady := startTLSServerWithConfig( | ||
| 2203 | fmt.Sprintf(":%d", cfg.Server.TLSPort), | 2223 | fmt.Sprintf(":%d", cfg.Server.TLSPort), | ||
| 2204 | httpsHandler, | 2224 | httpsHandler, | ||
| @@ -2535,6 +2555,9 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 2535 | v.SetDefault("server.port", 8088) | 2555 | v.SetDefault("server.port", 8088) | ||
| 2536 | v.SetDefault("server.registry_port", 5000) | 2556 | v.SetDefault("server.registry_port", 5000) | ||
| 2537 | v.SetDefault("server.tls_port", 8443) | 2557 | v.SetDefault("server.tls_port", 8443) | ||
| 2558 | + | v.SetDefault("server.tls_cert_file", "") | |||
| 2559 | + | v.SetDefault("server.tls_key_file", "") | |||
| 2560 | + | v.SetDefault("server.force_https_redirect", false) | |||
| 2538 | v.SetDefault("server.data_dir", DefaultDataDir()) | 2561 | v.SetDefault("server.data_dir", DefaultDataDir()) | ||
| 2539 | v.SetDefault("server.runtime", "auto") | 2562 | v.SetDefault("server.runtime", "auto") | ||
| 2540 | v.SetDefault("logging.level", "info") | 2563 | v.SetDefault("logging.level", "info") | ||