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 "github.com/fsnotify/fsnotify"
29 "github.com/spf13/viper"
30
31 // Adapters - Output
32 "github.com/bnema/gordon/internal/adapters/out/docker"
33 "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
34 "github.com/bnema/gordon/internal/adapters/out/envloader"
35 "github.com/bnema/gordon/internal/adapters/out/eventbus"
36 "github.com/bnema/gordon/internal/adapters/out/filesystem"
37 "github.com/bnema/gordon/internal/adapters/out/httpprober"
38 "github.com/bnema/gordon/internal/adapters/out/logwriter"
39 "github.com/bnema/gordon/internal/adapters/out/ratelimit"
40 "github.com/bnema/gordon/internal/adapters/out/secrets"
41 "github.com/bnema/gordon/internal/adapters/out/tokenstore"
42
43 // Adapters - Input
44 "github.com/bnema/gordon/internal/adapters/in/http/admin"
45 authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth"
46 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
47 "github.com/bnema/gordon/internal/adapters/in/http/registry"
48
49 // Boundaries
50 "github.com/bnema/gordon/internal/boundaries/out"
51
52 // Domain
53 "github.com/bnema/gordon/internal/domain"
54
55 // Use cases
56 "github.com/bnema/gordon/internal/usecase/auth"
57 "github.com/bnema/gordon/internal/usecase/backup"
58 "github.com/bnema/gordon/internal/usecase/config"
59 "github.com/bnema/gordon/internal/usecase/container"
60 "github.com/bnema/gordon/internal/usecase/health"
61 "github.com/bnema/gordon/internal/usecase/logs"
62 "github.com/bnema/gordon/internal/usecase/proxy"
63 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
64 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
65
66 // Pkg
67 "github.com/bnema/gordon/pkg/bytesize"
68 "github.com/bnema/gordon/pkg/duration"
69)
70
71// Config holds the application configuration.
72type Config struct {
73 Server struct {
74 Port int `mapstructure:"port"`
75 RegistryPort int `mapstructure:"registry_port"`
76 GordonDomain string `mapstructure:"gordon_domain"`
77 RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
78 TLSEnabled bool `mapstructure:"tls_enabled"`
79 TLSPort int `mapstructure:"tls_port"`
80 TLSCertFile string `mapstructure:"tls_cert_file"`
81 TLSKeyFile string `mapstructure:"tls_key_file"`
82 DataDir string `mapstructure:"data_dir"`
83 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
84 MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
85 MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
86 MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`
87 RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`
88 } `mapstructure:"server"`
89
90 Logging struct {
91 Level string `mapstructure:"level"`
92 Format string `mapstructure:"format"`
93 File struct {
94 Enabled bool `mapstructure:"enabled"`
95 Path string `mapstructure:"path"`
96 MaxSize int `mapstructure:"max_size"`
97 MaxBackups int `mapstructure:"max_backups"`
98 MaxAge int `mapstructure:"max_age"`
99 } `mapstructure:"file"`
100 ContainerLogs struct {
101 Enabled bool `mapstructure:"enabled"`
102 Dir string `mapstructure:"dir"`
103 MaxSize int `mapstructure:"max_size"`
104 MaxBackups int `mapstructure:"max_backups"`
105 MaxAge int `mapstructure:"max_age"`
106 } `mapstructure:"container_logs"`
107 } `mapstructure:"logging"`
108
109 Env struct {
110 Dir string `mapstructure:"dir"`
111 } `mapstructure:"env"`
112
113 Auth struct {
114 Enabled bool `mapstructure:"enabled"`
115 Type string `mapstructure:"type"` // "password" or "token"
116 SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe"
117 Username string `mapstructure:"username"`
118 Password string `mapstructure:"password"` // deprecated: use password_hash
119 PasswordHash string `mapstructure:"password_hash"` // path in secrets backend
120 TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
121 TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
122 } `mapstructure:"auth"`
123
124 API struct {
125 RateLimit struct {
126 Enabled bool `mapstructure:"enabled"`
127 GlobalRPS float64 `mapstructure:"global_rps"`
128 PerIPRPS float64 `mapstructure:"per_ip_rps"`
129 Burst int `mapstructure:"burst"`
130 TrustedProxies []string `mapstructure:"trusted_proxies"`
131 } `mapstructure:"rate_limit"`
132 } `mapstructure:"api"`
133
134 Backups struct {
135 Enabled bool `mapstructure:"enabled"`
136 StorageDir string `mapstructure:"storage_dir"`
137 Retention struct {
138 Hourly int `mapstructure:"hourly"`
139 Daily int `mapstructure:"daily"`
140 Weekly int `mapstructure:"weekly"`
141 Monthly int `mapstructure:"monthly"`
142 } `mapstructure:"retention"`
143 } `mapstructure:"backups"`
144}
145
146// services holds all the services used by the application.
147type services struct {
148 runtime *docker.Runtime
149 eventBus *eventbus.InMemory
150 blobStorage *filesystem.BlobStorage
151 manifestStorage *filesystem.ManifestStorage
152 backupStorage *filesystem.BackupStorage
153 envLoader out.EnvLoader
154 logWriter *logwriter.LogWriter
155 tokenStore out.TokenStore
156 configSvc *config.Service
157 containerSvc *container.Service
158 backupSvc *backup.Service
159 registrySvc *registrySvc.Service
160 proxySvc *proxy.Service
161 authSvc *auth.Service
162 authHandler *authhandler.Handler
163 adminHandler *admin.Handler
164 internalRegUser string
165 internalRegPass string
166 envDir string
167 maxBlobChunkSize int64
168}
169
170// Run initializes and starts the Gordon application.
171func Run(ctx context.Context, configPath string) error {
172 // Load configuration
173 v, cfg, err := initConfig(configPath)
174 if err != nil {
175 return err
176 }
177
178 // Initialize logger
179 log, cleanup, err := initLogger(cfg)
180 if err != nil {
181 return err
182 }
183 if cleanup != nil {
184 defer cleanup()
185 }
186
187 ctx = zerowrap.WithCtx(ctx, log)
188 log.Info().Msg("Gordon starting")
189
190 if err := ensureTLSConfig(&cfg, log); err != nil {
191 return err
192 }
193
194 // Create PID file
195 pidFile := createPidFile(log)
196 if pidFile != "" {
197 defer removePidFile(pidFile, log)
198 }
199
200 // Create all services
201 svc, err := createServices(ctx, v, cfg, log)
202 if err != nil {
203 return err
204 }
205
206 // Register event handlers
207 if err := registerEventHandlers(ctx, svc, cfg); err != nil {
208 return err
209 }
210
211 // Set up config hot reload
212 setupConfigHotReload(ctx, v, svc, log)
213
214 // Start event bus
215 if err := svc.eventBus.Start(); err != nil {
216 return log.WrapErr(err, "failed to start event bus")
217 }
218 defer svc.eventBus.Stop()
219
220 // Create HTTP handlers
221 registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)
222
223 // Start servers, wait for listeners to bind, then sync/auto-start containers.
224 return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, log)
225}
226
227func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error {
228 if !cfg.Server.TLSEnabled {
229 return nil
230 }
231
232 if cfg.Server.TLSCertFile != "" && cfg.Server.TLSKeyFile != "" {
233 return nil
234 }
235
236 dataDir := resolveDataDir(cfg.Server.DataDir)
237 tlsDir := filepath.Join(dataDir, "tls")
238 certPath := filepath.Join(tlsDir, "cert.pem")
239 keyPath := filepath.Join(tlsDir, "key.pem")
240
241 domainName := cfg.Server.GordonDomain
242 if domainName == "" {
243 domainName = cfg.Server.RegistryDomain
244 }
245 if domainName == "" {
246 domainName = "localhost"
247 }
248
249 if err := generateSelfSignedTLSCert(certPath, keyPath, domainName); err != nil {
250 return fmt.Errorf("failed to generate self-signed TLS certificate: %w", err)
251 }
252
253 cfg.Server.TLSCertFile = certPath
254 cfg.Server.TLSKeyFile = keyPath
255
256 log.Warn().
257 Str("cert_file", certPath).
258 Str("key_file", keyPath).
259 Str("domain", domainName).
260 Msg("server.tls_enabled=true with empty tls_cert_file/tls_key_file; using auto-generated self-signed certificate")
261
262 return nil
263}
264
265func generateSelfSignedTLSCert(certPath, keyPath, domainName string) error {
266 // Reuse existing pair if already generated.
267 if _, err := os.Stat(certPath); err == nil {
268 if _, keyErr := os.Stat(keyPath); keyErr == nil {
269 return nil
270 }
271 }
272
273 if err := os.MkdirAll(filepath.Dir(certPath), 0700); err != nil {
274 return err
275 }
276 if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil {
277 return err
278 }
279
280 priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
281 if err != nil {
282 return err
283 }
284
285 now := time.Now()
286 serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
287 serialNumber, err := rand.Int(rand.Reader, serialLimit)
288 if err != nil {
289 return err
290 }
291
292 template := x509.Certificate{
293 SerialNumber: serialNumber,
294 Subject: pkix.Name{
295 CommonName: domainName,
296 Organization: []string{"Gordon Auto-Generated TLS"},
297 },
298 NotBefore: now.Add(-5 * time.Minute),
299 NotAfter: now.AddDate(1, 0, 0),
300 KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
301 ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
302 BasicConstraintsValid: true,
303 }
304
305 if ip := net.ParseIP(domainName); ip != nil {
306 template.IPAddresses = []net.IP{ip}
307 } else {
308 template.DNSNames = []string{domainName}
309 }
310
311 derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
312 if err != nil {
313 return err
314 }
315
316 certOut, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
317 if err != nil {
318 return err
319 }
320 defer certOut.Close()
321
322 if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
323 return err
324 }
325
326 keyBytes, err := x509.MarshalECPrivateKey(priv)
327 if err != nil {
328 return err
329 }
330
331 keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
332 if err != nil {
333 return err
334 }
335 defer keyOut.Close()
336
337 if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}); err != nil {
338 return err
339 }
340
341 return nil
342}
343
344// initConfig loads configuration from file.
345func initConfig(configPath string) (*viper.Viper, Config, error) {
346 v := viper.New()
347 if err := loadConfig(v, configPath); err != nil {
348 return nil, Config{}, fmt.Errorf("failed to load config: %w", err)
349 }
350
351 var cfg Config
352 if err := v.Unmarshal(&cfg); err != nil {
353 return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
354 }
355
356 // Normalize domain config: prefer gordon_domain over registry_domain
357 if cfg.Server.GordonDomain != "" {
358 cfg.Server.RegistryDomain = cfg.Server.GordonDomain
359 }
360
361 return v, cfg, nil
362}
363
364// initLogger initializes the zerowrap logger.
365func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
366 logConfig := zerowrap.Config{
367 Level: cfg.Logging.Level,
368 Format: cfg.Logging.Format,
369 }
370
371 // Always enable file logging so admin API can read process logs
372 // (Config flag is respected for backward-compatibility)
373 logPath := cfg.Logging.File.Path
374 if logPath == "" {
375 dataDir := cfg.Server.DataDir
376 if dataDir == "" {
377 dataDir = DefaultDataDir()
378 }
379 logPath = filepath.Join(dataDir, "logs", "gordon.log")
380 }
381
382 log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{
383 Enabled: cfg.Logging.File.Enabled,
384 Path: logPath,
385 MaxSize: cfg.Logging.File.MaxSize,
386 MaxBackups: cfg.Logging.File.MaxBackups,
387 MaxAge: cfg.Logging.File.MaxAge,
388 Compress: true,
389 })
390 if err != nil {
391 return zerowrap.Default(), nil, fmt.Errorf("failed to create logger with file: %w", err)
392 }
393 return log, cleanup, nil
394}
395
396// createServices creates all the application services.
397func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (*services, error) {
398 svc := &services{}
399 var err error
400
401 // Create output adapters
402 if svc.runtime, svc.eventBus, err = createOutputAdapters(ctx, log); err != nil {
403 return nil, err
404 }
405
406 // Create storage
407 if svc.blobStorage, svc.manifestStorage, err = createStorage(cfg, log); err != nil {
408 return nil, err
409 }
410
411 // Create log writer
412 if svc.logWriter, err = createLogWriter(cfg, log); err != nil {
413 return nil, err
414 }
415
416 // Create auth service (if enabled)
417 if svc.tokenStore, svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil {
418 return nil, err
419 }
420
421 if err := setupInternalRegistryAuth(cfg, svc, log); err != nil {
422 return nil, err
423 }
424
425 // Create use case services
426 svc.configSvc = config.NewService(v, svc.eventBus)
427 if err := svc.configSvc.Load(ctx); err != nil {
428 return nil, log.WrapErr(err, "failed to load configuration")
429 }
430
431 var backend domain.SecretsBackend
432 var passStore *domainsecrets.PassStore
433 var domainSecretStore out.DomainSecretStore
434
435 svc.envDir, backend, passStore, domainSecretStore, err = createDomainSecretStore(cfg, log)
436 if err != nil {
437 return nil, err
438 }
439
440 if svc.envLoader, err = createEnvLoader(backend, svc.envDir, passStore, log); err != nil {
441 return nil, err
442 }
443
444 secretSvc := secretsSvc.NewService(domainSecretStore, log)
445
446 if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil {
447 return nil, err
448 }
449
450 if svc.backupStorage, svc.backupSvc, err = createBackupService(cfg, svc, log); err != nil {
451 return nil, err
452 }
453
454 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
455
456 proxyCfg, err := buildProxyConfig(cfg, log)
457 if err != nil {
458 return nil, err
459 }
460 svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
461 svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxyCfg.proxyConfig)
462
463 // Wire synchronous proxy cache invalidation for zero-downtime deployments.
464 // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget().
465 svc.containerSvc.SetProxyCacheInvalidator(svc.proxySvc)
466 svc.containerSvc.SetProxyDrainWaiter(svc.proxySvc)
467
468 // Create token handler for registry token endpoint
469 if svc.authSvc != nil {
470 internalAuth := authhandler.InternalAuth{
471 Username: svc.internalRegUser,
472 Password: svc.internalRegPass,
473 }
474 svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log)
475 }
476
477 // Create health service for route health checking
478 prober := httpprober.New()
479 healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)
480
481 // Create log service for accessing logs via admin API
482 logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log)
483
484 // Create admin handler for admin API
485 svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc)
486
487 return svc, nil
488}
489
490func setupInternalRegistryAuth(cfg Config, svc *services, log zerowrap.Logger) error {
491 if !cfg.Auth.Enabled {
492 return nil
493 }
494
495 var err error
496 svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
497 if err != nil {
498 return log.WrapErr(err, "failed to generate internal registry credentials")
499 }
500
501 // Persist credentials to file for CLI access (gordon auth internal)
502 if err := persistInternalCredentials(svc.internalRegUser, svc.internalRegPass); err != nil {
503 log.Warn().Err(err).Msg("failed to persist internal credentials for CLI access")
504 }
505
506 log.Debug().Msg("internal registry auth generated for loopback pulls")
507 return nil
508}
509
510func createDomainSecretStore(cfg Config, log zerowrap.Logger) (string, domain.SecretsBackend, *domainsecrets.PassStore, out.DomainSecretStore, error) {
511 envDir := resolveEnvDir(cfg)
512 backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)
513
514 switch backend {
515 case domain.SecretsBackendPass:
516 passStore, err := domainsecrets.NewPassStore(log)
517 if err != nil {
518 return "", backend, nil, nil, log.WrapErr(err, "failed to create pass domain secret store")
519 }
520 if err := migrateEnvFilesToPass(envDir, passStore, log); err != nil {
521 return "", backend, nil, nil, log.WrapErr(err, "failed to migrate env files to pass")
522 }
523 return envDir, backend, passStore, passStore, nil
524 default:
525 store, err := domainsecrets.NewFileStore(envDir, log)
526 if err != nil {
527 return "", backend, nil, nil, log.WrapErr(err, "failed to create domain secret store")
528 }
529 return envDir, backend, nil, store, nil
530 }
531}
532
533// resolveLogFilePath returns the configured log file path or a default.
534func resolveLogFilePath(cfg Config) string {
535 if cfg.Logging.File.Path != "" {
536 return cfg.Logging.File.Path
537 }
538 dataDir := cfg.Server.DataDir
539 if dataDir == "" {
540 dataDir = DefaultDataDir()
541 }
542 return filepath.Join(dataDir, "logs", "gordon.log")
543}
544
545// createOutputAdapters creates the Docker runtime and event bus.
546func createOutputAdapters(ctx context.Context, log zerowrap.Logger) (*docker.Runtime, *eventbus.InMemory, error) {
547 runtime, err := docker.NewRuntime()
548 if err != nil {
549 return nil, nil, log.WrapErr(err, "failed to create Docker runtime")
550 }
551
552 if err := runtime.Ping(ctx); err != nil {
553 return nil, nil, log.WrapErr(err, "Docker is not available")
554 }
555
556 dockerVersion, _ := runtime.Version(ctx)
557 log.Info().Str("docker_version", dockerVersion).Msg("Docker runtime initialized")
558
559 eventBus := eventbus.NewInMemory(100, log)
560
561 return runtime, eventBus, nil
562}
563
564// createStorage creates blob and manifest storage.
565func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *filesystem.ManifestStorage, error) {
566 dataDir := cfg.Server.DataDir
567 if dataDir == "" {
568 dataDir = DefaultDataDir()
569 }
570
571 registryDir := filepath.Join(dataDir, "registry")
572
573 blobStorage, err := filesystem.NewBlobStorage(registryDir, log)
574 if err != nil {
575 return nil, nil, log.WrapErr(err, "failed to create blob storage")
576 }
577
578 manifestStorage, err := filesystem.NewManifestStorage(registryDir, log)
579 if err != nil {
580 return nil, nil, log.WrapErr(err, "failed to create manifest storage")
581 }
582
583 return blobStorage, manifestStorage, nil
584}
585
586// createEnvLoader creates the environment loader with secret providers.
587func createEnvLoader(backend domain.SecretsBackend, envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) (out.EnvLoader, error) {
588 switch backend {
589 case domain.SecretsBackendPass:
590 loader, err := envloader.NewPassLoader(passStore, log)
591 if err != nil {
592 return nil, log.WrapErr(err, "failed to create pass env loader")
593 }
594 return loader, nil
595 default:
596 loader, err := envloader.NewFileLoader(envDir, log)
597 if err != nil {
598 return nil, log.WrapErr(err, "failed to create env loader")
599 }
600
601 // Register secret providers
602 passProvider := secrets.NewPassProvider(log)
603 if passProvider.IsAvailable() {
604 loader.RegisterSecretProvider(passProvider)
605 log.Debug().Msg("pass secret provider registered")
606 }
607
608 sopsProvider := secrets.NewSopsProvider(log)
609 if sopsProvider.IsAvailable() {
610 loader.RegisterSecretProvider(sopsProvider)
611 log.Debug().Msg("sops secret provider registered")
612 }
613
614 return loader, nil
615 }
616}
617
618// createLogWriter creates the container log writer.
619func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, error) {
620 if !cfg.Logging.ContainerLogs.Enabled {
621 log.Debug().Msg("container log collection disabled")
622 return nil, nil
623 }
624
625 // Determine log directory
626 logDir := cfg.Logging.ContainerLogs.Dir
627 if logDir == "" {
628 dataDir := cfg.Server.DataDir
629 if dataDir == "" {
630 dataDir = DefaultDataDir()
631 }
632 logDir = filepath.Join(dataDir, "logs", "containers")
633 }
634
635 writer, err := logwriter.New(logwriter.Config{
636 Dir: logDir,
637 MaxSize: cfg.Logging.ContainerLogs.MaxSize,
638 MaxBackups: cfg.Logging.ContainerLogs.MaxBackups,
639 MaxAge: cfg.Logging.ContainerLogs.MaxAge,
640 })
641 if err != nil {
642 return nil, log.WrapErr(err, "failed to create container log writer")
643 }
644
645 log.Info().Str("dir", logDir).Msg("container log collection enabled")
646 return writer, nil
647}
648
649const (
650 internalRegistryUsername = "gordon-internal"
651 serviceTokenSubject = "gordon-service"
652 serviceTokenDefaultTTL = 30 * 24 * time.Hour
653)
654
655func generateInternalRegistryAuth() (string, string, error) {
656 password, err := randomTokenHex(32)
657 if err != nil {
658 return "", "", err
659 }
660 return internalRegistryUsername, password, nil
661}
662
663func randomTokenHex(size int) (string, error) {
664 buf := make([]byte, size)
665 if _, err := rand.Read(buf); err != nil {
666 return "", err
667 }
668 return hex.EncodeToString(buf), nil
669}
670
671// InternalCredentials holds the internal registry credentials for CLI access.
672type InternalCredentials struct {
673 Username string `json:"username"`
674 Password string `json:"password"`
675}
676
677// getSecureRuntimeDir returns a secure directory for runtime files.
678// Priority: XDG_RUNTIME_DIR > ~/.gordon/run
679func getSecureRuntimeDir() (string, error) {
680 // Try XDG_RUNTIME_DIR first (typically /run/user/<uid> on Linux)
681 if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
682 gordonDir := filepath.Join(runtimeDir, "gordon")
683 if err := os.MkdirAll(gordonDir, 0700); err == nil {
684 return gordonDir, nil
685 }
686 }
687
688 // Fall back to ~/.gordon/run
689 homeDir, err := os.UserHomeDir()
690 if err != nil {
691 return "", fmt.Errorf("failed to get home directory: %w", err)
692 }
693
694 gordonDir := filepath.Join(homeDir, ".gordon", "run")
695 if err := os.MkdirAll(gordonDir, 0700); err != nil {
696 return "", fmt.Errorf("failed to create runtime directory: %w", err)
697 }
698
699 return gordonDir, nil
700}
701
702// getInternalCredentialsFile returns the path to the internal credentials file.
703// SECURITY: Credentials are stored in a secure location with restricted permissions.
704func getInternalCredentialsFile() string {
705 runtimeDir, err := getSecureRuntimeDir()
706 if err != nil {
707 // Fall back to temp dir if we can't get secure dir (shouldn't happen)
708 return filepath.Join(os.TempDir(), "gordon-internal-creds.json")
709 }
710 return filepath.Join(runtimeDir, "internal-creds.json")
711}
712
713// persistInternalCredentials saves the internal registry credentials to a secure file.
714// SECURITY: Credentials are stored in XDG_RUNTIME_DIR or ~/.gordon/run with 0600 permissions.
715// The file is cleaned up on graceful shutdown but may persist if Gordon crashes.
716// These credentials are for internal loopback communication only and are regenerated on each start.
717func persistInternalCredentials(username, password string) error {
718 creds := InternalCredentials{
719 Username: username,
720 Password: password,
721 }
722 data, err := json.Marshal(creds)
723 if err != nil {
724 return fmt.Errorf("failed to marshal credentials: %w", err)
725 }
726
727 credFile := getInternalCredentialsFile()
728
729 // Ensure parent directory exists with secure permissions
730 if err := os.MkdirAll(filepath.Dir(credFile), 0700); err != nil {
731 return fmt.Errorf("failed to create credentials directory: %w", err)
732 }
733
734 // Write file with restrictive permissions (owner read/write only)
735 if err := os.WriteFile(credFile, data, 0600); err != nil {
736 return fmt.Errorf("failed to write credentials file: %w", err)
737 }
738 return nil
739}
740
741// cleanupInternalCredentials removes the internal credentials file.
742func cleanupInternalCredentials() {
743 _ = os.Remove(getInternalCredentialsFile())
744}
745
746// GetInternalCredentials reads the internal registry credentials from file.
747// This is used by the CLI to display credentials for manual recovery.
748func GetInternalCredentials() (*InternalCredentials, error) {
749 credFile := getInternalCredentialsFile()
750 data, err := os.ReadFile(credFile)
751 if err != nil {
752 return nil, fmt.Errorf("failed to read credentials file (is Gordon running?): %w", err)
753 }
754 var creds InternalCredentials
755 if err := json.Unmarshal(data, &creds); err != nil {
756 return nil, fmt.Errorf("failed to parse credentials: %w", err)
757 }
758 return &creds, nil
759}
760
761// createAuthService creates the authentication service and token store.
762func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
763 if !cfg.Auth.Enabled {
764 return nil, nil, nil
765 }
766
767 authType := resolveAuthType(cfg)
768 backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)
769 dataDir := resolveDataDir(cfg.Server.DataDir)
770
771 store, err := createTokenStore(backend, dataDir, log)
772 if err != nil {
773 return nil, nil, err
774 }
775
776 authConfig, err := buildAuthConfig(ctx, cfg, authType, backend, dataDir, log)
777 if err != nil {
778 return nil, nil, err
779 }
780
781 authSvc := auth.NewService(authConfig, store, log)
782
783 log.Info().
784 Str("type", string(authType)).
785 Str("backend", string(backend)).
786 Msg("registry authentication enabled")
787
788 return store, authSvc, nil
789}
790
791// resolveAuthType determines the auth type from config.
792// If password_hash is configured, password auth is available (plus tokens).
793// If only token_secret is configured, token-only mode.
794// The explicit "type" field is deprecated but still respected for backwards compat.
795func resolveAuthType(cfg Config) domain.AuthType {
796 // Explicit type takes precedence (backwards compatibility)
797 if cfg.Auth.Type == "token" {
798 return domain.AuthTypeToken
799 }
800 if cfg.Auth.Type == "password" {
801 return domain.AuthTypePassword
802 }
803
804 // Infer from config: password auth if password_hash is configured
805 if cfg.Auth.PasswordHash != "" || cfg.Auth.Password != "" {
806 return domain.AuthTypePassword
807 }
808
809 // Default to token-only
810 return domain.AuthTypeToken
811}
812
813func resolveSecretsBackend(backend string) domain.SecretsBackend {
814 switch backend {
815 case "pass":
816 return domain.SecretsBackendPass
817 case "sops":
818 return domain.SecretsBackendSops
819 case "unsafe", "":
820 return domain.SecretsBackendUnsafe
821 default:
822 return domain.SecretsBackendUnsafe
823 }
824}
825
826func resolveDataDir(dataDir string) string {
827 if dataDir == "" {
828 return DefaultDataDir()
829 }
830 return dataDir
831}
832
833func resolveEnvDir(cfg Config) string {
834 dataDir := resolveDataDir(cfg.Server.DataDir)
835 envDir := cfg.Env.Dir
836 if envDir == "" {
837 envDir = filepath.Join(dataDir, "env")
838 }
839 return envDir
840}
841
842func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
843 // Token store is always created since tokens work in both auth modes
844 store, err := tokenstore.NewStore(backend, dataDir, log)
845 if err != nil {
846 return nil, log.WrapErr(err, "failed to create token store")
847 }
848 return store, nil
849}
850
851func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
852 authConfig := auth.Config{
853 Enabled: cfg.Auth.Enabled,
854 AuthType: authType,
855 Username: cfg.Auth.Username,
856 }
857
858 // Token config is always required (tokens work in all auth modes)
859 secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log)
860 if err != nil {
861 return auth.Config{}, err
862 }
863 authConfig.TokenSecret = secret
864 authConfig.TokenExpiry = expiry
865
866 // Password config only needed for password auth mode
867 if authType == domain.AuthTypePassword {
868 hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log)
869 if err != nil {
870 return auth.Config{}, err
871 }
872 if hash == "" {
873 return auth.Config{}, errAuthNotConfigured()
874 }
875 authConfig.PasswordHash = hash
876 }
877
878 return authConfig, nil
879}
880
881// errAuthNotConfigured returns an error when auth is enabled but credentials are not configured.
882func errAuthNotConfigured() error {
883 return fmt.Errorf("auth is enabled by default, configure auth type and secrets backend. See: https://gordon.bnema.dev/docs/config/auth")
884}
885
886func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) {
887 if cfg.Auth.PasswordHash != "" {
888 hash, err := loadSecret(ctx, backend, cfg.Auth.PasswordHash, dataDir, log)
889 if err != nil {
890 return "", log.WrapErr(err, "failed to load password hash")
891 }
892 return hash, nil
893 }
894
895 if cfg.Auth.Password != "" {
896 log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend")
897 return cfg.Auth.Password, nil
898 }
899
900 return "", nil
901}
902
903func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) {
904 secret, err := loadTokenSecret(ctx, cfg, backend, dataDir, log)
905 if err != nil {
906 return nil, 0, err
907 }
908
909 expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
910 if err != nil {
911 return nil, 0, err
912 }
913
914 return secret, expiry, nil
915}
916
917// TokenSecretEnvVar is the environment variable for the JWT signing secret.
918// SECURITY: This takes priority over config file to allow secure secret injection.
919const TokenSecretEnvVar = "GORDON_AUTH_TOKEN_SECRET" //nolint:gosec // This is an env var name, not a credential
920
921func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {
922 // SECURITY: Priority order for token secret:
923 // 1. Environment variable (most secure - no disk exposure)
924 // 2. Secrets backend (pass/sops - encrypted)
925 // 3. Config file path (least preferred)
926
927 // Check environment variable first
928 if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" {
929 log.Debug().Msg("using token secret from environment variable")
930 return []byte(envSecret), nil
931 }
932
933 // Fall back to config-specified path via secrets backend
934 if cfg.Auth.TokenSecret == "" {
935 return nil, fmt.Errorf("token_secret is required for JWT token generation; set %s environment variable or configure auth.token_secret", TokenSecretEnvVar)
936 }
937
938 secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log)
939 if err != nil {
940 return nil, log.WrapErr(err, "failed to load token secret")
941 }
942
943 return []byte(secret), nil
944}
945
946func parseTokenExpiry(expiry string) (time.Duration, error) {
947 if expiry == "" {
948 return 0, nil
949 }
950
951 parsed, err := duration.Parse(expiry)
952 if err != nil {
953 return 0, fmt.Errorf("invalid token_expiry: %w", err)
954 }
955
956 return parsed, nil
957}
958
959func resolveServiceTokenExpiry(cfg Config) (time.Duration, error) {
960 expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
961 if err != nil {
962 return 0, err
963 }
964 if expiry <= 0 {
965 return serviceTokenDefaultTTL, nil
966 }
967 return expiry, nil
968}
969
970// loadSecret loads a secret from the configured backend.
971func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) {
972 switch backend {
973 case domain.SecretsBackendPass:
974 provider := secrets.NewPassProvider(log)
975 return provider.GetSecret(ctx, path)
976 case domain.SecretsBackendSops:
977 provider := secrets.NewSopsProvider(log)
978 return provider.GetSecret(ctx, path)
979 case domain.SecretsBackendUnsafe:
980 // For unsafe backend, path is relative to dataDir/secrets/
981 secretFile := filepath.Join(dataDir, "secrets", path)
982 data, err := os.ReadFile(secretFile)
983 if err != nil {
984 return "", fmt.Errorf("failed to read secret file: %w", err)
985 }
986 return string(data), nil
987 default:
988 return "", fmt.Errorf("unknown secrets backend: %s", backend)
989 }
990}
991
992// proxyConfigResult holds parsed proxy and blob chunk size config.
993type proxyConfigResult struct {
994 proxyConfig proxy.Config
995 maxBlobChunkSize int64
996}
997
998// buildProxyConfig parses size-related config fields and builds the proxy config.
999func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, error) {
1000 maxProxyBodySize := int64(512 << 20) // 512MB default
1001 if cfg.Server.MaxProxyBodySize != "" {
1002 parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyBodySize)
1003 if err != nil {
1004 return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_body_size configuration", map[string]any{"value": cfg.Server.MaxProxyBodySize})
1005 }
1006 maxProxyBodySize = parsedSize
1007 }
1008
1009 maxBlobChunkSize := int64(registry.DefaultMaxBlobChunkSize)
1010 if cfg.Server.MaxBlobChunkSize != "" {
1011 parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobChunkSize)
1012 if err != nil {
1013 return nil, log.WrapErrWithFields(err, "invalid server.max_blob_chunk_size configuration", map[string]any{"value": cfg.Server.MaxBlobChunkSize})
1014 }
1015 maxBlobChunkSize = parsedSize
1016 }
1017
1018 maxProxyResponseSize := int64(1 << 30) // 1GB default
1019 if cfg.Server.MaxProxyResponseSize != "" {
1020 parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyResponseSize)
1021 if err != nil {
1022 return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_response_size configuration", map[string]any{"value": cfg.Server.MaxProxyResponseSize})
1023 }
1024 maxProxyResponseSize = parsedSize
1025 }
1026
1027 maxConcurrentConns := cfg.Server.MaxConcurrentConns
1028 if maxConcurrentConns < 0 {
1029 maxConcurrentConns = 10000 // default when explicitly set to -1
1030 }
1031 // 0 means no limit (as documented in proxy.Config)
1032
1033 return &proxyConfigResult{
1034 proxyConfig: proxy.Config{
1035 RegistryDomain: cfg.Server.RegistryDomain,
1036 RegistryPort: cfg.Server.RegistryPort,
1037 MaxBodySize: maxProxyBodySize,
1038 MaxResponseSize: maxProxyResponseSize,
1039 MaxConcurrentConns: maxConcurrentConns,
1040 },
1041 maxBlobChunkSize: maxBlobChunkSize,
1042 }, nil
1043}
1044
1045// createContainerService creates the container service with configuration.
1046func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
1047 containerConfig := container.Config{
1048 RegistryAuthEnabled: cfg.Auth.Enabled,
1049 RegistryDomain: cfg.Server.RegistryDomain,
1050 RegistryPort: cfg.Server.RegistryPort,
1051 InternalRegistryUsername: svc.internalRegUser,
1052 InternalRegistryPassword: svc.internalRegPass,
1053 PullPolicy: v.GetString("deploy.pull_policy"),
1054 VolumeAutoCreate: v.GetBool("volumes.auto_create"),
1055 VolumePrefix: v.GetString("volumes.prefix"),
1056 VolumePreserve: v.GetBool("volumes.preserve"),
1057 NetworkIsolation: v.GetBool("network_isolation.enabled"),
1058 NetworkPrefix: v.GetString("network_isolation.network_prefix"),
1059 DNSSuffix: v.GetString("network_isolation.dns_suffix"),
1060 NetworkGroups: svc.configSvc.GetNetworkGroups(),
1061 Attachments: svc.configSvc.GetAttachments(),
1062 ReadinessDelay: v.GetDuration("deploy.readiness_delay"),
1063 ReadinessMode: v.GetString("deploy.readiness_mode"),
1064 HealthTimeout: v.GetDuration("deploy.health_timeout"),
1065 DrainDelay: v.GetDuration("deploy.drain_delay"),
1066 DrainMode: v.GetString("deploy.drain_mode"),
1067 DrainTimeout: v.GetDuration("deploy.drain_timeout"),
1068 }
1069 if v.IsSet("deploy.drain_delay") {
1070 containerConfig.DrainDelayConfigured = true
1071 containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay")
1072 }
1073
1074 if cfg.Auth.Enabled && svc.authSvc != nil {
1075 expiry, err := resolveServiceTokenExpiry(cfg)
1076 if err != nil {
1077 return nil, log.WrapErr(err, "failed to resolve service token expiry")
1078 }
1079 // Note: Service tokens are not auto-refreshed. If the token expires during
1080 // container runtime, the container will need to be recreated to get a new token.
1081 serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, expiry)
1082 if err != nil {
1083 return nil, log.WrapErr(err, "failed to generate registry service token")
1084 }
1085 log.Info().
1086 Str("subject", serviceTokenSubject).
1087 Str("expiry", expiry.String()).
1088 Msg("generated service token for container registry access")
1089 containerConfig.ServiceTokenUsername = serviceTokenSubject
1090 containerConfig.ServiceToken = serviceToken
1091 }
1092
1093 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil
1094}
1095
1096func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*filesystem.BackupStorage, *backup.Service, error) {
1097 if !cfg.Backups.Enabled {
1098 return nil, nil, nil
1099 }
1100
1101 storageDir := cfg.Backups.StorageDir
1102 if storageDir == "" {
1103 dataDir := resolveDataDir(cfg.Server.DataDir)
1104 storageDir = filepath.Join(dataDir, "backups")
1105 }
1106
1107 backupStorage, err := filesystem.NewBackupStorage(storageDir, log)
1108 if err != nil {
1109 return nil, nil, log.WrapErr(err, "failed to create backup storage")
1110 }
1111
1112 retention, err := validateBackupRetention(cfg)
1113 if err != nil {
1114 return nil, nil, log.WrapErr(err, "invalid backup retention policy")
1115 }
1116
1117 backupCfg := domain.BackupConfig{
1118 Enabled: cfg.Backups.Enabled,
1119 StorageDir: storageDir,
1120 Retention: retention,
1121 }
1122
1123 backupSvc := backup.NewService(svc.runtime, backupStorage, svc.containerSvc, backupCfg, log)
1124
1125 log.Info().
1126 Str("storage_dir", storageDir).
1127 Msg("backup service initialized")
1128
1129 return backupStorage, backupSvc, nil
1130}
1131
1132func validateBackupRetention(cfg Config) (domain.RetentionPolicy, error) {
1133 if cfg.Backups.Retention.Hourly < 0 {
1134 return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.hourly cannot be negative")
1135 }
1136 if cfg.Backups.Retention.Daily < 0 {
1137 return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.daily cannot be negative")
1138 }
1139 if cfg.Backups.Retention.Weekly < 0 {
1140 return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.weekly cannot be negative")
1141 }
1142 if cfg.Backups.Retention.Monthly < 0 {
1143 return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.monthly cannot be negative")
1144 }
1145
1146 return domain.RetentionPolicy{
1147 Hourly: cfg.Backups.Retention.Hourly,
1148 Daily: cfg.Backups.Retention.Daily,
1149 Weekly: cfg.Backups.Retention.Weekly,
1150 Monthly: cfg.Backups.Retention.Monthly,
1151 }, nil
1152}
1153
1154// registerEventHandlers registers all event handlers.
1155func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error {
1156 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
1157 if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil {
1158 return fmt.Errorf("failed to subscribe image pushed handler: %w", err)
1159 }
1160
1161 // Auto-route handler for creating routes from image labels
1162 autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain).
1163 WithEnvExtractor(svc.runtime, svc.envDir)
1164 if err := svc.eventBus.Subscribe(autoRouteHandler); err != nil {
1165 return fmt.Errorf("failed to subscribe auto-route handler: %w", err)
1166 }
1167
1168 configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)
1169 if err := svc.eventBus.Subscribe(configReloadHandler); err != nil {
1170 return fmt.Errorf("failed to subscribe config reload handler: %w", err)
1171 }
1172
1173 manualReloadHandler := container.NewManualReloadHandler(ctx, svc.containerSvc, svc.configSvc)
1174 if err := svc.eventBus.Subscribe(manualReloadHandler); err != nil {
1175 return fmt.Errorf("failed to subscribe manual reload handler: %w", err)
1176 }
1177
1178 manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc)
1179 if err := svc.eventBus.Subscribe(manualDeployHandler); err != nil {
1180 return fmt.Errorf("failed to subscribe manual deploy handler: %w", err)
1181 }
1182
1183 // Proxy cache invalidation on config reload (clears stale targets for removed routes)
1184 configReloadProxyHandler := proxy.NewConfigReloadProxyHandler(ctx, svc.proxySvc)
1185 if err := svc.eventBus.Subscribe(configReloadProxyHandler); err != nil {
1186 return fmt.Errorf("failed to subscribe config reload proxy handler: %w", err)
1187 }
1188
1189 return nil
1190}
1191
1192// setupConfigHotReload sets up Viper config hot reload.
1193// NOTE: This does NOT reload routes into memory. Routes are managed via API
1194// (AddRoute/UpdateRoute/RemoveRoute) and memory is the source of truth.
1195// The file watcher only updates proxy config and refreshes targets.
1196// Manual config file edits to routes require a server restart.
1197func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) {
1198 v.OnConfigChange(func(e fsnotify.Event) {
1199 log.Info().Str("file", e.Name).Msg("config file changed")
1200
1201 if err := v.ReadInConfig(); err != nil {
1202 log.Error().Err(err).Msg("failed to reload config")
1203 return
1204 }
1205
1206 // Re-unmarshal config and rebuild proxy config to pick up all changes
1207 var reloadCfg Config
1208 if err := v.Unmarshal(&reloadCfg); err != nil {
1209 log.Error().Err(err).Msg("failed to unmarshal config on reload")
1210 return
1211 }
1212 if reloadCfg.Server.GordonDomain != "" {
1213 reloadCfg.Server.RegistryDomain = reloadCfg.Server.GordonDomain
1214 }
1215 reloadedProxy, err := buildProxyConfig(reloadCfg, log)
1216 if err != nil {
1217 log.Error().Err(err).Msg("failed to parse proxy config on reload")
1218 return
1219 }
1220 svc.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)
1221
1222 // Clear proxy target cache to pick up external route changes
1223 if err := svc.proxySvc.RefreshTargets(ctx); err != nil {
1224 log.Warn().Err(err).Msg("failed to refresh proxy targets")
1225 }
1226
1227 log.Debug().Msg("config hot reload complete (routes unchanged, memory is source of truth)")
1228 })
1229 v.WatchConfig()
1230}
1231
1232// syncAndAutoStart syncs existing containers and auto-starts if configured.
1233func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
1234 if err := svc.containerSvc.SyncContainers(ctx); err != nil {
1235 log.Warn().Err(err).Msg("failed to sync existing containers")
1236 }
1237
1238 if svc.configSvc.IsAutoRouteEnabled() {
1239 routes := svc.configSvc.GetRoutes(ctx)
1240 if err := svc.containerSvc.AutoStart(domain.WithInternalDeploy(ctx), routes); err != nil {
1241 log.Warn().Err(err).Msg("failed to auto-start containers")
1242 }
1243 }
1244}
1245
1246// createHTTPHandlers creates HTTP handlers with middleware.
1247func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) {
1248 // Parse trusted proxies once for all middleware chains.
1249 // This ensures consistent IP extraction across logging, rate limiting, and auth.
1250 trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
1251
1252 registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)
1253
1254 registryMiddlewares := []func(http.Handler) http.Handler{
1255 middleware.PanicRecovery(log),
1256 middleware.RequestLogger(log, trustedNets),
1257 middleware.SecurityHeaders,
1258 }
1259
1260 // CIDR allowlist for registry access (e.g., restrict to Tailscale IPs).
1261 // Inserted before rate limiting so denied IPs never consume limiter tokens.
1262 var cidrAllowlistMiddleware func(http.Handler) http.Handler
1263 if len(cfg.Server.RegistryAllowedIPs) > 0 {
1264 allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs)
1265 if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) {
1266 for _, entry := range cfg.Server.RegistryAllowedIPs {
1267 if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 {
1268 log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry")
1269 }
1270 }
1271 }
1272 if len(allowedNets) == 0 {
1273 log.Error().
1274 Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs).
1275 Msg("registry_allowed_ips is set but no valid entries were parsed; registry will allow all traffic")
1276 }
1277 cidrAllowlistMiddleware = middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)
1278 registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware)
1279 }
1280
1281 // Create rate limiters using the factory
1282 var rateLimitMiddleware func(http.Handler) http.Handler
1283 if cfg.API.RateLimit.Enabled {
1284 globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
1285 ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1286 rateLimitMiddleware = registry.RateLimitMiddleware(
1287 globalLimiter,
1288 ipLimiter,
1289 cfg.API.RateLimit.TrustedProxies,
1290 log,
1291 )
1292 } else {
1293 rateLimitMiddleware = registry.RateLimitMiddleware(nil, nil, nil, log)
1294 }
1295 registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
1296
1297 if cfg.Auth.Enabled && svc.authSvc != nil {
1298 internalAuth := middleware.InternalRegistryAuth{
1299 Username: svc.internalRegUser,
1300 Password: svc.internalRegPass,
1301 }
1302 registryMiddlewares = append(registryMiddlewares,
1303 middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
1304 }
1305
1306 registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler)
1307
1308 // Create a mux that routes:
1309 // - /auth/* to auth handler (no auth required, for password login and token exchange)
1310 // - /v2/* to registry handler (with auth)
1311 // - /admin/* to admin handler (with auth)
1312 registryMux := http.NewServeMux()
1313 if svc.authHandler != nil {
1314 // Auth endpoints are NOT protected by auth - they're where clients authenticate
1315 // but still need rate limiting to prevent brute force attacks
1316 authMiddlewares := []func(http.Handler) http.Handler{
1317 middleware.PanicRecovery(log),
1318 middleware.RequestLogger(log, trustedNets),
1319 middleware.SecurityHeaders,
1320 }
1321 if cidrAllowlistMiddleware != nil {
1322 authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)
1323 }
1324 authMiddlewares = append(authMiddlewares, rateLimitMiddleware)
1325 authWithMiddleware := middleware.Chain(authMiddlewares...)(svc.authHandler)
1326 registryMux.Handle("/auth/", authWithMiddleware)
1327 }
1328 registryMux.Handle("/v2/", registryWithMiddleware)
1329
1330 // Add admin API handler with auth middleware
1331 if svc.adminHandler != nil {
1332 adminMiddlewares := []func(http.Handler) http.Handler{
1333 middleware.PanicRecovery(log),
1334 middleware.RequestLogger(log, trustedNets),
1335 middleware.SecurityHeaders,
1336 }
1337 // Add admin auth middleware if auth is enabled
1338 if svc.authSvc != nil {
1339 // Create rate limiters for admin API - uses same config as registry
1340 var globalLimiter, ipLimiter out.RateLimiter
1341 if cfg.API.RateLimit.Enabled {
1342 globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
1343 ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1344 }
1345 adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log))
1346 }
1347 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
1348 registryMux.Handle("/admin/", adminWithMiddleware)
1349 }
1350
1351 // SECURITY: No CORS middleware on the proxy chain. Backend applications
1352 // should control their own CORS policies. A blanket Access-Control-Allow-Origin: *
1353 // would override backend CORS settings and allow any website to make
1354 // cross-origin authenticated requests to proxied applications.
1355 proxyMiddlewares := []func(http.Handler) http.Handler{
1356 middleware.PanicRecovery(log),
1357 middleware.RequestLogger(log, trustedNets),
1358 middleware.SecurityHeaders,
1359 }
1360
1361 proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc)
1362
1363 return registryMux, proxyWithMiddleware
1364}
1365
1366// runServers starts the HTTP servers and waits for shutdown.
1367// Signal handling notes:
1368// - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext
1369// - SIGUSR1: Triggers config reload without restart
1370// - SIGUSR2: Triggers manual deploy for a specific route
1371// The deferred signal.Stop calls ensure signal handlers are properly
1372// cleaned up before program exit, preventing signal handler leaks.
1373func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, svc *services, log zerowrap.Logger) error {
1374 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
1375 defer cancel()
1376
1377 // Set up SIGUSR1 for reload.
1378 // Note: signal.Stop must be called (via defer) to release the channel
1379 // and prevent signal handler leaks when the function returns.
1380 reloadChan := make(chan os.Signal, 1)
1381 signal.Notify(reloadChan, syscall.SIGUSR1)
1382 defer signal.Stop(reloadChan)
1383
1384 // Set up SIGUSR2 for manual deploy.
1385 deployChan := make(chan os.Signal, 1)
1386 signal.Notify(deployChan, syscall.SIGUSR2)
1387 defer signal.Stop(deployChan)
1388
1389 errChan := make(chan error, 3)
1390
1391 registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
1392 proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
1393 var tlsReady <-chan struct{}
1394 if cfg.Server.TLSEnabled {
1395 if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" {
1396 return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file")
1397 }
1398 tlsReady = startTLSServer(
1399 fmt.Sprintf(":%d", cfg.Server.TLSPort),
1400 proxyHandler,
1401 "proxy-tls",
1402 cfg.Server.TLSCertFile,
1403 cfg.Server.TLSKeyFile,
1404 errChan,
1405 log,
1406 )
1407 }
1408
1409 // Wait for both servers to bind their ports before auto-starting containers.
1410 // This prevents the race where auto-start pulls from the registry before it's listening.
1411 select {
1412 case <-registryReady:
1413 case err := <-errChan:
1414 return err
1415 }
1416 select {
1417 case <-proxyReady:
1418 case err := <-errChan:
1419 return err
1420 }
1421 if cfg.Server.TLSEnabled {
1422 select {
1423 case <-tlsReady:
1424 case err := <-errChan:
1425 return err
1426 }
1427 }
1428
1429 logEvent := log.Info().
1430 Int("proxy_port", cfg.Server.Port).
1431 Int("registry_port", cfg.Server.RegistryPort)
1432 if cfg.Server.TLSEnabled {
1433 logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort)
1434 }
1435 logEvent.Msg("Gordon is running")
1436
1437 // Auto-start after servers are listening (registry port is now bound).
1438 syncAndAutoStart(ctx, svc, log)
1439
1440 waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log)
1441 gracefulShutdown(registrySrv, proxySrv, containerSvc, log)
1442 return nil
1443}
1444
1445// waitForShutdown blocks on the event loop, handling server errors and
1446// Unix signals (reload, deploy, shutdown) until the context is cancelled.
1447func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, deployChan <-chan os.Signal, eventBus out.EventBus, log zerowrap.Logger) {
1448 for {
1449 select {
1450 case err := <-errChan:
1451 log.Error().Err(err).Msg("server error")
1452 return
1453 case <-reloadChan:
1454 log.Info().Msg("reload signal received (SIGUSR1)")
1455 if err := eventBus.Publish(domain.EventManualReload, nil); err != nil {
1456 log.Error().Err(err).Msg("failed to publish manual reload event")
1457 }
1458 case <-deployChan:
1459 log.Info().Msg("deploy signal received (SIGUSR2)")
1460 domainName, err := readDeployRequest()
1461 if err != nil {
1462 log.Error().Err(err).Msg("failed to read deploy request")
1463 continue
1464 }
1465 payload := &domain.ManualDeployPayload{Domain: domainName}
1466 if err := eventBus.Publish(domain.EventManualDeploy, payload); err != nil {
1467 log.Error().Err(err).Str("domain", domainName).Msg("failed to publish manual deploy event")
1468 }
1469 case <-ctx.Done():
1470 log.Info().Msg("shutdown signal received")
1471 return
1472 }
1473 }
1474}
1475
1476// gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
1477// the container service and cleans up runtime files.
1478func gracefulShutdown(registrySrv, proxySrv *http.Server, containerSvc *container.Service, log zerowrap.Logger) {
1479 log.Info().Msg("shutting down Gordon...")
1480
1481 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
1482 defer shutdownCancel()
1483
1484 if err := registrySrv.Shutdown(shutdownCtx); err != nil {
1485 log.Warn().Err(err).Msg("registry server shutdown error")
1486 }
1487 if err := proxySrv.Shutdown(shutdownCtx); err != nil {
1488 log.Warn().Err(err).Msg("proxy server shutdown error")
1489 }
1490
1491 if err := containerSvc.Shutdown(shutdownCtx); err != nil {
1492 log.Warn().Err(err).Msg("error during container shutdown")
1493 }
1494
1495 cleanupInternalCredentials()
1496 log.Info().Msg("Gordon stopped")
1497}
1498
1499// startServer starts an HTTP server, returning the server instance and a channel
1500// that closes once the listening socket is bound. This lets callers wait for the
1501// port to be ready before taking actions that depend on it (e.g. auto-start
1502// pulling from the local registry). The returned *http.Server can be used for
1503// graceful shutdown.
1504func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {
1505 ready := make(chan struct{})
1506
1507 server := &http.Server{
1508 Addr: addr,
1509 Handler: handler,
1510 ReadHeaderTimeout: 10 * time.Second,
1511 ReadTimeout: 5 * time.Minute,
1512 WriteTimeout: 5 * time.Minute,
1513 IdleTimeout: 120 * time.Second,
1514 MaxHeaderBytes: 1 << 20,
1515 }
1516
1517 go func() {
1518 log.Info().Str("address", addr).Msgf("%s server starting", name)
1519
1520 ln, err := net.Listen("tcp", addr)
1521 if err != nil {
1522 errChan <- fmt.Errorf("%s server error: %w", name, err)
1523 return
1524 }
1525 close(ready) // signal: port is bound and accepting connections
1526
1527 if err := server.Serve(ln); err != nil && err != http.ErrServerClosed {
1528 errChan <- fmt.Errorf("%s server error: %w", name, err)
1529 }
1530 }()
1531
1532 return server, ready
1533}
1534
1535// startTLSServer starts an HTTPS server with the provided certificate and key.
1536func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile string, errChan chan<- error, log zerowrap.Logger) <-chan struct{} {
1537 ready := make(chan struct{})
1538
1539 go func() {
1540 log.Info().
1541 Str("address", addr).
1542 Str("cert_file", certFile).
1543 Str("key_file", keyFile).
1544 Msgf("%s server starting", name)
1545
1546 server := &http.Server{
1547 Addr: addr,
1548 Handler: handler,
1549 ReadHeaderTimeout: 10 * time.Second,
1550 ReadTimeout: 5 * time.Minute,
1551 WriteTimeout: 5 * time.Minute,
1552 IdleTimeout: 120 * time.Second,
1553 MaxHeaderBytes: 1 << 20,
1554 }
1555
1556 ln, err := net.Listen("tcp", addr)
1557 if err != nil {
1558 errChan <- fmt.Errorf("%s server error: %w", name, err)
1559 return
1560 }
1561 close(ready)
1562
1563 if err := server.ServeTLS(ln, certFile, keyFile); err != nil && err != http.ErrServerClosed {
1564 errChan <- fmt.Errorf("%s server error: %w", name, err)
1565 }
1566 }()
1567
1568 return ready
1569}
1570
1571// SendReloadSignal sends SIGUSR1 to the running Gordon process.
1572func SendReloadSignal() error {
1573 process, _, err := findRunningProcess()
1574 if err != nil {
1575 return err
1576 }
1577
1578 if err := process.Signal(syscall.SIGUSR1); err != nil {
1579 return fmt.Errorf("failed to send reload signal: %w", err)
1580 }
1581
1582 return nil
1583}
1584
1585// getDeployRequestFile returns the path to the deploy request file.
1586func getDeployRequestFile() string {
1587 return filepath.Join(os.TempDir(), "gordon-deploy-request")
1588}
1589
1590// writeDeployRequestFile creates the deploy request file exclusively with retry.
1591// This prevents race conditions when multiple deploy commands run simultaneously.
1592func writeDeployRequestFile(path string, data []byte, timeout time.Duration) error {
1593 deadline := time.Now().Add(timeout)
1594
1595 for {
1596 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
1597 if err == nil {
1598 _, writeErr := f.Write(data)
1599 f.Close()
1600 if writeErr != nil {
1601 _ = os.Remove(path)
1602 return writeErr
1603 }
1604 return nil
1605 }
1606
1607 if os.IsExist(err) {
1608 if time.Now().After(deadline) {
1609 return fmt.Errorf("deploy request file still present after timeout; another deploy may be in progress")
1610 }
1611 time.Sleep(100 * time.Millisecond)
1612 continue
1613 }
1614
1615 return err
1616 }
1617}
1618
1619// SendDeploySignal triggers a manual deploy for a specific route via SIGUSR2.
1620// Returns the domain name on success for the caller to display.
1621func SendDeploySignal(domain string) (string, error) {
1622 deployFile := getDeployRequestFile()
1623 if err := writeDeployRequestFile(deployFile, []byte(domain), 5*time.Second); err != nil {
1624 return "", fmt.Errorf("failed to write deploy request: %w", err)
1625 }
1626
1627 // Find PID and send SIGUSR2
1628 process, _, err := findRunningProcess()
1629 if err != nil {
1630 _ = os.Remove(deployFile)
1631 return "", err
1632 }
1633
1634 if err := process.Signal(syscall.SIGUSR2); err != nil {
1635 _ = os.Remove(deployFile)
1636 return "", fmt.Errorf("failed to send deploy signal: %w", err)
1637 }
1638
1639 return domain, nil
1640}
1641
1642// readDeployRequest reads and removes the deploy request file atomically.
1643// Returns empty string if file doesn't exist (may have been consumed by another handler).
1644func readDeployRequest() (string, error) {
1645 deployFile := getDeployRequestFile()
1646
1647 // Rename to a temp file first to make the read-and-delete atomic
1648 tmpFile := deployFile + ".processing"
1649 if err := os.Rename(deployFile, tmpFile); err != nil {
1650 if os.IsNotExist(err) {
1651 return "", fmt.Errorf("deploy request file not found (may have been processed already)")
1652 }
1653 return "", fmt.Errorf("failed to acquire deploy request: %w", err)
1654 }
1655
1656 data, err := os.ReadFile(tmpFile)
1657 _ = os.Remove(tmpFile) // Always clean up
1658 if err != nil {
1659 return "", fmt.Errorf("failed to read deploy request: %w", err)
1660 }
1661
1662 return string(data), nil
1663}
1664
1665// createPidFile creates a PID file for the Gordon process.
1666// SECURITY: Prefers secure locations (XDG_RUNTIME_DIR, ~/.gordon/run) over /tmp
1667// to prevent symlink attacks and unauthorized access.
1668func createPidFile(log zerowrap.Logger) string {
1669 pid := os.Getpid()
1670
1671 // SECURITY: Prioritize secure locations over /tmp
1672 var locations []string
1673
1674 // Try secure runtime directory first
1675 if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1676 locations = append(locations, filepath.Join(runtimeDir, "gordon.pid"))
1677 }
1678
1679 // Fall back to home directory
1680 if homeDir, err := os.UserHomeDir(); err == nil {
1681 locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid"))
1682 }
1683
1684 // Last resort: /tmp (least secure due to world-writable)
1685 locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid"))
1686
1687 for _, location := range locations {
1688 // Ensure parent directory exists with secure permissions
1689 if err := os.MkdirAll(filepath.Dir(location), 0700); err != nil {
1690 continue
1691 }
1692 if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil {
1693 log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file")
1694 return location
1695 }
1696 }
1697
1698 log.Warn().Int("pid", pid).Msg("failed to create PID file in any location")
1699 return ""
1700}
1701
1702// removePidFile removes the PID file.
1703func removePidFile(pidFile string, log zerowrap.Logger) {
1704 if err := os.Remove(pidFile); err != nil {
1705 log.Warn().Err(err).Str("pid_file", pidFile).Msg("failed to remove PID file")
1706 } else {
1707 log.Debug().Str("pid_file", pidFile).Msg("removed PID file")
1708 }
1709}
1710
1711func pidFileLocations() []string {
1712 var locations []string
1713 seen := make(map[string]struct{})
1714 add := func(path string) {
1715 if path == "" {
1716 return
1717 }
1718 if _, ok := seen[path]; ok {
1719 return
1720 }
1721 seen[path] = struct{}{}
1722 locations = append(locations, path)
1723 }
1724
1725 // Check secure runtime directory first
1726 if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1727 add(filepath.Join(runtimeDir, "gordon.pid"))
1728 }
1729
1730 // Also check canonical /run/user/<uid> runtime path. This handles cases where
1731 // Gordon started under systemd user services with runtime dir available, but
1732 // CLI invocations (e.g. non-interactive SSH) don't have XDG_RUNTIME_DIR set.
1733 runtimeByUID := filepath.Join("/run/user", strconv.Itoa(os.Getuid()), "gordon", "gordon.pid")
1734 add(runtimeByUID)
1735
1736 // Check explicit XDG_RUNTIME_DIR if present in this process env.
1737 if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
1738 add(filepath.Join(runtimeDir, "gordon.pid"))
1739 }
1740
1741 // Check home directory
1742 if homeDir, err := os.UserHomeDir(); err == nil {
1743 add(filepath.Join(homeDir, ".gordon", "gordon.pid"))
1744 // Legacy location for backward compatibility
1745 add(filepath.Join(homeDir, ".gordon.pid"))
1746 }
1747
1748 // Legacy /tmp locations for backward compatibility
1749 add(filepath.Join(os.TempDir(), "gordon.pid"))
1750 add("/tmp/gordon.pid")
1751
1752 return locations
1753}
1754
1755// findRunningPidFile returns the first PID file whose PID belongs to a live process.
1756// Stale/invalid PID files are ignored and removed when possible.
1757func findRunningPidFile() (string, int, error) {
1758 return findRunningPidFileInLocations(pidFileLocations())
1759}
1760
1761func findRunningPidFileInLocations(locations []string) (string, int, error) {
1762 foundAny := false
1763
1764 for _, location := range locations {
1765 pidBytes, err := os.ReadFile(location)
1766 if err != nil {
1767 continue
1768 }
1769
1770 foundAny = true
1771
1772 var pid int
1773 if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil || pid <= 0 {
1774 _ = os.Remove(location)
1775 continue
1776 }
1777
1778 if isProcessAlive(pid) {
1779 return location, pid, nil
1780 }
1781
1782 _ = os.Remove(location)
1783 }
1784
1785 if foundAny {
1786 return "", 0, fmt.Errorf("found stale gordon PID file(s), is Gordon running?")
1787 }
1788
1789 return "", 0, fmt.Errorf("gordon PID file not found, is Gordon running?")
1790}
1791
1792func findRunningProcess() (*os.Process, int, error) {
1793 _, pid, err := findRunningPidFile()
1794 if err != nil {
1795 return nil, 0, err
1796 }
1797
1798 process, err := os.FindProcess(pid)
1799 if err != nil {
1800 return nil, 0, fmt.Errorf("failed to find process: %w", err)
1801 }
1802
1803 return process, pid, nil
1804}
1805
1806func isProcessAlive(pid int) bool {
1807 process, err := os.FindProcess(pid)
1808 if err != nil {
1809 return false
1810 }
1811
1812 err = process.Signal(syscall.Signal(0))
1813 if err == nil {
1814 return true
1815 }
1816
1817 return errors.Is(err, syscall.EPERM)
1818}
1819
1820// loadConfig loads configuration from file and sets defaults.
1821func loadConfig(v *viper.Viper, configPath string) error {
1822 v.SetDefault("server.port", 80)
1823 v.SetDefault("server.registry_port", 5000)
1824 v.SetDefault("server.tls_enabled", false)
1825 v.SetDefault("server.tls_port", 443)
1826 v.SetDefault("server.tls_cert_file", "")
1827 v.SetDefault("server.tls_key_file", "")
1828 v.SetDefault("server.data_dir", DefaultDataDir())
1829 v.SetDefault("logging.level", "info")
1830 v.SetDefault("logging.format", "console")
1831 v.SetDefault("logging.file.enabled", false)
1832 v.SetDefault("logging.file.max_size", 100)
1833 v.SetDefault("logging.file.max_backups", 3)
1834 v.SetDefault("logging.file.max_age", 28)
1835 v.SetDefault("logging.container_logs.enabled", true)
1836 v.SetDefault("logging.container_logs.dir", "")
1837 v.SetDefault("logging.container_logs.max_size", 100)
1838 v.SetDefault("logging.container_logs.max_backups", 3)
1839 v.SetDefault("logging.container_logs.max_age", 28)
1840 v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
1841 v.SetDefault("auth.enabled", true)
1842 // Note: auth.type is intentionally not set - it's inferred from config
1843 // If password_hash is set -> password mode, otherwise -> token mode
1844 v.SetDefault("auth.secrets_backend", "unsafe")
1845 v.SetDefault("auth.token_expiry", "720h")
1846 v.SetDefault("api.rate_limit.enabled", true)
1847 v.SetDefault("api.rate_limit.global_rps", 500)
1848 v.SetDefault("api.rate_limit.per_ip_rps", 50)
1849 v.SetDefault("api.rate_limit.burst", 100)
1850 v.SetDefault("auto_route.enabled", false)
1851 v.SetDefault("network_isolation.enabled", false)
1852 v.SetDefault("network_isolation.network_prefix", "gordon")
1853 v.SetDefault("network_isolation.dns_suffix", ".internal")
1854 v.SetDefault("volumes.auto_create", true)
1855 v.SetDefault("volumes.prefix", "gordon")
1856 v.SetDefault("volumes.preserve", true)
1857 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
1858 v.SetDefault("backups.enabled", false)
1859 v.SetDefault("backups.storage_dir", "")
1860 v.SetDefault("backups.retention.hourly", 0)
1861 v.SetDefault("backups.retention.daily", 0)
1862 v.SetDefault("backups.retention.weekly", 0)
1863 v.SetDefault("backups.retention.monthly", 0)
1864 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
1865 v.SetDefault("server.registry_allowed_ips", []string{})
1866 v.SetDefault("deploy.readiness_delay", "5s")
1867 v.SetDefault("deploy.readiness_mode", "auto")
1868 v.SetDefault("deploy.health_timeout", "90s")
1869 v.SetDefault("deploy.drain_mode", "auto")
1870 v.SetDefault("deploy.drain_timeout", "30s")
1871
1872 ConfigureViper(v, configPath)
1873
1874 if err := v.ReadInConfig(); err != nil {
1875 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
1876 return fmt.Errorf("failed to read config file: %w", err)
1877 }
1878 }
1879
1880 v.SetEnvPrefix("GORDON")
1881 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
1882 v.AutomaticEnv()
1883
1884 return nil
1885}