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), cfg.Logging.File.Enabled, 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 // Start background monitor to restart crashed containers.
1259 svc.containerSvc.StartMonitor(ctx)
1260}
1261
1262// createHTTPHandlers creates HTTP handlers with middleware.
1263func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) {
1264 // Parse trusted proxies once for all middleware chains.
1265 // This ensures consistent IP extraction across logging, rate limiting, and auth.
1266 trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
1267
1268 registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)
1269
1270 registryMiddlewares := []func(http.Handler) http.Handler{
1271 middleware.PanicRecovery(log),
1272 middleware.RequestLogger(log, trustedNets),
1273 middleware.SecurityHeaders,
1274 }
1275
1276 // CIDR allowlist for registry access (e.g., restrict to Tailscale IPs).
1277 // Inserted before rate limiting so denied IPs never consume limiter tokens.
1278 var cidrAllowlistMiddleware func(http.Handler) http.Handler
1279 if len(cfg.Server.RegistryAllowedIPs) > 0 {
1280 allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs)
1281 if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) {
1282 for _, entry := range cfg.Server.RegistryAllowedIPs {
1283 if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 {
1284 log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry")
1285 }
1286 }
1287 }
1288 if len(allowedNets) == 0 {
1289 log.Error().
1290 Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs).
1291 Msg("registry_allowed_ips is set but no valid entries were parsed; registry will allow all traffic")
1292 }
1293 cidrAllowlistMiddleware = middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)
1294 registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware)
1295 }
1296
1297 // Create rate limiters using the factory
1298 var rateLimitMiddleware func(http.Handler) http.Handler
1299 if cfg.API.RateLimit.Enabled {
1300 globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
1301 ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1302 rateLimitMiddleware = registry.RateLimitMiddleware(
1303 globalLimiter,
1304 ipLimiter,
1305 cfg.API.RateLimit.TrustedProxies,
1306 log,
1307 )
1308 } else {
1309 rateLimitMiddleware = registry.RateLimitMiddleware(nil, nil, nil, log)
1310 }
1311 registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
1312
1313 if cfg.Auth.Enabled && svc.authSvc != nil {
1314 internalAuth := middleware.InternalRegistryAuth{
1315 Username: svc.internalRegUser,
1316 Password: svc.internalRegPass,
1317 }
1318 registryMiddlewares = append(registryMiddlewares,
1319 middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
1320 }
1321
1322 registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler)
1323
1324 // Create a mux that routes:
1325 // - /auth/* to auth handler (no auth required, for password login and token exchange)
1326 // - /v2/* to registry handler (with auth)
1327 // - /admin/* to admin handler (with auth)
1328 registryMux := http.NewServeMux()
1329 if svc.authHandler != nil {
1330 // Auth endpoints are NOT protected by auth - they're where clients authenticate
1331 // but still need rate limiting to prevent brute force attacks
1332 authMiddlewares := []func(http.Handler) http.Handler{
1333 middleware.PanicRecovery(log),
1334 middleware.RequestLogger(log, trustedNets),
1335 middleware.SecurityHeaders,
1336 }
1337 if cidrAllowlistMiddleware != nil {
1338 authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)
1339 }
1340 authMiddlewares = append(authMiddlewares, rateLimitMiddleware)
1341 authWithMiddleware := middleware.Chain(authMiddlewares...)(svc.authHandler)
1342 registryMux.Handle("/auth/", authWithMiddleware)
1343 }
1344 registryMux.Handle("/v2/", registryWithMiddleware)
1345
1346 // Add admin API handler with auth middleware
1347 if svc.adminHandler != nil {
1348 adminMiddlewares := []func(http.Handler) http.Handler{
1349 middleware.PanicRecovery(log),
1350 middleware.RequestLogger(log, trustedNets),
1351 middleware.SecurityHeaders,
1352 }
1353 // Add admin auth middleware if auth is enabled
1354 if svc.authSvc != nil {
1355 // Create rate limiters for admin API - uses same config as registry
1356 var globalLimiter, ipLimiter out.RateLimiter
1357 if cfg.API.RateLimit.Enabled {
1358 globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
1359 ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1360 }
1361 adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log))
1362 }
1363 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
1364 registryMux.Handle("/admin/", adminWithMiddleware)
1365 }
1366
1367 // SECURITY: No CORS middleware on the proxy chain. Backend applications
1368 // should control their own CORS policies. A blanket Access-Control-Allow-Origin: *
1369 // would override backend CORS settings and allow any website to make
1370 // cross-origin authenticated requests to proxied applications.
1371 proxyMiddlewares := []func(http.Handler) http.Handler{
1372 middleware.PanicRecovery(log),
1373 middleware.RequestLogger(log, trustedNets),
1374 middleware.SecurityHeaders,
1375 }
1376
1377 proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc)
1378
1379 return registryMux, proxyWithMiddleware
1380}
1381
1382// runServers starts the HTTP servers and waits for shutdown.
1383// Signal handling notes:
1384// - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext
1385// - SIGUSR1: Triggers config reload without restart
1386// - SIGUSR2: Triggers manual deploy for a specific route
1387// The deferred signal.Stop calls ensure signal handlers are properly
1388// cleaned up before program exit, preventing signal handler leaks.
1389func 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 {
1390 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
1391 defer cancel()
1392
1393 // Set up SIGUSR1 for reload.
1394 // Note: signal.Stop must be called (via defer) to release the channel
1395 // and prevent signal handler leaks when the function returns.
1396 reloadChan := make(chan os.Signal, 1)
1397 signal.Notify(reloadChan, syscall.SIGUSR1)
1398 defer signal.Stop(reloadChan)
1399
1400 // Set up SIGUSR2 for manual deploy.
1401 deployChan := make(chan os.Signal, 1)
1402 signal.Notify(deployChan, syscall.SIGUSR2)
1403 defer signal.Stop(deployChan)
1404
1405 errChan := make(chan error, 3)
1406
1407 registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
1408 proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
1409 var tlsReady <-chan struct{}
1410 if cfg.Server.TLSEnabled {
1411 if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" {
1412 return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file")
1413 }
1414 tlsReady = startTLSServer(
1415 fmt.Sprintf(":%d", cfg.Server.TLSPort),
1416 proxyHandler,
1417 "proxy-tls",
1418 cfg.Server.TLSCertFile,
1419 cfg.Server.TLSKeyFile,
1420 errChan,
1421 log,
1422 )
1423 }
1424
1425 // Wait for all enabled servers to bind their ports before auto-starting containers.
1426 // This prevents the race where auto-start pulls from the registry before it's listening.
1427 if err := waitForServerReady(registryReady, errChan); err != nil {
1428 return err
1429 }
1430 if err := waitForServerReady(proxyReady, errChan); err != nil {
1431 return err
1432 }
1433 if cfg.Server.TLSEnabled {
1434 if err := waitForServerReady(tlsReady, errChan); err != nil {
1435 return err
1436 }
1437 }
1438
1439 logEvent := log.Info().
1440 Int("proxy_port", cfg.Server.Port).
1441 Int("registry_port", cfg.Server.RegistryPort)
1442 if cfg.Server.TLSEnabled {
1443 logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort)
1444 }
1445 logEvent.Msg("Gordon is running")
1446
1447 schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)
1448 if err != nil {
1449 return err
1450 }
1451 if schedulerCleanup != nil {
1452 defer schedulerCleanup()
1453 }
1454
1455 // Auto-start after servers are listening (registry port is now bound).
1456 syncAndAutoStart(ctx, svc, log)
1457
1458 waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log)
1459 gracefulShutdown(registrySrv, proxySrv, containerSvc, log)
1460 return nil
1461}
1462
1463func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
1464 select {
1465 case <-ready:
1466 return nil
1467 case err := <-errChan:
1468 return err
1469 }
1470}
1471
1472func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, v *viper.Viper) (func(), error) {
1473 schedulers := make([]*cronSvc.Scheduler, 0, 2)
1474
1475 backupScheduler, err := startBackupScheduler(ctx, cfg, svc, log)
1476 if err != nil {
1477 return nil, err
1478 }
1479 if backupScheduler != nil {
1480 schedulers = append(schedulers, backupScheduler)
1481 }
1482
1483 imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int {
1484 return v.GetInt("images.prune.keep_last")
1485 })
1486 if err != nil {
1487 return nil, err
1488 }
1489 if imageScheduler != nil {
1490 schedulers = append(schedulers, imageScheduler)
1491 }
1492
1493 if len(schedulers) == 0 {
1494 return nil, nil
1495 }
1496
1497 return func() {
1498 for i := len(schedulers) - 1; i >= 0; i-- {
1499 schedulers[i].Stop()
1500 }
1501 }, nil
1502}
1503
1504func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {
1505 if !cfg.Backups.Enabled || svc == nil || svc.backupSvc == nil {
1506 return nil, nil
1507 }
1508
1509 preset, err := resolveBackupSchedule(cfg.Backups.Schedule)
1510 if err != nil {
1511 return nil, err
1512 }
1513
1514 scheduler := cronSvc.NewScheduler(log)
1515 err = scheduler.Add(
1516 "backup-scheduler",
1517 "Backups",
1518 domain.CronSchedule{Preset: preset},
1519 func(jobCtx context.Context) error {
1520 if err := svc.backupSvc.RunForSchedule(jobCtx, preset); err != nil {
1521 return err
1522 }
1523 log.Info().
1524 Str("schedule", string(preset)).
1525 Msg("scheduled backup run complete")
1526 return nil
1527 },
1528 )
1529 if err != nil {
1530 return nil, log.WrapErr(err, "failed to register backup schedule")
1531 }
1532
1533 scheduler.Start(ctx)
1534 log.Info().
1535 Str("schedule", string(preset)).
1536 Msg("backup scheduler enabled")
1537
1538 return scheduler, nil
1539}
1540
1541func resolveBackupSchedule(raw string) (domain.BackupSchedule, error) {
1542 return resolveSchedulePreset(raw, "backups.schedule", domain.ScheduleDaily)
1543}
1544
1545func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, keepLastGetter func() int) (*cronSvc.Scheduler, error) {
1546 if !cfg.Images.Prune.Enabled || svc == nil || svc.imageSvc == nil {
1547 return nil, nil
1548 }
1549 if keepLastGetter == nil {
1550 keepLastGetter = func() int { return cfg.Images.Prune.KeepLast }
1551 }
1552 if keepLastGetter() < 0 {
1553 return nil, fmt.Errorf("images.prune.keep_last must be >= 0")
1554 }
1555
1556 preset, err := resolveImagePruneSchedule(cfg.Images.Prune.Schedule)
1557 if err != nil {
1558 return nil, err
1559 }
1560
1561 scheduler := cronSvc.NewScheduler(log)
1562 err = scheduler.Add(
1563 "image-prune",
1564 "Image prune",
1565 domain.CronSchedule{Preset: preset},
1566 func(jobCtx context.Context) error {
1567 keepLast := keepLastGetter()
1568 if keepLast < 0 {
1569 log.Warn().
1570 Int("configured_keep_last", keepLast).
1571 Int("fallback_keep_last", domain.DefaultImagePruneKeepLast).
1572 Msg("invalid images.prune.keep_last; using default")
1573 keepLast = domain.DefaultImagePruneKeepLast
1574 }
1575
1576 report, err := svc.imageSvc.Prune(jobCtx, keepLast)
1577 if err != nil {
1578 return err
1579 }
1580
1581 log.Info().
1582 Int("keep_last", keepLast).
1583 Int("runtime_deleted", report.Runtime.DeletedCount).
1584 Int64("runtime_reclaimed_bytes", report.Runtime.SpaceReclaimed).
1585 Int("registry_tags_removed", report.Registry.TagsRemoved).
1586 Int("registry_blobs_removed", report.Registry.BlobsRemoved).
1587 Msg("scheduled image prune complete")
1588 return nil
1589 },
1590 )
1591 if err != nil {
1592 return nil, log.WrapErr(err, "failed to register image prune schedule")
1593 }
1594
1595 scheduler.Start(ctx)
1596 log.Info().
1597 Str("schedule", string(preset)).
1598 Int("keep_last", keepLastGetter()).
1599 Msg("image prune scheduler enabled")
1600
1601 return scheduler, nil
1602}
1603
1604func resolveImagePruneSchedule(raw string) (domain.BackupSchedule, error) {
1605 return resolveSchedulePreset(raw, "images.prune.schedule", domain.ScheduleDaily)
1606}
1607
1608func resolveSchedulePreset(raw, name string, defaultVal domain.BackupSchedule) (domain.BackupSchedule, error) {
1609 schedule := domain.BackupSchedule(strings.ToLower(strings.TrimSpace(raw)))
1610 if schedule == "" {
1611 schedule = defaultVal
1612 }
1613
1614 switch schedule {
1615 case domain.ScheduleHourly, domain.ScheduleDaily, domain.ScheduleWeekly, domain.ScheduleMonthly:
1616 return schedule, nil
1617 default:
1618 return "", fmt.Errorf("%s must be one of: hourly, daily, weekly, monthly", name)
1619 }
1620}
1621
1622// waitForShutdown blocks on the event loop, handling server errors and
1623// Unix signals (reload, deploy, shutdown) until the context is cancelled.
1624func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, deployChan <-chan os.Signal, eventBus out.EventBus, log zerowrap.Logger) {
1625 for {
1626 select {
1627 case err := <-errChan:
1628 log.Error().Err(err).Msg("server error")
1629 return
1630 case <-reloadChan:
1631 log.Info().Msg("reload signal received (SIGUSR1)")
1632 if err := eventBus.Publish(domain.EventManualReload, nil); err != nil {
1633 log.Error().Err(err).Msg("failed to publish manual reload event")
1634 }
1635 case <-deployChan:
1636 log.Info().Msg("deploy signal received (SIGUSR2)")
1637 domainName, err := readDeployRequest()
1638 if err != nil {
1639 log.Error().Err(err).Msg("failed to read deploy request")
1640 continue
1641 }
1642 payload := &domain.ManualDeployPayload{Domain: domainName}
1643 if err := eventBus.Publish(domain.EventManualDeploy, payload); err != nil {
1644 log.Error().Err(err).Str("domain", domainName).Msg("failed to publish manual deploy event")
1645 }
1646 case <-ctx.Done():
1647 log.Info().Msg("shutdown signal received")
1648 return
1649 }
1650 }
1651}
1652
1653// gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
1654// the container service and cleans up runtime files.
1655func gracefulShutdown(registrySrv, proxySrv *http.Server, containerSvc *container.Service, log zerowrap.Logger) {
1656 log.Info().Msg("shutting down Gordon...")
1657
1658 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
1659 defer shutdownCancel()
1660
1661 if err := registrySrv.Shutdown(shutdownCtx); err != nil {
1662 log.Warn().Err(err).Msg("registry server shutdown error")
1663 }
1664 if err := proxySrv.Shutdown(shutdownCtx); err != nil {
1665 log.Warn().Err(err).Msg("proxy server shutdown error")
1666 }
1667
1668 containerSvc.StopMonitor()
1669
1670 if err := containerSvc.Shutdown(shutdownCtx); err != nil {
1671 log.Warn().Err(err).Msg("error during container shutdown")
1672 }
1673
1674 cleanupInternalCredentials()
1675 log.Info().Msg("Gordon stopped")
1676}
1677
1678// startServer starts an HTTP server, returning the server instance and a channel
1679// that closes once the listening socket is bound. This lets callers wait for the
1680// port to be ready before taking actions that depend on it (e.g. auto-start
1681// pulling from the local registry). The returned *http.Server can be used for
1682// graceful shutdown.
1683func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {
1684 ready := make(chan struct{})
1685
1686 server := &http.Server{
1687 Addr: addr,
1688 Handler: handler,
1689 ReadHeaderTimeout: 10 * time.Second,
1690 ReadTimeout: 5 * time.Minute,
1691 WriteTimeout: 5 * time.Minute,
1692 IdleTimeout: 120 * time.Second,
1693 MaxHeaderBytes: 1 << 20,
1694 }
1695
1696 go func() {
1697 log.Info().Str("address", addr).Msgf("%s server starting", name)
1698
1699 ln, err := net.Listen("tcp", addr)
1700 if err != nil {
1701 errChan <- fmt.Errorf("%s server error: %w", name, err)
1702 return
1703 }
1704 close(ready) // signal: port is bound and accepting connections
1705
1706 if err := server.Serve(ln); err != nil && err != http.ErrServerClosed {
1707 errChan <- fmt.Errorf("%s server error: %w", name, err)
1708 }
1709 }()
1710
1711 return server, ready
1712}
1713
1714// startTLSServer starts an HTTPS server with the provided certificate and key.
1715func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile string, errChan chan<- error, log zerowrap.Logger) <-chan struct{} {
1716 ready := make(chan struct{})
1717
1718 go func() {
1719 log.Info().
1720 Str("address", addr).
1721 Str("cert_file", certFile).
1722 Str("key_file", keyFile).
1723 Msgf("%s server starting", name)
1724
1725 server := &http.Server{
1726 Addr: addr,
1727 Handler: handler,
1728 ReadHeaderTimeout: 10 * time.Second,
1729 ReadTimeout: 5 * time.Minute,
1730 WriteTimeout: 5 * time.Minute,
1731 IdleTimeout: 120 * time.Second,
1732 MaxHeaderBytes: 1 << 20,
1733 }
1734
1735 ln, err := net.Listen("tcp", addr)
1736 if err != nil {
1737 errChan <- fmt.Errorf("%s server error: %w", name, err)
1738 return
1739 }
1740 close(ready)
1741
1742 if err := server.ServeTLS(ln, certFile, keyFile); err != nil && err != http.ErrServerClosed {
1743 errChan <- fmt.Errorf("%s server error: %w", name, err)
1744 }
1745 }()
1746
1747 return ready
1748}
1749
1750// SendReloadSignal sends SIGUSR1 to the running Gordon process.
1751func SendReloadSignal() error {
1752 process, _, err := findRunningProcess()
1753 if err != nil {
1754 return err
1755 }
1756
1757 if err := process.Signal(syscall.SIGUSR1); err != nil {
1758 return fmt.Errorf("failed to send reload signal: %w", err)
1759 }
1760
1761 return nil
1762}
1763
1764// getDeployRequestFile returns the path to the deploy request file.
1765func getDeployRequestFile() string {
1766 return filepath.Join(os.TempDir(), "gordon-deploy-request")
1767}
1768
1769// writeDeployRequestFile creates the deploy request file exclusively with retry.
1770// This prevents race conditions when multiple deploy commands run simultaneously.
1771func writeDeployRequestFile(path string, data []byte, timeout time.Duration) error {
1772 deadline := time.Now().Add(timeout)
1773
1774 for {
1775 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
1776 if err == nil {
1777 _, writeErr := f.Write(data)
1778 f.Close()
1779 if writeErr != nil {
1780 _ = os.Remove(path)
1781 return writeErr
1782 }
1783 return nil
1784 }
1785
1786 if os.IsExist(err) {
1787 if time.Now().After(deadline) {
1788 return fmt.Errorf("deploy request file still present after timeout; another deploy may be in progress")
1789 }
1790 time.Sleep(100 * time.Millisecond)
1791 continue
1792 }
1793
1794 return err
1795 }
1796}
1797
1798// SendDeploySignal triggers a manual deploy for a specific route via SIGUSR2.
1799// Returns the domain name on success for the caller to display.
1800func SendDeploySignal(domain string) (string, error) {
1801 deployFile := getDeployRequestFile()
1802 if err := writeDeployRequestFile(deployFile, []byte(domain), 5*time.Second); err != nil {
1803 return "", fmt.Errorf("failed to write deploy request: %w", err)
1804 }
1805
1806 // Find PID and send SIGUSR2
1807 process, _, err := findRunningProcess()
1808 if err != nil {
1809 _ = os.Remove(deployFile)
1810 return "", err
1811 }
1812
1813 if err := process.Signal(syscall.SIGUSR2); err != nil {
1814 _ = os.Remove(deployFile)
1815 return "", fmt.Errorf("failed to send deploy signal: %w", err)
1816 }
1817
1818 return domain, nil
1819}
1820
1821// readDeployRequest reads and removes the deploy request file atomically.
1822// Returns empty string if file doesn't exist (may have been consumed by another handler).
1823func readDeployRequest() (string, error) {
1824 deployFile := getDeployRequestFile()
1825
1826 // Rename to a temp file first to make the read-and-delete atomic
1827 tmpFile := deployFile + ".processing"
1828 if err := os.Rename(deployFile, tmpFile); err != nil {
1829 if os.IsNotExist(err) {
1830 return "", fmt.Errorf("deploy request file not found (may have been processed already)")
1831 }
1832 return "", fmt.Errorf("failed to acquire deploy request: %w", err)
1833 }
1834
1835 data, err := os.ReadFile(tmpFile)
1836 _ = os.Remove(tmpFile) // Always clean up
1837 if err != nil {
1838 return "", fmt.Errorf("failed to read deploy request: %w", err)
1839 }
1840
1841 return string(data), nil
1842}
1843
1844// createPidFile creates a PID file for the Gordon process.
1845// SECURITY: Prefers secure locations (XDG_RUNTIME_DIR, ~/.gordon/run) over /tmp
1846// to prevent symlink attacks and unauthorized access.
1847func createPidFile(log zerowrap.Logger) string {
1848 pid := os.Getpid()
1849
1850 // SECURITY: Prioritize secure locations over /tmp
1851 var locations []string
1852
1853 // Try secure runtime directory first
1854 if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1855 locations = append(locations, filepath.Join(runtimeDir, "gordon.pid"))
1856 }
1857
1858 // Fall back to home directory
1859 if homeDir, err := os.UserHomeDir(); err == nil {
1860 locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid"))
1861 }
1862
1863 // Last resort: /tmp (least secure due to world-writable)
1864 locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid"))
1865
1866 for _, location := range locations {
1867 // Ensure parent directory exists with secure permissions
1868 if err := os.MkdirAll(filepath.Dir(location), 0700); err != nil {
1869 continue
1870 }
1871 if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil {
1872 log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file")
1873 return location
1874 }
1875 }
1876
1877 log.Warn().Int("pid", pid).Msg("failed to create PID file in any location")
1878 return ""
1879}
1880
1881// removePidFile removes the PID file.
1882func removePidFile(pidFile string, log zerowrap.Logger) {
1883 if err := os.Remove(pidFile); err != nil {
1884 log.Warn().Err(err).Str("pid_file", pidFile).Msg("failed to remove PID file")
1885 } else {
1886 log.Debug().Str("pid_file", pidFile).Msg("removed PID file")
1887 }
1888}
1889
1890func pidFileLocations() []string {
1891 var locations []string
1892 seen := make(map[string]struct{})
1893 add := func(path string) {
1894 if path == "" {
1895 return
1896 }
1897 if _, ok := seen[path]; ok {
1898 return
1899 }
1900 seen[path] = struct{}{}
1901 locations = append(locations, path)
1902 }
1903
1904 // Check secure runtime directory first
1905 if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1906 add(filepath.Join(runtimeDir, "gordon.pid"))
1907 }
1908
1909 // Also check canonical /run/user/<uid> runtime path. This handles cases where
1910 // Gordon started under systemd user services with runtime dir available, but
1911 // CLI invocations (e.g. non-interactive SSH) don't have XDG_RUNTIME_DIR set.
1912 runtimeByUID := filepath.Join("/run/user", strconv.Itoa(os.Getuid()), "gordon", "gordon.pid")
1913 add(runtimeByUID)
1914
1915 // Check explicit XDG_RUNTIME_DIR if present in this process env.
1916 if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
1917 add(filepath.Join(runtimeDir, "gordon.pid"))
1918 }
1919
1920 // Check home directory
1921 if homeDir, err := os.UserHomeDir(); err == nil {
1922 add(filepath.Join(homeDir, ".gordon", "gordon.pid"))
1923 // Legacy location for backward compatibility
1924 add(filepath.Join(homeDir, ".gordon.pid"))
1925 }
1926
1927 // Legacy /tmp locations for backward compatibility
1928 add(filepath.Join(os.TempDir(), "gordon.pid"))
1929 add("/tmp/gordon.pid")
1930
1931 return locations
1932}
1933
1934// findRunningPidFile returns the first PID file whose PID belongs to a live process.
1935// Stale/invalid PID files are ignored and removed when possible.
1936func findRunningPidFile() (string, int, error) {
1937 return findRunningPidFileInLocations(pidFileLocations())
1938}
1939
1940func findRunningPidFileInLocations(locations []string) (string, int, error) {
1941 foundAny := false
1942
1943 for _, location := range locations {
1944 pidBytes, err := os.ReadFile(location)
1945 if err != nil {
1946 continue
1947 }
1948
1949 foundAny = true
1950
1951 var pid int
1952 if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil || pid <= 0 {
1953 _ = os.Remove(location)
1954 continue
1955 }
1956
1957 if isProcessAlive(pid) {
1958 return location, pid, nil
1959 }
1960
1961 _ = os.Remove(location)
1962 }
1963
1964 if foundAny {
1965 return "", 0, fmt.Errorf("found stale gordon PID file(s), is Gordon running?")
1966 }
1967
1968 return "", 0, fmt.Errorf("gordon PID file not found, is Gordon running?")
1969}
1970
1971func findRunningProcess() (*os.Process, int, error) {
1972 _, pid, err := findRunningPidFile()
1973 if err != nil {
1974 return nil, 0, err
1975 }
1976
1977 process, err := os.FindProcess(pid)
1978 if err != nil {
1979 return nil, 0, fmt.Errorf("failed to find process: %w", err)
1980 }
1981
1982 return process, pid, nil
1983}
1984
1985func isProcessAlive(pid int) bool {
1986 process, err := os.FindProcess(pid)
1987 if err != nil {
1988 return false
1989 }
1990
1991 err = process.Signal(syscall.Signal(0))
1992 if err == nil {
1993 return true
1994 }
1995
1996 return errors.Is(err, syscall.EPERM)
1997}
1998
1999// loadConfig loads configuration from file and sets defaults.
2000func loadConfig(v *viper.Viper, configPath string) error {
2001 v.SetDefault("server.port", 80)
2002 v.SetDefault("server.registry_port", 5000)
2003 v.SetDefault("server.tls_enabled", false)
2004 v.SetDefault("server.tls_port", 443)
2005 v.SetDefault("server.tls_cert_file", "")
2006 v.SetDefault("server.tls_key_file", "")
2007 v.SetDefault("server.data_dir", DefaultDataDir())
2008 v.SetDefault("logging.level", "info")
2009 v.SetDefault("logging.format", "console")
2010 v.SetDefault("logging.file.enabled", false)
2011 v.SetDefault("logging.file.max_size", 100)
2012 v.SetDefault("logging.file.max_backups", 3)
2013 v.SetDefault("logging.file.max_age", 28)
2014 v.SetDefault("logging.container_logs.enabled", true)
2015 v.SetDefault("logging.container_logs.dir", "")
2016 v.SetDefault("logging.container_logs.max_size", 100)
2017 v.SetDefault("logging.container_logs.max_backups", 3)
2018 v.SetDefault("logging.container_logs.max_age", 28)
2019 v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
2020 v.SetDefault("auth.enabled", true)
2021 // Note: auth.type is intentionally not set - it's inferred from config
2022 // If password_hash is set -> password mode, otherwise -> token mode
2023 v.SetDefault("auth.secrets_backend", "unsafe")
2024 v.SetDefault("auth.token_expiry", "720h")
2025 v.SetDefault("api.rate_limit.enabled", true)
2026 v.SetDefault("api.rate_limit.global_rps", 500)
2027 v.SetDefault("api.rate_limit.per_ip_rps", 50)
2028 v.SetDefault("api.rate_limit.burst", 100)
2029 v.SetDefault("auto_route.enabled", false)
2030 v.SetDefault("network_isolation.enabled", false)
2031 v.SetDefault("network_isolation.network_prefix", "gordon")
2032 v.SetDefault("network_isolation.dns_suffix", ".internal")
2033 v.SetDefault("volumes.auto_create", true)
2034 v.SetDefault("volumes.prefix", "gordon")
2035 v.SetDefault("volumes.preserve", true)
2036 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
2037 v.SetDefault("backups.enabled", false)
2038 v.SetDefault("backups.schedule", string(domain.ScheduleDaily))
2039 v.SetDefault("backups.storage_dir", "")
2040 v.SetDefault("backups.retention.hourly", 0)
2041 v.SetDefault("backups.retention.daily", 0)
2042 v.SetDefault("backups.retention.weekly", 0)
2043 v.SetDefault("backups.retention.monthly", 0)
2044 v.SetDefault("images.prune.enabled", false)
2045 v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))
2046 v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast)
2047 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
2048 v.SetDefault("server.registry_allowed_ips", []string{})
2049 v.SetDefault("deploy.readiness_delay", "5s")
2050 v.SetDefault("deploy.readiness_mode", "auto")
2051 v.SetDefault("deploy.health_timeout", "90s")
2052 v.SetDefault("deploy.drain_mode", "auto")
2053 v.SetDefault("deploy.drain_timeout", "30s")
2054
2055 ConfigureViper(v, configPath)
2056
2057 if err := v.ReadInConfig(); err != nil {
2058 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
2059 return fmt.Errorf("failed to read config file: %w", err)
2060 }
2061 }
2062
2063 v.SetEnvPrefix("GORDON")
2064 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
2065 v.AutomaticEnv()
2066
2067 return nil
2068}