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