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