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