v2.31.0-dev9 062dcae

run.go RawHistoryFolder

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

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