v2.31.0-dev2 4b72742

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

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