v2.31.0-dev23 58a92f3

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

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