v2.30.0-pre 7341e0e

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

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