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