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