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