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