detached 188dcab

Merge branch 'main' into security/proxy-hardening-top10

commit
188dcabf826a8d970a23db4fe0b276b5309f108d
author
bnema · · 2026-02-07 05:56 UTC
committer
GitHub · · 2026-02-07 05:56 UTC
merge of
6bd378a + 7750c89 · diff against the first parent
signature
unsigned
5 files · +240 -10
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 +203 -7
@@ -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,7 +74,11 @@ 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
71- DataDir string `mapstructure:"data_dir"`
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"`
81+ 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"
7484 MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
@@ -162,6 +172,10 @@ func Run(ctx context.Context, configPath string) error {
162172 ctx = zerowrap.WithCtx(ctx, log)
163173 log.Info().Msg("Gordon starting")
164174
175+ if err := ensureTLSConfig(&cfg, log); err != nil {
176+ return err
177+ }
178+
165179 // Create PID file
166180 pidFile := createPidFile(log)
167181 if pidFile != "" {
@@ -195,6 +209,123 @@ func Run(ctx context.Context, configPath string) error {
195209 return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, log)
196210 }
197211
212+func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error {
213+ if !cfg.Server.TLSEnabled {
214+ return nil
215+ }
216+
217+ if cfg.Server.TLSCertFile != "" && cfg.Server.TLSKeyFile != "" {
218+ return nil
219+ }
220+
221+ dataDir := resolveDataDir(cfg.Server.DataDir)
222+ tlsDir := filepath.Join(dataDir, "tls")
223+ certPath := filepath.Join(tlsDir, "cert.pem")
224+ keyPath := filepath.Join(tlsDir, "key.pem")
225+
226+ domainName := cfg.Server.GordonDomain
227+ if domainName == "" {
228+ domainName = cfg.Server.RegistryDomain
229+ }
230+ if domainName == "" {
231+ domainName = "localhost"
232+ }
233+
234+ if err := generateSelfSignedTLSCert(certPath, keyPath, domainName); err != nil {
235+ return fmt.Errorf("failed to generate self-signed TLS certificate: %w", err)
236+ }
237+
238+ cfg.Server.TLSCertFile = certPath
239+ cfg.Server.TLSKeyFile = keyPath
240+
241+ log.Warn().
242+ Str("cert_file", certPath).
243+ Str("key_file", keyPath).
244+ Str("domain", domainName).
245+ Msg("server.tls_enabled=true with empty tls_cert_file/tls_key_file; using auto-generated self-signed certificate")
246+
247+ return nil
248+}
249+
250+func generateSelfSignedTLSCert(certPath, keyPath, domainName string) error {
251+ // Reuse existing pair if already generated.
252+ if _, err := os.Stat(certPath); err == nil {
253+ if _, keyErr := os.Stat(keyPath); keyErr == nil {
254+ return nil
255+ }
256+ }
257+
258+ if err := os.MkdirAll(filepath.Dir(certPath), 0700); err != nil {
259+ return err
260+ }
261+ if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil {
262+ return err
263+ }
264+
265+ priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
266+ if err != nil {
267+ return err
268+ }
269+
270+ now := time.Now()
271+ serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
272+ serialNumber, err := rand.Int(rand.Reader, serialLimit)
273+ if err != nil {
274+ return err
275+ }
276+
277+ template := x509.Certificate{
278+ SerialNumber: serialNumber,
279+ Subject: pkix.Name{
280+ CommonName: domainName,
281+ Organization: []string{"Gordon Auto-Generated TLS"},
282+ },
283+ NotBefore: now.Add(-5 * time.Minute),
284+ NotAfter: now.AddDate(1, 0, 0),
285+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
286+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
287+ BasicConstraintsValid: true,
288+ }
289+
290+ if ip := net.ParseIP(domainName); ip != nil {
291+ template.IPAddresses = []net.IP{ip}
292+ } else {
293+ template.DNSNames = []string{domainName}
294+ }
295+
296+ derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
297+ if err != nil {
298+ return err
299+ }
300+
301+ certOut, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
302+ if err != nil {
303+ return err
304+ }
305+ defer certOut.Close()
306+
307+ if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
308+ return err
309+ }
310+
311+ keyBytes, err := x509.MarshalECPrivateKey(priv)
312+ if err != nil {
313+ return err
314+ }
315+
316+ keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
317+ if err != nil {
318+ return err
319+ }
320+ defer keyOut.Close()
321+
322+ if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}); err != nil {
323+ return err
324+ }
325+
326+ return nil
327+}
328+
198329 // initConfig loads configuration from file.
199330 func initConfig(configPath string) (*viper.Viper, Config, error) {
200331 v := viper.New()
@@ -1144,10 +1275,25 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
11441275 signal.Notify(deployChan, syscall.SIGUSR2)
11451276 defer signal.Stop(deployChan)
11461277
1147- errChan := make(chan error, 2)
1278+ errChan := make(chan error, 3)
11481279
1149- registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
1150- proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
1280+ registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
1281+ proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
1282+ var tlsReady <-chan struct{}
1283+ if cfg.Server.TLSEnabled {
1284+ if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" {
1285+ return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file")
1286+ }
1287+ tlsReady = startTLSServer(
1288+ fmt.Sprintf(":%d", cfg.Server.TLSPort),
1289+ proxyHandler,
1290+ "proxy-tls",
1291+ cfg.Server.TLSCertFile,
1292+ cfg.Server.TLSKeyFile,
1293+ errChan,
1294+ log,
1295+ )
1296+ }
11511297
11521298 // Wait for both servers to bind their ports before auto-starting containers.
11531299 // This prevents the race where auto-start pulls from the registry before it's listening.
@@ -1161,11 +1307,21 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
11611307 case err := <-errChan:
11621308 return err
11631309 }
1310+ if cfg.Server.TLSEnabled {
1311+ select {
1312+ case <-tlsReady:
1313+ case err := <-errChan:
1314+ return err
1315+ }
1316+ }
11641317
1165- log.Info().
1318+ logEvent := log.Info().
11661319 Int("proxy_port", cfg.Server.Port).
1167- Int("registry_port", cfg.Server.RegistryPort).
1168- Msg("Gordon is running")
1320+ Int("registry_port", cfg.Server.RegistryPort)
1321+ if cfg.Server.TLSEnabled {
1322+ logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort)
1323+ }
1324+ logEvent.Msg("Gordon is running")
11691325
11701326 // Auto-start after servers are listening (registry port is now bound).
11711327 syncAndAutoStart(ctx, svc, log)
@@ -1260,6 +1416,42 @@ func startServer(addr string, handler http.Handler, name string, errChan chan<-
12601416 return server, ready
12611417 }
12621418
1419+// startTLSServer starts an HTTPS server with the provided certificate and key.
1420+func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile string, errChan chan<- error, log zerowrap.Logger) <-chan struct{} {
1421+ ready := make(chan struct{})
1422+
1423+ go func() {
1424+ log.Info().
1425+ Str("address", addr).
1426+ Str("cert_file", certFile).
1427+ Str("key_file", keyFile).
1428+ Msgf("%s server starting", name)
1429+
1430+ server := &http.Server{
1431+ Addr: addr,
1432+ Handler: handler,
1433+ ReadHeaderTimeout: 10 * time.Second,
1434+ ReadTimeout: 5 * time.Minute,
1435+ WriteTimeout: 5 * time.Minute,
1436+ IdleTimeout: 120 * time.Second,
1437+ MaxHeaderBytes: 1 << 20,
1438+ }
1439+
1440+ ln, err := net.Listen("tcp", addr)
1441+ if err != nil {
1442+ errChan <- fmt.Errorf("%s server error: %w", name, err)
1443+ return
1444+ }
1445+ close(ready)
1446+
1447+ if err := server.ServeTLS(ln, certFile, keyFile); err != nil && err != http.ErrServerClosed {
1448+ errChan <- fmt.Errorf("%s server error: %w", name, err)
1449+ }
1450+ }()
1451+
1452+ return ready
1453+}
1454+
12631455 // SendReloadSignal sends SIGUSR1 to the running Gordon process.
12641456 func SendReloadSignal() error {
12651457 process, _, err := findRunningProcess()
@@ -1513,6 +1705,10 @@ func isProcessAlive(pid int) bool {
15131705 func loadConfig(v *viper.Viper, configPath string) error {
15141706 v.SetDefault("server.port", 80)
15151707 v.SetDefault("server.registry_port", 5000)
1708+ v.SetDefault("server.tls_enabled", false)
1709+ v.SetDefault("server.tls_port", 443)
1710+ v.SetDefault("server.tls_cert_file", "")
1711+ v.SetDefault("server.tls_key_file", "")
15161712 v.SetDefault("server.data_dir", DefaultDataDir())
15171713 v.SetDefault("logging.level", "info")
15181714 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,7 +74,11 @@ 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
71- DataDir string `mapstructure:"data_dir"`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"`
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"
74 MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit84 MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
@@ -162,6 +172,10 @@ func Run(ctx context.Context, configPath string) error {
162 ctx = zerowrap.WithCtx(ctx, log)172 ctx = zerowrap.WithCtx(ctx, log)
163 log.Info().Msg("Gordon starting")173 log.Info().Msg("Gordon starting")
164 174
175+ if err := ensureTLSConfig(&cfg, log); err != nil {
176+ return err
177+ }
178+
165 // Create PID file179 // Create PID file
166 pidFile := createPidFile(log)180 pidFile := createPidFile(log)
167 if pidFile != "" {181 if pidFile != "" {
@@ -195,6 +209,123 @@ func Run(ctx context.Context, configPath string) error {
195 return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, log)209 return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, log)
196 }210 }
197 211
212+func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error {
213+ if !cfg.Server.TLSEnabled {
214+ return nil
215+ }
216+
217+ if cfg.Server.TLSCertFile != "" && cfg.Server.TLSKeyFile != "" {
218+ return nil
219+ }
220+
221+ dataDir := resolveDataDir(cfg.Server.DataDir)
222+ tlsDir := filepath.Join(dataDir, "tls")
223+ certPath := filepath.Join(tlsDir, "cert.pem")
224+ keyPath := filepath.Join(tlsDir, "key.pem")
225+
226+ domainName := cfg.Server.GordonDomain
227+ if domainName == "" {
228+ domainName = cfg.Server.RegistryDomain
229+ }
230+ if domainName == "" {
231+ domainName = "localhost"
232+ }
233+
234+ if err := generateSelfSignedTLSCert(certPath, keyPath, domainName); err != nil {
235+ return fmt.Errorf("failed to generate self-signed TLS certificate: %w", err)
236+ }
237+
238+ cfg.Server.TLSCertFile = certPath
239+ cfg.Server.TLSKeyFile = keyPath
240+
241+ log.Warn().
242+ Str("cert_file", certPath).
243+ Str("key_file", keyPath).
244+ Str("domain", domainName).
245+ Msg("server.tls_enabled=true with empty tls_cert_file/tls_key_file; using auto-generated self-signed certificate")
246+
247+ return nil
248+}
249+
250+func generateSelfSignedTLSCert(certPath, keyPath, domainName string) error {
251+ // Reuse existing pair if already generated.
252+ if _, err := os.Stat(certPath); err == nil {
253+ if _, keyErr := os.Stat(keyPath); keyErr == nil {
254+ return nil
255+ }
256+ }
257+
258+ if err := os.MkdirAll(filepath.Dir(certPath), 0700); err != nil {
259+ return err
260+ }
261+ if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil {
262+ return err
263+ }
264+
265+ priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
266+ if err != nil {
267+ return err
268+ }
269+
270+ now := time.Now()
271+ serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
272+ serialNumber, err := rand.Int(rand.Reader, serialLimit)
273+ if err != nil {
274+ return err
275+ }
276+
277+ template := x509.Certificate{
278+ SerialNumber: serialNumber,
279+ Subject: pkix.Name{
280+ CommonName: domainName,
281+ Organization: []string{"Gordon Auto-Generated TLS"},
282+ },
283+ NotBefore: now.Add(-5 * time.Minute),
284+ NotAfter: now.AddDate(1, 0, 0),
285+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
286+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
287+ BasicConstraintsValid: true,
288+ }
289+
290+ if ip := net.ParseIP(domainName); ip != nil {
291+ template.IPAddresses = []net.IP{ip}
292+ } else {
293+ template.DNSNames = []string{domainName}
294+ }
295+
296+ derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
297+ if err != nil {
298+ return err
299+ }
300+
301+ certOut, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
302+ if err != nil {
303+ return err
304+ }
305+ defer certOut.Close()
306+
307+ if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
308+ return err
309+ }
310+
311+ keyBytes, err := x509.MarshalECPrivateKey(priv)
312+ if err != nil {
313+ return err
314+ }
315+
316+ keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
317+ if err != nil {
318+ return err
319+ }
320+ defer keyOut.Close()
321+
322+ if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}); err != nil {
323+ return err
324+ }
325+
326+ return nil
327+}
328+
198 // initConfig loads configuration from file.329 // initConfig loads configuration from file.
199 func initConfig(configPath string) (*viper.Viper, Config, error) {330 func initConfig(configPath string) (*viper.Viper, Config, error) {
200 v := viper.New()331 v := viper.New()
@@ -1144,10 +1275,25 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
1144 signal.Notify(deployChan, syscall.SIGUSR2)1275 signal.Notify(deployChan, syscall.SIGUSR2)
1145 defer signal.Stop(deployChan)1276 defer signal.Stop(deployChan)
1146 1277
1147- errChan := make(chan error, 2)1278+ errChan := make(chan error, 3)
1148 1279
1149- registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)1280+ registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
1150- proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)1281+ proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
1282+ var tlsReady <-chan struct{}
1283+ if cfg.Server.TLSEnabled {
1284+ if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" {
1285+ return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file")
1286+ }
1287+ tlsReady = startTLSServer(
1288+ fmt.Sprintf(":%d", cfg.Server.TLSPort),
1289+ proxyHandler,
1290+ "proxy-tls",
1291+ cfg.Server.TLSCertFile,
1292+ cfg.Server.TLSKeyFile,
1293+ errChan,
1294+ log,
1295+ )
1296+ }
1151 1297
1152 // Wait for both servers to bind their ports before auto-starting containers.1298 // Wait for both servers to bind their ports before auto-starting containers.
1153 // This prevents the race where auto-start pulls from the registry before it's listening.1299 // This prevents the race where auto-start pulls from the registry before it's listening.
@@ -1161,11 +1307,21 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
1161 case err := <-errChan:1307 case err := <-errChan:
1162 return err1308 return err
1163 }1309 }
1310+ if cfg.Server.TLSEnabled {
1311+ select {
1312+ case <-tlsReady:
1313+ case err := <-errChan:
1314+ return err
1315+ }
1316+ }
1164 1317
1165- log.Info().1318+ logEvent := log.Info().
1166 Int("proxy_port", cfg.Server.Port).1319 Int("proxy_port", cfg.Server.Port).
1167- Int("registry_port", cfg.Server.RegistryPort).1320+ Int("registry_port", cfg.Server.RegistryPort)
1168- Msg("Gordon is running")1321+ if cfg.Server.TLSEnabled {
1322+ logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort)
1323+ }
1324+ logEvent.Msg("Gordon is running")
1169 1325
1170 // Auto-start after servers are listening (registry port is now bound).1326 // Auto-start after servers are listening (registry port is now bound).
1171 syncAndAutoStart(ctx, svc, log)1327 syncAndAutoStart(ctx, svc, log)
@@ -1260,6 +1416,42 @@ func startServer(addr string, handler http.Handler, name string, errChan chan<-
1260 return server, ready1416 return server, ready
1261 }1417 }
1262 1418
1419+// startTLSServer starts an HTTPS server with the provided certificate and key.
1420+func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile string, errChan chan<- error, log zerowrap.Logger) <-chan struct{} {
1421+ ready := make(chan struct{})
1422+
1423+ go func() {
1424+ log.Info().
1425+ Str("address", addr).
1426+ Str("cert_file", certFile).
1427+ Str("key_file", keyFile).
1428+ Msgf("%s server starting", name)
1429+
1430+ server := &http.Server{
1431+ Addr: addr,
1432+ Handler: handler,
1433+ ReadHeaderTimeout: 10 * time.Second,
1434+ ReadTimeout: 5 * time.Minute,
1435+ WriteTimeout: 5 * time.Minute,
1436+ IdleTimeout: 120 * time.Second,
1437+ MaxHeaderBytes: 1 << 20,
1438+ }
1439+
1440+ ln, err := net.Listen("tcp", addr)
1441+ if err != nil {
1442+ errChan <- fmt.Errorf("%s server error: %w", name, err)
1443+ return
1444+ }
1445+ close(ready)
1446+
1447+ if err := server.ServeTLS(ln, certFile, keyFile); err != nil && err != http.ErrServerClosed {
1448+ errChan <- fmt.Errorf("%s server error: %w", name, err)
1449+ }
1450+ }()
1451+
1452+ return ready
1453+}
1454+
1263 // SendReloadSignal sends SIGUSR1 to the running Gordon process.1455 // SendReloadSignal sends SIGUSR1 to the running Gordon process.
1264 func SendReloadSignal() error {1456 func SendReloadSignal() error {
1265 process, _, err := findRunningProcess()1457 process, _, err := findRunningProcess()
@@ -1513,6 +1705,10 @@ func isProcessAlive(pid int) bool {
1513 func loadConfig(v *viper.Viper, configPath string) error {1705 func loadConfig(v *viper.Viper, configPath string) error {
1514 v.SetDefault("server.port", 80)1706 v.SetDefault("server.port", 80)
1515 v.SetDefault("server.registry_port", 5000)1707 v.SetDefault("server.registry_port", 5000)
1708+ v.SetDefault("server.tls_enabled", false)
1709+ v.SetDefault("server.tls_port", 443)
1710+ v.SetDefault("server.tls_cert_file", "")
1711+ v.SetDefault("server.tls_key_file", "")
1516 v.SetDefault("server.data_dir", DefaultDataDir())1712 v.SetDefault("server.data_dir", DefaultDataDir())
1517 v.SetDefault("logging.level", "info")1713 v.SetDefault("logging.level", "info")
1518 v.SetDefault("logging.format", "console")1714 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