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