1// Package app provides the application initialization and wiring.
2package app
3
4import (
5 "context"
6 "crypto/ecdsa"
7 "crypto/elliptic"
8 "crypto/rand"
9 "crypto/x509"
10 "crypto/x509/pkix"
11 "encoding/hex"
12 "encoding/json"
13 "encoding/pem"
14 "errors"
15 "fmt"
16 "math/big"
17 "net"
18 "net/http"
19 "os"
20 "os/signal"
21 "path/filepath"
22 "strconv"
23 "strings"
24 "syscall"
25 "time"
26
27 "github.com/bnema/zerowrap"
28 "github.com/fsnotify/fsnotify"
29 "github.com/spf13/viper"
30
31 // Adapters - Output
32 "github.com/bnema/gordon/internal/adapters/out/docker"
33 "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
34 "github.com/bnema/gordon/internal/adapters/out/envloader"
35 "github.com/bnema/gordon/internal/adapters/out/eventbus"
36 "github.com/bnema/gordon/internal/adapters/out/filesystem"
37 "github.com/bnema/gordon/internal/adapters/out/httpprober"
38 "github.com/bnema/gordon/internal/adapters/out/logwriter"
39 "github.com/bnema/gordon/internal/adapters/out/ratelimit"
40 "github.com/bnema/gordon/internal/adapters/out/secrets"
41 "github.com/bnema/gordon/internal/adapters/out/tokenstore"
42
43 // Adapters - Input
44 "github.com/bnema/gordon/internal/adapters/in/http/admin"
45 authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth"
46 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
47 "github.com/bnema/gordon/internal/adapters/in/http/registry"
48
49 // Boundaries
50 "github.com/bnema/gordon/internal/boundaries/out"
51
52 // Domain
53 "github.com/bnema/gordon/internal/domain"
54
55 // Use cases
56 "github.com/bnema/gordon/internal/usecase/auth"
57 "github.com/bnema/gordon/internal/usecase/backup"
58 "github.com/bnema/gordon/internal/usecase/config"
59 "github.com/bnema/gordon/internal/usecase/container"
60 cronSvc "github.com/bnema/gordon/internal/usecase/cron"
61 "github.com/bnema/gordon/internal/usecase/health"
62 "github.com/bnema/gordon/internal/usecase/images"
63 "github.com/bnema/gordon/internal/usecase/logs"
64 "github.com/bnema/gordon/internal/usecase/proxy"
65 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
66 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
67
68 // Pkg
69 "github.com/bnema/gordon/pkg/bytesize"
70 "github.com/bnema/gordon/pkg/duration"
71)
72
73// Config holds the application configuration.
74type Config struct {
75 Server struct {
76 Port int `mapstructure:"port"`
77 RegistryPort int `mapstructure:"registry_port"`
78 GordonDomain string `mapstructure:"gordon_domain"`
79 RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
80 TLSEnabled bool `mapstructure:"tls_enabled"`
81 TLSPort int `mapstructure:"tls_port"`
82 TLSCertFile string `mapstructure:"tls_cert_file"`
83 TLSKeyFile string `mapstructure:"tls_key_file"`
84 DataDir string `mapstructure:"data_dir"`
85 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
86 MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
87 MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
88 MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`
89 RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`
90 } `mapstructure:"server"`
91
92 Logging struct {
93 Level string `mapstructure:"level"`
94 Format string `mapstructure:"format"`
95 File struct {
96 Enabled bool `mapstructure:"enabled"`
97 Path string `mapstructure:"path"`
98 MaxSize int `mapstructure:"max_size"`
99 MaxBackups int `mapstructure:"max_backups"`
100 MaxAge int `mapstructure:"max_age"`
101 } `mapstructure:"file"`
102 ContainerLogs struct {
103 Enabled bool `mapstructure:"enabled"`
104 Dir string `mapstructure:"dir"`
105 MaxSize int `mapstructure:"max_size"`
106 MaxBackups int `mapstructure:"max_backups"`
107 MaxAge int `mapstructure:"max_age"`
108 } `mapstructure:"container_logs"`
109 } `mapstructure:"logging"`
110
111 Env struct {
112 Dir string `mapstructure:"dir"`
113 } `mapstructure:"env"`
114
115 Auth struct {
116 Enabled bool `mapstructure:"enabled"`
117 Type string `mapstructure:"type"` // "password" or "token"
118 SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe"
119 Username string `mapstructure:"username"`
120 Password string `mapstructure:"password"` // deprecated: use password_hash
121 PasswordHash string `mapstructure:"password_hash"` // path in secrets backend
122 TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
123 TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
124 } `mapstructure:"auth"`
125
126 API struct {
127 RateLimit struct {
128 Enabled bool `mapstructure:"enabled"`
129 GlobalRPS float64 `mapstructure:"global_rps"`
130 PerIPRPS float64 `mapstructure:"per_ip_rps"`
131 Burst int `mapstructure:"burst"`
132 TrustedProxies []string `mapstructure:"trusted_proxies"`
133 } `mapstructure:"rate_limit"`
134 } `mapstructure:"api"`
135
136 Backups struct {
137 Enabled bool `mapstructure:"enabled"`
138 Schedule string `mapstructure:"schedule"`
139 StorageDir string `mapstructure:"storage_dir"`
140 Retention struct {
141 Hourly int `mapstructure:"hourly"`
142 Daily int `mapstructure:"daily"`
143 Weekly int `mapstructure:"weekly"`
144 Monthly int `mapstructure:"monthly"`
145 } `mapstructure:"retention"`
146 } `mapstructure:"backups"`
147
148 Images struct {
149 Prune struct {
150 Enabled bool `mapstructure:"enabled"`
151 Schedule string `mapstructure:"schedule"`
152 KeepLast int `mapstructure:"keep_last"`
153 } `mapstructure:"prune"`
154 } `mapstructure:"images"`
155}
156
157// services holds all the services used by the application.
158type services struct {
159 runtime *docker.Runtime
160 eventBus *eventbus.InMemory
161 blobStorage *filesystem.BlobStorage
162 manifestStorage *filesystem.ManifestStorage
163 backupStorage *filesystem.BackupStorage
164 envLoader out.EnvLoader
165 logWriter *logwriter.LogWriter
166 tokenStore out.TokenStore
167 configSvc *config.Service
168 containerSvc *container.Service
169 backupSvc *backup.Service
170 registrySvc *registrySvc.Service
171 imageSvc *images.Service
172 proxySvc *proxy.Service
173 authSvc *auth.Service
174 authHandler *authhandler.Handler
175 adminHandler *admin.Handler
176 internalRegUser string
177 internalRegPass string
178 envDir string
179 maxBlobChunkSize int64
180}
181
182// Run initializes and starts the Gordon application.
183func Run(ctx context.Context, configPath string) error {
184 // Load configuration
185 v, cfg, err := initConfig(configPath)
186 if err != nil {
187 return err
188 }
189
190 // Initialize logger
191 log, cleanup, err := initLogger(cfg)
192 if err != nil {
193 return err
194 }
195 if cleanup != nil {
196 defer cleanup()
197 }
198
199 ctx = zerowrap.WithCtx(ctx, log)
200 log.Info().Msg("Gordon starting")
201
202 if err := ensureTLSConfig(&cfg, log); err != nil {
203 return err
204 }
205
206 // Create PID file
207 pidFile := createPidFile(log)
208 if pidFile != "" {
209 defer removePidFile(pidFile, log)
210 }
211
212 // Create all services
213 svc, err := createServices(ctx, v, cfg, log)
214 if err != nil {
215 return err
216 }
217
218 // Register event handlers
219 if err := registerEventHandlers(ctx, svc, cfg); err != nil {
220 return err
221 }
222
223 // Set up config hot reload
224 setupConfigHotReload(ctx, v, svc, log)
225
226 // Start event bus
227 if err := svc.eventBus.Start(); err != nil {
228 return log.WrapErr(err, "failed to start event bus")
229 }
230 defer svc.eventBus.Stop()
231
232 // Create HTTP handlers
233 registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)
234
235 // Start servers, wait for listeners to bind, then sync/auto-start containers.
236 return runServers(ctx, v, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, log)
237}
238
239func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error {
240 if !cfg.Server.TLSEnabled {
241 return nil
242 }
243
244 if cfg.Server.TLSCertFile != "" && cfg.Server.TLSKeyFile != "" {
245 return nil
246 }
247
248 dataDir := resolveDataDir(cfg.Server.DataDir)
249 tlsDir := filepath.Join(dataDir, "tls")
250 certPath := filepath.Join(tlsDir, "cert.pem")
251 keyPath := filepath.Join(tlsDir, "key.pem")
252
253 domainName := cfg.Server.GordonDomain
254 if domainName == "" {
255 domainName = cfg.Server.RegistryDomain
256 }
257 if domainName == "" {
258 domainName = "localhost"
259 }
260
261 if err := generateSelfSignedTLSCert(certPath, keyPath, domainName); err != nil {
262 return fmt.Errorf("failed to generate self-signed TLS certificate: %w", err)
263 }
264
265 cfg.Server.TLSCertFile = certPath
266 cfg.Server.TLSKeyFile = keyPath
267
268 log.Warn().
269 Str("cert_file", certPath).
270 Str("key_file", keyPath).
271 Str("domain", domainName).
272 Msg("server.tls_enabled=true with empty tls_cert_file/tls_key_file; using auto-generated self-signed certificate")
273
274 return nil
275}
276
277func generateSelfSignedTLSCert(certPath, keyPath, domainName string) error {
278 // Reuse existing pair if already generated.
279 if _, err := os.Stat(certPath); err == nil {
280 if _, keyErr := os.Stat(keyPath); keyErr == nil {
281 return nil
282 }
283 }
284
285 if err := os.MkdirAll(filepath.Dir(certPath), 0700); err != nil {
286 return err
287 }
288 if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil {
289 return err
290 }
291
292 priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
293 if err != nil {
294 return err
295 }
296
297 now := time.Now()
298 serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
299 serialNumber, err := rand.Int(rand.Reader, serialLimit)
300 if err != nil {
301 return err
302 }
303
304 template := x509.Certificate{
305 SerialNumber: serialNumber,
306 Subject: pkix.Name{
307 CommonName: domainName,
308 Organization: []string{"Gordon Auto-Generated TLS"},
309 },
310 NotBefore: now.Add(-5 * time.Minute),
311 NotAfter: now.AddDate(1, 0, 0),
312 KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
313 ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
314 BasicConstraintsValid: true,
315 }
316
317 if ip := net.ParseIP(domainName); ip != nil {
318 template.IPAddresses = []net.IP{ip}
319 } else {
320 template.DNSNames = []string{domainName}
321 }
322
323 derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
324 if err != nil {
325 return err
326 }
327
328 certOut, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
329 if err != nil {
330 return err
331 }
332 defer certOut.Close()
333
334 if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
335 return err
336 }
337
338 keyBytes, err := x509.MarshalECPrivateKey(priv)
339 if err != nil {
340 return err
341 }
342
343 keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
344 if err != nil {
345 return err
346 }
347 defer keyOut.Close()
348
349 if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}); err != nil {
350 return err
351 }
352
353 return nil
354}
355
356// initConfig loads configuration from file.
357func initConfig(configPath string) (*viper.Viper, Config, error) {
358 v := viper.New()
359 if err := loadConfig(v, configPath); err != nil {
360 return nil, Config{}, fmt.Errorf("failed to load config: %w", err)
361 }
362
363 var cfg Config
364 if err := v.Unmarshal(&cfg); err != nil {
365 return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
366 }
367
368 // Normalize domain config: prefer gordon_domain over registry_domain
369 if cfg.Server.GordonDomain != "" {
370 cfg.Server.RegistryDomain = cfg.Server.GordonDomain
371 }
372
373 return v, cfg, nil
374}
375
376// initLogger initializes the zerowrap logger.
377func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
378 logConfig := zerowrap.Config{
379 Level: cfg.Logging.Level,
380 Format: cfg.Logging.Format,
381 }
382
383 // Always enable file logging so admin API can read process logs
384 // (Config flag is respected for backward-compatibility)
385 logPath := cfg.Logging.File.Path
386 if logPath == "" {
387 dataDir := cfg.Server.DataDir
388 if dataDir == "" {
389 dataDir = DefaultDataDir()
390 }
391 logPath = filepath.Join(dataDir, "logs", "gordon.log")
392 }
393
394 log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{
395 Enabled: cfg.Logging.File.Enabled,
396 Path: logPath,
397 MaxSize: cfg.Logging.File.MaxSize,
398 MaxBackups: cfg.Logging.File.MaxBackups,
399 MaxAge: cfg.Logging.File.MaxAge,
400 Compress: true,
401 })
402 if err != nil {
403 return zerowrap.Default(), nil, fmt.Errorf("failed to create logger with file: %w", err)
404 }
405 return log, cleanup, nil
406}
407
408// createServices creates all the application services.
409func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (*services, error) {
410 svc := &services{}
411 var err error
412
413 // Create output adapters
414 if svc.runtime, svc.eventBus, err = createOutputAdapters(ctx, log); err != nil {
415 return nil, err
416 }
417
418 // Create storage
419 if svc.blobStorage, svc.manifestStorage, err = createStorage(cfg, log); err != nil {
420 return nil, err
421 }
422
423 // Create log writer
424 if svc.logWriter, err = createLogWriter(cfg, log); err != nil {
425 return nil, err
426 }
427
428 // Create auth service (if enabled)
429 if svc.tokenStore, svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil {
430 return nil, err
431 }
432
433 if err := setupInternalRegistryAuth(cfg, svc, log); err != nil {
434 return nil, err
435 }
436
437 // Create use case services
438 svc.configSvc = config.NewService(v, svc.eventBus)
439 if err := svc.configSvc.Load(ctx); err != nil {
440 return nil, log.WrapErr(err, "failed to load configuration")
441 }
442
443 var backend domain.SecretsBackend
444 var passStore *domainsecrets.PassStore
445 var domainSecretStore out.DomainSecretStore
446
447 svc.envDir, backend, passStore, domainSecretStore, err = createDomainSecretStore(cfg, log)
448 if err != nil {
449 return nil, err
450 }
451
452 if svc.envLoader, err = createEnvLoader(backend, svc.envDir, passStore, log); err != nil {
453 return nil, err
454 }
455
456 secretSvc := secretsSvc.NewService(domainSecretStore, log)
457
458 if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil {
459 return nil, err
460 }
461
462 if svc.backupStorage, svc.backupSvc, err = createBackupService(cfg, svc, log); err != nil {
463 return nil, err
464 }
465
466 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
467 svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log)
468
469 proxyCfg, err := buildProxyConfig(cfg, log)
470 if err != nil {
471 return nil, err
472 }
473 svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
474 svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxyCfg.proxyConfig)
475
476 // Wire synchronous proxy cache invalidation for zero-downtime deployments.
477 // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget().
478 svc.containerSvc.SetProxyCacheInvalidator(svc.proxySvc)
479 svc.containerSvc.SetProxyDrainWaiter(svc.proxySvc)
480
481 // Create token handler for registry token endpoint
482 if svc.authSvc != nil {
483 internalAuth := authhandler.InternalAuth{
484 Username: svc.internalRegUser,
485 Password: svc.internalRegPass,
486 }
487 svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log)
488 }
489
490 // Create health service for route health checking
491 prober := httpprober.New()
492 healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)
493
494 // Create log service for accessing logs via admin API
495 logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log)
496
497 // Create admin handler for admin API
498 svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc, svc.imageSvc)
499
500 return svc, nil
501}
502
503func setupInternalRegistryAuth(cfg Config, svc *services, log zerowrap.Logger) error {
504 if !cfg.Auth.Enabled {
505 return nil
506 }
507
508 var err error
509 svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
510 if err != nil {
511 return log.WrapErr(err, "failed to generate internal registry credentials")
512 }
513
514 // Persist credentials to file for CLI access (gordon auth internal)
515 if err := persistInternalCredentials(svc.internalRegUser, svc.internalRegPass); err != nil {
516 log.Warn().Err(err).Msg("failed to persist internal credentials for CLI access")
517 }
518
519 log.Debug().Msg("internal registry auth generated for loopback pulls")
520 return nil
521}
522
523func createDomainSecretStore(cfg Config, log zerowrap.Logger) (string, domain.SecretsBackend, *domainsecrets.PassStore, out.DomainSecretStore, error) {
524 envDir := resolveEnvDir(cfg)
525 backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)
526
527 switch backend {
528 case domain.SecretsBackendPass:
529 passStore, err := domainsecrets.NewPassStore(log)
530 if err != nil {
531 return "", backend, nil, nil, log.WrapErr(err, "failed to create pass domain secret store")
532 }
533 if err := migrateEnvFilesToPass(envDir, passStore, log); err != nil {
534 return "", backend, nil, nil, log.WrapErr(err, "failed to migrate env files to pass")
535 }
536 return envDir, backend, passStore, passStore, nil
537 default:
538 store, err := domainsecrets.NewFileStore(envDir, log)
539 if err != nil {
540 return "", backend, nil, nil, log.WrapErr(err, "failed to create domain secret store")
541 }
542 return envDir, backend, nil, store, nil
543 }
544}
545
546// resolveLogFilePath returns the configured log file path or a default.
547func resolveLogFilePath(cfg Config) string {
548 if cfg.Logging.File.Path != "" {
549 return cfg.Logging.File.Path
550 }
551 dataDir := cfg.Server.DataDir
552 if dataDir == "" {
553 dataDir = DefaultDataDir()
554 }
555 return filepath.Join(dataDir, "logs", "gordon.log")
556}
557
558// createOutputAdapters creates the Docker runtime and event bus.
559func createOutputAdapters(ctx context.Context, log zerowrap.Logger) (*docker.Runtime, *eventbus.InMemory, error) {
560 runtime, err := docker.NewRuntime()
561 if err != nil {
562 return nil, nil, log.WrapErr(err, "failed to create Docker runtime")
563 }
564
565 if err := runtime.Ping(ctx); err != nil {
566 return nil, nil, log.WrapErr(err, "Docker is not available")
567 }
568
569 dockerVersion, _ := runtime.Version(ctx)
570 log.Info().Str("docker_version", dockerVersion).Msg("Docker runtime initialized")
571
572 eventBus := eventbus.NewInMemory(100, log)
573
574 return runtime, eventBus, nil
575}
576
577// createStorage creates blob and manifest storage.
578func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *filesystem.ManifestStorage, error) {
579 dataDir := cfg.Server.DataDir
580 if dataDir == "" {
581 dataDir = DefaultDataDir()
582 }
583
584 registryDir := filepath.Join(dataDir, "registry")
585
586 blobStorage, err := filesystem.NewBlobStorage(registryDir, log)
587 if err != nil {
588 return nil, nil, log.WrapErr(err, "failed to create blob storage")
589 }
590
591 manifestStorage, err := filesystem.NewManifestStorage(registryDir, log)
592 if err != nil {
593 return nil, nil, log.WrapErr(err, "failed to create manifest storage")
594 }
595
596 return blobStorage, manifestStorage, nil
597}
598
599// createEnvLoader creates the environment loader with secret providers.
600func createEnvLoader(backend domain.SecretsBackend, envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) (out.EnvLoader, error) {
601 switch backend {
602 case domain.SecretsBackendPass:
603 loader, err := envloader.NewPassLoader(passStore, log)
604 if err != nil {
605 return nil, log.WrapErr(err, "failed to create pass env loader")
606 }
607 return loader, nil
608 default:
609 loader, err := envloader.NewFileLoader(envDir, log)
610 if err != nil {
611 return nil, log.WrapErr(err, "failed to create env loader")
612 }
613
614 // Register secret providers
615 passProvider := secrets.NewPassProvider(log)
616 if passProvider.IsAvailable() {
617 loader.RegisterSecretProvider(passProvider)
618 log.Debug().Msg("pass secret provider registered")
619 }
620
621 sopsProvider := secrets.NewSopsProvider(log)
622 if sopsProvider.IsAvailable() {
623 loader.RegisterSecretProvider(sopsProvider)
624 log.Debug().Msg("sops secret provider registered")
625 }
626
627 return loader, nil
628 }
629}
630
631// createLogWriter creates the container log writer.
632func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, error) {
633 if !cfg.Logging.ContainerLogs.Enabled {
634 log.Debug().Msg("container log collection disabled")
635 return nil, nil
636 }
637
638 // Determine log directory
639 logDir := cfg.Logging.ContainerLogs.Dir
640 if logDir == "" {
641 dataDir := cfg.Server.DataDir
642 if dataDir == "" {
643 dataDir = DefaultDataDir()
644 }
645 logDir = filepath.Join(dataDir, "logs", "containers")
646 }
647
648 writer, err := logwriter.New(logwriter.Config{
649 Dir: logDir,
650 MaxSize: cfg.Logging.ContainerLogs.MaxSize,
651 MaxBackups: cfg.Logging.ContainerLogs.MaxBackups,
652 MaxAge: cfg.Logging.ContainerLogs.MaxAge,
653 })
654 if err != nil {
655 return nil, log.WrapErr(err, "failed to create container log writer")
656 }
657
658 log.Info().Str("dir", logDir).Msg("container log collection enabled")
659 return writer, nil
660}
661
662const (
663 internalRegistryUsername = "gordon-internal"
664 serviceTokenSubject = "gordon-service"
665 serviceTokenDefaultTTL = 30 * 24 * time.Hour
666)
667
668func generateInternalRegistryAuth() (string, string, error) {
669 password, err := randomTokenHex(32)
670 if err != nil {
671 return "", "", err
672 }
673 return internalRegistryUsername, password, nil
674}
675
676func randomTokenHex(size int) (string, error) {
677 buf := make([]byte, size)
678 if _, err := rand.Read(buf); err != nil {
679 return "", err
680 }
681 return hex.EncodeToString(buf), nil
682}
683
684// InternalCredentials holds the internal registry credentials for CLI access.
685type InternalCredentials struct {
686 Username string `json:"username"`
687 Password string `json:"password"`
688}
689
690// getSecureRuntimeDir returns a secure directory for runtime files.
691// Priority: XDG_RUNTIME_DIR > ~/.gordon/run
692func getSecureRuntimeDir() (string, error) {
693 // Try XDG_RUNTIME_DIR first (typically /run/user/<uid> on Linux)
694 if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
695 gordonDir := filepath.Join(runtimeDir, "gordon")
696 if err := os.MkdirAll(gordonDir, 0700); err == nil {
697 return gordonDir, nil
698 }
699 }
700
701 // Fall back to ~/.gordon/run
702 homeDir, err := os.UserHomeDir()
703 if err != nil {
704 return "", fmt.Errorf("failed to get home directory: %w", err)
705 }
706
707 gordonDir := filepath.Join(homeDir, ".gordon", "run")
708 if err := os.MkdirAll(gordonDir, 0700); err != nil {
709 return "", fmt.Errorf("failed to create runtime directory: %w", err)
710 }
711
712 return gordonDir, nil
713}
714
715// getInternalCredentialsFile returns the path to the internal credentials file.
716// SECURITY: Credentials are stored in a secure location with restricted permissions.
717func getInternalCredentialsFile() string {
718 runtimeDir, err := getSecureRuntimeDir()
719 if err != nil {
720 // Fall back to temp dir if we can't get secure dir (shouldn't happen)
721 return filepath.Join(os.TempDir(), "gordon-internal-creds.json")
722 }
723 return filepath.Join(runtimeDir, "internal-creds.json")
724}
725
726// persistInternalCredentials saves the internal registry credentials to a secure file.
727// SECURITY: Credentials are stored in XDG_RUNTIME_DIR or ~/.gordon/run with 0600 permissions.
728// The file is cleaned up on graceful shutdown but may persist if Gordon crashes.
729// These credentials are for internal loopback communication only and are regenerated on each start.
730func persistInternalCredentials(username, password string) error {
731 creds := InternalCredentials{
732 Username: username,
733 Password: password,
734 }
735 data, err := json.Marshal(creds)
736 if err != nil {
737 return fmt.Errorf("failed to marshal credentials: %w", err)
738 }
739
740 credFile := getInternalCredentialsFile()
741
742 // Ensure parent directory exists with secure permissions
743 if err := os.MkdirAll(filepath.Dir(credFile), 0700); err != nil {
744 return fmt.Errorf("failed to create credentials directory: %w", err)
745 }
746
747 // Write file with restrictive permissions (owner read/write only)
748 if err := os.WriteFile(credFile, data, 0600); err != nil {
749 return fmt.Errorf("failed to write credentials file: %w", err)
750 }
751 return nil
752}
753
754// cleanupInternalCredentials removes the internal credentials file.
755func cleanupInternalCredentials() {
756 _ = os.Remove(getInternalCredentialsFile())
757}
758
759// GetInternalCredentials reads the internal registry credentials from file.
760// This is used by the CLI to display credentials for manual recovery.
761func GetInternalCredentials() (*InternalCredentials, error) {
762 credFile := getInternalCredentialsFile()
763 data, err := os.ReadFile(credFile)
764 if err != nil {
765 return nil, fmt.Errorf("failed to read credentials file (is Gordon running?): %w", err)
766 }
767 var creds InternalCredentials
768 if err := json.Unmarshal(data, &creds); err != nil {
769 return nil, fmt.Errorf("failed to parse credentials: %w", err)
770 }
771 return &creds, nil
772}
773
774// createAuthService creates the authentication service and token store.
775func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
776 if !cfg.Auth.Enabled {
777 return nil, nil, nil
778 }
779
780 authType := resolveAuthType(cfg)
781 backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)
782 dataDir := resolveDataDir(cfg.Server.DataDir)
783
784 store, err := createTokenStore(backend, dataDir, log)
785 if err != nil {
786 return nil, nil, err
787 }
788
789 authConfig, err := buildAuthConfig(ctx, cfg, authType, backend, dataDir, log)
790 if err != nil {
791 return nil, nil, err
792 }
793
794 authSvc := auth.NewService(authConfig, store, log)
795
796 log.Info().
797 Str("type", string(authType)).
798 Str("backend", string(backend)).
799 Msg("registry authentication enabled")
800
801 return store, authSvc, nil
802}
803
804// resolveAuthType determines the auth type from config.
805// If password_hash is configured, password auth is available (plus tokens).
806// If only token_secret is configured, token-only mode.
807// The explicit "type" field is deprecated but still respected for backwards compat.
808func resolveAuthType(cfg Config) domain.AuthType {
809 // Explicit type takes precedence (backwards compatibility)
810 if cfg.Auth.Type == "token" {
811 return domain.AuthTypeToken
812 }
813 if cfg.Auth.Type == "password" {
814 return domain.AuthTypePassword
815 }
816
817 // Infer from config: password auth if password_hash is configured
818 if cfg.Auth.PasswordHash != "" || cfg.Auth.Password != "" {
819 return domain.AuthTypePassword
820 }
821
822 // Default to token-only
823 return domain.AuthTypeToken
824}
825
826func resolveSecretsBackend(backend string) domain.SecretsBackend {
827 switch backend {
828 case "pass":
829 return domain.SecretsBackendPass
830 case "sops":
831 return domain.SecretsBackendSops
832 case "unsafe", "":
833 return domain.SecretsBackendUnsafe
834 default:
835 return domain.SecretsBackendUnsafe
836 }
837}
838
839func resolveDataDir(dataDir string) string {
840 if dataDir == "" {
841 return DefaultDataDir()
842 }
843 return dataDir
844}
845
846func resolveEnvDir(cfg Config) string {
847 dataDir := resolveDataDir(cfg.Server.DataDir)
848 envDir := cfg.Env.Dir
849 if envDir == "" {
850 envDir = filepath.Join(dataDir, "env")
851 }
852 return envDir
853}
854
855func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
856 // Token store is always created since tokens work in both auth modes
857 store, err := tokenstore.NewStore(backend, dataDir, log)
858 if err != nil {
859 return nil, log.WrapErr(err, "failed to create token store")
860 }
861 return store, nil
862}
863
864func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
865 authConfig := auth.Config{
866 Enabled: cfg.Auth.Enabled,
867 AuthType: authType,
868 Username: cfg.Auth.Username,
869 }
870
871 // Token config is always required (tokens work in all auth modes)
872 secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log)
873 if err != nil {
874 return auth.Config{}, err
875 }
876 authConfig.TokenSecret = secret
877 authConfig.TokenExpiry = expiry
878
879 // Password config only needed for password auth mode
880 if authType == domain.AuthTypePassword {
881 hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log)
882 if err != nil {
883 return auth.Config{}, err
884 }
885 if hash == "" {
886 return auth.Config{}, errAuthNotConfigured()
887 }
888 authConfig.PasswordHash = hash
889 }
890
891 return authConfig, nil
892}
893
894// errAuthNotConfigured returns an error when auth is enabled but credentials are not configured.
895func errAuthNotConfigured() error {
896 return fmt.Errorf("auth is enabled by default, configure auth type and secrets backend. See: https://gordon.bnema.dev/docs/config/auth")
897}
898
899func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) {
900 if cfg.Auth.PasswordHash != "" {
901 hash, err := loadSecret(ctx, backend, cfg.Auth.PasswordHash, dataDir, log)
902 if err != nil {
903 return "", log.WrapErr(err, "failed to load password hash")
904 }
905 return hash, nil
906 }
907
908 if cfg.Auth.Password != "" {
909 log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend")
910 return cfg.Auth.Password, nil
911 }
912
913 return "", nil
914}
915
916func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) {
917 secret, err := loadTokenSecret(ctx, cfg, backend, dataDir, log)
918 if err != nil {
919 return nil, 0, err
920 }
921
922 expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
923 if err != nil {
924 return nil, 0, err
925 }
926
927 return secret, expiry, nil
928}
929
930// TokenSecretEnvVar is the environment variable for the JWT signing secret.
931// SECURITY: This takes priority over config file to allow secure secret injection.
932const TokenSecretEnvVar = "GORDON_AUTH_TOKEN_SECRET" //nolint:gosec // This is an env var name, not a credential
933
934func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {
935 // SECURITY: Priority order for token secret:
936 // 1. Environment variable (most secure - no disk exposure)
937 // 2. Secrets backend (pass/sops - encrypted)
938 // 3. Config file path (least preferred)
939
940 // Check environment variable first
941 if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" {
942 log.Debug().Msg("using token secret from environment variable")
943 return []byte(envSecret), nil
944 }
945
946 // Fall back to config-specified path via secrets backend
947 if cfg.Auth.TokenSecret == "" {
948 return nil, fmt.Errorf("token_secret is required for JWT token generation; set %s environment variable or configure auth.token_secret", TokenSecretEnvVar)
949 }
950
951 secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log)
952 if err != nil {
953 return nil, log.WrapErr(err, "failed to load token secret")
954 }
955
956 return []byte(secret), nil
957}
958
959func parseTokenExpiry(expiry string) (time.Duration, error) {
960 if expiry == "" {
961 return 0, nil
962 }
963
964 parsed, err := duration.Parse(expiry)
965 if err != nil {
966 return 0, fmt.Errorf("invalid token_expiry: %w", err)
967 }
968
969 return parsed, nil
970}
971
972func resolveServiceTokenExpiry(cfg Config) (time.Duration, error) {
973 expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
974 if err != nil {
975 return 0, err
976 }
977 if expiry <= 0 {
978 return serviceTokenDefaultTTL, nil
979 }
980 return expiry, nil
981}
982
983// loadSecret loads a secret from the configured backend.
984func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) {
985 switch backend {
986 case domain.SecretsBackendPass:
987 provider := secrets.NewPassProvider(log)
988 return provider.GetSecret(ctx, path)
989 case domain.SecretsBackendSops:
990 provider := secrets.NewSopsProvider(log)
991 return provider.GetSecret(ctx, path)
992 case domain.SecretsBackendUnsafe:
993 // For unsafe backend, path is relative to dataDir/secrets/
994 secretFile := filepath.Join(dataDir, "secrets", path)
995 data, err := os.ReadFile(secretFile)
996 if err != nil {
997 return "", fmt.Errorf("failed to read secret file: %w", err)
998 }
999 return string(data), nil
1000 default:
1001 return "", fmt.Errorf("unknown secrets backend: %s", backend)
1002 }
1003}
1004
1005// proxyConfigResult holds parsed proxy and blob chunk size config.
1006type proxyConfigResult struct {
1007 proxyConfig proxy.Config
1008 maxBlobChunkSize int64
1009}
1010
1011// buildProxyConfig parses size-related config fields and builds the proxy config.
1012func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, error) {
1013 maxProxyBodySize := int64(512 << 20) // 512MB default
1014 if cfg.Server.MaxProxyBodySize != "" {
1015 parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyBodySize)
1016 if err != nil {
1017 return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_body_size configuration", map[string]any{"value": cfg.Server.MaxProxyBodySize})
1018 }
1019 maxProxyBodySize = parsedSize
1020 }
1021
1022 maxBlobChunkSize := int64(registry.DefaultMaxBlobChunkSize)
1023 if cfg.Server.MaxBlobChunkSize != "" {
1024 parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobChunkSize)
1025 if err != nil {
1026 return nil, log.WrapErrWithFields(err, "invalid server.max_blob_chunk_size configuration", map[string]any{"value": cfg.Server.MaxBlobChunkSize})
1027 }
1028 maxBlobChunkSize = parsedSize
1029 }
1030
1031 maxProxyResponseSize := int64(1 << 30) // 1GB default
1032 if cfg.Server.MaxProxyResponseSize != "" {
1033 parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyResponseSize)
1034 if err != nil {
1035 return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_response_size configuration", map[string]any{"value": cfg.Server.MaxProxyResponseSize})
1036 }
1037 maxProxyResponseSize = parsedSize
1038 }
1039
1040 maxConcurrentConns := cfg.Server.MaxConcurrentConns
1041 if maxConcurrentConns < 0 {
1042 maxConcurrentConns = 10000 // default when explicitly set to -1
1043 }
1044 // 0 means no limit (as documented in proxy.Config)
1045
1046 return &proxyConfigResult{
1047 proxyConfig: proxy.Config{
1048 RegistryDomain: cfg.Server.RegistryDomain,
1049 RegistryPort: cfg.Server.RegistryPort,
1050 MaxBodySize: maxProxyBodySize,
1051 MaxResponseSize: maxProxyResponseSize,
1052 MaxConcurrentConns: maxConcurrentConns,
1053 },
1054 maxBlobChunkSize: maxBlobChunkSize,
1055 }, nil
1056}
1057
1058// createContainerService creates the container service with configuration.
1059func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
1060 containerConfig := container.Config{
1061 RegistryAuthEnabled: cfg.Auth.Enabled,
1062 RegistryDomain: cfg.Server.RegistryDomain,
1063 RegistryPort: cfg.Server.RegistryPort,
1064 InternalRegistryUsername: svc.internalRegUser,
1065 InternalRegistryPassword: svc.internalRegPass,
1066 PullPolicy: v.GetString("deploy.pull_policy"),
1067 VolumeAutoCreate: v.GetBool("volumes.auto_create"),
1068 VolumePrefix: v.GetString("volumes.prefix"),
1069 VolumePreserve: v.GetBool("volumes.preserve"),
1070 NetworkIsolation: v.GetBool("network_isolation.enabled"),
1071 NetworkPrefix: v.GetString("network_isolation.network_prefix"),
1072 DNSSuffix: v.GetString("network_isolation.dns_suffix"),
1073 NetworkGroups: svc.configSvc.GetNetworkGroups(),
1074 Attachments: svc.configSvc.GetAttachments(),
1075 ReadinessDelay: v.GetDuration("deploy.readiness_delay"),
1076 ReadinessMode: v.GetString("deploy.readiness_mode"),
1077 HealthTimeout: v.GetDuration("deploy.health_timeout"),
1078 DrainDelay: v.GetDuration("deploy.drain_delay"),
1079 DrainMode: v.GetString("deploy.drain_mode"),
1080 DrainTimeout: v.GetDuration("deploy.drain_timeout"),
1081 }
1082 if v.IsSet("deploy.drain_delay") {
1083 containerConfig.DrainDelayConfigured = true
1084 containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay")
1085 }
1086
1087 if cfg.Auth.Enabled && svc.authSvc != nil {
1088 expiry, err := resolveServiceTokenExpiry(cfg)
1089 if err != nil {
1090 return nil, log.WrapErr(err, "failed to resolve service token expiry")
1091 }
1092 // Note: Service tokens are not auto-refreshed. If the token expires during
1093 // container runtime, the container will need to be recreated to get a new token.
1094 serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, expiry)
1095 if err != nil {
1096 return nil, log.WrapErr(err, "failed to generate registry service token")
1097 }
1098 log.Info().
1099 Str("subject", serviceTokenSubject).
1100 Str("expiry", expiry.String()).
1101 Msg("generated service token for container registry access")
1102 containerConfig.ServiceTokenUsername = serviceTokenSubject
1103 containerConfig.ServiceToken = serviceToken
1104 }
1105
1106 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil
1107}
1108
1109func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*filesystem.BackupStorage, *backup.Service, error) {
1110 if !cfg.Backups.Enabled {
1111 return nil, nil, nil
1112 }
1113
1114 storageDir := cfg.Backups.StorageDir
1115 if storageDir == "" {
1116 dataDir := resolveDataDir(cfg.Server.DataDir)
1117 storageDir = filepath.Join(dataDir, "backups")
1118 }
1119
1120 backupStorage, err := filesystem.NewBackupStorage(storageDir, log)
1121 if err != nil {
1122 return nil, nil, log.WrapErr(err, "failed to create backup storage")
1123 }
1124
1125 retention, err := validateBackupRetention(cfg)
1126 if err != nil {
1127 return nil, nil, log.WrapErr(err, "invalid backup retention policy")
1128 }
1129
1130 backupCfg := domain.BackupConfig{
1131 Enabled: cfg.Backups.Enabled,
1132 StorageDir: storageDir,
1133 Retention: retention,
1134 }
1135
1136 backupSvc := backup.NewService(svc.runtime, backupStorage, svc.containerSvc, backupCfg, log)
1137
1138 log.Info().
1139 Str("storage_dir", storageDir).
1140 Msg("backup service initialized")
1141
1142 return backupStorage, backupSvc, nil
1143}
1144
1145func validateBackupRetention(cfg Config) (domain.RetentionPolicy, error) {
1146 if cfg.Backups.Retention.Hourly < 0 {
1147 return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.hourly cannot be negative")
1148 }
1149 if cfg.Backups.Retention.Daily < 0 {
1150 return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.daily cannot be negative")
1151 }
1152 if cfg.Backups.Retention.Weekly < 0 {
1153 return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.weekly cannot be negative")
1154 }
1155 if cfg.Backups.Retention.Monthly < 0 {
1156 return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.monthly cannot be negative")
1157 }
1158
1159 return domain.RetentionPolicy{
1160 Hourly: cfg.Backups.Retention.Hourly,
1161 Daily: cfg.Backups.Retention.Daily,
1162 Weekly: cfg.Backups.Retention.Weekly,
1163 Monthly: cfg.Backups.Retention.Monthly,
1164 }, nil
1165}
1166
1167// registerEventHandlers registers all event handlers.
1168func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error {
1169 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
1170 if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil {
1171 return fmt.Errorf("failed to subscribe image pushed handler: %w", err)
1172 }
1173
1174 // Auto-route handler for creating routes from image labels
1175 autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain).
1176 WithEnvExtractor(svc.runtime, svc.envDir)
1177 if err := svc.eventBus.Subscribe(autoRouteHandler); err != nil {
1178 return fmt.Errorf("failed to subscribe auto-route handler: %w", err)
1179 }
1180
1181 configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)
1182 if err := svc.eventBus.Subscribe(configReloadHandler); err != nil {
1183 return fmt.Errorf("failed to subscribe config reload handler: %w", err)
1184 }
1185
1186 manualReloadHandler := container.NewManualReloadHandler(ctx, svc.containerSvc, svc.configSvc)
1187 if err := svc.eventBus.Subscribe(manualReloadHandler); err != nil {
1188 return fmt.Errorf("failed to subscribe manual reload handler: %w", err)
1189 }
1190
1191 manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc)
1192 if err := svc.eventBus.Subscribe(manualDeployHandler); err != nil {
1193 return fmt.Errorf("failed to subscribe manual deploy handler: %w", err)
1194 }
1195
1196 // Proxy cache invalidation on config reload (clears stale targets for removed routes)
1197 configReloadProxyHandler := proxy.NewConfigReloadProxyHandler(ctx, svc.proxySvc)
1198 if err := svc.eventBus.Subscribe(configReloadProxyHandler); err != nil {
1199 return fmt.Errorf("failed to subscribe config reload proxy handler: %w", err)
1200 }
1201
1202 return nil
1203}
1204
1205// setupConfigHotReload sets up Viper config hot reload.
1206// NOTE: This does NOT reload routes into memory. Routes are managed via API
1207// (AddRoute/UpdateRoute/RemoveRoute) and memory is the source of truth.
1208// The file watcher only updates proxy config and refreshes targets.
1209// Manual config file edits to routes require a server restart.
1210func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) {
1211 v.OnConfigChange(func(e fsnotify.Event) {
1212 log.Info().Str("file", e.Name).Msg("config file changed")
1213
1214 if err := v.ReadInConfig(); err != nil {
1215 log.Error().Err(err).Msg("failed to reload config")
1216 return
1217 }
1218
1219 // Re-unmarshal config and rebuild proxy config to pick up all changes
1220 var reloadCfg Config
1221 if err := v.Unmarshal(&reloadCfg); err != nil {
1222 log.Error().Err(err).Msg("failed to unmarshal config on reload")
1223 return
1224 }
1225 if reloadCfg.Server.GordonDomain != "" {
1226 reloadCfg.Server.RegistryDomain = reloadCfg.Server.GordonDomain
1227 }
1228 reloadedProxy, err := buildProxyConfig(reloadCfg, log)
1229 if err != nil {
1230 log.Error().Err(err).Msg("failed to parse proxy config on reload")
1231 return
1232 }
1233 svc.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)
1234
1235 // Clear proxy target cache to pick up external route changes
1236 if err := svc.proxySvc.RefreshTargets(ctx); err != nil {
1237 log.Warn().Err(err).Msg("failed to refresh proxy targets")
1238 }
1239
1240 log.Debug().Msg("config hot reload complete (routes unchanged, memory is source of truth)")
1241 })
1242 v.WatchConfig()
1243}
1244
1245// syncAndAutoStart syncs existing containers and auto-starts if configured.
1246func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
1247 if err := svc.containerSvc.SyncContainers(ctx); err != nil {
1248 log.Warn().Err(err).Msg("failed to sync existing containers")
1249 }
1250
1251 if svc.configSvc.IsAutoRouteEnabled() {
1252 routes := svc.configSvc.GetRoutes(ctx)
1253 if err := svc.containerSvc.AutoStart(domain.WithInternalDeploy(ctx), routes); err != nil {
1254 log.Warn().Err(err).Msg("failed to auto-start containers")
1255 }
1256 }
1257}
1258
1259// createHTTPHandlers creates HTTP handlers with middleware.
1260func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) {
1261 // Parse trusted proxies once for all middleware chains.
1262 // This ensures consistent IP extraction across logging, rate limiting, and auth.
1263 trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
1264
1265 registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)
1266
1267 registryMiddlewares := []func(http.Handler) http.Handler{
1268 middleware.PanicRecovery(log),
1269 middleware.RequestLogger(log, trustedNets),
1270 middleware.SecurityHeaders,
1271 }
1272
1273 // CIDR allowlist for registry access (e.g., restrict to Tailscale IPs).
1274 // Inserted before rate limiting so denied IPs never consume limiter tokens.
1275 var cidrAllowlistMiddleware func(http.Handler) http.Handler
1276 if len(cfg.Server.RegistryAllowedIPs) > 0 {
1277 allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs)
1278 if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) {
1279 for _, entry := range cfg.Server.RegistryAllowedIPs {
1280 if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 {
1281 log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry")
1282 }
1283 }
1284 }
1285 if len(allowedNets) == 0 {
1286 log.Error().
1287 Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs).
1288 Msg("registry_allowed_ips is set but no valid entries were parsed; registry will allow all traffic")
1289 }
1290 cidrAllowlistMiddleware = middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)
1291 registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware)
1292 }
1293
1294 // Create rate limiters using the factory
1295 var rateLimitMiddleware func(http.Handler) http.Handler
1296 if cfg.API.RateLimit.Enabled {
1297 globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
1298 ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1299 rateLimitMiddleware = registry.RateLimitMiddleware(
1300 globalLimiter,
1301 ipLimiter,
1302 cfg.API.RateLimit.TrustedProxies,
1303 log,
1304 )
1305 } else {
1306 rateLimitMiddleware = registry.RateLimitMiddleware(nil, nil, nil, log)
1307 }
1308 registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
1309
1310 if cfg.Auth.Enabled && svc.authSvc != nil {
1311 internalAuth := middleware.InternalRegistryAuth{
1312 Username: svc.internalRegUser,
1313 Password: svc.internalRegPass,
1314 }
1315 registryMiddlewares = append(registryMiddlewares,
1316 middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
1317 }
1318
1319 registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler)
1320
1321 // Create a mux that routes:
1322 // - /auth/* to auth handler (no auth required, for password login and token exchange)
1323 // - /v2/* to registry handler (with auth)
1324 // - /admin/* to admin handler (with auth)
1325 registryMux := http.NewServeMux()
1326 if svc.authHandler != nil {
1327 // Auth endpoints are NOT protected by auth - they're where clients authenticate
1328 // but still need rate limiting to prevent brute force attacks
1329 authMiddlewares := []func(http.Handler) http.Handler{
1330 middleware.PanicRecovery(log),
1331 middleware.RequestLogger(log, trustedNets),
1332 middleware.SecurityHeaders,
1333 }
1334 if cidrAllowlistMiddleware != nil {
1335 authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)
1336 }
1337 authMiddlewares = append(authMiddlewares, rateLimitMiddleware)
1338 authWithMiddleware := middleware.Chain(authMiddlewares...)(svc.authHandler)
1339 registryMux.Handle("/auth/", authWithMiddleware)
1340 }
1341 registryMux.Handle("/v2/", registryWithMiddleware)
1342
1343 // Add admin API handler with auth middleware
1344 if svc.adminHandler != nil {
1345 adminMiddlewares := []func(http.Handler) http.Handler{
1346 middleware.PanicRecovery(log),
1347 middleware.RequestLogger(log, trustedNets),
1348 middleware.SecurityHeaders,
1349 }
1350 // Add admin auth middleware if auth is enabled
1351 if svc.authSvc != nil {
1352 // Create rate limiters for admin API - uses same config as registry
1353 var globalLimiter, ipLimiter out.RateLimiter
1354 if cfg.API.RateLimit.Enabled {
1355 globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
1356 ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1357 }
1358 adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log))
1359 }
1360 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
1361 registryMux.Handle("/admin/", adminWithMiddleware)
1362 }
1363
1364 // SECURITY: No CORS middleware on the proxy chain. Backend applications
1365 // should control their own CORS policies. A blanket Access-Control-Allow-Origin: *
1366 // would override backend CORS settings and allow any website to make
1367 // cross-origin authenticated requests to proxied applications.
1368 proxyMiddlewares := []func(http.Handler) http.Handler{
1369 middleware.PanicRecovery(log),
1370 middleware.RequestLogger(log, trustedNets),
1371 middleware.SecurityHeaders,
1372 }
1373
1374 proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc)
1375
1376 return registryMux, proxyWithMiddleware
1377}
1378
1379// runServers starts the HTTP servers and waits for shutdown.
1380// Signal handling notes:
1381// - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext
1382// - SIGUSR1: Triggers config reload without restart
1383// - SIGUSR2: Triggers manual deploy for a specific route
1384// The deferred signal.Stop calls ensure signal handlers are properly
1385// cleaned up before program exit, preventing signal handler leaks.
1386func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, svc *services, log zerowrap.Logger) error {
1387 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
1388 defer cancel()
1389
1390 // Set up SIGUSR1 for reload.
1391 // Note: signal.Stop must be called (via defer) to release the channel
1392 // and prevent signal handler leaks when the function returns.
1393 reloadChan := make(chan os.Signal, 1)
1394 signal.Notify(reloadChan, syscall.SIGUSR1)
1395 defer signal.Stop(reloadChan)
1396
1397 // Set up SIGUSR2 for manual deploy.
1398 deployChan := make(chan os.Signal, 1)
1399 signal.Notify(deployChan, syscall.SIGUSR2)
1400 defer signal.Stop(deployChan)
1401
1402 errChan := make(chan error, 3)
1403
1404 registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
1405 proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
1406 var tlsReady <-chan struct{}
1407 if cfg.Server.TLSEnabled {
1408 if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" {
1409 return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file")
1410 }
1411 tlsReady = startTLSServer(
1412 fmt.Sprintf(":%d", cfg.Server.TLSPort),
1413 proxyHandler,
1414 "proxy-tls",
1415 cfg.Server.TLSCertFile,
1416 cfg.Server.TLSKeyFile,
1417 errChan,
1418 log,
1419 )
1420 }
1421
1422 // Wait for all enabled servers to bind their ports before auto-starting containers.
1423 // This prevents the race where auto-start pulls from the registry before it's listening.
1424 if err := waitForServerReady(registryReady, errChan); err != nil {
1425 return err
1426 }
1427 if err := waitForServerReady(proxyReady, errChan); err != nil {
1428 return err
1429 }
1430 if cfg.Server.TLSEnabled {
1431 if err := waitForServerReady(tlsReady, errChan); err != nil {
1432 return err
1433 }
1434 }
1435
1436 logEvent := log.Info().
1437 Int("proxy_port", cfg.Server.Port).
1438 Int("registry_port", cfg.Server.RegistryPort)
1439 if cfg.Server.TLSEnabled {
1440 logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort)
1441 }
1442 logEvent.Msg("Gordon is running")
1443
1444 schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)
1445 if err != nil {
1446 return err
1447 }
1448 if schedulerCleanup != nil {
1449 defer schedulerCleanup()
1450 }
1451
1452 // Auto-start after servers are listening (registry port is now bound).
1453 syncAndAutoStart(ctx, svc, log)
1454
1455 waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log)
1456 gracefulShutdown(registrySrv, proxySrv, containerSvc, log)
1457 return nil
1458}
1459
1460func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
1461 select {
1462 case <-ready:
1463 return nil
1464 case err := <-errChan:
1465 return err
1466 }
1467}
1468
1469func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, v *viper.Viper) (func(), error) {
1470 schedulers := make([]*cronSvc.Scheduler, 0, 2)
1471
1472 backupScheduler, err := startBackupScheduler(ctx, cfg, svc, log)
1473 if err != nil {
1474 return nil, err
1475 }
1476 if backupScheduler != nil {
1477 schedulers = append(schedulers, backupScheduler)
1478 }
1479
1480 imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int {
1481 return v.GetInt("images.prune.keep_last")
1482 })
1483 if err != nil {
1484 return nil, err
1485 }
1486 if imageScheduler != nil {
1487 schedulers = append(schedulers, imageScheduler)
1488 }
1489
1490 if len(schedulers) == 0 {
1491 return nil, nil
1492 }
1493
1494 return func() {
1495 for i := len(schedulers) - 1; i >= 0; i-- {
1496 schedulers[i].Stop()
1497 }
1498 }, nil
1499}
1500
1501func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {
1502 if !cfg.Backups.Enabled || svc == nil || svc.backupSvc == nil {
1503 return nil, nil
1504 }
1505
1506 preset, err := resolveBackupSchedule(cfg.Backups.Schedule)
1507 if err != nil {
1508 return nil, err
1509 }
1510
1511 scheduler := cronSvc.NewScheduler(log)
1512 err = scheduler.Add(
1513 "backup-scheduler",
1514 "Backups",
1515 domain.CronSchedule{Preset: preset},
1516 func(jobCtx context.Context) error {
1517 if err := svc.backupSvc.RunForSchedule(jobCtx, preset); err != nil {
1518 return err
1519 }
1520 log.Info().
1521 Str("schedule", string(preset)).
1522 Msg("scheduled backup run complete")
1523 return nil
1524 },
1525 )
1526 if err != nil {
1527 return nil, log.WrapErr(err, "failed to register backup schedule")
1528 }
1529
1530 scheduler.Start(ctx)
1531 log.Info().
1532 Str("schedule", string(preset)).
1533 Msg("backup scheduler enabled")
1534
1535 return scheduler, nil
1536}
1537
1538func resolveBackupSchedule(raw string) (domain.BackupSchedule, error) {
1539 return resolveSchedulePreset(raw, "backups.schedule", domain.ScheduleDaily)
1540}
1541
1542func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, keepLastGetter func() int) (*cronSvc.Scheduler, error) {
1543 if !cfg.Images.Prune.Enabled || svc == nil || svc.imageSvc == nil {
1544 return nil, nil
1545 }
1546 if keepLastGetter == nil {
1547 keepLastGetter = func() int { return cfg.Images.Prune.KeepLast }
1548 }
1549 if keepLastGetter() < 0 {
1550 return nil, fmt.Errorf("images.prune.keep_last must be >= 0")
1551 }
1552
1553 preset, err := resolveImagePruneSchedule(cfg.Images.Prune.Schedule)
1554 if err != nil {
1555 return nil, err
1556 }
1557
1558 scheduler := cronSvc.NewScheduler(log)
1559 err = scheduler.Add(
1560 "image-prune",
1561 "Image prune",
1562 domain.CronSchedule{Preset: preset},
1563 func(jobCtx context.Context) error {
1564 keepLast := keepLastGetter()
1565 if keepLast < 0 {
1566 log.Warn().
1567 Int("configured_keep_last", keepLast).
1568 Int("fallback_keep_last", domain.DefaultImagePruneKeepLast).
1569 Msg("invalid images.prune.keep_last; using default")
1570 keepLast = domain.DefaultImagePruneKeepLast
1571 }
1572
1573 report, err := svc.imageSvc.Prune(jobCtx, keepLast)
1574 if err != nil {
1575 return err
1576 }
1577
1578 log.Info().
1579 Int("keep_last", keepLast).
1580 Int("runtime_deleted", report.Runtime.DeletedCount).
1581 Int64("runtime_reclaimed_bytes", report.Runtime.SpaceReclaimed).
1582 Int("registry_tags_removed", report.Registry.TagsRemoved).
1583 Int("registry_blobs_removed", report.Registry.BlobsRemoved).
1584 Msg("scheduled image prune complete")
1585 return nil
1586 },
1587 )
1588 if err != nil {
1589 return nil, log.WrapErr(err, "failed to register image prune schedule")
1590 }
1591
1592 scheduler.Start(ctx)
1593 log.Info().
1594 Str("schedule", string(preset)).
1595 Int("keep_last", keepLastGetter()).
1596 Msg("image prune scheduler enabled")
1597
1598 return scheduler, nil
1599}
1600
1601func resolveImagePruneSchedule(raw string) (domain.BackupSchedule, error) {
1602 return resolveSchedulePreset(raw, "images.prune.schedule", domain.ScheduleDaily)
1603}
1604
1605func resolveSchedulePreset(raw, name string, defaultVal domain.BackupSchedule) (domain.BackupSchedule, error) {
1606 schedule := domain.BackupSchedule(strings.ToLower(strings.TrimSpace(raw)))
1607 if schedule == "" {
1608 schedule = defaultVal
1609 }
1610
1611 switch schedule {
1612 case domain.ScheduleHourly, domain.ScheduleDaily, domain.ScheduleWeekly, domain.ScheduleMonthly:
1613 return schedule, nil
1614 default:
1615 return "", fmt.Errorf("%s must be one of: hourly, daily, weekly, monthly", name)
1616 }
1617}
1618
1619// waitForShutdown blocks on the event loop, handling server errors and
1620// Unix signals (reload, deploy, shutdown) until the context is cancelled.
1621func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, deployChan <-chan os.Signal, eventBus out.EventBus, log zerowrap.Logger) {
1622 for {
1623 select {
1624 case err := <-errChan:
1625 log.Error().Err(err).Msg("server error")
1626 return
1627 case <-reloadChan:
1628 log.Info().Msg("reload signal received (SIGUSR1)")
1629 if err := eventBus.Publish(domain.EventManualReload, nil); err != nil {
1630 log.Error().Err(err).Msg("failed to publish manual reload event")
1631 }
1632 case <-deployChan:
1633 log.Info().Msg("deploy signal received (SIGUSR2)")
1634 domainName, err := readDeployRequest()
1635 if err != nil {
1636 log.Error().Err(err).Msg("failed to read deploy request")
1637 continue
1638 }
1639 payload := &domain.ManualDeployPayload{Domain: domainName}
1640 if err := eventBus.Publish(domain.EventManualDeploy, payload); err != nil {
1641 log.Error().Err(err).Str("domain", domainName).Msg("failed to publish manual deploy event")
1642 }
1643 case <-ctx.Done():
1644 log.Info().Msg("shutdown signal received")
1645 return
1646 }
1647 }
1648}
1649
1650// gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
1651// the container service and cleans up runtime files.
1652func gracefulShutdown(registrySrv, proxySrv *http.Server, containerSvc *container.Service, log zerowrap.Logger) {
1653 log.Info().Msg("shutting down Gordon...")
1654
1655 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
1656 defer shutdownCancel()
1657
1658 if err := registrySrv.Shutdown(shutdownCtx); err != nil {
1659 log.Warn().Err(err).Msg("registry server shutdown error")
1660 }
1661 if err := proxySrv.Shutdown(shutdownCtx); err != nil {
1662 log.Warn().Err(err).Msg("proxy server shutdown error")
1663 }
1664
1665 if err := containerSvc.Shutdown(shutdownCtx); err != nil {
1666 log.Warn().Err(err).Msg("error during container shutdown")
1667 }
1668
1669 cleanupInternalCredentials()
1670 log.Info().Msg("Gordon stopped")
1671}
1672
1673// startServer starts an HTTP server, returning the server instance and a channel
1674// that closes once the listening socket is bound. This lets callers wait for the
1675// port to be ready before taking actions that depend on it (e.g. auto-start
1676// pulling from the local registry). The returned *http.Server can be used for
1677// graceful shutdown.
1678func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {
1679 ready := make(chan struct{})
1680
1681 server := &http.Server{
1682 Addr: addr,
1683 Handler: handler,
1684 ReadHeaderTimeout: 10 * time.Second,
1685 ReadTimeout: 5 * time.Minute,
1686 WriteTimeout: 5 * time.Minute,
1687 IdleTimeout: 120 * time.Second,
1688 MaxHeaderBytes: 1 << 20,
1689 }
1690
1691 go func() {
1692 log.Info().Str("address", addr).Msgf("%s server starting", name)
1693
1694 ln, err := net.Listen("tcp", addr)
1695 if err != nil {
1696 errChan <- fmt.Errorf("%s server error: %w", name, err)
1697 return
1698 }
1699 close(ready) // signal: port is bound and accepting connections
1700
1701 if err := server.Serve(ln); err != nil && err != http.ErrServerClosed {
1702 errChan <- fmt.Errorf("%s server error: %w", name, err)
1703 }
1704 }()
1705
1706 return server, ready
1707}
1708
1709// startTLSServer starts an HTTPS server with the provided certificate and key.
1710func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile string, errChan chan<- error, log zerowrap.Logger) <-chan struct{} {
1711 ready := make(chan struct{})
1712
1713 go func() {
1714 log.Info().
1715 Str("address", addr).
1716 Str("cert_file", certFile).
1717 Str("key_file", keyFile).
1718 Msgf("%s server starting", name)
1719
1720 server := &http.Server{
1721 Addr: addr,
1722 Handler: handler,
1723 ReadHeaderTimeout: 10 * time.Second,
1724 ReadTimeout: 5 * time.Minute,
1725 WriteTimeout: 5 * time.Minute,
1726 IdleTimeout: 120 * time.Second,
1727 MaxHeaderBytes: 1 << 20,
1728 }
1729
1730 ln, err := net.Listen("tcp", addr)
1731 if err != nil {
1732 errChan <- fmt.Errorf("%s server error: %w", name, err)
1733 return
1734 }
1735 close(ready)
1736
1737 if err := server.ServeTLS(ln, certFile, keyFile); err != nil && err != http.ErrServerClosed {
1738 errChan <- fmt.Errorf("%s server error: %w", name, err)
1739 }
1740 }()
1741
1742 return ready
1743}
1744
1745// SendReloadSignal sends SIGUSR1 to the running Gordon process.
1746func SendReloadSignal() error {
1747 process, _, err := findRunningProcess()
1748 if err != nil {
1749 return err
1750 }
1751
1752 if err := process.Signal(syscall.SIGUSR1); err != nil {
1753 return fmt.Errorf("failed to send reload signal: %w", err)
1754 }
1755
1756 return nil
1757}
1758
1759// getDeployRequestFile returns the path to the deploy request file.
1760func getDeployRequestFile() string {
1761 return filepath.Join(os.TempDir(), "gordon-deploy-request")
1762}
1763
1764// writeDeployRequestFile creates the deploy request file exclusively with retry.
1765// This prevents race conditions when multiple deploy commands run simultaneously.
1766func writeDeployRequestFile(path string, data []byte, timeout time.Duration) error {
1767 deadline := time.Now().Add(timeout)
1768
1769 for {
1770 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
1771 if err == nil {
1772 _, writeErr := f.Write(data)
1773 f.Close()
1774 if writeErr != nil {
1775 _ = os.Remove(path)
1776 return writeErr
1777 }
1778 return nil
1779 }
1780
1781 if os.IsExist(err) {
1782 if time.Now().After(deadline) {
1783 return fmt.Errorf("deploy request file still present after timeout; another deploy may be in progress")
1784 }
1785 time.Sleep(100 * time.Millisecond)
1786 continue
1787 }
1788
1789 return err
1790 }
1791}
1792
1793// SendDeploySignal triggers a manual deploy for a specific route via SIGUSR2.
1794// Returns the domain name on success for the caller to display.
1795func SendDeploySignal(domain string) (string, error) {
1796 deployFile := getDeployRequestFile()
1797 if err := writeDeployRequestFile(deployFile, []byte(domain), 5*time.Second); err != nil {
1798 return "", fmt.Errorf("failed to write deploy request: %w", err)
1799 }
1800
1801 // Find PID and send SIGUSR2
1802 process, _, err := findRunningProcess()
1803 if err != nil {
1804 _ = os.Remove(deployFile)
1805 return "", err
1806 }
1807
1808 if err := process.Signal(syscall.SIGUSR2); err != nil {
1809 _ = os.Remove(deployFile)
1810 return "", fmt.Errorf("failed to send deploy signal: %w", err)
1811 }
1812
1813 return domain, nil
1814}
1815
1816// readDeployRequest reads and removes the deploy request file atomically.
1817// Returns empty string if file doesn't exist (may have been consumed by another handler).
1818func readDeployRequest() (string, error) {
1819 deployFile := getDeployRequestFile()
1820
1821 // Rename to a temp file first to make the read-and-delete atomic
1822 tmpFile := deployFile + ".processing"
1823 if err := os.Rename(deployFile, tmpFile); err != nil {
1824 if os.IsNotExist(err) {
1825 return "", fmt.Errorf("deploy request file not found (may have been processed already)")
1826 }
1827 return "", fmt.Errorf("failed to acquire deploy request: %w", err)
1828 }
1829
1830 data, err := os.ReadFile(tmpFile)
1831 _ = os.Remove(tmpFile) // Always clean up
1832 if err != nil {
1833 return "", fmt.Errorf("failed to read deploy request: %w", err)
1834 }
1835
1836 return string(data), nil
1837}
1838
1839// createPidFile creates a PID file for the Gordon process.
1840// SECURITY: Prefers secure locations (XDG_RUNTIME_DIR, ~/.gordon/run) over /tmp
1841// to prevent symlink attacks and unauthorized access.
1842func createPidFile(log zerowrap.Logger) string {
1843 pid := os.Getpid()
1844
1845 // SECURITY: Prioritize secure locations over /tmp
1846 var locations []string
1847
1848 // Try secure runtime directory first
1849 if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1850 locations = append(locations, filepath.Join(runtimeDir, "gordon.pid"))
1851 }
1852
1853 // Fall back to home directory
1854 if homeDir, err := os.UserHomeDir(); err == nil {
1855 locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid"))
1856 }
1857
1858 // Last resort: /tmp (least secure due to world-writable)
1859 locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid"))
1860
1861 for _, location := range locations {
1862 // Ensure parent directory exists with secure permissions
1863 if err := os.MkdirAll(filepath.Dir(location), 0700); err != nil {
1864 continue
1865 }
1866 if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil {
1867 log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file")
1868 return location
1869 }
1870 }
1871
1872 log.Warn().Int("pid", pid).Msg("failed to create PID file in any location")
1873 return ""
1874}
1875
1876// removePidFile removes the PID file.
1877func removePidFile(pidFile string, log zerowrap.Logger) {
1878 if err := os.Remove(pidFile); err != nil {
1879 log.Warn().Err(err).Str("pid_file", pidFile).Msg("failed to remove PID file")
1880 } else {
1881 log.Debug().Str("pid_file", pidFile).Msg("removed PID file")
1882 }
1883}
1884
1885func pidFileLocations() []string {
1886 var locations []string
1887 seen := make(map[string]struct{})
1888 add := func(path string) {
1889 if path == "" {
1890 return
1891 }
1892 if _, ok := seen[path]; ok {
1893 return
1894 }
1895 seen[path] = struct{}{}
1896 locations = append(locations, path)
1897 }
1898
1899 // Check secure runtime directory first
1900 if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1901 add(filepath.Join(runtimeDir, "gordon.pid"))
1902 }
1903
1904 // Also check canonical /run/user/<uid> runtime path. This handles cases where
1905 // Gordon started under systemd user services with runtime dir available, but
1906 // CLI invocations (e.g. non-interactive SSH) don't have XDG_RUNTIME_DIR set.
1907 runtimeByUID := filepath.Join("/run/user", strconv.Itoa(os.Getuid()), "gordon", "gordon.pid")
1908 add(runtimeByUID)
1909
1910 // Check explicit XDG_RUNTIME_DIR if present in this process env.
1911 if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
1912 add(filepath.Join(runtimeDir, "gordon.pid"))
1913 }
1914
1915 // Check home directory
1916 if homeDir, err := os.UserHomeDir(); err == nil {
1917 add(filepath.Join(homeDir, ".gordon", "gordon.pid"))
1918 // Legacy location for backward compatibility
1919 add(filepath.Join(homeDir, ".gordon.pid"))
1920 }
1921
1922 // Legacy /tmp locations for backward compatibility
1923 add(filepath.Join(os.TempDir(), "gordon.pid"))
1924 add("/tmp/gordon.pid")
1925
1926 return locations
1927}
1928
1929// findRunningPidFile returns the first PID file whose PID belongs to a live process.
1930// Stale/invalid PID files are ignored and removed when possible.
1931func findRunningPidFile() (string, int, error) {
1932 return findRunningPidFileInLocations(pidFileLocations())
1933}
1934
1935func findRunningPidFileInLocations(locations []string) (string, int, error) {
1936 foundAny := false
1937
1938 for _, location := range locations {
1939 pidBytes, err := os.ReadFile(location)
1940 if err != nil {
1941 continue
1942 }
1943
1944 foundAny = true
1945
1946 var pid int
1947 if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil || pid <= 0 {
1948 _ = os.Remove(location)
1949 continue
1950 }
1951
1952 if isProcessAlive(pid) {
1953 return location, pid, nil
1954 }
1955
1956 _ = os.Remove(location)
1957 }
1958
1959 if foundAny {
1960 return "", 0, fmt.Errorf("found stale gordon PID file(s), is Gordon running?")
1961 }
1962
1963 return "", 0, fmt.Errorf("gordon PID file not found, is Gordon running?")
1964}
1965
1966func findRunningProcess() (*os.Process, int, error) {
1967 _, pid, err := findRunningPidFile()
1968 if err != nil {
1969 return nil, 0, err
1970 }
1971
1972 process, err := os.FindProcess(pid)
1973 if err != nil {
1974 return nil, 0, fmt.Errorf("failed to find process: %w", err)
1975 }
1976
1977 return process, pid, nil
1978}
1979
1980func isProcessAlive(pid int) bool {
1981 process, err := os.FindProcess(pid)
1982 if err != nil {
1983 return false
1984 }
1985
1986 err = process.Signal(syscall.Signal(0))
1987 if err == nil {
1988 return true
1989 }
1990
1991 return errors.Is(err, syscall.EPERM)
1992}
1993
1994// loadConfig loads configuration from file and sets defaults.
1995func loadConfig(v *viper.Viper, configPath string) error {
1996 v.SetDefault("server.port", 80)
1997 v.SetDefault("server.registry_port", 5000)
1998 v.SetDefault("server.tls_enabled", false)
1999 v.SetDefault("server.tls_port", 443)
2000 v.SetDefault("server.tls_cert_file", "")
2001 v.SetDefault("server.tls_key_file", "")
2002 v.SetDefault("server.data_dir", DefaultDataDir())
2003 v.SetDefault("logging.level", "info")
2004 v.SetDefault("logging.format", "console")
2005 v.SetDefault("logging.file.enabled", false)
2006 v.SetDefault("logging.file.max_size", 100)
2007 v.SetDefault("logging.file.max_backups", 3)
2008 v.SetDefault("logging.file.max_age", 28)
2009 v.SetDefault("logging.container_logs.enabled", true)
2010 v.SetDefault("logging.container_logs.dir", "")
2011 v.SetDefault("logging.container_logs.max_size", 100)
2012 v.SetDefault("logging.container_logs.max_backups", 3)
2013 v.SetDefault("logging.container_logs.max_age", 28)
2014 v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
2015 v.SetDefault("auth.enabled", true)
2016 // Note: auth.type is intentionally not set - it's inferred from config
2017 // If password_hash is set -> password mode, otherwise -> token mode
2018 v.SetDefault("auth.secrets_backend", "unsafe")
2019 v.SetDefault("auth.token_expiry", "720h")
2020 v.SetDefault("api.rate_limit.enabled", true)
2021 v.SetDefault("api.rate_limit.global_rps", 500)
2022 v.SetDefault("api.rate_limit.per_ip_rps", 50)
2023 v.SetDefault("api.rate_limit.burst", 100)
2024 v.SetDefault("auto_route.enabled", false)
2025 v.SetDefault("network_isolation.enabled", false)
2026 v.SetDefault("network_isolation.network_prefix", "gordon")
2027 v.SetDefault("network_isolation.dns_suffix", ".internal")
2028 v.SetDefault("volumes.auto_create", true)
2029 v.SetDefault("volumes.prefix", "gordon")
2030 v.SetDefault("volumes.preserve", true)
2031 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
2032 v.SetDefault("backups.enabled", false)
2033 v.SetDefault("backups.schedule", string(domain.ScheduleDaily))
2034 v.SetDefault("backups.storage_dir", "")
2035 v.SetDefault("backups.retention.hourly", 0)
2036 v.SetDefault("backups.retention.daily", 0)
2037 v.SetDefault("backups.retention.weekly", 0)
2038 v.SetDefault("backups.retention.monthly", 0)
2039 v.SetDefault("images.prune.enabled", false)
2040 v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))
2041 v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast)
2042 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
2043 v.SetDefault("server.registry_allowed_ips", []string{})
2044 v.SetDefault("deploy.readiness_delay", "5s")
2045 v.SetDefault("deploy.readiness_mode", "auto")
2046 v.SetDefault("deploy.health_timeout", "90s")
2047 v.SetDefault("deploy.drain_mode", "auto")
2048 v.SetDefault("deploy.drain_timeout", "30s")
2049
2050 ConfigureViper(v, configPath)
2051
2052 if err := v.ReadInConfig(); err != nil {
2053 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
2054 return fmt.Errorf("failed to read config file: %w", err)
2055 }
2056 }
2057
2058 v.SetEnvPrefix("GORDON")
2059 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
2060 v.AutomaticEnv()
2061
2062 return nil
2063}