v2.30.0-pre6 511705f

run.go RawHistoryFolder

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

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