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