v2.15.1 c808592

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), cfg.Logging.File.Enabled, 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	// Start background monitor to restart crashed containers.
1259	svc.containerSvc.StartMonitor(ctx)
1260}
1261
1262// createHTTPHandlers creates HTTP handlers with middleware.
1263func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) {
1264	// Parse trusted proxies once for all middleware chains.
1265	// This ensures consistent IP extraction across logging, rate limiting, and auth.
1266	trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
1267
1268	registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)
1269
1270	registryMiddlewares := []func(http.Handler) http.Handler{
1271		middleware.PanicRecovery(log),
1272		middleware.RequestLogger(log, trustedNets),
1273		middleware.SecurityHeaders,
1274	}
1275
1276	// CIDR allowlist for registry access (e.g., restrict to Tailscale IPs).
1277	// Inserted before rate limiting so denied IPs never consume limiter tokens.
1278	var cidrAllowlistMiddleware func(http.Handler) http.Handler
1279	if len(cfg.Server.RegistryAllowedIPs) > 0 {
1280		allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs)
1281		if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) {
1282			for _, entry := range cfg.Server.RegistryAllowedIPs {
1283				if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 {
1284					log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry")
1285				}
1286			}
1287		}
1288		if len(allowedNets) == 0 {
1289			log.Error().
1290				Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs).
1291				Msg("registry_allowed_ips is set but no valid entries were parsed; registry will allow all traffic")
1292		}
1293		cidrAllowlistMiddleware = middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)
1294		registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware)
1295	}
1296
1297	// Create rate limiters using the factory
1298	var rateLimitMiddleware func(http.Handler) http.Handler
1299	if cfg.API.RateLimit.Enabled {
1300		globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
1301		ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1302		rateLimitMiddleware = registry.RateLimitMiddleware(
1303			globalLimiter,
1304			ipLimiter,
1305			cfg.API.RateLimit.TrustedProxies,
1306			log,
1307		)
1308	} else {
1309		rateLimitMiddleware = registry.RateLimitMiddleware(nil, nil, nil, log)
1310	}
1311	registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
1312
1313	if cfg.Auth.Enabled && svc.authSvc != nil {
1314		internalAuth := middleware.InternalRegistryAuth{
1315			Username: svc.internalRegUser,
1316			Password: svc.internalRegPass,
1317		}
1318		registryMiddlewares = append(registryMiddlewares,
1319			middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
1320	}
1321
1322	registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler)
1323
1324	// Create a mux that routes:
1325	// - /auth/* to auth handler (no auth required, for password login and token exchange)
1326	// - /v2/* to registry handler (with auth)
1327	// - /admin/* to admin handler (with auth)
1328	registryMux := http.NewServeMux()
1329	if svc.authHandler != nil {
1330		// Auth endpoints are NOT protected by auth - they're where clients authenticate
1331		// but still need rate limiting to prevent brute force attacks
1332		authMiddlewares := []func(http.Handler) http.Handler{
1333			middleware.PanicRecovery(log),
1334			middleware.RequestLogger(log, trustedNets),
1335			middleware.SecurityHeaders,
1336		}
1337		if cidrAllowlistMiddleware != nil {
1338			authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)
1339		}
1340		authMiddlewares = append(authMiddlewares, rateLimitMiddleware)
1341		authWithMiddleware := middleware.Chain(authMiddlewares...)(svc.authHandler)
1342		registryMux.Handle("/auth/", authWithMiddleware)
1343	}
1344	registryMux.Handle("/v2/", registryWithMiddleware)
1345
1346	// Add admin API handler with auth middleware
1347	if svc.adminHandler != nil {
1348		adminMiddlewares := []func(http.Handler) http.Handler{
1349			middleware.PanicRecovery(log),
1350			middleware.RequestLogger(log, trustedNets),
1351			middleware.SecurityHeaders,
1352		}
1353		// Add admin auth middleware if auth is enabled
1354		if svc.authSvc != nil {
1355			// Create rate limiters for admin API - uses same config as registry
1356			var globalLimiter, ipLimiter out.RateLimiter
1357			if cfg.API.RateLimit.Enabled {
1358				globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
1359				ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1360			}
1361			adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log))
1362		}
1363		adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
1364		registryMux.Handle("/admin/", adminWithMiddleware)
1365	}
1366
1367	// SECURITY: No CORS middleware on the proxy chain. Backend applications
1368	// should control their own CORS policies. A blanket Access-Control-Allow-Origin: *
1369	// would override backend CORS settings and allow any website to make
1370	// cross-origin authenticated requests to proxied applications.
1371	proxyMiddlewares := []func(http.Handler) http.Handler{
1372		middleware.PanicRecovery(log),
1373		middleware.RequestLogger(log, trustedNets),
1374		middleware.SecurityHeaders,
1375	}
1376
1377	proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc)
1378
1379	return registryMux, proxyWithMiddleware
1380}
1381
1382// runServers starts the HTTP servers and waits for shutdown.
1383// Signal handling notes:
1384// - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext
1385// - SIGUSR1: Triggers config reload without restart
1386// - SIGUSR2: Triggers manual deploy for a specific route
1387// The deferred signal.Stop calls ensure signal handlers are properly
1388// cleaned up before program exit, preventing signal handler leaks.
1389func 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 {
1390	ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
1391	defer cancel()
1392
1393	// Set up SIGUSR1 for reload.
1394	// Note: signal.Stop must be called (via defer) to release the channel
1395	// and prevent signal handler leaks when the function returns.
1396	reloadChan := make(chan os.Signal, 1)
1397	signal.Notify(reloadChan, syscall.SIGUSR1)
1398	defer signal.Stop(reloadChan)
1399
1400	// Set up SIGUSR2 for manual deploy.
1401	deployChan := make(chan os.Signal, 1)
1402	signal.Notify(deployChan, syscall.SIGUSR2)
1403	defer signal.Stop(deployChan)
1404
1405	errChan := make(chan error, 3)
1406
1407	registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
1408	proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
1409	var tlsReady <-chan struct{}
1410	if cfg.Server.TLSEnabled {
1411		if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" {
1412			return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file")
1413		}
1414		tlsReady = startTLSServer(
1415			fmt.Sprintf(":%d", cfg.Server.TLSPort),
1416			proxyHandler,
1417			"proxy-tls",
1418			cfg.Server.TLSCertFile,
1419			cfg.Server.TLSKeyFile,
1420			errChan,
1421			log,
1422		)
1423	}
1424
1425	// Wait for all enabled servers to bind their ports before auto-starting containers.
1426	// This prevents the race where auto-start pulls from the registry before it's listening.
1427	if err := waitForServerReady(registryReady, errChan); err != nil {
1428		return err
1429	}
1430	if err := waitForServerReady(proxyReady, errChan); err != nil {
1431		return err
1432	}
1433	if cfg.Server.TLSEnabled {
1434		if err := waitForServerReady(tlsReady, errChan); err != nil {
1435			return err
1436		}
1437	}
1438
1439	logEvent := log.Info().
1440		Int("proxy_port", cfg.Server.Port).
1441		Int("registry_port", cfg.Server.RegistryPort)
1442	if cfg.Server.TLSEnabled {
1443		logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort)
1444	}
1445	logEvent.Msg("Gordon is running")
1446
1447	schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)
1448	if err != nil {
1449		return err
1450	}
1451	if schedulerCleanup != nil {
1452		defer schedulerCleanup()
1453	}
1454
1455	// Auto-start after servers are listening (registry port is now bound).
1456	syncAndAutoStart(ctx, svc, log)
1457
1458	waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log)
1459	gracefulShutdown(registrySrv, proxySrv, containerSvc, log)
1460	return nil
1461}
1462
1463func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
1464	select {
1465	case <-ready:
1466		return nil
1467	case err := <-errChan:
1468		return err
1469	}
1470}
1471
1472func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, v *viper.Viper) (func(), error) {
1473	schedulers := make([]*cronSvc.Scheduler, 0, 2)
1474
1475	backupScheduler, err := startBackupScheduler(ctx, cfg, svc, log)
1476	if err != nil {
1477		return nil, err
1478	}
1479	if backupScheduler != nil {
1480		schedulers = append(schedulers, backupScheduler)
1481	}
1482
1483	imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int {
1484		return v.GetInt("images.prune.keep_last")
1485	})
1486	if err != nil {
1487		return nil, err
1488	}
1489	if imageScheduler != nil {
1490		schedulers = append(schedulers, imageScheduler)
1491	}
1492
1493	if len(schedulers) == 0 {
1494		return nil, nil
1495	}
1496
1497	return func() {
1498		for i := len(schedulers) - 1; i >= 0; i-- {
1499			schedulers[i].Stop()
1500		}
1501	}, nil
1502}
1503
1504func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {
1505	if !cfg.Backups.Enabled || svc == nil || svc.backupSvc == nil {
1506		return nil, nil
1507	}
1508
1509	preset, err := resolveBackupSchedule(cfg.Backups.Schedule)
1510	if err != nil {
1511		return nil, err
1512	}
1513
1514	scheduler := cronSvc.NewScheduler(log)
1515	err = scheduler.Add(
1516		"backup-scheduler",
1517		"Backups",
1518		domain.CronSchedule{Preset: preset},
1519		func(jobCtx context.Context) error {
1520			if err := svc.backupSvc.RunForSchedule(jobCtx, preset); err != nil {
1521				return err
1522			}
1523			log.Info().
1524				Str("schedule", string(preset)).
1525				Msg("scheduled backup run complete")
1526			return nil
1527		},
1528	)
1529	if err != nil {
1530		return nil, log.WrapErr(err, "failed to register backup schedule")
1531	}
1532
1533	scheduler.Start(ctx)
1534	log.Info().
1535		Str("schedule", string(preset)).
1536		Msg("backup scheduler enabled")
1537
1538	return scheduler, nil
1539}
1540
1541func resolveBackupSchedule(raw string) (domain.BackupSchedule, error) {
1542	return resolveSchedulePreset(raw, "backups.schedule", domain.ScheduleDaily)
1543}
1544
1545func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, keepLastGetter func() int) (*cronSvc.Scheduler, error) {
1546	if !cfg.Images.Prune.Enabled || svc == nil || svc.imageSvc == nil {
1547		return nil, nil
1548	}
1549	if keepLastGetter == nil {
1550		keepLastGetter = func() int { return cfg.Images.Prune.KeepLast }
1551	}
1552	if keepLastGetter() < 0 {
1553		return nil, fmt.Errorf("images.prune.keep_last must be >= 0")
1554	}
1555
1556	preset, err := resolveImagePruneSchedule(cfg.Images.Prune.Schedule)
1557	if err != nil {
1558		return nil, err
1559	}
1560
1561	scheduler := cronSvc.NewScheduler(log)
1562	err = scheduler.Add(
1563		"image-prune",
1564		"Image prune",
1565		domain.CronSchedule{Preset: preset},
1566		func(jobCtx context.Context) error {
1567			keepLast := keepLastGetter()
1568			if keepLast < 0 {
1569				log.Warn().
1570					Int("configured_keep_last", keepLast).
1571					Int("fallback_keep_last", domain.DefaultImagePruneKeepLast).
1572					Msg("invalid images.prune.keep_last; using default")
1573				keepLast = domain.DefaultImagePruneKeepLast
1574			}
1575
1576			report, err := svc.imageSvc.Prune(jobCtx, keepLast)
1577			if err != nil {
1578				return err
1579			}
1580
1581			log.Info().
1582				Int("keep_last", keepLast).
1583				Int("runtime_deleted", report.Runtime.DeletedCount).
1584				Int64("runtime_reclaimed_bytes", report.Runtime.SpaceReclaimed).
1585				Int("registry_tags_removed", report.Registry.TagsRemoved).
1586				Int("registry_blobs_removed", report.Registry.BlobsRemoved).
1587				Msg("scheduled image prune complete")
1588			return nil
1589		},
1590	)
1591	if err != nil {
1592		return nil, log.WrapErr(err, "failed to register image prune schedule")
1593	}
1594
1595	scheduler.Start(ctx)
1596	log.Info().
1597		Str("schedule", string(preset)).
1598		Int("keep_last", keepLastGetter()).
1599		Msg("image prune scheduler enabled")
1600
1601	return scheduler, nil
1602}
1603
1604func resolveImagePruneSchedule(raw string) (domain.BackupSchedule, error) {
1605	return resolveSchedulePreset(raw, "images.prune.schedule", domain.ScheduleDaily)
1606}
1607
1608func resolveSchedulePreset(raw, name string, defaultVal domain.BackupSchedule) (domain.BackupSchedule, error) {
1609	schedule := domain.BackupSchedule(strings.ToLower(strings.TrimSpace(raw)))
1610	if schedule == "" {
1611		schedule = defaultVal
1612	}
1613
1614	switch schedule {
1615	case domain.ScheduleHourly, domain.ScheduleDaily, domain.ScheduleWeekly, domain.ScheduleMonthly:
1616		return schedule, nil
1617	default:
1618		return "", fmt.Errorf("%s must be one of: hourly, daily, weekly, monthly", name)
1619	}
1620}
1621
1622// waitForShutdown blocks on the event loop, handling server errors and
1623// Unix signals (reload, deploy, shutdown) until the context is cancelled.
1624func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, deployChan <-chan os.Signal, eventBus out.EventBus, log zerowrap.Logger) {
1625	for {
1626		select {
1627		case err := <-errChan:
1628			log.Error().Err(err).Msg("server error")
1629			return
1630		case <-reloadChan:
1631			log.Info().Msg("reload signal received (SIGUSR1)")
1632			if err := eventBus.Publish(domain.EventManualReload, nil); err != nil {
1633				log.Error().Err(err).Msg("failed to publish manual reload event")
1634			}
1635		case <-deployChan:
1636			log.Info().Msg("deploy signal received (SIGUSR2)")
1637			domainName, err := readDeployRequest()
1638			if err != nil {
1639				log.Error().Err(err).Msg("failed to read deploy request")
1640				continue
1641			}
1642			payload := &domain.ManualDeployPayload{Domain: domainName}
1643			if err := eventBus.Publish(domain.EventManualDeploy, payload); err != nil {
1644				log.Error().Err(err).Str("domain", domainName).Msg("failed to publish manual deploy event")
1645			}
1646		case <-ctx.Done():
1647			log.Info().Msg("shutdown signal received")
1648			return
1649		}
1650	}
1651}
1652
1653// gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
1654// the container service and cleans up runtime files.
1655func gracefulShutdown(registrySrv, proxySrv *http.Server, containerSvc *container.Service, log zerowrap.Logger) {
1656	log.Info().Msg("shutting down Gordon...")
1657
1658	shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
1659	defer shutdownCancel()
1660
1661	if err := registrySrv.Shutdown(shutdownCtx); err != nil {
1662		log.Warn().Err(err).Msg("registry server shutdown error")
1663	}
1664	if err := proxySrv.Shutdown(shutdownCtx); err != nil {
1665		log.Warn().Err(err).Msg("proxy server shutdown error")
1666	}
1667
1668	containerSvc.StopMonitor()
1669
1670	if err := containerSvc.Shutdown(shutdownCtx); err != nil {
1671		log.Warn().Err(err).Msg("error during container shutdown")
1672	}
1673
1674	cleanupInternalCredentials()
1675	log.Info().Msg("Gordon stopped")
1676}
1677
1678// startServer starts an HTTP server, returning the server instance and a channel
1679// that closes once the listening socket is bound. This lets callers wait for the
1680// port to be ready before taking actions that depend on it (e.g. auto-start
1681// pulling from the local registry). The returned *http.Server can be used for
1682// graceful shutdown.
1683func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {
1684	ready := make(chan struct{})
1685
1686	server := &http.Server{
1687		Addr:              addr,
1688		Handler:           handler,
1689		ReadHeaderTimeout: 10 * time.Second,
1690		ReadTimeout:       5 * time.Minute,
1691		WriteTimeout:      5 * time.Minute,
1692		IdleTimeout:       120 * time.Second,
1693		MaxHeaderBytes:    1 << 20,
1694	}
1695
1696	go func() {
1697		log.Info().Str("address", addr).Msgf("%s server starting", name)
1698
1699		ln, err := net.Listen("tcp", addr)
1700		if err != nil {
1701			errChan <- fmt.Errorf("%s server error: %w", name, err)
1702			return
1703		}
1704		close(ready) // signal: port is bound and accepting connections
1705
1706		if err := server.Serve(ln); err != nil && err != http.ErrServerClosed {
1707			errChan <- fmt.Errorf("%s server error: %w", name, err)
1708		}
1709	}()
1710
1711	return server, ready
1712}
1713
1714// startTLSServer starts an HTTPS server with the provided certificate and key.
1715func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile string, errChan chan<- error, log zerowrap.Logger) <-chan struct{} {
1716	ready := make(chan struct{})
1717
1718	go func() {
1719		log.Info().
1720			Str("address", addr).
1721			Str("cert_file", certFile).
1722			Str("key_file", keyFile).
1723			Msgf("%s server starting", name)
1724
1725		server := &http.Server{
1726			Addr:              addr,
1727			Handler:           handler,
1728			ReadHeaderTimeout: 10 * time.Second,
1729			ReadTimeout:       5 * time.Minute,
1730			WriteTimeout:      5 * time.Minute,
1731			IdleTimeout:       120 * time.Second,
1732			MaxHeaderBytes:    1 << 20,
1733		}
1734
1735		ln, err := net.Listen("tcp", addr)
1736		if err != nil {
1737			errChan <- fmt.Errorf("%s server error: %w", name, err)
1738			return
1739		}
1740		close(ready)
1741
1742		if err := server.ServeTLS(ln, certFile, keyFile); err != nil && err != http.ErrServerClosed {
1743			errChan <- fmt.Errorf("%s server error: %w", name, err)
1744		}
1745	}()
1746
1747	return ready
1748}
1749
1750// SendReloadSignal sends SIGUSR1 to the running Gordon process.
1751func SendReloadSignal() error {
1752	process, _, err := findRunningProcess()
1753	if err != nil {
1754		return err
1755	}
1756
1757	if err := process.Signal(syscall.SIGUSR1); err != nil {
1758		return fmt.Errorf("failed to send reload signal: %w", err)
1759	}
1760
1761	return nil
1762}
1763
1764// getDeployRequestFile returns the path to the deploy request file.
1765func getDeployRequestFile() string {
1766	return filepath.Join(os.TempDir(), "gordon-deploy-request")
1767}
1768
1769// writeDeployRequestFile creates the deploy request file exclusively with retry.
1770// This prevents race conditions when multiple deploy commands run simultaneously.
1771func writeDeployRequestFile(path string, data []byte, timeout time.Duration) error {
1772	deadline := time.Now().Add(timeout)
1773
1774	for {
1775		f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
1776		if err == nil {
1777			_, writeErr := f.Write(data)
1778			f.Close()
1779			if writeErr != nil {
1780				_ = os.Remove(path)
1781				return writeErr
1782			}
1783			return nil
1784		}
1785
1786		if os.IsExist(err) {
1787			if time.Now().After(deadline) {
1788				return fmt.Errorf("deploy request file still present after timeout; another deploy may be in progress")
1789			}
1790			time.Sleep(100 * time.Millisecond)
1791			continue
1792		}
1793
1794		return err
1795	}
1796}
1797
1798// SendDeploySignal triggers a manual deploy for a specific route via SIGUSR2.
1799// Returns the domain name on success for the caller to display.
1800func SendDeploySignal(domain string) (string, error) {
1801	deployFile := getDeployRequestFile()
1802	if err := writeDeployRequestFile(deployFile, []byte(domain), 5*time.Second); err != nil {
1803		return "", fmt.Errorf("failed to write deploy request: %w", err)
1804	}
1805
1806	// Find PID and send SIGUSR2
1807	process, _, err := findRunningProcess()
1808	if err != nil {
1809		_ = os.Remove(deployFile)
1810		return "", err
1811	}
1812
1813	if err := process.Signal(syscall.SIGUSR2); err != nil {
1814		_ = os.Remove(deployFile)
1815		return "", fmt.Errorf("failed to send deploy signal: %w", err)
1816	}
1817
1818	return domain, nil
1819}
1820
1821// readDeployRequest reads and removes the deploy request file atomically.
1822// Returns empty string if file doesn't exist (may have been consumed by another handler).
1823func readDeployRequest() (string, error) {
1824	deployFile := getDeployRequestFile()
1825
1826	// Rename to a temp file first to make the read-and-delete atomic
1827	tmpFile := deployFile + ".processing"
1828	if err := os.Rename(deployFile, tmpFile); err != nil {
1829		if os.IsNotExist(err) {
1830			return "", fmt.Errorf("deploy request file not found (may have been processed already)")
1831		}
1832		return "", fmt.Errorf("failed to acquire deploy request: %w", err)
1833	}
1834
1835	data, err := os.ReadFile(tmpFile)
1836	_ = os.Remove(tmpFile) // Always clean up
1837	if err != nil {
1838		return "", fmt.Errorf("failed to read deploy request: %w", err)
1839	}
1840
1841	return string(data), nil
1842}
1843
1844// createPidFile creates a PID file for the Gordon process.
1845// SECURITY: Prefers secure locations (XDG_RUNTIME_DIR, ~/.gordon/run) over /tmp
1846// to prevent symlink attacks and unauthorized access.
1847func createPidFile(log zerowrap.Logger) string {
1848	pid := os.Getpid()
1849
1850	// SECURITY: Prioritize secure locations over /tmp
1851	var locations []string
1852
1853	// Try secure runtime directory first
1854	if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1855		locations = append(locations, filepath.Join(runtimeDir, "gordon.pid"))
1856	}
1857
1858	// Fall back to home directory
1859	if homeDir, err := os.UserHomeDir(); err == nil {
1860		locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid"))
1861	}
1862
1863	// Last resort: /tmp (least secure due to world-writable)
1864	locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid"))
1865
1866	for _, location := range locations {
1867		// Ensure parent directory exists with secure permissions
1868		if err := os.MkdirAll(filepath.Dir(location), 0700); err != nil {
1869			continue
1870		}
1871		if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil {
1872			log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file")
1873			return location
1874		}
1875	}
1876
1877	log.Warn().Int("pid", pid).Msg("failed to create PID file in any location")
1878	return ""
1879}
1880
1881// removePidFile removes the PID file.
1882func removePidFile(pidFile string, log zerowrap.Logger) {
1883	if err := os.Remove(pidFile); err != nil {
1884		log.Warn().Err(err).Str("pid_file", pidFile).Msg("failed to remove PID file")
1885	} else {
1886		log.Debug().Str("pid_file", pidFile).Msg("removed PID file")
1887	}
1888}
1889
1890func pidFileLocations() []string {
1891	var locations []string
1892	seen := make(map[string]struct{})
1893	add := func(path string) {
1894		if path == "" {
1895			return
1896		}
1897		if _, ok := seen[path]; ok {
1898			return
1899		}
1900		seen[path] = struct{}{}
1901		locations = append(locations, path)
1902	}
1903
1904	// Check secure runtime directory first
1905	if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1906		add(filepath.Join(runtimeDir, "gordon.pid"))
1907	}
1908
1909	// Also check canonical /run/user/<uid> runtime path. This handles cases where
1910	// Gordon started under systemd user services with runtime dir available, but
1911	// CLI invocations (e.g. non-interactive SSH) don't have XDG_RUNTIME_DIR set.
1912	runtimeByUID := filepath.Join("/run/user", strconv.Itoa(os.Getuid()), "gordon", "gordon.pid")
1913	add(runtimeByUID)
1914
1915	// Check explicit XDG_RUNTIME_DIR if present in this process env.
1916	if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
1917		add(filepath.Join(runtimeDir, "gordon.pid"))
1918	}
1919
1920	// Check home directory
1921	if homeDir, err := os.UserHomeDir(); err == nil {
1922		add(filepath.Join(homeDir, ".gordon", "gordon.pid"))
1923		// Legacy location for backward compatibility
1924		add(filepath.Join(homeDir, ".gordon.pid"))
1925	}
1926
1927	// Legacy /tmp locations for backward compatibility
1928	add(filepath.Join(os.TempDir(), "gordon.pid"))
1929	add("/tmp/gordon.pid")
1930
1931	return locations
1932}
1933
1934// findRunningPidFile returns the first PID file whose PID belongs to a live process.
1935// Stale/invalid PID files are ignored and removed when possible.
1936func findRunningPidFile() (string, int, error) {
1937	return findRunningPidFileInLocations(pidFileLocations())
1938}
1939
1940func findRunningPidFileInLocations(locations []string) (string, int, error) {
1941	foundAny := false
1942
1943	for _, location := range locations {
1944		pidBytes, err := os.ReadFile(location)
1945		if err != nil {
1946			continue
1947		}
1948
1949		foundAny = true
1950
1951		var pid int
1952		if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil || pid <= 0 {
1953			_ = os.Remove(location)
1954			continue
1955		}
1956
1957		if isProcessAlive(pid) {
1958			return location, pid, nil
1959		}
1960
1961		_ = os.Remove(location)
1962	}
1963
1964	if foundAny {
1965		return "", 0, fmt.Errorf("found stale gordon PID file(s), is Gordon running?")
1966	}
1967
1968	return "", 0, fmt.Errorf("gordon PID file not found, is Gordon running?")
1969}
1970
1971func findRunningProcess() (*os.Process, int, error) {
1972	_, pid, err := findRunningPidFile()
1973	if err != nil {
1974		return nil, 0, err
1975	}
1976
1977	process, err := os.FindProcess(pid)
1978	if err != nil {
1979		return nil, 0, fmt.Errorf("failed to find process: %w", err)
1980	}
1981
1982	return process, pid, nil
1983}
1984
1985func isProcessAlive(pid int) bool {
1986	process, err := os.FindProcess(pid)
1987	if err != nil {
1988		return false
1989	}
1990
1991	err = process.Signal(syscall.Signal(0))
1992	if err == nil {
1993		return true
1994	}
1995
1996	return errors.Is(err, syscall.EPERM)
1997}
1998
1999// loadConfig loads configuration from file and sets defaults.
2000func loadConfig(v *viper.Viper, configPath string) error {
2001	v.SetDefault("server.port", 80)
2002	v.SetDefault("server.registry_port", 5000)
2003	v.SetDefault("server.tls_enabled", false)
2004	v.SetDefault("server.tls_port", 443)
2005	v.SetDefault("server.tls_cert_file", "")
2006	v.SetDefault("server.tls_key_file", "")
2007	v.SetDefault("server.data_dir", DefaultDataDir())
2008	v.SetDefault("logging.level", "info")
2009	v.SetDefault("logging.format", "console")
2010	v.SetDefault("logging.file.enabled", false)
2011	v.SetDefault("logging.file.max_size", 100)
2012	v.SetDefault("logging.file.max_backups", 3)
2013	v.SetDefault("logging.file.max_age", 28)
2014	v.SetDefault("logging.container_logs.enabled", true)
2015	v.SetDefault("logging.container_logs.dir", "")
2016	v.SetDefault("logging.container_logs.max_size", 100)
2017	v.SetDefault("logging.container_logs.max_backups", 3)
2018	v.SetDefault("logging.container_logs.max_age", 28)
2019	v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
2020	v.SetDefault("auth.enabled", true)
2021	// Note: auth.type is intentionally not set - it's inferred from config
2022	// If password_hash is set -> password mode, otherwise -> token mode
2023	v.SetDefault("auth.secrets_backend", "unsafe")
2024	v.SetDefault("auth.token_expiry", "720h")
2025	v.SetDefault("api.rate_limit.enabled", true)
2026	v.SetDefault("api.rate_limit.global_rps", 500)
2027	v.SetDefault("api.rate_limit.per_ip_rps", 50)
2028	v.SetDefault("api.rate_limit.burst", 100)
2029	v.SetDefault("auto_route.enabled", false)
2030	v.SetDefault("network_isolation.enabled", false)
2031	v.SetDefault("network_isolation.network_prefix", "gordon")
2032	v.SetDefault("network_isolation.dns_suffix", ".internal")
2033	v.SetDefault("volumes.auto_create", true)
2034	v.SetDefault("volumes.prefix", "gordon")
2035	v.SetDefault("volumes.preserve", true)
2036	v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
2037	v.SetDefault("backups.enabled", false)
2038	v.SetDefault("backups.schedule", string(domain.ScheduleDaily))
2039	v.SetDefault("backups.storage_dir", "")
2040	v.SetDefault("backups.retention.hourly", 0)
2041	v.SetDefault("backups.retention.daily", 0)
2042	v.SetDefault("backups.retention.weekly", 0)
2043	v.SetDefault("backups.retention.monthly", 0)
2044	v.SetDefault("images.prune.enabled", false)
2045	v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))
2046	v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast)
2047	v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
2048	v.SetDefault("server.registry_allowed_ips", []string{})
2049	v.SetDefault("deploy.readiness_delay", "5s")
2050	v.SetDefault("deploy.readiness_mode", "auto")
2051	v.SetDefault("deploy.health_timeout", "90s")
2052	v.SetDefault("deploy.drain_mode", "auto")
2053	v.SetDefault("deploy.drain_timeout", "30s")
2054
2055	ConfigureViper(v, configPath)
2056
2057	if err := v.ReadInConfig(); err != nil {
2058		if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
2059			return fmt.Errorf("failed to read config file: %w", err)
2060		}
2061	}
2062
2063	v.SetEnvPrefix("GORDON")
2064	v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
2065	v.AutomaticEnv()
2066
2067	return nil
2068}

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