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