v2.15.0 cf76057

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

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