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