detached a6947b2

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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"
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_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.
346337
347338 `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`.
348339
@@ -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.
1212 port = 8088 # HTTP proxy port
1313 registry_port = 5000 # Container registry port
1414 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)
1518 gordon_domain = "" # Required: Gordon domain (registry + API)
1619 data_dir = "~/.gordon" # Data directory (varies by install type)
1720 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
169172 | `server.port` | `8088` | HTTP proxy port |
170173 | `server.registry_port` | `5000` | Container registry port |
171174 | `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) |
172178 | `server.gordon_domain` | `""` | **Required** - Gordon domain |
173179 | `server.data_dir` | `~/.gordon` | Data directory |
174180 | `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 port12 port = 8088 # HTTP proxy port
13 registry_port = 5000 # Container registry port13 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 chunk20 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.
99 port = 8088 # HTTP proxy port
1010 registry_port = 5000 # Container registry port
1111 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
1215 gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
1316 # data_dir = "~/.gordon" # Data storage directory (default)
1417 ```
@@ -20,6 +23,9 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
2023 | `port` | int | `8088` | HTTP proxy port for routing traffic to containers |
2124 | `registry_port` | int | `5000` | Docker registry port for image push/pull |
2225 | `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 |
2329 | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) |
2430 | `registry_domain` | string | - | Deprecated alias for `gordon_domain` |
2531 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |
@@ -60,12 +66,35 @@ With TLS enabled:
6066 - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/`
6167 - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory
6268 - 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
6470 - The root CA is stable across restarts (generated once, persisted to disk)
6571
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+
6695 #### Disabling TLS
6796
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.):
6998
7099 ```toml
71100 [server]
@@ -9,6 +9,9 @@ Core server settings for Gordon.
9 port = 8088 # HTTP proxy port9 port = 8088 # HTTP proxy port
10 registry_port = 5000 # Container registry port10 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 memory67 - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory
62 - The intermediate CA auto-renews before expiry68 - The intermediate CA auto-renews before expiry
63-- An onboarding page at `http://{host}/ca` lets clients download the root CA certificate69+- 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 TLS95 #### 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 ```toml99 ```toml
71 [server]100 [server]
M internal/adapters/in/http/middleware/cidr.go +42 -0
@@ -2,6 +2,7 @@ package middleware
22
33 import (
44 "encoding/json"
5+ "fmt"
56 "net"
67 "net/http"
78
@@ -57,6 +58,47 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L
5758 }, "registry", log)
5859 }
5960
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+
60102 // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.
61103 // This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only
62104 // 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 only103 // 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
323323 }
324324 keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
325325
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 {
327332 return err
328333 }
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
330350 }
331351
332352 // 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 err332 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 {
8989 GordonDomain string `mapstructure:"gordon_domain"`
9090 RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
9191 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"`
9295 DataDir string `mapstructure:"data_dir"`
9396 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
9497 MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
@@ -248,7 +251,7 @@ func Run(ctx context.Context, configPath string) error {
248251 log.Info().Msg("Gordon starting")
249252
250253 // 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"} {
252255 if v.IsSet(key) {
253256 log.Warn().Str("key", key).Msg("deprecated config key — Gordon now uses an internal CA with automatic TLS; remove this from your config")
254257 }
@@ -375,6 +378,10 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
375378 return nil, log.WrapErr(err, "failed to load configuration")
376379 }
377380
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+
378385 if cfg.Server.TLSPort != 0 {
379386 caAdapter, err := pkiadapter.NewCA(resolveDataDir(cfg.Server.DataDir), log)
380387 if err != nil {
@@ -1582,13 +1589,14 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
15821589 // Proxy handler
15831590 proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log)
15841591
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)
15871594
15881595 httpProxyMiddlewares := []func(http.Handler) http.Handler{
15891596 middleware.PanicRecovery(log),
15901597 middleware.RequestLogger(log, trustedNets),
15911598 middleware.SecurityHeaders,
1599+ middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log),
15921600 }
15931601 if proxyCIDRMiddleware != nil {
15941602 httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware)
@@ -2193,12 +2201,24 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv
21932201 return httpSrv, httpReady, nil, nil, nil
21942202 }
21952203
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.
21972206 tlsConfig := &tls.Config{
21982207 MinVersion: tls.VersionTLS12,
21992208 GetCertificate: pkiSvc.GetCertificate,
22002209 NextProtos: []string{"h2", "http/1.1"},
22012210 }
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+ }
22022222 tlsSrv, tlsReady := startTLSServerWithConfig(
22032223 fmt.Sprintf(":%d", cfg.Server.TLSPort),
22042224 httpsHandler,
@@ -2535,6 +2555,9 @@ func loadConfig(v *viper.Viper, configPath string) error {
25352555 v.SetDefault("server.port", 8088)
25362556 v.SetDefault("server.registry_port", 5000)
25372557 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)
25382561 v.SetDefault("server.data_dir", DefaultDataDir())
25392562 v.SetDefault("server.runtime", "auto")
25402563 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_domain90 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 fields253 // 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 handler1589 // 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 + proxy1592+ // 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, nil2201 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")

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