v2.31.0-dev22 8d342c4

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

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