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