v2.31.0-dev16 8d0a092

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

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