1// Package app provides the application initialization and wiring.
2package app
3
4import (
5 "context"
6 "crypto/rand"
7 "encoding/hex"
8 "encoding/json"
9 "fmt"
10 "net"
11 "net/http"
12 "os"
13 "os/signal"
14 "path/filepath"
15 "strings"
16 "syscall"
17 "time"
18
19 "github.com/bnema/zerowrap"
20 "github.com/fsnotify/fsnotify"
21 "github.com/spf13/viper"
22
23 // Adapters - Output
24 "gordon/internal/adapters/out/docker"
25 "gordon/internal/adapters/out/domainsecrets"
26 "gordon/internal/adapters/out/envloader"
27 "gordon/internal/adapters/out/eventbus"
28 "gordon/internal/adapters/out/filesystem"
29 "gordon/internal/adapters/out/httpprober"
30 "gordon/internal/adapters/out/logwriter"
31 "gordon/internal/adapters/out/ratelimit"
32 "gordon/internal/adapters/out/secrets"
33 "gordon/internal/adapters/out/tokenstore"
34
35 // Adapters - Input
36 "gordon/internal/adapters/in/http/admin"
37 authhandler "gordon/internal/adapters/in/http/auth"
38 "gordon/internal/adapters/in/http/middleware"
39 "gordon/internal/adapters/in/http/registry"
40
41 // Boundaries
42 "gordon/internal/boundaries/out"
43
44 // Domain
45 "gordon/internal/domain"
46
47 // Use cases
48 "gordon/internal/usecase/auth"
49 "gordon/internal/usecase/config"
50 "gordon/internal/usecase/container"
51 "gordon/internal/usecase/health"
52 "gordon/internal/usecase/logs"
53 "gordon/internal/usecase/proxy"
54 registrySvc "gordon/internal/usecase/registry"
55 secretsSvc "gordon/internal/usecase/secrets"
56
57 // Pkg
58 "gordon/pkg/duration"
59)
60
61// Config holds the application configuration.
62type Config struct {
63 Server struct {
64 Port int `mapstructure:"port"`
65 RegistryPort int `mapstructure:"registry_port"`
66 GordonDomain string `mapstructure:"gordon_domain"`
67 RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
68 DataDir string `mapstructure:"data_dir"`
69 } `mapstructure:"server"`
70
71 Logging struct {
72 Level string `mapstructure:"level"`
73 Format string `mapstructure:"format"`
74 File struct {
75 Enabled bool `mapstructure:"enabled"`
76 Path string `mapstructure:"path"`
77 MaxSize int `mapstructure:"max_size"`
78 MaxBackups int `mapstructure:"max_backups"`
79 MaxAge int `mapstructure:"max_age"`
80 } `mapstructure:"file"`
81 ContainerLogs struct {
82 Enabled bool `mapstructure:"enabled"`
83 Dir string `mapstructure:"dir"`
84 MaxSize int `mapstructure:"max_size"`
85 MaxBackups int `mapstructure:"max_backups"`
86 MaxAge int `mapstructure:"max_age"`
87 } `mapstructure:"container_logs"`
88 } `mapstructure:"logging"`
89
90 Env struct {
91 Dir string `mapstructure:"dir"`
92 } `mapstructure:"env"`
93
94 Auth struct {
95 Enabled bool `mapstructure:"enabled"`
96 Type string `mapstructure:"type"` // "password" or "token"
97 SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe"
98 Username string `mapstructure:"username"`
99 Password string `mapstructure:"password"` // deprecated: use password_hash
100 PasswordHash string `mapstructure:"password_hash"` // path in secrets backend
101 TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
102 TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
103 } `mapstructure:"auth"`
104
105 API struct {
106 RateLimit struct {
107 Enabled bool `mapstructure:"enabled"`
108 GlobalRPS float64 `mapstructure:"global_rps"`
109 PerIPRPS float64 `mapstructure:"per_ip_rps"`
110 Burst int `mapstructure:"burst"`
111 TrustedProxies []string `mapstructure:"trusted_proxies"`
112 } `mapstructure:"rate_limit"`
113 } `mapstructure:"api"`
114}
115
116// services holds all the services used by the application.
117type services struct {
118 runtime *docker.Runtime
119 eventBus *eventbus.InMemory
120 blobStorage *filesystem.BlobStorage
121 manifestStorage *filesystem.ManifestStorage
122 envLoader *envloader.FileLoader
123 logWriter *logwriter.LogWriter
124 tokenStore out.TokenStore
125 configSvc *config.Service
126 containerSvc *container.Service
127 registrySvc *registrySvc.Service
128 proxySvc *proxy.Service
129 authSvc *auth.Service
130 authHandler *authhandler.Handler
131 adminHandler *admin.Handler
132 internalRegUser string
133 internalRegPass string
134 envDir string
135}
136
137// Run initializes and starts the Gordon application.
138func Run(ctx context.Context, configPath string) error {
139 // Load configuration
140 v, cfg, err := initConfig(configPath)
141 if err != nil {
142 return err
143 }
144
145 // Initialize logger
146 log, cleanup, err := initLogger(cfg)
147 if err != nil {
148 return err
149 }
150 if cleanup != nil {
151 defer cleanup()
152 }
153
154 ctx = zerowrap.WithCtx(ctx, log)
155 log.Info().Msg("Gordon starting")
156
157 // Create PID file
158 pidFile := createPidFile(log)
159 if pidFile != "" {
160 defer removePidFile(pidFile, log)
161 }
162
163 // Create all services
164 svc, err := createServices(ctx, v, cfg, log)
165 if err != nil {
166 return err
167 }
168
169 // Register event handlers
170 if err := registerEventHandlers(ctx, svc, cfg); err != nil {
171 return err
172 }
173
174 // Set up config hot reload
175 setupConfigHotReload(ctx, v, svc, log)
176
177 // Start event bus
178 if err := svc.eventBus.Start(); err != nil {
179 return log.WrapErr(err, "failed to start event bus")
180 }
181 defer svc.eventBus.Stop()
182
183 // Sync and auto-start containers
184 syncAndAutoStart(ctx, svc, log)
185
186 // Create HTTP handlers
187 registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)
188
189 // Start servers and wait for shutdown
190 return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, log)
191}
192
193// initConfig loads configuration from file.
194func initConfig(configPath string) (*viper.Viper, Config, error) {
195 v := viper.New()
196 if err := loadConfig(v, configPath); err != nil {
197 return nil, Config{}, fmt.Errorf("failed to load config: %w", err)
198 }
199
200 var cfg Config
201 if err := v.Unmarshal(&cfg); err != nil {
202 return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
203 }
204
205 // Normalize domain config: prefer gordon_domain over registry_domain
206 if cfg.Server.GordonDomain != "" {
207 cfg.Server.RegistryDomain = cfg.Server.GordonDomain
208 }
209
210 return v, cfg, nil
211}
212
213// initLogger initializes the zerowrap logger.
214func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
215 logConfig := zerowrap.Config{
216 Level: cfg.Logging.Level,
217 Format: cfg.Logging.Format,
218 }
219
220 if cfg.Logging.File.Enabled {
221 logPath := cfg.Logging.File.Path
222 if logPath == "" {
223 // Default to {data_dir}/logs/gordon.log
224 logPath = filepath.Join(cfg.Server.DataDir, "logs", "gordon.log")
225 }
226
227 log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{
228 Enabled: true,
229 Path: logPath,
230 MaxSize: cfg.Logging.File.MaxSize,
231 MaxBackups: cfg.Logging.File.MaxBackups,
232 MaxAge: cfg.Logging.File.MaxAge,
233 Compress: true,
234 })
235 if err != nil {
236 return zerowrap.Default(), nil, fmt.Errorf("failed to create logger with file: %w", err)
237 }
238 return log, cleanup, nil
239 }
240
241 return zerowrap.New(logConfig), nil, nil
242}
243
244// createServices creates all the application services.
245func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (*services, error) {
246 svc := &services{}
247 var err error
248
249 // Create output adapters
250 if svc.runtime, svc.eventBus, err = createOutputAdapters(ctx, log); err != nil {
251 return nil, err
252 }
253
254 // Create storage
255 if svc.blobStorage, svc.manifestStorage, err = createStorage(cfg, log); err != nil {
256 return nil, err
257 }
258
259 // Create env loader
260 if svc.envLoader, err = createEnvLoader(cfg, log); err != nil {
261 return nil, err
262 }
263
264 // Create log writer
265 if svc.logWriter, err = createLogWriter(cfg, log); err != nil {
266 return nil, err
267 }
268
269 // Create auth service (if enabled)
270 if svc.tokenStore, svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil {
271 return nil, err
272 }
273
274 // Generate internal registry credentials for loopback-only pulls
275 if cfg.Auth.Enabled {
276 svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
277 if err != nil {
278 return nil, log.WrapErr(err, "failed to generate internal registry credentials")
279 }
280
281 // Persist credentials to file for CLI access (gordon auth internal)
282 if err := persistInternalCredentials(svc.internalRegUser, svc.internalRegPass); err != nil {
283 log.Warn().Err(err).Msg("failed to persist internal credentials for CLI access")
284 }
285
286 log.Debug().Msg("internal registry auth generated for loopback pulls")
287 }
288
289 // Create use case services
290 svc.configSvc = config.NewService(v, svc.eventBus)
291 if err := svc.configSvc.Load(ctx); err != nil {
292 return nil, log.WrapErr(err, "failed to load configuration")
293 }
294
295 svc.containerSvc = createContainerService(v, cfg, svc)
296 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
297 svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{
298 RegistryDomain: cfg.Server.RegistryDomain,
299 RegistryPort: cfg.Server.RegistryPort,
300 })
301
302 // Create token handler for registry token endpoint
303 if svc.authSvc != nil {
304 internalAuth := authhandler.InternalAuth{
305 Username: svc.internalRegUser,
306 Password: svc.internalRegPass,
307 }
308 svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log)
309 }
310
311 // Determine env directory for admin API
312 dataDir := cfg.Server.DataDir
313 if dataDir == "" {
314 dataDir = DefaultDataDir()
315 }
316 svc.envDir = cfg.Env.Dir
317 if svc.envDir == "" {
318 svc.envDir = filepath.Join(dataDir, "env")
319 }
320
321 // Create domain secret store and service
322 domainSecretStore, err := domainsecrets.NewFileStore(svc.envDir, log)
323 if err != nil {
324 return nil, log.WrapErr(err, "failed to create domain secret store")
325 }
326 secretSvc := secretsSvc.NewService(domainSecretStore, log)
327
328 // Create health service for route health checking
329 prober := httpprober.New()
330 healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)
331
332 // Create log service for accessing logs via admin API
333 logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log)
334
335 // Create admin handler for admin API
336 svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.eventBus, log)
337
338 return svc, nil
339}
340
341// resolveLogFilePath returns the configured log file path or a default.
342func resolveLogFilePath(cfg Config) string {
343 if cfg.Logging.File.Path != "" {
344 return cfg.Logging.File.Path
345 }
346 if cfg.Logging.File.Enabled {
347 return filepath.Join(cfg.Server.DataDir, "logs", "gordon.log")
348 }
349 return ""
350}
351
352// createOutputAdapters creates the Docker runtime and event bus.
353func createOutputAdapters(ctx context.Context, log zerowrap.Logger) (*docker.Runtime, *eventbus.InMemory, error) {
354 runtime, err := docker.NewRuntime()
355 if err != nil {
356 return nil, nil, log.WrapErr(err, "failed to create Docker runtime")
357 }
358
359 if err := runtime.Ping(ctx); err != nil {
360 return nil, nil, log.WrapErr(err, "Docker is not available")
361 }
362
363 dockerVersion, _ := runtime.Version(ctx)
364 log.Info().Str("docker_version", dockerVersion).Msg("Docker runtime initialized")
365
366 eventBus := eventbus.NewInMemory(100, log)
367
368 return runtime, eventBus, nil
369}
370
371// createStorage creates blob and manifest storage.
372func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *filesystem.ManifestStorage, error) {
373 dataDir := cfg.Server.DataDir
374 if dataDir == "" {
375 dataDir = DefaultDataDir()
376 }
377
378 registryDir := filepath.Join(dataDir, "registry")
379
380 blobStorage, err := filesystem.NewBlobStorage(registryDir, log)
381 if err != nil {
382 return nil, nil, log.WrapErr(err, "failed to create blob storage")
383 }
384
385 manifestStorage, err := filesystem.NewManifestStorage(registryDir, log)
386 if err != nil {
387 return nil, nil, log.WrapErr(err, "failed to create manifest storage")
388 }
389
390 return blobStorage, manifestStorage, nil
391}
392
393// createEnvLoader creates the environment loader with secret providers.
394func createEnvLoader(cfg Config, log zerowrap.Logger) (*envloader.FileLoader, error) {
395 dataDir := cfg.Server.DataDir
396 if dataDir == "" {
397 dataDir = DefaultDataDir()
398 }
399
400 envDir := cfg.Env.Dir
401 if envDir == "" {
402 envDir = filepath.Join(dataDir, "env")
403 }
404
405 envLoader, err := envloader.NewFileLoader(envDir, log)
406 if err != nil {
407 return nil, log.WrapErr(err, "failed to create env loader")
408 }
409
410 // Register secret providers
411 passProvider := secrets.NewPassProvider(log)
412 if passProvider.IsAvailable() {
413 envLoader.RegisterSecretProvider(passProvider)
414 log.Debug().Msg("pass secret provider registered")
415 }
416
417 sopsProvider := secrets.NewSopsProvider(log)
418 if sopsProvider.IsAvailable() {
419 envLoader.RegisterSecretProvider(sopsProvider)
420 log.Debug().Msg("sops secret provider registered")
421 }
422
423 return envLoader, nil
424}
425
426// createLogWriter creates the container log writer.
427func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, error) {
428 if !cfg.Logging.ContainerLogs.Enabled {
429 log.Debug().Msg("container log collection disabled")
430 return nil, nil
431 }
432
433 // Determine log directory
434 logDir := cfg.Logging.ContainerLogs.Dir
435 if logDir == "" {
436 dataDir := cfg.Server.DataDir
437 if dataDir == "" {
438 dataDir = DefaultDataDir()
439 }
440 logDir = filepath.Join(dataDir, "logs", "containers")
441 }
442
443 writer, err := logwriter.New(logwriter.Config{
444 Dir: logDir,
445 MaxSize: cfg.Logging.ContainerLogs.MaxSize,
446 MaxBackups: cfg.Logging.ContainerLogs.MaxBackups,
447 MaxAge: cfg.Logging.ContainerLogs.MaxAge,
448 })
449 if err != nil {
450 return nil, log.WrapErr(err, "failed to create container log writer")
451 }
452
453 log.Info().Str("dir", logDir).Msg("container log collection enabled")
454 return writer, nil
455}
456
457const internalRegistryUsername = "gordon-internal"
458
459func generateInternalRegistryAuth() (string, string, error) {
460 password, err := randomTokenHex(32)
461 if err != nil {
462 return "", "", err
463 }
464 return internalRegistryUsername, password, nil
465}
466
467func randomTokenHex(size int) (string, error) {
468 buf := make([]byte, size)
469 if _, err := rand.Read(buf); err != nil {
470 return "", err
471 }
472 return hex.EncodeToString(buf), nil
473}
474
475// InternalCredentials holds the internal registry credentials for CLI access.
476type InternalCredentials struct {
477 Username string `json:"username"`
478 Password string `json:"password"`
479}
480
481// getSecureRuntimeDir returns a secure directory for runtime files.
482// Priority: XDG_RUNTIME_DIR > ~/.gordon/run
483func getSecureRuntimeDir() (string, error) {
484 // Try XDG_RUNTIME_DIR first (typically /run/user/<uid> on Linux)
485 if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
486 gordonDir := filepath.Join(runtimeDir, "gordon")
487 if err := os.MkdirAll(gordonDir, 0700); err == nil {
488 return gordonDir, nil
489 }
490 }
491
492 // Fall back to ~/.gordon/run
493 homeDir, err := os.UserHomeDir()
494 if err != nil {
495 return "", fmt.Errorf("failed to get home directory: %w", err)
496 }
497
498 gordonDir := filepath.Join(homeDir, ".gordon", "run")
499 if err := os.MkdirAll(gordonDir, 0700); err != nil {
500 return "", fmt.Errorf("failed to create runtime directory: %w", err)
501 }
502
503 return gordonDir, nil
504}
505
506// getInternalCredentialsFile returns the path to the internal credentials file.
507// SECURITY: Credentials are stored in a secure location with restricted permissions.
508func getInternalCredentialsFile() string {
509 runtimeDir, err := getSecureRuntimeDir()
510 if err != nil {
511 // Fall back to temp dir if we can't get secure dir (shouldn't happen)
512 return filepath.Join(os.TempDir(), "gordon-internal-creds.json")
513 }
514 return filepath.Join(runtimeDir, "internal-creds.json")
515}
516
517// persistInternalCredentials saves the internal registry credentials to a secure file.
518// SECURITY: Credentials are stored in XDG_RUNTIME_DIR or ~/.gordon/run with 0600 permissions.
519// The file is cleaned up on graceful shutdown but may persist if Gordon crashes.
520// These credentials are for internal loopback communication only and are regenerated on each start.
521func persistInternalCredentials(username, password string) error {
522 creds := InternalCredentials{
523 Username: username,
524 Password: password,
525 }
526 data, err := json.Marshal(creds)
527 if err != nil {
528 return fmt.Errorf("failed to marshal credentials: %w", err)
529 }
530
531 credFile := getInternalCredentialsFile()
532
533 // Ensure parent directory exists with secure permissions
534 if err := os.MkdirAll(filepath.Dir(credFile), 0700); err != nil {
535 return fmt.Errorf("failed to create credentials directory: %w", err)
536 }
537
538 // Write file with restrictive permissions (owner read/write only)
539 if err := os.WriteFile(credFile, data, 0600); err != nil {
540 return fmt.Errorf("failed to write credentials file: %w", err)
541 }
542 return nil
543}
544
545// cleanupInternalCredentials removes the internal credentials file.
546func cleanupInternalCredentials() {
547 _ = os.Remove(getInternalCredentialsFile())
548}
549
550// GetInternalCredentials reads the internal registry credentials from file.
551// This is used by the CLI to display credentials for manual recovery.
552func GetInternalCredentials() (*InternalCredentials, error) {
553 credFile := getInternalCredentialsFile()
554 data, err := os.ReadFile(credFile)
555 if err != nil {
556 return nil, fmt.Errorf("failed to read credentials file (is Gordon running?): %w", err)
557 }
558 var creds InternalCredentials
559 if err := json.Unmarshal(data, &creds); err != nil {
560 return nil, fmt.Errorf("failed to parse credentials: %w", err)
561 }
562 return &creds, nil
563}
564
565// createAuthService creates the authentication service and token store.
566func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
567 if !cfg.Auth.Enabled {
568 return nil, nil, nil
569 }
570
571 authType := resolveAuthType(cfg)
572 backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)
573 dataDir := resolveDataDir(cfg.Server.DataDir)
574
575 store, err := createTokenStore(backend, dataDir, log)
576 if err != nil {
577 return nil, nil, err
578 }
579
580 authConfig, err := buildAuthConfig(ctx, cfg, authType, backend, dataDir, log)
581 if err != nil {
582 return nil, nil, err
583 }
584
585 authSvc := auth.NewService(authConfig, store, log)
586
587 log.Info().
588 Str("type", string(authType)).
589 Str("backend", string(backend)).
590 Msg("registry authentication enabled")
591
592 return store, authSvc, nil
593}
594
595// resolveAuthType determines the auth type from config.
596// If password_hash is configured, password auth is available (plus tokens).
597// If only token_secret is configured, token-only mode.
598// The explicit "type" field is deprecated but still respected for backwards compat.
599func resolveAuthType(cfg Config) domain.AuthType {
600 // Explicit type takes precedence (backwards compatibility)
601 if cfg.Auth.Type == "token" {
602 return domain.AuthTypeToken
603 }
604 if cfg.Auth.Type == "password" {
605 return domain.AuthTypePassword
606 }
607
608 // Infer from config: password auth if password_hash is configured
609 if cfg.Auth.PasswordHash != "" || cfg.Auth.Password != "" {
610 return domain.AuthTypePassword
611 }
612
613 // Default to token-only
614 return domain.AuthTypeToken
615}
616
617func resolveSecretsBackend(backend string) domain.SecretsBackend {
618 switch backend {
619 case "pass":
620 return domain.SecretsBackendPass
621 case "sops":
622 return domain.SecretsBackendSops
623 case "unsafe", "":
624 return domain.SecretsBackendUnsafe
625 default:
626 return domain.SecretsBackendUnsafe
627 }
628}
629
630func resolveDataDir(dataDir string) string {
631 if dataDir == "" {
632 return DefaultDataDir()
633 }
634 return dataDir
635}
636
637func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
638 // Token store is always created since tokens work in both auth modes
639 store, err := tokenstore.NewStore(backend, dataDir, log)
640 if err != nil {
641 return nil, log.WrapErr(err, "failed to create token store")
642 }
643 return store, nil
644}
645
646func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
647 authConfig := auth.Config{
648 Enabled: cfg.Auth.Enabled,
649 AuthType: authType,
650 Username: cfg.Auth.Username,
651 }
652
653 // Token config is always required (tokens work in all auth modes)
654 secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log)
655 if err != nil {
656 return auth.Config{}, err
657 }
658 authConfig.TokenSecret = secret
659 authConfig.TokenExpiry = expiry
660
661 // Password config only needed for password auth mode
662 if authType == domain.AuthTypePassword {
663 hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log)
664 if err != nil {
665 return auth.Config{}, err
666 }
667 if hash == "" {
668 return auth.Config{}, errAuthNotConfigured()
669 }
670 authConfig.PasswordHash = hash
671 }
672
673 return authConfig, nil
674}
675
676// errAuthNotConfigured returns an error when auth is enabled but credentials are not configured.
677func errAuthNotConfigured() error {
678 return fmt.Errorf("auth is enabled by default, configure auth type and secrets backend. See: https://gordon.bnema.dev/docs/config/auth")
679}
680
681func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) {
682 if cfg.Auth.PasswordHash != "" {
683 hash, err := loadSecret(ctx, backend, cfg.Auth.PasswordHash, dataDir, log)
684 if err != nil {
685 return "", log.WrapErr(err, "failed to load password hash")
686 }
687 return hash, nil
688 }
689
690 if cfg.Auth.Password != "" {
691 log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend")
692 return cfg.Auth.Password, nil
693 }
694
695 return "", nil
696}
697
698func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) {
699 secret, err := loadTokenSecret(ctx, cfg, backend, dataDir, log)
700 if err != nil {
701 return nil, 0, err
702 }
703
704 expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
705 if err != nil {
706 return nil, 0, err
707 }
708
709 return secret, expiry, nil
710}
711
712// TokenSecretEnvVar is the environment variable for the JWT signing secret.
713// SECURITY: This takes priority over config file to allow secure secret injection.
714const TokenSecretEnvVar = "GORDON_AUTH_TOKEN_SECRET" //nolint:gosec // This is an env var name, not a credential
715
716func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {
717 // SECURITY: Priority order for token secret:
718 // 1. Environment variable (most secure - no disk exposure)
719 // 2. Secrets backend (pass/sops - encrypted)
720 // 3. Config file path (least preferred)
721
722 // Check environment variable first
723 if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" {
724 log.Debug().Msg("using token secret from environment variable")
725 return []byte(envSecret), nil
726 }
727
728 // Fall back to config-specified path via secrets backend
729 if cfg.Auth.TokenSecret == "" {
730 return nil, fmt.Errorf("token_secret is required for JWT token generation; set %s environment variable or configure auth.token_secret", TokenSecretEnvVar)
731 }
732
733 secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log)
734 if err != nil {
735 return nil, log.WrapErr(err, "failed to load token secret")
736 }
737
738 return []byte(secret), nil
739}
740
741func parseTokenExpiry(expiry string) (time.Duration, error) {
742 if expiry == "" {
743 return 0, nil
744 }
745
746 parsed, err := duration.Parse(expiry)
747 if err != nil {
748 return 0, fmt.Errorf("invalid token_expiry: %w", err)
749 }
750
751 return parsed, nil
752}
753
754// loadSecret loads a secret from the configured backend.
755func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) {
756 switch backend {
757 case domain.SecretsBackendPass:
758 provider := secrets.NewPassProvider(log)
759 return provider.GetSecret(ctx, path)
760 case domain.SecretsBackendSops:
761 provider := secrets.NewSopsProvider(log)
762 return provider.GetSecret(ctx, path)
763 case domain.SecretsBackendUnsafe:
764 // For unsafe backend, path is relative to dataDir/secrets/
765 secretFile := filepath.Join(dataDir, "secrets", path)
766 data, err := os.ReadFile(secretFile)
767 if err != nil {
768 return "", fmt.Errorf("failed to read secret file: %w", err)
769 }
770 return string(data), nil
771 default:
772 return "", fmt.Errorf("unknown secrets backend: %s", backend)
773 }
774}
775
776// createContainerService creates the container service with configuration.
777func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service {
778 containerConfig := container.Config{
779 RegistryAuthEnabled: cfg.Auth.Enabled,
780 RegistryDomain: cfg.Server.RegistryDomain,
781 RegistryPort: cfg.Server.RegistryPort,
782 RegistryUsername: cfg.Auth.Username,
783 RegistryPassword: cfg.Auth.Password,
784 InternalRegistryUsername: svc.internalRegUser,
785 InternalRegistryPassword: svc.internalRegPass,
786 PullPolicy: v.GetString("deploy.pull_policy"),
787 VolumeAutoCreate: v.GetBool("volumes.auto_create"),
788 VolumePrefix: v.GetString("volumes.prefix"),
789 VolumePreserve: v.GetBool("volumes.preserve"),
790 NetworkIsolation: v.GetBool("network_isolation.enabled"),
791 NetworkPrefix: v.GetString("network_isolation.network_prefix"),
792 DNSSuffix: v.GetString("network_isolation.dns_suffix"),
793 NetworkGroups: svc.configSvc.GetNetworkGroups(),
794 Attachments: svc.configSvc.GetAttachments(),
795 }
796 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig)
797}
798
799// registerEventHandlers registers all event handlers.
800func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error {
801 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
802 if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil {
803 return fmt.Errorf("failed to subscribe image pushed handler: %w", err)
804 }
805
806 // Auto-route handler for creating routes from image labels
807 autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain).
808 WithEnvExtractor(svc.runtime, svc.envDir)
809 if err := svc.eventBus.Subscribe(autoRouteHandler); err != nil {
810 return fmt.Errorf("failed to subscribe auto-route handler: %w", err)
811 }
812
813 configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)
814 if err := svc.eventBus.Subscribe(configReloadHandler); err != nil {
815 return fmt.Errorf("failed to subscribe config reload handler: %w", err)
816 }
817
818 manualReloadHandler := container.NewManualReloadHandler(ctx, svc.containerSvc, svc.configSvc)
819 if err := svc.eventBus.Subscribe(manualReloadHandler); err != nil {
820 return fmt.Errorf("failed to subscribe manual reload handler: %w", err)
821 }
822
823 manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc)
824 if err := svc.eventBus.Subscribe(manualDeployHandler); err != nil {
825 return fmt.Errorf("failed to subscribe manual deploy handler: %w", err)
826 }
827
828 // Proxy cache invalidation on container deployment (for zero-downtime)
829 containerDeployedHandler := proxy.NewContainerDeployedHandler(ctx, svc.proxySvc)
830 if err := svc.eventBus.Subscribe(containerDeployedHandler); err != nil {
831 return fmt.Errorf("failed to subscribe container deployed handler: %w", err)
832 }
833
834 return nil
835}
836
837// setupConfigHotReload sets up Viper config hot reload.
838// NOTE: This does NOT reload routes into memory. Routes are managed via API
839// (AddRoute/UpdateRoute/RemoveRoute) and memory is the source of truth.
840// The file watcher only updates proxy config and refreshes targets.
841// Manual config file edits to routes require a server restart.
842func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) {
843 v.OnConfigChange(func(e fsnotify.Event) {
844 log.Info().Str("file", e.Name).Msg("config file changed")
845
846 if err := v.ReadInConfig(); err != nil {
847 log.Error().Err(err).Msg("failed to reload config")
848 return
849 }
850
851 // Update proxy config from viper (reads directly from viper, not memory)
852 svc.proxySvc.UpdateConfig(proxy.Config{
853 RegistryDomain: v.GetString("server.registry_domain"),
854 RegistryPort: v.GetInt("server.registry_port"),
855 })
856
857 // Clear proxy target cache to pick up external route changes
858 if err := svc.proxySvc.RefreshTargets(ctx); err != nil {
859 log.Warn().Err(err).Msg("failed to refresh proxy targets")
860 }
861
862 log.Debug().Msg("config hot reload complete (routes unchanged, memory is source of truth)")
863 })
864 v.WatchConfig()
865}
866
867// syncAndAutoStart syncs existing containers and auto-starts if configured.
868func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
869 if err := svc.containerSvc.SyncContainers(ctx); err != nil {
870 log.Warn().Err(err).Msg("failed to sync existing containers")
871 }
872
873 if svc.configSvc.IsAutoRouteEnabled() {
874 routes := svc.configSvc.GetRoutes(ctx)
875 if err := svc.containerSvc.AutoStart(ctx, routes); err != nil {
876 log.Warn().Err(err).Msg("failed to auto-start containers")
877 }
878 }
879}
880
881// createHTTPHandlers creates HTTP handlers with middleware.
882func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) {
883 registryHandler := registry.NewHandler(svc.registrySvc, log)
884
885 registryMiddlewares := []func(http.Handler) http.Handler{
886 middleware.PanicRecovery(log),
887 middleware.RequestLogger(log),
888 middleware.SecurityHeaders,
889 }
890
891 // Create rate limiters using the factory
892 var rateLimitMiddleware func(http.Handler) http.Handler
893 if cfg.API.RateLimit.Enabled {
894 globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
895 ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
896 rateLimitMiddleware = registry.RateLimitMiddleware(
897 globalLimiter,
898 ipLimiter,
899 cfg.API.RateLimit.TrustedProxies,
900 log,
901 )
902 } else {
903 rateLimitMiddleware = registry.RateLimitMiddleware(nil, nil, nil, log)
904 }
905 registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
906
907 if cfg.Auth.Enabled && svc.authSvc != nil {
908 internalAuth := middleware.InternalRegistryAuth{
909 Username: svc.internalRegUser,
910 Password: svc.internalRegPass,
911 }
912 registryMiddlewares = append(registryMiddlewares,
913 middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
914 }
915
916 registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler)
917
918 // Create a mux that routes:
919 // - /auth/* to auth handler (no auth required, for password login and token exchange)
920 // - /v2/* to registry handler (with auth)
921 // - /admin/* to admin handler (with auth)
922 registryMux := http.NewServeMux()
923 if svc.authHandler != nil {
924 // Auth endpoints are NOT protected by auth - they're where clients authenticate
925 // but still need rate limiting to prevent brute force attacks
926 authWithMiddleware := middleware.Chain(
927 middleware.PanicRecovery(log),
928 middleware.RequestLogger(log),
929 middleware.SecurityHeaders,
930 rateLimitMiddleware,
931 )(svc.authHandler)
932 registryMux.Handle("/auth/", authWithMiddleware)
933 }
934 registryMux.Handle("/v2/", registryWithMiddleware)
935
936 // Add admin API handler with auth middleware
937 if svc.adminHandler != nil {
938 adminMiddlewares := []func(http.Handler) http.Handler{
939 middleware.PanicRecovery(log),
940 middleware.RequestLogger(log),
941 middleware.SecurityHeaders,
942 }
943 // Add admin auth middleware if auth is enabled
944 if svc.authSvc != nil {
945 // Create rate limiters for admin API - uses same config as registry
946 var globalLimiter, ipLimiter out.RateLimiter
947 var trustedNets []*net.IPNet
948 if cfg.API.RateLimit.Enabled {
949 globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
950 ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
951 trustedNets = middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
952 }
953 adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log))
954 }
955 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
956 registryMux.Handle("/admin/", adminWithMiddleware)
957 }
958
959 proxyMiddlewares := []func(http.Handler) http.Handler{
960 middleware.PanicRecovery(log),
961 middleware.RequestLogger(log),
962 middleware.SecurityHeaders,
963 middleware.CORS,
964 }
965
966 proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc)
967
968 return registryMux, proxyWithMiddleware
969}
970
971// runServers starts the HTTP servers and waits for shutdown.
972// Signal handling notes:
973// - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext
974// - SIGUSR1: Triggers config reload without restart
975// - SIGUSR2: Triggers manual deploy for a specific route
976// The deferred signal.Stop calls ensure signal handlers are properly
977// cleaned up before program exit, preventing signal handler leaks.
978func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, log zerowrap.Logger) error {
979 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
980 defer cancel()
981
982 // Set up SIGUSR1 for reload.
983 // Note: signal.Stop must be called (via defer) to release the channel
984 // and prevent signal handler leaks when the function returns.
985 reloadChan := make(chan os.Signal, 1)
986 signal.Notify(reloadChan, syscall.SIGUSR1)
987 defer signal.Stop(reloadChan)
988
989 // Set up SIGUSR2 for manual deploy.
990 deployChan := make(chan os.Signal, 1)
991 signal.Notify(deployChan, syscall.SIGUSR2)
992 defer signal.Stop(deployChan)
993
994 errChan := make(chan error, 2)
995
996 go startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
997 go startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
998
999 log.Info().
1000 Int("proxy_port", cfg.Server.Port).
1001 Int("registry_port", cfg.Server.RegistryPort).
1002 Msg("Gordon is running")
1003
1004 for {
1005 select {
1006 case err := <-errChan:
1007 return err
1008 case <-reloadChan:
1009 log.Info().Msg("reload signal received (SIGUSR1)")
1010 if err := eventBus.Publish(domain.EventManualReload, nil); err != nil {
1011 log.Error().Err(err).Msg("failed to publish manual reload event")
1012 }
1013 case <-deployChan:
1014 log.Info().Msg("deploy signal received (SIGUSR2)")
1015 domainName, err := readDeployRequest()
1016 if err != nil {
1017 log.Error().Err(err).Msg("failed to read deploy request")
1018 continue
1019 }
1020 payload := &domain.ManualDeployPayload{Domain: domainName}
1021 if err := eventBus.Publish(domain.EventManualDeploy, payload); err != nil {
1022 log.Error().Err(err).Str("domain", domainName).Msg("failed to publish manual deploy event")
1023 }
1024 case <-ctx.Done():
1025 log.Info().Msg("shutdown signal received")
1026 goto shutdown
1027 }
1028 }
1029
1030shutdown:
1031 log.Info().Msg("shutting down Gordon...")
1032
1033 if err := containerSvc.Shutdown(ctx); err != nil {
1034 log.Warn().Err(err).Msg("error during container shutdown")
1035 }
1036
1037 // Clean up internal credentials file
1038 cleanupInternalCredentials()
1039
1040 log.Info().Msg("Gordon stopped")
1041 return nil
1042}
1043
1044// startServer starts an HTTP server.
1045func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) {
1046 log.Info().Str("address", addr).Msgf("%s server starting", name)
1047
1048 server := &http.Server{
1049 Addr: addr,
1050 Handler: handler,
1051 ReadHeaderTimeout: 10 * time.Second,
1052 ReadTimeout: 5 * time.Minute, // Timeout for reading entire request
1053 WriteTimeout: 5 * time.Minute, // Timeout for writing response
1054 IdleTimeout: 120 * time.Second, // Timeout for idle keep-alive connections
1055 MaxHeaderBytes: 1 << 20, // 1MB max header size
1056 }
1057
1058 if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
1059 errChan <- fmt.Errorf("%s server error: %w", name, err)
1060 }
1061}
1062
1063// SendReloadSignal sends SIGUSR1 to the running Gordon process.
1064func SendReloadSignal() error {
1065 pidFile := findPidFile()
1066 if pidFile == "" {
1067 return fmt.Errorf("gordon PID file not found, is Gordon running?")
1068 }
1069
1070 pidBytes, err := os.ReadFile(pidFile)
1071 if err != nil {
1072 return fmt.Errorf("failed to read PID file: %w", err)
1073 }
1074
1075 var pid int
1076 if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil {
1077 return fmt.Errorf("failed to parse PID: %w", err)
1078 }
1079
1080 process, err := os.FindProcess(pid)
1081 if err != nil {
1082 return fmt.Errorf("failed to find process: %w", err)
1083 }
1084
1085 if err := process.Signal(syscall.SIGUSR1); err != nil {
1086 return fmt.Errorf("failed to send reload signal: %w", err)
1087 }
1088
1089 return nil
1090}
1091
1092// getDeployRequestFile returns the path to the deploy request file.
1093func getDeployRequestFile() string {
1094 return filepath.Join(os.TempDir(), "gordon-deploy-request")
1095}
1096
1097// writeDeployRequestFile creates the deploy request file exclusively with retry.
1098// This prevents race conditions when multiple deploy commands run simultaneously.
1099func writeDeployRequestFile(path string, data []byte, timeout time.Duration) error {
1100 deadline := time.Now().Add(timeout)
1101
1102 for {
1103 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
1104 if err == nil {
1105 _, writeErr := f.Write(data)
1106 f.Close()
1107 if writeErr != nil {
1108 _ = os.Remove(path)
1109 return writeErr
1110 }
1111 return nil
1112 }
1113
1114 if os.IsExist(err) {
1115 if time.Now().After(deadline) {
1116 return fmt.Errorf("deploy request file still present after timeout; another deploy may be in progress")
1117 }
1118 time.Sleep(100 * time.Millisecond)
1119 continue
1120 }
1121
1122 return err
1123 }
1124}
1125
1126// SendDeploySignal triggers a manual deploy for a specific route via SIGUSR2.
1127// Returns the domain name on success for the caller to display.
1128func SendDeploySignal(domain string) (string, error) {
1129 deployFile := getDeployRequestFile()
1130 if err := writeDeployRequestFile(deployFile, []byte(domain), 5*time.Second); err != nil {
1131 return "", fmt.Errorf("failed to write deploy request: %w", err)
1132 }
1133
1134 // Find PID and send SIGUSR2
1135 pidFile := findPidFile()
1136 if pidFile == "" {
1137 _ = os.Remove(deployFile)
1138 return "", fmt.Errorf("gordon PID file not found, is Gordon running?")
1139 }
1140
1141 pidBytes, err := os.ReadFile(pidFile)
1142 if err != nil {
1143 _ = os.Remove(deployFile)
1144 return "", fmt.Errorf("failed to read PID file: %w", err)
1145 }
1146
1147 var pid int
1148 if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil {
1149 _ = os.Remove(deployFile)
1150 return "", fmt.Errorf("failed to parse PID: %w", err)
1151 }
1152
1153 process, err := os.FindProcess(pid)
1154 if err != nil {
1155 _ = os.Remove(deployFile)
1156 return "", fmt.Errorf("failed to find process: %w", err)
1157 }
1158
1159 if err := process.Signal(syscall.SIGUSR2); err != nil {
1160 _ = os.Remove(deployFile)
1161 return "", fmt.Errorf("failed to send deploy signal: %w", err)
1162 }
1163
1164 return domain, nil
1165}
1166
1167// readDeployRequest reads and removes the deploy request file atomically.
1168// Returns empty string if file doesn't exist (may have been consumed by another handler).
1169func readDeployRequest() (string, error) {
1170 deployFile := getDeployRequestFile()
1171
1172 // Rename to a temp file first to make the read-and-delete atomic
1173 tmpFile := deployFile + ".processing"
1174 if err := os.Rename(deployFile, tmpFile); err != nil {
1175 if os.IsNotExist(err) {
1176 return "", fmt.Errorf("deploy request file not found (may have been processed already)")
1177 }
1178 return "", fmt.Errorf("failed to acquire deploy request: %w", err)
1179 }
1180
1181 data, err := os.ReadFile(tmpFile)
1182 _ = os.Remove(tmpFile) // Always clean up
1183 if err != nil {
1184 return "", fmt.Errorf("failed to read deploy request: %w", err)
1185 }
1186
1187 return string(data), nil
1188}
1189
1190// createPidFile creates a PID file for the Gordon process.
1191// SECURITY: Prefers secure locations (XDG_RUNTIME_DIR, ~/.gordon/run) over /tmp
1192// to prevent symlink attacks and unauthorized access.
1193func createPidFile(log zerowrap.Logger) string {
1194 pid := os.Getpid()
1195
1196 // SECURITY: Prioritize secure locations over /tmp
1197 var locations []string
1198
1199 // Try secure runtime directory first
1200 if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1201 locations = append(locations, filepath.Join(runtimeDir, "gordon.pid"))
1202 }
1203
1204 // Fall back to home directory
1205 if homeDir, err := os.UserHomeDir(); err == nil {
1206 locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid"))
1207 }
1208
1209 // Last resort: /tmp (least secure due to world-writable)
1210 locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid"))
1211
1212 for _, location := range locations {
1213 // Ensure parent directory exists with secure permissions
1214 if err := os.MkdirAll(filepath.Dir(location), 0700); err != nil {
1215 continue
1216 }
1217 if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil {
1218 log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file")
1219 return location
1220 }
1221 }
1222
1223 log.Warn().Int("pid", pid).Msg("failed to create PID file in any location")
1224 return ""
1225}
1226
1227// removePidFile removes the PID file.
1228func removePidFile(pidFile string, log zerowrap.Logger) {
1229 if err := os.Remove(pidFile); err != nil {
1230 log.Warn().Err(err).Str("pid_file", pidFile).Msg("failed to remove PID file")
1231 } else {
1232 log.Debug().Str("pid_file", pidFile).Msg("removed PID file")
1233 }
1234}
1235
1236// findPidFile finds the Gordon PID file.
1237// Checks secure locations first, then falls back to legacy /tmp location.
1238func findPidFile() string {
1239 var locations []string
1240
1241 // Check secure runtime directory first
1242 if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1243 locations = append(locations, filepath.Join(runtimeDir, "gordon.pid"))
1244 }
1245
1246 // Check home directory
1247 if homeDir, err := os.UserHomeDir(); err == nil {
1248 locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid"))
1249 // Legacy location for backward compatibility
1250 locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
1251 }
1252
1253 // Legacy /tmp locations for backward compatibility
1254 locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid"))
1255 locations = append(locations, "/tmp/gordon.pid")
1256
1257 for _, location := range locations {
1258 if _, err := os.Stat(location); err == nil {
1259 return location
1260 }
1261 }
1262
1263 return ""
1264}
1265
1266// loadConfig loads configuration from file and sets defaults.
1267func loadConfig(v *viper.Viper, configPath string) error {
1268 v.SetDefault("server.port", 80)
1269 v.SetDefault("server.registry_port", 5000)
1270 v.SetDefault("server.data_dir", DefaultDataDir())
1271 v.SetDefault("logging.level", "info")
1272 v.SetDefault("logging.format", "console")
1273 v.SetDefault("logging.file.enabled", false)
1274 v.SetDefault("logging.file.max_size", 100)
1275 v.SetDefault("logging.file.max_backups", 3)
1276 v.SetDefault("logging.file.max_age", 28)
1277 v.SetDefault("logging.container_logs.enabled", true)
1278 v.SetDefault("logging.container_logs.dir", "")
1279 v.SetDefault("logging.container_logs.max_size", 100)
1280 v.SetDefault("logging.container_logs.max_backups", 3)
1281 v.SetDefault("logging.container_logs.max_age", 28)
1282 v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
1283 v.SetDefault("auth.enabled", true)
1284 // Note: auth.type is intentionally not set - it's inferred from config
1285 // If password_hash is set -> password mode, otherwise -> token mode
1286 v.SetDefault("auth.secrets_backend", "unsafe")
1287 v.SetDefault("auth.token_expiry", "720h")
1288 v.SetDefault("api.rate_limit.enabled", true)
1289 v.SetDefault("api.rate_limit.global_rps", 500)
1290 v.SetDefault("api.rate_limit.per_ip_rps", 50)
1291 v.SetDefault("api.rate_limit.burst", 100)
1292 v.SetDefault("auto_route.enabled", false)
1293 v.SetDefault("network_isolation.enabled", false)
1294 v.SetDefault("network_isolation.network_prefix", "gordon")
1295 v.SetDefault("network_isolation.dns_suffix", ".internal")
1296 v.SetDefault("volumes.auto_create", true)
1297 v.SetDefault("volumes.prefix", "gordon")
1298 v.SetDefault("volumes.preserve", true)
1299 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
1300
1301 ConfigureViper(v, configPath)
1302
1303 if err := v.ReadInConfig(); err != nil {
1304 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
1305 return fmt.Errorf("failed to read config file: %w", err)
1306 }
1307 }
1308
1309 v.SetEnvPrefix("GORDON")
1310 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
1311 v.AutomaticEnv()
1312
1313 return nil
1314}