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