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