v2.31.1 f37fe67

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

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