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