1// Package app provides the application initialization and wiring.
2package app
3
4import (
5 "context"
6 "crypto/rand"
7 "crypto/tls"
8 "crypto/x509"
9 "encoding/hex"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "net"
14 "net/http"
15 "os"
16 "os/signal"
17 "path/filepath"
18 "strconv"
19 "strings"
20 "sync"
21 "syscall"
22 "time"
23
24 "github.com/bnema/zerowrap"
25 zerowrapotel "github.com/bnema/zerowrap/otel"
26 "github.com/spf13/viper"
27 "golang.org/x/sys/unix"
28
29 // Adapters - Output
30 "github.com/bnema/gordon/internal/adapters/out/accesslog"
31 acmelego "github.com/bnema/gordon/internal/adapters/out/acmelego"
32 acmestore "github.com/bnema/gordon/internal/adapters/out/acmestore"
33 "github.com/bnema/gordon/internal/adapters/out/docker"
34 "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
35 "github.com/bnema/gordon/internal/adapters/out/envloader"
36 "github.com/bnema/gordon/internal/adapters/out/eventbus"
37 "github.com/bnema/gordon/internal/adapters/out/filesystem"
38 "github.com/bnema/gordon/internal/adapters/out/httpprober"
39 "github.com/bnema/gordon/internal/adapters/out/logwriter"
40 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
41 "github.com/bnema/gordon/internal/adapters/out/ratelimit"
42 s3storage "github.com/bnema/gordon/internal/adapters/out/s3"
43 "github.com/bnema/gordon/internal/adapters/out/secrets"
44 "github.com/bnema/gordon/internal/adapters/out/telemetry"
45 "github.com/bnema/gordon/internal/adapters/out/tokenstore"
46
47 // OTel
48 "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
49
50 // Adapters - Input
51 "github.com/bnema/gordon/internal/adapters/dto"
52 acmehttp "github.com/bnema/gordon/internal/adapters/in/http/acme"
53 "github.com/bnema/gordon/internal/adapters/in/http/admin"
54 authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth"
55 "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
56 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
57 "github.com/bnema/gordon/internal/adapters/in/http/onboarding"
58 proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy"
59 "github.com/bnema/gordon/internal/adapters/in/http/registry"
60 trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
61
62 // Boundaries
63 "github.com/bnema/gordon/internal/boundaries/in"
64 "github.com/bnema/gordon/internal/boundaries/out"
65
66 // Domain
67 "github.com/bnema/gordon/internal/domain"
68
69 // Packages
70 "github.com/bnema/gordon/pkg/version"
71
72 // Use cases
73 "github.com/bnema/gordon/internal/usecase/auth"
74 "github.com/bnema/gordon/internal/usecase/auto"
75 "github.com/bnema/gordon/internal/usecase/auto/preview"
76 "github.com/bnema/gordon/internal/usecase/backup"
77 "github.com/bnema/gordon/internal/usecase/config"
78 "github.com/bnema/gordon/internal/usecase/container"
79 cronSvc "github.com/bnema/gordon/internal/usecase/cron"
80 "github.com/bnema/gordon/internal/usecase/health"
81 "github.com/bnema/gordon/internal/usecase/images"
82 "github.com/bnema/gordon/internal/usecase/logs"
83 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
84 "github.com/bnema/gordon/internal/usecase/proxy"
85 "github.com/bnema/gordon/internal/usecase/publictls"
86 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
87 "github.com/bnema/gordon/internal/usecase/registrystate"
88 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
89 servicecfg "github.com/bnema/gordon/internal/usecase/services"
90 "github.com/bnema/gordon/internal/usecase/traffic"
91 volumesSvc "github.com/bnema/gordon/internal/usecase/volumes"
92
93 // Pkg
94 "github.com/bnema/gordon/pkg/bytesize"
95 "github.com/bnema/gordon/pkg/duration"
96)
97
98// Config holds the application configuration.
99type Config struct {
100 Server struct {
101 Port int `mapstructure:"port"`
102 RegistryPort int `mapstructure:"registry_port"`
103 GordonDomain string `mapstructure:"gordon_domain"`
104 RegistryDomain string `mapstructure:"registry_domain"`
105 LegacyRegistryDomains []string `mapstructure:"legacy_registry_domains"`
106 TLSPort int `mapstructure:"tls_port"`
107 TLSCertFile string `mapstructure:"tls_cert_file"`
108 TLSKeyFile string `mapstructure:"tls_key_file"`
109 ForceHTTPSRedirect bool `mapstructure:"force_https_redirect"`
110 DataDir string `mapstructure:"data_dir"`
111 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
112 MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
113 MaxBlobSize string `mapstructure:"max_blob_size"` // e.g., "1GB", "2GB"
114 MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
115 MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`
116 RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`
117 ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"`
118 RegistryListenAddr string `mapstructure:"registry_listen_address"`
119 } `mapstructure:"server"`
120
121 Logging struct {
122 Level string `mapstructure:"level"`
123 Format string `mapstructure:"format"`
124 File struct {
125 Enabled bool `mapstructure:"enabled"`
126 Path string `mapstructure:"path"`
127 MaxSize int `mapstructure:"max_size"`
128 MaxBackups int `mapstructure:"max_backups"`
129 MaxAge int `mapstructure:"max_age"`
130 } `mapstructure:"file"`
131 ContainerLogs struct {
132 Enabled bool `mapstructure:"enabled"`
133 Dir string `mapstructure:"dir"`
134 MaxSize int `mapstructure:"max_size"`
135 MaxBackups int `mapstructure:"max_backups"`
136 MaxAge int `mapstructure:"max_age"`
137 } `mapstructure:"container_logs"`
138 AccessLog struct {
139 Enabled bool `mapstructure:"enabled"`
140 Format string `mapstructure:"format"`
141 Output string `mapstructure:"output"`
142 FilePath string `mapstructure:"file_path"`
143 MaxSize int `mapstructure:"max_size"`
144 MaxBackups int `mapstructure:"max_backups"`
145 MaxAge int `mapstructure:"max_age"`
146 ExcludeHealthChecks bool `mapstructure:"exclude_health_checks"`
147 SyslogIdentifier string `mapstructure:"syslog_identifier"`
148 } `mapstructure:"access_log"`
149 } `mapstructure:"logging"`
150
151 Env struct {
152 Dir string `mapstructure:"dir"`
153 } `mapstructure:"env"`
154
155 Volumes struct {
156 AutoCreate bool `mapstructure:"auto_create"`
157 Prefix string `mapstructure:"prefix"`
158 Preserve bool `mapstructure:"preserve"`
159 } `mapstructure:"volumes"`
160
161 Auth struct {
162 Enabled bool `mapstructure:"enabled"`
163 Type string `mapstructure:"type"` // only "token" is supported
164 SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe"
165 Username string `mapstructure:"username"`
166 TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
167 TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
168 AccessTokenTTL string `mapstructure:"access_token_ttl"` // e.g., "15m", "30m" (default: 15m)
169 } `mapstructure:"auth"`
170
171 API struct {
172 RateLimit struct {
173 Enabled bool `mapstructure:"enabled"`
174 GlobalRPS float64 `mapstructure:"global_rps"`
175 PerIPRPS float64 `mapstructure:"per_ip_rps"`
176 Burst int `mapstructure:"burst"`
177 TrustedProxies []string `mapstructure:"trusted_proxies"`
178 } `mapstructure:"rate_limit"`
179 } `mapstructure:"api"`
180
181 EntryPoints map[string]traffic.EntryPointConfig `mapstructure:"entrypoints"`
182 Traffic traffic.Config `mapstructure:"traffic"`
183 NetworkServices []traffic.NetworkServiceConfig `mapstructure:"network_services"`
184 Services []servicecfg.Config `mapstructure:"services"`
185
186 Backups struct {
187 // Legacy database backup keys. Prefer backups.databases.* for new configs.
188 Enabled bool `mapstructure:"enabled"`
189 Schedule string `mapstructure:"schedule"`
190 StorageDir string `mapstructure:"storage_dir"`
191 Retention struct {
192 Hourly int `mapstructure:"hourly"`
193 Daily int `mapstructure:"daily"`
194 Weekly int `mapstructure:"weekly"`
195 Monthly int `mapstructure:"monthly"`
196 } `mapstructure:"retention"`
197 Databases struct {
198 Enabled bool `mapstructure:"enabled"`
199 Schedule string `mapstructure:"schedule"`
200 StorageDir string `mapstructure:"storage_dir"`
201 Retention struct {
202 Hourly int `mapstructure:"hourly"`
203 Daily int `mapstructure:"daily"`
204 Weekly int `mapstructure:"weekly"`
205 Monthly int `mapstructure:"monthly"`
206 } `mapstructure:"retention"`
207 } `mapstructure:"databases"`
208 Volumes struct {
209 Enabled bool `mapstructure:"enabled"`
210 Interval string `mapstructure:"interval"`
211 Compression string `mapstructure:"compression"`
212 Timeout string `mapstructure:"timeout"`
213 MaxConcurrency int `mapstructure:"max_concurrency"`
214 HelperImage string `mapstructure:"helper_image"`
215 S3 struct {
216 Bucket string `mapstructure:"bucket"`
217 Region string `mapstructure:"region"`
218 Prefix string `mapstructure:"prefix"`
219 Endpoint string `mapstructure:"endpoint"`
220 PathStyle bool `mapstructure:"path_style"`
221 SSEAlgorithm string `mapstructure:"sse_algorithm"`
222 SSEKMSKeyID string `mapstructure:"sse_kms_key_id"`
223 } `mapstructure:"s3"`
224 Retention struct {
225 Keep int `mapstructure:"keep"`
226 } `mapstructure:"retention"`
227 } `mapstructure:"volumes"`
228 } `mapstructure:"backups"`
229
230 Images struct {
231 AllowedRegistries []string `mapstructure:"allowed_registries"`
232 RequireDigest bool `mapstructure:"require_digest"`
233 Prune struct {
234 Enabled bool `mapstructure:"enabled"`
235 Schedule string `mapstructure:"schedule"`
236 KeepLast int `mapstructure:"keep_last"`
237 } `mapstructure:"prune"`
238 } `mapstructure:"images"`
239
240 Containers struct {
241 MemoryLimit string `mapstructure:"memory_limit"` // e.g., "512MB", "1GB"
242 CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core
243 PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512
244 SecurityProfile string `mapstructure:"security_profile"` // compat or strict
245 } `mapstructure:"containers"`
246
247 Telemetry telemetry.Config `mapstructure:"telemetry"`
248
249 TLS struct {
250 ACME struct {
251 Enabled bool `mapstructure:"enabled"`
252 Email string `mapstructure:"email"`
253 Challenge string `mapstructure:"challenge"`
254 ObtainBatchSize int `mapstructure:"obtain_batch_size"`
255 } `mapstructure:"acme"`
256 } `mapstructure:"tls"`
257
258 DNS struct {
259 Resolvers []string `mapstructure:"resolvers"`
260 PropagationTimeout string `mapstructure:"propagation_timeout"`
261 PollingInterval string `mapstructure:"polling_interval"`
262 } `mapstructure:"dns"`
263}
264
265// services holds all the services used by the application.
266type services struct {
267 runtime *docker.Runtime
268 eventBus *eventbus.InMemory
269 blobStorage *filesystem.BlobStorage
270 manifestStorage *filesystem.ManifestStorage
271 backupStorage *filesystem.BackupStorage
272 volumeBackupStore out.VolumeBackupStorage
273 volumeBackupCfg domain.VolumeBackupConfig
274 envLoader out.EnvLoader
275 logWriter *logwriter.LogWriter
276 tokenStore out.TokenStore
277 configSvc *config.Service
278 secretSvc *secretsSvc.Service
279 containerSvc *container.Service
280 backupSvc *backup.Service
281 volumeBackupSvc *backup.VolumeService
282 registrySvc *registrySvc.Service
283 healthSvc *health.Service
284 logSvc *logs.Service
285 imageSvc *images.Service
286 volumeSvc *volumesSvc.Service
287 proxySvc *proxy.Service
288 standaloneServiceSvc in.StandaloneServiceService
289 serviceSecretProvider out.SecretProvider
290 authSvc *auth.Service
291 authHandler *authhandler.Handler
292 adminHandler *admin.Handler
293 httpProxyHandler http.Handler
294 httpsProxyHandler http.Handler
295 internalRegUser string
296 internalRegPass string
297 previewStore *filesystem.PreviewStore
298 previewService *preview.Service
299 envDir string
300 maxBlobChunkSize int64
301 maxBlobSize int64
302 caAdapter *pkiadapter.CA
303 pkiSvc *pkiusecase.Service
304 reloadCoordinator *reloadCoordinator
305 publicTLSSvc in.PublicTLSService
306 publicTLSRuntime publicTLSRuntime
307 trafficManager *trafficadapter.Manager
308 tlsHTTPEntryPoints map[string]struct{}
309 smartHTTPEntryPoints map[string]struct{}
310 registryHandler interface {
311 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
312 }
313}
314
315// Run initializes and starts the Gordon application.
316func Run(ctx context.Context, configPath string) error {
317 // Load configuration
318 v, cfg, err := initConfig(configPath)
319 if err != nil {
320 return err
321 }
322
323 // Initialize logger
324 log, cleanup, err := initLogger(cfg)
325 if err != nil {
326 return err
327 }
328 if cleanup != nil {
329 defer cleanup()
330 }
331
332 ctx = zerowrap.WithCtx(ctx, log)
333
334 // Initialize OpenTelemetry
335 telProvider, telShutdown, err := telemetry.NewProvider(ctx, cfg.Telemetry, "gordon", version.Version())
336 if err != nil {
337 log.Warn().Err(err).Msg("failed to initialize telemetry, continuing without it")
338 } else {
339 // Use a fresh context for shutdown so a canceled app ctx doesn't prevent flushing.
340 defer func() {
341 shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
342 defer cancel()
343 telShutdown(shutdownCtx)
344 }()
345 if cfg.Telemetry.Enabled && cfg.Telemetry.Endpoint != "" {
346 // Bridge zerowrap logs to OTel if log export is enabled
347 if cfg.Telemetry.Logs && telProvider.LogProvider != nil {
348 otelHook := zerowrapotel.NewHookWithProvider(telProvider.LogProvider, "gordon")
349 log = zerowrap.WithHook(log, otelHook)
350 ctx = zerowrap.WithCtx(ctx, log)
351 }
352 log.Info().Str("endpoint", cfg.Telemetry.Endpoint).Msg("telemetry initialized")
353 }
354 }
355
356 log.Info().Msg("Gordon starting")
357
358 warnDeprecatedConfigKeys(v, log)
359
360 // Create PID file
361 pidFile := createPidFile(log)
362 if pidFile != "" {
363 defer removePidFile(pidFile, log)
364 }
365
366 // Create all services
367 svc, err := createServices(ctx, v, cfg, log)
368 if err != nil {
369 return err
370 }
371
372 // Register event handlers
373 cleanupHandlers, err := registerEventHandlers(ctx, svc, cfg)
374 if err != nil {
375 return err
376 }
377 // cleanupHandlers is passed into runServers so it can stop debounce
378 // timers before graceful shutdown, preventing deploys during drain.
379
380 // Set up config hot reload
381 if err := setupConfigHotReload(ctx, svc.configSvc, svc.reloadCoordinator); err != nil {
382 return err
383 }
384
385 // Start event bus
386 if err := svc.eventBus.Start(); err != nil {
387 return log.WrapErr(err, "failed to start event bus")
388 }
389 defer svc.eventBus.Stop()
390
391 // Start servers, wait for listeners to bind, then sync/auto-start containers.
392 return runServers(ctx, v, cfg, svc, svc.reloadCoordinator, cleanupHandlers, log)
393}
394
395func warnDeprecatedConfigKeys(v *viper.Viper, log zerowrap.Logger) {
396 for _, key := range []string{"server.tls_enabled", "server.force_hsts"} {
397 if v.IsSet(key) {
398 log.Warn().Str("key", key).Msg("deprecated config key — Gordon now uses an internal CA with automatic TLS; remove this from your config")
399 }
400 }
401}
402
403// initConfig loads configuration from file.
404func initConfig(configPath string) (*viper.Viper, Config, error) {
405 v := viper.New()
406 if err := loadConfig(v, configPath); err != nil {
407 return nil, Config{}, fmt.Errorf("failed to load config: %w", err)
408 }
409
410 var cfg Config
411 if err := v.Unmarshal(&cfg); err != nil {
412 return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
413 }
414 if err := validateEntrypointMigration(v, cfg); err != nil {
415 return nil, Config{}, err
416 }
417
418 return v, cfg, nil
419}
420
421func validateEntrypointMigration(v *viper.Viper, cfg Config) error {
422 if len(cfg.EntryPoints) > 0 {
423 return nil
424 }
425
426 legacyKeys := make([]string, 0, 2)
427 for _, key := range []string{"server.port", "server.tls_port"} {
428 if v.IsSet(key) {
429 legacyKeys = append(legacyKeys, key)
430 }
431 }
432 if len(legacyKeys) == 0 {
433 return nil
434 }
435
436 return fmt.Errorf("legacy %s configuration requires at least one [entrypoints] entry; see docs/upgrading.md", strings.Join(legacyKeys, " or "))
437}
438
439// initLogger initializes the zerowrap logger.
440func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
441 logConfig := zerowrap.Config{
442 Level: cfg.Logging.Level,
443 Format: cfg.Logging.Format,
444 }
445
446 // Always enable file logging so admin API can read process logs
447 // (Config flag is respected for backward-compatibility)
448 logPath := cfg.Logging.File.Path
449 if logPath == "" {
450 dataDir := cfg.Server.DataDir
451 if dataDir == "" {
452 dataDir = DefaultDataDir()
453 }
454 logPath = filepath.Join(dataDir, "logs", "gordon.log")
455 }
456
457 log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{
458 Enabled: cfg.Logging.File.Enabled,
459 Path: logPath,
460 MaxSize: cfg.Logging.File.MaxSize,
461 MaxBackups: cfg.Logging.File.MaxBackups,
462 MaxAge: cfg.Logging.File.MaxAge,
463 Compress: true,
464 })
465 if err != nil {
466 return zerowrap.Default(), nil, fmt.Errorf("failed to create logger with file: %w", err)
467 }
468 return log, cleanup, nil
469}
470
471// initAccessLog creates an access log writer when access logging is enabled.
472// Returns nil, nil when disabled — callers must treat nil writer as "disabled".
473func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) {
474 if !cfg.Logging.AccessLog.Enabled {
475 return nil, nil
476 }
477
478 filePath := cfg.Logging.AccessLog.FilePath
479 if filePath == "" && cfg.Logging.AccessLog.Output == "file" {
480 dataDir := cfg.Server.DataDir
481 if dataDir == "" {
482 dataDir = DefaultDataDir()
483 }
484 filePath = filepath.Join(dataDir, "logs", "access.log")
485 }
486
487 writer, err := accesslog.New(accesslog.Config{
488 Format: cfg.Logging.AccessLog.Format,
489 Output: cfg.Logging.AccessLog.Output,
490 FilePath: filePath,
491 MaxSize: cfg.Logging.AccessLog.MaxSize,
492 MaxBackups: cfg.Logging.AccessLog.MaxBackups,
493 MaxAge: cfg.Logging.AccessLog.MaxAge,
494 SyslogIdentifier: cfg.Logging.AccessLog.SyslogIdentifier,
495 })
496 if err != nil {
497 return nil, fmt.Errorf("failed to initialize access log: %w", err)
498 }
499
500 log.Info().
501 Str("format", cfg.Logging.AccessLog.Format).
502 Str("output", cfg.Logging.AccessLog.Output).
503 Msg("access log enabled")
504
505 return writer, nil
506}
507
508// serviceInit holds the shared context for service initialization helpers.
509type serviceInit struct {
510 ctx context.Context
511 v *viper.Viper
512 cfg Config
513 log zerowrap.Logger
514 svc *services
515}
516
517// createServices creates all the application services for server runtime.
518// Public ACME reconciliation is started later, after the HTTP listener is bound.
519func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) {
520 return createServicesWithOptions(ctx, v, cfg, log)
521}
522
523// createServicesWithOptions creates all the application services.
524// ACME Reconcile and renewal loop are started later from runServers,
525// after HTTP listeners are bound.
526func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) {
527 si := &serviceInit{
528 ctx: ctx,
529 v: v,
530 cfg: cfg,
531 log: log,
532 svc: &services{},
533 }
534 defer func() {
535 if retErr != nil {
536 if si.svc.pkiSvc != nil {
537 si.svc.pkiSvc.Stop()
538 }
539 if si.svc.publicTLSSvc != nil {
540 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
541 defer cancel()
542 if err := si.svc.publicTLSSvc.Stop(ctx); err != nil {
543 si.log.Warn().Err(err).Msg("failed to stop public TLS service during createServices cleanup")
544 }
545 }
546 }
547 }()
548 var err error
549
550 // Create output adapters
551 runtimeSocket := resolveRuntimeConfig(v.GetString("server.runtime"))
552 if si.svc.runtime, si.svc.eventBus, err = createOutputAdapters(ctx, log, runtimeSocket); err != nil {
553 return nil, err
554 }
555
556 // Create storage
557 if si.svc.blobStorage, si.svc.manifestStorage, err = createStorage(cfg, log); err != nil {
558 return nil, err
559 }
560
561 // Create log writer
562 if si.svc.logWriter, err = createLogWriter(cfg, log); err != nil {
563 return nil, err
564 }
565
566 // Create auth service (if enabled)
567 if si.svc.tokenStore, si.svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil {
568 return nil, err
569 }
570
571 if err := setupInternalRegistryAuth(si.svc, log); err != nil {
572 return nil, err
573 }
574
575 // Create config service
576 si.svc.configSvc = config.NewService(v, si.svc.eventBus)
577 if err := si.svc.configSvc.Load(ctx); err != nil {
578 return nil, log.WrapErr(err, "failed to load configuration")
579 }
580
581 if err := si.initPKI(); err != nil {
582 return nil, err
583 }
584
585 if err := si.initSecrets(); err != nil {
586 return nil, err
587 }
588
589 if err := si.initPublicTLS(); err != nil {
590 return nil, err
591 }
592
593 if err := si.initRuntimeProxyAndTraffic(); err != nil {
594 return nil, err
595 }
596
597 si.svc.reloadCoordinator = newReloadCoordinator(v, si.svc.configSvc, si.svc.proxySvc, nil, si.svc.eventBus, si.svc.publicTLSSvc, log)
598 si.registerReloadCoordinatorHooks()
599
600 si.initHandlers()
601
602 return si.svc, nil
603}
604
605// initPKI initialises the internal CA and PKI service when TLS is enabled.
606func (si *serviceInit) initPKI() error {
607 if !hasTLSCapableEntrypoint(si.cfg) {
608 si.log.Info().Msg("internal CA disabled (no TLS-capable entrypoint configured)")
609 return nil
610 }
611
612 if (si.cfg.Server.TLSCertFile == "") != (si.cfg.Server.TLSKeyFile == "") {
613 return fmt.Errorf("both tls_cert_file and tls_key_file must be set, or neither")
614 }
615
616 caAdapter, err := pkiadapter.NewCA(resolveDataDir(si.cfg.Server.DataDir), si.log)
617 if err != nil {
618 return si.log.WrapErr(err, "failed to initialize internal CA")
619 }
620 si.svc.caAdapter = caAdapter
621 si.svc.pkiSvc = pkiusecase.NewService(si.ctx, caAdapter, si.svc.configSvc, []string{si.cfg.Server.GordonDomain}, si.log)
622 return nil
623}
624
625// initPublicTLS initializes the public ACME TLS service if enabled.
626func (si *serviceInit) initPublicTLS() error {
627 if !si.cfg.TLS.ACME.Enabled {
628 return nil
629 }
630
631 if err := validatePublicTLSReadiness(si.cfg); err != nil {
632 return err
633 }
634
635 ctx := si.ctx
636 log := si.log
637
638 dnsCfg, err := buildDNSConfig(si.cfg)
639 if err != nil {
640 return log.WrapErr(err, "invalid DNS configuration")
641 }
642
643 publicTLSCfg := publictls.Config{
644 Enabled: si.cfg.TLS.ACME.Enabled,
645 Email: si.cfg.TLS.ACME.Email,
646 Challenge: si.cfg.TLS.ACME.Challenge,
647 HTTPPort: effectiveHTTP01Port(si.cfg),
648 TLSPort: effectivePublicTLSPort(si.cfg),
649 DataDir: resolveDataDir(si.cfg.Server.DataDir),
650 ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize,
651 DNS: dnsCfg,
652 }
653
654 tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{})
655
656 effective, err := publictls.ResolveEffectiveChallenge(ctx, publicTLSCfg, tokenResolver)
657 if err != nil {
658 return log.WrapErr(err, "resolve ACME challenge")
659 }
660 if err := validateEffectivePublicTLSReadiness(si.cfg, effective); err != nil {
661 return err
662 }
663
664 store, err := acmestore.New(filepath.Join(resolveDataDir(si.cfg.Server.DataDir), "acme"))
665 if err != nil {
666 return log.WrapErr(err, "create ACME store")
667 }
668
669 challenges := publictls.NewHTTP01Challenges()
670
671 var zoneResolver *acmelego.CloudflareZoneResolver
672 if effective.Mode == domain.ACMEChallengeCloudflareDNS01 {
673 zoneResolver = acmelego.NewCloudflareZoneResolver(effective.Token)
674 }
675
676 issuer, err := acmelego.NewIssuer(acmelego.Config{
677 Email: si.cfg.TLS.ACME.Email,
678 Challenge: effective.Mode,
679 Token: effective.Token,
680 Store: store,
681 HTTPChallengeSink: challenges,
682 DNSResolvers: publicTLSCfg.DNS.Resolvers,
683 DNSPropagationTimeout: publicTLSCfg.DNS.PropagationTimeout,
684 DNSPollingInterval: publicTLSCfg.DNS.PollingInterval,
685 })
686 if err != nil {
687 return log.WrapErr(err, "create ACME issuer")
688 }
689
690 svc := publictls.NewService(publicTLSCfg, publictls.ServiceDeps{
691 Routes: si.svc.configSvc,
692 Issuer: issuer,
693 Store: store,
694 ZoneResolver: zoneResolver,
695 Challenges: challenges,
696 Effective: effective,
697 AdditionalHosts: []string{si.cfg.Server.GordonDomain},
698 })
699
700 if err := svc.Load(ctx); err != nil {
701 log.Warn().Err(err).Msg("failed to load ACME certificates, continuing")
702 }
703
704 log.Info().
705 Str("email", si.cfg.TLS.ACME.Email).
706 Str("challenge", string(effective.Mode)).
707 Msg("public ACME TLS initialized (runtime start deferred)")
708
709 si.svc.publicTLSSvc = svc
710 si.svc.publicTLSRuntime = svc
711 return nil
712}
713
714// publicTLSRuntime is the subset of in.PublicTLSService needed at server runtime
715// after the HTTP listener is bound: reconcile missing certs and start the renewal
716// loop. It is nil when ACME is disabled.
717type publicTLSRuntime interface {
718 Reconcile(context.Context) error
719 StartRenewalLoop(context.Context, time.Duration) <-chan struct{}
720}
721
722func startPublicTLSRuntime(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) error {
723 if svc == nil {
724 return nil
725 }
726 reconcileErr := svc.Reconcile(ctx)
727 svc.StartRenewalLoop(ctx, time.Hour)
728 log.Info().Msg("public ACME TLS runtime started")
729 return reconcileErr
730}
731
732// initSecrets creates the domain secret store, env loader, and secret service.
733func (si *serviceInit) initSecrets() error {
734 envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log)
735 if err != nil {
736 return err
737 }
738 si.svc.envDir = envDir
739
740 if si.svc.envLoader, err = createEnvLoader(backend, envDir, passStore, si.log); err != nil {
741 return err
742 }
743
744 si.svc.serviceSecretProvider = createStandaloneServiceSecretProvider(backend, resolveDataDir(si.cfg.Server.DataDir), si.log)
745 si.svc.secretSvc = secretsSvc.NewService(domainSecretStore, si.log, si.svc.eventBus)
746 return nil
747}
748
749// initRuntimeAndProxy creates container, backup, registry, image, volume, and proxy services.
750func (si *serviceInit) initRuntimeProxyAndTraffic() error {
751 if err := si.initRuntimeAndProxy(); err != nil {
752 return err
753 }
754 si.svc.trafficManager = trafficadapter.NewManager()
755 return nil
756}
757
758func (si *serviceInit) initRuntimeAndProxy() error {
759 var err error
760
761 if si.svc.containerSvc, err = createContainerService(si.ctx, si.v, si.cfg, si.svc, si.log); err != nil {
762 return err
763 }
764
765 if si.svc.backupStorage, si.svc.backupSvc, err = createBackupService(si.cfg, si.svc, si.log); err != nil {
766 return err
767 }
768 if si.svc.volumeBackupStore, si.svc.volumeBackupSvc, si.svc.volumeBackupCfg, err = createVolumeBackupService(si.ctx, si.cfg, si.svc, si.log); err != nil {
769 return err
770 }
771
772 registryState := registrystate.New()
773 si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus, registryState)
774 si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log, registryState)
775 si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime)
776
777 injectTelemetryMetrics(si.cfg, si.svc, si.log)
778
779 proxyCfg, err := buildProxyConfig(si.cfg, si.log)
780 if err != nil {
781 return err
782 }
783 si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
784 si.svc.maxBlobSize = proxyCfg.maxBlobSize
785 si.svc.proxySvc = proxy.NewService(si.svc.runtime, si.svc.containerSvc, si.svc.configSvc, proxyCfg.proxyConfig)
786 si.svc.standaloneServiceSvc = servicecfg.NewServiceWithSecretProvider(si.svc.runtime, si.svc.serviceSecretProvider)
787
788 // Wire synchronous proxy cache invalidation for zero-downtime deployments.
789 // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget().
790 si.svc.containerSvc.SetProxyCacheInvalidator(si.svc.proxySvc)
791 si.svc.containerSvc.SetProxyDrainWaiter(si.svc.proxySvc)
792 return nil
793}
794
795// initHandlers creates the auth, health, log, preview, and admin handlers.
796func (si *serviceInit) registerReloadCoordinatorHooks() {
797 if si.svc.reloadCoordinator == nil || si.svc.containerSvc == nil {
798 return
799 }
800
801 si.svc.reloadCoordinator.SetContainerConfigApplier(func(reloadCtx context.Context, reloadCfg Config) error {
802 containerCfg, err := buildContainerServiceConfig(reloadCtx, si.v, reloadCfg, si.svc, si.log)
803 if err != nil {
804 return err
805 }
806 managementHosts := []string{reloadCfg.Server.GordonDomain}
807 if si.svc.pkiSvc != nil {
808 si.svc.pkiSvc.SetAdditionalDomains(managementHosts)
809 }
810 if si.svc.publicTLSSvc != nil {
811 si.svc.publicTLSSvc.SetAdditionalHosts(reloadCtx, managementHosts)
812 }
813 var tlsConfig *tls.Config
814 if hasTLSCapableEntrypoint(reloadCfg) && si.svc.httpsProxyHandler != nil {
815 var tlsErr error
816 tlsConfig, tlsErr = proxyTLSConfig(reloadCfg, si.svc.pkiSvc, si.svc.publicTLSSvc, si.log)
817 if tlsErr != nil {
818 return tlsErr
819 }
820 }
821 if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil {
822 return err
823 }
824 si.svc.tlsHTTPEntryPoints = registerTLSMuxHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpsProxyHandler, tlsConfig, si.svc.tlsHTTPEntryPoints)
825 si.svc.smartHTTPEntryPoints = registerSmartTCPHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpProxyHandler, si.svc.httpsProxyHandler, tlsConfig, si.svc.smartHTTPEntryPoints)
826 if err := reconcileStandaloneServices(reloadCtx, si.svc.standaloneServiceSvc, reloadCfg); err != nil {
827 return err
828 }
829 si.svc.containerSvc.UpdateConfig(containerCfg)
830 return nil
831 })
832}
833
834func (si *serviceInit) initHandlers() {
835 if si.svc.authSvc != nil {
836 internalAuth := authhandler.InternalAuth{
837 Username: si.svc.internalRegUser,
838 Password: si.svc.internalRegPass,
839 }
840 si.svc.authHandler = authhandler.NewHandler(si.svc.authSvc, internalAuth, si.log)
841 }
842
843 prober := httpprober.New()
844 si.svc.healthSvc = health.NewService(si.svc.configSvc, si.svc.containerSvc, prober, si.log)
845
846 si.svc.logSvc = logs.NewService(resolveLogFilePath(si.cfg), si.cfg.Logging.File.Enabled, si.svc.containerSvc, si.svc.runtime, si.log)
847
848 initPreviewService(si.ctx, si.cfg, si.svc, si.log)
849 if si.svc.trafficManager == nil {
850 si.svc.trafficManager = trafficadapter.NewManager()
851 }
852
853 si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{
854 ConfigSvc: si.svc.configSvc,
855 AuthSvc: si.svc.authSvc,
856 ContainerSvc: si.svc.containerSvc,
857 HealthSvc: si.svc.healthSvc,
858 SecretSvc: si.svc.secretSvc,
859 LogSvc: si.svc.logSvc,
860 RegistrySvc: si.svc.registrySvc,
861 ReloadTrigger: si.svc.reloadCoordinator,
862 Log: si.log,
863 BackupSvc: si.svc.backupSvc,
864 VolumeBackupSvc: si.svc.volumeBackupSvc,
865 PreviewSvc: si.svc.previewService,
866 ImageSvc: si.svc.imageSvc,
867 VolumeSvc: si.svc.volumeSvc,
868 PublicTLSSvc: si.svc.publicTLSSvc,
869 TrafficSvc: si.svc.trafficManager,
870 })
871}
872
873// initPreviewService sets up the preview store, service, and TTL ticker.
874func initPreviewService(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) {
875 previewStorePath := filepath.Join(resolveDataDir(cfg.Server.DataDir), "previews.json")
876 svc.previewStore = filesystem.NewPreviewStore(previewStorePath)
877 previewConfig := svc.configSvc.GetPreviewConfig()
878 svc.previewService = preview.NewService(svc.previewStore, previewConfig.TTL).
879 WithDeployer(svc.containerSvc).
880 WithRouteManager(svc.configSvc).
881 WithVolumeCloner(svc.runtime).
882 WithRegistryDomain(svc.configSvc.GetRegistryDomain()).
883 WithEnvLoader(svc.envLoader)
884 if err := svc.previewService.Load(ctx); err != nil {
885 log.Warn().Err(err).Msg("failed to load previews")
886 }
887
888 // Derive sweep interval from TTL: half the TTL, capped at 1 hour, minimum 1 minute.
889 sweepInterval := max(min(previewConfig.TTL/2, time.Hour), time.Minute)
890
891 svc.previewService.StartTicker(ctx, sweepInterval, func(ctx context.Context, p domain.PreviewRoute) {
892 teardownTrackedPreview(ctx, svc, p)
893 }, func(ctx context.Context) {
894 gcOrphanedPreviews(ctx, svc, previewConfig)
895 })
896}
897
898// teardownTrackedPreview removes all resources for a tracked preview that has expired.
899func teardownTrackedPreview(ctx context.Context, svc *services, p domain.PreviewRoute) {
900 log := zerowrap.FromCtx(ctx)
901 for _, containerName := range p.Containers {
902 if err := svc.runtime.StopContainer(ctx, containerName); err != nil {
903 log.Warn().Err(err).Str("container", containerName).Str("preview", p.Domain).Msg("failed to stop preview container")
904 }
905 if err := svc.runtime.RemoveContainer(ctx, containerName, true); err != nil {
906 log.Warn().Err(err).Str("container", containerName).Str("preview", p.Domain).Msg("failed to remove preview container")
907 }
908 }
909 for _, volName := range p.Volumes {
910 if err := svc.runtime.RemoveVolume(ctx, volName, true); err != nil {
911 log.Warn().Err(err).Str("volume", volName).Str("preview", p.Domain).Msg("failed to remove preview volume")
912 }
913 }
914 // Remove network (naming convention: {networkPrefix}-{domain-sanitized}).
915 networkPrefix := svc.configSvc.GetNetworkPrefix()
916 networkName := networkPrefix + "-" + strings.ReplaceAll(p.Domain, ".", "-")
917 if err := svc.runtime.RemoveNetwork(ctx, networkName); err != nil {
918 log.Warn().Err(err).Str("network", networkName).Str("preview", p.Domain).Msg("failed to remove preview network")
919 }
920 // Remove route from config so proxy stops routing to this domain.
921 if err := svc.configSvc.RemoveRoute(ctx, p.Domain); err != nil {
922 if errors.Is(err, domain.ErrRouteNotFound) {
923 log.Debug().Str("domain", p.Domain).Msg("preview route already removed from config")
924 } else {
925 log.Warn().Err(err).Str("domain", p.Domain).Msg("failed to remove preview route from config")
926 }
927 }
928 svc.proxySvc.InvalidateTarget(ctx, p.Domain)
929}
930
931// gcOrphanedPreviews finds and tears down untracked preview containers.
932func gcOrphanedPreviews(ctx context.Context, svc *services, previewConfig domain.PreviewConfig) {
933 log := zerowrap.FromCtx(ctx)
934 orphans := svc.previewService.CollectOrphans(ctx, svc.runtime, previewConfig.TagPatterns, previewConfig.Separator)
935 for _, c := range orphans {
936 orphanDomain := c.Labels[domain.LabelDomain]
937 log.Warn().Str("container", c.Name).Str("image", c.Image).Str("domain", orphanDomain).
938 Time("created", c.Created).Msg("orphaned preview container detected, cleaning up")
939
940 // Order: stop → remove volumes/route → remove container → remove network.
941 // Network removal must happen after container removal because the runtime
942 // refuses to delete a network that still has containers attached.
943 if err := svc.runtime.StopContainer(ctx, c.Name); err != nil {
944 log.Warn().Err(err).Str("container", c.Name).Msg("failed to stop orphan container")
945 }
946
947 if orphanDomain != "" {
948 if err := cleanupOrphanDomainResources(ctx, svc, orphanDomain); err != nil {
949 log.Warn().Err(err).Str("container", c.Name).Str("domain", orphanDomain).
950 Msg("orphan resource cleanup failed, deferring container removal to next scan")
951 continue
952 }
953 }
954
955 if err := svc.runtime.RemoveContainer(ctx, c.Name, true); err != nil {
956 log.Warn().Err(err).Str("container", c.Name).Msg("failed to remove orphan container")
957 }
958
959 // Remove network after container is gone.
960 if orphanDomain != "" {
961 if err := removeOrphanNetwork(ctx, svc, strings.ReplaceAll(orphanDomain, ".", "-"), log); err != nil {
962 log.Warn().Err(err).Str("container", c.Name).Msg("failed to remove orphan network after container removal")
963 }
964 }
965
966 log.Info().Str("container", c.Name).Str("domain", orphanDomain).Msg("orphaned preview container cleaned up")
967 }
968
969 // Re-sync container state so stale routes disappear from routes list.
970 if len(orphans) > 0 {
971 if err := svc.containerSvc.SyncContainers(ctx); err != nil {
972 log.Warn().Err(err).Msg("failed to sync containers after orphan GC")
973 }
974 }
975}
976
977// cleanupOrphanDomainResources removes volumes and route for an orphaned
978// preview domain. Network removal is handled separately in gcOrphanedPreviews
979// after the container is removed. Returns an error if any step fails so the
980// caller can defer container removal until the next scan.
981func cleanupOrphanDomainResources(ctx context.Context, svc *services, orphanDomain string) error {
982 log := zerowrap.FromCtx(ctx)
983 domainSanitized := strings.ReplaceAll(orphanDomain, ".", "-")
984 var errs []error
985
986 errs = append(errs, removeOrphanVolumes(ctx, svc, domainSanitized, log)...)
987 errs = append(errs, removeOrphanRoute(ctx, svc, orphanDomain, log))
988 svc.proxySvc.InvalidateTarget(ctx, orphanDomain)
989
990 return errors.Join(errs...)
991}
992
993func removeOrphanVolumes(ctx context.Context, svc *services, domainSanitized string, log zerowrap.Logger) []error {
994 _, volPrefix, _ := svc.configSvc.GetVolumeConfig()
995 prefix := volPrefix + "-" + domainSanitized + "-"
996
997 volumes, err := svc.runtime.ListVolumes(ctx)
998 if err != nil {
999 log.Warn().Err(err).Msg("failed to list volumes for orphan cleanup")
1000 return []error{err}
1001 }
1002
1003 var errs []error
1004 for _, v := range volumes {
1005 if !strings.HasPrefix(v.Name, prefix) {
1006 continue
1007 }
1008 if err := svc.runtime.RemoveVolume(ctx, v.Name, true); err != nil {
1009 log.Warn().Err(err).Str("volume", v.Name).Msg("failed to remove orphan volume")
1010 errs = append(errs, err)
1011 }
1012 }
1013 return errs
1014}
1015
1016func removeOrphanNetwork(ctx context.Context, svc *services, domainSanitized string, log zerowrap.Logger) error {
1017 name := svc.configSvc.GetNetworkPrefix() + "-" + domainSanitized
1018 if err := svc.runtime.RemoveNetwork(ctx, name); err != nil {
1019 log.Warn().Err(err).Str("network", name).Msg("failed to remove orphan network")
1020 return err
1021 }
1022 return nil
1023}
1024
1025func removeOrphanRoute(ctx context.Context, svc *services, orphanDomain string, log zerowrap.Logger) error {
1026 err := svc.configSvc.RemoveRoute(ctx, orphanDomain)
1027 if err == nil || errors.Is(err, domain.ErrRouteNotFound) {
1028 return nil
1029 }
1030 log.Warn().Err(err).Str("domain", orphanDomain).Msg("failed to remove orphan route")
1031 return err
1032}
1033
1034// injectTelemetryMetrics creates and injects OTel metrics into services when
1035// telemetry is enabled. Skipped otherwise to avoid unnecessary allocations.
1036func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) {
1037 if !cfg.Telemetry.Enabled || !cfg.Telemetry.Metrics {
1038 return
1039 }
1040 gordonMetrics, err := telemetry.NewMetrics()
1041 if err != nil {
1042 log.Warn().Err(err).Msg("failed to create telemetry metrics, continuing without metrics")
1043 return
1044 }
1045 svc.containerSvc.SetMetrics(gordonMetrics)
1046 svc.registrySvc.SetMetrics(gordonMetrics)
1047 svc.eventBus.SetMetrics(gordonMetrics)
1048}
1049
1050func setupInternalRegistryAuth(svc *services, log zerowrap.Logger) error {
1051 var err error
1052 svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
1053 if err != nil {
1054 return log.WrapErr(err, "failed to generate internal registry credentials")
1055 }
1056
1057 // Persist credentials to file for CLI access (gordon auth internal)
1058 if err := persistInternalCredentials(svc.internalRegUser, svc.internalRegPass); err != nil {
1059 log.Warn().Err(err).Msg("failed to persist internal credentials for CLI access")
1060 }
1061
1062 log.Debug().Msg("internal registry auth generated for loopback pulls")
1063 return nil
1064}
1065
1066func createDomainSecretStore(cfg Config, log zerowrap.Logger) (string, domain.SecretsBackend, *domainsecrets.PassStore, out.DomainSecretStore, error) {
1067 envDir := resolveEnvDir(cfg)
1068 backend, err := resolveSecretsBackend(cfg.Auth.SecretsBackend)
1069 if err != nil {
1070 return "", "", nil, nil, log.WrapErr(err, "failed to resolve secrets backend")
1071 }
1072
1073 switch backend {
1074 case domain.SecretsBackendPass:
1075 passStore, err := domainsecrets.NewPassStore(log)
1076 if err != nil {
1077 return "", backend, nil, nil, log.WrapErr(err, "failed to create pass domain secret store")
1078 }
1079 if err := migrateEnvFilesToPass(envDir, passStore, log); err != nil {
1080 return "", backend, nil, nil, log.WrapErr(err, "failed to migrate env files to pass")
1081 }
1082 return envDir, backend, passStore, passStore, nil
1083 default:
1084 store, err := domainsecrets.NewFileStore(envDir, log)
1085 if err != nil {
1086 return "", backend, nil, nil, log.WrapErr(err, "failed to create domain secret store")
1087 }
1088 return envDir, backend, nil, store, nil
1089 }
1090}
1091
1092// resolveLogFilePath returns the configured log file path or a default.
1093func resolveLogFilePath(cfg Config) string {
1094 if cfg.Logging.File.Path != "" {
1095 return cfg.Logging.File.Path
1096 }
1097 dataDir := cfg.Server.DataDir
1098 if dataDir == "" {
1099 dataDir = DefaultDataDir()
1100 }
1101 return filepath.Join(dataDir, "logs", "gordon.log")
1102}
1103
1104// resolveRuntimeConfig converts a server.runtime config value to a socket path.
1105// "auto" or "" means auto-detect.
1106// Named runtimes ("podman", "docker") are resolved to well-known socket paths.
1107// URI schemes (unix://) are stripped so callers receive a bare path.
1108func resolveRuntimeConfig(value string) string {
1109 if value == "" || value == "auto" {
1110 return ""
1111 }
1112 // Named runtimes: resolve to well-known socket paths.
1113 switch value {
1114 case "podman":
1115 // Check XDG_RUNTIME_DIR first (rootless Podman).
1116 if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
1117 candidate := filepath.Join(xdg, "podman", "podman.sock")
1118 if _, err := os.Stat(candidate); err == nil {
1119 return candidate
1120 }
1121 }
1122 // Fallback to system-wide Podman socket.
1123 return "/run/podman/podman.sock"
1124 case "docker":
1125 return "/var/run/docker.sock"
1126 }
1127 // Explicit socket path — strip URI scheme if present.
1128 if socketPath, ok := strings.CutPrefix(value, "unix://"); ok {
1129 return socketPath
1130 }
1131 return value
1132}
1133
1134// createOutputAdapters creates the container runtime and event bus.
1135func createOutputAdapters(ctx context.Context, log zerowrap.Logger, runtimeSocket string) (*docker.Runtime, *eventbus.InMemory, error) {
1136 detection := docker.DetectRuntimeSocket(runtimeSocket)
1137
1138 var runtime *docker.Runtime
1139 var err error
1140
1141 switch detection.Source {
1142 case "none":
1143 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")
1144 case "DOCKER_HOST_passthrough":
1145 detection.RuntimeName = "docker"
1146 runtime, err = docker.NewRuntime()
1147 default:
1148 if detection.SocketPath != "" {
1149 runtime, err = docker.NewRuntimeWithSocket(detection.SocketPath)
1150 } else {
1151 runtime, err = docker.NewRuntime()
1152 }
1153 }
1154 if err != nil {
1155 return nil, nil, log.WrapErr(err, "failed to create container runtime")
1156 }
1157
1158 if err := runtime.Ping(ctx); err != nil {
1159 return nil, nil, log.WrapErr(err, fmt.Sprintf("container runtime not available (detected: %s via %s)", detection.RuntimeName, detection.Source))
1160 }
1161
1162 runtimeVersion, _ := runtime.Version(ctx)
1163 log.Info().
1164 Str("runtime", detection.RuntimeName).
1165 Str("version", runtimeVersion).
1166 Str("source", detection.Source).
1167 Msg("container runtime initialized")
1168
1169 eventBus := eventbus.NewInMemory(100, log)
1170
1171 return runtime, eventBus, nil
1172}
1173
1174// createStorage creates blob and manifest storage.
1175func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *filesystem.ManifestStorage, error) {
1176 dataDir := cfg.Server.DataDir
1177 if dataDir == "" {
1178 dataDir = DefaultDataDir()
1179 }
1180
1181 registryDir := filepath.Join(dataDir, "registry")
1182
1183 blobStorage, err := filesystem.NewBlobStorage(registryDir, log)
1184 if err != nil {
1185 return nil, nil, log.WrapErr(err, "failed to create blob storage")
1186 }
1187
1188 manifestStorage, err := filesystem.NewManifestStorage(registryDir, log)
1189 if err != nil {
1190 return nil, nil, log.WrapErr(err, "failed to create manifest storage")
1191 }
1192
1193 return blobStorage, manifestStorage, nil
1194}
1195
1196// createEnvLoader creates the environment loader with secret providers.
1197func createEnvLoader(backend domain.SecretsBackend, envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) (out.EnvLoader, error) {
1198 switch backend {
1199 case domain.SecretsBackendPass:
1200 loader, err := envloader.NewPassLoader(passStore, log)
1201 if err != nil {
1202 return nil, log.WrapErr(err, "failed to create pass env loader")
1203 }
1204 return loader, nil
1205 default:
1206 loader, err := envloader.NewFileLoader(envDir, log)
1207 if err != nil {
1208 return nil, log.WrapErr(err, "failed to create env loader")
1209 }
1210
1211 // Register secret providers
1212 passProvider := secrets.NewPassProvider(log)
1213 if passProvider.IsAvailable() {
1214 loader.RegisterSecretProvider(passProvider)
1215 log.Debug().Msg("pass secret provider registered")
1216 }
1217
1218 sopsProvider := secrets.NewSopsProvider(log)
1219 if sopsProvider.IsAvailable() {
1220 loader.RegisterSecretProvider(sopsProvider)
1221 log.Debug().Msg("sops secret provider registered")
1222 }
1223
1224 return loader, nil
1225 }
1226}
1227
1228// createLogWriter creates the container log writer.
1229func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, error) {
1230 if !cfg.Logging.ContainerLogs.Enabled {
1231 log.Debug().Msg("container log collection disabled")
1232 return nil, nil
1233 }
1234
1235 // Determine log directory
1236 logDir := cfg.Logging.ContainerLogs.Dir
1237 if logDir == "" {
1238 dataDir := cfg.Server.DataDir
1239 if dataDir == "" {
1240 dataDir = DefaultDataDir()
1241 }
1242 logDir = filepath.Join(dataDir, "logs", "containers")
1243 }
1244
1245 writer, err := logwriter.New(logwriter.Config{
1246 Dir: logDir,
1247 MaxSize: cfg.Logging.ContainerLogs.MaxSize,
1248 MaxBackups: cfg.Logging.ContainerLogs.MaxBackups,
1249 MaxAge: cfg.Logging.ContainerLogs.MaxAge,
1250 })
1251 if err != nil {
1252 return nil, log.WrapErr(err, "failed to create container log writer")
1253 }
1254
1255 log.Info().Str("dir", logDir).Msg("container log collection enabled")
1256 return writer, nil
1257}
1258
1259const (
1260 internalRegistryUsername = "gordon-internal"
1261 serviceTokenSubject = "gordon-service"
1262 serviceTokenDefaultTTL = 30 * 24 * time.Hour
1263)
1264
1265func generateInternalRegistryAuth() (string, string, error) {
1266 password, err := randomTokenHex(32)
1267 if err != nil {
1268 return "", "", err
1269 }
1270 return internalRegistryUsername, password, nil
1271}
1272
1273func randomTokenHex(size int) (string, error) {
1274 buf := make([]byte, size)
1275 if _, err := rand.Read(buf); err != nil {
1276 return "", err
1277 }
1278 return hex.EncodeToString(buf), nil
1279}
1280
1281// InternalCredentials holds the internal registry credentials for CLI access.
1282type InternalCredentials struct {
1283 Username string `json:"username"`
1284 Password string `json:"password"`
1285}
1286
1287// getSecureRuntimeDir returns a secure directory for runtime files.
1288// Priority: XDG_RUNTIME_DIR > ~/.gordon/run
1289func getSecureRuntimeDir() (string, error) {
1290 // Try XDG_RUNTIME_DIR first (typically /run/user/<uid> on Linux)
1291 if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
1292 gordonDir := filepath.Join(runtimeDir, "gordon")
1293 if err := os.MkdirAll(gordonDir, 0700); err == nil {
1294 return gordonDir, nil
1295 }
1296 }
1297
1298 // Fall back to ~/.gordon/run
1299 homeDir, err := os.UserHomeDir()
1300 if err != nil {
1301 return "", fmt.Errorf("failed to get home directory: %w", err)
1302 }
1303
1304 gordonDir := filepath.Join(homeDir, ".gordon", "run")
1305 if err := os.MkdirAll(gordonDir, 0700); err != nil {
1306 return "", fmt.Errorf("failed to create runtime directory: %w", err)
1307 }
1308
1309 return gordonDir, nil
1310}
1311
1312// getInternalCredentialsFile returns the path to the internal credentials file.
1313// SECURITY: Credentials are stored in a secure location with restricted permissions.
1314func getInternalCredentialsFile() string {
1315 runtimeDir, err := getSecureRuntimeDir()
1316 if err != nil {
1317 // Fall back to temp dir if we can't get secure dir (shouldn't happen)
1318 return filepath.Join(os.TempDir(), "gordon-internal-creds.json")
1319 }
1320 return filepath.Join(runtimeDir, "internal-creds.json")
1321}
1322
1323// persistInternalCredentials saves the internal registry credentials to a secure file.
1324// SECURITY: Credentials are stored in XDG_RUNTIME_DIR or ~/.gordon/run with 0600 permissions.
1325// The file is cleaned up on graceful shutdown but may persist if Gordon crashes.
1326// These credentials are for internal loopback communication only and are regenerated on each start.
1327func persistInternalCredentials(username, password string) error {
1328 creds := InternalCredentials{
1329 Username: username,
1330 Password: password,
1331 }
1332 data, err := json.Marshal(creds)
1333 if err != nil {
1334 return fmt.Errorf("failed to marshal credentials: %w", err)
1335 }
1336
1337 credFile := getInternalCredentialsFile()
1338
1339 // Ensure parent directory exists with secure permissions
1340 if err := os.MkdirAll(filepath.Dir(credFile), 0700); err != nil {
1341 return fmt.Errorf("failed to create credentials directory: %w", err)
1342 }
1343
1344 // Write file with restrictive permissions (owner read/write only)
1345 if err := os.WriteFile(credFile, data, 0600); err != nil {
1346 return fmt.Errorf("failed to write credentials file: %w", err)
1347 }
1348 return nil
1349}
1350
1351// cleanupInternalCredentials removes the internal credentials file.
1352func cleanupInternalCredentials() {
1353 _ = os.Remove(getInternalCredentialsFile())
1354}
1355
1356// getInternalCredentialsCandidates returns candidate file paths in priority order:
1357// 1. XDG_RUNTIME_DIR/gordon/ (set by systemd for the daemon)
1358// 2. /run/user/<uid>/gordon/ (well-known systemd default, for CLI in shells without XDG_RUNTIME_DIR)
1359// 3. ~/.gordon/run/ (fallback for non-systemd environments)
1360// 4. os.TempDir() (last resort, matches getInternalCredentialsFile fallback path)
1361func getInternalCredentialsCandidates() []string {
1362 var candidates []string
1363
1364 // 1. XDG_RUNTIME_DIR (set in daemon's environment)
1365 if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
1366 candidates = append(candidates, filepath.Join(runtimeDir, "gordon", "internal-creds.json"))
1367 }
1368
1369 // 2. /run/user/<uid>/gordon/ (systemd default, may not be in CLI's env)
1370 uid := os.Getuid()
1371 sysRuntime := filepath.Join("/run/user", fmt.Sprintf("%d", uid), "gordon", "internal-creds.json")
1372 // Avoid duplicate if XDG_RUNTIME_DIR already points here
1373 if len(candidates) == 0 || candidates[0] != sysRuntime {
1374 candidates = append(candidates, sysRuntime)
1375 }
1376
1377 // 3. ~/.gordon/run/ fallback
1378 if homeDir, err := os.UserHomeDir(); err == nil {
1379 candidates = append(candidates, filepath.Join(homeDir, ".gordon", "run", "internal-creds.json"))
1380 }
1381
1382 // 4. os.TempDir() last resort — matches the fallback path in getInternalCredentialsFile,
1383 // ensuring GetInternalCredentials can find credentials even when getSecureRuntimeDir fails.
1384 candidates = append(candidates, filepath.Join(os.TempDir(), "gordon-internal-creds.json"))
1385
1386 return candidates
1387}
1388
1389// GetInternalCredentialsFromCandidates reads credentials from the first candidate file that exists.
1390// Exported for testing.
1391func GetInternalCredentialsFromCandidates(candidates []string) (*InternalCredentials, error) {
1392 var lastErr error
1393 for _, path := range candidates {
1394 data, err := os.ReadFile(path)
1395 if os.IsNotExist(err) {
1396 continue
1397 }
1398 if err != nil {
1399 // Non-permission errors (e.g. EACCES) may be transient or path-specific;
1400 // record and try the next candidate rather than failing immediately.
1401 lastErr = fmt.Errorf("failed to read credentials file %s: %w", path, err)
1402 continue
1403 }
1404 var creds InternalCredentials
1405 if err := json.Unmarshal(data, &creds); err != nil {
1406 // Corrupt file — record and fall through to lower-priority candidates.
1407 lastErr = fmt.Errorf("failed to parse credentials at %s: %w", path, err)
1408 continue
1409 }
1410 return &creds, nil
1411 }
1412 if lastErr != nil {
1413 return nil, lastErr
1414 }
1415 return nil, fmt.Errorf("no credentials file found (is Gordon running?): checked %v", candidates)
1416}
1417
1418// GetInternalCredentials reads the internal registry credentials from file.
1419// Probes all candidate runtime directories so CLI works regardless of whether
1420// XDG_RUNTIME_DIR is set in the current shell environment.
1421func GetInternalCredentials() (*InternalCredentials, error) {
1422 return GetInternalCredentialsFromCandidates(getInternalCredentialsCandidates())
1423}
1424
1425// createAuthService creates the authentication service and token store.
1426func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
1427 if !cfg.Auth.Enabled {
1428 log.Warn().Msg("auth.enabled=false detected: running in local-only mode (registry loopback-only, admin API disabled)")
1429 return nil, nil, nil
1430 }
1431
1432 authType, err := resolveAuthType(cfg)
1433 if err != nil {
1434 return nil, nil, fmt.Errorf("resolve auth type: %w", err)
1435 }
1436 backend, err := resolveSecretsBackend(cfg.Auth.SecretsBackend)
1437 if err != nil {
1438 return nil, nil, log.WrapErr(err, "failed to resolve secrets backend")
1439 }
1440 dataDir := resolveDataDir(cfg.Server.DataDir)
1441
1442 store, err := createTokenStore(backend, dataDir, log)
1443 if err != nil {
1444 return nil, nil, err
1445 }
1446
1447 authConfig, err := buildAuthConfig(ctx, cfg, authType, backend, dataDir, log)
1448 if err != nil {
1449 return nil, nil, err
1450 }
1451
1452 authSvc := auth.NewService(authConfig, store, log)
1453
1454 log.Info().
1455 Str("type", string(authType)).
1456 Str("backend", string(backend)).
1457 Msg("registry authentication enabled")
1458
1459 return store, authSvc, nil
1460}
1461
1462// resolveAuthType determines the auth type from config.
1463// Token-only authentication is the only supported mode.
1464func resolveAuthType(cfg Config) (domain.AuthType, error) {
1465 if cfg.Auth.Type != "" && cfg.Auth.Type != "token" {
1466 return "", fmt.Errorf("unsupported auth.type %q; only \"token\" is supported", cfg.Auth.Type)
1467 }
1468 return domain.AuthTypeToken, nil
1469}
1470
1471func resolveSecretsBackend(backend string) (domain.SecretsBackend, error) {
1472 switch backend {
1473 case "pass":
1474 return domain.SecretsBackendPass, nil
1475 case "sops":
1476 return domain.SecretsBackendSops, nil
1477 case "unsafe":
1478 return domain.SecretsBackendUnsafe, nil
1479 case "":
1480 return "", fmt.Errorf("auth.secrets_backend is required")
1481 default:
1482 return "", fmt.Errorf("unsupported auth.secrets_backend %q", backend)
1483 }
1484}
1485
1486func resolveDataDir(dataDir string) string {
1487 if dataDir == "" {
1488 return DefaultDataDir()
1489 }
1490 return dataDir
1491}
1492
1493func resolveEnvDir(cfg Config) string {
1494 dataDir := resolveDataDir(cfg.Server.DataDir)
1495 envDir := cfg.Env.Dir
1496 if envDir == "" {
1497 envDir = filepath.Join(dataDir, "env")
1498 }
1499 return envDir
1500}
1501
1502func resolveRegistryDomains(cfg Config) (string, []string) {
1503 registryDomain := cfg.Server.GordonDomain
1504 if registryDomain == "" {
1505 registryDomain = cfg.Server.RegistryDomain
1506 }
1507 return registryDomain, append([]string{}, cfg.Server.LegacyRegistryDomains...)
1508}
1509
1510func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
1511 // Token store is always created since tokens work in both auth modes
1512 store, err := tokenstore.NewStore(backend, dataDir, log)
1513 if err != nil {
1514 return nil, log.WrapErr(err, "failed to create token store")
1515 }
1516 return store, nil
1517}
1518
1519func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
1520 authConfig := auth.Config{
1521 Enabled: cfg.Auth.Enabled,
1522 AuthType: authType,
1523 Username: cfg.Auth.Username,
1524 }
1525
1526 // Token config is always required (tokens work in all auth modes)
1527 secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log)
1528 if err != nil {
1529 return auth.Config{}, err
1530 }
1531 authConfig.TokenSecret = secret
1532 authConfig.TokenExpiry = expiry
1533
1534 accessTokenTTL := 15 * time.Minute // default
1535 if cfg.Auth.AccessTokenTTL != "" {
1536 parsed, err := time.ParseDuration(cfg.Auth.AccessTokenTTL)
1537 if err != nil {
1538 return auth.Config{}, fmt.Errorf("invalid auth.access_token_ttl %q: %w", cfg.Auth.AccessTokenTTL, err)
1539 }
1540 if parsed <= 0 {
1541 return auth.Config{}, fmt.Errorf("auth.access_token_ttl must be positive")
1542 }
1543 if parsed > auth.MaxAccessTokenLifetime {
1544 return auth.Config{}, fmt.Errorf("auth.access_token_ttl must not exceed %v", auth.MaxAccessTokenLifetime)
1545 }
1546 accessTokenTTL = parsed
1547 }
1548 authConfig.AccessTokenTTL = accessTokenTTL
1549
1550 return authConfig, nil
1551}
1552
1553func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) {
1554 secret, err := loadTokenSecret(ctx, cfg, backend, dataDir, log)
1555 if err != nil {
1556 return nil, 0, err
1557 }
1558
1559 expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
1560 if err != nil {
1561 return nil, 0, err
1562 }
1563
1564 return secret, expiry, nil
1565}
1566
1567// TokenSecretEnvVar is the environment variable for the JWT signing secret.
1568// SECURITY: This takes priority over config file to allow secure secret injection.
1569const TokenSecretEnvVar = "GORDON_AUTH_TOKEN_SECRET" //nolint:gosec // This is an env var name, not a credential
1570
1571func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {
1572 // SECURITY: Priority order for token secret:
1573 // 1. Environment variable (most secure - no disk exposure)
1574 // 2. Secrets backend (pass/sops - encrypted)
1575 // 3. Config file path (least preferred)
1576
1577 const minTokenSecretLength = 32
1578
1579 // Check environment variable first
1580 if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" {
1581 if len(envSecret) < minTokenSecretLength {
1582 return nil, fmt.Errorf("token secret from %s must be at least %d bytes (got %d)", TokenSecretEnvVar, minTokenSecretLength, len(envSecret))
1583 }
1584 log.Debug().Msg("using token secret from environment variable")
1585 return []byte(envSecret), nil
1586 }
1587
1588 // Fall back to config-specified path via secrets backend
1589 if cfg.Auth.TokenSecret == "" {
1590 return nil, fmt.Errorf("token_secret is required for JWT token generation; set %s environment variable or configure auth.token_secret", TokenSecretEnvVar)
1591 }
1592
1593 secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log)
1594 if err != nil {
1595 return nil, log.WrapErr(err, "failed to load token secret")
1596 }
1597
1598 if len(secret) < minTokenSecretLength {
1599 return nil, fmt.Errorf("token_secret must be at least %d bytes (got %d); use a strong random secret", minTokenSecretLength, len(secret))
1600 }
1601
1602 return []byte(secret), nil
1603}
1604
1605func parseTokenExpiry(expiry string) (time.Duration, error) {
1606 if expiry == "" {
1607 return 0, nil
1608 }
1609
1610 parsed, err := duration.Parse(expiry)
1611 if err != nil {
1612 return 0, fmt.Errorf("invalid token_expiry: %w", err)
1613 }
1614
1615 return parsed, nil
1616}
1617
1618func resolveServiceTokenExpiry(cfg Config) (time.Duration, error) {
1619 expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
1620 if err != nil {
1621 return 0, err
1622 }
1623 if expiry <= 0 {
1624 return serviceTokenDefaultTTL, nil
1625 }
1626 return expiry, nil
1627}
1628
1629// loadSecret loads a secret from the configured backend.
1630func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) {
1631 switch backend {
1632 case domain.SecretsBackendPass:
1633 provider := secrets.NewPassProvider(log)
1634 return provider.GetSecret(ctx, path)
1635 case domain.SecretsBackendSops:
1636 provider := secrets.NewSopsProvider(log)
1637 return provider.GetSecret(ctx, path)
1638 case domain.SecretsBackendUnsafe:
1639 // For unsafe backend, path is relative to dataDir/secrets/.
1640 return readUnsafeSecret(dataDir, path)
1641 default:
1642 return "", fmt.Errorf("unknown secrets backend: %s", backend)
1643 }
1644}
1645
1646func readFileBeneath(root, cleanedRelPath string) ([]byte, error) {
1647 rootFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
1648 if err != nil {
1649 return nil, fmt.Errorf("failed to open secrets root: %w", err)
1650 }
1651 defer unix.Close(rootFD)
1652
1653 parts := strings.Split(filepath.ToSlash(cleanedRelPath), "/")
1654 dirFD := rootFD
1655 var closeDirFDs []int
1656 defer func() {
1657 for i := len(closeDirFDs) - 1; i >= 0; i-- {
1658 _ = unix.Close(closeDirFDs[i])
1659 }
1660 }()
1661
1662 for i, part := range parts {
1663 if part == "" || part == "." || part == ".." {
1664 return nil, fmt.Errorf("invalid secret path: path must stay under dataDir/secrets")
1665 }
1666 last := i == len(parts)-1
1667 if last {
1668 fd, err := unix.Openat(dirFD, part, unix.O_RDONLY|unix.O_NONBLOCK|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
1669 if err != nil {
1670 return nil, fmt.Errorf("failed to read secret file: %w", err)
1671 }
1672 defer unix.Close(fd)
1673 var st unix.Stat_t
1674 if err := unix.Fstat(fd, &st); err != nil {
1675 return nil, fmt.Errorf("failed to stat secret file: %w", err)
1676 }
1677 if st.Mode&unix.S_IFMT != unix.S_IFREG {
1678 return nil, fmt.Errorf("invalid secret path: secret must be a regular file")
1679 }
1680 data, err := os.ReadFile(fmt.Sprintf("/proc/self/fd/%d", fd))
1681 if err != nil {
1682 return nil, fmt.Errorf("failed to read secret file: %w", err)
1683 }
1684 return data, nil
1685 }
1686
1687 nextFD, err := unix.Openat(dirFD, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
1688 if err != nil {
1689 return nil, fmt.Errorf("failed to open secret path component: %w", err)
1690 }
1691 closeDirFDs = append(closeDirFDs, nextFD)
1692 dirFD = nextFD
1693 }
1694
1695 return nil, fmt.Errorf("invalid secret path: empty path")
1696}
1697
1698type unsafeSecretProvider struct {
1699 dataDir string
1700}
1701
1702func (p unsafeSecretProvider) Name() string { return string(domain.SecretsBackendUnsafe) }
1703
1704func (p unsafeSecretProvider) IsAvailable() bool { return true }
1705
1706func (p unsafeSecretProvider) GetSecret(_ context.Context, path string) (string, error) {
1707 return readUnsafeSecret(p.dataDir, path)
1708}
1709
1710func createStandaloneServiceSecretProvider(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) out.SecretProvider {
1711 switch backend {
1712 case domain.SecretsBackendPass:
1713 return secrets.NewPassProvider(log)
1714 case domain.SecretsBackendSops:
1715 return secrets.NewSopsProvider(log)
1716 case domain.SecretsBackendUnsafe:
1717 return unsafeSecretProvider{dataDir: dataDir}
1718 default:
1719 return nil
1720 }
1721}
1722
1723func readUnsafeSecret(dataDir, secretPath string) (string, error) {
1724 if filepath.IsAbs(secretPath) {
1725 return "", fmt.Errorf("invalid secret path: absolute paths are not allowed")
1726 }
1727 cleaned := filepath.Clean(secretPath)
1728 if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
1729 return "", fmt.Errorf("invalid secret path: path must stay under dataDir/secrets")
1730 }
1731
1732 root := filepath.Clean(filepath.Join(dataDir, "secrets"))
1733 data, err := readFileBeneath(root, cleaned)
1734 if err != nil {
1735 return "", err
1736 }
1737 return string(data), nil
1738}
1739
1740// proxyConfigResult holds parsed proxy and blob chunk size config.
1741type proxyConfigResult struct {
1742 proxyConfig proxy.Config
1743 maxBlobChunkSize int64
1744 maxBlobSize int64
1745}
1746
1747type configWatcher interface {
1748 Watch(ctx context.Context, onChange func()) error
1749}
1750
1751// publicTLSReconciler is the interface for reconciling public TLS certificates.
1752type publicTLSReconciler interface {
1753 Reconcile(context.Context) error
1754}
1755
1756type configReloader interface {
1757 Reload(ctx context.Context) error
1758}
1759
1760type proxyConfigUpdater interface {
1761 UpdateConfig(config proxy.Config)
1762}
1763
1764type reloadTrigger interface {
1765 Trigger(ctx context.Context) error
1766}
1767
1768type loadedConfigApplier interface {
1769 ApplyLoadedConfig(ctx context.Context) error
1770}
1771
1772type reloadCoordinator struct {
1773 mu sync.Mutex
1774 lastRun time.Time
1775 debounce time.Duration
1776
1777 configSvc configReloader
1778 v *viper.Viper
1779 proxySvc proxyConfigUpdater
1780 applyContainerConfig func(context.Context, Config) error
1781 registryLimits interface {
1782 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
1783 }
1784 eventBus out.EventPublisher
1785 publicTLS publicTLSReconciler
1786 log zerowrap.Logger
1787}
1788
1789func newReloadCoordinator(v *viper.Viper, configSvc configReloader, proxySvc proxyConfigUpdater, registryLimits interface {
1790 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
1791}, eventBus out.EventPublisher, publicTLS publicTLSReconciler, log zerowrap.Logger) *reloadCoordinator {
1792 return &reloadCoordinator{
1793 debounce: 500 * time.Millisecond,
1794 configSvc: configSvc,
1795 v: v,
1796 proxySvc: proxySvc,
1797 registryLimits: registryLimits,
1798 eventBus: eventBus,
1799 publicTLS: publicTLS,
1800 log: log,
1801 }
1802}
1803
1804func (c *reloadCoordinator) SetRegistryLimits(limits interface {
1805 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
1806}) {
1807 c.mu.Lock()
1808 defer c.mu.Unlock()
1809 c.registryLimits = limits
1810}
1811
1812func (c *reloadCoordinator) SetContainerConfigApplier(apply func(context.Context, Config) error) {
1813 c.mu.Lock()
1814 defer c.mu.Unlock()
1815 c.applyContainerConfig = apply
1816}
1817
1818func (c *reloadCoordinator) Trigger(ctx context.Context) error {
1819 c.mu.Lock()
1820 defer c.mu.Unlock()
1821
1822 return c.reloadLocked(ctx, true)
1823}
1824
1825func (c *reloadCoordinator) ApplyLoadedConfig(ctx context.Context) error {
1826 c.mu.Lock()
1827 defer c.mu.Unlock()
1828
1829 return c.reloadLocked(ctx, false)
1830}
1831
1832func (c *reloadCoordinator) reloadLocked(ctx context.Context, loadConfig bool) error {
1833 now := time.Now()
1834 if !c.lastRun.IsZero() && now.Sub(c.lastRun) < c.debounce {
1835 c.log.Debug().Dur("since_last_reload", now.Sub(c.lastRun)).Msg("skipping config reload trigger due to debounce")
1836 return nil
1837 }
1838
1839 if loadConfig {
1840 if err := c.configSvc.Reload(ctx); err != nil {
1841 c.log.Error().Err(err).Msg("failed to reload config")
1842 return fmt.Errorf("failed to reload config: %w", err)
1843 }
1844 }
1845
1846 if err := c.applyLoadedConfig(ctx, now); err != nil {
1847 return err
1848 }
1849
1850 return nil
1851}
1852
1853func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time) error {
1854 var reloadCfg Config
1855 if err := c.v.Unmarshal(&reloadCfg); err != nil {
1856 c.log.Error().Err(err).Msg("failed to unmarshal config on reload")
1857 return fmt.Errorf("failed to unmarshal config on reload: %w", err)
1858 }
1859 if err := validateEntrypointMigration(c.v, reloadCfg); err != nil {
1860 return err
1861 }
1862
1863 reloadedProxy, err := buildProxyConfig(reloadCfg, c.log)
1864 if err != nil {
1865 c.log.Error().Err(err).Msg("failed to parse proxy config on reload")
1866 return fmt.Errorf("failed to parse proxy config on reload: %w", err)
1867 }
1868
1869 if c.applyContainerConfig != nil {
1870 if err := c.applyContainerConfig(ctx, reloadCfg); err != nil {
1871 c.log.Error().Err(err).Msg("failed to apply container config on reload")
1872 return fmt.Errorf("failed to apply container config on reload: %w", err)
1873 }
1874 }
1875 c.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)
1876 if c.registryLimits != nil {
1877 c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize)
1878 }
1879
1880 // Reconcile public TLS before publishing reload events so certificate
1881 // authorization reflects the loaded config even if event delivery fails.
1882 // A transient ACME issue must not abort the rest of the reload.
1883 if c.publicTLS != nil {
1884 if err := c.publicTLS.Reconcile(ctx); err != nil {
1885 c.log.Warn().Err(err).Msg("failed to reconcile public TLS certificates after reload, continuing")
1886 }
1887 }
1888
1889 if c.eventBus != nil {
1890 if err := c.eventBus.Publish(domain.EventConfigReload, nil); err != nil {
1891 c.log.Error().Err(err).Msg("failed to publish config reload event")
1892 return fmt.Errorf("failed to publish config reload event: %w", err)
1893 }
1894 }
1895
1896 c.lastRun = now
1897
1898 c.log.Debug().Msg("config hot reload complete")
1899 return nil
1900}
1901
1902// buildProxyConfig parses size-related config fields and builds the proxy config.
1903func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, error) {
1904 maxProxyBodySize := int64(512 << 20) // 512MB default
1905 if cfg.Server.MaxProxyBodySize != "" {
1906 parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyBodySize)
1907 if err != nil {
1908 return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_body_size configuration", map[string]any{"value": cfg.Server.MaxProxyBodySize})
1909 }
1910 maxProxyBodySize = parsedSize
1911 }
1912
1913 maxBlobChunkSize := int64(registry.DefaultMaxBlobChunkSize)
1914 if cfg.Server.MaxBlobChunkSize != "" {
1915 parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobChunkSize)
1916 if err != nil {
1917 return nil, log.WrapErrWithFields(err, "invalid server.max_blob_chunk_size configuration", map[string]any{"value": cfg.Server.MaxBlobChunkSize})
1918 }
1919 maxBlobChunkSize = parsedSize
1920 }
1921
1922 maxBlobSize := int64(registry.DefaultMaxBlobSize)
1923 if cfg.Server.MaxBlobSize != "" {
1924 parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobSize)
1925 if err != nil {
1926 return nil, log.WrapErrWithFields(err, "invalid server.max_blob_size configuration", map[string]any{"value": cfg.Server.MaxBlobSize})
1927 }
1928 maxBlobSize = parsedSize
1929 }
1930
1931 maxProxyResponseSize := int64(1 << 30) // 1GB default
1932 if cfg.Server.MaxProxyResponseSize != "" {
1933 parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyResponseSize)
1934 if err != nil {
1935 return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_response_size configuration", map[string]any{"value": cfg.Server.MaxProxyResponseSize})
1936 }
1937 maxProxyResponseSize = parsedSize
1938 }
1939
1940 maxConcurrentConns := cfg.Server.MaxConcurrentConns
1941 if maxConcurrentConns < 0 {
1942 maxConcurrentConns = 10000 // default when explicitly set to -1
1943 }
1944 // 0 means no limit (as documented in proxy.Config)
1945
1946 registryDomain, _ := resolveRegistryDomains(cfg)
1947
1948 return &proxyConfigResult{
1949 proxyConfig: proxy.Config{
1950 RegistryDomain: registryDomain,
1951 RegistryPort: cfg.Server.RegistryPort,
1952 MaxBodySize: maxProxyBodySize,
1953 MaxResponseSize: maxProxyResponseSize,
1954 MaxConcurrentConns: maxConcurrentConns,
1955 },
1956 maxBlobChunkSize: maxBlobChunkSize,
1957 maxBlobSize: maxBlobSize,
1958 }, nil
1959}
1960
1961// buildDNSConfig parses the raw dns config section into a publictls.DNSConfig.
1962func buildDNSConfig(cfg Config) (publictls.DNSConfig, error) {
1963 defaults := publictls.DefaultDNSConfig()
1964
1965 resolvers := cfg.DNS.Resolvers
1966 if len(resolvers) == 0 {
1967 resolvers = defaults.Resolvers
1968 }
1969
1970 propagationTimeout := defaults.PropagationTimeout
1971 if cfg.DNS.PropagationTimeout != "" {
1972 parsed, err := time.ParseDuration(cfg.DNS.PropagationTimeout)
1973 if err != nil {
1974 return publictls.DNSConfig{}, fmt.Errorf("invalid dns.propagation_timeout: %w", err)
1975 }
1976 propagationTimeout = parsed
1977 }
1978
1979 pollingInterval := defaults.PollingInterval
1980 if cfg.DNS.PollingInterval != "" {
1981 parsed, err := time.ParseDuration(cfg.DNS.PollingInterval)
1982 if err != nil {
1983 return publictls.DNSConfig{}, fmt.Errorf("invalid dns.polling_interval: %w", err)
1984 }
1985 pollingInterval = parsed
1986 }
1987
1988 dnsCfg := publictls.DNSConfig{
1989 Resolvers: append([]string(nil), resolvers...),
1990 PropagationTimeout: propagationTimeout,
1991 PollingInterval: pollingInterval,
1992 }
1993 if err := dnsCfg.Validate(); err != nil {
1994 return publictls.DNSConfig{}, err
1995 }
1996 return dnsCfg, nil
1997}
1998
1999func buildContainerServiceConfig(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (container.Config, error) {
2000 if cfg.Containers.CPULimit < 0 {
2001 return container.Config{}, fmt.Errorf("containers.cpu_limit must be >= 0 (got %f)", cfg.Containers.CPULimit)
2002 }
2003 if cfg.Containers.PidsLimit < 0 {
2004 return container.Config{}, fmt.Errorf("containers.pids_limit must be >= 0 (got %d)", cfg.Containers.PidsLimit)
2005 }
2006 var defaultMemoryLimit int64
2007 if cfg.Containers.MemoryLimit != "" {
2008 parsed, err := bytesize.Parse(cfg.Containers.MemoryLimit)
2009 if err != nil {
2010 return container.Config{}, fmt.Errorf("invalid containers.memory_limit %q: %w", cfg.Containers.MemoryLimit, err)
2011 }
2012 if parsed <= 0 {
2013 return container.Config{}, fmt.Errorf("containers.memory_limit must be positive (got %q)", cfg.Containers.MemoryLimit)
2014 }
2015 defaultMemoryLimit = parsed
2016 }
2017 var defaultNanoCPUs int64
2018 if cfg.Containers.CPULimit > 0 {
2019 defaultNanoCPUs = int64(cfg.Containers.CPULimit * 1e9)
2020 }
2021
2022 attachmentConfig := svc.configSvc.GetAttachmentConfig()
2023 registryDomain, legacyRegistryDomains := resolveRegistryDomains(cfg)
2024
2025 containerConfig := container.Config{
2026 RegistryAuthEnabled: cfg.Auth.Enabled,
2027 RegistryDomain: registryDomain,
2028 LegacyRegistryDomains: legacyRegistryDomains,
2029 RegistryPort: cfg.Server.RegistryPort,
2030 InternalRegistryUsername: svc.internalRegUser,
2031 InternalRegistryPassword: svc.internalRegPass,
2032 PullPolicy: v.GetString("deploy.pull_policy"),
2033 VolumeAutoCreate: v.GetBool("volumes.auto_create"),
2034 VolumePrefix: v.GetString("volumes.prefix"),
2035 VolumePreserve: v.GetBool("volumes.preserve"),
2036 NetworkIsolation: v.GetBool("network_isolation.enabled"),
2037 NetworkPrefix: v.GetString("network_isolation.network_prefix"),
2038 NetworkGroups: attachmentConfig.NetworkGroups,
2039 NetworkInternal: v.GetBool("network_isolation.internal"),
2040 Attachments: attachmentConfig.Attachments,
2041 AllowedRegistries: cfg.Images.AllowedRegistries,
2042 RequireImageDigest: cfg.Images.RequireDigest,
2043 SecurityProfile: cfg.Containers.SecurityProfile,
2044 ReadinessDelay: v.GetDuration("deploy.readiness_delay"),
2045 ReadinessMode: v.GetString("deploy.readiness_mode"),
2046 HealthTimeout: v.GetDuration("deploy.health_timeout"),
2047 StabilizationDelay: v.GetDuration("deploy.stabilization_delay"),
2048 TCPProbeTimeout: v.GetDuration("deploy.tcp_probe_timeout"),
2049 HTTPProbeTimeout: v.GetDuration("deploy.http_probe_timeout"),
2050 DrainDelay: v.GetDuration("deploy.drain_delay"),
2051 DrainMode: v.GetString("deploy.drain_mode"),
2052 DrainTimeout: v.GetDuration("deploy.drain_timeout"),
2053 DefaultMemoryLimit: defaultMemoryLimit,
2054 DefaultNanoCPUs: defaultNanoCPUs,
2055 DefaultPidsLimit: cfg.Containers.PidsLimit,
2056 AttachmentReadinessTimeout: v.GetDuration("deploy.attachment_readiness_timeout"),
2057 }
2058 if v.IsSet("deploy.drain_delay") {
2059 containerConfig.DrainDelayConfigured = true
2060 containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay")
2061 }
2062
2063 if containerConfig.RegistryAuthEnabled {
2064 if svc.authSvc == nil {
2065 return container.Config{}, fmt.Errorf("authentication service unavailable: cannot generate registry service token")
2066 }
2067 expiry, err := resolveServiceTokenExpiry(cfg)
2068 if err != nil {
2069 return container.Config{}, log.WrapErr(err, "failed to resolve service token expiry")
2070 }
2071 serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, expiry)
2072 if err != nil {
2073 return container.Config{}, log.WrapErr(err, "failed to generate registry service token")
2074 }
2075 log.Info().
2076 Str("subject", serviceTokenSubject).
2077 Str("expiry", expiry.String()).
2078 Msg("generated service token for container registry access")
2079 containerConfig.ServiceTokenUsername = serviceTokenSubject
2080 containerConfig.ServiceToken = serviceToken
2081 } else {
2082 log.Warn().Msg("registry auth disabled; container image pulls will use unauthenticated mode")
2083 }
2084
2085 return containerConfig, nil
2086}
2087
2088// createContainerService creates the container service with configuration.
2089func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
2090 containerConfig, err := buildContainerServiceConfig(ctx, v, cfg, svc, log)
2091 if err != nil {
2092 return nil, err
2093 }
2094 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig, svc.configSvc), nil
2095}
2096
2097type databaseBackupSettingsConfig struct {
2098 Enabled bool
2099 Schedule string
2100 StorageDir string
2101 Retention struct {
2102 Hourly int
2103 Daily int
2104 Weekly int
2105 Monthly int
2106 }
2107}
2108
2109func databaseBackupSettings(cfg Config) databaseBackupSettingsConfig {
2110 out := databaseBackupSettingsConfig{
2111 Enabled: cfg.Backups.Databases.Enabled,
2112 Schedule: cfg.Backups.Databases.Schedule,
2113 StorageDir: cfg.Backups.Databases.StorageDir,
2114 }
2115 out.Retention.Hourly = cfg.Backups.Databases.Retention.Hourly
2116 out.Retention.Daily = cfg.Backups.Databases.Retention.Daily
2117 out.Retention.Weekly = cfg.Backups.Databases.Retention.Weekly
2118 out.Retention.Monthly = cfg.Backups.Databases.Retention.Monthly
2119 // Legacy [backups] keys intentionally override new database defaults when
2120 // backups.enabled is true, preserving existing working pg_dump schedules.
2121 // Otherwise, prefer the already-populated backups.databases.* values.
2122 if cfg.Backups.Enabled {
2123 out.Enabled = true
2124 if cfg.Backups.Schedule != "" {
2125 out.Schedule = cfg.Backups.Schedule
2126 }
2127 if cfg.Backups.StorageDir != "" {
2128 out.StorageDir = cfg.Backups.StorageDir
2129 }
2130 } else {
2131 if out.Schedule == "" {
2132 out.Schedule = cfg.Backups.Schedule
2133 }
2134 if out.StorageDir == "" {
2135 out.StorageDir = cfg.Backups.StorageDir
2136 }
2137 }
2138 if out.Retention.Hourly == 0 {
2139 out.Retention.Hourly = cfg.Backups.Retention.Hourly
2140 }
2141 if out.Retention.Daily == 0 {
2142 out.Retention.Daily = cfg.Backups.Retention.Daily
2143 }
2144 if out.Retention.Weekly == 0 {
2145 out.Retention.Weekly = cfg.Backups.Retention.Weekly
2146 }
2147 if out.Retention.Monthly == 0 {
2148 out.Retention.Monthly = cfg.Backups.Retention.Monthly
2149 }
2150 return out
2151}
2152
2153func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*filesystem.BackupStorage, *backup.Service, error) {
2154 dbCfg := databaseBackupSettings(cfg)
2155 if !dbCfg.Enabled {
2156 return nil, nil, nil
2157 }
2158
2159 storageDir := dbCfg.StorageDir
2160 if storageDir == "" {
2161 dataDir := resolveDataDir(cfg.Server.DataDir)
2162 storageDir = filepath.Join(dataDir, "backups")
2163 }
2164
2165 backupStorage, err := filesystem.NewBackupStorage(storageDir, log)
2166 if err != nil {
2167 return nil, nil, log.WrapErr(err, "failed to create backup storage")
2168 }
2169
2170 retention, err := validateBackupRetention(cfg)
2171 if err != nil {
2172 return nil, nil, log.WrapErr(err, "invalid backup retention policy")
2173 }
2174
2175 backupCfg := domain.BackupConfig{
2176 Enabled: dbCfg.Enabled,
2177 StorageDir: storageDir,
2178 Retention: retention,
2179 }
2180
2181 backupSvc := backup.NewService(svc.runtime, backupStorage, svc.containerSvc, backupCfg, log)
2182
2183 log.Info().
2184 Str("storage_dir", storageDir).
2185 Msg("backup service initialized")
2186
2187 return backupStorage, backupSvc, nil
2188}
2189
2190func createVolumeBackupService(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (out.VolumeBackupStorage, *backup.VolumeService, domain.VolumeBackupConfig, error) {
2191 if !cfg.Backups.Volumes.Enabled {
2192 return nil, nil, domain.VolumeBackupConfig{}, nil
2193 }
2194 volumeCfg, err := validateVolumeBackupConfig(cfg)
2195 if err != nil {
2196 return nil, nil, domain.VolumeBackupConfig{}, log.WrapErr(err, "invalid volume backup configuration")
2197 }
2198
2199 storage, err := s3storage.NewVolumeBackupStorage(ctx, volumeCfg)
2200 if err != nil {
2201 return nil, nil, domain.VolumeBackupConfig{}, log.WrapErr(err, "failed to create volume backup storage")
2202 }
2203
2204 volumeSvc := backup.NewVolumeService(svc.runtime, svc.runtime, storage, volumeCfg, log)
2205 log.Info().
2206 Str("bucket", volumeCfg.S3Bucket).
2207 Str("prefix", volumeCfg.S3Prefix).
2208 Msg("volume backup service initialized")
2209
2210 return storage, volumeSvc, volumeCfg, nil
2211}
2212
2213func validateBackupRetention(cfg Config) (domain.RetentionPolicy, error) {
2214 dbCfg := databaseBackupSettings(cfg)
2215 if dbCfg.Retention.Hourly < 0 {
2216 return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.hourly cannot be negative")
2217 }
2218 if dbCfg.Retention.Daily < 0 {
2219 return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.daily cannot be negative")
2220 }
2221 if dbCfg.Retention.Weekly < 0 {
2222 return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.weekly cannot be negative")
2223 }
2224 if dbCfg.Retention.Monthly < 0 {
2225 return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.monthly cannot be negative")
2226 }
2227
2228 return domain.RetentionPolicy{
2229 Hourly: dbCfg.Retention.Hourly,
2230 Daily: dbCfg.Retention.Daily,
2231 Weekly: dbCfg.Retention.Weekly,
2232 Monthly: dbCfg.Retention.Monthly,
2233 }, nil
2234}
2235
2236func validateVolumeBackupConfig(cfg Config) (domain.VolumeBackupConfig, error) {
2237 volumeCfg := cfg.Backups.Volumes
2238 interval, err := parsePositiveDurationDefault(volumeCfg.Interval, "24h", "backups.volumes.interval")
2239 if err != nil {
2240 return domain.VolumeBackupConfig{}, err
2241 }
2242 timeout, err := parsePositiveDurationDefault(volumeCfg.Timeout, "2h", "backups.volumes.timeout")
2243 if err != nil {
2244 return domain.VolumeBackupConfig{}, err
2245 }
2246 compression, err := parseVolumeBackupCompression(volumeCfg.Compression)
2247 if err != nil {
2248 return domain.VolumeBackupConfig{}, err
2249 }
2250 maxConcurrency := volumeCfg.MaxConcurrency
2251 if maxConcurrency == 0 {
2252 maxConcurrency = 2
2253 }
2254 helperImage := strings.TrimSpace(volumeCfg.HelperImage)
2255 if helperImage == "" {
2256 helperImage = "alpine:3.20"
2257 }
2258 volumePrefix := strings.TrimSpace(cfg.Volumes.Prefix)
2259 if volumePrefix == "" {
2260 volumePrefix = "gordon"
2261 }
2262 if compression == domain.VolumeBackupCompressionZstd && helperImage == "alpine:3.20" {
2263 return domain.VolumeBackupConfig{}, fmt.Errorf("backups.volumes.compression zstd requires a helper_image that provides zstd")
2264 }
2265 if err := validateVolumeBackupS3Settings(volumeCfg.Enabled, volumeCfg.Retention.Keep, maxConcurrency, volumeCfg.S3.Bucket, volumeCfg.S3.Region); err != nil {
2266 return domain.VolumeBackupConfig{}, err
2267 }
2268 return domain.VolumeBackupConfig{
2269 Enabled: volumeCfg.Enabled,
2270 Interval: interval,
2271 Compression: compression,
2272 Retention: domain.VolumeBackupRetentionPolicy{Keep: volumeCfg.Retention.Keep},
2273 Timeout: timeout,
2274 MaxConcurrency: maxConcurrency,
2275 HelperImage: helperImage,
2276 VolumePrefix: volumePrefix,
2277 S3Bucket: strings.TrimSpace(volumeCfg.S3.Bucket),
2278 S3Region: strings.TrimSpace(volumeCfg.S3.Region),
2279 S3Prefix: strings.TrimSpace(volumeCfg.S3.Prefix),
2280 S3Endpoint: strings.TrimSpace(volumeCfg.S3.Endpoint),
2281 S3PathStyle: volumeCfg.S3.PathStyle,
2282 S3SSEAlgorithm: strings.TrimSpace(volumeCfg.S3.SSEAlgorithm),
2283 S3SSEKMSKeyID: strings.TrimSpace(volumeCfg.S3.SSEKMSKeyID),
2284 }, nil
2285}
2286
2287func parsePositiveDurationDefault(raw, defaultValue, field string) (time.Duration, error) {
2288 if strings.TrimSpace(raw) == "" {
2289 raw = defaultValue
2290 }
2291 d, err := time.ParseDuration(strings.TrimSpace(raw))
2292 if err != nil || d <= 0 {
2293 return 0, fmt.Errorf("%s must be a positive duration", field)
2294 }
2295 return d, nil
2296}
2297
2298func parseVolumeBackupCompression(raw string) (domain.VolumeBackupCompression, error) {
2299 if strings.TrimSpace(raw) == "" {
2300 raw = string(domain.VolumeBackupCompressionGzip)
2301 }
2302 compression := domain.VolumeBackupCompression(strings.ToLower(strings.TrimSpace(raw)))
2303 switch compression {
2304 case domain.VolumeBackupCompressionGzip, domain.VolumeBackupCompressionZstd:
2305 return compression, nil
2306 default:
2307 return "", fmt.Errorf("backups.volumes.compression must be one of: gzip, zstd")
2308 }
2309}
2310
2311func validateVolumeBackupS3Settings(enabled bool, keep, maxConcurrency int, bucket, region string) error {
2312 if keep < 0 {
2313 return fmt.Errorf("backups.volumes.retention.keep cannot be negative")
2314 }
2315 if enabled && keep == 0 {
2316 return fmt.Errorf("backups.volumes.retention.keep must be positive when volume backups are enabled")
2317 }
2318 if maxConcurrency < 1 {
2319 return fmt.Errorf("backups.volumes.max_concurrency must be at least 1")
2320 }
2321 if enabled && strings.TrimSpace(bucket) == "" {
2322 return fmt.Errorf("backups.volumes.s3.bucket is required when volume backups are enabled")
2323 }
2324 if enabled && strings.TrimSpace(region) == "" {
2325 return fmt.Errorf("backups.volumes.s3.region is required when volume backups are enabled")
2326 }
2327 return nil
2328}
2329
2330// registerEventHandlers registers all event handlers.
2331func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func(), error) {
2332 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
2333 if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil {
2334 return nil, fmt.Errorf("failed to subscribe image pushed handler: %w", err)
2335 }
2336
2337 // Auto-route handler for creating routes from image labels
2338 registryDomain, legacyRegistryDomains := resolveRegistryDomains(cfg)
2339 autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, registryDomain, legacyRegistryDomains...).
2340 WithEnvExtractor(svc.runtime, svc.envDir)
2341
2342 // Preview handler for creating preview environments from tagged images
2343 autoPreviewHandler := preview.NewAutoPreviewHandler(
2344 ctx,
2345 svc.configSvc,
2346 svc.previewService,
2347 )
2348
2349 // Dispatcher routes image push events to either auto-route or preview handler
2350 dispatcher := auto.NewImagePushDispatcher(svc.configSvc, autoRouteHandler, autoPreviewHandler)
2351 if err := svc.eventBus.Subscribe(dispatcher); err != nil {
2352 return nil, fmt.Errorf("subscribe image push dispatcher: %w", err)
2353 }
2354
2355 configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)
2356 if err := svc.eventBus.Subscribe(configReloadHandler); err != nil {
2357 return nil, fmt.Errorf("failed to subscribe config reload handler: %w", err)
2358 }
2359
2360 manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc)
2361 if err := svc.eventBus.Subscribe(manualDeployHandler); err != nil {
2362 return nil, fmt.Errorf("failed to subscribe manual deploy handler: %w", err)
2363 }
2364
2365 secretsChangedHandler := container.NewSecretsChangedHandler(ctx, svc.containerSvc, svc.configSvc, container.DefaultSecretsDebounce)
2366 if err := svc.eventBus.Subscribe(secretsChangedHandler); err != nil {
2367 return nil, fmt.Errorf("failed to subscribe secrets changed handler: %w", err)
2368 }
2369
2370 // Proxy cache invalidation on config reload (clears stale targets for removed routes)
2371 configReloadProxyHandler := proxy.NewConfigReloadProxyHandler(ctx, svc.proxySvc)
2372 if err := svc.eventBus.Subscribe(configReloadProxyHandler); err != nil {
2373 return nil, fmt.Errorf("failed to subscribe config reload proxy handler: %w", err)
2374 }
2375
2376 cleanup := func() {
2377 secretsChangedHandler.Stop()
2378 }
2379
2380 return cleanup, nil
2381}
2382
2383// setupConfigHotReload sets up config hot reload.
2384func setupConfigHotReload(ctx context.Context, configSvc configWatcher, coordinator loadedConfigApplier) error {
2385 if err := configSvc.Watch(ctx, func() {
2386 _ = coordinator.ApplyLoadedConfig(ctx)
2387 }); err != nil {
2388 return fmt.Errorf("failed to watch config: %w", err)
2389 }
2390
2391 return nil
2392}
2393
2394func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler {
2395 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2396 host, _, err := net.SplitHostPort(r.RemoteAddr)
2397 if err != nil {
2398 host = r.RemoteAddr
2399 }
2400
2401 ip := net.ParseIP(host)
2402 if ip == nil || !ip.IsLoopback() {
2403 log.Warn().
2404 Str("path", r.URL.Path).
2405 Str("remote_addr", r.RemoteAddr).
2406 Msg("blocked non-loopback access on internal admin route")
2407 w.Header().Set("Content-Type", "application/json")
2408 w.WriteHeader(http.StatusForbidden)
2409 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"})
2410 return
2411 }
2412
2413 next.ServeHTTP(w, r)
2414 })
2415}
2416
2417// createHTTPHandlers creates HTTP handlers with middleware.
2418// Returns three handlers: registry, HTTP proxy (with CIDR + onboarding), and HTTPS proxy.
2419func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWriter out.AccessLogWriter) (http.Handler, http.Handler, http.Handler) {
2420 // Parse trusted proxies once for all middleware chains.
2421 // This ensures consistent IP extraction across logging, rate limiting, and auth.
2422 trustedNets := httphelper.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
2423
2424 // Registry handler
2425 registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize, svc.maxBlobSize)
2426 svc.registryHandler = registryHandler
2427 if svc.reloadCoordinator != nil {
2428 svc.reloadCoordinator.SetRegistryLimits(registryHandler)
2429 }
2430 registryWithMiddleware, cidrAllowlistMiddleware, rateLimitMiddleware := buildRegistryHandlerWithMiddleware(
2431 svc,
2432 cfg,
2433 trustedNets,
2434 registryHandler,
2435 log,
2436 )
2437
2438 registryMux := http.NewServeMux()
2439 registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, cfg, log)
2440 registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log))
2441 registerAdminRoutes(registryMux, svc, cfg, trustedNets, log)
2442
2443 // Proxy handler
2444 proxyHandler := proxyadapter.NewHandler(svc.proxySvc, trustedNets, log)
2445
2446 // HTTP proxy handler chain: HTTPS redirect for non-proxy clients, then CIDR allowlist
2447 proxyAllowedNets, proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log)
2448
2449 httpProxyMiddlewares := []func(http.Handler) http.Handler{
2450 middleware.PanicRecovery(log),
2451 middleware.RequestLogger(log, trustedNets),
2452 middleware.SecurityHeaders,
2453 middleware.HTTPSRedirect(proxyAllowedNets, effectiveProxyHTTPPort(cfg), effectiveProxyTLSPort(cfg), cfg.Server.ForceHTTPSRedirect, log, func(host string) bool {
2454 return svc.proxySvc.IsKnownHost(context.Background(), host)
2455 }),
2456 }
2457 if proxyCIDRMiddleware != nil {
2458 httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware)
2459 }
2460
2461 httpProxyWithMiddleware := otelhttp.NewHandler(
2462 middleware.Chain(httpProxyMiddlewares...)(proxyHandler),
2463 "gordon.proxy",
2464 )
2465
2466 // Build the onboarding handler once if internal CA is available and TLS is enabled.
2467 var obHandler *onboarding.Handler
2468 if svc.caAdapter != nil && effectiveProxyTLSPort(cfg) != 0 {
2469 mobileconfigBytes := pkiadapter.GenerateMobileconfig(
2470 svc.caAdapter.RootCertificateDER(),
2471 svc.caAdapter.RootCommonName(),
2472 )
2473 obHandler = onboarding.NewHandler(
2474 svc.caAdapter.RootCertificate(),
2475 mobileconfigBytes,
2476 svc.caAdapter.RootFingerprint(),
2477 effectiveProxyHTTPPort(cfg),
2478 effectiveProxyTLSPort(cfg),
2479 )
2480 }
2481
2482 // HTTP proxyMux: trusted proxy traffic flows through the normal proxy chain.
2483 // Direct clients get an onboarding gate (when CA is available) placed BEFORE
2484 // HTTPSRedirect so force_https_redirect cannot bypass onboarding.
2485 // ACME HTTP-01 challenge handler is registered before the catch-all "/" so
2486 // it gets first chance regardless of source IP.
2487 proxyMux := http.NewServeMux()
2488
2489 // Register ACME HTTP-01 challenge handler before all other routes so
2490 // Let's Encrypt validation always succeeds, even for onboarding clients.
2491 if svc.publicTLSSvc != nil {
2492 proxyMux.Handle(acmehttp.Prefix, acmehttp.NewHandler(svc.publicTLSSvc))
2493 }
2494
2495 if proxyCIDRMiddleware != nil && proxyAllowedNets == nil {
2496 // Invalid proxy_allowed_ips: deny all traffic (fail-closed).
2497 proxyMux.Handle("/", proxyCIDRMiddleware(httpProxyWithMiddleware))
2498 } else if obHandler != nil {
2499 proxyMux.Handle("/", directHTTPOnboardingGate(obHandler, proxyAllowedNets, httpProxyWithMiddleware, log))
2500 } else {
2501 proxyMux.Handle("/", httpProxyWithMiddleware)
2502 }
2503
2504 // HTTPS proxy handler chain: security headers + proxy + CA onboarding
2505 // Onboarding routes live on the TLS port so Tailnet / direct clients
2506 // can click through the initial cert warning, install the CA, and
2507 // then trust all subsequent connections.
2508 // The middleware chain wraps the entire mux so onboarding routes also
2509 // get PanicRecovery, RequestLogger, and SecurityHeaders.
2510 httpsProxyMiddlewares := []func(http.Handler) http.Handler{
2511 middleware.PanicRecovery(log),
2512 middleware.RequestLogger(log, trustedNets),
2513 middleware.SecurityHeaders,
2514 }
2515
2516 httpsMux := http.NewServeMux()
2517 if obHandler != nil && cfg.Server.GordonDomain != "" {
2518 gordonDomain := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(cfg.Server.GordonDomain)), ".")
2519 onboardingMux := http.NewServeMux()
2520 registerOnboardingRoutes(onboardingMux, obHandler)
2521 // Register onboarding paths host-gated so normal traffic hits
2522 // proxyHandler directly through the catch-all / pattern.
2523 httpsMux.Handle("GET /.well-known/gordon/", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2524 httpsMux.Handle("GET /.well-known/gordon/ca", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2525 httpsMux.Handle("GET /.well-known/gordon/ca.crt", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2526 httpsMux.Handle("GET /.well-known/gordon/ca.mobileconfig", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2527 httpsMux.Handle("/", proxyHandler)
2528 } else {
2529 httpsMux.Handle("/", proxyHandler)
2530 }
2531
2532 httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls")
2533
2534 // Wrap top-level handlers with access logging outside all gates
2535 // (loopbackOnly, denyAllHandler, CIDR allowlist) so every request —
2536 // including rejected probes — produces exactly one access-log line.
2537 var registryOut, proxyOut, httpsOut http.Handler = registryMux, proxyMux, httpsHandler
2538 if accessWriter != nil {
2539 excludeHC := cfg.Logging.AccessLog.ExcludeHealthChecks
2540 registryOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(registryOut)
2541 proxyOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(proxyOut)
2542 httpsOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(httpsOut)
2543 }
2544 svc.httpsProxyHandler = httpsOut
2545
2546 return registryOut, proxyOut, httpsOut
2547}
2548
2549// registerOnboardingRoutes registers CA onboarding well-known HTTP routes on
2550// the given mux. Both direct-HTTP and Gordon-domain HTTPS onboarding use this.
2551func registerOnboardingRoutes(mux *http.ServeMux, ob *onboarding.Handler) {
2552 mux.HandleFunc("GET /.well-known/gordon/", ob.ServeOnboardingPage)
2553 mux.HandleFunc("GET /.well-known/gordon/ca", ob.ServeOnboardingPage)
2554 mux.HandleFunc("GET /.well-known/gordon/ca.crt", ob.ServeCACert)
2555 mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", ob.ServeMobileconfig)
2556}
2557
2558// directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic
2559// by source IP. Trusted proxy IPs flow through to the normal proxy chain.
2560// Direct clients are served the CA onboarding flow on allowed paths and
2561// receive 403 on everything else. This gate runs BEFORE HTTPSRedirect so
2562// force_https_redirect cannot bypass onboarding for direct clients.
2563func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, proxyChain http.Handler, log zerowrap.Logger) http.Handler {
2564 // Build a small mux for direct-client onboarding paths.
2565 onboardingMux := http.NewServeMux()
2566 registerOnboardingRoutes(onboardingMux, ob)
2567
2568 // Reserve ACME challenge path for future use.
2569 onboardingMux.HandleFunc("/.well-known/acme-challenge/", func(w http.ResponseWriter, _ *http.Request) {
2570 http.Error(w, "not found", http.StatusNotFound)
2571 })
2572
2573 // Catch-all: reject any other direct HTTP request.
2574 // Uses a method-aware split: GET writes a body, HEAD gets an empty 403.
2575 onboardingMux.HandleFunc("/", directHTTPForbidden)
2576
2577 onboardingWithMiddleware := middleware.Chain(
2578 middleware.PanicRecovery(log),
2579 middleware.RequestLogger(log), // Intentionally omit trusted proxy nets so direct onboarding logs use RemoteAddr only.
2580 middleware.SecurityHeaders,
2581 )(onboardingMux)
2582
2583 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2584 remoteIP := httphelper.ExtractRemoteIP(r.RemoteAddr)
2585 if httphelper.IsTrustedOrLocal(remoteIP, proxyNets) {
2586 proxyChain.ServeHTTP(w, r)
2587 return
2588 }
2589 onboardingWithMiddleware.ServeHTTP(w, r)
2590 })
2591}
2592
2593// canonicalHostsEqual compares two hosts after normalising both: stripping
2594// port, trimming spaces, lowercasing, and removing trailing dot.
2595func canonicalHostsEqual(host, expected string) bool {
2596 host = strings.TrimSpace(host)
2597 if h, _, err := net.SplitHostPort(host); err == nil {
2598 host = h
2599 }
2600 host = strings.TrimSuffix(strings.ToLower(host), ".")
2601 expected = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(expected)), ".")
2602 return host == expected
2603}
2604
2605// gordonDomainOnboardingGate returns a handler that serves onboarding routes
2606// only when the request host matches gordonDomain. For mismatched hosts it
2607// delegates to proxyHandler. gordonDomain must already be canonicalised
2608// (trimmed, lowered, trailing dot removed).
2609func gordonDomainOnboardingGate(gordonDomain string, onboardingMux, proxyHandler http.Handler) http.Handler {
2610 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2611 if gordonDomain != "" && canonicalHostsEqual(r.Host, gordonDomain) {
2612 onboardingMux.ServeHTTP(w, r)
2613 return
2614 }
2615 proxyHandler.ServeHTTP(w, r)
2616 })
2617}
2618
2619// directHTTPForbidden responds with 403 for non-onboarding HTTP paths.
2620// HEAD requests get an empty body per HTTP semantics.
2621func directHTTPForbidden(w http.ResponseWriter, r *http.Request) {
2622 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
2623 w.WriteHeader(http.StatusForbidden)
2624 if r.Method != http.MethodHead {
2625 _, _ = w.Write([]byte("Only certificate onboarding is available over HTTP.\n"))
2626 }
2627}
2628
2629func buildRegistryHandlerWithMiddleware(
2630 svc *services,
2631 cfg Config,
2632 trustedNets []*net.IPNet,
2633 registryHandler http.Handler,
2634 log zerowrap.Logger,
2635) (http.Handler, func(http.Handler) http.Handler, func(http.Handler) http.Handler) {
2636 registryMiddlewares := []func(http.Handler) http.Handler{
2637 middleware.PanicRecovery(log),
2638 middleware.RequestLogger(log, trustedNets),
2639 middleware.SecurityHeaders,
2640 }
2641
2642 cidrAllowlistMiddleware := buildRegistryCIDRAllowlistMiddleware(cfg, trustedNets, log)
2643 if cidrAllowlistMiddleware != nil {
2644 registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware)
2645 }
2646
2647 rateLimitMiddleware := buildRegistryRateLimitMiddleware(cfg, log)
2648 registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
2649
2650 appendRegistryAuthMiddleware(®istryMiddlewares, svc, cfg, trustedNets, log)
2651
2652 registryWithOtel := otelhttp.NewHandler(
2653 middleware.Chain(registryMiddlewares...)(registryHandler),
2654 "gordon.registry",
2655 )
2656 return registryWithOtel, cidrAllowlistMiddleware, rateLimitMiddleware
2657}
2658
2659// parseCIDRAllowlist parses a list of IPs/CIDRs, logs warnings for invalid entries,
2660// and returns the parsed nets. label is used in log messages (e.g. "registry_allowed_ips").
2661func parseCIDRAllowlist(ips []string, label string, log zerowrap.Logger) ([]*net.IPNet, bool) {
2662 if len(ips) == 0 {
2663 return nil, false
2664 }
2665
2666 allowedNets := httphelper.ParseTrustedProxies(ips)
2667 if len(allowedNets) != len(ips) {
2668 for _, entry := range ips {
2669 if nets := httphelper.ParseTrustedProxies([]string{entry}); len(nets) == 0 {
2670 log.Warn().Str("entry", entry).Msgf("ignoring invalid %s entry", label)
2671 }
2672 }
2673 }
2674
2675 if len(allowedNets) == 0 {
2676 log.Error().
2677 Strs(label, ips).
2678 Msgf("%s is set but no valid entries were parsed; will deny all traffic (fail-closed)", label)
2679 return nil, true // allInvalid
2680 }
2681
2682 return allowedNets, false
2683}
2684
2685// denyAllHandler returns a middleware that rejects every request with 403 Forbidden.
2686func denyAllHandler(label string, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
2687 return func(next http.Handler) http.Handler {
2688 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2689 log.Warn().
2690 Str(zerowrap.FieldClientIP, middleware.GetClientIP(r, trustedNets)).
2691 Msgf("access denied due to invalid %s configuration", label)
2692 w.Header().Set("Content-Type", "application/json")
2693 w.WriteHeader(http.StatusForbidden)
2694 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"})
2695 })
2696 }
2697}
2698
2699func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
2700 allowedNets, allInvalid := parseCIDRAllowlist(cfg.Server.RegistryAllowedIPs, "registry_allowed_ips", log)
2701 if allInvalid {
2702 return denyAllHandler("registry_allowed_ips", trustedNets, log)
2703 }
2704 if allowedNets == nil {
2705 return nil
2706 }
2707 return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)
2708}
2709
2710func buildProxyCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) ([]*net.IPNet, func(http.Handler) http.Handler) {
2711 allowedNets, allInvalid := parseCIDRAllowlist(cfg.Server.ProxyAllowedIPs, "proxy_allowed_ips", log)
2712 if allInvalid {
2713 return nil, denyAllHandler("proxy_allowed_ips", trustedNets, log)
2714 }
2715 if allowedNets == nil {
2716 return nil, nil
2717 }
2718
2719 log.Info().
2720 Strs("proxy_allowed_ips", cfg.Server.ProxyAllowedIPs).
2721 Msg("proxy origin IP allowlist enabled")
2722
2723 return allowedNets, middleware.ProxyCIDRAllowlist(allowedNets, log)
2724}
2725
2726func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler {
2727 if cfg.API.RateLimit.Enabled {
2728 globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
2729 ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
2730 return registry.RateLimitMiddleware(
2731 globalLimiter,
2732 ipLimiter,
2733 cfg.API.RateLimit.TrustedProxies,
2734 log,
2735 )
2736 }
2737
2738 return registry.RateLimitMiddleware(nil, nil, nil, log)
2739}
2740
2741func appendRegistryAuthMiddleware(registryMiddlewares *[]func(http.Handler) http.Handler, svc *services, cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) {
2742 if svc.authSvc != nil {
2743 internalAuth := middleware.InternalRegistryAuth{
2744 Username: svc.internalRegUser,
2745 Password: svc.internalRegPass,
2746 }
2747 *registryMiddlewares = append(*registryMiddlewares, middleware.RegistryAuthV2(svc.authSvc, internalAuth, trustedNets, log))
2748 return
2749 }
2750
2751 if cfg.Auth.Enabled {
2752 log.Error().Msg("authentication service unavailable; registry requests will be denied")
2753 *registryMiddlewares = append(*registryMiddlewares, func(next http.Handler) http.Handler {
2754 return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2755 w.Header().Set("Content-Type", "application/json")
2756 w.WriteHeader(http.StatusServiceUnavailable)
2757 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"})
2758 })
2759 })
2760 }
2761}
2762
2763func registerAuthRoutes(
2764 registryMux *http.ServeMux,
2765 svc *services,
2766 trustedNets []*net.IPNet,
2767 cidrAllowlistMiddleware func(http.Handler) http.Handler,
2768 rateLimitMiddleware func(http.Handler) http.Handler,
2769 cfg Config,
2770 log zerowrap.Logger,
2771) {
2772 if svc.authHandler == nil {
2773 return
2774 }
2775
2776 // Auth endpoints always get rate limiting, even if global rate limiting is disabled.
2777 // This prevents brute-force attacks against password/token endpoints.
2778 authRateLimitMiddleware := rateLimitMiddleware
2779 if !cfg.API.RateLimit.Enabled {
2780 authGlobalLimiter := ratelimit.NewMemoryStore(50, 100, log)
2781 authIPLimiter := ratelimit.NewMemoryStore(5, 10, log)
2782 authRateLimitMiddleware = registry.RateLimitMiddleware(authGlobalLimiter, authIPLimiter, cfg.API.RateLimit.TrustedProxies, log)
2783 }
2784
2785 // Auth endpoints are NOT protected by auth - they're where clients authenticate
2786 // but still need rate limiting to prevent brute force attacks.
2787 authMiddlewares := []func(http.Handler) http.Handler{
2788 middleware.PanicRecovery(log),
2789 middleware.RequestLogger(log, trustedNets),
2790 middleware.SecurityHeaders,
2791 }
2792 if cidrAllowlistMiddleware != nil {
2793 authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)
2794 }
2795 authMiddlewares = append(authMiddlewares, authRateLimitMiddleware)
2796 authWithMiddleware := otelhttp.NewHandler(
2797 middleware.Chain(authMiddlewares...)(svc.authHandler),
2798 "gordon.auth",
2799 )
2800 registryMux.Handle("/auth/", authWithMiddleware)
2801}
2802
2803func wrapRegistryForLocalMode(registryWithMiddleware http.Handler, cfg Config, log zerowrap.Logger) http.Handler {
2804 if !cfg.Auth.Enabled {
2805 return loopbackOnly(registryWithMiddleware, log)
2806 }
2807 return registryWithMiddleware
2808}
2809
2810func registerAdminRoutes(registryMux *http.ServeMux, svc *services, cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) {
2811 if svc.adminHandler == nil {
2812 return
2813 }
2814
2815 if !cfg.Auth.Enabled {
2816 log.Warn().Msg("auth disabled: admin API endpoints are not registered")
2817 return
2818 }
2819
2820 adminMiddlewares := []func(http.Handler) http.Handler{
2821 middleware.PanicRecovery(log),
2822 middleware.RequestLogger(log, trustedNets),
2823 middleware.SecurityHeaders,
2824 }
2825
2826 if svc.authSvc != nil {
2827 // Create rate limiters for admin API - uses same config as registry.
2828 var globalLimiter, ipLimiter out.RateLimiter
2829 if cfg.API.RateLimit.Enabled {
2830 globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
2831 ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
2832 }
2833 adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log))
2834 } else {
2835 adminMiddlewares = append(adminMiddlewares, func(next http.Handler) http.Handler {
2836 return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2837 w.Header().Set("Content-Type", "application/json")
2838 w.WriteHeader(http.StatusServiceUnavailable)
2839 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"})
2840 })
2841 })
2842 }
2843
2844 adminWithMiddleware := otelhttp.NewHandler(
2845 middleware.Chain(adminMiddlewares...)(svc.adminHandler),
2846 "gordon.admin",
2847 )
2848 registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log))
2849}
2850
2851// runServers starts the HTTP servers and waits for shutdown.
2852// Signal handling notes:
2853// - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext
2854// - SIGUSR1: Triggers config reload without restart
2855// - SIGUSR2: Triggers manual deploy for a specific route
2856// The deferred signal.Stop calls ensure signal handlers are properly
2857// cleaned up before program exit, preventing signal handler leaks.
2858func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, reload reloadTrigger, cleanupHandlers func(), log zerowrap.Logger) error {
2859 // Initialize access log writer. Kept here (not in Run) to keep Run's cyclomatic
2860 // complexity within the project limit of 15.
2861 accessWriterConcrete, err := initAccessLog(cfg, log)
2862 if err != nil {
2863 return err
2864 }
2865 if accessWriterConcrete != nil {
2866 defer accessWriterConcrete.Close()
2867 }
2868 // Convert to interface only when non-nil to avoid the Go nil-interface pitfall
2869 // where a typed nil pointer becomes a non-nil interface value.
2870 var accessWriter out.AccessLogWriter
2871 if accessWriterConcrete != nil {
2872 accessWriter = accessWriterConcrete
2873 }
2874 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
2875 defer cancel()
2876
2877 // Set up SIGUSR1 for reload.
2878 // Note: signal.Stop must be called (via defer) to release the channel
2879 // and prevent signal handler leaks when the function returns.
2880 reloadChan := make(chan os.Signal, 1)
2881 signal.Notify(reloadChan, syscall.SIGUSR1)
2882 defer signal.Stop(reloadChan)
2883
2884 // Set up SIGUSR2 for manual deploy.
2885 deployChan := make(chan os.Signal, 1)
2886 signal.Notify(deployChan, syscall.SIGUSR2)
2887 defer signal.Stop(deployChan)
2888
2889 errChan := make(chan error, 3)
2890
2891 registryHandler, httpProxyHandler, httpsProxyHandler := createHTTPHandlers(svc, cfg, log, accessWriter)
2892 svc.httpProxyHandler = httpProxyHandler
2893 svc.httpsProxyHandler = httpsProxyHandler
2894
2895 registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort))
2896 registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log)
2897
2898 // closeStarted shuts down any servers that were started before an error occurred,
2899 // preventing leaked listeners during partial startup failures.
2900 closeStarted := func(servers ...*http.Server) {
2901 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
2902 defer shutdownCancel()
2903 for _, srv := range servers {
2904 if srv != nil {
2905 if err := srv.Shutdown(shutdownCtx); err != nil {
2906 log.Error().Err(err).Msg("failed to shut down server during startup cleanup")
2907 }
2908 }
2909 }
2910 }
2911
2912 proxySrv, proxyReady, tlsSrv, _, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, log)
2913 if err != nil {
2914 closeStarted(registrySrv)
2915 return err
2916 }
2917 svc.tlsHTTPEntryPoints = tlsMuxHTTPServerNames(cfg)
2918 svc.smartHTTPEntryPoints = smartTCPHTTPServerNames(cfg)
2919
2920 // Wait for the registry and HTTP proxy to bind before applying the traffic graph.
2921 // This prevents auto-start races while keeping the TLS mux under one owner.
2922 if err := waitForCoreProxyReadyAndApplyTraffic(ctx, cfg, svc, registryReady, proxyReady, errChan); err != nil {
2923 closeStarted(registrySrv, proxySrv, tlsSrv)
2924 return err
2925 }
2926
2927 logEvent := log.Info().
2928 Int("proxy_port", cfg.Server.Port).
2929 Int("registry_port", cfg.Server.RegistryPort)
2930 if cfg.Server.TLSPort != 0 {
2931 logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort)
2932 }
2933 logEvent.Msg("Gordon is running")
2934
2935 startPublicTLSRuntimeWithWarning(ctx, svc.publicTLSRuntime, log)
2936
2937 schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)
2938 if err != nil {
2939 shutdownTrafficManagerForStartupCleanup(svc.trafficManager, log)
2940 closeStarted(registrySrv, proxySrv, tlsSrv)
2941 return err
2942 }
2943 if schedulerCleanup != nil {
2944 defer schedulerCleanup()
2945 }
2946
2947 // Recover configured routes after servers are listening (registry port is now bound).
2948 syncAndRecoverConfiguredRoutes(ctx, svc.configSvc, svc.containerSvc, log)
2949
2950 waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log)
2951 cleanupHandlers() // Stop debounce timers before draining containers
2952 gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, log)
2953 return nil
2954}
2955
2956func startPublicTLSRuntimeWithWarning(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) {
2957 if err := startPublicTLSRuntime(ctx, svc, log); err != nil {
2958 log.Warn().Err(err).Msg("initial public ACME reconcile failed, continuing with renewal loop")
2959 }
2960}
2961
2962func waitForCoreProxyReadyAndApplyTraffic(ctx context.Context, cfg Config, svc *services, registryReady <-chan struct{}, proxyReady <-chan struct{}, errChan <-chan error) error {
2963 if err := waitForServerReady(registryReady, errChan); err != nil {
2964 return err
2965 }
2966 if err := waitForServerReady(proxyReady, errChan); err != nil {
2967 return err
2968 }
2969 if err := applyTrafficRuntimeConfig(ctx, svc.trafficManager, cfg, svc.configSvc); err != nil {
2970 return err
2971 }
2972 return reconcileStandaloneServices(ctx, svc.standaloneServiceSvc, cfg)
2973}
2974
2975func reconcileStandaloneServices(ctx context.Context, serviceSvc in.StandaloneServiceService, cfg Config) error {
2976 if serviceSvc == nil {
2977 return nil
2978 }
2979 standaloneServices, err := servicecfg.ToDomain(cfg.Services)
2980 if err != nil {
2981 return fmt.Errorf("convert standalone service config: %w", err)
2982 }
2983 if err := serviceSvc.Reconcile(ctx, standaloneServices); err != nil {
2984 return fmt.Errorf("reconcile standalone services: %w", err)
2985 }
2986 return nil
2987}
2988
2989func shutdownTrafficManagerForStartupCleanup(manager *trafficadapter.Manager, log zerowrap.Logger) {
2990 if manager == nil {
2991 return
2992 }
2993 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
2994 defer shutdownCancel()
2995 if err := manager.Shutdown(shutdownCtx); err != nil {
2996 log.Error().Err(err).Msg("failed to shut down traffic manager during startup cleanup")
2997 }
2998}
2999
3000func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
3001 if ready == nil {
3002 return nil
3003 }
3004 select {
3005 case <-ready:
3006 return nil
3007 case err := <-errChan:
3008 return err
3009 }
3010}
3011
3012func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, v *viper.Viper) (func(), error) {
3013 schedulers := make([]*cronSvc.Scheduler, 0, 3)
3014
3015 backupScheduler, err := startBackupScheduler(ctx, cfg, svc, log)
3016 if err != nil {
3017 return nil, err
3018 }
3019 if backupScheduler != nil {
3020 schedulers = append(schedulers, backupScheduler)
3021 }
3022
3023 volumeBackupScheduler, err := startVolumeBackupScheduler(ctx, cfg, svc, log)
3024 if err != nil {
3025 return nil, err
3026 }
3027 if volumeBackupScheduler != nil {
3028 schedulers = append(schedulers, volumeBackupScheduler)
3029 }
3030
3031 imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int {
3032 return v.GetInt("images.prune.keep_last")
3033 })
3034 if err != nil {
3035 return nil, err
3036 }
3037 if imageScheduler != nil {
3038 schedulers = append(schedulers, imageScheduler)
3039 }
3040
3041 if len(schedulers) == 0 {
3042 return nil, nil
3043 }
3044
3045 return func() {
3046 for i := len(schedulers) - 1; i >= 0; i-- {
3047 schedulers[i].Stop()
3048 }
3049 }, nil
3050}
3051
3052func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {
3053 dbCfg := databaseBackupSettings(cfg)
3054 if !dbCfg.Enabled || svc == nil || svc.backupSvc == nil {
3055 return nil, nil
3056 }
3057
3058 preset, err := resolveBackupSchedule(dbCfg.Schedule)
3059 if err != nil {
3060 return nil, err
3061 }
3062
3063 scheduler := cronSvc.NewScheduler(log)
3064 err = scheduler.Add(
3065 "backup-scheduler",
3066 "Backups",
3067 domain.CronSchedule{Preset: preset},
3068 func(jobCtx context.Context) error {
3069 if err := svc.backupSvc.RunForSchedule(jobCtx, preset); err != nil {
3070 return err
3071 }
3072 log.Info().
3073 Str("schedule", string(preset)).
3074 Msg("scheduled backup run complete")
3075 return nil
3076 },
3077 )
3078 if err != nil {
3079 return nil, log.WrapErr(err, "failed to register backup schedule")
3080 }
3081
3082 scheduler.Start(ctx)
3083 log.Info().
3084 Str("schedule", string(preset)).
3085 Msg("backup scheduler enabled")
3086
3087 return scheduler, nil
3088}
3089
3090func resolveBackupSchedule(raw string) (domain.BackupSchedule, error) {
3091 return resolveSchedulePreset(raw, "backups.databases.schedule", domain.ScheduleDaily)
3092}
3093
3094func startVolumeBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {
3095 if !cfg.Backups.Volumes.Enabled || svc == nil || svc.volumeBackupSvc == nil {
3096 return nil, nil
3097 }
3098
3099 volumeCfg, err := validateVolumeBackupConfig(cfg)
3100 if err != nil {
3101 return nil, err
3102 }
3103
3104 scheduler := cronSvc.NewScheduler(log)
3105 err = scheduler.Add(
3106 "volume-backup-scheduler",
3107 "Volume Backups",
3108 domain.CronSchedule{Interval: volumeCfg.Interval},
3109 func(jobCtx context.Context) error {
3110 if _, err := svc.volumeBackupSvc.RunVolumeBackups(jobCtx, "", ""); err != nil {
3111 return err
3112 }
3113 log.Info().
3114 Dur("interval", volumeCfg.Interval).
3115 Msg("scheduled volume backup run complete")
3116 return nil
3117 },
3118 )
3119 if err != nil {
3120 return nil, log.WrapErr(err, "failed to register volume backup schedule")
3121 }
3122
3123 scheduler.Start(ctx)
3124 log.Info().
3125 Dur("interval", volumeCfg.Interval).
3126 Msg("volume backup scheduler enabled")
3127
3128 return scheduler, nil
3129}
3130
3131func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, keepLastGetter func() int) (*cronSvc.Scheduler, error) {
3132 if !cfg.Images.Prune.Enabled || svc == nil || svc.imageSvc == nil {
3133 return nil, nil
3134 }
3135 if keepLastGetter == nil {
3136 keepLastGetter = func() int { return cfg.Images.Prune.KeepLast }
3137 }
3138 if keepLastGetter() < 0 {
3139 return nil, fmt.Errorf("images.prune.keep_last must be >= 0")
3140 }
3141
3142 preset, err := resolveImagePruneSchedule(cfg.Images.Prune.Schedule)
3143 if err != nil {
3144 return nil, err
3145 }
3146
3147 scheduler := cronSvc.NewScheduler(log)
3148 err = scheduler.Add(
3149 "image-prune",
3150 "Image prune",
3151 domain.CronSchedule{Preset: preset},
3152 func(jobCtx context.Context) error {
3153 keepLast := keepLastGetter()
3154 if keepLast < 0 {
3155 log.Warn().
3156 Int("configured_keep_last", keepLast).
3157 Int("fallback_keep_last", domain.DefaultImagePruneKeepLast).
3158 Msg("invalid images.prune.keep_last; using default")
3159 keepLast = domain.DefaultImagePruneKeepLast
3160 }
3161
3162 report, err := svc.imageSvc.Prune(jobCtx, domain.ImagePruneOptions{
3163 KeepLast: keepLast,
3164 PruneDangling: true,
3165 PruneRegistry: true,
3166 })
3167 if err != nil {
3168 return err
3169 }
3170
3171 log.Info().
3172 Int("keep_last", keepLast).
3173 Int("runtime_deleted", report.Runtime.DeletedCount).
3174 Int64("runtime_reclaimed_bytes", report.Runtime.SpaceReclaimed).
3175 Int("registry_tags_removed", report.Registry.TagsRemoved).
3176 Int("registry_blobs_removed", report.Registry.BlobsRemoved).
3177 Int("registry_uploads_removed", report.Registry.UploadsRemoved).
3178 Int64("registry_upload_bytes_reclaimed", report.Registry.UploadSpaceReclaimed).
3179 Msg("scheduled image prune complete")
3180 return nil
3181 },
3182 )
3183 if err != nil {
3184 return nil, log.WrapErr(err, "failed to register image prune schedule")
3185 }
3186
3187 scheduler.Start(ctx)
3188 log.Info().
3189 Str("schedule", string(preset)).
3190 Int("keep_last", keepLastGetter()).
3191 Msg("image prune scheduler enabled")
3192
3193 return scheduler, nil
3194}
3195
3196func resolveImagePruneSchedule(raw string) (domain.BackupSchedule, error) {
3197 return resolveSchedulePreset(raw, "images.prune.schedule", domain.ScheduleDaily)
3198}
3199
3200func resolveSchedulePreset(raw, name string, defaultVal domain.BackupSchedule) (domain.BackupSchedule, error) {
3201 schedule := domain.BackupSchedule(strings.ToLower(strings.TrimSpace(raw)))
3202 if schedule == "" {
3203 schedule = defaultVal
3204 }
3205
3206 switch schedule {
3207 case domain.ScheduleHourly, domain.ScheduleDaily, domain.ScheduleWeekly, domain.ScheduleMonthly:
3208 return schedule, nil
3209 default:
3210 return "", fmt.Errorf("%s must be one of: hourly, daily, weekly, monthly", name)
3211 }
3212}
3213
3214// waitForShutdown blocks on the event loop, handling server errors and
3215// Unix signals (reload, deploy, shutdown) until the context is cancelled.
3216func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, deployChan <-chan os.Signal, reload reloadTrigger, eventBus out.EventBus, log zerowrap.Logger) {
3217 for {
3218 select {
3219 case err := <-errChan:
3220 log.Error().Err(err).Msg("server error")
3221 return
3222 case <-reloadChan:
3223 log.Info().Msg("reload signal received (SIGUSR1)")
3224 _ = reload.Trigger(ctx)
3225 case <-deployChan:
3226 log.Info().Msg("deploy signal received (SIGUSR2)")
3227 domainName, err := readDeployRequest()
3228 if err != nil {
3229 log.Error().Err(err).Msg("failed to read deploy request")
3230 continue
3231 }
3232 payload := &domain.ManualDeployPayload{Domain: domainName}
3233 if err := eventBus.Publish(domain.EventManualDeploy, payload); err != nil {
3234 log.Error().Err(err).Str("domain", domainName).Msg("failed to publish manual deploy event")
3235 }
3236 case <-ctx.Done():
3237 log.Info().Msg("shutdown signal received")
3238 return
3239 }
3240 }
3241}
3242
3243// gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
3244// the container service and cleans up runtime files.
3245func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, trafficManager *trafficadapter.Manager, log zerowrap.Logger) {
3246 log.Info().Msg("shutting down Gordon...")
3247
3248 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
3249 defer shutdownCancel()
3250
3251 // Phase 1: Stop ingress frontends (TLS, then proxy) — no new traffic accepted
3252 for _, srv := range []*http.Server{tlsSrv, proxySrv} {
3253 if srv == nil {
3254 continue
3255 }
3256 if err := srv.Shutdown(shutdownCtx); err != nil {
3257 log.Warn().Err(err).Str("addr", srv.Addr).Msg("server shutdown error")
3258 }
3259 }
3260
3261 if trafficManager != nil {
3262 if err := trafficManager.Shutdown(shutdownCtx); err != nil {
3263 log.Warn().Err(err).Msg("traffic manager shutdown error")
3264 }
3265 }
3266
3267 // Stop PKI maintenance goroutines
3268 if pkiSvc != nil {
3269 pkiSvc.Stop()
3270 }
3271
3272 // Stop public ACME TLS renewal loop
3273 if publicTLS != nil {
3274 if err := publicTLS.Stop(shutdownCtx); err != nil {
3275 log.Warn().Err(err).Msg("public TLS stop error")
3276 }
3277 }
3278
3279 // Phase 2: Drain in-flight registry push sessions before stopping the backend
3280 if proxySvc != nil {
3281 log.Info().Msg("draining in-flight registry requests...")
3282 if drained := proxySvc.DrainRegistryInFlight(25 * time.Second); !drained {
3283 log.Warn().Int64("in_flight", proxySvc.RegistryInFlight()).Msg("registry drain timed out; some in-flight pushes may be interrupted")
3284 }
3285 }
3286
3287 // Phase 3: Stop the registry backend
3288 if registrySrv != nil {
3289 if err := registrySrv.Shutdown(shutdownCtx); err != nil {
3290 log.Warn().Err(err).Str("addr", registrySrv.Addr).Msg("server shutdown error")
3291 }
3292 }
3293
3294 containerSvc.StopMonitor()
3295
3296 if err := containerSvc.Shutdown(shutdownCtx); err != nil {
3297 log.Warn().Err(err).Msg("error during container shutdown")
3298 }
3299
3300 cleanupInternalCredentials()
3301 log.Info().Msg("Gordon stopped")
3302}
3303
3304// startProxyServers sets up the HTTP proxy server and, when tls_port != 0,
3305// an HTTPS proxy server with on-demand TLS certificates from the internal CA.
3306// certificateSelector implements a multi-source TLS certificate lookup.
3307// Priority: static certs → public ACME TLS → local PKI (internal CA).
3308type certificateSelector struct {
3309 staticCerts []staticTLSCertificate
3310 publicTLS in.PublicTLSService
3311 localPKI *pkiusecase.Service
3312}
3313
3314type staticTLSCertificate struct {
3315 cert tls.Certificate
3316 leaf *x509.Certificate
3317}
3318
3319// GetCertificate selects a TLS certificate based on the ClientHello SNI.
3320//
3321// Priority:
3322// 1. Static certs — exact SNI match (leaf VerifyHostname)
3323// 2. Public ACME TLS — if the host requires ACME coverage
3324// 3. Local PKI (internal CA) — fallback for all other hosts
3325// 4. nil, nil — if no source can serve the host
3326func (s *certificateSelector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
3327 // 1. Static certs — exact match via leaf VerifyHostname.
3328 if cert := matchingPreparedStaticCert(s.staticCerts, hello.ServerName); cert != nil {
3329 return cert, nil
3330 }
3331
3332 // 2. Public ACME TLS.
3333 if s.publicTLS != nil {
3334 cert, err := s.publicTLS.GetCertificateForHost(hello.ServerName)
3335 if err == nil && cert != nil {
3336 return cert, nil
3337 }
3338 // nil, nil means this host is not an ACME-required route. Errors mean
3339 // public ACME cannot currently serve this host. In both cases, fall
3340 // through to local PKI instead of aborting the TLS handshake.
3341 }
3342
3343 // 3. Local PKI (internal CA).
3344 if s.localPKI != nil {
3345 return s.localPKI.GetCertificate(hello)
3346 }
3347
3348 return nil, nil
3349}
3350
3351func prepareStaticTLSCertificates(certs []tls.Certificate) []staticTLSCertificate {
3352 prepared := make([]staticTLSCertificate, 0, len(certs))
3353 for _, cert := range certs {
3354 if cert.Leaf == nil && len(cert.Certificate) > 0 {
3355 leaf, err := x509.ParseCertificate(cert.Certificate[0])
3356 if err == nil {
3357 cert.Leaf = leaf
3358 }
3359 }
3360 prepared = append(prepared, staticTLSCertificate{cert: cert, leaf: cert.Leaf})
3361 }
3362 return prepared
3363}
3364
3365func matchingPreparedStaticCert(certs []staticTLSCertificate, serverName string) *tls.Certificate {
3366 if serverName == "" {
3367 if len(certs) == 0 {
3368 return nil
3369 }
3370 return &certs[0].cert
3371 }
3372 for i := range certs {
3373 if certs[i].leaf == nil {
3374 continue
3375 }
3376 if err := certs[i].leaf.VerifyHostname(serverName); err == nil {
3377 return &certs[i].cert
3378 }
3379 }
3380 return nil
3381}
3382
3383// matchingStaticCert returns a pointer to the first static certificate whose
3384// leaf verifies the given serverName. Returns nil if no match is found.
3385func matchingStaticCert(certs []tls.Certificate, serverName string) *tls.Certificate {
3386 return matchingPreparedStaticCert(prepareStaticTLSCertificates(certs), serverName)
3387}
3388
3389func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, trafficManager *trafficadapter.Manager, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {
3390 var httpSrv *http.Server
3391 var httpReady <-chan struct{}
3392
3393 needsTLS := hasTLSCapableEntrypoint(cfg)
3394 if !hasSmartTCPEntrypoint(cfg) && !needsTLS {
3395 return httpSrv, httpReady, nil, nil, nil
3396 }
3397 cleanupHTTP := func(err error) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {
3398 return httpSrv, httpReady, nil, nil, err
3399 }
3400
3401 var tlsConfig *tls.Config
3402 if needsTLS {
3403 var err error
3404 tlsConfig, err = proxyTLSConfig(cfg, pkiSvc, publicTLS, log)
3405 if err != nil {
3406 return cleanupHTTP(err)
3407 }
3408 }
3409 if trafficManager == nil {
3410 return cleanupHTTP(fmt.Errorf("traffic manager is required when traffic entrypoints are enabled"))
3411 }
3412 registerTLSMuxHTTPServers(trafficManager, cfg, httpsHandler, tlsConfig, nil)
3413 registerSmartTCPHTTPServers(trafficManager, cfg, httpHandler, httpsHandler, tlsConfig, nil)
3414
3415 return httpSrv, httpReady, nil, nil, nil
3416}
3417
3418func hasSmartTCPEntrypoint(cfg Config) bool {
3419 for _, entryPoint := range cfg.EntryPoints {
3420 if entryPoint.Protocol == domain.EntryPointProtocolSmartTCP {
3421 return true
3422 }
3423 }
3424 return false
3425}
3426
3427func hasTLSCapableEntrypoint(cfg Config) bool {
3428 for _, entryPoint := range cfg.EntryPoints {
3429 switch entryPoint.Protocol {
3430 case domain.EntryPointProtocolSmartTCP, domain.EntryPointProtocolTLSMux:
3431 return true
3432 }
3433 }
3434 return false
3435}
3436
3437func effectivePublicTLSPort(cfg Config) int {
3438 return effectiveEntrypointPort(cfg, tlsCapableEntryPoint)
3439}
3440
3441func effectiveProxyTLSPort(cfg Config) int {
3442 return effectivePublicTLSPort(cfg)
3443}
3444
3445func effectiveProxyHTTPPort(cfg Config) int {
3446 return effectiveEntrypointPort(cfg, func(protocol domain.EntryPointProtocol) bool {
3447 return protocol == domain.EntryPointProtocolSmartTCP
3448 })
3449}
3450
3451func effectiveEntrypointPort(cfg Config, match func(domain.EntryPointProtocol) bool) int {
3452 if entryPoint, ok := cfg.EntryPoints[traffic.DefaultEdgeEntryPointName]; ok && match(entryPoint.Protocol) {
3453 if port := portFromAddress(entryPoint.Address); port > 0 {
3454 return port
3455 }
3456 }
3457
3458 var candidatePort int
3459 candidates := 0
3460 for name, entryPoint := range cfg.EntryPoints {
3461 if name == traffic.DefaultEdgeEntryPointName || !match(entryPoint.Protocol) {
3462 continue
3463 }
3464 port := portFromAddress(entryPoint.Address)
3465 if port == 0 {
3466 continue
3467 }
3468 candidatePort = port
3469 candidates++
3470 }
3471 if candidates == 1 {
3472 return candidatePort
3473 }
3474 return 0
3475}
3476
3477func tlsCapableEntryPoint(protocol domain.EntryPointProtocol) bool {
3478 switch protocol {
3479 case domain.EntryPointProtocolSmartTCP, domain.EntryPointProtocolTLSMux:
3480 return true
3481 default:
3482 return false
3483 }
3484}
3485
3486func effectiveHTTP01Port(cfg Config) int {
3487 if hasSmartTCPHTTP01Entrypoint(cfg) {
3488 return 80
3489 }
3490 return 0
3491}
3492
3493func validatePublicTLSReadiness(cfg Config) error {
3494 mode, err := domain.ParseACMEChallengeMode(cfg.TLS.ACME.Challenge)
3495 if err != nil {
3496 return err
3497 }
3498 switch mode {
3499 case domain.ACMEChallengeCloudflareDNS01, domain.ACMEChallengeAuto:
3500 return nil
3501 case domain.ACMEChallengeHTTP01:
3502 return validateHTTP01ChallengeReadiness(cfg)
3503 default:
3504 return fmt.Errorf("%w: %q", domain.ErrACMEChallengeInvalid, mode)
3505 }
3506}
3507
3508func validateEffectivePublicTLSReadiness(cfg Config, effective publictls.EffectiveChallenge) error {
3509 if effective.Mode != domain.ACMEChallengeHTTP01 {
3510 return nil
3511 }
3512 return validateHTTP01ChallengeReadiness(cfg)
3513}
3514
3515func validateHTTP01ChallengeReadiness(cfg Config) error {
3516 if hasBoundHTTP01ChallengeListener(cfg) {
3517 return nil
3518 }
3519 return fmt.Errorf("%w: http-01 requires an actually bound HTTP-01 challenge listener on external :80", domain.ErrACMEChallengeInvalid)
3520}
3521
3522func hasBoundHTTP01ChallengeListener(cfg Config) bool {
3523 return hasSmartTCPHTTP01Entrypoint(cfg)
3524}
3525
3526func hasSmartTCPHTTP01Entrypoint(cfg Config) bool {
3527 for _, entryPoint := range cfg.EntryPoints {
3528 if entryPoint.Protocol == domain.EntryPointProtocolSmartTCP && portFromAddress(entryPoint.Address) == 80 {
3529 return true
3530 }
3531 }
3532 return false
3533}
3534
3535func portFromAddress(address string) int {
3536 _, portText, err := net.SplitHostPort(address)
3537 if err != nil {
3538 return 0
3539 }
3540 port, err := strconv.Atoi(portText)
3541 if err != nil {
3542 return 0
3543 }
3544 return port
3545}
3546
3547func proxyTLSConfig(cfg Config, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, log zerowrap.Logger) (*tls.Config, error) {
3548 var staticCerts []tls.Certificate
3549 if cfg.Server.TLSCertFile != "" {
3550 staticCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile)
3551 if err != nil {
3552 return nil, fmt.Errorf("load TLS keypair: %w", err)
3553 }
3554 staticCerts = []tls.Certificate{staticCert}
3555 log.Info().
3556 Str("cert", cfg.Server.TLSCertFile).
3557 Str("key", cfg.Server.TLSKeyFile).
3558 Msg("loaded static TLS certificate (public ACME and internal CA handle remaining domains)")
3559 }
3560 selector := &certificateSelector{
3561 staticCerts: prepareStaticTLSCertificates(staticCerts),
3562 publicTLS: publicTLS,
3563 localPKI: pkiSvc,
3564 }
3565 return &tls.Config{
3566 MinVersion: tls.VersionTLS12,
3567 GetCertificate: selector.GetCertificate,
3568 NextProtos: []string{"h2", "http/1.1"},
3569 }, nil
3570}
3571
3572func registerSmartTCPHTTPServers(manager *trafficadapter.Manager, cfg Config, httpHandler, httpsHandler http.Handler, tlsConfig *tls.Config, previous map[string]struct{}) map[string]struct{} {
3573 if manager == nil {
3574 return previous
3575 }
3576 next := smartTCPHTTPServerNames(cfg)
3577 for name := range previous {
3578 if _, ok := next[name]; !ok {
3579 manager.SetSmartTCPHTTPServer(name, nil, nil)
3580 manager.SetSmartTCPTLSServer(name, nil, nil)
3581 }
3582 }
3583 var httpProtos http.Protocols
3584 httpProtos.SetHTTP1(true)
3585 httpProtos.SetUnencryptedHTTP2(true)
3586 for name := range next {
3587 if httpHandler != nil {
3588 manager.SetSmartTCPHTTPServer(name, httpHandler, &httpProtos)
3589 }
3590 if httpsHandler != nil && tlsConfig != nil {
3591 manager.SetSmartTCPTLSServer(name, httpsHandler, tlsConfig)
3592 }
3593 }
3594 return next
3595}
3596
3597func smartTCPHTTPServerNames(cfg Config) map[string]struct{} {
3598 names := map[string]struct{}{}
3599 for name, entryPoint := range cfg.EntryPoints {
3600 if entryPoint.Protocol == domain.EntryPointProtocolSmartTCP {
3601 names[name] = struct{}{}
3602 }
3603 }
3604 return names
3605}
3606
3607func registerTLSMuxHTTPServers(manager *trafficadapter.Manager, cfg Config, httpsHandler http.Handler, tlsConfig *tls.Config, previous map[string]struct{}) map[string]struct{} {
3608 if manager == nil {
3609 return previous
3610 }
3611 next := tlsMuxHTTPServerNames(cfg)
3612 for name := range previous {
3613 if _, ok := next[name]; !ok {
3614 manager.SetTLSHTTPServer(name, nil, nil)
3615 }
3616 }
3617 if httpsHandler == nil || tlsConfig == nil {
3618 return next
3619 }
3620 for name := range next {
3621 manager.SetTLSHTTPServer(name, httpsHandler, tlsConfig)
3622 }
3623 return next
3624}
3625
3626func tlsMuxHTTPServerNames(cfg Config) map[string]struct{} {
3627 names := map[string]struct{}{}
3628 for name, entryPoint := range cfg.EntryPoints {
3629 domainEntryPoint := domain.EntryPoint{Name: name, Protocol: entryPoint.Protocol}
3630 if trafficManagerOwnsEntryPoint(domainEntryPoint) && domainEntryPoint.Protocol == domain.EntryPointProtocolTLSMux {
3631 names[name] = struct{}{}
3632 }
3633 }
3634 return names
3635}
3636
3637// startServer starts an HTTP server, returning the server instance and a channel
3638// that closes once the listening socket is bound. This lets callers wait for the
3639// port to be ready before taking actions that depend on it (e.g. auto-start
3640// pulling from the local registry). The returned *http.Server can be used for
3641// graceful shutdown.
3642func startServer(addr string, handler http.Handler, name string, protocols *http.Protocols, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {
3643 ready := make(chan struct{})
3644
3645 server := &http.Server{
3646 Addr: addr,
3647 Handler: handler,
3648 Protocols: protocols,
3649 ReadHeaderTimeout: 10 * time.Second,
3650 ReadTimeout: 5 * time.Minute,
3651 WriteTimeout: 5 * time.Minute,
3652 IdleTimeout: 120 * time.Second,
3653 MaxHeaderBytes: 1 << 20,
3654 }
3655
3656 go func() {
3657 log.Info().Str("address", addr).Msgf("%s server starting", name)
3658
3659 ln, err := net.Listen("tcp", addr)
3660 if err != nil {
3661 errChan <- fmt.Errorf("%s server error: %w", name, err)
3662 return
3663 }
3664 close(ready) // signal: port is bound and accepting connections
3665
3666 if err := server.Serve(ln); err != nil && err != http.ErrServerClosed {
3667 errChan <- fmt.Errorf("%s server error: %w", name, err)
3668 }
3669 }()
3670
3671 return server, ready
3672}
3673
3674// SendReloadSignal sends SIGUSR1 to the running Gordon process.
3675func SendReloadSignal() error {
3676 process, _, err := findRunningProcess()
3677 if err != nil {
3678 return err
3679 }
3680
3681 if err := process.Signal(syscall.SIGUSR1); err != nil {
3682 return fmt.Errorf("failed to send reload signal: %w", err)
3683 }
3684
3685 return nil
3686}
3687
3688// getDeployRequestFile returns the path to the deploy request file.
3689func getDeployRequestFile() string {
3690 return filepath.Join(os.TempDir(), "gordon-deploy-request")
3691}
3692
3693// writeDeployRequestFile creates the deploy request file exclusively with retry.
3694// This prevents race conditions when multiple deploy commands run simultaneously.
3695func writeDeployRequestFile(path string, data []byte, timeout time.Duration) error {
3696 deadline := time.Now().Add(timeout)
3697
3698 for {
3699 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
3700 if err == nil {
3701 _, writeErr := f.Write(data)
3702 f.Close()
3703 if writeErr != nil {
3704 _ = os.Remove(path)
3705 return writeErr
3706 }
3707 return nil
3708 }
3709
3710 if os.IsExist(err) {
3711 if time.Now().After(deadline) {
3712 return fmt.Errorf("deploy request file still present after timeout; another deploy may be in progress")
3713 }
3714 time.Sleep(100 * time.Millisecond)
3715 continue
3716 }
3717
3718 return err
3719 }
3720}
3721
3722// SendDeploySignal triggers a manual deploy for a specific route via SIGUSR2.
3723// Returns the domain name on success for the caller to display.
3724func SendDeploySignal(domain string) (string, error) {
3725 deployFile := getDeployRequestFile()
3726 if err := writeDeployRequestFile(deployFile, []byte(domain), 5*time.Second); err != nil {
3727 return "", fmt.Errorf("failed to write deploy request: %w", err)
3728 }
3729
3730 // Find PID and send SIGUSR2
3731 process, _, err := findRunningProcess()
3732 if err != nil {
3733 _ = os.Remove(deployFile)
3734 return "", err
3735 }
3736
3737 if err := process.Signal(syscall.SIGUSR2); err != nil {
3738 _ = os.Remove(deployFile)
3739 return "", fmt.Errorf("failed to send deploy signal: %w", err)
3740 }
3741
3742 return domain, nil
3743}
3744
3745// readDeployRequest reads and removes the deploy request file atomically.
3746// Returns empty string if file doesn't exist (may have been consumed by another handler).
3747func readDeployRequest() (string, error) {
3748 deployFile := getDeployRequestFile()
3749
3750 // Rename to a temp file first to make the read-and-delete atomic
3751 tmpFile := deployFile + ".processing"
3752 if err := os.Rename(deployFile, tmpFile); err != nil {
3753 if os.IsNotExist(err) {
3754 return "", fmt.Errorf("deploy request file not found (may have been processed already)")
3755 }
3756 return "", fmt.Errorf("failed to acquire deploy request: %w", err)
3757 }
3758
3759 data, err := os.ReadFile(tmpFile)
3760 _ = os.Remove(tmpFile) // Always clean up
3761 if err != nil {
3762 return "", fmt.Errorf("failed to read deploy request: %w", err)
3763 }
3764
3765 return string(data), nil
3766}
3767
3768// createPidFile creates a PID file for the Gordon process.
3769// SECURITY: Prefers secure locations (XDG_RUNTIME_DIR, ~/.gordon/run) over /tmp
3770// to prevent symlink attacks and unauthorized access.
3771func createPidFile(log zerowrap.Logger) string {
3772 pid := os.Getpid()
3773
3774 // SECURITY: Prioritize secure locations over /tmp
3775 var locations []string
3776
3777 // Try secure runtime directory first
3778 if runtimeDir, err := getSecureRuntimeDir(); err == nil {
3779 locations = append(locations, filepath.Join(runtimeDir, "gordon.pid"))
3780 }
3781
3782 // Fall back to home directory
3783 if homeDir, err := os.UserHomeDir(); err == nil {
3784 locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid"))
3785 }
3786
3787 // Last resort: /tmp (least secure due to world-writable)
3788 locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid"))
3789
3790 for _, location := range locations {
3791 // Ensure parent directory exists with secure permissions
3792 if err := os.MkdirAll(filepath.Dir(location), 0700); err != nil {
3793 continue
3794 }
3795 if err := os.WriteFile(location, fmt.Appendf(nil, "%d", pid), 0600); err == nil {
3796 log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file")
3797 return location
3798 }
3799 }
3800
3801 log.Warn().Int("pid", pid).Msg("failed to create PID file in any location")
3802 return ""
3803}
3804
3805// removePidFile removes the PID file.
3806func removePidFile(pidFile string, log zerowrap.Logger) {
3807 if err := os.Remove(pidFile); err != nil {
3808 log.Warn().Err(err).Str("pid_file", pidFile).Msg("failed to remove PID file")
3809 } else {
3810 log.Debug().Str("pid_file", pidFile).Msg("removed PID file")
3811 }
3812}
3813
3814func pidFileLocations() []string {
3815 var locations []string
3816 seen := make(map[string]struct{})
3817 add := func(path string) {
3818 if path == "" {
3819 return
3820 }
3821 if _, ok := seen[path]; ok {
3822 return
3823 }
3824 seen[path] = struct{}{}
3825 locations = append(locations, path)
3826 }
3827
3828 // Check secure runtime directory first
3829 if runtimeDir, err := getSecureRuntimeDir(); err == nil {
3830 add(filepath.Join(runtimeDir, "gordon.pid"))
3831 }
3832
3833 // Also check canonical /run/user/<uid> runtime path. This handles cases where
3834 // Gordon started under systemd user services with runtime dir available, but
3835 // CLI invocations (e.g. non-interactive SSH) don't have XDG_RUNTIME_DIR set.
3836 runtimeByUID := filepath.Join("/run/user", strconv.Itoa(os.Getuid()), "gordon", "gordon.pid")
3837 add(runtimeByUID)
3838
3839 // Check explicit XDG_RUNTIME_DIR if present in this process env.
3840 if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
3841 add(filepath.Join(runtimeDir, "gordon.pid"))
3842 }
3843
3844 // Check home directory
3845 if homeDir, err := os.UserHomeDir(); err == nil {
3846 add(filepath.Join(homeDir, ".gordon", "gordon.pid"))
3847 // Legacy location for backward compatibility
3848 add(filepath.Join(homeDir, ".gordon.pid"))
3849 }
3850
3851 // Legacy /tmp locations for backward compatibility
3852 add(filepath.Join(os.TempDir(), "gordon.pid"))
3853 add("/tmp/gordon.pid")
3854
3855 return locations
3856}
3857
3858// findRunningPidFile returns the first PID file whose PID belongs to a live process.
3859// Stale/invalid PID files are ignored and removed when possible.
3860func findRunningPidFile() (string, int, error) {
3861 return findRunningPidFileInLocations(pidFileLocations())
3862}
3863
3864func findRunningPidFileInLocations(locations []string) (string, int, error) {
3865 foundAny := false
3866
3867 for _, location := range locations {
3868 pidBytes, err := os.ReadFile(location)
3869 if err != nil {
3870 continue
3871 }
3872
3873 foundAny = true
3874
3875 var pid int
3876 if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil || pid <= 0 {
3877 _ = os.Remove(location)
3878 continue
3879 }
3880
3881 if isProcessAlive(pid) {
3882 return location, pid, nil
3883 }
3884
3885 _ = os.Remove(location)
3886 }
3887
3888 if foundAny {
3889 return "", 0, fmt.Errorf("found stale gordon PID file(s), is Gordon running?")
3890 }
3891
3892 return "", 0, fmt.Errorf("gordon PID file not found, is Gordon running?")
3893}
3894
3895func findRunningProcess() (*os.Process, int, error) {
3896 _, pid, err := findRunningPidFile()
3897 if err != nil {
3898 return nil, 0, err
3899 }
3900
3901 process, err := os.FindProcess(pid)
3902 if err != nil {
3903 return nil, 0, fmt.Errorf("failed to find process: %w", err)
3904 }
3905
3906 return process, pid, nil
3907}
3908
3909func isProcessAlive(pid int) bool {
3910 process, err := os.FindProcess(pid)
3911 if err != nil {
3912 return false
3913 }
3914
3915 err = process.Signal(syscall.Signal(0))
3916 if err == nil {
3917 return true
3918 }
3919
3920 return errors.Is(err, syscall.EPERM)
3921}
3922
3923// loadConfig loads configuration from file and sets defaults.
3924func loadConfig(v *viper.Viper, configPath string) error {
3925 v.SetDefault("server.registry_port", 5000)
3926 v.SetDefault("server.legacy_registry_domains", []string{})
3927 v.SetDefault("server.tls_cert_file", "")
3928 v.SetDefault("server.tls_key_file", "")
3929 v.SetDefault("tls.acme.enabled", false)
3930 v.SetDefault("tls.acme.email", "")
3931 v.SetDefault("tls.acme.challenge", "auto")
3932 v.SetDefault("tls.acme.obtain_batch_size", 1)
3933 v.SetDefault("dns.resolvers", publictls.DefaultDNSResolvers)
3934 v.SetDefault("dns.propagation_timeout", "5m")
3935 v.SetDefault("dns.polling_interval", "5s")
3936 v.SetDefault("server.force_https_redirect", false)
3937 v.SetDefault("server.data_dir", DefaultDataDir())
3938 v.SetDefault("server.runtime", "auto")
3939 v.SetDefault("logging.level", "info")
3940 v.SetDefault("logging.format", "console")
3941 v.SetDefault("logging.file.enabled", false)
3942 v.SetDefault("logging.file.max_size", 100)
3943 v.SetDefault("logging.file.max_backups", 3)
3944 v.SetDefault("logging.file.max_age", 28)
3945 v.SetDefault("logging.container_logs.enabled", true)
3946 v.SetDefault("logging.container_logs.dir", "")
3947 v.SetDefault("logging.container_logs.max_size", 100)
3948 v.SetDefault("logging.container_logs.max_backups", 3)
3949 v.SetDefault("logging.container_logs.max_age", 28)
3950 v.SetDefault("logging.access_log.enabled", false)
3951 v.SetDefault("logging.access_log.format", "json")
3952 v.SetDefault("logging.access_log.output", "stdout")
3953 v.SetDefault("logging.access_log.file_path", "")
3954 v.SetDefault("logging.access_log.max_size", 100)
3955 v.SetDefault("logging.access_log.max_backups", 3)
3956 v.SetDefault("logging.access_log.max_age", 28)
3957 v.SetDefault("logging.access_log.exclude_health_checks", true)
3958 v.SetDefault("logging.access_log.syslog_identifier", "gordon-access")
3959 v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
3960 v.SetDefault("auth.enabled", true)
3961 // Note: auth.type defaults to "token" (the only supported mode)
3962 v.SetDefault("auth.secrets_backend", "")
3963 v.SetDefault("auth.token_expiry", "720h")
3964 v.SetDefault("api.rate_limit.enabled", true)
3965 v.SetDefault("api.rate_limit.global_rps", 500)
3966 v.SetDefault("api.rate_limit.per_ip_rps", 50)
3967 v.SetDefault("api.rate_limit.burst", 100)
3968 v.SetDefault("auto_route.enabled", false)
3969 v.SetDefault("network_isolation.enabled", true)
3970 v.SetDefault("network_isolation.network_prefix", "gordon")
3971 v.SetDefault("network_isolation.internal", false)
3972 v.SetDefault("volumes.auto_create", true)
3973 v.SetDefault("volumes.prefix", "gordon")
3974 v.SetDefault("volumes.preserve", true)
3975 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
3976 v.SetDefault("backups.databases.enabled", false)
3977 v.SetDefault("backups.databases.schedule", string(domain.ScheduleDaily))
3978 v.SetDefault("backups.databases.storage_dir", "")
3979 v.SetDefault("backups.databases.retention.hourly", 0)
3980 v.SetDefault("backups.databases.retention.daily", 0)
3981 v.SetDefault("backups.databases.retention.weekly", 0)
3982 v.SetDefault("backups.databases.retention.monthly", 0)
3983 v.SetDefault("backups.volumes.enabled", false)
3984 v.SetDefault("backups.volumes.interval", "24h")
3985 v.SetDefault("backups.volumes.compression", string(domain.VolumeBackupCompressionGzip))
3986 v.SetDefault("backups.volumes.timeout", "2h")
3987 v.SetDefault("backups.volumes.max_concurrency", 2)
3988 v.SetDefault("backups.volumes.helper_image", "alpine:3.20")
3989 v.SetDefault("backups.volumes.s3.bucket", "")
3990 v.SetDefault("backups.volumes.s3.region", "")
3991 v.SetDefault("backups.volumes.s3.prefix", "")
3992 v.SetDefault("backups.volumes.s3.endpoint", "")
3993 v.SetDefault("backups.volumes.s3.path_style", false)
3994 v.SetDefault("backups.volumes.s3.sse_algorithm", "")
3995 v.SetDefault("backups.volumes.s3.sse_kms_key_id", "")
3996 v.SetDefault("backups.volumes.retention.keep", 14)
3997 v.SetDefault("images.allowed_registries", []string{})
3998 v.SetDefault("images.require_digest", false)
3999 v.SetDefault("images.prune.enabled", false)
4000 v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))
4001 v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast)
4002 v.SetDefault("containers.security_profile", "compat")
4003 v.SetDefault("telemetry.enabled", false)
4004 v.SetDefault("telemetry.endpoint", "")
4005 v.SetDefault("telemetry.auth_token", "")
4006 v.SetDefault("telemetry.traces", true)
4007 v.SetDefault("telemetry.metrics", true)
4008 v.SetDefault("telemetry.logs", true)
4009 v.SetDefault("telemetry.trace_sample_rate", 1.0)
4010
4011 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
4012 v.SetDefault("server.registry_allowed_ips", []string{})
4013 v.SetDefault("server.proxy_allowed_ips", []string{})
4014 v.SetDefault("server.registry_listen_address", "")
4015 v.SetDefault("deploy.readiness_delay", "5s")
4016 v.SetDefault("deploy.readiness_mode", "auto")
4017 v.SetDefault("deploy.health_timeout", "90s")
4018 v.SetDefault("deploy.stabilization_delay", "2s")
4019 v.SetDefault("deploy.tcp_probe_timeout", "30s")
4020 v.SetDefault("deploy.http_probe_timeout", "60s")
4021 v.SetDefault("deploy.attachment_readiness_timeout", "30s")
4022 v.SetDefault("deploy.drain_mode", "auto")
4023 v.SetDefault("deploy.drain_timeout", "30s")
4024
4025 ConfigureViper(v, configPath)
4026
4027 if err := v.ReadInConfig(); err != nil {
4028 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
4029 return fmt.Errorf("failed to read config file: %w", err)
4030 }
4031 }
4032
4033 v.SetEnvPrefix("GORDON")
4034 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
4035 v.AutomaticEnv()
4036
4037 return nil
4038}