v2.31.0-dev12 6a38a35

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

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