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