v2.31.0-dev8 ec8c5b9

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

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