detached df419f4

feat: add native TLS listener support

Allow Gordon to terminate HTTPS directly via server.tls_enabled,
with optional auto-generated self-signed certificates when no
cert/key paths are provided. Update docs to reflect the new
TLS configuration options.
commit
df419f47802bcfba808697d35df32f23821d8638
author
brice · · 2026-02-07 04:42 UTC
committer
brice · · 2026-02-07 04:54 UTC
parent
4a08931
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +237 -7
M docs/config/rate-limiting.md +2 -2
@@ -28,7 +28,7 @@ burst = 100
2828 trusted_proxies = []
2929 ```
3030
31-> **Important:** Gordon does not handle TLS termination, so production deployments require a front proxy (Cloudflare, nginx, etc.) for HTTPS. You **must** configure `trusted_proxies` to ensure rate limiting sees real client IPs instead of your proxy's IP.
31+> **Important:** If you run Gordon behind a front proxy (Cloudflare, nginx, etc.), configure `trusted_proxies` so rate limiting sees real client IPs instead of your proxy's IP.
3232
3333 ## Configuration
3434
@@ -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, but it **does not handle TLS termination**. For HTTPS support, you must place Gordon 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 (`server.tls_enabled = true`) or place it behind a TLS-terminating proxy like Cloudflare, nginx, or a load balancer.
154154
155155 ```
156156 Internet → [Cloudflare/nginx] → Gordon → Containers
@@ -28,7 +28,7 @@ burst = 100
28 trusted_proxies = []28 trusted_proxies = []
29 ```29 ```
30 30
31-> **Important:** Gordon does not handle TLS termination, so production deployments require a front proxy (Cloudflare, nginx, etc.) for HTTPS. You **must** configure `trusted_proxies` to ensure rate limiting sees real client IPs instead of your proxy's IP.31+> **Important:** If you run Gordon behind a front proxy (Cloudflare, nginx, etc.), configure `trusted_proxies` so rate limiting sees real client IPs instead of your proxy's IP.
32 32
33 ## Configuration33 ## Configuration
34 34
@@ -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, but it **does not handle TLS termination**. For HTTPS support, you must place Gordon 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 (`server.tls_enabled = true`) 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 +8 -0
@@ -11,6 +11,10 @@ Complete configuration reference with all options and their default values.
1111 [server]
1212 port = 80 # 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)
1418 gordon_domain = "" # Required: Gordon domain (registry + API)
1519 data_dir = "~/.gordon" # Data directory (varies by install type)
1620 max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk
@@ -124,6 +128,10 @@ preserve = true # Keep volumes when containers are
124128 |---------|---------|-------------|
125129 | `server.port` | `80` | HTTP proxy port |
126130 | `server.registry_port` | `5000` | Container registry port |
131+| `server.tls_enabled` | `false` | Enable native HTTPS listener |
132+| `server.tls_port` | `443` | HTTPS listener port |
133+| `server.tls_cert_file` | `""` | TLS cert path (auto-generated when empty) |
134+| `server.tls_key_file` | `""` | TLS key path (auto-generated when empty) |
127135 | `server.gordon_domain` | `""` | **Required** - Gordon domain |
128136 | `server.data_dir` | `~/.gordon` | Data directory |
129137 | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk |
@@ -11,6 +11,10 @@ Complete configuration reference with all options and their default values.
11 [server]11 [server]
12 port = 80 # HTTP proxy port12 port = 80 # HTTP proxy port
13 registry_port = 5000 # Container registry port13 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 gordon_domain = "" # Required: Gordon domain (registry + API)18 gordon_domain = "" # Required: Gordon domain (registry + API)
15 data_dir = "~/.gordon" # Data directory (varies by install type)19 data_dir = "~/.gordon" # Data directory (varies by install type)
16 max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk20 max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk
@@ -124,6 +128,10 @@ preserve = true # Keep volumes when containers are
124 |---------|---------|-------------|128 |---------|---------|-------------|
125 | `server.port` | `80` | HTTP proxy port |129 | `server.port` | `80` | HTTP proxy port |
126 | `server.registry_port` | `5000` | Container registry port |130 | `server.registry_port` | `5000` | Container registry port |
131+| `server.tls_enabled` | `false` | Enable native HTTPS listener |
132+| `server.tls_port` | `443` | HTTPS listener port |
133+| `server.tls_cert_file` | `""` | TLS cert path (auto-generated when empty) |
134+| `server.tls_key_file` | `""` | TLS key path (auto-generated when empty) |
127 | `server.gordon_domain` | `""` | **Required** - Gordon domain |135 | `server.gordon_domain` | `""` | **Required** - Gordon domain |
128 | `server.data_dir` | `~/.gordon` | Data directory |136 | `server.data_dir` | `~/.gordon` | Data directory |
129 | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk |137 | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk |
M docs/config/server.md +26 -0
@@ -8,6 +8,10 @@ Core server settings for Gordon.
88 [server]
99 port = 8080 # 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)
1115 gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
1216 # data_dir = "~/.gordon" # Data storage directory (default)
1317 ```
@@ -18,6 +22,10 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
1822 |--------|------|---------|-------------|
1923 | `port` | int | `80` | HTTP proxy port for routing traffic to containers |
2024 | `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 |
2129 | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) |
2230 | `registry_domain` | string | - | Deprecated alias for `gordon_domain` |
2331 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |
@@ -42,6 +50,24 @@ For rootless containers, you'll typically use a high port and configure firewall
4250 sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8080
4351 ```
4452
53+### Native TLS Listener
54+
55+Enable native HTTPS directly in Gordon:
56+
57+```toml
58+[server]
59+tls_enabled = true
60+tls_port = 443
61+tls_cert_file = "/etc/gordon/certs/fullchain.pem"
62+tls_key_file = "/etc/gordon/certs/privkey.pem"
63+```
64+
65+With TLS enabled:
66+- Gordon terminates HTTPS on `tls_port`
67+- Host-based routing still works (apps, `/v2/*`, `/auth/*`, `/admin/*`)
68+- `port` remains available for plain HTTP unless you restrict it externally
69+- If `tls_cert_file`/`tls_key_file` are empty, Gordon auto-generates a self-signed pair in `{data_dir}/tls/`
70+
4571 ### Registry Port
4672
4773 The `registry_port` setting controls where Gordon accepts image pushes:
@@ -8,6 +8,10 @@ Core server settings for Gordon.
8 [server]8 [server]
9 port = 8080 # HTTP proxy port9 port = 8080 # HTTP proxy port
10 registry_port = 5000 # Container registry port10 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 gordon_domain = "gordon.mydomain.com" # Gordon domain (required)15 gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
12 # data_dir = "~/.gordon" # Data storage directory (default)16 # data_dir = "~/.gordon" # Data storage directory (default)
13 ```17 ```
@@ -18,6 +22,10 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
18 |--------|------|---------|-------------|22 |--------|------|---------|-------------|
19 | `port` | int | `80` | HTTP proxy port for routing traffic to containers |23 | `port` | int | `80` | HTTP proxy port for routing traffic to containers |
20 | `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 |
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 |
21 | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) |29 | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) |
22 | `registry_domain` | string | - | Deprecated alias for `gordon_domain` |30 | `registry_domain` | string | - | Deprecated alias for `gordon_domain` |
23 | `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 |
@@ -42,6 +50,24 @@ For rootless containers, you'll typically use a high port and configure firewall
42 sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=808050 sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8080
43 ```51 ```
44 52
53+### Native TLS Listener
54+
55+Enable native HTTPS directly in Gordon:
56+
57+```toml
58+[server]
59+tls_enabled = true
60+tls_port = 443
61+tls_cert_file = "/etc/gordon/certs/fullchain.pem"
62+tls_key_file = "/etc/gordon/certs/privkey.pem"
63+```
64+
65+With TLS enabled:
66+- Gordon terminates HTTPS on `tls_port`
67+- Host-based routing still works (apps, `/v2/*`, `/auth/*`, `/admin/*`)
68+- `port` remains available for plain HTTP unless you restrict it externally
69+- If `tls_cert_file`/`tls_key_file` are empty, Gordon auto-generates a self-signed pair in `{data_dir}/tls/`
70+
45 ### Registry Port71 ### Registry Port
46 72
47 The `registry_port` setting controls where Gordon accepts image pushes:73 The `registry_port` setting controls where Gordon accepts image pushes:
M docs/index.md +1 -1
@@ -77,7 +77,7 @@ Gordon runs on your VPS and provides:
7777 - Domain pointing to your server
7878 - Cloudflare account for HTTPS (free tier works)
7979
80-> **Note:** Gordon does not handle TLS certificates directly. HTTPS is terminated by Cloudflare (or similar proxy) which forwards HTTP traffic to Gordon internally.
80+> **Note:** Gordon can run behind Cloudflare/nginx, or terminate TLS directly when `server.tls_enabled = true`.
8181
8282 ## Related
8383
@@ -77,7 +77,7 @@ Gordon runs on your VPS and provides:
77 - Domain pointing to your server77 - Domain pointing to your server
78 - Cloudflare account for HTTPS (free tier works)78 - Cloudflare account for HTTPS (free tier works)
79 79
80-> **Note:** Gordon does not handle TLS certificates directly. HTTPS is terminated by Cloudflare (or similar proxy) which forwards HTTP traffic to Gordon internally.80+> **Note:** Gordon can run behind Cloudflare/nginx, or terminate TLS directly when `server.tls_enabled = true`.
81 81
82 ## Related82 ## Related
83 83
M internal/app/run.go +200 -4
@@ -3,11 +3,17 @@ package app
33
44 import (
55 "context"
6+ "crypto/ecdsa"
7+ "crypto/elliptic"
68 "crypto/rand"
9+ "crypto/x509"
10+ "crypto/x509/pkix"
711 "encoding/hex"
812 "encoding/json"
13+ "encoding/pem"
914 "errors"
1015 "fmt"
16+ "math/big"
1117 "net"
1218 "net/http"
1319 "os"
@@ -68,6 +74,10 @@ type Config struct {
6874 RegistryPort int `mapstructure:"registry_port"`
6975 GordonDomain string `mapstructure:"gordon_domain"`
7076 RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
77+ TLSEnabled bool `mapstructure:"tls_enabled"`
78+ TLSPort int `mapstructure:"tls_port"`
79+ TLSCertFile string `mapstructure:"tls_cert_file"`
80+ TLSKeyFile string `mapstructure:"tls_key_file"`
7181 DataDir string `mapstructure:"data_dir"`
7282 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
7383 MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
@@ -160,6 +170,10 @@ func Run(ctx context.Context, configPath string) error {
160170 ctx = zerowrap.WithCtx(ctx, log)
161171 log.Info().Msg("Gordon starting")
162172
173+ if err := ensureTLSConfig(&cfg, log); err != nil {
174+ return err
175+ }
176+
163177 // Create PID file
164178 pidFile := createPidFile(log)
165179 if pidFile != "" {
@@ -193,6 +207,123 @@ func Run(ctx context.Context, configPath string) error {
193207 return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, log)
194208 }
195209
210+func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error {
211+ if !cfg.Server.TLSEnabled {
212+ return nil
213+ }
214+
215+ if cfg.Server.TLSCertFile != "" && cfg.Server.TLSKeyFile != "" {
216+ return nil
217+ }
218+
219+ dataDir := resolveDataDir(cfg.Server.DataDir)
220+ tlsDir := filepath.Join(dataDir, "tls")
221+ certPath := filepath.Join(tlsDir, "cert.pem")
222+ keyPath := filepath.Join(tlsDir, "key.pem")
223+
224+ domainName := cfg.Server.GordonDomain
225+ if domainName == "" {
226+ domainName = cfg.Server.RegistryDomain
227+ }
228+ if domainName == "" {
229+ domainName = "localhost"
230+ }
231+
232+ if err := generateSelfSignedTLSCert(certPath, keyPath, domainName); err != nil {
233+ return fmt.Errorf("failed to generate self-signed TLS certificate: %w", err)
234+ }
235+
236+ cfg.Server.TLSCertFile = certPath
237+ cfg.Server.TLSKeyFile = keyPath
238+
239+ log.Warn().
240+ Str("cert_file", certPath).
241+ Str("key_file", keyPath).
242+ Str("domain", domainName).
243+ Msg("server.tls_enabled=true with empty tls_cert_file/tls_key_file; using auto-generated self-signed certificate")
244+
245+ return nil
246+}
247+
248+func generateSelfSignedTLSCert(certPath, keyPath, domainName string) error {
249+ // Reuse existing pair if already generated.
250+ if _, err := os.Stat(certPath); err == nil {
251+ if _, keyErr := os.Stat(keyPath); keyErr == nil {
252+ return nil
253+ }
254+ }
255+
256+ if err := os.MkdirAll(filepath.Dir(certPath), 0700); err != nil {
257+ return err
258+ }
259+ if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil {
260+ return err
261+ }
262+
263+ priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
264+ if err != nil {
265+ return err
266+ }
267+
268+ now := time.Now()
269+ serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
270+ serialNumber, err := rand.Int(rand.Reader, serialLimit)
271+ if err != nil {
272+ return err
273+ }
274+
275+ template := x509.Certificate{
276+ SerialNumber: serialNumber,
277+ Subject: pkix.Name{
278+ CommonName: domainName,
279+ Organization: []string{"Gordon Auto-Generated TLS"},
280+ },
281+ NotBefore: now.Add(-5 * time.Minute),
282+ NotAfter: now.AddDate(1, 0, 0),
283+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
284+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
285+ BasicConstraintsValid: true,
286+ }
287+
288+ if ip := net.ParseIP(domainName); ip != nil {
289+ template.IPAddresses = []net.IP{ip}
290+ } else {
291+ template.DNSNames = []string{domainName}
292+ }
293+
294+ derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
295+ if err != nil {
296+ return err
297+ }
298+
299+ certOut, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
300+ if err != nil {
301+ return err
302+ }
303+ defer certOut.Close()
304+
305+ if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
306+ return err
307+ }
308+
309+ keyBytes, err := x509.MarshalECPrivateKey(priv)
310+ if err != nil {
311+ return err
312+ }
313+
314+ keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
315+ if err != nil {
316+ return err
317+ }
318+ defer keyOut.Close()
319+
320+ if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}); err != nil {
321+ return err
322+ }
323+
324+ return nil
325+}
326+
196327 // initConfig loads configuration from file.
197328 func initConfig(configPath string) (*viper.Viper, Config, error) {
198329 v := viper.New()
@@ -1100,10 +1231,25 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
11001231 signal.Notify(deployChan, syscall.SIGUSR2)
11011232 defer signal.Stop(deployChan)
11021233
1103- errChan := make(chan error, 2)
1234+ errChan := make(chan error, 3)
11041235
11051236 registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
11061237 proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
1238+ var tlsReady <-chan struct{}
1239+ if cfg.Server.TLSEnabled {
1240+ if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" {
1241+ return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file")
1242+ }
1243+ tlsReady = startTLSServer(
1244+ fmt.Sprintf(":%d", cfg.Server.TLSPort),
1245+ proxyHandler,
1246+ "proxy-tls",
1247+ cfg.Server.TLSCertFile,
1248+ cfg.Server.TLSKeyFile,
1249+ errChan,
1250+ log,
1251+ )
1252+ }
11071253
11081254 // Wait for both servers to bind their ports before auto-starting containers.
11091255 // This prevents the race where auto-start pulls from the registry before it's listening.
@@ -1117,11 +1263,21 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
11171263 case err := <-errChan:
11181264 return err
11191265 }
1266+ if cfg.Server.TLSEnabled {
1267+ select {
1268+ case <-tlsReady:
1269+ case err := <-errChan:
1270+ return err
1271+ }
1272+ }
11201273
1121- log.Info().
1274+ logEvent := log.Info().
11221275 Int("proxy_port", cfg.Server.Port).
1123- Int("registry_port", cfg.Server.RegistryPort).
1124- Msg("Gordon is running")
1276+ Int("registry_port", cfg.Server.RegistryPort)
1277+ if cfg.Server.TLSEnabled {
1278+ logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort)
1279+ }
1280+ logEvent.Msg("Gordon is running")
11251281
11261282 // Auto-start after servers are listening (registry port is now bound).
11271283 syncAndAutoStart(ctx, svc, log)
@@ -1201,6 +1357,42 @@ func startServer(addr string, handler http.Handler, name string, errChan chan<-
12011357 return ready
12021358 }
12031359
1360+// startTLSServer starts an HTTPS server with the provided certificate and key.
1361+func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile string, errChan chan<- error, log zerowrap.Logger) <-chan struct{} {
1362+ ready := make(chan struct{})
1363+
1364+ go func() {
1365+ log.Info().
1366+ Str("address", addr).
1367+ Str("cert_file", certFile).
1368+ Str("key_file", keyFile).
1369+ Msgf("%s server starting", name)
1370+
1371+ server := &http.Server{
1372+ Addr: addr,
1373+ Handler: handler,
1374+ ReadHeaderTimeout: 10 * time.Second,
1375+ ReadTimeout: 5 * time.Minute,
1376+ WriteTimeout: 5 * time.Minute,
1377+ IdleTimeout: 120 * time.Second,
1378+ MaxHeaderBytes: 1 << 20,
1379+ }
1380+
1381+ ln, err := net.Listen("tcp", addr)
1382+ if err != nil {
1383+ errChan <- fmt.Errorf("%s server error: %w", name, err)
1384+ return
1385+ }
1386+ close(ready)
1387+
1388+ if err := server.ServeTLS(ln, certFile, keyFile); err != nil && err != http.ErrServerClosed {
1389+ errChan <- fmt.Errorf("%s server error: %w", name, err)
1390+ }
1391+ }()
1392+
1393+ return ready
1394+}
1395+
12041396 // SendReloadSignal sends SIGUSR1 to the running Gordon process.
12051397 func SendReloadSignal() error {
12061398 process, _, err := findRunningProcess()
@@ -1454,6 +1646,10 @@ func isProcessAlive(pid int) bool {
14541646 func loadConfig(v *viper.Viper, configPath string) error {
14551647 v.SetDefault("server.port", 80)
14561648 v.SetDefault("server.registry_port", 5000)
1649+ v.SetDefault("server.tls_enabled", false)
1650+ v.SetDefault("server.tls_port", 443)
1651+ v.SetDefault("server.tls_cert_file", "")
1652+ v.SetDefault("server.tls_key_file", "")
14571653 v.SetDefault("server.data_dir", DefaultDataDir())
14581654 v.SetDefault("logging.level", "info")
14591655 v.SetDefault("logging.format", "console")
@@ -3,11 +3,17 @@ package app
3 3
4 import (4 import (
5 "context"5 "context"
6+ "crypto/ecdsa"
7+ "crypto/elliptic"
6 "crypto/rand"8 "crypto/rand"
9+ "crypto/x509"
10+ "crypto/x509/pkix"
7 "encoding/hex"11 "encoding/hex"
8 "encoding/json"12 "encoding/json"
13+ "encoding/pem"
9 "errors"14 "errors"
10 "fmt"15 "fmt"
16+ "math/big"
11 "net"17 "net"
12 "net/http"18 "net/http"
13 "os"19 "os"
@@ -68,6 +74,10 @@ type Config struct {
68 RegistryPort int `mapstructure:"registry_port"`74 RegistryPort int `mapstructure:"registry_port"`
69 GordonDomain string `mapstructure:"gordon_domain"`75 GordonDomain string `mapstructure:"gordon_domain"`
70 RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain76 RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
77+ TLSEnabled bool `mapstructure:"tls_enabled"`
78+ TLSPort int `mapstructure:"tls_port"`
79+ TLSCertFile string `mapstructure:"tls_cert_file"`
80+ TLSKeyFile string `mapstructure:"tls_key_file"`
71 DataDir string `mapstructure:"data_dir"`81 DataDir string `mapstructure:"data_dir"`
72 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"82 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
73 MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"83 MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
@@ -160,6 +170,10 @@ func Run(ctx context.Context, configPath string) error {
160 ctx = zerowrap.WithCtx(ctx, log)170 ctx = zerowrap.WithCtx(ctx, log)
161 log.Info().Msg("Gordon starting")171 log.Info().Msg("Gordon starting")
162 172
173+ if err := ensureTLSConfig(&cfg, log); err != nil {
174+ return err
175+ }
176+
163 // Create PID file177 // Create PID file
164 pidFile := createPidFile(log)178 pidFile := createPidFile(log)
165 if pidFile != "" {179 if pidFile != "" {
@@ -193,6 +207,123 @@ func Run(ctx context.Context, configPath string) error {
193 return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, log)207 return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, log)
194 }208 }
195 209
210+func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error {
211+ if !cfg.Server.TLSEnabled {
212+ return nil
213+ }
214+
215+ if cfg.Server.TLSCertFile != "" && cfg.Server.TLSKeyFile != "" {
216+ return nil
217+ }
218+
219+ dataDir := resolveDataDir(cfg.Server.DataDir)
220+ tlsDir := filepath.Join(dataDir, "tls")
221+ certPath := filepath.Join(tlsDir, "cert.pem")
222+ keyPath := filepath.Join(tlsDir, "key.pem")
223+
224+ domainName := cfg.Server.GordonDomain
225+ if domainName == "" {
226+ domainName = cfg.Server.RegistryDomain
227+ }
228+ if domainName == "" {
229+ domainName = "localhost"
230+ }
231+
232+ if err := generateSelfSignedTLSCert(certPath, keyPath, domainName); err != nil {
233+ return fmt.Errorf("failed to generate self-signed TLS certificate: %w", err)
234+ }
235+
236+ cfg.Server.TLSCertFile = certPath
237+ cfg.Server.TLSKeyFile = keyPath
238+
239+ log.Warn().
240+ Str("cert_file", certPath).
241+ Str("key_file", keyPath).
242+ Str("domain", domainName).
243+ Msg("server.tls_enabled=true with empty tls_cert_file/tls_key_file; using auto-generated self-signed certificate")
244+
245+ return nil
246+}
247+
248+func generateSelfSignedTLSCert(certPath, keyPath, domainName string) error {
249+ // Reuse existing pair if already generated.
250+ if _, err := os.Stat(certPath); err == nil {
251+ if _, keyErr := os.Stat(keyPath); keyErr == nil {
252+ return nil
253+ }
254+ }
255+
256+ if err := os.MkdirAll(filepath.Dir(certPath), 0700); err != nil {
257+ return err
258+ }
259+ if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil {
260+ return err
261+ }
262+
263+ priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
264+ if err != nil {
265+ return err
266+ }
267+
268+ now := time.Now()
269+ serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
270+ serialNumber, err := rand.Int(rand.Reader, serialLimit)
271+ if err != nil {
272+ return err
273+ }
274+
275+ template := x509.Certificate{
276+ SerialNumber: serialNumber,
277+ Subject: pkix.Name{
278+ CommonName: domainName,
279+ Organization: []string{"Gordon Auto-Generated TLS"},
280+ },
281+ NotBefore: now.Add(-5 * time.Minute),
282+ NotAfter: now.AddDate(1, 0, 0),
283+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
284+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
285+ BasicConstraintsValid: true,
286+ }
287+
288+ if ip := net.ParseIP(domainName); ip != nil {
289+ template.IPAddresses = []net.IP{ip}
290+ } else {
291+ template.DNSNames = []string{domainName}
292+ }
293+
294+ derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
295+ if err != nil {
296+ return err
297+ }
298+
299+ certOut, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
300+ if err != nil {
301+ return err
302+ }
303+ defer certOut.Close()
304+
305+ if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
306+ return err
307+ }
308+
309+ keyBytes, err := x509.MarshalECPrivateKey(priv)
310+ if err != nil {
311+ return err
312+ }
313+
314+ keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
315+ if err != nil {
316+ return err
317+ }
318+ defer keyOut.Close()
319+
320+ if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}); err != nil {
321+ return err
322+ }
323+
324+ return nil
325+}
326+
196 // initConfig loads configuration from file.327 // initConfig loads configuration from file.
197 func initConfig(configPath string) (*viper.Viper, Config, error) {328 func initConfig(configPath string) (*viper.Viper, Config, error) {
198 v := viper.New()329 v := viper.New()
@@ -1100,10 +1231,25 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
1100 signal.Notify(deployChan, syscall.SIGUSR2)1231 signal.Notify(deployChan, syscall.SIGUSR2)
1101 defer signal.Stop(deployChan)1232 defer signal.Stop(deployChan)
1102 1233
1103- errChan := make(chan error, 2)1234+ errChan := make(chan error, 3)
1104 1235
1105 registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)1236 registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
1106 proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)1237 proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
1238+ var tlsReady <-chan struct{}
1239+ if cfg.Server.TLSEnabled {
1240+ if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" {
1241+ return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file")
1242+ }
1243+ tlsReady = startTLSServer(
1244+ fmt.Sprintf(":%d", cfg.Server.TLSPort),
1245+ proxyHandler,
1246+ "proxy-tls",
1247+ cfg.Server.TLSCertFile,
1248+ cfg.Server.TLSKeyFile,
1249+ errChan,
1250+ log,
1251+ )
1252+ }
1107 1253
1108 // Wait for both servers to bind their ports before auto-starting containers.1254 // Wait for both servers to bind their ports before auto-starting containers.
1109 // This prevents the race where auto-start pulls from the registry before it's listening.1255 // This prevents the race where auto-start pulls from the registry before it's listening.
@@ -1117,11 +1263,21 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
1117 case err := <-errChan:1263 case err := <-errChan:
1118 return err1264 return err
1119 }1265 }
1266+ if cfg.Server.TLSEnabled {
1267+ select {
1268+ case <-tlsReady:
1269+ case err := <-errChan:
1270+ return err
1271+ }
1272+ }
1120 1273
1121- log.Info().1274+ logEvent := log.Info().
1122 Int("proxy_port", cfg.Server.Port).1275 Int("proxy_port", cfg.Server.Port).
1123- Int("registry_port", cfg.Server.RegistryPort).1276+ Int("registry_port", cfg.Server.RegistryPort)
1124- Msg("Gordon is running")1277+ if cfg.Server.TLSEnabled {
1278+ logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort)
1279+ }
1280+ logEvent.Msg("Gordon is running")
1125 1281
1126 // Auto-start after servers are listening (registry port is now bound).1282 // Auto-start after servers are listening (registry port is now bound).
1127 syncAndAutoStart(ctx, svc, log)1283 syncAndAutoStart(ctx, svc, log)
@@ -1201,6 +1357,42 @@ func startServer(addr string, handler http.Handler, name string, errChan chan<-
1201 return ready1357 return ready
1202 }1358 }
1203 1359
1360+// startTLSServer starts an HTTPS server with the provided certificate and key.
1361+func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile string, errChan chan<- error, log zerowrap.Logger) <-chan struct{} {
1362+ ready := make(chan struct{})
1363+
1364+ go func() {
1365+ log.Info().
1366+ Str("address", addr).
1367+ Str("cert_file", certFile).
1368+ Str("key_file", keyFile).
1369+ Msgf("%s server starting", name)
1370+
1371+ server := &http.Server{
1372+ Addr: addr,
1373+ Handler: handler,
1374+ ReadHeaderTimeout: 10 * time.Second,
1375+ ReadTimeout: 5 * time.Minute,
1376+ WriteTimeout: 5 * time.Minute,
1377+ IdleTimeout: 120 * time.Second,
1378+ MaxHeaderBytes: 1 << 20,
1379+ }
1380+
1381+ ln, err := net.Listen("tcp", addr)
1382+ if err != nil {
1383+ errChan <- fmt.Errorf("%s server error: %w", name, err)
1384+ return
1385+ }
1386+ close(ready)
1387+
1388+ if err := server.ServeTLS(ln, certFile, keyFile); err != nil && err != http.ErrServerClosed {
1389+ errChan <- fmt.Errorf("%s server error: %w", name, err)
1390+ }
1391+ }()
1392+
1393+ return ready
1394+}
1395+
1204 // SendReloadSignal sends SIGUSR1 to the running Gordon process.1396 // SendReloadSignal sends SIGUSR1 to the running Gordon process.
1205 func SendReloadSignal() error {1397 func SendReloadSignal() error {
1206 process, _, err := findRunningProcess()1398 process, _, err := findRunningProcess()
@@ -1454,6 +1646,10 @@ func isProcessAlive(pid int) bool {
1454 func loadConfig(v *viper.Viper, configPath string) error {1646 func loadConfig(v *viper.Viper, configPath string) error {
1455 v.SetDefault("server.port", 80)1647 v.SetDefault("server.port", 80)
1456 v.SetDefault("server.registry_port", 5000)1648 v.SetDefault("server.registry_port", 5000)
1649+ v.SetDefault("server.tls_enabled", false)
1650+ v.SetDefault("server.tls_port", 443)
1651+ v.SetDefault("server.tls_cert_file", "")
1652+ v.SetDefault("server.tls_key_file", "")
1457 v.SetDefault("server.data_dir", DefaultDataDir())1653 v.SetDefault("server.data_dir", DefaultDataDir())
1458 v.SetDefault("logging.level", "info")1654 v.SetDefault("logging.level", "info")
1459 v.SetDefault("logging.format", "console")1655 v.SetDefault("logging.format", "console")

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