v2.31.0-dev13 0d280f4

run.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close