1// Package app provides the application initialization and wiring.
2package app
3
4import (
5 "context"
6 "crypto/ecdsa"
7 "crypto/elliptic"
8 "crypto/rand"
9 "crypto/x509"
10 "crypto/x509/pkix"
11 "encoding/hex"
12 "encoding/json"
13 "encoding/pem"
14 "errors"
15 "fmt"
16 "math/big"
17 "net"
18 "net/http"
19 "os"
20 "os/signal"
21 "path/filepath"
22 "strconv"
23 "strings"
24 "syscall"
25 "time"
26
27 "github.com/bnema/zerowrap"
28 "github.com/fsnotify/fsnotify"
29 "github.com/spf13/viper"
30
31 // Adapters - Output
32 "github.com/bnema/gordon/internal/adapters/out/docker"
33 "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
34 "github.com/bnema/gordon/internal/adapters/out/envloader"
35 "github.com/bnema/gordon/internal/adapters/out/eventbus"
36 "github.com/bnema/gordon/internal/adapters/out/filesystem"
37 "github.com/bnema/gordon/internal/adapters/out/httpprober"
38 "github.com/bnema/gordon/internal/adapters/out/logwriter"
39 "github.com/bnema/gordon/internal/adapters/out/ratelimit"
40 "github.com/bnema/gordon/internal/adapters/out/secrets"
41 "github.com/bnema/gordon/internal/adapters/out/tokenstore"
42
43 // Adapters - Input
44 "github.com/bnema/gordon/internal/adapters/in/http/admin"
45 authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth"
46 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
47 "github.com/bnema/gordon/internal/adapters/in/http/registry"
48
49 // Boundaries
50 "github.com/bnema/gordon/internal/boundaries/out"
51
52 // Domain
53 "github.com/bnema/gordon/internal/domain"
54
55 // Use cases
56 "github.com/bnema/gordon/internal/usecase/auth"
57 "github.com/bnema/gordon/internal/usecase/config"
58 "github.com/bnema/gordon/internal/usecase/container"
59 "github.com/bnema/gordon/internal/usecase/health"
60 "github.com/bnema/gordon/internal/usecase/logs"
61 "github.com/bnema/gordon/internal/usecase/proxy"
62 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
63 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
64
65 // Pkg
66 "github.com/bnema/gordon/pkg/bytesize"
67 "github.com/bnema/gordon/pkg/duration"
68)
69
70// Config holds the application configuration.
71type Config struct {
72 Server struct {
73 Port int `mapstructure:"port"`
74 RegistryPort int `mapstructure:"registry_port"`
75 GordonDomain string `mapstructure:"gordon_domain"`
76 RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
77 TLSEnabled bool `mapstructure:"tls_enabled"`
78 TLSPort int `mapstructure:"tls_port"`
79 TLSCertFile string `mapstructure:"tls_cert_file"`
80 TLSKeyFile string `mapstructure:"tls_key_file"`
81 DataDir string `mapstructure:"data_dir"`
82 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
83 MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
84 MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
85 MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`
86 RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`
87 } `mapstructure:"server"`
88
89 Logging struct {
90 Level string `mapstructure:"level"`
91 Format string `mapstructure:"format"`
92 File struct {
93 Enabled bool `mapstructure:"enabled"`
94 Path string `mapstructure:"path"`
95 MaxSize int `mapstructure:"max_size"`
96 MaxBackups int `mapstructure:"max_backups"`
97 MaxAge int `mapstructure:"max_age"`
98 } `mapstructure:"file"`
99 ContainerLogs struct {
100 Enabled bool `mapstructure:"enabled"`
101 Dir string `mapstructure:"dir"`
102 MaxSize int `mapstructure:"max_size"`
103 MaxBackups int `mapstructure:"max_backups"`
104 MaxAge int `mapstructure:"max_age"`
105 } `mapstructure:"container_logs"`
106 } `mapstructure:"logging"`
107
108 Env struct {
109 Dir string `mapstructure:"dir"`
110 } `mapstructure:"env"`
111
112 Auth struct {
113 Enabled bool `mapstructure:"enabled"`
114 Type string `mapstructure:"type"` // "password" or "token"
115 SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe"
116 Username string `mapstructure:"username"`
117 Password string `mapstructure:"password"` // deprecated: use password_hash
118 PasswordHash string `mapstructure:"password_hash"` // path in secrets backend
119 TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
120 TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
121 } `mapstructure:"auth"`
122
123 API struct {
124 RateLimit struct {
125 Enabled bool `mapstructure:"enabled"`
126 GlobalRPS float64 `mapstructure:"global_rps"`
127 PerIPRPS float64 `mapstructure:"per_ip_rps"`
128 Burst int `mapstructure:"burst"`
129 TrustedProxies []string `mapstructure:"trusted_proxies"`
130 } `mapstructure:"rate_limit"`
131 } `mapstructure:"api"`
132}
133
134// services holds all the services used by the application.
135type services struct {
136 runtime *docker.Runtime
137 eventBus *eventbus.InMemory
138 blobStorage *filesystem.BlobStorage
139 manifestStorage *filesystem.ManifestStorage
140 envLoader out.EnvLoader
141 logWriter *logwriter.LogWriter
142 tokenStore out.TokenStore
143 configSvc *config.Service
144 containerSvc *container.Service
145 registrySvc *registrySvc.Service
146 proxySvc *proxy.Service
147 authSvc *auth.Service
148 authHandler *authhandler.Handler
149 adminHandler *admin.Handler
150 internalRegUser string
151 internalRegPass string
152 envDir string
153 maxBlobChunkSize int64
154}
155
156// Run initializes and starts the Gordon application.
157func Run(ctx context.Context, configPath string) error {
158 // Load configuration
159 v, cfg, err := initConfig(configPath)
160 if err != nil {
161 return err
162 }
163
164 // Initialize logger
165 log, cleanup, err := initLogger(cfg)
166 if err != nil {
167 return err
168 }
169 if cleanup != nil {
170 defer cleanup()
171 }
172
173 ctx = zerowrap.WithCtx(ctx, log)
174 log.Info().Msg("Gordon starting")
175
176 if err := ensureTLSConfig(&cfg, log); err != nil {
177 return err
178 }
179
180 // Create PID file
181 pidFile := createPidFile(log)
182 if pidFile != "" {
183 defer removePidFile(pidFile, log)
184 }
185
186 // Create all services
187 svc, err := createServices(ctx, v, cfg, log)
188 if err != nil {
189 return err
190 }
191
192 // Register event handlers
193 if err := registerEventHandlers(ctx, svc, cfg); err != nil {
194 return err
195 }
196
197 // Set up config hot reload
198 setupConfigHotReload(ctx, v, svc, log)
199
200 // Start event bus
201 if err := svc.eventBus.Start(); err != nil {
202 return log.WrapErr(err, "failed to start event bus")
203 }
204 defer svc.eventBus.Stop()
205
206 // Create HTTP handlers
207 registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)
208
209 // Start servers, wait for listeners to bind, then sync/auto-start containers.
210 return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, log)
211}
212
213func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error {
214 if !cfg.Server.TLSEnabled {
215 return nil
216 }
217
218 if cfg.Server.TLSCertFile != "" && cfg.Server.TLSKeyFile != "" {
219 return nil
220 }
221
222 dataDir := resolveDataDir(cfg.Server.DataDir)
223 tlsDir := filepath.Join(dataDir, "tls")
224 certPath := filepath.Join(tlsDir, "cert.pem")
225 keyPath := filepath.Join(tlsDir, "key.pem")
226
227 domainName := cfg.Server.GordonDomain
228 if domainName == "" {
229 domainName = cfg.Server.RegistryDomain
230 }
231 if domainName == "" {
232 domainName = "localhost"
233 }
234
235 if err := generateSelfSignedTLSCert(certPath, keyPath, domainName); err != nil {
236 return fmt.Errorf("failed to generate self-signed TLS certificate: %w", err)
237 }
238
239 cfg.Server.TLSCertFile = certPath
240 cfg.Server.TLSKeyFile = keyPath
241
242 log.Warn().
243 Str("cert_file", certPath).
244 Str("key_file", keyPath).
245 Str("domain", domainName).
246 Msg("server.tls_enabled=true with empty tls_cert_file/tls_key_file; using auto-generated self-signed certificate")
247
248 return nil
249}
250
251func generateSelfSignedTLSCert(certPath, keyPath, domainName string) error {
252 // Reuse existing pair if already generated.
253 if _, err := os.Stat(certPath); err == nil {
254 if _, keyErr := os.Stat(keyPath); keyErr == nil {
255 return nil
256 }
257 }
258
259 if err := os.MkdirAll(filepath.Dir(certPath), 0700); err != nil {
260 return err
261 }
262 if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil {
263 return err
264 }
265
266 priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
267 if err != nil {
268 return err
269 }
270
271 now := time.Now()
272 serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
273 serialNumber, err := rand.Int(rand.Reader, serialLimit)
274 if err != nil {
275 return err
276 }
277
278 template := x509.Certificate{
279 SerialNumber: serialNumber,
280 Subject: pkix.Name{
281 CommonName: domainName,
282 Organization: []string{"Gordon Auto-Generated TLS"},
283 },
284 NotBefore: now.Add(-5 * time.Minute),
285 NotAfter: now.AddDate(1, 0, 0),
286 KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
287 ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
288 BasicConstraintsValid: true,
289 }
290
291 if ip := net.ParseIP(domainName); ip != nil {
292 template.IPAddresses = []net.IP{ip}
293 } else {
294 template.DNSNames = []string{domainName}
295 }
296
297 derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
298 if err != nil {
299 return err
300 }
301
302 certOut, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
303 if err != nil {
304 return err
305 }
306 defer certOut.Close()
307
308 if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
309 return err
310 }
311
312 keyBytes, err := x509.MarshalECPrivateKey(priv)
313 if err != nil {
314 return err
315 }
316
317 keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
318 if err != nil {
319 return err
320 }
321 defer keyOut.Close()
322
323 if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}); err != nil {
324 return err
325 }
326
327 return nil
328}
329
330// initConfig loads configuration from file.
331func initConfig(configPath string) (*viper.Viper, Config, error) {
332 v := viper.New()
333 if err := loadConfig(v, configPath); err != nil {
334 return nil, Config{}, fmt.Errorf("failed to load config: %w", err)
335 }
336
337 var cfg Config
338 if err := v.Unmarshal(&cfg); err != nil {
339 return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
340 }
341
342 // Normalize domain config: prefer gordon_domain over registry_domain
343 if cfg.Server.GordonDomain != "" {
344 cfg.Server.RegistryDomain = cfg.Server.GordonDomain
345 }
346
347 return v, cfg, nil
348}
349
350// initLogger initializes the zerowrap logger.
351func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
352 logConfig := zerowrap.Config{
353 Level: cfg.Logging.Level,
354 Format: cfg.Logging.Format,
355 }
356
357 // Always enable file logging so admin API can read process logs
358 // (Config flag is respected for backward-compatibility)
359 logPath := cfg.Logging.File.Path
360 if logPath == "" {
361 dataDir := cfg.Server.DataDir
362 if dataDir == "" {
363 dataDir = DefaultDataDir()
364 }
365 logPath = filepath.Join(dataDir, "logs", "gordon.log")
366 }
367
368 log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{
369 Enabled: cfg.Logging.File.Enabled,
370 Path: logPath,
371 MaxSize: cfg.Logging.File.MaxSize,
372 MaxBackups: cfg.Logging.File.MaxBackups,
373 MaxAge: cfg.Logging.File.MaxAge,
374 Compress: true,
375 })
376 if err != nil {
377 return zerowrap.Default(), nil, fmt.Errorf("failed to create logger with file: %w", err)
378 }
379 return log, cleanup, nil
380}
381
382// createServices creates all the application services.
383func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (*services, error) {
384 svc := &services{}
385 var err error
386
387 // Create output adapters
388 if svc.runtime, svc.eventBus, err = createOutputAdapters(ctx, log); err != nil {
389 return nil, err
390 }
391
392 // Create storage
393 if svc.blobStorage, svc.manifestStorage, err = createStorage(cfg, log); err != nil {
394 return nil, err
395 }
396
397 // Create log writer
398 if svc.logWriter, err = createLogWriter(cfg, log); err != nil {
399 return nil, err
400 }
401
402 // Create auth service (if enabled)
403 if svc.tokenStore, svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil {
404 return nil, err
405 }
406
407 if err := setupInternalRegistryAuth(cfg, svc, log); err != nil {
408 return nil, err
409 }
410
411 // Create use case services
412 svc.configSvc = config.NewService(v, svc.eventBus)
413 if err := svc.configSvc.Load(ctx); err != nil {
414 return nil, log.WrapErr(err, "failed to load configuration")
415 }
416
417 var backend domain.SecretsBackend
418 var passStore *domainsecrets.PassStore
419 var domainSecretStore out.DomainSecretStore
420
421 svc.envDir, backend, passStore, domainSecretStore, err = createDomainSecretStore(cfg, log)
422 if err != nil {
423 return nil, err
424 }
425
426 if svc.envLoader, err = createEnvLoader(backend, svc.envDir, passStore, log); err != nil {
427 return nil, err
428 }
429
430 secretSvc := secretsSvc.NewService(domainSecretStore, log)
431
432 if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil {
433 return nil, err
434 }
435 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
436
437 proxyCfg, err := buildProxyConfig(cfg, log)
438 if err != nil {
439 return nil, err
440 }
441 svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
442 svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxyCfg.proxyConfig)
443
444 // Create token handler for registry token endpoint
445 if svc.authSvc != nil {
446 internalAuth := authhandler.InternalAuth{
447 Username: svc.internalRegUser,
448 Password: svc.internalRegPass,
449 }
450 svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log)
451 }
452
453 // Create health service for route health checking
454 prober := httpprober.New()
455 healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)
456
457 // Create log service for accessing logs via admin API
458 logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log)
459
460 // Create admin handler for admin API
461 svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log)
462
463 return svc, nil
464}
465
466func setupInternalRegistryAuth(cfg Config, svc *services, log zerowrap.Logger) error {
467 if !cfg.Auth.Enabled {
468 return nil
469 }
470
471 var err error
472 svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
473 if err != nil {
474 return log.WrapErr(err, "failed to generate internal registry credentials")
475 }
476
477 // Persist credentials to file for CLI access (gordon auth internal)
478 if err := persistInternalCredentials(svc.internalRegUser, svc.internalRegPass); err != nil {
479 log.Warn().Err(err).Msg("failed to persist internal credentials for CLI access")
480 }
481
482 log.Debug().Msg("internal registry auth generated for loopback pulls")
483 return nil
484}
485
486func createDomainSecretStore(cfg Config, log zerowrap.Logger) (string, domain.SecretsBackend, *domainsecrets.PassStore, out.DomainSecretStore, error) {
487 envDir := resolveEnvDir(cfg)
488 backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)
489
490 switch backend {
491 case domain.SecretsBackendPass:
492 passStore, err := domainsecrets.NewPassStore(log)
493 if err != nil {
494 return "", backend, nil, nil, log.WrapErr(err, "failed to create pass domain secret store")
495 }
496 if err := migrateEnvFilesToPass(envDir, passStore, log); err != nil {
497 return "", backend, nil, nil, log.WrapErr(err, "failed to migrate env files to pass")
498 }
499 return envDir, backend, passStore, passStore, nil
500 default:
501 store, err := domainsecrets.NewFileStore(envDir, log)
502 if err != nil {
503 return "", backend, nil, nil, log.WrapErr(err, "failed to create domain secret store")
504 }
505 return envDir, backend, nil, store, nil
506 }
507}
508
509// resolveLogFilePath returns the configured log file path or a default.
510func resolveLogFilePath(cfg Config) string {
511 if cfg.Logging.File.Path != "" {
512 return cfg.Logging.File.Path
513 }
514 dataDir := cfg.Server.DataDir
515 if dataDir == "" {
516 dataDir = DefaultDataDir()
517 }
518 return filepath.Join(dataDir, "logs", "gordon.log")
519}
520
521// createOutputAdapters creates the Docker runtime and event bus.
522func createOutputAdapters(ctx context.Context, log zerowrap.Logger) (*docker.Runtime, *eventbus.InMemory, error) {
523 runtime, err := docker.NewRuntime()
524 if err != nil {
525 return nil, nil, log.WrapErr(err, "failed to create Docker runtime")
526 }
527
528 if err := runtime.Ping(ctx); err != nil {
529 return nil, nil, log.WrapErr(err, "Docker is not available")
530 }
531
532 dockerVersion, _ := runtime.Version(ctx)
533 log.Info().Str("docker_version", dockerVersion).Msg("Docker runtime initialized")
534
535 eventBus := eventbus.NewInMemory(100, log)
536
537 return runtime, eventBus, nil
538}
539
540// createStorage creates blob and manifest storage.
541func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *filesystem.ManifestStorage, error) {
542 dataDir := cfg.Server.DataDir
543 if dataDir == "" {
544 dataDir = DefaultDataDir()
545 }
546
547 registryDir := filepath.Join(dataDir, "registry")
548
549 blobStorage, err := filesystem.NewBlobStorage(registryDir, log)
550 if err != nil {
551 return nil, nil, log.WrapErr(err, "failed to create blob storage")
552 }
553
554 manifestStorage, err := filesystem.NewManifestStorage(registryDir, log)
555 if err != nil {
556 return nil, nil, log.WrapErr(err, "failed to create manifest storage")
557 }
558
559 return blobStorage, manifestStorage, nil
560}
561
562// createEnvLoader creates the environment loader with secret providers.
563func createEnvLoader(backend domain.SecretsBackend, envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) (out.EnvLoader, error) {
564 switch backend {
565 case domain.SecretsBackendPass:
566 loader, err := envloader.NewPassLoader(passStore, log)
567 if err != nil {
568 return nil, log.WrapErr(err, "failed to create pass env loader")
569 }
570 return loader, nil
571 default:
572 loader, err := envloader.NewFileLoader(envDir, log)
573 if err != nil {
574 return nil, log.WrapErr(err, "failed to create env loader")
575 }
576
577 // Register secret providers
578 passProvider := secrets.NewPassProvider(log)
579 if passProvider.IsAvailable() {
580 loader.RegisterSecretProvider(passProvider)
581 log.Debug().Msg("pass secret provider registered")
582 }
583
584 sopsProvider := secrets.NewSopsProvider(log)
585 if sopsProvider.IsAvailable() {
586 loader.RegisterSecretProvider(sopsProvider)
587 log.Debug().Msg("sops secret provider registered")
588 }
589
590 return loader, nil
591 }
592}
593
594// createLogWriter creates the container log writer.
595func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, error) {
596 if !cfg.Logging.ContainerLogs.Enabled {
597 log.Debug().Msg("container log collection disabled")
598 return nil, nil
599 }
600
601 // Determine log directory
602 logDir := cfg.Logging.ContainerLogs.Dir
603 if logDir == "" {
604 dataDir := cfg.Server.DataDir
605 if dataDir == "" {
606 dataDir = DefaultDataDir()
607 }
608 logDir = filepath.Join(dataDir, "logs", "containers")
609 }
610
611 writer, err := logwriter.New(logwriter.Config{
612 Dir: logDir,
613 MaxSize: cfg.Logging.ContainerLogs.MaxSize,
614 MaxBackups: cfg.Logging.ContainerLogs.MaxBackups,
615 MaxAge: cfg.Logging.ContainerLogs.MaxAge,
616 })
617 if err != nil {
618 return nil, log.WrapErr(err, "failed to create container log writer")
619 }
620
621 log.Info().Str("dir", logDir).Msg("container log collection enabled")
622 return writer, nil
623}
624
625const (
626 internalRegistryUsername = "gordon-internal"
627 serviceTokenSubject = "gordon-service"
628 serviceTokenDefaultTTL = 30 * 24 * time.Hour
629)
630
631func generateInternalRegistryAuth() (string, string, error) {
632 password, err := randomTokenHex(32)
633 if err != nil {
634 return "", "", err
635 }
636 return internalRegistryUsername, password, nil
637}
638
639func randomTokenHex(size int) (string, error) {
640 buf := make([]byte, size)
641 if _, err := rand.Read(buf); err != nil {
642 return "", err
643 }
644 return hex.EncodeToString(buf), nil
645}
646
647// InternalCredentials holds the internal registry credentials for CLI access.
648type InternalCredentials struct {
649 Username string `json:"username"`
650 Password string `json:"password"`
651}
652
653// getSecureRuntimeDir returns a secure directory for runtime files.
654// Priority: XDG_RUNTIME_DIR > ~/.gordon/run
655func getSecureRuntimeDir() (string, error) {
656 // Try XDG_RUNTIME_DIR first (typically /run/user/<uid> on Linux)
657 if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
658 gordonDir := filepath.Join(runtimeDir, "gordon")
659 if err := os.MkdirAll(gordonDir, 0700); err == nil {
660 return gordonDir, nil
661 }
662 }
663
664 // Fall back to ~/.gordon/run
665 homeDir, err := os.UserHomeDir()
666 if err != nil {
667 return "", fmt.Errorf("failed to get home directory: %w", err)
668 }
669
670 gordonDir := filepath.Join(homeDir, ".gordon", "run")
671 if err := os.MkdirAll(gordonDir, 0700); err != nil {
672 return "", fmt.Errorf("failed to create runtime directory: %w", err)
673 }
674
675 return gordonDir, nil
676}
677
678// getInternalCredentialsFile returns the path to the internal credentials file.
679// SECURITY: Credentials are stored in a secure location with restricted permissions.
680func getInternalCredentialsFile() string {
681 runtimeDir, err := getSecureRuntimeDir()
682 if err != nil {
683 // Fall back to temp dir if we can't get secure dir (shouldn't happen)
684 return filepath.Join(os.TempDir(), "gordon-internal-creds.json")
685 }
686 return filepath.Join(runtimeDir, "internal-creds.json")
687}
688
689// persistInternalCredentials saves the internal registry credentials to a secure file.
690// SECURITY: Credentials are stored in XDG_RUNTIME_DIR or ~/.gordon/run with 0600 permissions.
691// The file is cleaned up on graceful shutdown but may persist if Gordon crashes.
692// These credentials are for internal loopback communication only and are regenerated on each start.
693func persistInternalCredentials(username, password string) error {
694 creds := InternalCredentials{
695 Username: username,
696 Password: password,
697 }
698 data, err := json.Marshal(creds)
699 if err != nil {
700 return fmt.Errorf("failed to marshal credentials: %w", err)
701 }
702
703 credFile := getInternalCredentialsFile()
704
705 // Ensure parent directory exists with secure permissions
706 if err := os.MkdirAll(filepath.Dir(credFile), 0700); err != nil {
707 return fmt.Errorf("failed to create credentials directory: %w", err)
708 }
709
710 // Write file with restrictive permissions (owner read/write only)
711 if err := os.WriteFile(credFile, data, 0600); err != nil {
712 return fmt.Errorf("failed to write credentials file: %w", err)
713 }
714 return nil
715}
716
717// cleanupInternalCredentials removes the internal credentials file.
718func cleanupInternalCredentials() {
719 _ = os.Remove(getInternalCredentialsFile())
720}
721
722// GetInternalCredentials reads the internal registry credentials from file.
723// This is used by the CLI to display credentials for manual recovery.
724func GetInternalCredentials() (*InternalCredentials, error) {
725 credFile := getInternalCredentialsFile()
726 data, err := os.ReadFile(credFile)
727 if err != nil {
728 return nil, fmt.Errorf("failed to read credentials file (is Gordon running?): %w", err)
729 }
730 var creds InternalCredentials
731 if err := json.Unmarshal(data, &creds); err != nil {
732 return nil, fmt.Errorf("failed to parse credentials: %w", err)
733 }
734 return &creds, nil
735}
736
737// createAuthService creates the authentication service and token store.
738func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
739 if !cfg.Auth.Enabled {
740 return nil, nil, nil
741 }
742
743 authType := resolveAuthType(cfg)
744 backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)
745 dataDir := resolveDataDir(cfg.Server.DataDir)
746
747 store, err := createTokenStore(backend, dataDir, log)
748 if err != nil {
749 return nil, nil, err
750 }
751
752 authConfig, err := buildAuthConfig(ctx, cfg, authType, backend, dataDir, log)
753 if err != nil {
754 return nil, nil, err
755 }
756
757 authSvc := auth.NewService(authConfig, store, log)
758
759 log.Info().
760 Str("type", string(authType)).
761 Str("backend", string(backend)).
762 Msg("registry authentication enabled")
763
764 return store, authSvc, nil
765}
766
767// resolveAuthType determines the auth type from config.
768// If password_hash is configured, password auth is available (plus tokens).
769// If only token_secret is configured, token-only mode.
770// The explicit "type" field is deprecated but still respected for backwards compat.
771func resolveAuthType(cfg Config) domain.AuthType {
772 // Explicit type takes precedence (backwards compatibility)
773 if cfg.Auth.Type == "token" {
774 return domain.AuthTypeToken
775 }
776 if cfg.Auth.Type == "password" {
777 return domain.AuthTypePassword
778 }
779
780 // Infer from config: password auth if password_hash is configured
781 if cfg.Auth.PasswordHash != "" || cfg.Auth.Password != "" {
782 return domain.AuthTypePassword
783 }
784
785 // Default to token-only
786 return domain.AuthTypeToken
787}
788
789func resolveSecretsBackend(backend string) domain.SecretsBackend {
790 switch backend {
791 case "pass":
792 return domain.SecretsBackendPass
793 case "sops":
794 return domain.SecretsBackendSops
795 case "unsafe", "":
796 return domain.SecretsBackendUnsafe
797 default:
798 return domain.SecretsBackendUnsafe
799 }
800}
801
802func resolveDataDir(dataDir string) string {
803 if dataDir == "" {
804 return DefaultDataDir()
805 }
806 return dataDir
807}
808
809func resolveEnvDir(cfg Config) string {
810 dataDir := resolveDataDir(cfg.Server.DataDir)
811 envDir := cfg.Env.Dir
812 if envDir == "" {
813 envDir = filepath.Join(dataDir, "env")
814 }
815 return envDir
816}
817
818func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
819 // Token store is always created since tokens work in both auth modes
820 store, err := tokenstore.NewStore(backend, dataDir, log)
821 if err != nil {
822 return nil, log.WrapErr(err, "failed to create token store")
823 }
824 return store, nil
825}
826
827func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
828 authConfig := auth.Config{
829 Enabled: cfg.Auth.Enabled,
830 AuthType: authType,
831 Username: cfg.Auth.Username,
832 }
833
834 // Token config is always required (tokens work in all auth modes)
835 secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log)
836 if err != nil {
837 return auth.Config{}, err
838 }
839 authConfig.TokenSecret = secret
840 authConfig.TokenExpiry = expiry
841
842 // Password config only needed for password auth mode
843 if authType == domain.AuthTypePassword {
844 hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log)
845 if err != nil {
846 return auth.Config{}, err
847 }
848 if hash == "" {
849 return auth.Config{}, errAuthNotConfigured()
850 }
851 authConfig.PasswordHash = hash
852 }
853
854 return authConfig, nil
855}
856
857// errAuthNotConfigured returns an error when auth is enabled but credentials are not configured.
858func errAuthNotConfigured() error {
859 return fmt.Errorf("auth is enabled by default, configure auth type and secrets backend. See: https://gordon.bnema.dev/docs/config/auth")
860}
861
862func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) {
863 if cfg.Auth.PasswordHash != "" {
864 hash, err := loadSecret(ctx, backend, cfg.Auth.PasswordHash, dataDir, log)
865 if err != nil {
866 return "", log.WrapErr(err, "failed to load password hash")
867 }
868 return hash, nil
869 }
870
871 if cfg.Auth.Password != "" {
872 log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend")
873 return cfg.Auth.Password, nil
874 }
875
876 return "", nil
877}
878
879func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) {
880 secret, err := loadTokenSecret(ctx, cfg, backend, dataDir, log)
881 if err != nil {
882 return nil, 0, err
883 }
884
885 expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
886 if err != nil {
887 return nil, 0, err
888 }
889
890 return secret, expiry, nil
891}
892
893// TokenSecretEnvVar is the environment variable for the JWT signing secret.
894// SECURITY: This takes priority over config file to allow secure secret injection.
895const TokenSecretEnvVar = "GORDON_AUTH_TOKEN_SECRET" //nolint:gosec // This is an env var name, not a credential
896
897func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {
898 // SECURITY: Priority order for token secret:
899 // 1. Environment variable (most secure - no disk exposure)
900 // 2. Secrets backend (pass/sops - encrypted)
901 // 3. Config file path (least preferred)
902
903 // Check environment variable first
904 if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" {
905 log.Debug().Msg("using token secret from environment variable")
906 return []byte(envSecret), nil
907 }
908
909 // Fall back to config-specified path via secrets backend
910 if cfg.Auth.TokenSecret == "" {
911 return nil, fmt.Errorf("token_secret is required for JWT token generation; set %s environment variable or configure auth.token_secret", TokenSecretEnvVar)
912 }
913
914 secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log)
915 if err != nil {
916 return nil, log.WrapErr(err, "failed to load token secret")
917 }
918
919 return []byte(secret), nil
920}
921
922func parseTokenExpiry(expiry string) (time.Duration, error) {
923 if expiry == "" {
924 return 0, nil
925 }
926
927 parsed, err := duration.Parse(expiry)
928 if err != nil {
929 return 0, fmt.Errorf("invalid token_expiry: %w", err)
930 }
931
932 return parsed, nil
933}
934
935func resolveServiceTokenExpiry(cfg Config) (time.Duration, error) {
936 expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
937 if err != nil {
938 return 0, err
939 }
940 if expiry <= 0 {
941 return serviceTokenDefaultTTL, nil
942 }
943 return expiry, nil
944}
945
946// loadSecret loads a secret from the configured backend.
947func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) {
948 switch backend {
949 case domain.SecretsBackendPass:
950 provider := secrets.NewPassProvider(log)
951 return provider.GetSecret(ctx, path)
952 case domain.SecretsBackendSops:
953 provider := secrets.NewSopsProvider(log)
954 return provider.GetSecret(ctx, path)
955 case domain.SecretsBackendUnsafe:
956 // For unsafe backend, path is relative to dataDir/secrets/
957 secretFile := filepath.Join(dataDir, "secrets", path)
958 data, err := os.ReadFile(secretFile)
959 if err != nil {
960 return "", fmt.Errorf("failed to read secret file: %w", err)
961 }
962 return string(data), nil
963 default:
964 return "", fmt.Errorf("unknown secrets backend: %s", backend)
965 }
966}
967
968// proxyConfigResult holds parsed proxy and blob chunk size config.
969type proxyConfigResult struct {
970 proxyConfig proxy.Config
971 maxBlobChunkSize int64
972}
973
974// buildProxyConfig parses size-related config fields and builds the proxy config.
975func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, error) {
976 maxProxyBodySize := int64(512 << 20) // 512MB default
977 if cfg.Server.MaxProxyBodySize != "" {
978 parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyBodySize)
979 if err != nil {
980 return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_body_size configuration", map[string]any{"value": cfg.Server.MaxProxyBodySize})
981 }
982 maxProxyBodySize = parsedSize
983 }
984
985 maxBlobChunkSize := int64(registry.DefaultMaxBlobChunkSize)
986 if cfg.Server.MaxBlobChunkSize != "" {
987 parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobChunkSize)
988 if err != nil {
989 return nil, log.WrapErrWithFields(err, "invalid server.max_blob_chunk_size configuration", map[string]any{"value": cfg.Server.MaxBlobChunkSize})
990 }
991 maxBlobChunkSize = parsedSize
992 }
993
994 maxProxyResponseSize := int64(1 << 30) // 1GB default
995 if cfg.Server.MaxProxyResponseSize != "" {
996 parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyResponseSize)
997 if err != nil {
998 return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_response_size configuration", map[string]any{"value": cfg.Server.MaxProxyResponseSize})
999 }
1000 maxProxyResponseSize = parsedSize
1001 }
1002
1003 maxConcurrentConns := cfg.Server.MaxConcurrentConns
1004 if maxConcurrentConns < 0 {
1005 maxConcurrentConns = 10000 // default when explicitly set to -1
1006 }
1007 // 0 means no limit (as documented in proxy.Config)
1008
1009 return &proxyConfigResult{
1010 proxyConfig: proxy.Config{
1011 RegistryDomain: cfg.Server.RegistryDomain,
1012 RegistryPort: cfg.Server.RegistryPort,
1013 MaxBodySize: maxProxyBodySize,
1014 MaxResponseSize: maxProxyResponseSize,
1015 MaxConcurrentConns: maxConcurrentConns,
1016 },
1017 maxBlobChunkSize: maxBlobChunkSize,
1018 }, nil
1019}
1020
1021// createContainerService creates the container service with configuration.
1022func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
1023 containerConfig := container.Config{
1024 RegistryAuthEnabled: cfg.Auth.Enabled,
1025 RegistryDomain: cfg.Server.RegistryDomain,
1026 RegistryPort: cfg.Server.RegistryPort,
1027 InternalRegistryUsername: svc.internalRegUser,
1028 InternalRegistryPassword: svc.internalRegPass,
1029 PullPolicy: v.GetString("deploy.pull_policy"),
1030 VolumeAutoCreate: v.GetBool("volumes.auto_create"),
1031 VolumePrefix: v.GetString("volumes.prefix"),
1032 VolumePreserve: v.GetBool("volumes.preserve"),
1033 NetworkIsolation: v.GetBool("network_isolation.enabled"),
1034 NetworkPrefix: v.GetString("network_isolation.network_prefix"),
1035 DNSSuffix: v.GetString("network_isolation.dns_suffix"),
1036 NetworkGroups: svc.configSvc.GetNetworkGroups(),
1037 Attachments: svc.configSvc.GetAttachments(),
1038 ReadinessDelay: v.GetDuration("deploy.readiness_delay"),
1039 }
1040
1041 if cfg.Auth.Enabled && svc.authSvc != nil {
1042 expiry, err := resolveServiceTokenExpiry(cfg)
1043 if err != nil {
1044 return nil, log.WrapErr(err, "failed to resolve service token expiry")
1045 }
1046 // Note: Service tokens are not auto-refreshed. If the token expires during
1047 // container runtime, the container will need to be recreated to get a new token.
1048 serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, expiry)
1049 if err != nil {
1050 return nil, log.WrapErr(err, "failed to generate registry service token")
1051 }
1052 log.Info().
1053 Str("subject", serviceTokenSubject).
1054 Str("expiry", expiry.String()).
1055 Msg("generated service token for container registry access")
1056 containerConfig.ServiceTokenUsername = serviceTokenSubject
1057 containerConfig.ServiceToken = serviceToken
1058 }
1059
1060 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil
1061}
1062
1063// registerEventHandlers registers all event handlers.
1064func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error {
1065 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
1066 if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil {
1067 return fmt.Errorf("failed to subscribe image pushed handler: %w", err)
1068 }
1069
1070 // Auto-route handler for creating routes from image labels
1071 autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain).
1072 WithEnvExtractor(svc.runtime, svc.envDir)
1073 if err := svc.eventBus.Subscribe(autoRouteHandler); err != nil {
1074 return fmt.Errorf("failed to subscribe auto-route handler: %w", err)
1075 }
1076
1077 configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)
1078 if err := svc.eventBus.Subscribe(configReloadHandler); err != nil {
1079 return fmt.Errorf("failed to subscribe config reload handler: %w", err)
1080 }
1081
1082 manualReloadHandler := container.NewManualReloadHandler(ctx, svc.containerSvc, svc.configSvc)
1083 if err := svc.eventBus.Subscribe(manualReloadHandler); err != nil {
1084 return fmt.Errorf("failed to subscribe manual reload handler: %w", err)
1085 }
1086
1087 manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc)
1088 if err := svc.eventBus.Subscribe(manualDeployHandler); err != nil {
1089 return fmt.Errorf("failed to subscribe manual deploy handler: %w", err)
1090 }
1091
1092 // Proxy cache invalidation on container deployment (for zero-downtime)
1093 containerDeployedHandler := proxy.NewContainerDeployedHandler(ctx, svc.proxySvc)
1094 if err := svc.eventBus.Subscribe(containerDeployedHandler); err != nil {
1095 return fmt.Errorf("failed to subscribe container deployed handler: %w", err)
1096 }
1097
1098 // Proxy cache invalidation on config reload (clears stale targets for removed routes)
1099 configReloadProxyHandler := proxy.NewConfigReloadProxyHandler(ctx, svc.proxySvc)
1100 if err := svc.eventBus.Subscribe(configReloadProxyHandler); err != nil {
1101 return fmt.Errorf("failed to subscribe config reload proxy handler: %w", err)
1102 }
1103
1104 return nil
1105}
1106
1107// setupConfigHotReload sets up Viper config hot reload.
1108// NOTE: This does NOT reload routes into memory. Routes are managed via API
1109// (AddRoute/UpdateRoute/RemoveRoute) and memory is the source of truth.
1110// The file watcher only updates proxy config and refreshes targets.
1111// Manual config file edits to routes require a server restart.
1112func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) {
1113 v.OnConfigChange(func(e fsnotify.Event) {
1114 log.Info().Str("file", e.Name).Msg("config file changed")
1115
1116 if err := v.ReadInConfig(); err != nil {
1117 log.Error().Err(err).Msg("failed to reload config")
1118 return
1119 }
1120
1121 // Re-unmarshal config and rebuild proxy config to pick up all changes
1122 var reloadCfg Config
1123 if err := v.Unmarshal(&reloadCfg); err != nil {
1124 log.Error().Err(err).Msg("failed to unmarshal config on reload")
1125 return
1126 }
1127 if reloadCfg.Server.GordonDomain != "" {
1128 reloadCfg.Server.RegistryDomain = reloadCfg.Server.GordonDomain
1129 }
1130 reloadedProxy, err := buildProxyConfig(reloadCfg, log)
1131 if err != nil {
1132 log.Error().Err(err).Msg("failed to parse proxy config on reload")
1133 return
1134 }
1135 svc.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)
1136
1137 // Clear proxy target cache to pick up external route changes
1138 if err := svc.proxySvc.RefreshTargets(ctx); err != nil {
1139 log.Warn().Err(err).Msg("failed to refresh proxy targets")
1140 }
1141
1142 log.Debug().Msg("config hot reload complete (routes unchanged, memory is source of truth)")
1143 })
1144 v.WatchConfig()
1145}
1146
1147// syncAndAutoStart syncs existing containers and auto-starts if configured.
1148func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
1149 if err := svc.containerSvc.SyncContainers(ctx); err != nil {
1150 log.Warn().Err(err).Msg("failed to sync existing containers")
1151 }
1152
1153 if svc.configSvc.IsAutoRouteEnabled() {
1154 routes := svc.configSvc.GetRoutes(ctx)
1155 if err := svc.containerSvc.AutoStart(domain.WithInternalDeploy(ctx), routes); err != nil {
1156 log.Warn().Err(err).Msg("failed to auto-start containers")
1157 }
1158 }
1159}
1160
1161// createHTTPHandlers creates HTTP handlers with middleware.
1162func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) {
1163 // Parse trusted proxies once for all middleware chains.
1164 // This ensures consistent IP extraction across logging, rate limiting, and auth.
1165 trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
1166
1167 registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)
1168
1169 registryMiddlewares := []func(http.Handler) http.Handler{
1170 middleware.PanicRecovery(log),
1171 middleware.RequestLogger(log, trustedNets),
1172 middleware.SecurityHeaders,
1173 }
1174
1175 // CIDR allowlist for registry access (e.g., restrict to Tailscale IPs).
1176 // Inserted before rate limiting so denied IPs never consume limiter tokens.
1177 var cidrAllowlistMiddleware func(http.Handler) http.Handler
1178 if len(cfg.Server.RegistryAllowedIPs) > 0 {
1179 allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs)
1180 if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) {
1181 for _, entry := range cfg.Server.RegistryAllowedIPs {
1182 if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 {
1183 log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry")
1184 }
1185 }
1186 }
1187 if len(allowedNets) == 0 {
1188 log.Error().
1189 Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs).
1190 Msg("registry_allowed_ips is set but no valid entries were parsed; registry will allow all traffic")
1191 }
1192 cidrAllowlistMiddleware = middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)
1193 registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware)
1194 }
1195
1196 // Create rate limiters using the factory
1197 var rateLimitMiddleware func(http.Handler) http.Handler
1198 if cfg.API.RateLimit.Enabled {
1199 globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
1200 ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1201 rateLimitMiddleware = registry.RateLimitMiddleware(
1202 globalLimiter,
1203 ipLimiter,
1204 cfg.API.RateLimit.TrustedProxies,
1205 log,
1206 )
1207 } else {
1208 rateLimitMiddleware = registry.RateLimitMiddleware(nil, nil, nil, log)
1209 }
1210 registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
1211
1212 if cfg.Auth.Enabled && svc.authSvc != nil {
1213 internalAuth := middleware.InternalRegistryAuth{
1214 Username: svc.internalRegUser,
1215 Password: svc.internalRegPass,
1216 }
1217 registryMiddlewares = append(registryMiddlewares,
1218 middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
1219 }
1220
1221 registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler)
1222
1223 // Create a mux that routes:
1224 // - /auth/* to auth handler (no auth required, for password login and token exchange)
1225 // - /v2/* to registry handler (with auth)
1226 // - /admin/* to admin handler (with auth)
1227 registryMux := http.NewServeMux()
1228 if svc.authHandler != nil {
1229 // Auth endpoints are NOT protected by auth - they're where clients authenticate
1230 // but still need rate limiting to prevent brute force attacks
1231 authMiddlewares := []func(http.Handler) http.Handler{
1232 middleware.PanicRecovery(log),
1233 middleware.RequestLogger(log, trustedNets),
1234 middleware.SecurityHeaders,
1235 }
1236 if cidrAllowlistMiddleware != nil {
1237 authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)
1238 }
1239 authMiddlewares = append(authMiddlewares, rateLimitMiddleware)
1240 authWithMiddleware := middleware.Chain(authMiddlewares...)(svc.authHandler)
1241 registryMux.Handle("/auth/", authWithMiddleware)
1242 }
1243 registryMux.Handle("/v2/", registryWithMiddleware)
1244
1245 // Add admin API handler with auth middleware
1246 if svc.adminHandler != nil {
1247 adminMiddlewares := []func(http.Handler) http.Handler{
1248 middleware.PanicRecovery(log),
1249 middleware.RequestLogger(log, trustedNets),
1250 middleware.SecurityHeaders,
1251 }
1252 // Add admin auth middleware if auth is enabled
1253 if svc.authSvc != nil {
1254 // Create rate limiters for admin API - uses same config as registry
1255 var globalLimiter, ipLimiter out.RateLimiter
1256 if cfg.API.RateLimit.Enabled {
1257 globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
1258 ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1259 }
1260 adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log))
1261 }
1262 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
1263 registryMux.Handle("/admin/", adminWithMiddleware)
1264 }
1265
1266 // SECURITY: No CORS middleware on the proxy chain. Backend applications
1267 // should control their own CORS policies. A blanket Access-Control-Allow-Origin: *
1268 // would override backend CORS settings and allow any website to make
1269 // cross-origin authenticated requests to proxied applications.
1270 proxyMiddlewares := []func(http.Handler) http.Handler{
1271 middleware.PanicRecovery(log),
1272 middleware.RequestLogger(log, trustedNets),
1273 middleware.SecurityHeaders,
1274 }
1275
1276 proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc)
1277
1278 return registryMux, proxyWithMiddleware
1279}
1280
1281// runServers starts the HTTP servers and waits for shutdown.
1282// Signal handling notes:
1283// - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext
1284// - SIGUSR1: Triggers config reload without restart
1285// - SIGUSR2: Triggers manual deploy for a specific route
1286// The deferred signal.Stop calls ensure signal handlers are properly
1287// cleaned up before program exit, preventing signal handler leaks.
1288func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, svc *services, log zerowrap.Logger) error {
1289 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
1290 defer cancel()
1291
1292 // Set up SIGUSR1 for reload.
1293 // Note: signal.Stop must be called (via defer) to release the channel
1294 // and prevent signal handler leaks when the function returns.
1295 reloadChan := make(chan os.Signal, 1)
1296 signal.Notify(reloadChan, syscall.SIGUSR1)
1297 defer signal.Stop(reloadChan)
1298
1299 // Set up SIGUSR2 for manual deploy.
1300 deployChan := make(chan os.Signal, 1)
1301 signal.Notify(deployChan, syscall.SIGUSR2)
1302 defer signal.Stop(deployChan)
1303
1304 errChan := make(chan error, 3)
1305
1306 registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
1307 proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
1308 var tlsReady <-chan struct{}
1309 if cfg.Server.TLSEnabled {
1310 if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" {
1311 return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file")
1312 }
1313 tlsReady = startTLSServer(
1314 fmt.Sprintf(":%d", cfg.Server.TLSPort),
1315 proxyHandler,
1316 "proxy-tls",
1317 cfg.Server.TLSCertFile,
1318 cfg.Server.TLSKeyFile,
1319 errChan,
1320 log,
1321 )
1322 }
1323
1324 // Wait for both servers to bind their ports before auto-starting containers.
1325 // This prevents the race where auto-start pulls from the registry before it's listening.
1326 select {
1327 case <-registryReady:
1328 case err := <-errChan:
1329 return err
1330 }
1331 select {
1332 case <-proxyReady:
1333 case err := <-errChan:
1334 return err
1335 }
1336 if cfg.Server.TLSEnabled {
1337 select {
1338 case <-tlsReady:
1339 case err := <-errChan:
1340 return err
1341 }
1342 }
1343
1344 logEvent := log.Info().
1345 Int("proxy_port", cfg.Server.Port).
1346 Int("registry_port", cfg.Server.RegistryPort)
1347 if cfg.Server.TLSEnabled {
1348 logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort)
1349 }
1350 logEvent.Msg("Gordon is running")
1351
1352 // Auto-start after servers are listening (registry port is now bound).
1353 syncAndAutoStart(ctx, svc, log)
1354
1355 waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log)
1356 gracefulShutdown(registrySrv, proxySrv, containerSvc, log)
1357 return nil
1358}
1359
1360// waitForShutdown blocks on the event loop, handling server errors and
1361// Unix signals (reload, deploy, shutdown) until the context is cancelled.
1362func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, deployChan <-chan os.Signal, eventBus out.EventBus, log zerowrap.Logger) {
1363 for {
1364 select {
1365 case err := <-errChan:
1366 log.Error().Err(err).Msg("server error")
1367 return
1368 case <-reloadChan:
1369 log.Info().Msg("reload signal received (SIGUSR1)")
1370 if err := eventBus.Publish(domain.EventManualReload, nil); err != nil {
1371 log.Error().Err(err).Msg("failed to publish manual reload event")
1372 }
1373 case <-deployChan:
1374 log.Info().Msg("deploy signal received (SIGUSR2)")
1375 domainName, err := readDeployRequest()
1376 if err != nil {
1377 log.Error().Err(err).Msg("failed to read deploy request")
1378 continue
1379 }
1380 payload := &domain.ManualDeployPayload{Domain: domainName}
1381 if err := eventBus.Publish(domain.EventManualDeploy, payload); err != nil {
1382 log.Error().Err(err).Str("domain", domainName).Msg("failed to publish manual deploy event")
1383 }
1384 case <-ctx.Done():
1385 log.Info().Msg("shutdown signal received")
1386 return
1387 }
1388 }
1389}
1390
1391// gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
1392// the container service and cleans up runtime files.
1393func gracefulShutdown(registrySrv, proxySrv *http.Server, containerSvc *container.Service, log zerowrap.Logger) {
1394 log.Info().Msg("shutting down Gordon...")
1395
1396 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
1397 defer shutdownCancel()
1398
1399 if err := registrySrv.Shutdown(shutdownCtx); err != nil {
1400 log.Warn().Err(err).Msg("registry server shutdown error")
1401 }
1402 if err := proxySrv.Shutdown(shutdownCtx); err != nil {
1403 log.Warn().Err(err).Msg("proxy server shutdown error")
1404 }
1405
1406 if err := containerSvc.Shutdown(shutdownCtx); err != nil {
1407 log.Warn().Err(err).Msg("error during container shutdown")
1408 }
1409
1410 cleanupInternalCredentials()
1411 log.Info().Msg("Gordon stopped")
1412}
1413
1414// startServer starts an HTTP server, returning the server instance and a channel
1415// that closes once the listening socket is bound. This lets callers wait for the
1416// port to be ready before taking actions that depend on it (e.g. auto-start
1417// pulling from the local registry). The returned *http.Server can be used for
1418// graceful shutdown.
1419func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {
1420 ready := make(chan struct{})
1421
1422 server := &http.Server{
1423 Addr: addr,
1424 Handler: handler,
1425 ReadHeaderTimeout: 10 * time.Second,
1426 ReadTimeout: 5 * time.Minute,
1427 WriteTimeout: 5 * time.Minute,
1428 IdleTimeout: 120 * time.Second,
1429 MaxHeaderBytes: 1 << 20,
1430 }
1431
1432 go func() {
1433 log.Info().Str("address", addr).Msgf("%s server starting", name)
1434
1435 ln, err := net.Listen("tcp", addr)
1436 if err != nil {
1437 errChan <- fmt.Errorf("%s server error: %w", name, err)
1438 return
1439 }
1440 close(ready) // signal: port is bound and accepting connections
1441
1442 if err := server.Serve(ln); err != nil && err != http.ErrServerClosed {
1443 errChan <- fmt.Errorf("%s server error: %w", name, err)
1444 }
1445 }()
1446
1447 return server, ready
1448}
1449
1450// startTLSServer starts an HTTPS server with the provided certificate and key.
1451func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile string, errChan chan<- error, log zerowrap.Logger) <-chan struct{} {
1452 ready := make(chan struct{})
1453
1454 go func() {
1455 log.Info().
1456 Str("address", addr).
1457 Str("cert_file", certFile).
1458 Str("key_file", keyFile).
1459 Msgf("%s server starting", name)
1460
1461 server := &http.Server{
1462 Addr: addr,
1463 Handler: handler,
1464 ReadHeaderTimeout: 10 * time.Second,
1465 ReadTimeout: 5 * time.Minute,
1466 WriteTimeout: 5 * time.Minute,
1467 IdleTimeout: 120 * time.Second,
1468 MaxHeaderBytes: 1 << 20,
1469 }
1470
1471 ln, err := net.Listen("tcp", addr)
1472 if err != nil {
1473 errChan <- fmt.Errorf("%s server error: %w", name, err)
1474 return
1475 }
1476 close(ready)
1477
1478 if err := server.ServeTLS(ln, certFile, keyFile); err != nil && err != http.ErrServerClosed {
1479 errChan <- fmt.Errorf("%s server error: %w", name, err)
1480 }
1481 }()
1482
1483 return ready
1484}
1485
1486// SendReloadSignal sends SIGUSR1 to the running Gordon process.
1487func SendReloadSignal() error {
1488 process, _, err := findRunningProcess()
1489 if err != nil {
1490 return err
1491 }
1492
1493 if err := process.Signal(syscall.SIGUSR1); err != nil {
1494 return fmt.Errorf("failed to send reload signal: %w", err)
1495 }
1496
1497 return nil
1498}
1499
1500// getDeployRequestFile returns the path to the deploy request file.
1501func getDeployRequestFile() string {
1502 return filepath.Join(os.TempDir(), "gordon-deploy-request")
1503}
1504
1505// writeDeployRequestFile creates the deploy request file exclusively with retry.
1506// This prevents race conditions when multiple deploy commands run simultaneously.
1507func writeDeployRequestFile(path string, data []byte, timeout time.Duration) error {
1508 deadline := time.Now().Add(timeout)
1509
1510 for {
1511 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
1512 if err == nil {
1513 _, writeErr := f.Write(data)
1514 f.Close()
1515 if writeErr != nil {
1516 _ = os.Remove(path)
1517 return writeErr
1518 }
1519 return nil
1520 }
1521
1522 if os.IsExist(err) {
1523 if time.Now().After(deadline) {
1524 return fmt.Errorf("deploy request file still present after timeout; another deploy may be in progress")
1525 }
1526 time.Sleep(100 * time.Millisecond)
1527 continue
1528 }
1529
1530 return err
1531 }
1532}
1533
1534// SendDeploySignal triggers a manual deploy for a specific route via SIGUSR2.
1535// Returns the domain name on success for the caller to display.
1536func SendDeploySignal(domain string) (string, error) {
1537 deployFile := getDeployRequestFile()
1538 if err := writeDeployRequestFile(deployFile, []byte(domain), 5*time.Second); err != nil {
1539 return "", fmt.Errorf("failed to write deploy request: %w", err)
1540 }
1541
1542 // Find PID and send SIGUSR2
1543 process, _, err := findRunningProcess()
1544 if err != nil {
1545 _ = os.Remove(deployFile)
1546 return "", err
1547 }
1548
1549 if err := process.Signal(syscall.SIGUSR2); err != nil {
1550 _ = os.Remove(deployFile)
1551 return "", fmt.Errorf("failed to send deploy signal: %w", err)
1552 }
1553
1554 return domain, nil
1555}
1556
1557// readDeployRequest reads and removes the deploy request file atomically.
1558// Returns empty string if file doesn't exist (may have been consumed by another handler).
1559func readDeployRequest() (string, error) {
1560 deployFile := getDeployRequestFile()
1561
1562 // Rename to a temp file first to make the read-and-delete atomic
1563 tmpFile := deployFile + ".processing"
1564 if err := os.Rename(deployFile, tmpFile); err != nil {
1565 if os.IsNotExist(err) {
1566 return "", fmt.Errorf("deploy request file not found (may have been processed already)")
1567 }
1568 return "", fmt.Errorf("failed to acquire deploy request: %w", err)
1569 }
1570
1571 data, err := os.ReadFile(tmpFile)
1572 _ = os.Remove(tmpFile) // Always clean up
1573 if err != nil {
1574 return "", fmt.Errorf("failed to read deploy request: %w", err)
1575 }
1576
1577 return string(data), nil
1578}
1579
1580// createPidFile creates a PID file for the Gordon process.
1581// SECURITY: Prefers secure locations (XDG_RUNTIME_DIR, ~/.gordon/run) over /tmp
1582// to prevent symlink attacks and unauthorized access.
1583func createPidFile(log zerowrap.Logger) string {
1584 pid := os.Getpid()
1585
1586 // SECURITY: Prioritize secure locations over /tmp
1587 var locations []string
1588
1589 // Try secure runtime directory first
1590 if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1591 locations = append(locations, filepath.Join(runtimeDir, "gordon.pid"))
1592 }
1593
1594 // Fall back to home directory
1595 if homeDir, err := os.UserHomeDir(); err == nil {
1596 locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid"))
1597 }
1598
1599 // Last resort: /tmp (least secure due to world-writable)
1600 locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid"))
1601
1602 for _, location := range locations {
1603 // Ensure parent directory exists with secure permissions
1604 if err := os.MkdirAll(filepath.Dir(location), 0700); err != nil {
1605 continue
1606 }
1607 if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil {
1608 log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file")
1609 return location
1610 }
1611 }
1612
1613 log.Warn().Int("pid", pid).Msg("failed to create PID file in any location")
1614 return ""
1615}
1616
1617// removePidFile removes the PID file.
1618func removePidFile(pidFile string, log zerowrap.Logger) {
1619 if err := os.Remove(pidFile); err != nil {
1620 log.Warn().Err(err).Str("pid_file", pidFile).Msg("failed to remove PID file")
1621 } else {
1622 log.Debug().Str("pid_file", pidFile).Msg("removed PID file")
1623 }
1624}
1625
1626func pidFileLocations() []string {
1627 var locations []string
1628 seen := make(map[string]struct{})
1629 add := func(path string) {
1630 if path == "" {
1631 return
1632 }
1633 if _, ok := seen[path]; ok {
1634 return
1635 }
1636 seen[path] = struct{}{}
1637 locations = append(locations, path)
1638 }
1639
1640 // Check secure runtime directory first
1641 if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1642 add(filepath.Join(runtimeDir, "gordon.pid"))
1643 }
1644
1645 // Also check canonical /run/user/<uid> runtime path. This handles cases where
1646 // Gordon started under systemd user services with runtime dir available, but
1647 // CLI invocations (e.g. non-interactive SSH) don't have XDG_RUNTIME_DIR set.
1648 runtimeByUID := filepath.Join("/run/user", strconv.Itoa(os.Getuid()), "gordon", "gordon.pid")
1649 add(runtimeByUID)
1650
1651 // Check explicit XDG_RUNTIME_DIR if present in this process env.
1652 if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
1653 add(filepath.Join(runtimeDir, "gordon.pid"))
1654 }
1655
1656 // Check home directory
1657 if homeDir, err := os.UserHomeDir(); err == nil {
1658 add(filepath.Join(homeDir, ".gordon", "gordon.pid"))
1659 // Legacy location for backward compatibility
1660 add(filepath.Join(homeDir, ".gordon.pid"))
1661 }
1662
1663 // Legacy /tmp locations for backward compatibility
1664 add(filepath.Join(os.TempDir(), "gordon.pid"))
1665 add("/tmp/gordon.pid")
1666
1667 return locations
1668}
1669
1670// findRunningPidFile returns the first PID file whose PID belongs to a live process.
1671// Stale/invalid PID files are ignored and removed when possible.
1672func findRunningPidFile() (string, int, error) {
1673 return findRunningPidFileInLocations(pidFileLocations())
1674}
1675
1676func findRunningPidFileInLocations(locations []string) (string, int, error) {
1677 foundAny := false
1678
1679 for _, location := range locations {
1680 pidBytes, err := os.ReadFile(location)
1681 if err != nil {
1682 continue
1683 }
1684
1685 foundAny = true
1686
1687 var pid int
1688 if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil || pid <= 0 {
1689 _ = os.Remove(location)
1690 continue
1691 }
1692
1693 if isProcessAlive(pid) {
1694 return location, pid, nil
1695 }
1696
1697 _ = os.Remove(location)
1698 }
1699
1700 if foundAny {
1701 return "", 0, fmt.Errorf("found stale gordon PID file(s), is Gordon running?")
1702 }
1703
1704 return "", 0, fmt.Errorf("gordon PID file not found, is Gordon running?")
1705}
1706
1707func findRunningProcess() (*os.Process, int, error) {
1708 _, pid, err := findRunningPidFile()
1709 if err != nil {
1710 return nil, 0, err
1711 }
1712
1713 process, err := os.FindProcess(pid)
1714 if err != nil {
1715 return nil, 0, fmt.Errorf("failed to find process: %w", err)
1716 }
1717
1718 return process, pid, nil
1719}
1720
1721func isProcessAlive(pid int) bool {
1722 process, err := os.FindProcess(pid)
1723 if err != nil {
1724 return false
1725 }
1726
1727 err = process.Signal(syscall.Signal(0))
1728 if err == nil {
1729 return true
1730 }
1731
1732 return errors.Is(err, syscall.EPERM)
1733}
1734
1735// loadConfig loads configuration from file and sets defaults.
1736func loadConfig(v *viper.Viper, configPath string) error {
1737 v.SetDefault("server.port", 80)
1738 v.SetDefault("server.registry_port", 5000)
1739 v.SetDefault("server.tls_enabled", false)
1740 v.SetDefault("server.tls_port", 443)
1741 v.SetDefault("server.tls_cert_file", "")
1742 v.SetDefault("server.tls_key_file", "")
1743 v.SetDefault("server.data_dir", DefaultDataDir())
1744 v.SetDefault("logging.level", "info")
1745 v.SetDefault("logging.format", "console")
1746 v.SetDefault("logging.file.enabled", false)
1747 v.SetDefault("logging.file.max_size", 100)
1748 v.SetDefault("logging.file.max_backups", 3)
1749 v.SetDefault("logging.file.max_age", 28)
1750 v.SetDefault("logging.container_logs.enabled", true)
1751 v.SetDefault("logging.container_logs.dir", "")
1752 v.SetDefault("logging.container_logs.max_size", 100)
1753 v.SetDefault("logging.container_logs.max_backups", 3)
1754 v.SetDefault("logging.container_logs.max_age", 28)
1755 v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
1756 v.SetDefault("auth.enabled", true)
1757 // Note: auth.type is intentionally not set - it's inferred from config
1758 // If password_hash is set -> password mode, otherwise -> token mode
1759 v.SetDefault("auth.secrets_backend", "unsafe")
1760 v.SetDefault("auth.token_expiry", "720h")
1761 v.SetDefault("api.rate_limit.enabled", true)
1762 v.SetDefault("api.rate_limit.global_rps", 500)
1763 v.SetDefault("api.rate_limit.per_ip_rps", 50)
1764 v.SetDefault("api.rate_limit.burst", 100)
1765 v.SetDefault("auto_route.enabled", false)
1766 v.SetDefault("network_isolation.enabled", false)
1767 v.SetDefault("network_isolation.network_prefix", "gordon")
1768 v.SetDefault("network_isolation.dns_suffix", ".internal")
1769 v.SetDefault("volumes.auto_create", true)
1770 v.SetDefault("volumes.prefix", "gordon")
1771 v.SetDefault("volumes.preserve", true)
1772 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
1773 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
1774 v.SetDefault("server.registry_allowed_ips", []string{})
1775 v.SetDefault("deploy.readiness_delay", "5s")
1776
1777 ConfigureViper(v, configPath)
1778
1779 if err := v.ReadInConfig(); err != nil {
1780 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
1781 return fmt.Errorf("failed to read config file: %w", err)
1782 }
1783 }
1784
1785 v.SetEnvPrefix("GORDON")
1786 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
1787 v.AutomaticEnv()
1788
1789 return nil
1790}