1// Package app provides the application initialization and wiring.
2package app
3
4import (
5 "context"
6 "crypto/rand"
7 "crypto/tls"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "net"
13 "net/http"
14 "os"
15 "os/signal"
16 "path/filepath"
17 "strconv"
18 "strings"
19 "sync"
20 "syscall"
21 "time"
22
23 "github.com/bnema/zerowrap"
24 zerowrapotel "github.com/bnema/zerowrap/otel"
25 "github.com/spf13/viper"
26 "golang.org/x/sys/unix"
27
28 // Adapters - Output
29 "github.com/bnema/gordon/internal/adapters/out/accesslog"
30 "github.com/bnema/gordon/internal/adapters/out/docker"
31 "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
32 "github.com/bnema/gordon/internal/adapters/out/envloader"
33 "github.com/bnema/gordon/internal/adapters/out/eventbus"
34 "github.com/bnema/gordon/internal/adapters/out/filesystem"
35 "github.com/bnema/gordon/internal/adapters/out/httpprober"
36 "github.com/bnema/gordon/internal/adapters/out/logwriter"
37 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
38 "github.com/bnema/gordon/internal/adapters/out/ratelimit"
39 "github.com/bnema/gordon/internal/adapters/out/secrets"
40 "github.com/bnema/gordon/internal/adapters/out/telemetry"
41 "github.com/bnema/gordon/internal/adapters/out/tokenstore"
42
43 // OTel
44 "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
45
46 // Adapters - Input
47 "github.com/bnema/gordon/internal/adapters/dto"
48 "github.com/bnema/gordon/internal/adapters/in/http/admin"
49 authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth"
50 "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
51 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
52 "github.com/bnema/gordon/internal/adapters/in/http/onboarding"
53 proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy"
54 "github.com/bnema/gordon/internal/adapters/in/http/registry"
55
56 // Boundaries
57 "github.com/bnema/gordon/internal/boundaries/out"
58
59 // Domain
60 "github.com/bnema/gordon/internal/domain"
61
62 // Packages
63 "github.com/bnema/gordon/pkg/version"
64
65 // Use cases
66 "github.com/bnema/gordon/internal/usecase/auth"
67 "github.com/bnema/gordon/internal/usecase/auto"
68 "github.com/bnema/gordon/internal/usecase/auto/preview"
69 "github.com/bnema/gordon/internal/usecase/backup"
70 "github.com/bnema/gordon/internal/usecase/config"
71 "github.com/bnema/gordon/internal/usecase/container"
72 cronSvc "github.com/bnema/gordon/internal/usecase/cron"
73 "github.com/bnema/gordon/internal/usecase/health"
74 "github.com/bnema/gordon/internal/usecase/images"
75 "github.com/bnema/gordon/internal/usecase/logs"
76 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
77 "github.com/bnema/gordon/internal/usecase/proxy"
78 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
79 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
80 volumesSvc "github.com/bnema/gordon/internal/usecase/volumes"
81
82 // Pkg
83 "github.com/bnema/gordon/pkg/bytesize"
84 "github.com/bnema/gordon/pkg/duration"
85)
86
87// Config holds the application configuration.
88type Config struct {
89 Server struct {
90 Port int `mapstructure:"port"`
91 RegistryPort int `mapstructure:"registry_port"`
92 GordonDomain string `mapstructure:"gordon_domain"`
93 TLSPort int `mapstructure:"tls_port"`
94 TLSCertFile string `mapstructure:"tls_cert_file"`
95 TLSKeyFile string `mapstructure:"tls_key_file"`
96 ForceHTTPSRedirect bool `mapstructure:"force_https_redirect"`
97 DataDir string `mapstructure:"data_dir"`
98 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
99 MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
100 MaxBlobSize string `mapstructure:"max_blob_size"` // e.g., "1GB", "2GB"
101 MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
102 MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`
103 RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`
104 ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"`
105 RegistryListenAddr string `mapstructure:"registry_listen_address"`
106 } `mapstructure:"server"`
107
108 Logging struct {
109 Level string `mapstructure:"level"`
110 Format string `mapstructure:"format"`
111 File struct {
112 Enabled bool `mapstructure:"enabled"`
113 Path string `mapstructure:"path"`
114 MaxSize int `mapstructure:"max_size"`
115 MaxBackups int `mapstructure:"max_backups"`
116 MaxAge int `mapstructure:"max_age"`
117 } `mapstructure:"file"`
118 ContainerLogs struct {
119 Enabled bool `mapstructure:"enabled"`
120 Dir string `mapstructure:"dir"`
121 MaxSize int `mapstructure:"max_size"`
122 MaxBackups int `mapstructure:"max_backups"`
123 MaxAge int `mapstructure:"max_age"`
124 } `mapstructure:"container_logs"`
125 AccessLog struct {
126 Enabled bool `mapstructure:"enabled"`
127 Format string `mapstructure:"format"`
128 Output string `mapstructure:"output"`
129 FilePath string `mapstructure:"file_path"`
130 MaxSize int `mapstructure:"max_size"`
131 MaxBackups int `mapstructure:"max_backups"`
132 MaxAge int `mapstructure:"max_age"`
133 ExcludeHealthChecks bool `mapstructure:"exclude_health_checks"`
134 SyslogIdentifier string `mapstructure:"syslog_identifier"`
135 } `mapstructure:"access_log"`
136 } `mapstructure:"logging"`
137
138 Env struct {
139 Dir string `mapstructure:"dir"`
140 } `mapstructure:"env"`
141
142 Auth struct {
143 Enabled bool `mapstructure:"enabled"`
144 Type string `mapstructure:"type"` // only "token" is supported
145 SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe"
146 Username string `mapstructure:"username"`
147 TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
148 TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
149 AccessTokenTTL string `mapstructure:"access_token_ttl"` // e.g., "15m", "30m" (default: 15m)
150 } `mapstructure:"auth"`
151
152 API struct {
153 RateLimit struct {
154 Enabled bool `mapstructure:"enabled"`
155 GlobalRPS float64 `mapstructure:"global_rps"`
156 PerIPRPS float64 `mapstructure:"per_ip_rps"`
157 Burst int `mapstructure:"burst"`
158 TrustedProxies []string `mapstructure:"trusted_proxies"`
159 } `mapstructure:"rate_limit"`
160 } `mapstructure:"api"`
161
162 Backups struct {
163 Enabled bool `mapstructure:"enabled"`
164 Schedule string `mapstructure:"schedule"`
165 StorageDir string `mapstructure:"storage_dir"`
166 Retention struct {
167 Hourly int `mapstructure:"hourly"`
168 Daily int `mapstructure:"daily"`
169 Weekly int `mapstructure:"weekly"`
170 Monthly int `mapstructure:"monthly"`
171 } `mapstructure:"retention"`
172 } `mapstructure:"backups"`
173
174 Images struct {
175 AllowedRegistries []string `mapstructure:"allowed_registries"`
176 RequireDigest bool `mapstructure:"require_digest"`
177 Prune struct {
178 Enabled bool `mapstructure:"enabled"`
179 Schedule string `mapstructure:"schedule"`
180 KeepLast int `mapstructure:"keep_last"`
181 } `mapstructure:"prune"`
182 } `mapstructure:"images"`
183
184 Containers struct {
185 MemoryLimit string `mapstructure:"memory_limit"` // e.g., "512MB", "1GB"
186 CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core
187 PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512
188 SecurityProfile string `mapstructure:"security_profile"` // compat or strict
189 } `mapstructure:"containers"`
190
191 Telemetry telemetry.Config `mapstructure:"telemetry"`
192}
193
194// services holds all the services used by the application.
195type services struct {
196 runtime *docker.Runtime
197 eventBus *eventbus.InMemory
198 blobStorage *filesystem.BlobStorage
199 manifestStorage *filesystem.ManifestStorage
200 backupStorage *filesystem.BackupStorage
201 envLoader out.EnvLoader
202 logWriter *logwriter.LogWriter
203 tokenStore out.TokenStore
204 configSvc *config.Service
205 secretSvc *secretsSvc.Service
206 containerSvc *container.Service
207 backupSvc *backup.Service
208 registrySvc *registrySvc.Service
209 healthSvc *health.Service
210 logSvc *logs.Service
211 imageSvc *images.Service
212 volumeSvc *volumesSvc.Service
213 proxySvc *proxy.Service
214 authSvc *auth.Service
215 authHandler *authhandler.Handler
216 adminHandler *admin.Handler
217 internalRegUser string
218 internalRegPass string
219 previewStore *filesystem.PreviewStore
220 previewService *preview.Service
221 envDir string
222 maxBlobChunkSize int64
223 maxBlobSize int64
224 caAdapter *pkiadapter.CA
225 pkiSvc *pkiusecase.Service
226 reloadCoordinator *reloadCoordinator
227 registryHandler interface {
228 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
229 }
230}
231
232// Run initializes and starts the Gordon application.
233func Run(ctx context.Context, configPath string) error {
234 // Load configuration
235 v, cfg, err := initConfig(configPath)
236 if err != nil {
237 return err
238 }
239
240 // Initialize logger
241 log, cleanup, err := initLogger(cfg)
242 if err != nil {
243 return err
244 }
245 if cleanup != nil {
246 defer cleanup()
247 }
248
249 ctx = zerowrap.WithCtx(ctx, log)
250
251 // Initialize OpenTelemetry
252 telProvider, telShutdown, err := telemetry.NewProvider(ctx, cfg.Telemetry, "gordon", version.Version())
253 if err != nil {
254 log.Warn().Err(err).Msg("failed to initialize telemetry, continuing without it")
255 } else {
256 // Use a fresh context for shutdown so a canceled app ctx doesn't prevent flushing.
257 defer func() {
258 shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
259 defer cancel()
260 telShutdown(shutdownCtx)
261 }()
262 if cfg.Telemetry.Enabled && cfg.Telemetry.Endpoint != "" {
263 // Bridge zerowrap logs to OTel if log export is enabled
264 if cfg.Telemetry.Logs && telProvider.LogProvider != nil {
265 otelHook := zerowrapotel.NewHookWithProvider(telProvider.LogProvider, "gordon")
266 log = zerowrap.WithHook(log, otelHook)
267 ctx = zerowrap.WithCtx(ctx, log)
268 }
269 log.Info().Str("endpoint", cfg.Telemetry.Endpoint).Msg("telemetry initialized")
270 }
271 }
272
273 log.Info().Msg("Gordon starting")
274
275 warnDeprecatedConfigKeys(v, log)
276
277 // Create PID file
278 pidFile := createPidFile(log)
279 if pidFile != "" {
280 defer removePidFile(pidFile, log)
281 }
282
283 // Create all services
284 svc, err := createServices(ctx, v, cfg, log)
285 if err != nil {
286 return err
287 }
288
289 // Register event handlers
290 cleanupHandlers, err := registerEventHandlers(ctx, svc, cfg)
291 if err != nil {
292 return err
293 }
294 // cleanupHandlers is passed into runServers so it can stop debounce
295 // timers before graceful shutdown, preventing deploys during drain.
296
297 // Set up config hot reload
298 if err := setupConfigHotReload(ctx, svc.configSvc, svc.reloadCoordinator); err != nil {
299 return err
300 }
301
302 // Start event bus
303 if err := svc.eventBus.Start(); err != nil {
304 return log.WrapErr(err, "failed to start event bus")
305 }
306 defer svc.eventBus.Stop()
307
308 // Start servers, wait for listeners to bind, then sync/auto-start containers.
309 return runServers(ctx, v, cfg, svc, svc.reloadCoordinator, cleanupHandlers, log)
310}
311
312func warnDeprecatedConfigKeys(v *viper.Viper, log zerowrap.Logger) {
313 for _, key := range []string{"server.tls_enabled", "server.force_hsts"} {
314 if v.IsSet(key) {
315 log.Warn().Str("key", key).Msg("deprecated config key — Gordon now uses an internal CA with automatic TLS; remove this from your config")
316 }
317 }
318}
319
320// initConfig loads configuration from file.
321func initConfig(configPath string) (*viper.Viper, Config, error) {
322 v := viper.New()
323 if err := loadConfig(v, configPath); err != nil {
324 return nil, Config{}, fmt.Errorf("failed to load config: %w", err)
325 }
326
327 var cfg Config
328 if err := v.Unmarshal(&cfg); err != nil {
329 return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
330 }
331
332 return v, cfg, nil
333}
334
335// initLogger initializes the zerowrap logger.
336func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
337 logConfig := zerowrap.Config{
338 Level: cfg.Logging.Level,
339 Format: cfg.Logging.Format,
340 }
341
342 // Always enable file logging so admin API can read process logs
343 // (Config flag is respected for backward-compatibility)
344 logPath := cfg.Logging.File.Path
345 if logPath == "" {
346 dataDir := cfg.Server.DataDir
347 if dataDir == "" {
348 dataDir = DefaultDataDir()
349 }
350 logPath = filepath.Join(dataDir, "logs", "gordon.log")
351 }
352
353 log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{
354 Enabled: cfg.Logging.File.Enabled,
355 Path: logPath,
356 MaxSize: cfg.Logging.File.MaxSize,
357 MaxBackups: cfg.Logging.File.MaxBackups,
358 MaxAge: cfg.Logging.File.MaxAge,
359 Compress: true,
360 })
361 if err != nil {
362 return zerowrap.Default(), nil, fmt.Errorf("failed to create logger with file: %w", err)
363 }
364 return log, cleanup, nil
365}
366
367// initAccessLog creates an access log writer when access logging is enabled.
368// Returns nil, nil when disabled — callers must treat nil writer as "disabled".
369func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) {
370 if !cfg.Logging.AccessLog.Enabled {
371 return nil, nil
372 }
373
374 filePath := cfg.Logging.AccessLog.FilePath
375 if filePath == "" && cfg.Logging.AccessLog.Output == "file" {
376 dataDir := cfg.Server.DataDir
377 if dataDir == "" {
378 dataDir = DefaultDataDir()
379 }
380 filePath = filepath.Join(dataDir, "logs", "access.log")
381 }
382
383 writer, err := accesslog.New(accesslog.Config{
384 Format: cfg.Logging.AccessLog.Format,
385 Output: cfg.Logging.AccessLog.Output,
386 FilePath: filePath,
387 MaxSize: cfg.Logging.AccessLog.MaxSize,
388 MaxBackups: cfg.Logging.AccessLog.MaxBackups,
389 MaxAge: cfg.Logging.AccessLog.MaxAge,
390 SyslogIdentifier: cfg.Logging.AccessLog.SyslogIdentifier,
391 })
392 if err != nil {
393 return nil, fmt.Errorf("failed to initialize access log: %w", err)
394 }
395
396 log.Info().
397 Str("format", cfg.Logging.AccessLog.Format).
398 Str("output", cfg.Logging.AccessLog.Output).
399 Msg("access log enabled")
400
401 return writer, nil
402}
403
404// serviceInit holds the shared context for service initialization helpers.
405type serviceInit struct {
406 ctx context.Context
407 v *viper.Viper
408 cfg Config
409 log zerowrap.Logger
410 svc *services
411}
412
413// createServices creates all the application services.
414func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) {
415 si := &serviceInit{
416 ctx: ctx,
417 v: v,
418 cfg: cfg,
419 log: log,
420 svc: &services{},
421 }
422 defer func() {
423 if retErr != nil && si.svc.pkiSvc != nil {
424 si.svc.pkiSvc.Stop()
425 }
426 }()
427 var err error
428
429 // Create output adapters
430 runtimeSocket := resolveRuntimeConfig(v.GetString("server.runtime"))
431 if si.svc.runtime, si.svc.eventBus, err = createOutputAdapters(ctx, log, runtimeSocket); err != nil {
432 return nil, err
433 }
434
435 // Create storage
436 if si.svc.blobStorage, si.svc.manifestStorage, err = createStorage(cfg, log); err != nil {
437 return nil, err
438 }
439
440 // Create log writer
441 if si.svc.logWriter, err = createLogWriter(cfg, log); err != nil {
442 return nil, err
443 }
444
445 // Create auth service (if enabled)
446 if si.svc.tokenStore, si.svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil {
447 return nil, err
448 }
449
450 if err := setupInternalRegistryAuth(si.svc, log); err != nil {
451 return nil, err
452 }
453
454 // Create config service
455 si.svc.configSvc = config.NewService(v, si.svc.eventBus)
456 if err := si.svc.configSvc.Load(ctx); err != nil {
457 return nil, log.WrapErr(err, "failed to load configuration")
458 }
459
460 if err := si.initPKI(); err != nil {
461 return nil, err
462 }
463
464 if err := si.initSecrets(); err != nil {
465 return nil, err
466 }
467
468 if err := si.initRuntimeAndProxy(); err != nil {
469 return nil, err
470 }
471
472 si.svc.reloadCoordinator = newReloadCoordinator(v, si.svc.configSvc, si.svc.proxySvc, nil, si.svc.eventBus, log)
473
474 si.initHandlers()
475
476 return si.svc, nil
477}
478
479// initPKI initialises the internal CA and PKI service when TLS is enabled.
480func (si *serviceInit) initPKI() error {
481 if si.cfg.Server.TLSPort == 0 {
482 si.log.Info().Msg("internal CA disabled (server.tls_port=0)")
483 return nil
484 }
485
486 if (si.cfg.Server.TLSCertFile == "") != (si.cfg.Server.TLSKeyFile == "") {
487 return fmt.Errorf("both tls_cert_file and tls_key_file must be set, or neither")
488 }
489
490 caAdapter, err := pkiadapter.NewCA(resolveDataDir(si.cfg.Server.DataDir), si.log)
491 if err != nil {
492 return si.log.WrapErr(err, "failed to initialize internal CA")
493 }
494 si.svc.caAdapter = caAdapter
495 si.svc.pkiSvc = pkiusecase.NewService(si.ctx, caAdapter, si.svc.configSvc, si.log)
496 return nil
497}
498
499// initSecrets creates the domain secret store, env loader, and secret service.
500func (si *serviceInit) initSecrets() error {
501 envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log)
502 if err != nil {
503 return err
504 }
505 si.svc.envDir = envDir
506
507 if si.svc.envLoader, err = createEnvLoader(backend, envDir, passStore, si.log); err != nil {
508 return err
509 }
510
511 si.svc.secretSvc = secretsSvc.NewService(domainSecretStore, si.log, si.svc.eventBus)
512 return nil
513}
514
515// initRuntimeAndProxy creates container, backup, registry, image, volume, and proxy services.
516func (si *serviceInit) initRuntimeAndProxy() error {
517 var err error
518
519 if si.svc.containerSvc, err = createContainerService(si.ctx, si.v, si.cfg, si.svc, si.log); err != nil {
520 return err
521 }
522
523 if si.svc.backupStorage, si.svc.backupSvc, err = createBackupService(si.cfg, si.svc, si.log); err != nil {
524 return err
525 }
526
527 si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus)
528 si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log)
529 si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime)
530
531 injectTelemetryMetrics(si.cfg, si.svc, si.log)
532
533 proxyCfg, err := buildProxyConfig(si.cfg, si.log)
534 if err != nil {
535 return err
536 }
537 si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
538 si.svc.maxBlobSize = proxyCfg.maxBlobSize
539 si.svc.proxySvc = proxy.NewService(si.svc.runtime, si.svc.containerSvc, si.svc.configSvc, proxyCfg.proxyConfig)
540
541 // Wire synchronous proxy cache invalidation for zero-downtime deployments.
542 // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget().
543 si.svc.containerSvc.SetProxyCacheInvalidator(si.svc.proxySvc)
544 si.svc.containerSvc.SetProxyDrainWaiter(si.svc.proxySvc)
545 return nil
546}
547
548// initHandlers creates the auth, health, log, preview, and admin handlers.
549func (si *serviceInit) initHandlers() {
550 if si.svc.authSvc != nil {
551 internalAuth := authhandler.InternalAuth{
552 Username: si.svc.internalRegUser,
553 Password: si.svc.internalRegPass,
554 }
555 si.svc.authHandler = authhandler.NewHandler(si.svc.authSvc, internalAuth, si.log)
556 }
557
558 prober := httpprober.New()
559 si.svc.healthSvc = health.NewService(si.svc.configSvc, si.svc.containerSvc, prober, si.log)
560
561 si.svc.logSvc = logs.NewService(resolveLogFilePath(si.cfg), si.cfg.Logging.File.Enabled, si.svc.containerSvc, si.svc.runtime, si.log)
562
563 initPreviewService(si.ctx, si.cfg, si.svc, si.log)
564
565 si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{
566 ConfigSvc: si.svc.configSvc,
567 AuthSvc: si.svc.authSvc,
568 ContainerSvc: si.svc.containerSvc,
569 HealthSvc: si.svc.healthSvc,
570 SecretSvc: si.svc.secretSvc,
571 LogSvc: si.svc.logSvc,
572 RegistrySvc: si.svc.registrySvc,
573 ReloadTrigger: si.svc.reloadCoordinator,
574 Log: si.log,
575 BackupSvc: si.svc.backupSvc,
576 PreviewSvc: si.svc.previewService,
577 ImageSvc: si.svc.imageSvc,
578 VolumeSvc: si.svc.volumeSvc,
579 })
580}
581
582// initPreviewService sets up the preview store, service, and TTL ticker.
583func initPreviewService(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) {
584 previewStorePath := filepath.Join(resolveDataDir(cfg.Server.DataDir), "previews.json")
585 svc.previewStore = filesystem.NewPreviewStore(previewStorePath)
586 previewConfig := svc.configSvc.GetPreviewConfig()
587 svc.previewService = preview.NewService(svc.previewStore, previewConfig.TTL).
588 WithDeployer(svc.containerSvc).
589 WithRouteManager(svc.configSvc).
590 WithVolumeCloner(svc.runtime).
591 WithRegistryDomain(svc.configSvc.GetRegistryDomain()).
592 WithEnvLoader(svc.envLoader)
593 if err := svc.previewService.Load(ctx); err != nil {
594 log.Warn().Err(err).Msg("failed to load previews")
595 }
596
597 // Derive sweep interval from TTL: half the TTL, capped at 1 hour, minimum 1 minute.
598 sweepInterval := previewConfig.TTL / 2
599 if sweepInterval > 1*time.Hour {
600 sweepInterval = 1 * time.Hour
601 }
602 if sweepInterval < 1*time.Minute {
603 sweepInterval = 1 * time.Minute
604 }
605
606 svc.previewService.StartTicker(ctx, sweepInterval, func(ctx context.Context, p domain.PreviewRoute) {
607 teardownTrackedPreview(ctx, svc, p)
608 }, func(ctx context.Context) {
609 gcOrphanedPreviews(ctx, svc, previewConfig)
610 })
611}
612
613// teardownTrackedPreview removes all resources for a tracked preview that has expired.
614func teardownTrackedPreview(ctx context.Context, svc *services, p domain.PreviewRoute) {
615 log := zerowrap.FromCtx(ctx)
616 for _, containerName := range p.Containers {
617 if err := svc.runtime.StopContainer(ctx, containerName); err != nil {
618 log.Warn().Err(err).Str("container", containerName).Str("preview", p.Domain).Msg("failed to stop preview container")
619 }
620 if err := svc.runtime.RemoveContainer(ctx, containerName, true); err != nil {
621 log.Warn().Err(err).Str("container", containerName).Str("preview", p.Domain).Msg("failed to remove preview container")
622 }
623 }
624 for _, volName := range p.Volumes {
625 if err := svc.runtime.RemoveVolume(ctx, volName, true); err != nil {
626 log.Warn().Err(err).Str("volume", volName).Str("preview", p.Domain).Msg("failed to remove preview volume")
627 }
628 }
629 // Remove network (naming convention: {networkPrefix}-{domain-sanitized}).
630 networkPrefix := svc.configSvc.GetNetworkPrefix()
631 networkName := networkPrefix + "-" + strings.ReplaceAll(p.Domain, ".", "-")
632 if err := svc.runtime.RemoveNetwork(ctx, networkName); err != nil {
633 log.Warn().Err(err).Str("network", networkName).Str("preview", p.Domain).Msg("failed to remove preview network")
634 }
635 // Remove route from config so proxy stops routing to this domain.
636 if err := svc.configSvc.RemoveRoute(ctx, p.Domain); err != nil {
637 if errors.Is(err, domain.ErrRouteNotFound) {
638 log.Debug().Str("domain", p.Domain).Msg("preview route already removed from config")
639 } else {
640 log.Warn().Err(err).Str("domain", p.Domain).Msg("failed to remove preview route from config")
641 }
642 }
643 svc.proxySvc.InvalidateTarget(ctx, p.Domain)
644}
645
646// gcOrphanedPreviews finds and tears down untracked preview containers.
647func gcOrphanedPreviews(ctx context.Context, svc *services, previewConfig domain.PreviewConfig) {
648 log := zerowrap.FromCtx(ctx)
649 orphans := svc.previewService.CollectOrphans(ctx, svc.runtime, previewConfig.TagPatterns, previewConfig.Separator)
650 for _, c := range orphans {
651 orphanDomain := c.Labels[domain.LabelDomain]
652 log.Warn().Str("container", c.Name).Str("image", c.Image).Str("domain", orphanDomain).
653 Time("created", c.Created).Msg("orphaned preview container detected, cleaning up")
654
655 // Order: stop → remove volumes/route → remove container → remove network.
656 // Network removal must happen after container removal because the runtime
657 // refuses to delete a network that still has containers attached.
658 if err := svc.runtime.StopContainer(ctx, c.Name); err != nil {
659 log.Warn().Err(err).Str("container", c.Name).Msg("failed to stop orphan container")
660 }
661
662 if orphanDomain != "" {
663 if err := cleanupOrphanDomainResources(ctx, svc, orphanDomain); err != nil {
664 log.Warn().Err(err).Str("container", c.Name).Str("domain", orphanDomain).
665 Msg("orphan resource cleanup failed, deferring container removal to next scan")
666 continue
667 }
668 }
669
670 if err := svc.runtime.RemoveContainer(ctx, c.Name, true); err != nil {
671 log.Warn().Err(err).Str("container", c.Name).Msg("failed to remove orphan container")
672 }
673
674 // Remove network after container is gone.
675 if orphanDomain != "" {
676 if err := removeOrphanNetwork(ctx, svc, strings.ReplaceAll(orphanDomain, ".", "-"), log); err != nil {
677 log.Warn().Err(err).Str("container", c.Name).Msg("failed to remove orphan network after container removal")
678 }
679 }
680
681 log.Info().Str("container", c.Name).Str("domain", orphanDomain).Msg("orphaned preview container cleaned up")
682 }
683
684 // Re-sync container state so stale routes disappear from routes list.
685 if len(orphans) > 0 {
686 if err := svc.containerSvc.SyncContainers(ctx); err != nil {
687 log.Warn().Err(err).Msg("failed to sync containers after orphan GC")
688 }
689 }
690}
691
692// cleanupOrphanDomainResources removes volumes and route for an orphaned
693// preview domain. Network removal is handled separately in gcOrphanedPreviews
694// after the container is removed. Returns an error if any step fails so the
695// caller can defer container removal until the next scan.
696func cleanupOrphanDomainResources(ctx context.Context, svc *services, orphanDomain string) error {
697 log := zerowrap.FromCtx(ctx)
698 domainSanitized := strings.ReplaceAll(orphanDomain, ".", "-")
699 var errs []error
700
701 errs = append(errs, removeOrphanVolumes(ctx, svc, domainSanitized, log)...)
702 errs = append(errs, removeOrphanRoute(ctx, svc, orphanDomain, log))
703 svc.proxySvc.InvalidateTarget(ctx, orphanDomain)
704
705 return errors.Join(errs...)
706}
707
708func removeOrphanVolumes(ctx context.Context, svc *services, domainSanitized string, log zerowrap.Logger) []error {
709 _, volPrefix, _ := svc.configSvc.GetVolumeConfig()
710 prefix := volPrefix + "-" + domainSanitized + "-"
711
712 volumes, err := svc.runtime.ListVolumes(ctx)
713 if err != nil {
714 log.Warn().Err(err).Msg("failed to list volumes for orphan cleanup")
715 return []error{err}
716 }
717
718 var errs []error
719 for _, v := range volumes {
720 if !strings.HasPrefix(v.Name, prefix) {
721 continue
722 }
723 if err := svc.runtime.RemoveVolume(ctx, v.Name, true); err != nil {
724 log.Warn().Err(err).Str("volume", v.Name).Msg("failed to remove orphan volume")
725 errs = append(errs, err)
726 }
727 }
728 return errs
729}
730
731func removeOrphanNetwork(ctx context.Context, svc *services, domainSanitized string, log zerowrap.Logger) error {
732 name := svc.configSvc.GetNetworkPrefix() + "-" + domainSanitized
733 if err := svc.runtime.RemoveNetwork(ctx, name); err != nil {
734 log.Warn().Err(err).Str("network", name).Msg("failed to remove orphan network")
735 return err
736 }
737 return nil
738}
739
740func removeOrphanRoute(ctx context.Context, svc *services, orphanDomain string, log zerowrap.Logger) error {
741 err := svc.configSvc.RemoveRoute(ctx, orphanDomain)
742 if err == nil || errors.Is(err, domain.ErrRouteNotFound) {
743 return nil
744 }
745 log.Warn().Err(err).Str("domain", orphanDomain).Msg("failed to remove orphan route")
746 return err
747}
748
749// injectTelemetryMetrics creates and injects OTel metrics into services when
750// telemetry is enabled. Skipped otherwise to avoid unnecessary allocations.
751func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) {
752 if !cfg.Telemetry.Enabled || !cfg.Telemetry.Metrics {
753 return
754 }
755 gordonMetrics, err := telemetry.NewMetrics()
756 if err != nil {
757 log.Warn().Err(err).Msg("failed to create telemetry metrics, continuing without metrics")
758 return
759 }
760 svc.containerSvc.SetMetrics(gordonMetrics)
761 svc.registrySvc.SetMetrics(gordonMetrics)
762 svc.eventBus.SetMetrics(gordonMetrics)
763}
764
765func setupInternalRegistryAuth(svc *services, log zerowrap.Logger) error {
766 var err error
767 svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
768 if err != nil {
769 return log.WrapErr(err, "failed to generate internal registry credentials")
770 }
771
772 // Persist credentials to file for CLI access (gordon auth internal)
773 if err := persistInternalCredentials(svc.internalRegUser, svc.internalRegPass); err != nil {
774 log.Warn().Err(err).Msg("failed to persist internal credentials for CLI access")
775 }
776
777 log.Debug().Msg("internal registry auth generated for loopback pulls")
778 return nil
779}
780
781func createDomainSecretStore(cfg Config, log zerowrap.Logger) (string, domain.SecretsBackend, *domainsecrets.PassStore, out.DomainSecretStore, error) {
782 envDir := resolveEnvDir(cfg)
783 backend, err := resolveSecretsBackend(cfg.Auth.SecretsBackend)
784 if err != nil {
785 return "", "", nil, nil, log.WrapErr(err, "failed to resolve secrets backend")
786 }
787
788 switch backend {
789 case domain.SecretsBackendPass:
790 passStore, err := domainsecrets.NewPassStore(log)
791 if err != nil {
792 return "", backend, nil, nil, log.WrapErr(err, "failed to create pass domain secret store")
793 }
794 if err := migrateEnvFilesToPass(envDir, passStore, log); err != nil {
795 return "", backend, nil, nil, log.WrapErr(err, "failed to migrate env files to pass")
796 }
797 return envDir, backend, passStore, passStore, nil
798 default:
799 store, err := domainsecrets.NewFileStore(envDir, log)
800 if err != nil {
801 return "", backend, nil, nil, log.WrapErr(err, "failed to create domain secret store")
802 }
803 return envDir, backend, nil, store, nil
804 }
805}
806
807// resolveLogFilePath returns the configured log file path or a default.
808func resolveLogFilePath(cfg Config) string {
809 if cfg.Logging.File.Path != "" {
810 return cfg.Logging.File.Path
811 }
812 dataDir := cfg.Server.DataDir
813 if dataDir == "" {
814 dataDir = DefaultDataDir()
815 }
816 return filepath.Join(dataDir, "logs", "gordon.log")
817}
818
819// resolveRuntimeConfig converts a server.runtime config value to a socket path.
820// "auto" or "" means auto-detect.
821// Named runtimes ("podman", "docker") are resolved to well-known socket paths.
822// URI schemes (unix://) are stripped so callers receive a bare path.
823func resolveRuntimeConfig(value string) string {
824 if value == "" || value == "auto" {
825 return ""
826 }
827 // Named runtimes: resolve to well-known socket paths.
828 switch value {
829 case "podman":
830 // Check XDG_RUNTIME_DIR first (rootless Podman).
831 if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
832 candidate := filepath.Join(xdg, "podman", "podman.sock")
833 if _, err := os.Stat(candidate); err == nil {
834 return candidate
835 }
836 }
837 // Fallback to system-wide Podman socket.
838 return "/run/podman/podman.sock"
839 case "docker":
840 return "/var/run/docker.sock"
841 }
842 // Explicit socket path — strip URI scheme if present.
843 if strings.HasPrefix(value, "unix://") {
844 return strings.TrimPrefix(value, "unix://")
845 }
846 return value
847}
848
849// createOutputAdapters creates the container runtime and event bus.
850func createOutputAdapters(ctx context.Context, log zerowrap.Logger, runtimeSocket string) (*docker.Runtime, *eventbus.InMemory, error) {
851 detection := docker.DetectRuntimeSocket(runtimeSocket)
852
853 var runtime *docker.Runtime
854 var err error
855
856 switch detection.Source {
857 case "none":
858 return nil, nil, fmt.Errorf("no container runtime found: checked Docker socket, Podman socket, DOCKER_HOST env var. Install Docker or Podman, or set server.runtime in config")
859 case "DOCKER_HOST_passthrough":
860 detection.RuntimeName = "docker"
861 runtime, err = docker.NewRuntime()
862 default:
863 if detection.SocketPath != "" {
864 runtime, err = docker.NewRuntimeWithSocket(detection.SocketPath)
865 } else {
866 runtime, err = docker.NewRuntime()
867 }
868 }
869 if err != nil {
870 return nil, nil, log.WrapErr(err, "failed to create container runtime")
871 }
872
873 if err := runtime.Ping(ctx); err != nil {
874 return nil, nil, log.WrapErr(err, fmt.Sprintf("container runtime not available (detected: %s via %s)", detection.RuntimeName, detection.Source))
875 }
876
877 runtimeVersion, _ := runtime.Version(ctx)
878 log.Info().
879 Str("runtime", detection.RuntimeName).
880 Str("version", runtimeVersion).
881 Str("source", detection.Source).
882 Msg("container runtime initialized")
883
884 eventBus := eventbus.NewInMemory(100, log)
885
886 return runtime, eventBus, nil
887}
888
889// createStorage creates blob and manifest storage.
890func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *filesystem.ManifestStorage, error) {
891 dataDir := cfg.Server.DataDir
892 if dataDir == "" {
893 dataDir = DefaultDataDir()
894 }
895
896 registryDir := filepath.Join(dataDir, "registry")
897
898 blobStorage, err := filesystem.NewBlobStorage(registryDir, log)
899 if err != nil {
900 return nil, nil, log.WrapErr(err, "failed to create blob storage")
901 }
902
903 manifestStorage, err := filesystem.NewManifestStorage(registryDir, log)
904 if err != nil {
905 return nil, nil, log.WrapErr(err, "failed to create manifest storage")
906 }
907
908 return blobStorage, manifestStorage, nil
909}
910
911// createEnvLoader creates the environment loader with secret providers.
912func createEnvLoader(backend domain.SecretsBackend, envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) (out.EnvLoader, error) {
913 switch backend {
914 case domain.SecretsBackendPass:
915 loader, err := envloader.NewPassLoader(passStore, log)
916 if err != nil {
917 return nil, log.WrapErr(err, "failed to create pass env loader")
918 }
919 return loader, nil
920 default:
921 loader, err := envloader.NewFileLoader(envDir, log)
922 if err != nil {
923 return nil, log.WrapErr(err, "failed to create env loader")
924 }
925
926 // Register secret providers
927 passProvider := secrets.NewPassProvider(log)
928 if passProvider.IsAvailable() {
929 loader.RegisterSecretProvider(passProvider)
930 log.Debug().Msg("pass secret provider registered")
931 }
932
933 sopsProvider := secrets.NewSopsProvider(log)
934 if sopsProvider.IsAvailable() {
935 loader.RegisterSecretProvider(sopsProvider)
936 log.Debug().Msg("sops secret provider registered")
937 }
938
939 return loader, nil
940 }
941}
942
943// createLogWriter creates the container log writer.
944func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, error) {
945 if !cfg.Logging.ContainerLogs.Enabled {
946 log.Debug().Msg("container log collection disabled")
947 return nil, nil
948 }
949
950 // Determine log directory
951 logDir := cfg.Logging.ContainerLogs.Dir
952 if logDir == "" {
953 dataDir := cfg.Server.DataDir
954 if dataDir == "" {
955 dataDir = DefaultDataDir()
956 }
957 logDir = filepath.Join(dataDir, "logs", "containers")
958 }
959
960 writer, err := logwriter.New(logwriter.Config{
961 Dir: logDir,
962 MaxSize: cfg.Logging.ContainerLogs.MaxSize,
963 MaxBackups: cfg.Logging.ContainerLogs.MaxBackups,
964 MaxAge: cfg.Logging.ContainerLogs.MaxAge,
965 })
966 if err != nil {
967 return nil, log.WrapErr(err, "failed to create container log writer")
968 }
969
970 log.Info().Str("dir", logDir).Msg("container log collection enabled")
971 return writer, nil
972}
973
974const (
975 internalRegistryUsername = "gordon-internal"
976 serviceTokenSubject = "gordon-service"
977 serviceTokenDefaultTTL = 30 * 24 * time.Hour
978)
979
980func generateInternalRegistryAuth() (string, string, error) {
981 password, err := randomTokenHex(32)
982 if err != nil {
983 return "", "", err
984 }
985 return internalRegistryUsername, password, nil
986}
987
988func randomTokenHex(size int) (string, error) {
989 buf := make([]byte, size)
990 if _, err := rand.Read(buf); err != nil {
991 return "", err
992 }
993 return hex.EncodeToString(buf), nil
994}
995
996// InternalCredentials holds the internal registry credentials for CLI access.
997type InternalCredentials struct {
998 Username string `json:"username"`
999 Password string `json:"password"`
1000}
1001
1002// getSecureRuntimeDir returns a secure directory for runtime files.
1003// Priority: XDG_RUNTIME_DIR > ~/.gordon/run
1004func getSecureRuntimeDir() (string, error) {
1005 // Try XDG_RUNTIME_DIR first (typically /run/user/<uid> on Linux)
1006 if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
1007 gordonDir := filepath.Join(runtimeDir, "gordon")
1008 if err := os.MkdirAll(gordonDir, 0700); err == nil {
1009 return gordonDir, nil
1010 }
1011 }
1012
1013 // Fall back to ~/.gordon/run
1014 homeDir, err := os.UserHomeDir()
1015 if err != nil {
1016 return "", fmt.Errorf("failed to get home directory: %w", err)
1017 }
1018
1019 gordonDir := filepath.Join(homeDir, ".gordon", "run")
1020 if err := os.MkdirAll(gordonDir, 0700); err != nil {
1021 return "", fmt.Errorf("failed to create runtime directory: %w", err)
1022 }
1023
1024 return gordonDir, nil
1025}
1026
1027// getInternalCredentialsFile returns the path to the internal credentials file.
1028// SECURITY: Credentials are stored in a secure location with restricted permissions.
1029func getInternalCredentialsFile() string {
1030 runtimeDir, err := getSecureRuntimeDir()
1031 if err != nil {
1032 // Fall back to temp dir if we can't get secure dir (shouldn't happen)
1033 return filepath.Join(os.TempDir(), "gordon-internal-creds.json")
1034 }
1035 return filepath.Join(runtimeDir, "internal-creds.json")
1036}
1037
1038// persistInternalCredentials saves the internal registry credentials to a secure file.
1039// SECURITY: Credentials are stored in XDG_RUNTIME_DIR or ~/.gordon/run with 0600 permissions.
1040// The file is cleaned up on graceful shutdown but may persist if Gordon crashes.
1041// These credentials are for internal loopback communication only and are regenerated on each start.
1042func persistInternalCredentials(username, password string) error {
1043 creds := InternalCredentials{
1044 Username: username,
1045 Password: password,
1046 }
1047 data, err := json.Marshal(creds)
1048 if err != nil {
1049 return fmt.Errorf("failed to marshal credentials: %w", err)
1050 }
1051
1052 credFile := getInternalCredentialsFile()
1053
1054 // Ensure parent directory exists with secure permissions
1055 if err := os.MkdirAll(filepath.Dir(credFile), 0700); err != nil {
1056 return fmt.Errorf("failed to create credentials directory: %w", err)
1057 }
1058
1059 // Write file with restrictive permissions (owner read/write only)
1060 if err := os.WriteFile(credFile, data, 0600); err != nil {
1061 return fmt.Errorf("failed to write credentials file: %w", err)
1062 }
1063 return nil
1064}
1065
1066// cleanupInternalCredentials removes the internal credentials file.
1067func cleanupInternalCredentials() {
1068 _ = os.Remove(getInternalCredentialsFile())
1069}
1070
1071// getInternalCredentialsCandidates returns candidate file paths in priority order:
1072// 1. XDG_RUNTIME_DIR/gordon/ (set by systemd for the daemon)
1073// 2. /run/user/<uid>/gordon/ (well-known systemd default, for CLI in shells without XDG_RUNTIME_DIR)
1074// 3. ~/.gordon/run/ (fallback for non-systemd environments)
1075// 4. os.TempDir() (last resort, matches getInternalCredentialsFile fallback path)
1076func getInternalCredentialsCandidates() []string {
1077 var candidates []string
1078
1079 // 1. XDG_RUNTIME_DIR (set in daemon's environment)
1080 if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
1081 candidates = append(candidates, filepath.Join(runtimeDir, "gordon", "internal-creds.json"))
1082 }
1083
1084 // 2. /run/user/<uid>/gordon/ (systemd default, may not be in CLI's env)
1085 uid := os.Getuid()
1086 sysRuntime := filepath.Join("/run/user", fmt.Sprintf("%d", uid), "gordon", "internal-creds.json")
1087 // Avoid duplicate if XDG_RUNTIME_DIR already points here
1088 if len(candidates) == 0 || candidates[0] != sysRuntime {
1089 candidates = append(candidates, sysRuntime)
1090 }
1091
1092 // 3. ~/.gordon/run/ fallback
1093 if homeDir, err := os.UserHomeDir(); err == nil {
1094 candidates = append(candidates, filepath.Join(homeDir, ".gordon", "run", "internal-creds.json"))
1095 }
1096
1097 // 4. os.TempDir() last resort — matches the fallback path in getInternalCredentialsFile,
1098 // ensuring GetInternalCredentials can find credentials even when getSecureRuntimeDir fails.
1099 candidates = append(candidates, filepath.Join(os.TempDir(), "gordon-internal-creds.json"))
1100
1101 return candidates
1102}
1103
1104// GetInternalCredentialsFromCandidates reads credentials from the first candidate file that exists.
1105// Exported for testing.
1106func GetInternalCredentialsFromCandidates(candidates []string) (*InternalCredentials, error) {
1107 var lastErr error
1108 for _, path := range candidates {
1109 data, err := os.ReadFile(path)
1110 if os.IsNotExist(err) {
1111 continue
1112 }
1113 if err != nil {
1114 // Non-permission errors (e.g. EACCES) may be transient or path-specific;
1115 // record and try the next candidate rather than failing immediately.
1116 lastErr = fmt.Errorf("failed to read credentials file %s: %w", path, err)
1117 continue
1118 }
1119 var creds InternalCredentials
1120 if err := json.Unmarshal(data, &creds); err != nil {
1121 // Corrupt file — record and fall through to lower-priority candidates.
1122 lastErr = fmt.Errorf("failed to parse credentials at %s: %w", path, err)
1123 continue
1124 }
1125 return &creds, nil
1126 }
1127 if lastErr != nil {
1128 return nil, lastErr
1129 }
1130 return nil, fmt.Errorf("no credentials file found (is Gordon running?): checked %v", candidates)
1131}
1132
1133// GetInternalCredentials reads the internal registry credentials from file.
1134// Probes all candidate runtime directories so CLI works regardless of whether
1135// XDG_RUNTIME_DIR is set in the current shell environment.
1136func GetInternalCredentials() (*InternalCredentials, error) {
1137 return GetInternalCredentialsFromCandidates(getInternalCredentialsCandidates())
1138}
1139
1140// createAuthService creates the authentication service and token store.
1141func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
1142 if !cfg.Auth.Enabled {
1143 log.Warn().Msg("auth.enabled=false detected: running in local-only mode (registry loopback-only, admin API disabled)")
1144 return nil, nil, nil
1145 }
1146
1147 authType, err := resolveAuthType(cfg)
1148 if err != nil {
1149 return nil, nil, fmt.Errorf("resolve auth type: %w", err)
1150 }
1151 backend, err := resolveSecretsBackend(cfg.Auth.SecretsBackend)
1152 if err != nil {
1153 return nil, nil, log.WrapErr(err, "failed to resolve secrets backend")
1154 }
1155 dataDir := resolveDataDir(cfg.Server.DataDir)
1156
1157 store, err := createTokenStore(backend, dataDir, log)
1158 if err != nil {
1159 return nil, nil, err
1160 }
1161
1162 authConfig, err := buildAuthConfig(ctx, cfg, authType, backend, dataDir, log)
1163 if err != nil {
1164 return nil, nil, err
1165 }
1166
1167 authSvc := auth.NewService(authConfig, store, log)
1168
1169 log.Info().
1170 Str("type", string(authType)).
1171 Str("backend", string(backend)).
1172 Msg("registry authentication enabled")
1173
1174 return store, authSvc, nil
1175}
1176
1177// resolveAuthType determines the auth type from config.
1178// Token-only authentication is the only supported mode.
1179func resolveAuthType(cfg Config) (domain.AuthType, error) {
1180 if cfg.Auth.Type != "" && cfg.Auth.Type != "token" {
1181 return "", fmt.Errorf("unsupported auth.type %q; only \"token\" is supported", cfg.Auth.Type)
1182 }
1183 return domain.AuthTypeToken, nil
1184}
1185
1186func resolveSecretsBackend(backend string) (domain.SecretsBackend, error) {
1187 switch backend {
1188 case "pass":
1189 return domain.SecretsBackendPass, nil
1190 case "sops":
1191 return domain.SecretsBackendSops, nil
1192 case "unsafe":
1193 return domain.SecretsBackendUnsafe, nil
1194 case "":
1195 return "", fmt.Errorf("auth.secrets_backend is required")
1196 default:
1197 return "", fmt.Errorf("unsupported auth.secrets_backend %q", backend)
1198 }
1199}
1200
1201func resolveDataDir(dataDir string) string {
1202 if dataDir == "" {
1203 return DefaultDataDir()
1204 }
1205 return dataDir
1206}
1207
1208func resolveEnvDir(cfg Config) string {
1209 dataDir := resolveDataDir(cfg.Server.DataDir)
1210 envDir := cfg.Env.Dir
1211 if envDir == "" {
1212 envDir = filepath.Join(dataDir, "env")
1213 }
1214 return envDir
1215}
1216
1217func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
1218 // Token store is always created since tokens work in both auth modes
1219 store, err := tokenstore.NewStore(backend, dataDir, log)
1220 if err != nil {
1221 return nil, log.WrapErr(err, "failed to create token store")
1222 }
1223 return store, nil
1224}
1225
1226func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
1227 authConfig := auth.Config{
1228 Enabled: cfg.Auth.Enabled,
1229 AuthType: authType,
1230 Username: cfg.Auth.Username,
1231 }
1232
1233 // Token config is always required (tokens work in all auth modes)
1234 secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log)
1235 if err != nil {
1236 return auth.Config{}, err
1237 }
1238 authConfig.TokenSecret = secret
1239 authConfig.TokenExpiry = expiry
1240
1241 accessTokenTTL := 15 * time.Minute // default
1242 if cfg.Auth.AccessTokenTTL != "" {
1243 parsed, err := time.ParseDuration(cfg.Auth.AccessTokenTTL)
1244 if err != nil {
1245 return auth.Config{}, fmt.Errorf("invalid auth.access_token_ttl %q: %w", cfg.Auth.AccessTokenTTL, err)
1246 }
1247 if parsed <= 0 {
1248 return auth.Config{}, fmt.Errorf("auth.access_token_ttl must be positive")
1249 }
1250 if parsed > auth.MaxAccessTokenLifetime {
1251 return auth.Config{}, fmt.Errorf("auth.access_token_ttl must not exceed %v", auth.MaxAccessTokenLifetime)
1252 }
1253 accessTokenTTL = parsed
1254 }
1255 authConfig.AccessTokenTTL = accessTokenTTL
1256
1257 return authConfig, nil
1258}
1259
1260func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) {
1261 secret, err := loadTokenSecret(ctx, cfg, backend, dataDir, log)
1262 if err != nil {
1263 return nil, 0, err
1264 }
1265
1266 expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
1267 if err != nil {
1268 return nil, 0, err
1269 }
1270
1271 return secret, expiry, nil
1272}
1273
1274// TokenSecretEnvVar is the environment variable for the JWT signing secret.
1275// SECURITY: This takes priority over config file to allow secure secret injection.
1276const TokenSecretEnvVar = "GORDON_AUTH_TOKEN_SECRET" //nolint:gosec // This is an env var name, not a credential
1277
1278func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {
1279 // SECURITY: Priority order for token secret:
1280 // 1. Environment variable (most secure - no disk exposure)
1281 // 2. Secrets backend (pass/sops - encrypted)
1282 // 3. Config file path (least preferred)
1283
1284 const minTokenSecretLength = 32
1285
1286 // Check environment variable first
1287 if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" {
1288 if len(envSecret) < minTokenSecretLength {
1289 return nil, fmt.Errorf("token secret from %s must be at least %d bytes (got %d)", TokenSecretEnvVar, minTokenSecretLength, len(envSecret))
1290 }
1291 log.Debug().Msg("using token secret from environment variable")
1292 return []byte(envSecret), nil
1293 }
1294
1295 // Fall back to config-specified path via secrets backend
1296 if cfg.Auth.TokenSecret == "" {
1297 return nil, fmt.Errorf("token_secret is required for JWT token generation; set %s environment variable or configure auth.token_secret", TokenSecretEnvVar)
1298 }
1299
1300 secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log)
1301 if err != nil {
1302 return nil, log.WrapErr(err, "failed to load token secret")
1303 }
1304
1305 if len(secret) < minTokenSecretLength {
1306 return nil, fmt.Errorf("token_secret must be at least %d bytes (got %d); use a strong random secret", minTokenSecretLength, len(secret))
1307 }
1308
1309 return []byte(secret), nil
1310}
1311
1312func parseTokenExpiry(expiry string) (time.Duration, error) {
1313 if expiry == "" {
1314 return 0, nil
1315 }
1316
1317 parsed, err := duration.Parse(expiry)
1318 if err != nil {
1319 return 0, fmt.Errorf("invalid token_expiry: %w", err)
1320 }
1321
1322 return parsed, nil
1323}
1324
1325func resolveServiceTokenExpiry(cfg Config) (time.Duration, error) {
1326 expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
1327 if err != nil {
1328 return 0, err
1329 }
1330 if expiry <= 0 {
1331 return serviceTokenDefaultTTL, nil
1332 }
1333 return expiry, nil
1334}
1335
1336// loadSecret loads a secret from the configured backend.
1337func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) {
1338 switch backend {
1339 case domain.SecretsBackendPass:
1340 provider := secrets.NewPassProvider(log)
1341 return provider.GetSecret(ctx, path)
1342 case domain.SecretsBackendSops:
1343 provider := secrets.NewSopsProvider(log)
1344 return provider.GetSecret(ctx, path)
1345 case domain.SecretsBackendUnsafe:
1346 // For unsafe backend, path is relative to dataDir/secrets/.
1347 return readUnsafeSecret(dataDir, path)
1348 default:
1349 return "", fmt.Errorf("unknown secrets backend: %s", backend)
1350 }
1351}
1352
1353func readFileBeneath(root, cleanedRelPath string) ([]byte, error) {
1354 rootFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
1355 if err != nil {
1356 return nil, fmt.Errorf("failed to open secrets root: %w", err)
1357 }
1358 defer unix.Close(rootFD)
1359
1360 parts := strings.Split(filepath.ToSlash(cleanedRelPath), "/")
1361 dirFD := rootFD
1362 var closeDirFDs []int
1363 defer func() {
1364 for i := len(closeDirFDs) - 1; i >= 0; i-- {
1365 _ = unix.Close(closeDirFDs[i])
1366 }
1367 }()
1368
1369 for i, part := range parts {
1370 if part == "" || part == "." || part == ".." {
1371 return nil, fmt.Errorf("invalid secret path: path must stay under dataDir/secrets")
1372 }
1373 last := i == len(parts)-1
1374 if last {
1375 fd, err := unix.Openat(dirFD, part, unix.O_RDONLY|unix.O_NONBLOCK|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
1376 if err != nil {
1377 return nil, fmt.Errorf("failed to read secret file: %w", err)
1378 }
1379 defer unix.Close(fd)
1380 var st unix.Stat_t
1381 if err := unix.Fstat(fd, &st); err != nil {
1382 return nil, fmt.Errorf("failed to stat secret file: %w", err)
1383 }
1384 if st.Mode&unix.S_IFMT != unix.S_IFREG {
1385 return nil, fmt.Errorf("invalid secret path: secret must be a regular file")
1386 }
1387 data, err := os.ReadFile(fmt.Sprintf("/proc/self/fd/%d", fd))
1388 if err != nil {
1389 return nil, fmt.Errorf("failed to read secret file: %w", err)
1390 }
1391 return data, nil
1392 }
1393
1394 nextFD, err := unix.Openat(dirFD, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
1395 if err != nil {
1396 return nil, fmt.Errorf("failed to open secret path component: %w", err)
1397 }
1398 closeDirFDs = append(closeDirFDs, nextFD)
1399 dirFD = nextFD
1400 }
1401
1402 return nil, fmt.Errorf("invalid secret path: empty path")
1403}
1404
1405func readUnsafeSecret(dataDir, secretPath string) (string, error) {
1406 if filepath.IsAbs(secretPath) {
1407 return "", fmt.Errorf("invalid secret path: absolute paths are not allowed")
1408 }
1409 cleaned := filepath.Clean(secretPath)
1410 if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
1411 return "", fmt.Errorf("invalid secret path: path must stay under dataDir/secrets")
1412 }
1413
1414 root := filepath.Clean(filepath.Join(dataDir, "secrets"))
1415 data, err := readFileBeneath(root, cleaned)
1416 if err != nil {
1417 return "", err
1418 }
1419 return string(data), nil
1420}
1421
1422// proxyConfigResult holds parsed proxy and blob chunk size config.
1423type proxyConfigResult struct {
1424 proxyConfig proxy.Config
1425 maxBlobChunkSize int64
1426 maxBlobSize int64
1427}
1428
1429type configWatcher interface {
1430 Watch(ctx context.Context, onChange func()) error
1431}
1432
1433type configReloader interface {
1434 Reload(ctx context.Context) error
1435}
1436
1437type proxyConfigUpdater interface {
1438 UpdateConfig(config proxy.Config)
1439}
1440
1441type reloadTrigger interface {
1442 Trigger(ctx context.Context) error
1443}
1444
1445type loadedConfigApplier interface {
1446 ApplyLoadedConfig(ctx context.Context) error
1447}
1448
1449type reloadCoordinator struct {
1450 mu sync.Mutex
1451 lastRun time.Time
1452 debounce time.Duration
1453
1454 configSvc configReloader
1455 v *viper.Viper
1456 proxySvc proxyConfigUpdater
1457 registryLimits interface {
1458 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
1459 }
1460 eventBus out.EventPublisher
1461 log zerowrap.Logger
1462}
1463
1464func newReloadCoordinator(v *viper.Viper, configSvc configReloader, proxySvc proxyConfigUpdater, registryLimits interface {
1465 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
1466}, eventBus out.EventPublisher, log zerowrap.Logger) *reloadCoordinator {
1467 return &reloadCoordinator{
1468 debounce: 500 * time.Millisecond,
1469 configSvc: configSvc,
1470 v: v,
1471 proxySvc: proxySvc,
1472 registryLimits: registryLimits,
1473 eventBus: eventBus,
1474 log: log,
1475 }
1476}
1477
1478func (c *reloadCoordinator) SetRegistryLimits(limits interface {
1479 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
1480}) {
1481 c.mu.Lock()
1482 defer c.mu.Unlock()
1483 c.registryLimits = limits
1484}
1485
1486func (c *reloadCoordinator) Trigger(ctx context.Context) error {
1487 c.mu.Lock()
1488 defer c.mu.Unlock()
1489
1490 return c.reloadLocked(ctx, true)
1491}
1492
1493func (c *reloadCoordinator) ApplyLoadedConfig(ctx context.Context) error {
1494 c.mu.Lock()
1495 defer c.mu.Unlock()
1496
1497 return c.reloadLocked(ctx, false)
1498}
1499
1500func (c *reloadCoordinator) reloadLocked(ctx context.Context, loadConfig bool) error {
1501 now := time.Now()
1502 if !c.lastRun.IsZero() && now.Sub(c.lastRun) < c.debounce {
1503 c.log.Debug().Dur("since_last_reload", now.Sub(c.lastRun)).Msg("skipping config reload trigger due to debounce")
1504 return nil
1505 }
1506
1507 if loadConfig {
1508 if err := c.configSvc.Reload(ctx); err != nil {
1509 c.log.Error().Err(err).Msg("failed to reload config")
1510 return fmt.Errorf("failed to reload config: %w", err)
1511 }
1512 }
1513
1514 if err := c.applyLoadedConfig(now); err != nil {
1515 return err
1516 }
1517
1518 return nil
1519}
1520
1521func (c *reloadCoordinator) applyLoadedConfig(now time.Time) error {
1522 var reloadCfg Config
1523 if err := c.v.Unmarshal(&reloadCfg); err != nil {
1524 c.log.Error().Err(err).Msg("failed to unmarshal config on reload")
1525 return fmt.Errorf("failed to unmarshal config on reload: %w", err)
1526 }
1527
1528 reloadedProxy, err := buildProxyConfig(reloadCfg, c.log)
1529 if err != nil {
1530 c.log.Error().Err(err).Msg("failed to parse proxy config on reload")
1531 return fmt.Errorf("failed to parse proxy config on reload: %w", err)
1532 }
1533
1534 c.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)
1535 if c.registryLimits != nil {
1536 c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize)
1537 }
1538
1539 if c.eventBus != nil {
1540 if err := c.eventBus.Publish(domain.EventConfigReload, nil); err != nil {
1541 c.log.Error().Err(err).Msg("failed to publish config reload event")
1542 return fmt.Errorf("failed to publish config reload event: %w", err)
1543 }
1544 }
1545
1546 c.lastRun = now
1547
1548 c.log.Debug().Msg("config hot reload complete")
1549 return nil
1550}
1551
1552// buildProxyConfig parses size-related config fields and builds the proxy config.
1553func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, error) {
1554 maxProxyBodySize := int64(512 << 20) // 512MB default
1555 if cfg.Server.MaxProxyBodySize != "" {
1556 parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyBodySize)
1557 if err != nil {
1558 return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_body_size configuration", map[string]any{"value": cfg.Server.MaxProxyBodySize})
1559 }
1560 maxProxyBodySize = parsedSize
1561 }
1562
1563 maxBlobChunkSize := int64(registry.DefaultMaxBlobChunkSize)
1564 if cfg.Server.MaxBlobChunkSize != "" {
1565 parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobChunkSize)
1566 if err != nil {
1567 return nil, log.WrapErrWithFields(err, "invalid server.max_blob_chunk_size configuration", map[string]any{"value": cfg.Server.MaxBlobChunkSize})
1568 }
1569 maxBlobChunkSize = parsedSize
1570 }
1571
1572 maxBlobSize := int64(registry.DefaultMaxBlobSize)
1573 if cfg.Server.MaxBlobSize != "" {
1574 parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobSize)
1575 if err != nil {
1576 return nil, log.WrapErrWithFields(err, "invalid server.max_blob_size configuration", map[string]any{"value": cfg.Server.MaxBlobSize})
1577 }
1578 maxBlobSize = parsedSize
1579 }
1580
1581 maxProxyResponseSize := int64(1 << 30) // 1GB default
1582 if cfg.Server.MaxProxyResponseSize != "" {
1583 parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyResponseSize)
1584 if err != nil {
1585 return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_response_size configuration", map[string]any{"value": cfg.Server.MaxProxyResponseSize})
1586 }
1587 maxProxyResponseSize = parsedSize
1588 }
1589
1590 maxConcurrentConns := cfg.Server.MaxConcurrentConns
1591 if maxConcurrentConns < 0 {
1592 maxConcurrentConns = 10000 // default when explicitly set to -1
1593 }
1594 // 0 means no limit (as documented in proxy.Config)
1595
1596 return &proxyConfigResult{
1597 proxyConfig: proxy.Config{
1598 RegistryDomain: cfg.Server.GordonDomain,
1599 RegistryPort: cfg.Server.RegistryPort,
1600 MaxBodySize: maxProxyBodySize,
1601 MaxResponseSize: maxProxyResponseSize,
1602 MaxConcurrentConns: maxConcurrentConns,
1603 },
1604 maxBlobChunkSize: maxBlobChunkSize,
1605 maxBlobSize: maxBlobSize,
1606 }, nil
1607}
1608
1609// createContainerService creates the container service with configuration.
1610func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
1611 // Parse and validate container resource limits from config
1612 if cfg.Containers.CPULimit < 0 {
1613 return nil, fmt.Errorf("containers.cpu_limit must be >= 0 (got %f)", cfg.Containers.CPULimit)
1614 }
1615 if cfg.Containers.PidsLimit < 0 {
1616 return nil, fmt.Errorf("containers.pids_limit must be >= 0 (got %d)", cfg.Containers.PidsLimit)
1617 }
1618 var defaultMemoryLimit int64
1619 if cfg.Containers.MemoryLimit != "" {
1620 parsed, err := bytesize.Parse(cfg.Containers.MemoryLimit)
1621 if err != nil {
1622 return nil, fmt.Errorf("invalid containers.memory_limit %q: %w", cfg.Containers.MemoryLimit, err)
1623 }
1624 if parsed <= 0 {
1625 return nil, fmt.Errorf("containers.memory_limit must be positive (got %q)", cfg.Containers.MemoryLimit)
1626 }
1627 defaultMemoryLimit = parsed
1628 }
1629 var defaultNanoCPUs int64
1630 if cfg.Containers.CPULimit > 0 {
1631 defaultNanoCPUs = int64(cfg.Containers.CPULimit * 1e9)
1632 }
1633
1634 attachmentConfig := svc.configSvc.GetAttachmentConfig()
1635
1636 containerConfig := container.Config{
1637 RegistryAuthEnabled: cfg.Auth.Enabled,
1638 RegistryDomain: cfg.Server.GordonDomain,
1639 RegistryPort: cfg.Server.RegistryPort,
1640 InternalRegistryUsername: svc.internalRegUser,
1641 InternalRegistryPassword: svc.internalRegPass,
1642 PullPolicy: v.GetString("deploy.pull_policy"),
1643 VolumeAutoCreate: v.GetBool("volumes.auto_create"),
1644 VolumePrefix: v.GetString("volumes.prefix"),
1645 VolumePreserve: v.GetBool("volumes.preserve"),
1646 NetworkIsolation: v.GetBool("network_isolation.enabled"),
1647 NetworkPrefix: v.GetString("network_isolation.network_prefix"),
1648 NetworkGroups: attachmentConfig.NetworkGroups,
1649 NetworkInternal: v.GetBool("network_isolation.internal"),
1650 Attachments: attachmentConfig.Attachments,
1651 AllowedRegistries: cfg.Images.AllowedRegistries,
1652 RequireImageDigest: cfg.Images.RequireDigest,
1653 SecurityProfile: cfg.Containers.SecurityProfile,
1654 ReadinessDelay: v.GetDuration("deploy.readiness_delay"),
1655 ReadinessMode: v.GetString("deploy.readiness_mode"),
1656 HealthTimeout: v.GetDuration("deploy.health_timeout"),
1657 StabilizationDelay: v.GetDuration("deploy.stabilization_delay"),
1658 TCPProbeTimeout: v.GetDuration("deploy.tcp_probe_timeout"),
1659 HTTPProbeTimeout: v.GetDuration("deploy.http_probe_timeout"),
1660 DrainDelay: v.GetDuration("deploy.drain_delay"),
1661 DrainMode: v.GetString("deploy.drain_mode"),
1662 DrainTimeout: v.GetDuration("deploy.drain_timeout"),
1663 DefaultMemoryLimit: defaultMemoryLimit,
1664 DefaultNanoCPUs: defaultNanoCPUs,
1665 DefaultPidsLimit: cfg.Containers.PidsLimit,
1666 AttachmentReadinessTimeout: v.GetDuration("deploy.attachment_readiness_timeout"),
1667 }
1668 if v.IsSet("deploy.drain_delay") {
1669 containerConfig.DrainDelayConfigured = true
1670 containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay")
1671 }
1672
1673 if containerConfig.RegistryAuthEnabled {
1674 if svc.authSvc == nil {
1675 return nil, fmt.Errorf("authentication service unavailable: cannot generate registry service token")
1676 }
1677 expiry, err := resolveServiceTokenExpiry(cfg)
1678 if err != nil {
1679 return nil, log.WrapErr(err, "failed to resolve service token expiry")
1680 }
1681 // Note: Service tokens are not auto-refreshed. If the token expires during
1682 // container runtime, the container will need to be recreated to get a new token.
1683 serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, expiry)
1684 if err != nil {
1685 return nil, log.WrapErr(err, "failed to generate registry service token")
1686 }
1687 log.Info().
1688 Str("subject", serviceTokenSubject).
1689 Str("expiry", expiry.String()).
1690 Msg("generated service token for container registry access")
1691 containerConfig.ServiceTokenUsername = serviceTokenSubject
1692 containerConfig.ServiceToken = serviceToken
1693 } else {
1694 log.Warn().Msg("registry auth disabled; container image pulls will use unauthenticated mode")
1695 }
1696
1697 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig, svc.configSvc), nil
1698}
1699
1700func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*filesystem.BackupStorage, *backup.Service, error) {
1701 if !cfg.Backups.Enabled {
1702 return nil, nil, nil
1703 }
1704
1705 storageDir := cfg.Backups.StorageDir
1706 if storageDir == "" {
1707 dataDir := resolveDataDir(cfg.Server.DataDir)
1708 storageDir = filepath.Join(dataDir, "backups")
1709 }
1710
1711 backupStorage, err := filesystem.NewBackupStorage(storageDir, log)
1712 if err != nil {
1713 return nil, nil, log.WrapErr(err, "failed to create backup storage")
1714 }
1715
1716 retention, err := validateBackupRetention(cfg)
1717 if err != nil {
1718 return nil, nil, log.WrapErr(err, "invalid backup retention policy")
1719 }
1720
1721 backupCfg := domain.BackupConfig{
1722 Enabled: cfg.Backups.Enabled,
1723 StorageDir: storageDir,
1724 Retention: retention,
1725 }
1726
1727 backupSvc := backup.NewService(svc.runtime, backupStorage, svc.containerSvc, backupCfg, log)
1728
1729 log.Info().
1730 Str("storage_dir", storageDir).
1731 Msg("backup service initialized")
1732
1733 return backupStorage, backupSvc, nil
1734}
1735
1736func validateBackupRetention(cfg Config) (domain.RetentionPolicy, error) {
1737 if cfg.Backups.Retention.Hourly < 0 {
1738 return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.hourly cannot be negative")
1739 }
1740 if cfg.Backups.Retention.Daily < 0 {
1741 return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.daily cannot be negative")
1742 }
1743 if cfg.Backups.Retention.Weekly < 0 {
1744 return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.weekly cannot be negative")
1745 }
1746 if cfg.Backups.Retention.Monthly < 0 {
1747 return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.monthly cannot be negative")
1748 }
1749
1750 return domain.RetentionPolicy{
1751 Hourly: cfg.Backups.Retention.Hourly,
1752 Daily: cfg.Backups.Retention.Daily,
1753 Weekly: cfg.Backups.Retention.Weekly,
1754 Monthly: cfg.Backups.Retention.Monthly,
1755 }, nil
1756}
1757
1758// registerEventHandlers registers all event handlers.
1759func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func(), error) {
1760 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
1761 if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil {
1762 return nil, fmt.Errorf("failed to subscribe image pushed handler: %w", err)
1763 }
1764
1765 // Auto-route handler for creating routes from image labels
1766 autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.GordonDomain).
1767 WithEnvExtractor(svc.runtime, svc.envDir)
1768
1769 // Preview handler for creating preview environments from tagged images
1770 autoPreviewHandler := preview.NewAutoPreviewHandler(
1771 ctx,
1772 svc.configSvc,
1773 svc.previewService,
1774 )
1775
1776 // Dispatcher routes image push events to either auto-route or preview handler
1777 dispatcher := auto.NewImagePushDispatcher(svc.configSvc, autoRouteHandler, autoPreviewHandler)
1778 if err := svc.eventBus.Subscribe(dispatcher); err != nil {
1779 return nil, fmt.Errorf("subscribe image push dispatcher: %w", err)
1780 }
1781
1782 configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)
1783 if err := svc.eventBus.Subscribe(configReloadHandler); err != nil {
1784 return nil, fmt.Errorf("failed to subscribe config reload handler: %w", err)
1785 }
1786
1787 manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc)
1788 if err := svc.eventBus.Subscribe(manualDeployHandler); err != nil {
1789 return nil, fmt.Errorf("failed to subscribe manual deploy handler: %w", err)
1790 }
1791
1792 secretsChangedHandler := container.NewSecretsChangedHandler(ctx, svc.containerSvc, svc.configSvc, container.DefaultSecretsDebounce)
1793 if err := svc.eventBus.Subscribe(secretsChangedHandler); err != nil {
1794 return nil, fmt.Errorf("failed to subscribe secrets changed handler: %w", err)
1795 }
1796
1797 // Proxy cache invalidation on config reload (clears stale targets for removed routes)
1798 configReloadProxyHandler := proxy.NewConfigReloadProxyHandler(ctx, svc.proxySvc)
1799 if err := svc.eventBus.Subscribe(configReloadProxyHandler); err != nil {
1800 return nil, fmt.Errorf("failed to subscribe config reload proxy handler: %w", err)
1801 }
1802
1803 cleanup := func() {
1804 secretsChangedHandler.Stop()
1805 }
1806
1807 return cleanup, nil
1808}
1809
1810// setupConfigHotReload sets up config hot reload.
1811func setupConfigHotReload(ctx context.Context, configSvc configWatcher, coordinator loadedConfigApplier) error {
1812 if err := configSvc.Watch(ctx, func() {
1813 _ = coordinator.ApplyLoadedConfig(ctx)
1814 }); err != nil {
1815 return fmt.Errorf("failed to watch config: %w", err)
1816 }
1817
1818 return nil
1819}
1820
1821// syncAndAutoStart syncs existing containers and auto-starts if configured.
1822func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
1823 if err := svc.containerSvc.SyncContainers(ctx); err != nil {
1824 log.Warn().Err(err).Msg("failed to sync existing containers")
1825 }
1826
1827 if svc.configSvc.IsAutoRouteEnabled() {
1828 routes := svc.configSvc.GetRoutes(ctx)
1829 if err := svc.containerSvc.AutoStart(domain.WithInternalDeploy(ctx), routes); err != nil {
1830 log.Warn().Err(err).Msg("failed to auto-start containers")
1831 }
1832 }
1833
1834 // Start background monitor to restart crashed containers.
1835 svc.containerSvc.StartMonitor(ctx)
1836}
1837func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler {
1838 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1839 host, _, err := net.SplitHostPort(r.RemoteAddr)
1840 if err != nil {
1841 host = r.RemoteAddr
1842 }
1843
1844 ip := net.ParseIP(host)
1845 if ip == nil || !ip.IsLoopback() {
1846 log.Warn().
1847 Str("path", r.URL.Path).
1848 Str("remote_addr", r.RemoteAddr).
1849 Msg("blocked non-loopback access on internal admin route")
1850 w.Header().Set("Content-Type", "application/json")
1851 w.WriteHeader(http.StatusForbidden)
1852 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"})
1853 return
1854 }
1855
1856 next.ServeHTTP(w, r)
1857 })
1858}
1859
1860// createHTTPHandlers creates HTTP handlers with middleware.
1861// Returns three handlers: registry, HTTP proxy (with CIDR + onboarding), and HTTPS proxy.
1862func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWriter out.AccessLogWriter) (http.Handler, http.Handler, http.Handler) {
1863 // Parse trusted proxies once for all middleware chains.
1864 // This ensures consistent IP extraction across logging, rate limiting, and auth.
1865 trustedNets := httphelper.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
1866
1867 // Registry handler
1868 registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize, svc.maxBlobSize)
1869 svc.registryHandler = registryHandler
1870 if svc.reloadCoordinator != nil {
1871 svc.reloadCoordinator.SetRegistryLimits(registryHandler)
1872 }
1873 registryWithMiddleware, cidrAllowlistMiddleware, rateLimitMiddleware := buildRegistryHandlerWithMiddleware(
1874 svc,
1875 cfg,
1876 trustedNets,
1877 registryHandler,
1878 log,
1879 )
1880
1881 registryMux := http.NewServeMux()
1882 registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, cfg, log)
1883 registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log))
1884 registerAdminRoutes(registryMux, svc, cfg, trustedNets, log)
1885
1886 // Proxy handler
1887 proxyHandler := proxyadapter.NewHandler(svc.proxySvc, trustedNets, log)
1888
1889 // HTTP proxy handler chain: HTTPS redirect for non-proxy clients, then CIDR allowlist
1890 proxyAllowedNets, proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log)
1891
1892 httpProxyMiddlewares := []func(http.Handler) http.Handler{
1893 middleware.PanicRecovery(log),
1894 middleware.RequestLogger(log, trustedNets),
1895 middleware.SecurityHeaders,
1896 middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.Port, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log, func(host string) bool {
1897 return svc.proxySvc.IsKnownHost(context.Background(), host)
1898 }),
1899 }
1900 if proxyCIDRMiddleware != nil {
1901 httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware)
1902 }
1903
1904 httpProxyWithMiddleware := otelhttp.NewHandler(
1905 middleware.Chain(httpProxyMiddlewares...)(proxyHandler),
1906 "gordon.proxy",
1907 )
1908
1909 // Build the onboarding handler once if internal CA is available and TLS is enabled.
1910 var obHandler *onboarding.Handler
1911 if svc.caAdapter != nil && cfg.Server.TLSPort != 0 {
1912 mobileconfigBytes := pkiadapter.GenerateMobileconfig(
1913 svc.caAdapter.RootCertificateDER(),
1914 svc.caAdapter.RootCommonName(),
1915 )
1916 obHandler = onboarding.NewHandler(
1917 svc.caAdapter.RootCertificate(),
1918 mobileconfigBytes,
1919 svc.caAdapter.RootFingerprint(),
1920 cfg.Server.Port,
1921 cfg.Server.TLSPort,
1922 )
1923 }
1924
1925 // HTTP proxyMux: trusted proxy traffic flows through the normal proxy chain.
1926 // Direct clients get an onboarding gate (when CA is available) placed BEFORE
1927 // HTTPSRedirect so force_https_redirect cannot bypass onboarding.
1928 proxyMux := http.NewServeMux()
1929 if proxyCIDRMiddleware != nil && proxyAllowedNets == nil {
1930 // Invalid proxy_allowed_ips: deny all traffic (fail-closed).
1931 proxyMux.Handle("/", proxyCIDRMiddleware(httpProxyWithMiddleware))
1932 } else if obHandler != nil {
1933 proxyMux.Handle("/", directHTTPOnboardingGate(obHandler, proxyAllowedNets, httpProxyWithMiddleware, log))
1934 } else {
1935 proxyMux.Handle("/", httpProxyWithMiddleware)
1936 }
1937
1938 // HTTPS proxy handler chain: security headers + proxy + CA onboarding
1939 // Onboarding routes live on the TLS port so Tailnet / direct clients
1940 // can click through the initial cert warning, install the CA, and
1941 // then trust all subsequent connections.
1942 // The middleware chain wraps the entire mux so onboarding routes also
1943 // get PanicRecovery, RequestLogger, and SecurityHeaders.
1944 httpsProxyMiddlewares := []func(http.Handler) http.Handler{
1945 middleware.PanicRecovery(log),
1946 middleware.RequestLogger(log, trustedNets),
1947 middleware.SecurityHeaders,
1948 }
1949
1950 httpsMux := http.NewServeMux()
1951 if obHandler != nil {
1952 httpsMux.HandleFunc("GET /ca", obHandler.ServeOnboardingPage)
1953 httpsMux.HandleFunc("GET /ca.crt", obHandler.ServeCACert)
1954 httpsMux.HandleFunc("GET /ca.mobileconfig", obHandler.ServeMobileconfig)
1955 }
1956 httpsMux.Handle("/", proxyHandler)
1957
1958 httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls")
1959
1960 // Wrap top-level handlers with access logging outside all gates
1961 // (loopbackOnly, denyAllHandler, CIDR allowlist) so every request —
1962 // including rejected probes — produces exactly one access-log line.
1963 var registryOut, proxyOut, httpsOut http.Handler = registryMux, proxyMux, httpsHandler
1964 if accessWriter != nil {
1965 excludeHC := cfg.Logging.AccessLog.ExcludeHealthChecks
1966 registryOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(registryOut)
1967 proxyOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(proxyOut)
1968 httpsOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(httpsOut)
1969 }
1970
1971 return registryOut, proxyOut, httpsOut
1972}
1973
1974// directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic
1975// by source IP. Trusted proxy IPs flow through to the normal proxy chain.
1976// Direct clients are served the CA onboarding flow on allowed paths and
1977// receive 403 on everything else. This gate runs BEFORE HTTPSRedirect so
1978// force_https_redirect cannot bypass onboarding for direct clients.
1979func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, proxyChain http.Handler, log zerowrap.Logger) http.Handler {
1980 // Build a small mux for direct-client onboarding paths.
1981 onboardingMux := http.NewServeMux()
1982 onboardingMux.HandleFunc("GET /{$}", ob.ServeOnboardingPage)
1983 onboardingMux.HandleFunc("GET /ca", ob.ServeOnboardingPage)
1984 onboardingMux.HandleFunc("GET /ca.crt", ob.ServeCACert)
1985 onboardingMux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig)
1986
1987 // Reserve ACME challenge path for future use.
1988 onboardingMux.HandleFunc("/.well-known/acme-challenge/", func(w http.ResponseWriter, _ *http.Request) {
1989 http.Error(w, "not found", http.StatusNotFound)
1990 })
1991
1992 // Catch-all: reject any other direct HTTP request.
1993 // Uses a method-aware split: GET writes a body, HEAD gets an empty 403.
1994 onboardingMux.HandleFunc("/", directHTTPForbidden)
1995
1996 onboardingWithMiddleware := middleware.Chain(
1997 middleware.PanicRecovery(log),
1998 middleware.RequestLogger(log), // Intentionally omit trusted proxy nets so direct onboarding logs use RemoteAddr only.
1999 middleware.SecurityHeaders,
2000 )(onboardingMux)
2001
2002 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2003 remoteIP := httphelper.ExtractRemoteIP(r.RemoteAddr)
2004 if httphelper.IsTrustedOrLocal(remoteIP, proxyNets) {
2005 proxyChain.ServeHTTP(w, r)
2006 return
2007 }
2008 onboardingWithMiddleware.ServeHTTP(w, r)
2009 })
2010}
2011
2012// directHTTPForbidden responds with 403 for non-onboarding HTTP paths.
2013// HEAD requests get an empty body per HTTP semantics.
2014func directHTTPForbidden(w http.ResponseWriter, r *http.Request) {
2015 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
2016 w.WriteHeader(http.StatusForbidden)
2017 if r.Method != http.MethodHead {
2018 _, _ = w.Write([]byte("Only certificate onboarding is available over HTTP.\n"))
2019 }
2020}
2021
2022func buildRegistryHandlerWithMiddleware(
2023 svc *services,
2024 cfg Config,
2025 trustedNets []*net.IPNet,
2026 registryHandler http.Handler,
2027 log zerowrap.Logger,
2028) (http.Handler, func(http.Handler) http.Handler, func(http.Handler) http.Handler) {
2029 registryMiddlewares := []func(http.Handler) http.Handler{
2030 middleware.PanicRecovery(log),
2031 middleware.RequestLogger(log, trustedNets),
2032 middleware.SecurityHeaders,
2033 }
2034
2035 cidrAllowlistMiddleware := buildRegistryCIDRAllowlistMiddleware(cfg, trustedNets, log)
2036 if cidrAllowlistMiddleware != nil {
2037 registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware)
2038 }
2039
2040 rateLimitMiddleware := buildRegistryRateLimitMiddleware(cfg, log)
2041 registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
2042
2043 appendRegistryAuthMiddleware(®istryMiddlewares, svc, cfg, trustedNets, log)
2044
2045 registryWithOtel := otelhttp.NewHandler(
2046 middleware.Chain(registryMiddlewares...)(registryHandler),
2047 "gordon.registry",
2048 )
2049 return registryWithOtel, cidrAllowlistMiddleware, rateLimitMiddleware
2050}
2051
2052// parseCIDRAllowlist parses a list of IPs/CIDRs, logs warnings for invalid entries,
2053// and returns the parsed nets. label is used in log messages (e.g. "registry_allowed_ips").
2054func parseCIDRAllowlist(ips []string, label string, log zerowrap.Logger) ([]*net.IPNet, bool) {
2055 if len(ips) == 0 {
2056 return nil, false
2057 }
2058
2059 allowedNets := httphelper.ParseTrustedProxies(ips)
2060 if len(allowedNets) != len(ips) {
2061 for _, entry := range ips {
2062 if nets := httphelper.ParseTrustedProxies([]string{entry}); len(nets) == 0 {
2063 log.Warn().Str("entry", entry).Msgf("ignoring invalid %s entry", label)
2064 }
2065 }
2066 }
2067
2068 if len(allowedNets) == 0 {
2069 log.Error().
2070 Strs(label, ips).
2071 Msgf("%s is set but no valid entries were parsed; will deny all traffic (fail-closed)", label)
2072 return nil, true // allInvalid
2073 }
2074
2075 return allowedNets, false
2076}
2077
2078// denyAllHandler returns a middleware that rejects every request with 403 Forbidden.
2079func denyAllHandler(label string, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
2080 return func(next http.Handler) http.Handler {
2081 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2082 log.Warn().
2083 Str(zerowrap.FieldClientIP, middleware.GetClientIP(r, trustedNets)).
2084 Msgf("access denied due to invalid %s configuration", label)
2085 w.Header().Set("Content-Type", "application/json")
2086 w.WriteHeader(http.StatusForbidden)
2087 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"})
2088 })
2089 }
2090}
2091
2092func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
2093 allowedNets, allInvalid := parseCIDRAllowlist(cfg.Server.RegistryAllowedIPs, "registry_allowed_ips", log)
2094 if allInvalid {
2095 return denyAllHandler("registry_allowed_ips", trustedNets, log)
2096 }
2097 if allowedNets == nil {
2098 return nil
2099 }
2100 return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)
2101}
2102
2103func buildProxyCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) ([]*net.IPNet, func(http.Handler) http.Handler) {
2104 allowedNets, allInvalid := parseCIDRAllowlist(cfg.Server.ProxyAllowedIPs, "proxy_allowed_ips", log)
2105 if allInvalid {
2106 return nil, denyAllHandler("proxy_allowed_ips", trustedNets, log)
2107 }
2108 if allowedNets == nil {
2109 return nil, nil
2110 }
2111
2112 log.Info().
2113 Strs("proxy_allowed_ips", cfg.Server.ProxyAllowedIPs).
2114 Msg("proxy origin IP allowlist enabled")
2115
2116 return allowedNets, middleware.ProxyCIDRAllowlist(allowedNets, log)
2117}
2118
2119func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler {
2120 if cfg.API.RateLimit.Enabled {
2121 globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
2122 ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
2123 return registry.RateLimitMiddleware(
2124 globalLimiter,
2125 ipLimiter,
2126 cfg.API.RateLimit.TrustedProxies,
2127 log,
2128 )
2129 }
2130
2131 return registry.RateLimitMiddleware(nil, nil, nil, log)
2132}
2133
2134func appendRegistryAuthMiddleware(registryMiddlewares *[]func(http.Handler) http.Handler, svc *services, cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) {
2135 if svc.authSvc != nil {
2136 internalAuth := middleware.InternalRegistryAuth{
2137 Username: svc.internalRegUser,
2138 Password: svc.internalRegPass,
2139 }
2140 *registryMiddlewares = append(*registryMiddlewares, middleware.RegistryAuthV2(svc.authSvc, internalAuth, trustedNets, log))
2141 return
2142 }
2143
2144 if cfg.Auth.Enabled {
2145 log.Error().Msg("authentication service unavailable; registry requests will be denied")
2146 *registryMiddlewares = append(*registryMiddlewares, func(next http.Handler) http.Handler {
2147 return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2148 w.Header().Set("Content-Type", "application/json")
2149 w.WriteHeader(http.StatusServiceUnavailable)
2150 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"})
2151 })
2152 })
2153 }
2154}
2155
2156func registerAuthRoutes(
2157 registryMux *http.ServeMux,
2158 svc *services,
2159 trustedNets []*net.IPNet,
2160 cidrAllowlistMiddleware func(http.Handler) http.Handler,
2161 rateLimitMiddleware func(http.Handler) http.Handler,
2162 cfg Config,
2163 log zerowrap.Logger,
2164) {
2165 if svc.authHandler == nil {
2166 return
2167 }
2168
2169 // Auth endpoints always get rate limiting, even if global rate limiting is disabled.
2170 // This prevents brute-force attacks against password/token endpoints.
2171 authRateLimitMiddleware := rateLimitMiddleware
2172 if !cfg.API.RateLimit.Enabled {
2173 authGlobalLimiter := ratelimit.NewMemoryStore(50, 100, log)
2174 authIPLimiter := ratelimit.NewMemoryStore(5, 10, log)
2175 authRateLimitMiddleware = registry.RateLimitMiddleware(authGlobalLimiter, authIPLimiter, cfg.API.RateLimit.TrustedProxies, log)
2176 }
2177
2178 // Auth endpoints are NOT protected by auth - they're where clients authenticate
2179 // but still need rate limiting to prevent brute force attacks.
2180 authMiddlewares := []func(http.Handler) http.Handler{
2181 middleware.PanicRecovery(log),
2182 middleware.RequestLogger(log, trustedNets),
2183 middleware.SecurityHeaders,
2184 }
2185 if cidrAllowlistMiddleware != nil {
2186 authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)
2187 }
2188 authMiddlewares = append(authMiddlewares, authRateLimitMiddleware)
2189 authWithMiddleware := otelhttp.NewHandler(
2190 middleware.Chain(authMiddlewares...)(svc.authHandler),
2191 "gordon.auth",
2192 )
2193 registryMux.Handle("/auth/", authWithMiddleware)
2194}
2195
2196func wrapRegistryForLocalMode(registryWithMiddleware http.Handler, cfg Config, log zerowrap.Logger) http.Handler {
2197 if !cfg.Auth.Enabled {
2198 return loopbackOnly(registryWithMiddleware, log)
2199 }
2200 return registryWithMiddleware
2201}
2202
2203func registerAdminRoutes(registryMux *http.ServeMux, svc *services, cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) {
2204 if svc.adminHandler == nil {
2205 return
2206 }
2207
2208 if !cfg.Auth.Enabled {
2209 log.Warn().Msg("auth disabled: admin API endpoints are not registered")
2210 return
2211 }
2212
2213 adminMiddlewares := []func(http.Handler) http.Handler{
2214 middleware.PanicRecovery(log),
2215 middleware.RequestLogger(log, trustedNets),
2216 middleware.SecurityHeaders,
2217 }
2218
2219 if svc.authSvc != nil {
2220 // Create rate limiters for admin API - uses same config as registry.
2221 var globalLimiter, ipLimiter out.RateLimiter
2222 if cfg.API.RateLimit.Enabled {
2223 globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
2224 ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
2225 }
2226 adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log))
2227 } else {
2228 adminMiddlewares = append(adminMiddlewares, func(next http.Handler) http.Handler {
2229 return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2230 w.Header().Set("Content-Type", "application/json")
2231 w.WriteHeader(http.StatusServiceUnavailable)
2232 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"})
2233 })
2234 })
2235 }
2236
2237 adminWithMiddleware := otelhttp.NewHandler(
2238 middleware.Chain(adminMiddlewares...)(svc.adminHandler),
2239 "gordon.admin",
2240 )
2241 registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log))
2242}
2243
2244// runServers starts the HTTP servers and waits for shutdown.
2245// Signal handling notes:
2246// - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext
2247// - SIGUSR1: Triggers config reload without restart
2248// - SIGUSR2: Triggers manual deploy for a specific route
2249// The deferred signal.Stop calls ensure signal handlers are properly
2250// cleaned up before program exit, preventing signal handler leaks.
2251func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, reload reloadTrigger, cleanupHandlers func(), log zerowrap.Logger) error {
2252 // Initialize access log writer. Kept here (not in Run) to keep Run's cyclomatic
2253 // complexity within the project limit of 15.
2254 accessWriterConcrete, err := initAccessLog(cfg, log)
2255 if err != nil {
2256 return err
2257 }
2258 if accessWriterConcrete != nil {
2259 defer accessWriterConcrete.Close()
2260 }
2261 // Convert to interface only when non-nil to avoid the Go nil-interface pitfall
2262 // where a typed nil pointer becomes a non-nil interface value.
2263 var accessWriter out.AccessLogWriter
2264 if accessWriterConcrete != nil {
2265 accessWriter = accessWriterConcrete
2266 }
2267 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
2268 defer cancel()
2269
2270 // Set up SIGUSR1 for reload.
2271 // Note: signal.Stop must be called (via defer) to release the channel
2272 // and prevent signal handler leaks when the function returns.
2273 reloadChan := make(chan os.Signal, 1)
2274 signal.Notify(reloadChan, syscall.SIGUSR1)
2275 defer signal.Stop(reloadChan)
2276
2277 // Set up SIGUSR2 for manual deploy.
2278 deployChan := make(chan os.Signal, 1)
2279 signal.Notify(deployChan, syscall.SIGUSR2)
2280 defer signal.Stop(deployChan)
2281
2282 errChan := make(chan error, 3)
2283
2284 registryHandler, httpProxyHandler, httpsProxyHandler := createHTTPHandlers(svc, cfg, log, accessWriter)
2285
2286 registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort))
2287 registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log)
2288
2289 // closeStarted shuts down any servers that were started before an error occurred,
2290 // preventing leaked listeners during partial startup failures.
2291 closeStarted := func(servers ...*http.Server) {
2292 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
2293 defer shutdownCancel()
2294 for _, srv := range servers {
2295 if srv != nil {
2296 if err := srv.Shutdown(shutdownCtx); err != nil {
2297 log.Error().Err(err).Msg("failed to shut down server during startup cleanup")
2298 }
2299 }
2300 }
2301 }
2302
2303 proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, errChan, log)
2304 if err != nil {
2305 closeStarted(registrySrv)
2306 return err
2307 }
2308
2309 // Wait for all servers to bind their ports before auto-starting containers.
2310 // This prevents the race where auto-start pulls from the registry before it's listening.
2311 if err := waitForServerReady(registryReady, errChan); err != nil {
2312 closeStarted(registrySrv, proxySrv, tlsSrv)
2313 return err
2314 }
2315 if err := waitForServerReady(proxyReady, errChan); err != nil {
2316 closeStarted(registrySrv, proxySrv, tlsSrv)
2317 return err
2318 }
2319 if tlsReady != nil {
2320 if err := waitForServerReady(tlsReady, errChan); err != nil {
2321 closeStarted(registrySrv, proxySrv, tlsSrv)
2322 return err
2323 }
2324 }
2325
2326 logEvent := log.Info().
2327 Int("proxy_port", cfg.Server.Port).
2328 Int("registry_port", cfg.Server.RegistryPort)
2329 if tlsSrv != nil {
2330 logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort)
2331 }
2332 logEvent.Msg("Gordon is running")
2333
2334 schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)
2335 if err != nil {
2336 return err
2337 }
2338 if schedulerCleanup != nil {
2339 defer schedulerCleanup()
2340 }
2341
2342 // Auto-start after servers are listening (registry port is now bound).
2343 syncAndAutoStart(ctx, svc, log)
2344
2345 waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log)
2346 cleanupHandlers() // Stop debounce timers before draining containers
2347 gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, log)
2348 return nil
2349}
2350
2351func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
2352 select {
2353 case <-ready:
2354 return nil
2355 case err := <-errChan:
2356 return err
2357 }
2358}
2359
2360func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, v *viper.Viper) (func(), error) {
2361 schedulers := make([]*cronSvc.Scheduler, 0, 2)
2362
2363 backupScheduler, err := startBackupScheduler(ctx, cfg, svc, log)
2364 if err != nil {
2365 return nil, err
2366 }
2367 if backupScheduler != nil {
2368 schedulers = append(schedulers, backupScheduler)
2369 }
2370
2371 imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int {
2372 return v.GetInt("images.prune.keep_last")
2373 })
2374 if err != nil {
2375 return nil, err
2376 }
2377 if imageScheduler != nil {
2378 schedulers = append(schedulers, imageScheduler)
2379 }
2380
2381 if len(schedulers) == 0 {
2382 return nil, nil
2383 }
2384
2385 return func() {
2386 for i := len(schedulers) - 1; i >= 0; i-- {
2387 schedulers[i].Stop()
2388 }
2389 }, nil
2390}
2391
2392func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {
2393 if !cfg.Backups.Enabled || svc == nil || svc.backupSvc == nil {
2394 return nil, nil
2395 }
2396
2397 preset, err := resolveBackupSchedule(cfg.Backups.Schedule)
2398 if err != nil {
2399 return nil, err
2400 }
2401
2402 scheduler := cronSvc.NewScheduler(log)
2403 err = scheduler.Add(
2404 "backup-scheduler",
2405 "Backups",
2406 domain.CronSchedule{Preset: preset},
2407 func(jobCtx context.Context) error {
2408 if err := svc.backupSvc.RunForSchedule(jobCtx, preset); err != nil {
2409 return err
2410 }
2411 log.Info().
2412 Str("schedule", string(preset)).
2413 Msg("scheduled backup run complete")
2414 return nil
2415 },
2416 )
2417 if err != nil {
2418 return nil, log.WrapErr(err, "failed to register backup schedule")
2419 }
2420
2421 scheduler.Start(ctx)
2422 log.Info().
2423 Str("schedule", string(preset)).
2424 Msg("backup scheduler enabled")
2425
2426 return scheduler, nil
2427}
2428
2429func resolveBackupSchedule(raw string) (domain.BackupSchedule, error) {
2430 return resolveSchedulePreset(raw, "backups.schedule", domain.ScheduleDaily)
2431}
2432
2433func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, keepLastGetter func() int) (*cronSvc.Scheduler, error) {
2434 if !cfg.Images.Prune.Enabled || svc == nil || svc.imageSvc == nil {
2435 return nil, nil
2436 }
2437 if keepLastGetter == nil {
2438 keepLastGetter = func() int { return cfg.Images.Prune.KeepLast }
2439 }
2440 if keepLastGetter() < 0 {
2441 return nil, fmt.Errorf("images.prune.keep_last must be >= 0")
2442 }
2443
2444 preset, err := resolveImagePruneSchedule(cfg.Images.Prune.Schedule)
2445 if err != nil {
2446 return nil, err
2447 }
2448
2449 scheduler := cronSvc.NewScheduler(log)
2450 err = scheduler.Add(
2451 "image-prune",
2452 "Image prune",
2453 domain.CronSchedule{Preset: preset},
2454 func(jobCtx context.Context) error {
2455 keepLast := keepLastGetter()
2456 if keepLast < 0 {
2457 log.Warn().
2458 Int("configured_keep_last", keepLast).
2459 Int("fallback_keep_last", domain.DefaultImagePruneKeepLast).
2460 Msg("invalid images.prune.keep_last; using default")
2461 keepLast = domain.DefaultImagePruneKeepLast
2462 }
2463
2464 report, err := svc.imageSvc.Prune(jobCtx, domain.ImagePruneOptions{
2465 KeepLast: keepLast,
2466 PruneDangling: true,
2467 PruneRegistry: true,
2468 })
2469 if err != nil {
2470 return err
2471 }
2472
2473 log.Info().
2474 Int("keep_last", keepLast).
2475 Int("runtime_deleted", report.Runtime.DeletedCount).
2476 Int64("runtime_reclaimed_bytes", report.Runtime.SpaceReclaimed).
2477 Int("registry_tags_removed", report.Registry.TagsRemoved).
2478 Int("registry_blobs_removed", report.Registry.BlobsRemoved).
2479 Int("registry_uploads_removed", report.Registry.UploadsRemoved).
2480 Int64("registry_upload_bytes_reclaimed", report.Registry.UploadSpaceReclaimed).
2481 Msg("scheduled image prune complete")
2482 return nil
2483 },
2484 )
2485 if err != nil {
2486 return nil, log.WrapErr(err, "failed to register image prune schedule")
2487 }
2488
2489 scheduler.Start(ctx)
2490 log.Info().
2491 Str("schedule", string(preset)).
2492 Int("keep_last", keepLastGetter()).
2493 Msg("image prune scheduler enabled")
2494
2495 return scheduler, nil
2496}
2497
2498func resolveImagePruneSchedule(raw string) (domain.BackupSchedule, error) {
2499 return resolveSchedulePreset(raw, "images.prune.schedule", domain.ScheduleDaily)
2500}
2501
2502func resolveSchedulePreset(raw, name string, defaultVal domain.BackupSchedule) (domain.BackupSchedule, error) {
2503 schedule := domain.BackupSchedule(strings.ToLower(strings.TrimSpace(raw)))
2504 if schedule == "" {
2505 schedule = defaultVal
2506 }
2507
2508 switch schedule {
2509 case domain.ScheduleHourly, domain.ScheduleDaily, domain.ScheduleWeekly, domain.ScheduleMonthly:
2510 return schedule, nil
2511 default:
2512 return "", fmt.Errorf("%s must be one of: hourly, daily, weekly, monthly", name)
2513 }
2514}
2515
2516// waitForShutdown blocks on the event loop, handling server errors and
2517// Unix signals (reload, deploy, shutdown) until the context is cancelled.
2518func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, deployChan <-chan os.Signal, reload reloadTrigger, eventBus out.EventBus, log zerowrap.Logger) {
2519 for {
2520 select {
2521 case err := <-errChan:
2522 log.Error().Err(err).Msg("server error")
2523 return
2524 case <-reloadChan:
2525 log.Info().Msg("reload signal received (SIGUSR1)")
2526 _ = reload.Trigger(ctx)
2527 case <-deployChan:
2528 log.Info().Msg("deploy signal received (SIGUSR2)")
2529 domainName, err := readDeployRequest()
2530 if err != nil {
2531 log.Error().Err(err).Msg("failed to read deploy request")
2532 continue
2533 }
2534 payload := &domain.ManualDeployPayload{Domain: domainName}
2535 if err := eventBus.Publish(domain.EventManualDeploy, payload); err != nil {
2536 log.Error().Err(err).Str("domain", domainName).Msg("failed to publish manual deploy event")
2537 }
2538 case <-ctx.Done():
2539 log.Info().Msg("shutdown signal received")
2540 return
2541 }
2542 }
2543}
2544
2545// gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
2546// the container service and cleans up runtime files.
2547func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, log zerowrap.Logger) {
2548 log.Info().Msg("shutting down Gordon...")
2549
2550 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
2551 defer shutdownCancel()
2552
2553 // Phase 1: Stop ingress frontends (TLS, then proxy) — no new traffic accepted
2554 for _, srv := range []*http.Server{tlsSrv, proxySrv} {
2555 if srv == nil {
2556 continue
2557 }
2558 if err := srv.Shutdown(shutdownCtx); err != nil {
2559 log.Warn().Err(err).Str("addr", srv.Addr).Msg("server shutdown error")
2560 }
2561 }
2562
2563 // Stop PKI maintenance goroutines
2564 if pkiSvc != nil {
2565 pkiSvc.Stop()
2566 }
2567
2568 // Phase 2: Drain in-flight registry push sessions before stopping the backend
2569 if proxySvc != nil {
2570 log.Info().Msg("draining in-flight registry requests...")
2571 if drained := proxySvc.DrainRegistryInFlight(25 * time.Second); !drained {
2572 log.Warn().Int64("in_flight", proxySvc.RegistryInFlight()).Msg("registry drain timed out; some in-flight pushes may be interrupted")
2573 }
2574 }
2575
2576 // Phase 3: Stop the registry backend
2577 if registrySrv != nil {
2578 if err := registrySrv.Shutdown(shutdownCtx); err != nil {
2579 log.Warn().Err(err).Str("addr", registrySrv.Addr).Msg("server shutdown error")
2580 }
2581 }
2582
2583 containerSvc.StopMonitor()
2584
2585 if err := containerSvc.Shutdown(shutdownCtx); err != nil {
2586 log.Warn().Err(err).Msg("error during container shutdown")
2587 }
2588
2589 cleanupInternalCredentials()
2590 log.Info().Msg("Gordon stopped")
2591}
2592
2593// startProxyServers sets up the HTTP proxy server and, when tls_port != 0,
2594// an HTTPS proxy server with on-demand TLS certificates from the internal CA.
2595func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {
2596 // HTTP listener (Cloudflare proxy + onboarding for direct clients)
2597 var httpProtos http.Protocols
2598 httpProtos.SetHTTP1(true)
2599 httpProtos.SetUnencryptedHTTP2(true)
2600 httpSrv, httpReady := startServer(
2601 fmt.Sprintf(":%d", cfg.Server.Port),
2602 httpHandler,
2603 "proxy-http",
2604 &httpProtos,
2605 errChan,
2606 log,
2607 )
2608
2609 if cfg.Server.TLSPort == 0 {
2610 return httpSrv, httpReady, nil, nil, nil
2611 }
2612
2613 // HTTPS listener — static cert (if provided) takes priority by SNI match,
2614 // unmatched domains fall through to on-demand internal CA certs.
2615 tlsConfig := &tls.Config{
2616 MinVersion: tls.VersionTLS12,
2617 GetCertificate: pkiSvc.GetCertificate,
2618 NextProtos: []string{"h2", "http/1.1"},
2619 }
2620 if cfg.Server.TLSCertFile != "" {
2621 staticCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile)
2622 if err != nil {
2623 return nil, nil, nil, nil, fmt.Errorf("load TLS keypair: %w", err)
2624 }
2625 tlsConfig.Certificates = []tls.Certificate{staticCert}
2626 log.Info().
2627 Str("cert", cfg.Server.TLSCertFile).
2628 Str("key", cfg.Server.TLSKeyFile).
2629 Msg("loaded static TLS certificate (internal CA handles remaining domains)")
2630 }
2631 tlsSrv, tlsReady := startTLSServerWithConfig(
2632 fmt.Sprintf(":%d", cfg.Server.TLSPort),
2633 httpsHandler,
2634 "proxy-tls",
2635 tlsConfig,
2636 errChan,
2637 log,
2638 )
2639
2640 return httpSrv, httpReady, tlsSrv, tlsReady, nil
2641}
2642
2643// startServer starts an HTTP server, returning the server instance and a channel
2644// that closes once the listening socket is bound. This lets callers wait for the
2645// port to be ready before taking actions that depend on it (e.g. auto-start
2646// pulling from the local registry). The returned *http.Server can be used for
2647// graceful shutdown.
2648func startServer(addr string, handler http.Handler, name string, protocols *http.Protocols, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {
2649 ready := make(chan struct{})
2650
2651 server := &http.Server{
2652 Addr: addr,
2653 Handler: handler,
2654 Protocols: protocols,
2655 ReadHeaderTimeout: 10 * time.Second,
2656 ReadTimeout: 5 * time.Minute,
2657 WriteTimeout: 5 * time.Minute,
2658 IdleTimeout: 120 * time.Second,
2659 MaxHeaderBytes: 1 << 20,
2660 }
2661
2662 go func() {
2663 log.Info().Str("address", addr).Msgf("%s server starting", name)
2664
2665 ln, err := net.Listen("tcp", addr)
2666 if err != nil {
2667 errChan <- fmt.Errorf("%s server error: %w", name, err)
2668 return
2669 }
2670 close(ready) // signal: port is bound and accepting connections
2671
2672 if err := server.Serve(ln); err != nil && err != http.ErrServerClosed {
2673 errChan <- fmt.Errorf("%s server error: %w", name, err)
2674 }
2675 }()
2676
2677 return server, ready
2678}
2679
2680// startTLSServerWithConfig starts an HTTPS server using a pre-built tls.Config
2681// (e.g. with GetCertificate for on-demand issuance via the internal CA).
2682// Returns the server instance and a channel that closes once the port is bound.
2683func startTLSServerWithConfig(addr string, handler http.Handler, name string, tlsCfg *tls.Config, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {
2684 srv := &http.Server{
2685 Addr: addr,
2686 Handler: handler,
2687 TLSConfig: tlsCfg,
2688 ReadHeaderTimeout: 10 * time.Second,
2689 ReadTimeout: 5 * time.Minute,
2690 WriteTimeout: 5 * time.Minute,
2691 IdleTimeout: 120 * time.Second,
2692 MaxHeaderBytes: 1 << 20,
2693 }
2694
2695 ready := make(chan struct{})
2696 go func() {
2697 ln, err := net.Listen("tcp", addr)
2698 if err != nil {
2699 errChan <- fmt.Errorf("%s listen: %w", name, err)
2700 return
2701 }
2702 tlsLn := tls.NewListener(ln, tlsCfg)
2703 log.Info().Str("addr", addr).Str("name", name).Msg("TLS server starting")
2704 close(ready)
2705 if err := srv.Serve(tlsLn); err != nil && err != http.ErrServerClosed {
2706 errChan <- fmt.Errorf("%s: %w", name, err)
2707 }
2708 }()
2709
2710 return srv, ready
2711}
2712
2713// SendReloadSignal sends SIGUSR1 to the running Gordon process.
2714func SendReloadSignal() error {
2715 process, _, err := findRunningProcess()
2716 if err != nil {
2717 return err
2718 }
2719
2720 if err := process.Signal(syscall.SIGUSR1); err != nil {
2721 return fmt.Errorf("failed to send reload signal: %w", err)
2722 }
2723
2724 return nil
2725}
2726
2727// getDeployRequestFile returns the path to the deploy request file.
2728func getDeployRequestFile() string {
2729 return filepath.Join(os.TempDir(), "gordon-deploy-request")
2730}
2731
2732// writeDeployRequestFile creates the deploy request file exclusively with retry.
2733// This prevents race conditions when multiple deploy commands run simultaneously.
2734func writeDeployRequestFile(path string, data []byte, timeout time.Duration) error {
2735 deadline := time.Now().Add(timeout)
2736
2737 for {
2738 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
2739 if err == nil {
2740 _, writeErr := f.Write(data)
2741 f.Close()
2742 if writeErr != nil {
2743 _ = os.Remove(path)
2744 return writeErr
2745 }
2746 return nil
2747 }
2748
2749 if os.IsExist(err) {
2750 if time.Now().After(deadline) {
2751 return fmt.Errorf("deploy request file still present after timeout; another deploy may be in progress")
2752 }
2753 time.Sleep(100 * time.Millisecond)
2754 continue
2755 }
2756
2757 return err
2758 }
2759}
2760
2761// SendDeploySignal triggers a manual deploy for a specific route via SIGUSR2.
2762// Returns the domain name on success for the caller to display.
2763func SendDeploySignal(domain string) (string, error) {
2764 deployFile := getDeployRequestFile()
2765 if err := writeDeployRequestFile(deployFile, []byte(domain), 5*time.Second); err != nil {
2766 return "", fmt.Errorf("failed to write deploy request: %w", err)
2767 }
2768
2769 // Find PID and send SIGUSR2
2770 process, _, err := findRunningProcess()
2771 if err != nil {
2772 _ = os.Remove(deployFile)
2773 return "", err
2774 }
2775
2776 if err := process.Signal(syscall.SIGUSR2); err != nil {
2777 _ = os.Remove(deployFile)
2778 return "", fmt.Errorf("failed to send deploy signal: %w", err)
2779 }
2780
2781 return domain, nil
2782}
2783
2784// readDeployRequest reads and removes the deploy request file atomically.
2785// Returns empty string if file doesn't exist (may have been consumed by another handler).
2786func readDeployRequest() (string, error) {
2787 deployFile := getDeployRequestFile()
2788
2789 // Rename to a temp file first to make the read-and-delete atomic
2790 tmpFile := deployFile + ".processing"
2791 if err := os.Rename(deployFile, tmpFile); err != nil {
2792 if os.IsNotExist(err) {
2793 return "", fmt.Errorf("deploy request file not found (may have been processed already)")
2794 }
2795 return "", fmt.Errorf("failed to acquire deploy request: %w", err)
2796 }
2797
2798 data, err := os.ReadFile(tmpFile)
2799 _ = os.Remove(tmpFile) // Always clean up
2800 if err != nil {
2801 return "", fmt.Errorf("failed to read deploy request: %w", err)
2802 }
2803
2804 return string(data), nil
2805}
2806
2807// createPidFile creates a PID file for the Gordon process.
2808// SECURITY: Prefers secure locations (XDG_RUNTIME_DIR, ~/.gordon/run) over /tmp
2809// to prevent symlink attacks and unauthorized access.
2810func createPidFile(log zerowrap.Logger) string {
2811 pid := os.Getpid()
2812
2813 // SECURITY: Prioritize secure locations over /tmp
2814 var locations []string
2815
2816 // Try secure runtime directory first
2817 if runtimeDir, err := getSecureRuntimeDir(); err == nil {
2818 locations = append(locations, filepath.Join(runtimeDir, "gordon.pid"))
2819 }
2820
2821 // Fall back to home directory
2822 if homeDir, err := os.UserHomeDir(); err == nil {
2823 locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid"))
2824 }
2825
2826 // Last resort: /tmp (least secure due to world-writable)
2827 locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid"))
2828
2829 for _, location := range locations {
2830 // Ensure parent directory exists with secure permissions
2831 if err := os.MkdirAll(filepath.Dir(location), 0700); err != nil {
2832 continue
2833 }
2834 if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil {
2835 log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file")
2836 return location
2837 }
2838 }
2839
2840 log.Warn().Int("pid", pid).Msg("failed to create PID file in any location")
2841 return ""
2842}
2843
2844// removePidFile removes the PID file.
2845func removePidFile(pidFile string, log zerowrap.Logger) {
2846 if err := os.Remove(pidFile); err != nil {
2847 log.Warn().Err(err).Str("pid_file", pidFile).Msg("failed to remove PID file")
2848 } else {
2849 log.Debug().Str("pid_file", pidFile).Msg("removed PID file")
2850 }
2851}
2852
2853func pidFileLocations() []string {
2854 var locations []string
2855 seen := make(map[string]struct{})
2856 add := func(path string) {
2857 if path == "" {
2858 return
2859 }
2860 if _, ok := seen[path]; ok {
2861 return
2862 }
2863 seen[path] = struct{}{}
2864 locations = append(locations, path)
2865 }
2866
2867 // Check secure runtime directory first
2868 if runtimeDir, err := getSecureRuntimeDir(); err == nil {
2869 add(filepath.Join(runtimeDir, "gordon.pid"))
2870 }
2871
2872 // Also check canonical /run/user/<uid> runtime path. This handles cases where
2873 // Gordon started under systemd user services with runtime dir available, but
2874 // CLI invocations (e.g. non-interactive SSH) don't have XDG_RUNTIME_DIR set.
2875 runtimeByUID := filepath.Join("/run/user", strconv.Itoa(os.Getuid()), "gordon", "gordon.pid")
2876 add(runtimeByUID)
2877
2878 // Check explicit XDG_RUNTIME_DIR if present in this process env.
2879 if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
2880 add(filepath.Join(runtimeDir, "gordon.pid"))
2881 }
2882
2883 // Check home directory
2884 if homeDir, err := os.UserHomeDir(); err == nil {
2885 add(filepath.Join(homeDir, ".gordon", "gordon.pid"))
2886 // Legacy location for backward compatibility
2887 add(filepath.Join(homeDir, ".gordon.pid"))
2888 }
2889
2890 // Legacy /tmp locations for backward compatibility
2891 add(filepath.Join(os.TempDir(), "gordon.pid"))
2892 add("/tmp/gordon.pid")
2893
2894 return locations
2895}
2896
2897// findRunningPidFile returns the first PID file whose PID belongs to a live process.
2898// Stale/invalid PID files are ignored and removed when possible.
2899func findRunningPidFile() (string, int, error) {
2900 return findRunningPidFileInLocations(pidFileLocations())
2901}
2902
2903func findRunningPidFileInLocations(locations []string) (string, int, error) {
2904 foundAny := false
2905
2906 for _, location := range locations {
2907 pidBytes, err := os.ReadFile(location)
2908 if err != nil {
2909 continue
2910 }
2911
2912 foundAny = true
2913
2914 var pid int
2915 if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil || pid <= 0 {
2916 _ = os.Remove(location)
2917 continue
2918 }
2919
2920 if isProcessAlive(pid) {
2921 return location, pid, nil
2922 }
2923
2924 _ = os.Remove(location)
2925 }
2926
2927 if foundAny {
2928 return "", 0, fmt.Errorf("found stale gordon PID file(s), is Gordon running?")
2929 }
2930
2931 return "", 0, fmt.Errorf("gordon PID file not found, is Gordon running?")
2932}
2933
2934func findRunningProcess() (*os.Process, int, error) {
2935 _, pid, err := findRunningPidFile()
2936 if err != nil {
2937 return nil, 0, err
2938 }
2939
2940 process, err := os.FindProcess(pid)
2941 if err != nil {
2942 return nil, 0, fmt.Errorf("failed to find process: %w", err)
2943 }
2944
2945 return process, pid, nil
2946}
2947
2948func isProcessAlive(pid int) bool {
2949 process, err := os.FindProcess(pid)
2950 if err != nil {
2951 return false
2952 }
2953
2954 err = process.Signal(syscall.Signal(0))
2955 if err == nil {
2956 return true
2957 }
2958
2959 return errors.Is(err, syscall.EPERM)
2960}
2961
2962// loadConfig loads configuration from file and sets defaults.
2963func loadConfig(v *viper.Viper, configPath string) error {
2964 v.SetDefault("server.port", 8088)
2965 v.SetDefault("server.registry_port", 5000)
2966 v.SetDefault("server.tls_port", 8443)
2967 v.SetDefault("server.tls_cert_file", "")
2968 v.SetDefault("server.tls_key_file", "")
2969 v.SetDefault("server.force_https_redirect", false)
2970 v.SetDefault("server.data_dir", DefaultDataDir())
2971 v.SetDefault("server.runtime", "auto")
2972 v.SetDefault("logging.level", "info")
2973 v.SetDefault("logging.format", "console")
2974 v.SetDefault("logging.file.enabled", false)
2975 v.SetDefault("logging.file.max_size", 100)
2976 v.SetDefault("logging.file.max_backups", 3)
2977 v.SetDefault("logging.file.max_age", 28)
2978 v.SetDefault("logging.container_logs.enabled", true)
2979 v.SetDefault("logging.container_logs.dir", "")
2980 v.SetDefault("logging.container_logs.max_size", 100)
2981 v.SetDefault("logging.container_logs.max_backups", 3)
2982 v.SetDefault("logging.container_logs.max_age", 28)
2983 v.SetDefault("logging.access_log.enabled", false)
2984 v.SetDefault("logging.access_log.format", "json")
2985 v.SetDefault("logging.access_log.output", "stdout")
2986 v.SetDefault("logging.access_log.file_path", "")
2987 v.SetDefault("logging.access_log.max_size", 100)
2988 v.SetDefault("logging.access_log.max_backups", 3)
2989 v.SetDefault("logging.access_log.max_age", 28)
2990 v.SetDefault("logging.access_log.exclude_health_checks", true)
2991 v.SetDefault("logging.access_log.syslog_identifier", "gordon-access")
2992 v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
2993 v.SetDefault("auth.enabled", true)
2994 // Note: auth.type defaults to "token" (the only supported mode)
2995 v.SetDefault("auth.secrets_backend", "")
2996 v.SetDefault("auth.token_expiry", "720h")
2997 v.SetDefault("api.rate_limit.enabled", true)
2998 v.SetDefault("api.rate_limit.global_rps", 500)
2999 v.SetDefault("api.rate_limit.per_ip_rps", 50)
3000 v.SetDefault("api.rate_limit.burst", 100)
3001 v.SetDefault("auto_route.enabled", false)
3002 v.SetDefault("network_isolation.enabled", true)
3003 v.SetDefault("network_isolation.network_prefix", "gordon")
3004 v.SetDefault("network_isolation.internal", false)
3005 v.SetDefault("volumes.auto_create", true)
3006 v.SetDefault("volumes.prefix", "gordon")
3007 v.SetDefault("volumes.preserve", true)
3008 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
3009 v.SetDefault("backups.enabled", false)
3010 v.SetDefault("backups.schedule", string(domain.ScheduleDaily))
3011 v.SetDefault("backups.storage_dir", "")
3012 v.SetDefault("backups.retention.hourly", 0)
3013 v.SetDefault("backups.retention.daily", 0)
3014 v.SetDefault("backups.retention.weekly", 0)
3015 v.SetDefault("backups.retention.monthly", 0)
3016 v.SetDefault("images.allowed_registries", []string{})
3017 v.SetDefault("images.require_digest", false)
3018 v.SetDefault("images.prune.enabled", false)
3019 v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))
3020 v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast)
3021 v.SetDefault("containers.security_profile", "compat")
3022 v.SetDefault("telemetry.enabled", false)
3023 v.SetDefault("telemetry.endpoint", "")
3024 v.SetDefault("telemetry.auth_token", "")
3025 v.SetDefault("telemetry.traces", true)
3026 v.SetDefault("telemetry.metrics", true)
3027 v.SetDefault("telemetry.logs", true)
3028 v.SetDefault("telemetry.trace_sample_rate", 1.0)
3029
3030 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
3031 v.SetDefault("server.registry_allowed_ips", []string{})
3032 v.SetDefault("server.proxy_allowed_ips", []string{})
3033 v.SetDefault("server.registry_listen_address", "")
3034 v.SetDefault("deploy.readiness_delay", "5s")
3035 v.SetDefault("deploy.readiness_mode", "auto")
3036 v.SetDefault("deploy.health_timeout", "90s")
3037 v.SetDefault("deploy.stabilization_delay", "2s")
3038 v.SetDefault("deploy.tcp_probe_timeout", "30s")
3039 v.SetDefault("deploy.http_probe_timeout", "60s")
3040 v.SetDefault("deploy.attachment_readiness_timeout", "30s")
3041 v.SetDefault("deploy.drain_mode", "auto")
3042 v.SetDefault("deploy.drain_timeout", "30s")
3043
3044 ConfigureViper(v, configPath)
3045
3046 if err := v.ReadInConfig(); err != nil {
3047 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
3048 return fmt.Errorf("failed to read config file: %w", err)
3049 }
3050 }
3051
3052 v.SetEnvPrefix("GORDON")
3053 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
3054 v.AutomaticEnv()
3055
3056 return nil
3057}