v2.16.0 ebd2ab3

run.go RawHistoryFolder

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	zerowrapotel "github.com/bnema/zerowrap/otel"
29	"github.com/fsnotify/fsnotify"
30	"github.com/spf13/viper"
31
32	// Adapters - Output
33	"github.com/bnema/gordon/internal/adapters/out/docker"
34	"github.com/bnema/gordon/internal/adapters/out/domainsecrets"
35	"github.com/bnema/gordon/internal/adapters/out/envloader"
36	"github.com/bnema/gordon/internal/adapters/out/eventbus"
37	"github.com/bnema/gordon/internal/adapters/out/filesystem"
38	"github.com/bnema/gordon/internal/adapters/out/httpprober"
39	"github.com/bnema/gordon/internal/adapters/out/logwriter"
40	"github.com/bnema/gordon/internal/adapters/out/ratelimit"
41	"github.com/bnema/gordon/internal/adapters/out/secrets"
42	"github.com/bnema/gordon/internal/adapters/out/telemetry"
43	"github.com/bnema/gordon/internal/adapters/out/tokenstore"
44
45	// OTel
46	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
47
48	// Adapters - Input
49	"github.com/bnema/gordon/internal/adapters/dto"
50	"github.com/bnema/gordon/internal/adapters/in/http/admin"
51	authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth"
52	"github.com/bnema/gordon/internal/adapters/in/http/middleware"
53	"github.com/bnema/gordon/internal/adapters/in/http/registry"
54
55	// Boundaries
56	"github.com/bnema/gordon/internal/boundaries/out"
57
58	// Domain
59	"github.com/bnema/gordon/internal/domain"
60
61	// Packages
62	"github.com/bnema/gordon/pkg/version"
63
64	// Use cases
65	"github.com/bnema/gordon/internal/usecase/auth"
66	"github.com/bnema/gordon/internal/usecase/backup"
67	"github.com/bnema/gordon/internal/usecase/config"
68	"github.com/bnema/gordon/internal/usecase/container"
69	cronSvc "github.com/bnema/gordon/internal/usecase/cron"
70	"github.com/bnema/gordon/internal/usecase/health"
71	"github.com/bnema/gordon/internal/usecase/images"
72	"github.com/bnema/gordon/internal/usecase/logs"
73	"github.com/bnema/gordon/internal/usecase/proxy"
74	registrySvc "github.com/bnema/gordon/internal/usecase/registry"
75	secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
76
77	// Pkg
78	"github.com/bnema/gordon/pkg/bytesize"
79	"github.com/bnema/gordon/pkg/duration"
80)
81
82// Config holds the application configuration.
83type Config struct {
84	Server struct {
85		Port                 int      `mapstructure:"port"`
86		RegistryPort         int      `mapstructure:"registry_port"`
87		GordonDomain         string   `mapstructure:"gordon_domain"`
88		RegistryDomain       string   `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
89		TLSEnabled           bool     `mapstructure:"tls_enabled"`
90		TLSPort              int      `mapstructure:"tls_port"`
91		TLSCertFile          string   `mapstructure:"tls_cert_file"`
92		TLSKeyFile           string   `mapstructure:"tls_key_file"`
93		DataDir              string   `mapstructure:"data_dir"`
94		MaxProxyBodySize     string   `mapstructure:"max_proxy_body_size"`     // e.g., "512MB", "1GB"
95		MaxBlobChunkSize     string   `mapstructure:"max_blob_chunk_size"`     // e.g., "512MB", "1GB"
96		MaxProxyResponseSize string   `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
97		MaxConcurrentConns   int      `mapstructure:"max_concurrent_connections"`
98		RegistryAllowedIPs   []string `mapstructure:"registry_allowed_ips"`
99	} `mapstructure:"server"`
100
101	Logging struct {
102		Level  string `mapstructure:"level"`
103		Format string `mapstructure:"format"`
104		File   struct {
105			Enabled    bool   `mapstructure:"enabled"`
106			Path       string `mapstructure:"path"`
107			MaxSize    int    `mapstructure:"max_size"`
108			MaxBackups int    `mapstructure:"max_backups"`
109			MaxAge     int    `mapstructure:"max_age"`
110		} `mapstructure:"file"`
111		ContainerLogs struct {
112			Enabled    bool   `mapstructure:"enabled"`
113			Dir        string `mapstructure:"dir"`
114			MaxSize    int    `mapstructure:"max_size"`
115			MaxBackups int    `mapstructure:"max_backups"`
116			MaxAge     int    `mapstructure:"max_age"`
117		} `mapstructure:"container_logs"`
118	} `mapstructure:"logging"`
119
120	Env struct {
121		Dir string `mapstructure:"dir"`
122	} `mapstructure:"env"`
123
124	Auth struct {
125		Enabled        bool   `mapstructure:"enabled"`
126		Type           string `mapstructure:"type"`            // "password" or "token"
127		SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe"
128		Username       string `mapstructure:"username"`
129		Password       string `mapstructure:"password"`      // deprecated: use password_hash
130		PasswordHash   string `mapstructure:"password_hash"` // path in secrets backend
131		TokenSecret    string `mapstructure:"token_secret"`  // path in secrets backend
132		TokenExpiry    string `mapstructure:"token_expiry"`  // e.g., "720h", "30d"
133	} `mapstructure:"auth"`
134
135	API struct {
136		RateLimit struct {
137			Enabled        bool     `mapstructure:"enabled"`
138			GlobalRPS      float64  `mapstructure:"global_rps"`
139			PerIPRPS       float64  `mapstructure:"per_ip_rps"`
140			Burst          int      `mapstructure:"burst"`
141			TrustedProxies []string `mapstructure:"trusted_proxies"`
142		} `mapstructure:"rate_limit"`
143	} `mapstructure:"api"`
144
145	Backups struct {
146		Enabled    bool   `mapstructure:"enabled"`
147		Schedule   string `mapstructure:"schedule"`
148		StorageDir string `mapstructure:"storage_dir"`
149		Retention  struct {
150			Hourly  int `mapstructure:"hourly"`
151			Daily   int `mapstructure:"daily"`
152			Weekly  int `mapstructure:"weekly"`
153			Monthly int `mapstructure:"monthly"`
154		} `mapstructure:"retention"`
155	} `mapstructure:"backups"`
156
157	Images struct {
158		Prune struct {
159			Enabled  bool   `mapstructure:"enabled"`
160			Schedule string `mapstructure:"schedule"`
161			KeepLast int    `mapstructure:"keep_last"`
162		} `mapstructure:"prune"`
163	} `mapstructure:"images"`
164
165	Telemetry telemetry.Config `mapstructure:"telemetry"`
166}
167
168// services holds all the services used by the application.
169type services struct {
170	runtime          *docker.Runtime
171	eventBus         *eventbus.InMemory
172	blobStorage      *filesystem.BlobStorage
173	manifestStorage  *filesystem.ManifestStorage
174	backupStorage    *filesystem.BackupStorage
175	envLoader        out.EnvLoader
176	logWriter        *logwriter.LogWriter
177	tokenStore       out.TokenStore
178	configSvc        *config.Service
179	secretSvc        *secretsSvc.Service
180	containerSvc     *container.Service
181	backupSvc        *backup.Service
182	registrySvc      *registrySvc.Service
183	healthSvc        *health.Service
184	logSvc           *logs.Service
185	imageSvc         *images.Service
186	proxySvc         *proxy.Service
187	authSvc          *auth.Service
188	authHandler      *authhandler.Handler
189	adminHandler     *admin.Handler
190	internalRegUser  string
191	internalRegPass  string
192	envDir           string
193	maxBlobChunkSize int64
194}
195
196// Run initializes and starts the Gordon application.
197func Run(ctx context.Context, configPath string) error {
198	// Load configuration
199	v, cfg, err := initConfig(configPath)
200	if err != nil {
201		return err
202	}
203
204	// Initialize logger
205	log, cleanup, err := initLogger(cfg)
206	if err != nil {
207		return err
208	}
209	if cleanup != nil {
210		defer cleanup()
211	}
212
213	ctx = zerowrap.WithCtx(ctx, log)
214
215	// Initialize OpenTelemetry
216	telProvider, telShutdown, err := telemetry.NewProvider(ctx, cfg.Telemetry, "gordon", version.Version())
217	if err != nil {
218		log.Warn().Err(err).Msg("failed to initialize telemetry, continuing without it")
219	} else {
220		// Use a fresh context for shutdown so a canceled app ctx doesn't prevent flushing.
221		defer func() {
222			shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
223			defer cancel()
224			telShutdown(shutdownCtx)
225		}()
226		if cfg.Telemetry.Enabled && cfg.Telemetry.Endpoint != "" {
227			// Bridge zerowrap logs to OTel if log export is enabled
228			if cfg.Telemetry.Logs && telProvider.LogProvider != nil {
229				otelHook := zerowrapotel.NewHookWithProvider(telProvider.LogProvider, "gordon")
230				log = zerowrap.WithHook(log, otelHook)
231				ctx = zerowrap.WithCtx(ctx, log)
232			}
233			log.Info().Str("endpoint", cfg.Telemetry.Endpoint).Msg("telemetry initialized")
234		}
235	}
236
237	log.Info().Msg("Gordon starting")
238
239	if err := ensureTLSConfig(&cfg, log); err != nil {
240		return err
241	}
242
243	// Create PID file
244	pidFile := createPidFile(log)
245	if pidFile != "" {
246		defer removePidFile(pidFile, log)
247	}
248
249	// Create all services
250	svc, err := createServices(ctx, v, cfg, log)
251	if err != nil {
252		return err
253	}
254
255	// Register event handlers
256	if err := registerEventHandlers(ctx, svc, cfg); err != nil {
257		return err
258	}
259
260	// Set up config hot reload
261	setupConfigHotReload(ctx, v, svc, log)
262
263	// Start event bus
264	if err := svc.eventBus.Start(); err != nil {
265		return log.WrapErr(err, "failed to start event bus")
266	}
267	defer svc.eventBus.Stop()
268
269	// Create HTTP handlers
270	registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)
271
272	// Start servers, wait for listeners to bind, then sync/auto-start containers.
273	return runServers(ctx, v, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, log)
274}
275
276func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error {
277	if !cfg.Server.TLSEnabled {
278		return nil
279	}
280
281	if cfg.Server.TLSCertFile != "" && cfg.Server.TLSKeyFile != "" {
282		return nil
283	}
284
285	dataDir := resolveDataDir(cfg.Server.DataDir)
286	tlsDir := filepath.Join(dataDir, "tls")
287	certPath := filepath.Join(tlsDir, "cert.pem")
288	keyPath := filepath.Join(tlsDir, "key.pem")
289
290	domainName := cfg.Server.GordonDomain
291	if domainName == "" {
292		domainName = cfg.Server.RegistryDomain
293	}
294	if domainName == "" {
295		domainName = "localhost"
296	}
297
298	if err := generateSelfSignedTLSCert(certPath, keyPath, domainName); err != nil {
299		return fmt.Errorf("failed to generate self-signed TLS certificate: %w", err)
300	}
301
302	cfg.Server.TLSCertFile = certPath
303	cfg.Server.TLSKeyFile = keyPath
304
305	log.Warn().
306		Str("cert_file", certPath).
307		Str("key_file", keyPath).
308		Str("domain", domainName).
309		Msg("server.tls_enabled=true with empty tls_cert_file/tls_key_file; using auto-generated self-signed certificate")
310
311	return nil
312}
313
314func generateSelfSignedTLSCert(certPath, keyPath, domainName string) error {
315	// Reuse existing pair if already generated.
316	if _, err := os.Stat(certPath); err == nil {
317		if _, keyErr := os.Stat(keyPath); keyErr == nil {
318			return nil
319		}
320	}
321
322	if err := os.MkdirAll(filepath.Dir(certPath), 0700); err != nil {
323		return err
324	}
325	if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil {
326		return err
327	}
328
329	priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
330	if err != nil {
331		return err
332	}
333
334	now := time.Now()
335	serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
336	serialNumber, err := rand.Int(rand.Reader, serialLimit)
337	if err != nil {
338		return err
339	}
340
341	template := x509.Certificate{
342		SerialNumber: serialNumber,
343		Subject: pkix.Name{
344			CommonName:   domainName,
345			Organization: []string{"Gordon Auto-Generated TLS"},
346		},
347		NotBefore:             now.Add(-5 * time.Minute),
348		NotAfter:              now.AddDate(1, 0, 0),
349		KeyUsage:              x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
350		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
351		BasicConstraintsValid: true,
352	}
353
354	if ip := net.ParseIP(domainName); ip != nil {
355		template.IPAddresses = []net.IP{ip}
356	} else {
357		template.DNSNames = []string{domainName}
358	}
359
360	derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
361	if err != nil {
362		return err
363	}
364
365	certOut, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
366	if err != nil {
367		return err
368	}
369	defer certOut.Close()
370
371	if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
372		return err
373	}
374
375	keyBytes, err := x509.MarshalECPrivateKey(priv)
376	if err != nil {
377		return err
378	}
379
380	keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
381	if err != nil {
382		return err
383	}
384	defer keyOut.Close()
385
386	if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}); err != nil {
387		return err
388	}
389
390	return nil
391}
392
393// initConfig loads configuration from file.
394func initConfig(configPath string) (*viper.Viper, Config, error) {
395	v := viper.New()
396	if err := loadConfig(v, configPath); err != nil {
397		return nil, Config{}, fmt.Errorf("failed to load config: %w", err)
398	}
399
400	var cfg Config
401	if err := v.Unmarshal(&cfg); err != nil {
402		return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
403	}
404
405	// Normalize domain config: prefer gordon_domain over registry_domain
406	if cfg.Server.GordonDomain != "" {
407		cfg.Server.RegistryDomain = cfg.Server.GordonDomain
408	}
409
410	return v, cfg, nil
411}
412
413// initLogger initializes the zerowrap logger.
414func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
415	logConfig := zerowrap.Config{
416		Level:  cfg.Logging.Level,
417		Format: cfg.Logging.Format,
418	}
419
420	// Always enable file logging so admin API can read process logs
421	// (Config flag is respected for backward-compatibility)
422	logPath := cfg.Logging.File.Path
423	if logPath == "" {
424		dataDir := cfg.Server.DataDir
425		if dataDir == "" {
426			dataDir = DefaultDataDir()
427		}
428		logPath = filepath.Join(dataDir, "logs", "gordon.log")
429	}
430
431	log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{
432		Enabled:    cfg.Logging.File.Enabled,
433		Path:       logPath,
434		MaxSize:    cfg.Logging.File.MaxSize,
435		MaxBackups: cfg.Logging.File.MaxBackups,
436		MaxAge:     cfg.Logging.File.MaxAge,
437		Compress:   true,
438	})
439	if err != nil {
440		return zerowrap.Default(), nil, fmt.Errorf("failed to create logger with file: %w", err)
441	}
442	return log, cleanup, nil
443}
444
445// createServices creates all the application services.
446func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (*services, error) {
447	svc := &services{}
448	var err error
449
450	// Create output adapters
451	if svc.runtime, svc.eventBus, err = createOutputAdapters(ctx, log); err != nil {
452		return nil, err
453	}
454
455	// Create storage
456	if svc.blobStorage, svc.manifestStorage, err = createStorage(cfg, log); err != nil {
457		return nil, err
458	}
459
460	// Create log writer
461	if svc.logWriter, err = createLogWriter(cfg, log); err != nil {
462		return nil, err
463	}
464
465	// Create auth service (if enabled)
466	if svc.tokenStore, svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil {
467		return nil, err
468	}
469
470	if err := setupInternalRegistryAuth(svc, log); err != nil {
471		return nil, err
472	}
473
474	// Create use case services
475	svc.configSvc = config.NewService(v, svc.eventBus)
476	if err := svc.configSvc.Load(ctx); err != nil {
477		return nil, log.WrapErr(err, "failed to load configuration")
478	}
479
480	var backend domain.SecretsBackend
481	var passStore *domainsecrets.PassStore
482	var domainSecretStore out.DomainSecretStore
483
484	svc.envDir, backend, passStore, domainSecretStore, err = createDomainSecretStore(cfg, log)
485	if err != nil {
486		return nil, err
487	}
488
489	if svc.envLoader, err = createEnvLoader(backend, svc.envDir, passStore, log); err != nil {
490		return nil, err
491	}
492
493	svc.secretSvc = secretsSvc.NewService(domainSecretStore, log)
494
495	if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil {
496		return nil, err
497	}
498
499	if svc.backupStorage, svc.backupSvc, err = createBackupService(cfg, svc, log); err != nil {
500		return nil, err
501	}
502
503	svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
504	svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log)
505
506	injectTelemetryMetrics(cfg, svc, log)
507
508	proxyCfg, err := buildProxyConfig(cfg, log)
509	if err != nil {
510		return nil, err
511	}
512	svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
513	svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxyCfg.proxyConfig)
514
515	// Wire synchronous proxy cache invalidation for zero-downtime deployments.
516	// The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget().
517	svc.containerSvc.SetProxyCacheInvalidator(svc.proxySvc)
518	svc.containerSvc.SetProxyDrainWaiter(svc.proxySvc)
519
520	// Create token handler for registry token endpoint
521	if svc.authSvc != nil {
522		internalAuth := authhandler.InternalAuth{
523			Username: svc.internalRegUser,
524			Password: svc.internalRegPass,
525		}
526		svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log)
527	}
528
529	// Create health service for route health checking
530	prober := httpprober.New()
531	svc.healthSvc = health.NewService(svc.configSvc, svc.containerSvc, prober, log)
532
533	// Create log service for accessing logs via admin API
534	svc.logSvc = logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log)
535
536	// Create admin handler for admin API
537	svc.adminHandler = admin.NewHandler(
538		svc.configSvc,
539		svc.authSvc,
540		svc.containerSvc,
541		svc.healthSvc,
542		svc.secretSvc,
543		svc.logSvc,
544		svc.registrySvc,
545		svc.eventBus,
546		log,
547		svc.backupSvc,
548		svc.imageSvc,
549	)
550
551	return svc, nil
552}
553
554// injectTelemetryMetrics creates and injects OTel metrics into services when
555// telemetry is enabled. Skipped otherwise to avoid unnecessary allocations.
556func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) {
557	if !cfg.Telemetry.Enabled || !cfg.Telemetry.Metrics {
558		return
559	}
560	gordonMetrics, err := telemetry.NewMetrics()
561	if err != nil {
562		log.Warn().Err(err).Msg("failed to create telemetry metrics, continuing without metrics")
563		return
564	}
565	svc.containerSvc.SetMetrics(gordonMetrics)
566	svc.registrySvc.SetMetrics(gordonMetrics)
567	svc.eventBus.SetMetrics(gordonMetrics)
568}
569
570func setupInternalRegistryAuth(svc *services, log zerowrap.Logger) error {
571	var err error
572	svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
573	if err != nil {
574		return log.WrapErr(err, "failed to generate internal registry credentials")
575	}
576
577	// Persist credentials to file for CLI access (gordon auth internal)
578	if err := persistInternalCredentials(svc.internalRegUser, svc.internalRegPass); err != nil {
579		log.Warn().Err(err).Msg("failed to persist internal credentials for CLI access")
580	}
581
582	log.Debug().Msg("internal registry auth generated for loopback pulls")
583	return nil
584}
585
586func createDomainSecretStore(cfg Config, log zerowrap.Logger) (string, domain.SecretsBackend, *domainsecrets.PassStore, out.DomainSecretStore, error) {
587	envDir := resolveEnvDir(cfg)
588	backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)
589
590	switch backend {
591	case domain.SecretsBackendPass:
592		passStore, err := domainsecrets.NewPassStore(log)
593		if err != nil {
594			return "", backend, nil, nil, log.WrapErr(err, "failed to create pass domain secret store")
595		}
596		if err := migrateEnvFilesToPass(envDir, passStore, log); err != nil {
597			return "", backend, nil, nil, log.WrapErr(err, "failed to migrate env files to pass")
598		}
599		return envDir, backend, passStore, passStore, nil
600	default:
601		store, err := domainsecrets.NewFileStore(envDir, log)
602		if err != nil {
603			return "", backend, nil, nil, log.WrapErr(err, "failed to create domain secret store")
604		}
605		return envDir, backend, nil, store, nil
606	}
607}
608
609// resolveLogFilePath returns the configured log file path or a default.
610func resolveLogFilePath(cfg Config) string {
611	if cfg.Logging.File.Path != "" {
612		return cfg.Logging.File.Path
613	}
614	dataDir := cfg.Server.DataDir
615	if dataDir == "" {
616		dataDir = DefaultDataDir()
617	}
618	return filepath.Join(dataDir, "logs", "gordon.log")
619}
620
621// createOutputAdapters creates the Docker runtime and event bus.
622func createOutputAdapters(ctx context.Context, log zerowrap.Logger) (*docker.Runtime, *eventbus.InMemory, error) {
623	runtime, err := docker.NewRuntime()
624	if err != nil {
625		return nil, nil, log.WrapErr(err, "failed to create Docker runtime")
626	}
627
628	if err := runtime.Ping(ctx); err != nil {
629		return nil, nil, log.WrapErr(err, "Docker is not available")
630	}
631
632	dockerVersion, _ := runtime.Version(ctx)
633	log.Info().Str("docker_version", dockerVersion).Msg("Docker runtime initialized")
634
635	eventBus := eventbus.NewInMemory(100, log)
636
637	return runtime, eventBus, nil
638}
639
640// createStorage creates blob and manifest storage.
641func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *filesystem.ManifestStorage, error) {
642	dataDir := cfg.Server.DataDir
643	if dataDir == "" {
644		dataDir = DefaultDataDir()
645	}
646
647	registryDir := filepath.Join(dataDir, "registry")
648
649	blobStorage, err := filesystem.NewBlobStorage(registryDir, log)
650	if err != nil {
651		return nil, nil, log.WrapErr(err, "failed to create blob storage")
652	}
653
654	manifestStorage, err := filesystem.NewManifestStorage(registryDir, log)
655	if err != nil {
656		return nil, nil, log.WrapErr(err, "failed to create manifest storage")
657	}
658
659	return blobStorage, manifestStorage, nil
660}
661
662// createEnvLoader creates the environment loader with secret providers.
663func createEnvLoader(backend domain.SecretsBackend, envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) (out.EnvLoader, error) {
664	switch backend {
665	case domain.SecretsBackendPass:
666		loader, err := envloader.NewPassLoader(passStore, log)
667		if err != nil {
668			return nil, log.WrapErr(err, "failed to create pass env loader")
669		}
670		return loader, nil
671	default:
672		loader, err := envloader.NewFileLoader(envDir, log)
673		if err != nil {
674			return nil, log.WrapErr(err, "failed to create env loader")
675		}
676
677		// Register secret providers
678		passProvider := secrets.NewPassProvider(log)
679		if passProvider.IsAvailable() {
680			loader.RegisterSecretProvider(passProvider)
681			log.Debug().Msg("pass secret provider registered")
682		}
683
684		sopsProvider := secrets.NewSopsProvider(log)
685		if sopsProvider.IsAvailable() {
686			loader.RegisterSecretProvider(sopsProvider)
687			log.Debug().Msg("sops secret provider registered")
688		}
689
690		return loader, nil
691	}
692}
693
694// createLogWriter creates the container log writer.
695func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, error) {
696	if !cfg.Logging.ContainerLogs.Enabled {
697		log.Debug().Msg("container log collection disabled")
698		return nil, nil
699	}
700
701	// Determine log directory
702	logDir := cfg.Logging.ContainerLogs.Dir
703	if logDir == "" {
704		dataDir := cfg.Server.DataDir
705		if dataDir == "" {
706			dataDir = DefaultDataDir()
707		}
708		logDir = filepath.Join(dataDir, "logs", "containers")
709	}
710
711	writer, err := logwriter.New(logwriter.Config{
712		Dir:        logDir,
713		MaxSize:    cfg.Logging.ContainerLogs.MaxSize,
714		MaxBackups: cfg.Logging.ContainerLogs.MaxBackups,
715		MaxAge:     cfg.Logging.ContainerLogs.MaxAge,
716	})
717	if err != nil {
718		return nil, log.WrapErr(err, "failed to create container log writer")
719	}
720
721	log.Info().Str("dir", logDir).Msg("container log collection enabled")
722	return writer, nil
723}
724
725const (
726	internalRegistryUsername = "gordon-internal"
727	serviceTokenSubject      = "gordon-service"
728	serviceTokenDefaultTTL   = 30 * 24 * time.Hour
729)
730
731func generateInternalRegistryAuth() (string, string, error) {
732	password, err := randomTokenHex(32)
733	if err != nil {
734		return "", "", err
735	}
736	return internalRegistryUsername, password, nil
737}
738
739func randomTokenHex(size int) (string, error) {
740	buf := make([]byte, size)
741	if _, err := rand.Read(buf); err != nil {
742		return "", err
743	}
744	return hex.EncodeToString(buf), nil
745}
746
747// InternalCredentials holds the internal registry credentials for CLI access.
748type InternalCredentials struct {
749	Username string `json:"username"`
750	Password string `json:"password"`
751}
752
753// getSecureRuntimeDir returns a secure directory for runtime files.
754// Priority: XDG_RUNTIME_DIR > ~/.gordon/run
755func getSecureRuntimeDir() (string, error) {
756	// Try XDG_RUNTIME_DIR first (typically /run/user/<uid> on Linux)
757	if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
758		gordonDir := filepath.Join(runtimeDir, "gordon")
759		if err := os.MkdirAll(gordonDir, 0700); err == nil {
760			return gordonDir, nil
761		}
762	}
763
764	// Fall back to ~/.gordon/run
765	homeDir, err := os.UserHomeDir()
766	if err != nil {
767		return "", fmt.Errorf("failed to get home directory: %w", err)
768	}
769
770	gordonDir := filepath.Join(homeDir, ".gordon", "run")
771	if err := os.MkdirAll(gordonDir, 0700); err != nil {
772		return "", fmt.Errorf("failed to create runtime directory: %w", err)
773	}
774
775	return gordonDir, nil
776}
777
778// getInternalCredentialsFile returns the path to the internal credentials file.
779// SECURITY: Credentials are stored in a secure location with restricted permissions.
780func getInternalCredentialsFile() string {
781	runtimeDir, err := getSecureRuntimeDir()
782	if err != nil {
783		// Fall back to temp dir if we can't get secure dir (shouldn't happen)
784		return filepath.Join(os.TempDir(), "gordon-internal-creds.json")
785	}
786	return filepath.Join(runtimeDir, "internal-creds.json")
787}
788
789// persistInternalCredentials saves the internal registry credentials to a secure file.
790// SECURITY: Credentials are stored in XDG_RUNTIME_DIR or ~/.gordon/run with 0600 permissions.
791// The file is cleaned up on graceful shutdown but may persist if Gordon crashes.
792// These credentials are for internal loopback communication only and are regenerated on each start.
793func persistInternalCredentials(username, password string) error {
794	creds := InternalCredentials{
795		Username: username,
796		Password: password,
797	}
798	data, err := json.Marshal(creds)
799	if err != nil {
800		return fmt.Errorf("failed to marshal credentials: %w", err)
801	}
802
803	credFile := getInternalCredentialsFile()
804
805	// Ensure parent directory exists with secure permissions
806	if err := os.MkdirAll(filepath.Dir(credFile), 0700); err != nil {
807		return fmt.Errorf("failed to create credentials directory: %w", err)
808	}
809
810	// Write file with restrictive permissions (owner read/write only)
811	if err := os.WriteFile(credFile, data, 0600); err != nil {
812		return fmt.Errorf("failed to write credentials file: %w", err)
813	}
814	return nil
815}
816
817// cleanupInternalCredentials removes the internal credentials file.
818func cleanupInternalCredentials() {
819	_ = os.Remove(getInternalCredentialsFile())
820}
821
822// GetInternalCredentials reads the internal registry credentials from file.
823// This is used by the CLI to display credentials for manual recovery.
824func GetInternalCredentials() (*InternalCredentials, error) {
825	credFile := getInternalCredentialsFile()
826	data, err := os.ReadFile(credFile)
827	if err != nil {
828		return nil, fmt.Errorf("failed to read credentials file (is Gordon running?): %w", err)
829	}
830	var creds InternalCredentials
831	if err := json.Unmarshal(data, &creds); err != nil {
832		return nil, fmt.Errorf("failed to parse credentials: %w", err)
833	}
834	return &creds, nil
835}
836
837// createAuthService creates the authentication service and token store.
838func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
839	if !cfg.Auth.Enabled {
840		log.Warn().Msg("auth.enabled=false detected: running in local-only mode (registry loopback-only, admin API disabled)")
841		return nil, nil, nil
842	}
843
844	authType := resolveAuthType(cfg)
845	backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)
846	dataDir := resolveDataDir(cfg.Server.DataDir)
847
848	store, err := createTokenStore(backend, dataDir, log)
849	if err != nil {
850		return nil, nil, err
851	}
852
853	authConfig, err := buildAuthConfig(ctx, cfg, authType, backend, dataDir, log)
854	if err != nil {
855		return nil, nil, err
856	}
857
858	authSvc := auth.NewService(authConfig, store, log)
859
860	log.Info().
861		Str("type", string(authType)).
862		Str("backend", string(backend)).
863		Msg("registry authentication enabled")
864
865	return store, authSvc, nil
866}
867
868// resolveAuthType determines the auth type from config.
869// If password_hash is configured, password auth is available (plus tokens).
870// If only token_secret is configured, token-only mode.
871// The explicit "type" field is deprecated but still respected for backwards compat.
872func resolveAuthType(cfg Config) domain.AuthType {
873	// Explicit type takes precedence (backwards compatibility)
874	if cfg.Auth.Type == "token" {
875		return domain.AuthTypeToken
876	}
877	if cfg.Auth.Type == "password" {
878		return domain.AuthTypePassword
879	}
880
881	// Infer from config: password auth if password_hash is configured
882	if cfg.Auth.PasswordHash != "" || cfg.Auth.Password != "" {
883		return domain.AuthTypePassword
884	}
885
886	// Default to token-only
887	return domain.AuthTypeToken
888}
889
890func resolveSecretsBackend(backend string) domain.SecretsBackend {
891	switch backend {
892	case "pass":
893		return domain.SecretsBackendPass
894	case "sops":
895		return domain.SecretsBackendSops
896	case "unsafe", "":
897		return domain.SecretsBackendUnsafe
898	default:
899		return domain.SecretsBackendUnsafe
900	}
901}
902
903func resolveDataDir(dataDir string) string {
904	if dataDir == "" {
905		return DefaultDataDir()
906	}
907	return dataDir
908}
909
910func resolveEnvDir(cfg Config) string {
911	dataDir := resolveDataDir(cfg.Server.DataDir)
912	envDir := cfg.Env.Dir
913	if envDir == "" {
914		envDir = filepath.Join(dataDir, "env")
915	}
916	return envDir
917}
918
919func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
920	// Token store is always created since tokens work in both auth modes
921	store, err := tokenstore.NewStore(backend, dataDir, log)
922	if err != nil {
923		return nil, log.WrapErr(err, "failed to create token store")
924	}
925	return store, nil
926}
927
928func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
929	authConfig := auth.Config{
930		Enabled:  cfg.Auth.Enabled,
931		AuthType: authType,
932		Username: cfg.Auth.Username,
933	}
934
935	// Token config is always required (tokens work in all auth modes)
936	secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log)
937	if err != nil {
938		return auth.Config{}, err
939	}
940	authConfig.TokenSecret = secret
941	authConfig.TokenExpiry = expiry
942
943	// Password config only needed for password auth mode
944	if authType == domain.AuthTypePassword {
945		hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log)
946		if err != nil {
947			return auth.Config{}, err
948		}
949		if hash == "" {
950			return auth.Config{}, errAuthNotConfigured()
951		}
952		authConfig.PasswordHash = hash
953	}
954
955	return authConfig, nil
956}
957
958// errAuthNotConfigured returns an error when auth is enabled but credentials are not configured.
959func errAuthNotConfigured() error {
960	return fmt.Errorf("auth is enabled by default, configure auth type and secrets backend. See: https://gordon.bnema.dev/docs/config/auth")
961}
962
963func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) {
964	if cfg.Auth.PasswordHash != "" {
965		hash, err := loadSecret(ctx, backend, cfg.Auth.PasswordHash, dataDir, log)
966		if err != nil {
967			return "", log.WrapErr(err, "failed to load password hash")
968		}
969		return hash, nil
970	}
971
972	if cfg.Auth.Password != "" {
973		log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend")
974		return cfg.Auth.Password, nil
975	}
976
977	return "", nil
978}
979
980func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) {
981	secret, err := loadTokenSecret(ctx, cfg, backend, dataDir, log)
982	if err != nil {
983		return nil, 0, err
984	}
985
986	expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
987	if err != nil {
988		return nil, 0, err
989	}
990
991	return secret, expiry, nil
992}
993
994// TokenSecretEnvVar is the environment variable for the JWT signing secret.
995// SECURITY: This takes priority over config file to allow secure secret injection.
996const TokenSecretEnvVar = "GORDON_AUTH_TOKEN_SECRET" //nolint:gosec // This is an env var name, not a credential
997
998func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {
999	// SECURITY: Priority order for token secret:
1000	// 1. Environment variable (most secure - no disk exposure)
1001	// 2. Secrets backend (pass/sops - encrypted)
1002	// 3. Config file path (least preferred)
1003
1004	// Check environment variable first
1005	if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" {
1006		log.Debug().Msg("using token secret from environment variable")
1007		return []byte(envSecret), nil
1008	}
1009
1010	// Fall back to config-specified path via secrets backend
1011	if cfg.Auth.TokenSecret == "" {
1012		return nil, fmt.Errorf("token_secret is required for JWT token generation; set %s environment variable or configure auth.token_secret", TokenSecretEnvVar)
1013	}
1014
1015	secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log)
1016	if err != nil {
1017		return nil, log.WrapErr(err, "failed to load token secret")
1018	}
1019
1020	return []byte(secret), nil
1021}
1022
1023func parseTokenExpiry(expiry string) (time.Duration, error) {
1024	if expiry == "" {
1025		return 0, nil
1026	}
1027
1028	parsed, err := duration.Parse(expiry)
1029	if err != nil {
1030		return 0, fmt.Errorf("invalid token_expiry: %w", err)
1031	}
1032
1033	return parsed, nil
1034}
1035
1036func resolveServiceTokenExpiry(cfg Config) (time.Duration, error) {
1037	expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
1038	if err != nil {
1039		return 0, err
1040	}
1041	if expiry <= 0 {
1042		return serviceTokenDefaultTTL, nil
1043	}
1044	return expiry, nil
1045}
1046
1047// loadSecret loads a secret from the configured backend.
1048func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) {
1049	switch backend {
1050	case domain.SecretsBackendPass:
1051		provider := secrets.NewPassProvider(log)
1052		return provider.GetSecret(ctx, path)
1053	case domain.SecretsBackendSops:
1054		provider := secrets.NewSopsProvider(log)
1055		return provider.GetSecret(ctx, path)
1056	case domain.SecretsBackendUnsafe:
1057		// For unsafe backend, path is relative to dataDir/secrets/
1058		secretFile := filepath.Join(dataDir, "secrets", path)
1059		data, err := os.ReadFile(secretFile)
1060		if err != nil {
1061			return "", fmt.Errorf("failed to read secret file: %w", err)
1062		}
1063		return string(data), nil
1064	default:
1065		return "", fmt.Errorf("unknown secrets backend: %s", backend)
1066	}
1067}
1068
1069// proxyConfigResult holds parsed proxy and blob chunk size config.
1070type proxyConfigResult struct {
1071	proxyConfig      proxy.Config
1072	maxBlobChunkSize int64
1073}
1074
1075// buildProxyConfig parses size-related config fields and builds the proxy config.
1076func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, error) {
1077	maxProxyBodySize := int64(512 << 20) // 512MB default
1078	if cfg.Server.MaxProxyBodySize != "" {
1079		parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyBodySize)
1080		if err != nil {
1081			return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_body_size configuration", map[string]any{"value": cfg.Server.MaxProxyBodySize})
1082		}
1083		maxProxyBodySize = parsedSize
1084	}
1085
1086	maxBlobChunkSize := int64(registry.DefaultMaxBlobChunkSize)
1087	if cfg.Server.MaxBlobChunkSize != "" {
1088		parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobChunkSize)
1089		if err != nil {
1090			return nil, log.WrapErrWithFields(err, "invalid server.max_blob_chunk_size configuration", map[string]any{"value": cfg.Server.MaxBlobChunkSize})
1091		}
1092		maxBlobChunkSize = parsedSize
1093	}
1094
1095	maxProxyResponseSize := int64(1 << 30) // 1GB default
1096	if cfg.Server.MaxProxyResponseSize != "" {
1097		parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyResponseSize)
1098		if err != nil {
1099			return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_response_size configuration", map[string]any{"value": cfg.Server.MaxProxyResponseSize})
1100		}
1101		maxProxyResponseSize = parsedSize
1102	}
1103
1104	maxConcurrentConns := cfg.Server.MaxConcurrentConns
1105	if maxConcurrentConns < 0 {
1106		maxConcurrentConns = 10000 // default when explicitly set to -1
1107	}
1108	// 0 means no limit (as documented in proxy.Config)
1109
1110	return &proxyConfigResult{
1111		proxyConfig: proxy.Config{
1112			RegistryDomain:     cfg.Server.RegistryDomain,
1113			RegistryPort:       cfg.Server.RegistryPort,
1114			MaxBodySize:        maxProxyBodySize,
1115			MaxResponseSize:    maxProxyResponseSize,
1116			MaxConcurrentConns: maxConcurrentConns,
1117		},
1118		maxBlobChunkSize: maxBlobChunkSize,
1119	}, nil
1120}
1121
1122// createContainerService creates the container service with configuration.
1123func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
1124	containerConfig := container.Config{
1125		RegistryAuthEnabled:      cfg.Auth.Enabled,
1126		RegistryDomain:           cfg.Server.RegistryDomain,
1127		RegistryPort:             cfg.Server.RegistryPort,
1128		InternalRegistryUsername: svc.internalRegUser,
1129		InternalRegistryPassword: svc.internalRegPass,
1130		PullPolicy:               v.GetString("deploy.pull_policy"),
1131		VolumeAutoCreate:         v.GetBool("volumes.auto_create"),
1132		VolumePrefix:             v.GetString("volumes.prefix"),
1133		VolumePreserve:           v.GetBool("volumes.preserve"),
1134		NetworkIsolation:         v.GetBool("network_isolation.enabled"),
1135		NetworkPrefix:            v.GetString("network_isolation.network_prefix"),
1136		DNSSuffix:                v.GetString("network_isolation.dns_suffix"),
1137		NetworkGroups:            svc.configSvc.GetNetworkGroups(),
1138		Attachments:              svc.configSvc.GetAttachments(),
1139		ReadinessDelay:           v.GetDuration("deploy.readiness_delay"),
1140		ReadinessMode:            v.GetString("deploy.readiness_mode"),
1141		HealthTimeout:            v.GetDuration("deploy.health_timeout"),
1142		DrainDelay:               v.GetDuration("deploy.drain_delay"),
1143		DrainMode:                v.GetString("deploy.drain_mode"),
1144		DrainTimeout:             v.GetDuration("deploy.drain_timeout"),
1145	}
1146	if v.IsSet("deploy.drain_delay") {
1147		containerConfig.DrainDelayConfigured = true
1148		containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay")
1149	}
1150
1151	if containerConfig.RegistryAuthEnabled {
1152		if svc.authSvc == nil {
1153			return nil, fmt.Errorf("authentication service unavailable: cannot generate registry service token")
1154		}
1155		expiry, err := resolveServiceTokenExpiry(cfg)
1156		if err != nil {
1157			return nil, log.WrapErr(err, "failed to resolve service token expiry")
1158		}
1159		// Note: Service tokens are not auto-refreshed. If the token expires during
1160		// container runtime, the container will need to be recreated to get a new token.
1161		serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, expiry)
1162		if err != nil {
1163			return nil, log.WrapErr(err, "failed to generate registry service token")
1164		}
1165		log.Info().
1166			Str("subject", serviceTokenSubject).
1167			Str("expiry", expiry.String()).
1168			Msg("generated service token for container registry access")
1169		containerConfig.ServiceTokenUsername = serviceTokenSubject
1170		containerConfig.ServiceToken = serviceToken
1171	} else {
1172		log.Warn().Msg("registry auth disabled; container image pulls will use unauthenticated mode")
1173	}
1174
1175	return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil
1176}
1177
1178func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*filesystem.BackupStorage, *backup.Service, error) {
1179	if !cfg.Backups.Enabled {
1180		return nil, nil, nil
1181	}
1182
1183	storageDir := cfg.Backups.StorageDir
1184	if storageDir == "" {
1185		dataDir := resolveDataDir(cfg.Server.DataDir)
1186		storageDir = filepath.Join(dataDir, "backups")
1187	}
1188
1189	backupStorage, err := filesystem.NewBackupStorage(storageDir, log)
1190	if err != nil {
1191		return nil, nil, log.WrapErr(err, "failed to create backup storage")
1192	}
1193
1194	retention, err := validateBackupRetention(cfg)
1195	if err != nil {
1196		return nil, nil, log.WrapErr(err, "invalid backup retention policy")
1197	}
1198
1199	backupCfg := domain.BackupConfig{
1200		Enabled:    cfg.Backups.Enabled,
1201		StorageDir: storageDir,
1202		Retention:  retention,
1203	}
1204
1205	backupSvc := backup.NewService(svc.runtime, backupStorage, svc.containerSvc, backupCfg, log)
1206
1207	log.Info().
1208		Str("storage_dir", storageDir).
1209		Msg("backup service initialized")
1210
1211	return backupStorage, backupSvc, nil
1212}
1213
1214func validateBackupRetention(cfg Config) (domain.RetentionPolicy, error) {
1215	if cfg.Backups.Retention.Hourly < 0 {
1216		return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.hourly cannot be negative")
1217	}
1218	if cfg.Backups.Retention.Daily < 0 {
1219		return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.daily cannot be negative")
1220	}
1221	if cfg.Backups.Retention.Weekly < 0 {
1222		return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.weekly cannot be negative")
1223	}
1224	if cfg.Backups.Retention.Monthly < 0 {
1225		return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.monthly cannot be negative")
1226	}
1227
1228	return domain.RetentionPolicy{
1229		Hourly:  cfg.Backups.Retention.Hourly,
1230		Daily:   cfg.Backups.Retention.Daily,
1231		Weekly:  cfg.Backups.Retention.Weekly,
1232		Monthly: cfg.Backups.Retention.Monthly,
1233	}, nil
1234}
1235
1236// registerEventHandlers registers all event handlers.
1237func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error {
1238	imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
1239	if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil {
1240		return fmt.Errorf("failed to subscribe image pushed handler: %w", err)
1241	}
1242
1243	// Auto-route handler for creating routes from image labels
1244	autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain).
1245		WithEnvExtractor(svc.runtime, svc.envDir)
1246	if err := svc.eventBus.Subscribe(autoRouteHandler); err != nil {
1247		return fmt.Errorf("failed to subscribe auto-route handler: %w", err)
1248	}
1249
1250	configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)
1251	if err := svc.eventBus.Subscribe(configReloadHandler); err != nil {
1252		return fmt.Errorf("failed to subscribe config reload handler: %w", err)
1253	}
1254
1255	manualReloadHandler := container.NewManualReloadHandler(ctx, svc.containerSvc, svc.configSvc)
1256	if err := svc.eventBus.Subscribe(manualReloadHandler); err != nil {
1257		return fmt.Errorf("failed to subscribe manual reload handler: %w", err)
1258	}
1259
1260	manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc)
1261	if err := svc.eventBus.Subscribe(manualDeployHandler); err != nil {
1262		return fmt.Errorf("failed to subscribe manual deploy handler: %w", err)
1263	}
1264
1265	// Proxy cache invalidation on config reload (clears stale targets for removed routes)
1266	configReloadProxyHandler := proxy.NewConfigReloadProxyHandler(ctx, svc.proxySvc)
1267	if err := svc.eventBus.Subscribe(configReloadProxyHandler); err != nil {
1268		return fmt.Errorf("failed to subscribe config reload proxy handler: %w", err)
1269	}
1270
1271	return nil
1272}
1273
1274// setupConfigHotReload sets up Viper config hot reload.
1275// NOTE: This does NOT reload routes into memory. Routes are managed via API
1276// (AddRoute/UpdateRoute/RemoveRoute) and memory is the source of truth.
1277// The file watcher only updates proxy config and refreshes targets.
1278// Manual config file edits to routes require a server restart.
1279func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) {
1280	v.OnConfigChange(func(e fsnotify.Event) {
1281		log.Info().Str("file", e.Name).Msg("config file changed")
1282
1283		if err := v.ReadInConfig(); err != nil {
1284			log.Error().Err(err).Msg("failed to reload config")
1285			return
1286		}
1287
1288		// Re-unmarshal config and rebuild proxy config to pick up all changes
1289		var reloadCfg Config
1290		if err := v.Unmarshal(&reloadCfg); err != nil {
1291			log.Error().Err(err).Msg("failed to unmarshal config on reload")
1292			return
1293		}
1294		if reloadCfg.Server.GordonDomain != "" {
1295			reloadCfg.Server.RegistryDomain = reloadCfg.Server.GordonDomain
1296		}
1297		reloadedProxy, err := buildProxyConfig(reloadCfg, log)
1298		if err != nil {
1299			log.Error().Err(err).Msg("failed to parse proxy config on reload")
1300			return
1301		}
1302		svc.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)
1303
1304		// Clear proxy target cache to pick up external route changes
1305		if err := svc.proxySvc.RefreshTargets(ctx); err != nil {
1306			log.Warn().Err(err).Msg("failed to refresh proxy targets")
1307		}
1308
1309		log.Debug().Msg("config hot reload complete (routes unchanged, memory is source of truth)")
1310	})
1311	v.WatchConfig()
1312}
1313
1314// syncAndAutoStart syncs existing containers and auto-starts if configured.
1315func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
1316	if err := svc.containerSvc.SyncContainers(ctx); err != nil {
1317		log.Warn().Err(err).Msg("failed to sync existing containers")
1318	}
1319
1320	if svc.configSvc.IsAutoRouteEnabled() {
1321		routes := svc.configSvc.GetRoutes(ctx)
1322		if err := svc.containerSvc.AutoStart(domain.WithInternalDeploy(ctx), routes); err != nil {
1323			log.Warn().Err(err).Msg("failed to auto-start containers")
1324		}
1325	}
1326
1327	// Start background monitor to restart crashed containers.
1328	svc.containerSvc.StartMonitor(ctx)
1329}
1330func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler {
1331	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1332		host, _, err := net.SplitHostPort(r.RemoteAddr)
1333		if err != nil {
1334			host = r.RemoteAddr
1335		}
1336
1337		ip := net.ParseIP(host)
1338		if ip == nil || !ip.IsLoopback() {
1339			log.Warn().
1340				Str("path", r.URL.Path).
1341				Str("remote_addr", r.RemoteAddr).
1342				Msg("blocked non-loopback access on internal admin route")
1343			w.Header().Set("Content-Type", "application/json")
1344			w.WriteHeader(http.StatusForbidden)
1345			_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"})
1346			return
1347		}
1348
1349		next.ServeHTTP(w, r)
1350	})
1351}
1352
1353// createHTTPHandlers creates HTTP handlers with middleware.
1354func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) {
1355	// Parse trusted proxies once for all middleware chains.
1356	// This ensures consistent IP extraction across logging, rate limiting, and auth.
1357	trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
1358
1359	registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)
1360	registryWithMiddleware, cidrAllowlistMiddleware, rateLimitMiddleware := buildRegistryHandlerWithMiddleware(
1361		svc,
1362		cfg,
1363		trustedNets,
1364		registryHandler,
1365		log,
1366	)
1367
1368	registryMux := http.NewServeMux()
1369	registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, log)
1370	registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log))
1371
1372	// SECURITY: No CORS middleware on the proxy chain. Backend applications
1373	// should control their own CORS policies. A blanket Access-Control-Allow-Origin: *
1374	// would override backend CORS settings and allow any website to make
1375	// cross-origin authenticated requests to proxied applications.
1376	proxyMiddlewares := []func(http.Handler) http.Handler{
1377		middleware.PanicRecovery(log),
1378		middleware.RequestLogger(log, trustedNets),
1379		middleware.SecurityHeaders,
1380	}
1381
1382	proxyWithMiddleware := otelhttp.NewHandler(
1383		middleware.Chain(proxyMiddlewares...)(svc.proxySvc),
1384		"gordon.proxy",
1385	)
1386	proxyMux := http.NewServeMux()
1387	proxyMux.Handle("/", proxyWithMiddleware)
1388
1389	registerAdminRoutes(registryMux, proxyMux, svc, cfg, trustedNets, log)
1390
1391	return registryMux, proxyMux
1392}
1393
1394func buildRegistryHandlerWithMiddleware(
1395	svc *services,
1396	cfg Config,
1397	trustedNets []*net.IPNet,
1398	registryHandler http.Handler,
1399	log zerowrap.Logger,
1400) (http.Handler, func(http.Handler) http.Handler, func(http.Handler) http.Handler) {
1401	registryMiddlewares := []func(http.Handler) http.Handler{
1402		middleware.PanicRecovery(log),
1403		middleware.RequestLogger(log, trustedNets),
1404		middleware.SecurityHeaders,
1405	}
1406
1407	cidrAllowlistMiddleware := buildRegistryCIDRAllowlistMiddleware(cfg, trustedNets, log)
1408	if cidrAllowlistMiddleware != nil {
1409		registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware)
1410	}
1411
1412	rateLimitMiddleware := buildRegistryRateLimitMiddleware(cfg, log)
1413	registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
1414
1415	appendRegistryAuthMiddleware(&registryMiddlewares, svc, cfg, log)
1416
1417	registryWithOtel := otelhttp.NewHandler(
1418		middleware.Chain(registryMiddlewares...)(registryHandler),
1419		"gordon.registry",
1420	)
1421	return registryWithOtel, cidrAllowlistMiddleware, rateLimitMiddleware
1422}
1423
1424func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
1425	if len(cfg.Server.RegistryAllowedIPs) == 0 {
1426		return nil
1427	}
1428
1429	allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs)
1430	if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) {
1431		for _, entry := range cfg.Server.RegistryAllowedIPs {
1432			if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 {
1433				log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry")
1434			}
1435		}
1436	}
1437
1438	if len(allowedNets) == 0 {
1439		log.Error().
1440			Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs).
1441			Msg("registry_allowed_ips is set but no valid entries were parsed; registry will deny all traffic (fail-closed)")
1442		return func(next http.Handler) http.Handler {
1443			return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1444				log.Warn().
1445					Str(zerowrap.FieldPath, r.URL.Path).
1446					Str(zerowrap.FieldClientIP, middleware.GetClientIP(r, trustedNets)).
1447					Msg("registry access denied due to invalid registry_allowed_ips configuration")
1448				w.Header().Set("Content-Type", "application/json")
1449				w.WriteHeader(http.StatusForbidden)
1450				_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"})
1451			})
1452		}
1453	}
1454
1455	return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)
1456}
1457
1458func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler {
1459	if cfg.API.RateLimit.Enabled {
1460		globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
1461		ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1462		return registry.RateLimitMiddleware(
1463			globalLimiter,
1464			ipLimiter,
1465			cfg.API.RateLimit.TrustedProxies,
1466			log,
1467		)
1468	}
1469
1470	return registry.RateLimitMiddleware(nil, nil, nil, log)
1471}
1472
1473func appendRegistryAuthMiddleware(registryMiddlewares *[]func(http.Handler) http.Handler, svc *services, cfg Config, log zerowrap.Logger) {
1474	if svc.authSvc != nil {
1475		internalAuth := middleware.InternalRegistryAuth{
1476			Username: svc.internalRegUser,
1477			Password: svc.internalRegPass,
1478		}
1479		*registryMiddlewares = append(*registryMiddlewares, middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
1480		return
1481	}
1482
1483	if cfg.Auth.Enabled {
1484		log.Error().Msg("authentication service unavailable; registry requests will be denied")
1485		*registryMiddlewares = append(*registryMiddlewares, func(next http.Handler) http.Handler {
1486			return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1487				w.Header().Set("Content-Type", "application/json")
1488				w.WriteHeader(http.StatusServiceUnavailable)
1489				_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"})
1490			})
1491		})
1492	}
1493}
1494
1495func registerAuthRoutes(
1496	registryMux *http.ServeMux,
1497	svc *services,
1498	trustedNets []*net.IPNet,
1499	cidrAllowlistMiddleware func(http.Handler) http.Handler,
1500	rateLimitMiddleware func(http.Handler) http.Handler,
1501	log zerowrap.Logger,
1502) {
1503	if svc.authHandler == nil {
1504		return
1505	}
1506
1507	// Auth endpoints are NOT protected by auth - they're where clients authenticate
1508	// but still need rate limiting to prevent brute force attacks.
1509	authMiddlewares := []func(http.Handler) http.Handler{
1510		middleware.PanicRecovery(log),
1511		middleware.RequestLogger(log, trustedNets),
1512		middleware.SecurityHeaders,
1513	}
1514	if cidrAllowlistMiddleware != nil {
1515		authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)
1516	}
1517	authMiddlewares = append(authMiddlewares, rateLimitMiddleware)
1518	authWithMiddleware := otelhttp.NewHandler(
1519		middleware.Chain(authMiddlewares...)(svc.authHandler),
1520		"gordon.auth",
1521	)
1522	registryMux.Handle("/auth/", authWithMiddleware)
1523}
1524
1525func wrapRegistryForLocalMode(registryWithMiddleware http.Handler, cfg Config, log zerowrap.Logger) http.Handler {
1526	if !cfg.Auth.Enabled {
1527		return loopbackOnly(registryWithMiddleware, log)
1528	}
1529	return registryWithMiddleware
1530}
1531
1532func registerAdminRoutes(registryMux, proxyMux *http.ServeMux, svc *services, cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) {
1533	if svc.adminHandler == nil {
1534		return
1535	}
1536
1537	if !cfg.Auth.Enabled {
1538		log.Warn().Msg("auth disabled: admin API endpoints are not registered")
1539		return
1540	}
1541
1542	adminMiddlewares := []func(http.Handler) http.Handler{
1543		middleware.PanicRecovery(log),
1544		middleware.RequestLogger(log, trustedNets),
1545		middleware.SecurityHeaders,
1546	}
1547
1548	if svc.authSvc != nil {
1549		// Create rate limiters for admin API - uses same config as registry.
1550		var globalLimiter, ipLimiter out.RateLimiter
1551		if cfg.API.RateLimit.Enabled {
1552			globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
1553			ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1554		}
1555		adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log))
1556	} else {
1557		adminMiddlewares = append(adminMiddlewares, func(next http.Handler) http.Handler {
1558			return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1559				w.Header().Set("Content-Type", "application/json")
1560				w.WriteHeader(http.StatusServiceUnavailable)
1561				_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"})
1562			})
1563		})
1564	}
1565
1566	adminWithMiddleware := otelhttp.NewHandler(
1567		middleware.Chain(adminMiddlewares...)(svc.adminHandler),
1568		"gordon.admin",
1569	)
1570	registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log))
1571	proxyMux.Handle("/admin/", adminHostOnly(adminWithMiddleware, cfg.Server.RegistryDomain, log))
1572}
1573
1574func adminHostOnly(next http.Handler, allowedHost string, log zerowrap.Logger) http.Handler {
1575	canonicalAllowedHost := canonicalHost(allowedHost)
1576
1577	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1578		host := canonicalHost(r.Host)
1579		if host == "" {
1580			host = canonicalHost(r.URL.Host)
1581		}
1582
1583		if !isLoopbackHost(host) && (canonicalAllowedHost == "" || !strings.EqualFold(host, canonicalAllowedHost)) {
1584			log.Warn().
1585				Str("path", r.URL.Path).
1586				Str("host", r.Host).
1587				Str("allowed_host", canonicalAllowedHost).
1588				Msg("blocked admin request on unauthorized host")
1589			http.NotFound(w, r)
1590			return
1591		}
1592
1593		next.ServeHTTP(w, r)
1594	})
1595}
1596
1597func canonicalHost(rawHost string) string {
1598	host := strings.TrimSpace(rawHost)
1599	if host == "" {
1600		return ""
1601	}
1602
1603	if splitHost, _, err := net.SplitHostPort(host); err == nil {
1604		host = splitHost
1605	}
1606
1607	host = strings.TrimPrefix(host, "[")
1608	host = strings.TrimSuffix(host, "]")
1609	return strings.ToLower(host)
1610}
1611
1612func isLoopbackHost(host string) bool {
1613	if host == "localhost" {
1614		return true
1615	}
1616
1617	ip := net.ParseIP(host)
1618	return ip != nil && ip.IsLoopback()
1619}
1620
1621// runServers starts the HTTP servers and waits for shutdown.
1622// Signal handling notes:
1623// - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext
1624// - SIGUSR1: Triggers config reload without restart
1625// - SIGUSR2: Triggers manual deploy for a specific route
1626// The deferred signal.Stop calls ensure signal handlers are properly
1627// cleaned up before program exit, preventing signal handler leaks.
1628func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, svc *services, log zerowrap.Logger) error {
1629	ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
1630	defer cancel()
1631
1632	// Set up SIGUSR1 for reload.
1633	// Note: signal.Stop must be called (via defer) to release the channel
1634	// and prevent signal handler leaks when the function returns.
1635	reloadChan := make(chan os.Signal, 1)
1636	signal.Notify(reloadChan, syscall.SIGUSR1)
1637	defer signal.Stop(reloadChan)
1638
1639	// Set up SIGUSR2 for manual deploy.
1640	deployChan := make(chan os.Signal, 1)
1641	signal.Notify(deployChan, syscall.SIGUSR2)
1642	defer signal.Stop(deployChan)
1643
1644	errChan := make(chan error, 3)
1645
1646	registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
1647	proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
1648	var tlsReady <-chan struct{}
1649	if cfg.Server.TLSEnabled {
1650		if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" {
1651			return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file")
1652		}
1653		tlsReady = startTLSServer(
1654			fmt.Sprintf(":%d", cfg.Server.TLSPort),
1655			proxyHandler,
1656			"proxy-tls",
1657			cfg.Server.TLSCertFile,
1658			cfg.Server.TLSKeyFile,
1659			errChan,
1660			log,
1661		)
1662	}
1663
1664	// Wait for all enabled servers to bind their ports before auto-starting containers.
1665	// This prevents the race where auto-start pulls from the registry before it's listening.
1666	if err := waitForServerReady(registryReady, errChan); err != nil {
1667		return err
1668	}
1669	if err := waitForServerReady(proxyReady, errChan); err != nil {
1670		return err
1671	}
1672	if cfg.Server.TLSEnabled {
1673		if err := waitForServerReady(tlsReady, errChan); err != nil {
1674			return err
1675		}
1676	}
1677
1678	logEvent := log.Info().
1679		Int("proxy_port", cfg.Server.Port).
1680		Int("registry_port", cfg.Server.RegistryPort)
1681	if cfg.Server.TLSEnabled {
1682		logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort)
1683	}
1684	logEvent.Msg("Gordon is running")
1685
1686	schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)
1687	if err != nil {
1688		return err
1689	}
1690	if schedulerCleanup != nil {
1691		defer schedulerCleanup()
1692	}
1693
1694	// Auto-start after servers are listening (registry port is now bound).
1695	syncAndAutoStart(ctx, svc, log)
1696
1697	waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log)
1698	gracefulShutdown(registrySrv, proxySrv, containerSvc, log)
1699	return nil
1700}
1701
1702func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
1703	select {
1704	case <-ready:
1705		return nil
1706	case err := <-errChan:
1707		return err
1708	}
1709}
1710
1711func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, v *viper.Viper) (func(), error) {
1712	schedulers := make([]*cronSvc.Scheduler, 0, 2)
1713
1714	backupScheduler, err := startBackupScheduler(ctx, cfg, svc, log)
1715	if err != nil {
1716		return nil, err
1717	}
1718	if backupScheduler != nil {
1719		schedulers = append(schedulers, backupScheduler)
1720	}
1721
1722	imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int {
1723		return v.GetInt("images.prune.keep_last")
1724	})
1725	if err != nil {
1726		return nil, err
1727	}
1728	if imageScheduler != nil {
1729		schedulers = append(schedulers, imageScheduler)
1730	}
1731
1732	if len(schedulers) == 0 {
1733		return nil, nil
1734	}
1735
1736	return func() {
1737		for i := len(schedulers) - 1; i >= 0; i-- {
1738			schedulers[i].Stop()
1739		}
1740	}, nil
1741}
1742
1743func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {
1744	if !cfg.Backups.Enabled || svc == nil || svc.backupSvc == nil {
1745		return nil, nil
1746	}
1747
1748	preset, err := resolveBackupSchedule(cfg.Backups.Schedule)
1749	if err != nil {
1750		return nil, err
1751	}
1752
1753	scheduler := cronSvc.NewScheduler(log)
1754	err = scheduler.Add(
1755		"backup-scheduler",
1756		"Backups",
1757		domain.CronSchedule{Preset: preset},
1758		func(jobCtx context.Context) error {
1759			if err := svc.backupSvc.RunForSchedule(jobCtx, preset); err != nil {
1760				return err
1761			}
1762			log.Info().
1763				Str("schedule", string(preset)).
1764				Msg("scheduled backup run complete")
1765			return nil
1766		},
1767	)
1768	if err != nil {
1769		return nil, log.WrapErr(err, "failed to register backup schedule")
1770	}
1771
1772	scheduler.Start(ctx)
1773	log.Info().
1774		Str("schedule", string(preset)).
1775		Msg("backup scheduler enabled")
1776
1777	return scheduler, nil
1778}
1779
1780func resolveBackupSchedule(raw string) (domain.BackupSchedule, error) {
1781	return resolveSchedulePreset(raw, "backups.schedule", domain.ScheduleDaily)
1782}
1783
1784func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, keepLastGetter func() int) (*cronSvc.Scheduler, error) {
1785	if !cfg.Images.Prune.Enabled || svc == nil || svc.imageSvc == nil {
1786		return nil, nil
1787	}
1788	if keepLastGetter == nil {
1789		keepLastGetter = func() int { return cfg.Images.Prune.KeepLast }
1790	}
1791	if keepLastGetter() < 0 {
1792		return nil, fmt.Errorf("images.prune.keep_last must be >= 0")
1793	}
1794
1795	preset, err := resolveImagePruneSchedule(cfg.Images.Prune.Schedule)
1796	if err != nil {
1797		return nil, err
1798	}
1799
1800	scheduler := cronSvc.NewScheduler(log)
1801	err = scheduler.Add(
1802		"image-prune",
1803		"Image prune",
1804		domain.CronSchedule{Preset: preset},
1805		func(jobCtx context.Context) error {
1806			keepLast := keepLastGetter()
1807			if keepLast < 0 {
1808				log.Warn().
1809					Int("configured_keep_last", keepLast).
1810					Int("fallback_keep_last", domain.DefaultImagePruneKeepLast).
1811					Msg("invalid images.prune.keep_last; using default")
1812				keepLast = domain.DefaultImagePruneKeepLast
1813			}
1814
1815			report, err := svc.imageSvc.Prune(jobCtx, keepLast)
1816			if err != nil {
1817				return err
1818			}
1819
1820			log.Info().
1821				Int("keep_last", keepLast).
1822				Int("runtime_deleted", report.Runtime.DeletedCount).
1823				Int64("runtime_reclaimed_bytes", report.Runtime.SpaceReclaimed).
1824				Int("registry_tags_removed", report.Registry.TagsRemoved).
1825				Int("registry_blobs_removed", report.Registry.BlobsRemoved).
1826				Msg("scheduled image prune complete")
1827			return nil
1828		},
1829	)
1830	if err != nil {
1831		return nil, log.WrapErr(err, "failed to register image prune schedule")
1832	}
1833
1834	scheduler.Start(ctx)
1835	log.Info().
1836		Str("schedule", string(preset)).
1837		Int("keep_last", keepLastGetter()).
1838		Msg("image prune scheduler enabled")
1839
1840	return scheduler, nil
1841}
1842
1843func resolveImagePruneSchedule(raw string) (domain.BackupSchedule, error) {
1844	return resolveSchedulePreset(raw, "images.prune.schedule", domain.ScheduleDaily)
1845}
1846
1847func resolveSchedulePreset(raw, name string, defaultVal domain.BackupSchedule) (domain.BackupSchedule, error) {
1848	schedule := domain.BackupSchedule(strings.ToLower(strings.TrimSpace(raw)))
1849	if schedule == "" {
1850		schedule = defaultVal
1851	}
1852
1853	switch schedule {
1854	case domain.ScheduleHourly, domain.ScheduleDaily, domain.ScheduleWeekly, domain.ScheduleMonthly:
1855		return schedule, nil
1856	default:
1857		return "", fmt.Errorf("%s must be one of: hourly, daily, weekly, monthly", name)
1858	}
1859}
1860
1861// waitForShutdown blocks on the event loop, handling server errors and
1862// Unix signals (reload, deploy, shutdown) until the context is cancelled.
1863func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, deployChan <-chan os.Signal, eventBus out.EventBus, log zerowrap.Logger) {
1864	for {
1865		select {
1866		case err := <-errChan:
1867			log.Error().Err(err).Msg("server error")
1868			return
1869		case <-reloadChan:
1870			log.Info().Msg("reload signal received (SIGUSR1)")
1871			if err := eventBus.Publish(domain.EventManualReload, nil); err != nil {
1872				log.Error().Err(err).Msg("failed to publish manual reload event")
1873			}
1874		case <-deployChan:
1875			log.Info().Msg("deploy signal received (SIGUSR2)")
1876			domainName, err := readDeployRequest()
1877			if err != nil {
1878				log.Error().Err(err).Msg("failed to read deploy request")
1879				continue
1880			}
1881			payload := &domain.ManualDeployPayload{Domain: domainName}
1882			if err := eventBus.Publish(domain.EventManualDeploy, payload); err != nil {
1883				log.Error().Err(err).Str("domain", domainName).Msg("failed to publish manual deploy event")
1884			}
1885		case <-ctx.Done():
1886			log.Info().Msg("shutdown signal received")
1887			return
1888		}
1889	}
1890}
1891
1892// gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
1893// the container service and cleans up runtime files.
1894func gracefulShutdown(registrySrv, proxySrv *http.Server, containerSvc *container.Service, log zerowrap.Logger) {
1895	log.Info().Msg("shutting down Gordon...")
1896
1897	shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
1898	defer shutdownCancel()
1899
1900	if err := registrySrv.Shutdown(shutdownCtx); err != nil {
1901		log.Warn().Err(err).Msg("registry server shutdown error")
1902	}
1903	if err := proxySrv.Shutdown(shutdownCtx); err != nil {
1904		log.Warn().Err(err).Msg("proxy server shutdown error")
1905	}
1906
1907	containerSvc.StopMonitor()
1908
1909	if err := containerSvc.Shutdown(shutdownCtx); err != nil {
1910		log.Warn().Err(err).Msg("error during container shutdown")
1911	}
1912
1913	cleanupInternalCredentials()
1914	log.Info().Msg("Gordon stopped")
1915}
1916
1917// startServer starts an HTTP server, returning the server instance and a channel
1918// that closes once the listening socket is bound. This lets callers wait for the
1919// port to be ready before taking actions that depend on it (e.g. auto-start
1920// pulling from the local registry). The returned *http.Server can be used for
1921// graceful shutdown.
1922func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {
1923	ready := make(chan struct{})
1924
1925	server := &http.Server{
1926		Addr:              addr,
1927		Handler:           handler,
1928		ReadHeaderTimeout: 10 * time.Second,
1929		ReadTimeout:       5 * time.Minute,
1930		WriteTimeout:      5 * time.Minute,
1931		IdleTimeout:       120 * time.Second,
1932		MaxHeaderBytes:    1 << 20,
1933	}
1934
1935	go func() {
1936		log.Info().Str("address", addr).Msgf("%s server starting", name)
1937
1938		ln, err := net.Listen("tcp", addr)
1939		if err != nil {
1940			errChan <- fmt.Errorf("%s server error: %w", name, err)
1941			return
1942		}
1943		close(ready) // signal: port is bound and accepting connections
1944
1945		if err := server.Serve(ln); err != nil && err != http.ErrServerClosed {
1946			errChan <- fmt.Errorf("%s server error: %w", name, err)
1947		}
1948	}()
1949
1950	return server, ready
1951}
1952
1953// startTLSServer starts an HTTPS server with the provided certificate and key.
1954func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile string, errChan chan<- error, log zerowrap.Logger) <-chan struct{} {
1955	ready := make(chan struct{})
1956
1957	go func() {
1958		log.Info().
1959			Str("address", addr).
1960			Str("cert_file", certFile).
1961			Str("key_file", keyFile).
1962			Msgf("%s server starting", name)
1963
1964		server := &http.Server{
1965			Addr:              addr,
1966			Handler:           handler,
1967			ReadHeaderTimeout: 10 * time.Second,
1968			ReadTimeout:       5 * time.Minute,
1969			WriteTimeout:      5 * time.Minute,
1970			IdleTimeout:       120 * time.Second,
1971			MaxHeaderBytes:    1 << 20,
1972		}
1973
1974		ln, err := net.Listen("tcp", addr)
1975		if err != nil {
1976			errChan <- fmt.Errorf("%s server error: %w", name, err)
1977			return
1978		}
1979		close(ready)
1980
1981		if err := server.ServeTLS(ln, certFile, keyFile); err != nil && err != http.ErrServerClosed {
1982			errChan <- fmt.Errorf("%s server error: %w", name, err)
1983		}
1984	}()
1985
1986	return ready
1987}
1988
1989// SendReloadSignal sends SIGUSR1 to the running Gordon process.
1990func SendReloadSignal() error {
1991	process, _, err := findRunningProcess()
1992	if err != nil {
1993		return err
1994	}
1995
1996	if err := process.Signal(syscall.SIGUSR1); err != nil {
1997		return fmt.Errorf("failed to send reload signal: %w", err)
1998	}
1999
2000	return nil
2001}
2002
2003// getDeployRequestFile returns the path to the deploy request file.
2004func getDeployRequestFile() string {
2005	return filepath.Join(os.TempDir(), "gordon-deploy-request")
2006}
2007
2008// writeDeployRequestFile creates the deploy request file exclusively with retry.
2009// This prevents race conditions when multiple deploy commands run simultaneously.
2010func writeDeployRequestFile(path string, data []byte, timeout time.Duration) error {
2011	deadline := time.Now().Add(timeout)
2012
2013	for {
2014		f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
2015		if err == nil {
2016			_, writeErr := f.Write(data)
2017			f.Close()
2018			if writeErr != nil {
2019				_ = os.Remove(path)
2020				return writeErr
2021			}
2022			return nil
2023		}
2024
2025		if os.IsExist(err) {
2026			if time.Now().After(deadline) {
2027				return fmt.Errorf("deploy request file still present after timeout; another deploy may be in progress")
2028			}
2029			time.Sleep(100 * time.Millisecond)
2030			continue
2031		}
2032
2033		return err
2034	}
2035}
2036
2037// SendDeploySignal triggers a manual deploy for a specific route via SIGUSR2.
2038// Returns the domain name on success for the caller to display.
2039func SendDeploySignal(domain string) (string, error) {
2040	deployFile := getDeployRequestFile()
2041	if err := writeDeployRequestFile(deployFile, []byte(domain), 5*time.Second); err != nil {
2042		return "", fmt.Errorf("failed to write deploy request: %w", err)
2043	}
2044
2045	// Find PID and send SIGUSR2
2046	process, _, err := findRunningProcess()
2047	if err != nil {
2048		_ = os.Remove(deployFile)
2049		return "", err
2050	}
2051
2052	if err := process.Signal(syscall.SIGUSR2); err != nil {
2053		_ = os.Remove(deployFile)
2054		return "", fmt.Errorf("failed to send deploy signal: %w", err)
2055	}
2056
2057	return domain, nil
2058}
2059
2060// readDeployRequest reads and removes the deploy request file atomically.
2061// Returns empty string if file doesn't exist (may have been consumed by another handler).
2062func readDeployRequest() (string, error) {
2063	deployFile := getDeployRequestFile()
2064
2065	// Rename to a temp file first to make the read-and-delete atomic
2066	tmpFile := deployFile + ".processing"
2067	if err := os.Rename(deployFile, tmpFile); err != nil {
2068		if os.IsNotExist(err) {
2069			return "", fmt.Errorf("deploy request file not found (may have been processed already)")
2070		}
2071		return "", fmt.Errorf("failed to acquire deploy request: %w", err)
2072	}
2073
2074	data, err := os.ReadFile(tmpFile)
2075	_ = os.Remove(tmpFile) // Always clean up
2076	if err != nil {
2077		return "", fmt.Errorf("failed to read deploy request: %w", err)
2078	}
2079
2080	return string(data), nil
2081}
2082
2083// createPidFile creates a PID file for the Gordon process.
2084// SECURITY: Prefers secure locations (XDG_RUNTIME_DIR, ~/.gordon/run) over /tmp
2085// to prevent symlink attacks and unauthorized access.
2086func createPidFile(log zerowrap.Logger) string {
2087	pid := os.Getpid()
2088
2089	// SECURITY: Prioritize secure locations over /tmp
2090	var locations []string
2091
2092	// Try secure runtime directory first
2093	if runtimeDir, err := getSecureRuntimeDir(); err == nil {
2094		locations = append(locations, filepath.Join(runtimeDir, "gordon.pid"))
2095	}
2096
2097	// Fall back to home directory
2098	if homeDir, err := os.UserHomeDir(); err == nil {
2099		locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid"))
2100	}
2101
2102	// Last resort: /tmp (least secure due to world-writable)
2103	locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid"))
2104
2105	for _, location := range locations {
2106		// Ensure parent directory exists with secure permissions
2107		if err := os.MkdirAll(filepath.Dir(location), 0700); err != nil {
2108			continue
2109		}
2110		if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil {
2111			log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file")
2112			return location
2113		}
2114	}
2115
2116	log.Warn().Int("pid", pid).Msg("failed to create PID file in any location")
2117	return ""
2118}
2119
2120// removePidFile removes the PID file.
2121func removePidFile(pidFile string, log zerowrap.Logger) {
2122	if err := os.Remove(pidFile); err != nil {
2123		log.Warn().Err(err).Str("pid_file", pidFile).Msg("failed to remove PID file")
2124	} else {
2125		log.Debug().Str("pid_file", pidFile).Msg("removed PID file")
2126	}
2127}
2128
2129func pidFileLocations() []string {
2130	var locations []string
2131	seen := make(map[string]struct{})
2132	add := func(path string) {
2133		if path == "" {
2134			return
2135		}
2136		if _, ok := seen[path]; ok {
2137			return
2138		}
2139		seen[path] = struct{}{}
2140		locations = append(locations, path)
2141	}
2142
2143	// Check secure runtime directory first
2144	if runtimeDir, err := getSecureRuntimeDir(); err == nil {
2145		add(filepath.Join(runtimeDir, "gordon.pid"))
2146	}
2147
2148	// Also check canonical /run/user/<uid> runtime path. This handles cases where
2149	// Gordon started under systemd user services with runtime dir available, but
2150	// CLI invocations (e.g. non-interactive SSH) don't have XDG_RUNTIME_DIR set.
2151	runtimeByUID := filepath.Join("/run/user", strconv.Itoa(os.Getuid()), "gordon", "gordon.pid")
2152	add(runtimeByUID)
2153
2154	// Check explicit XDG_RUNTIME_DIR if present in this process env.
2155	if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
2156		add(filepath.Join(runtimeDir, "gordon.pid"))
2157	}
2158
2159	// Check home directory
2160	if homeDir, err := os.UserHomeDir(); err == nil {
2161		add(filepath.Join(homeDir, ".gordon", "gordon.pid"))
2162		// Legacy location for backward compatibility
2163		add(filepath.Join(homeDir, ".gordon.pid"))
2164	}
2165
2166	// Legacy /tmp locations for backward compatibility
2167	add(filepath.Join(os.TempDir(), "gordon.pid"))
2168	add("/tmp/gordon.pid")
2169
2170	return locations
2171}
2172
2173// findRunningPidFile returns the first PID file whose PID belongs to a live process.
2174// Stale/invalid PID files are ignored and removed when possible.
2175func findRunningPidFile() (string, int, error) {
2176	return findRunningPidFileInLocations(pidFileLocations())
2177}
2178
2179func findRunningPidFileInLocations(locations []string) (string, int, error) {
2180	foundAny := false
2181
2182	for _, location := range locations {
2183		pidBytes, err := os.ReadFile(location)
2184		if err != nil {
2185			continue
2186		}
2187
2188		foundAny = true
2189
2190		var pid int
2191		if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil || pid <= 0 {
2192			_ = os.Remove(location)
2193			continue
2194		}
2195
2196		if isProcessAlive(pid) {
2197			return location, pid, nil
2198		}
2199
2200		_ = os.Remove(location)
2201	}
2202
2203	if foundAny {
2204		return "", 0, fmt.Errorf("found stale gordon PID file(s), is Gordon running?")
2205	}
2206
2207	return "", 0, fmt.Errorf("gordon PID file not found, is Gordon running?")
2208}
2209
2210func findRunningProcess() (*os.Process, int, error) {
2211	_, pid, err := findRunningPidFile()
2212	if err != nil {
2213		return nil, 0, err
2214	}
2215
2216	process, err := os.FindProcess(pid)
2217	if err != nil {
2218		return nil, 0, fmt.Errorf("failed to find process: %w", err)
2219	}
2220
2221	return process, pid, nil
2222}
2223
2224func isProcessAlive(pid int) bool {
2225	process, err := os.FindProcess(pid)
2226	if err != nil {
2227		return false
2228	}
2229
2230	err = process.Signal(syscall.Signal(0))
2231	if err == nil {
2232		return true
2233	}
2234
2235	return errors.Is(err, syscall.EPERM)
2236}
2237
2238// loadConfig loads configuration from file and sets defaults.
2239func loadConfig(v *viper.Viper, configPath string) error {
2240	v.SetDefault("server.port", 80)
2241	v.SetDefault("server.registry_port", 5000)
2242	v.SetDefault("server.tls_enabled", false)
2243	v.SetDefault("server.tls_port", 443)
2244	v.SetDefault("server.tls_cert_file", "")
2245	v.SetDefault("server.tls_key_file", "")
2246	v.SetDefault("server.data_dir", DefaultDataDir())
2247	v.SetDefault("logging.level", "info")
2248	v.SetDefault("logging.format", "console")
2249	v.SetDefault("logging.file.enabled", false)
2250	v.SetDefault("logging.file.max_size", 100)
2251	v.SetDefault("logging.file.max_backups", 3)
2252	v.SetDefault("logging.file.max_age", 28)
2253	v.SetDefault("logging.container_logs.enabled", true)
2254	v.SetDefault("logging.container_logs.dir", "")
2255	v.SetDefault("logging.container_logs.max_size", 100)
2256	v.SetDefault("logging.container_logs.max_backups", 3)
2257	v.SetDefault("logging.container_logs.max_age", 28)
2258	v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
2259	v.SetDefault("auth.enabled", true)
2260	// Note: auth.type is intentionally not set - it's inferred from config
2261	// If password_hash is set -> password mode, otherwise -> token mode
2262	v.SetDefault("auth.secrets_backend", "unsafe")
2263	v.SetDefault("auth.token_expiry", "720h")
2264	v.SetDefault("api.rate_limit.enabled", true)
2265	v.SetDefault("api.rate_limit.global_rps", 500)
2266	v.SetDefault("api.rate_limit.per_ip_rps", 50)
2267	v.SetDefault("api.rate_limit.burst", 100)
2268	v.SetDefault("auto_route.enabled", false)
2269	v.SetDefault("network_isolation.enabled", false)
2270	v.SetDefault("network_isolation.network_prefix", "gordon")
2271	v.SetDefault("network_isolation.dns_suffix", ".internal")
2272	v.SetDefault("volumes.auto_create", true)
2273	v.SetDefault("volumes.prefix", "gordon")
2274	v.SetDefault("volumes.preserve", true)
2275	v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
2276	v.SetDefault("backups.enabled", false)
2277	v.SetDefault("backups.schedule", string(domain.ScheduleDaily))
2278	v.SetDefault("backups.storage_dir", "")
2279	v.SetDefault("backups.retention.hourly", 0)
2280	v.SetDefault("backups.retention.daily", 0)
2281	v.SetDefault("backups.retention.weekly", 0)
2282	v.SetDefault("backups.retention.monthly", 0)
2283	v.SetDefault("images.prune.enabled", false)
2284	v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))
2285	v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast)
2286	v.SetDefault("telemetry.enabled", false)
2287	v.SetDefault("telemetry.endpoint", "")
2288	v.SetDefault("telemetry.auth_token", "")
2289	v.SetDefault("telemetry.traces", true)
2290	v.SetDefault("telemetry.metrics", true)
2291	v.SetDefault("telemetry.logs", true)
2292	v.SetDefault("telemetry.trace_sample_rate", 1.0)
2293
2294	v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
2295	v.SetDefault("server.registry_allowed_ips", []string{})
2296	v.SetDefault("deploy.readiness_delay", "5s")
2297	v.SetDefault("deploy.readiness_mode", "auto")
2298	v.SetDefault("deploy.health_timeout", "90s")
2299	v.SetDefault("deploy.drain_mode", "auto")
2300	v.SetDefault("deploy.drain_timeout", "30s")
2301
2302	ConfigureViper(v, configPath)
2303
2304	if err := v.ReadInConfig(); err != nil {
2305		if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
2306			return fmt.Errorf("failed to read config file: %w", err)
2307		}
2308	}
2309
2310	v.SetEnvPrefix("GORDON")
2311	v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
2312	v.AutomaticEnv()
2313
2314	return nil
2315}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close