v2.31.0-dev21 b59533b

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

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