v2.30.0-pre7 3ee801c

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

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