v2.14.0 5e11a00

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	"github.com/fsnotify/fsnotify"
29	"github.com/spf13/viper"
30
31	// Adapters - Output
32	"github.com/bnema/gordon/internal/adapters/out/docker"
33	"github.com/bnema/gordon/internal/adapters/out/domainsecrets"
34	"github.com/bnema/gordon/internal/adapters/out/envloader"
35	"github.com/bnema/gordon/internal/adapters/out/eventbus"
36	"github.com/bnema/gordon/internal/adapters/out/filesystem"
37	"github.com/bnema/gordon/internal/adapters/out/httpprober"
38	"github.com/bnema/gordon/internal/adapters/out/logwriter"
39	"github.com/bnema/gordon/internal/adapters/out/ratelimit"
40	"github.com/bnema/gordon/internal/adapters/out/secrets"
41	"github.com/bnema/gordon/internal/adapters/out/tokenstore"
42
43	// Adapters - Input
44	"github.com/bnema/gordon/internal/adapters/in/http/admin"
45	authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth"
46	"github.com/bnema/gordon/internal/adapters/in/http/middleware"
47	"github.com/bnema/gordon/internal/adapters/in/http/registry"
48
49	// Boundaries
50	"github.com/bnema/gordon/internal/boundaries/out"
51
52	// Domain
53	"github.com/bnema/gordon/internal/domain"
54
55	// Use cases
56	"github.com/bnema/gordon/internal/usecase/auth"
57	"github.com/bnema/gordon/internal/usecase/backup"
58	"github.com/bnema/gordon/internal/usecase/config"
59	"github.com/bnema/gordon/internal/usecase/container"
60	"github.com/bnema/gordon/internal/usecase/health"
61	"github.com/bnema/gordon/internal/usecase/logs"
62	"github.com/bnema/gordon/internal/usecase/proxy"
63	registrySvc "github.com/bnema/gordon/internal/usecase/registry"
64	secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
65
66	// Pkg
67	"github.com/bnema/gordon/pkg/bytesize"
68	"github.com/bnema/gordon/pkg/duration"
69)
70
71// Config holds the application configuration.
72type Config struct {
73	Server struct {
74		Port                 int      `mapstructure:"port"`
75		RegistryPort         int      `mapstructure:"registry_port"`
76		GordonDomain         string   `mapstructure:"gordon_domain"`
77		RegistryDomain       string   `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
78		TLSEnabled           bool     `mapstructure:"tls_enabled"`
79		TLSPort              int      `mapstructure:"tls_port"`
80		TLSCertFile          string   `mapstructure:"tls_cert_file"`
81		TLSKeyFile           string   `mapstructure:"tls_key_file"`
82		DataDir              string   `mapstructure:"data_dir"`
83		MaxProxyBodySize     string   `mapstructure:"max_proxy_body_size"`     // e.g., "512MB", "1GB"
84		MaxBlobChunkSize     string   `mapstructure:"max_blob_chunk_size"`     // e.g., "512MB", "1GB"
85		MaxProxyResponseSize string   `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
86		MaxConcurrentConns   int      `mapstructure:"max_concurrent_connections"`
87		RegistryAllowedIPs   []string `mapstructure:"registry_allowed_ips"`
88	} `mapstructure:"server"`
89
90	Logging struct {
91		Level  string `mapstructure:"level"`
92		Format string `mapstructure:"format"`
93		File   struct {
94			Enabled    bool   `mapstructure:"enabled"`
95			Path       string `mapstructure:"path"`
96			MaxSize    int    `mapstructure:"max_size"`
97			MaxBackups int    `mapstructure:"max_backups"`
98			MaxAge     int    `mapstructure:"max_age"`
99		} `mapstructure:"file"`
100		ContainerLogs struct {
101			Enabled    bool   `mapstructure:"enabled"`
102			Dir        string `mapstructure:"dir"`
103			MaxSize    int    `mapstructure:"max_size"`
104			MaxBackups int    `mapstructure:"max_backups"`
105			MaxAge     int    `mapstructure:"max_age"`
106		} `mapstructure:"container_logs"`
107	} `mapstructure:"logging"`
108
109	Env struct {
110		Dir string `mapstructure:"dir"`
111	} `mapstructure:"env"`
112
113	Auth struct {
114		Enabled        bool   `mapstructure:"enabled"`
115		Type           string `mapstructure:"type"`            // "password" or "token"
116		SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe"
117		Username       string `mapstructure:"username"`
118		Password       string `mapstructure:"password"`      // deprecated: use password_hash
119		PasswordHash   string `mapstructure:"password_hash"` // path in secrets backend
120		TokenSecret    string `mapstructure:"token_secret"`  // path in secrets backend
121		TokenExpiry    string `mapstructure:"token_expiry"`  // e.g., "720h", "30d"
122	} `mapstructure:"auth"`
123
124	API struct {
125		RateLimit struct {
126			Enabled        bool     `mapstructure:"enabled"`
127			GlobalRPS      float64  `mapstructure:"global_rps"`
128			PerIPRPS       float64  `mapstructure:"per_ip_rps"`
129			Burst          int      `mapstructure:"burst"`
130			TrustedProxies []string `mapstructure:"trusted_proxies"`
131		} `mapstructure:"rate_limit"`
132	} `mapstructure:"api"`
133
134	Backups struct {
135		Enabled    bool   `mapstructure:"enabled"`
136		StorageDir string `mapstructure:"storage_dir"`
137		Retention  struct {
138			Hourly  int `mapstructure:"hourly"`
139			Daily   int `mapstructure:"daily"`
140			Weekly  int `mapstructure:"weekly"`
141			Monthly int `mapstructure:"monthly"`
142		} `mapstructure:"retention"`
143	} `mapstructure:"backups"`
144}
145
146// services holds all the services used by the application.
147type services struct {
148	runtime          *docker.Runtime
149	eventBus         *eventbus.InMemory
150	blobStorage      *filesystem.BlobStorage
151	manifestStorage  *filesystem.ManifestStorage
152	backupStorage    *filesystem.BackupStorage
153	envLoader        out.EnvLoader
154	logWriter        *logwriter.LogWriter
155	tokenStore       out.TokenStore
156	configSvc        *config.Service
157	containerSvc     *container.Service
158	backupSvc        *backup.Service
159	registrySvc      *registrySvc.Service
160	proxySvc         *proxy.Service
161	authSvc          *auth.Service
162	authHandler      *authhandler.Handler
163	adminHandler     *admin.Handler
164	internalRegUser  string
165	internalRegPass  string
166	envDir           string
167	maxBlobChunkSize int64
168}
169
170// Run initializes and starts the Gordon application.
171func Run(ctx context.Context, configPath string) error {
172	// Load configuration
173	v, cfg, err := initConfig(configPath)
174	if err != nil {
175		return err
176	}
177
178	// Initialize logger
179	log, cleanup, err := initLogger(cfg)
180	if err != nil {
181		return err
182	}
183	if cleanup != nil {
184		defer cleanup()
185	}
186
187	ctx = zerowrap.WithCtx(ctx, log)
188	log.Info().Msg("Gordon starting")
189
190	if err := ensureTLSConfig(&cfg, log); err != nil {
191		return err
192	}
193
194	// Create PID file
195	pidFile := createPidFile(log)
196	if pidFile != "" {
197		defer removePidFile(pidFile, log)
198	}
199
200	// Create all services
201	svc, err := createServices(ctx, v, cfg, log)
202	if err != nil {
203		return err
204	}
205
206	// Register event handlers
207	if err := registerEventHandlers(ctx, svc, cfg); err != nil {
208		return err
209	}
210
211	// Set up config hot reload
212	setupConfigHotReload(ctx, v, svc, log)
213
214	// Start event bus
215	if err := svc.eventBus.Start(); err != nil {
216		return log.WrapErr(err, "failed to start event bus")
217	}
218	defer svc.eventBus.Stop()
219
220	// Create HTTP handlers
221	registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)
222
223	// Start servers, wait for listeners to bind, then sync/auto-start containers.
224	return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, log)
225}
226
227func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error {
228	if !cfg.Server.TLSEnabled {
229		return nil
230	}
231
232	if cfg.Server.TLSCertFile != "" && cfg.Server.TLSKeyFile != "" {
233		return nil
234	}
235
236	dataDir := resolveDataDir(cfg.Server.DataDir)
237	tlsDir := filepath.Join(dataDir, "tls")
238	certPath := filepath.Join(tlsDir, "cert.pem")
239	keyPath := filepath.Join(tlsDir, "key.pem")
240
241	domainName := cfg.Server.GordonDomain
242	if domainName == "" {
243		domainName = cfg.Server.RegistryDomain
244	}
245	if domainName == "" {
246		domainName = "localhost"
247	}
248
249	if err := generateSelfSignedTLSCert(certPath, keyPath, domainName); err != nil {
250		return fmt.Errorf("failed to generate self-signed TLS certificate: %w", err)
251	}
252
253	cfg.Server.TLSCertFile = certPath
254	cfg.Server.TLSKeyFile = keyPath
255
256	log.Warn().
257		Str("cert_file", certPath).
258		Str("key_file", keyPath).
259		Str("domain", domainName).
260		Msg("server.tls_enabled=true with empty tls_cert_file/tls_key_file; using auto-generated self-signed certificate")
261
262	return nil
263}
264
265func generateSelfSignedTLSCert(certPath, keyPath, domainName string) error {
266	// Reuse existing pair if already generated.
267	if _, err := os.Stat(certPath); err == nil {
268		if _, keyErr := os.Stat(keyPath); keyErr == nil {
269			return nil
270		}
271	}
272
273	if err := os.MkdirAll(filepath.Dir(certPath), 0700); err != nil {
274		return err
275	}
276	if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil {
277		return err
278	}
279
280	priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
281	if err != nil {
282		return err
283	}
284
285	now := time.Now()
286	serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
287	serialNumber, err := rand.Int(rand.Reader, serialLimit)
288	if err != nil {
289		return err
290	}
291
292	template := x509.Certificate{
293		SerialNumber: serialNumber,
294		Subject: pkix.Name{
295			CommonName:   domainName,
296			Organization: []string{"Gordon Auto-Generated TLS"},
297		},
298		NotBefore:             now.Add(-5 * time.Minute),
299		NotAfter:              now.AddDate(1, 0, 0),
300		KeyUsage:              x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
301		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
302		BasicConstraintsValid: true,
303	}
304
305	if ip := net.ParseIP(domainName); ip != nil {
306		template.IPAddresses = []net.IP{ip}
307	} else {
308		template.DNSNames = []string{domainName}
309	}
310
311	derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
312	if err != nil {
313		return err
314	}
315
316	certOut, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
317	if err != nil {
318		return err
319	}
320	defer certOut.Close()
321
322	if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
323		return err
324	}
325
326	keyBytes, err := x509.MarshalECPrivateKey(priv)
327	if err != nil {
328		return err
329	}
330
331	keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
332	if err != nil {
333		return err
334	}
335	defer keyOut.Close()
336
337	if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}); err != nil {
338		return err
339	}
340
341	return nil
342}
343
344// initConfig loads configuration from file.
345func initConfig(configPath string) (*viper.Viper, Config, error) {
346	v := viper.New()
347	if err := loadConfig(v, configPath); err != nil {
348		return nil, Config{}, fmt.Errorf("failed to load config: %w", err)
349	}
350
351	var cfg Config
352	if err := v.Unmarshal(&cfg); err != nil {
353		return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
354	}
355
356	// Normalize domain config: prefer gordon_domain over registry_domain
357	if cfg.Server.GordonDomain != "" {
358		cfg.Server.RegistryDomain = cfg.Server.GordonDomain
359	}
360
361	return v, cfg, nil
362}
363
364// initLogger initializes the zerowrap logger.
365func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
366	logConfig := zerowrap.Config{
367		Level:  cfg.Logging.Level,
368		Format: cfg.Logging.Format,
369	}
370
371	// Always enable file logging so admin API can read process logs
372	// (Config flag is respected for backward-compatibility)
373	logPath := cfg.Logging.File.Path
374	if logPath == "" {
375		dataDir := cfg.Server.DataDir
376		if dataDir == "" {
377			dataDir = DefaultDataDir()
378		}
379		logPath = filepath.Join(dataDir, "logs", "gordon.log")
380	}
381
382	log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{
383		Enabled:    cfg.Logging.File.Enabled,
384		Path:       logPath,
385		MaxSize:    cfg.Logging.File.MaxSize,
386		MaxBackups: cfg.Logging.File.MaxBackups,
387		MaxAge:     cfg.Logging.File.MaxAge,
388		Compress:   true,
389	})
390	if err != nil {
391		return zerowrap.Default(), nil, fmt.Errorf("failed to create logger with file: %w", err)
392	}
393	return log, cleanup, nil
394}
395
396// createServices creates all the application services.
397func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (*services, error) {
398	svc := &services{}
399	var err error
400
401	// Create output adapters
402	if svc.runtime, svc.eventBus, err = createOutputAdapters(ctx, log); err != nil {
403		return nil, err
404	}
405
406	// Create storage
407	if svc.blobStorage, svc.manifestStorage, err = createStorage(cfg, log); err != nil {
408		return nil, err
409	}
410
411	// Create log writer
412	if svc.logWriter, err = createLogWriter(cfg, log); err != nil {
413		return nil, err
414	}
415
416	// Create auth service (if enabled)
417	if svc.tokenStore, svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil {
418		return nil, err
419	}
420
421	if err := setupInternalRegistryAuth(cfg, svc, log); err != nil {
422		return nil, err
423	}
424
425	// Create use case services
426	svc.configSvc = config.NewService(v, svc.eventBus)
427	if err := svc.configSvc.Load(ctx); err != nil {
428		return nil, log.WrapErr(err, "failed to load configuration")
429	}
430
431	var backend domain.SecretsBackend
432	var passStore *domainsecrets.PassStore
433	var domainSecretStore out.DomainSecretStore
434
435	svc.envDir, backend, passStore, domainSecretStore, err = createDomainSecretStore(cfg, log)
436	if err != nil {
437		return nil, err
438	}
439
440	if svc.envLoader, err = createEnvLoader(backend, svc.envDir, passStore, log); err != nil {
441		return nil, err
442	}
443
444	secretSvc := secretsSvc.NewService(domainSecretStore, log)
445
446	if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil {
447		return nil, err
448	}
449
450	if svc.backupStorage, svc.backupSvc, err = createBackupService(cfg, svc, log); err != nil {
451		return nil, err
452	}
453
454	svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
455
456	proxyCfg, err := buildProxyConfig(cfg, log)
457	if err != nil {
458		return nil, err
459	}
460	svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
461	svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxyCfg.proxyConfig)
462
463	// Wire synchronous proxy cache invalidation for zero-downtime deployments.
464	// The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget().
465	svc.containerSvc.SetProxyCacheInvalidator(svc.proxySvc)
466	svc.containerSvc.SetProxyDrainWaiter(svc.proxySvc)
467
468	// Create token handler for registry token endpoint
469	if svc.authSvc != nil {
470		internalAuth := authhandler.InternalAuth{
471			Username: svc.internalRegUser,
472			Password: svc.internalRegPass,
473		}
474		svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log)
475	}
476
477	// Create health service for route health checking
478	prober := httpprober.New()
479	healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)
480
481	// Create log service for accessing logs via admin API
482	logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log)
483
484	// Create admin handler for admin API
485	svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc)
486
487	return svc, nil
488}
489
490func setupInternalRegistryAuth(cfg Config, svc *services, log zerowrap.Logger) error {
491	if !cfg.Auth.Enabled {
492		return nil
493	}
494
495	var err error
496	svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
497	if err != nil {
498		return log.WrapErr(err, "failed to generate internal registry credentials")
499	}
500
501	// Persist credentials to file for CLI access (gordon auth internal)
502	if err := persistInternalCredentials(svc.internalRegUser, svc.internalRegPass); err != nil {
503		log.Warn().Err(err).Msg("failed to persist internal credentials for CLI access")
504	}
505
506	log.Debug().Msg("internal registry auth generated for loopback pulls")
507	return nil
508}
509
510func createDomainSecretStore(cfg Config, log zerowrap.Logger) (string, domain.SecretsBackend, *domainsecrets.PassStore, out.DomainSecretStore, error) {
511	envDir := resolveEnvDir(cfg)
512	backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)
513
514	switch backend {
515	case domain.SecretsBackendPass:
516		passStore, err := domainsecrets.NewPassStore(log)
517		if err != nil {
518			return "", backend, nil, nil, log.WrapErr(err, "failed to create pass domain secret store")
519		}
520		if err := migrateEnvFilesToPass(envDir, passStore, log); err != nil {
521			return "", backend, nil, nil, log.WrapErr(err, "failed to migrate env files to pass")
522		}
523		return envDir, backend, passStore, passStore, nil
524	default:
525		store, err := domainsecrets.NewFileStore(envDir, log)
526		if err != nil {
527			return "", backend, nil, nil, log.WrapErr(err, "failed to create domain secret store")
528		}
529		return envDir, backend, nil, store, nil
530	}
531}
532
533// resolveLogFilePath returns the configured log file path or a default.
534func resolveLogFilePath(cfg Config) string {
535	if cfg.Logging.File.Path != "" {
536		return cfg.Logging.File.Path
537	}
538	dataDir := cfg.Server.DataDir
539	if dataDir == "" {
540		dataDir = DefaultDataDir()
541	}
542	return filepath.Join(dataDir, "logs", "gordon.log")
543}
544
545// createOutputAdapters creates the Docker runtime and event bus.
546func createOutputAdapters(ctx context.Context, log zerowrap.Logger) (*docker.Runtime, *eventbus.InMemory, error) {
547	runtime, err := docker.NewRuntime()
548	if err != nil {
549		return nil, nil, log.WrapErr(err, "failed to create Docker runtime")
550	}
551
552	if err := runtime.Ping(ctx); err != nil {
553		return nil, nil, log.WrapErr(err, "Docker is not available")
554	}
555
556	dockerVersion, _ := runtime.Version(ctx)
557	log.Info().Str("docker_version", dockerVersion).Msg("Docker runtime initialized")
558
559	eventBus := eventbus.NewInMemory(100, log)
560
561	return runtime, eventBus, nil
562}
563
564// createStorage creates blob and manifest storage.
565func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *filesystem.ManifestStorage, error) {
566	dataDir := cfg.Server.DataDir
567	if dataDir == "" {
568		dataDir = DefaultDataDir()
569	}
570
571	registryDir := filepath.Join(dataDir, "registry")
572
573	blobStorage, err := filesystem.NewBlobStorage(registryDir, log)
574	if err != nil {
575		return nil, nil, log.WrapErr(err, "failed to create blob storage")
576	}
577
578	manifestStorage, err := filesystem.NewManifestStorage(registryDir, log)
579	if err != nil {
580		return nil, nil, log.WrapErr(err, "failed to create manifest storage")
581	}
582
583	return blobStorage, manifestStorage, nil
584}
585
586// createEnvLoader creates the environment loader with secret providers.
587func createEnvLoader(backend domain.SecretsBackend, envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) (out.EnvLoader, error) {
588	switch backend {
589	case domain.SecretsBackendPass:
590		loader, err := envloader.NewPassLoader(passStore, log)
591		if err != nil {
592			return nil, log.WrapErr(err, "failed to create pass env loader")
593		}
594		return loader, nil
595	default:
596		loader, err := envloader.NewFileLoader(envDir, log)
597		if err != nil {
598			return nil, log.WrapErr(err, "failed to create env loader")
599		}
600
601		// Register secret providers
602		passProvider := secrets.NewPassProvider(log)
603		if passProvider.IsAvailable() {
604			loader.RegisterSecretProvider(passProvider)
605			log.Debug().Msg("pass secret provider registered")
606		}
607
608		sopsProvider := secrets.NewSopsProvider(log)
609		if sopsProvider.IsAvailable() {
610			loader.RegisterSecretProvider(sopsProvider)
611			log.Debug().Msg("sops secret provider registered")
612		}
613
614		return loader, nil
615	}
616}
617
618// createLogWriter creates the container log writer.
619func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, error) {
620	if !cfg.Logging.ContainerLogs.Enabled {
621		log.Debug().Msg("container log collection disabled")
622		return nil, nil
623	}
624
625	// Determine log directory
626	logDir := cfg.Logging.ContainerLogs.Dir
627	if logDir == "" {
628		dataDir := cfg.Server.DataDir
629		if dataDir == "" {
630			dataDir = DefaultDataDir()
631		}
632		logDir = filepath.Join(dataDir, "logs", "containers")
633	}
634
635	writer, err := logwriter.New(logwriter.Config{
636		Dir:        logDir,
637		MaxSize:    cfg.Logging.ContainerLogs.MaxSize,
638		MaxBackups: cfg.Logging.ContainerLogs.MaxBackups,
639		MaxAge:     cfg.Logging.ContainerLogs.MaxAge,
640	})
641	if err != nil {
642		return nil, log.WrapErr(err, "failed to create container log writer")
643	}
644
645	log.Info().Str("dir", logDir).Msg("container log collection enabled")
646	return writer, nil
647}
648
649const (
650	internalRegistryUsername = "gordon-internal"
651	serviceTokenSubject      = "gordon-service"
652	serviceTokenDefaultTTL   = 30 * 24 * time.Hour
653)
654
655func generateInternalRegistryAuth() (string, string, error) {
656	password, err := randomTokenHex(32)
657	if err != nil {
658		return "", "", err
659	}
660	return internalRegistryUsername, password, nil
661}
662
663func randomTokenHex(size int) (string, error) {
664	buf := make([]byte, size)
665	if _, err := rand.Read(buf); err != nil {
666		return "", err
667	}
668	return hex.EncodeToString(buf), nil
669}
670
671// InternalCredentials holds the internal registry credentials for CLI access.
672type InternalCredentials struct {
673	Username string `json:"username"`
674	Password string `json:"password"`
675}
676
677// getSecureRuntimeDir returns a secure directory for runtime files.
678// Priority: XDG_RUNTIME_DIR > ~/.gordon/run
679func getSecureRuntimeDir() (string, error) {
680	// Try XDG_RUNTIME_DIR first (typically /run/user/<uid> on Linux)
681	if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
682		gordonDir := filepath.Join(runtimeDir, "gordon")
683		if err := os.MkdirAll(gordonDir, 0700); err == nil {
684			return gordonDir, nil
685		}
686	}
687
688	// Fall back to ~/.gordon/run
689	homeDir, err := os.UserHomeDir()
690	if err != nil {
691		return "", fmt.Errorf("failed to get home directory: %w", err)
692	}
693
694	gordonDir := filepath.Join(homeDir, ".gordon", "run")
695	if err := os.MkdirAll(gordonDir, 0700); err != nil {
696		return "", fmt.Errorf("failed to create runtime directory: %w", err)
697	}
698
699	return gordonDir, nil
700}
701
702// getInternalCredentialsFile returns the path to the internal credentials file.
703// SECURITY: Credentials are stored in a secure location with restricted permissions.
704func getInternalCredentialsFile() string {
705	runtimeDir, err := getSecureRuntimeDir()
706	if err != nil {
707		// Fall back to temp dir if we can't get secure dir (shouldn't happen)
708		return filepath.Join(os.TempDir(), "gordon-internal-creds.json")
709	}
710	return filepath.Join(runtimeDir, "internal-creds.json")
711}
712
713// persistInternalCredentials saves the internal registry credentials to a secure file.
714// SECURITY: Credentials are stored in XDG_RUNTIME_DIR or ~/.gordon/run with 0600 permissions.
715// The file is cleaned up on graceful shutdown but may persist if Gordon crashes.
716// These credentials are for internal loopback communication only and are regenerated on each start.
717func persistInternalCredentials(username, password string) error {
718	creds := InternalCredentials{
719		Username: username,
720		Password: password,
721	}
722	data, err := json.Marshal(creds)
723	if err != nil {
724		return fmt.Errorf("failed to marshal credentials: %w", err)
725	}
726
727	credFile := getInternalCredentialsFile()
728
729	// Ensure parent directory exists with secure permissions
730	if err := os.MkdirAll(filepath.Dir(credFile), 0700); err != nil {
731		return fmt.Errorf("failed to create credentials directory: %w", err)
732	}
733
734	// Write file with restrictive permissions (owner read/write only)
735	if err := os.WriteFile(credFile, data, 0600); err != nil {
736		return fmt.Errorf("failed to write credentials file: %w", err)
737	}
738	return nil
739}
740
741// cleanupInternalCredentials removes the internal credentials file.
742func cleanupInternalCredentials() {
743	_ = os.Remove(getInternalCredentialsFile())
744}
745
746// GetInternalCredentials reads the internal registry credentials from file.
747// This is used by the CLI to display credentials for manual recovery.
748func GetInternalCredentials() (*InternalCredentials, error) {
749	credFile := getInternalCredentialsFile()
750	data, err := os.ReadFile(credFile)
751	if err != nil {
752		return nil, fmt.Errorf("failed to read credentials file (is Gordon running?): %w", err)
753	}
754	var creds InternalCredentials
755	if err := json.Unmarshal(data, &creds); err != nil {
756		return nil, fmt.Errorf("failed to parse credentials: %w", err)
757	}
758	return &creds, nil
759}
760
761// createAuthService creates the authentication service and token store.
762func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
763	if !cfg.Auth.Enabled {
764		return nil, nil, nil
765	}
766
767	authType := resolveAuthType(cfg)
768	backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)
769	dataDir := resolveDataDir(cfg.Server.DataDir)
770
771	store, err := createTokenStore(backend, dataDir, log)
772	if err != nil {
773		return nil, nil, err
774	}
775
776	authConfig, err := buildAuthConfig(ctx, cfg, authType, backend, dataDir, log)
777	if err != nil {
778		return nil, nil, err
779	}
780
781	authSvc := auth.NewService(authConfig, store, log)
782
783	log.Info().
784		Str("type", string(authType)).
785		Str("backend", string(backend)).
786		Msg("registry authentication enabled")
787
788	return store, authSvc, nil
789}
790
791// resolveAuthType determines the auth type from config.
792// If password_hash is configured, password auth is available (plus tokens).
793// If only token_secret is configured, token-only mode.
794// The explicit "type" field is deprecated but still respected for backwards compat.
795func resolveAuthType(cfg Config) domain.AuthType {
796	// Explicit type takes precedence (backwards compatibility)
797	if cfg.Auth.Type == "token" {
798		return domain.AuthTypeToken
799	}
800	if cfg.Auth.Type == "password" {
801		return domain.AuthTypePassword
802	}
803
804	// Infer from config: password auth if password_hash is configured
805	if cfg.Auth.PasswordHash != "" || cfg.Auth.Password != "" {
806		return domain.AuthTypePassword
807	}
808
809	// Default to token-only
810	return domain.AuthTypeToken
811}
812
813func resolveSecretsBackend(backend string) domain.SecretsBackend {
814	switch backend {
815	case "pass":
816		return domain.SecretsBackendPass
817	case "sops":
818		return domain.SecretsBackendSops
819	case "unsafe", "":
820		return domain.SecretsBackendUnsafe
821	default:
822		return domain.SecretsBackendUnsafe
823	}
824}
825
826func resolveDataDir(dataDir string) string {
827	if dataDir == "" {
828		return DefaultDataDir()
829	}
830	return dataDir
831}
832
833func resolveEnvDir(cfg Config) string {
834	dataDir := resolveDataDir(cfg.Server.DataDir)
835	envDir := cfg.Env.Dir
836	if envDir == "" {
837		envDir = filepath.Join(dataDir, "env")
838	}
839	return envDir
840}
841
842func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
843	// Token store is always created since tokens work in both auth modes
844	store, err := tokenstore.NewStore(backend, dataDir, log)
845	if err != nil {
846		return nil, log.WrapErr(err, "failed to create token store")
847	}
848	return store, nil
849}
850
851func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
852	authConfig := auth.Config{
853		Enabled:  cfg.Auth.Enabled,
854		AuthType: authType,
855		Username: cfg.Auth.Username,
856	}
857
858	// Token config is always required (tokens work in all auth modes)
859	secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log)
860	if err != nil {
861		return auth.Config{}, err
862	}
863	authConfig.TokenSecret = secret
864	authConfig.TokenExpiry = expiry
865
866	// Password config only needed for password auth mode
867	if authType == domain.AuthTypePassword {
868		hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log)
869		if err != nil {
870			return auth.Config{}, err
871		}
872		if hash == "" {
873			return auth.Config{}, errAuthNotConfigured()
874		}
875		authConfig.PasswordHash = hash
876	}
877
878	return authConfig, nil
879}
880
881// errAuthNotConfigured returns an error when auth is enabled but credentials are not configured.
882func errAuthNotConfigured() error {
883	return fmt.Errorf("auth is enabled by default, configure auth type and secrets backend. See: https://gordon.bnema.dev/docs/config/auth")
884}
885
886func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) {
887	if cfg.Auth.PasswordHash != "" {
888		hash, err := loadSecret(ctx, backend, cfg.Auth.PasswordHash, dataDir, log)
889		if err != nil {
890			return "", log.WrapErr(err, "failed to load password hash")
891		}
892		return hash, nil
893	}
894
895	if cfg.Auth.Password != "" {
896		log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend")
897		return cfg.Auth.Password, nil
898	}
899
900	return "", nil
901}
902
903func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) {
904	secret, err := loadTokenSecret(ctx, cfg, backend, dataDir, log)
905	if err != nil {
906		return nil, 0, err
907	}
908
909	expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
910	if err != nil {
911		return nil, 0, err
912	}
913
914	return secret, expiry, nil
915}
916
917// TokenSecretEnvVar is the environment variable for the JWT signing secret.
918// SECURITY: This takes priority over config file to allow secure secret injection.
919const TokenSecretEnvVar = "GORDON_AUTH_TOKEN_SECRET" //nolint:gosec // This is an env var name, not a credential
920
921func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {
922	// SECURITY: Priority order for token secret:
923	// 1. Environment variable (most secure - no disk exposure)
924	// 2. Secrets backend (pass/sops - encrypted)
925	// 3. Config file path (least preferred)
926
927	// Check environment variable first
928	if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" {
929		log.Debug().Msg("using token secret from environment variable")
930		return []byte(envSecret), nil
931	}
932
933	// Fall back to config-specified path via secrets backend
934	if cfg.Auth.TokenSecret == "" {
935		return nil, fmt.Errorf("token_secret is required for JWT token generation; set %s environment variable or configure auth.token_secret", TokenSecretEnvVar)
936	}
937
938	secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log)
939	if err != nil {
940		return nil, log.WrapErr(err, "failed to load token secret")
941	}
942
943	return []byte(secret), nil
944}
945
946func parseTokenExpiry(expiry string) (time.Duration, error) {
947	if expiry == "" {
948		return 0, nil
949	}
950
951	parsed, err := duration.Parse(expiry)
952	if err != nil {
953		return 0, fmt.Errorf("invalid token_expiry: %w", err)
954	}
955
956	return parsed, nil
957}
958
959func resolveServiceTokenExpiry(cfg Config) (time.Duration, error) {
960	expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
961	if err != nil {
962		return 0, err
963	}
964	if expiry <= 0 {
965		return serviceTokenDefaultTTL, nil
966	}
967	return expiry, nil
968}
969
970// loadSecret loads a secret from the configured backend.
971func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) {
972	switch backend {
973	case domain.SecretsBackendPass:
974		provider := secrets.NewPassProvider(log)
975		return provider.GetSecret(ctx, path)
976	case domain.SecretsBackendSops:
977		provider := secrets.NewSopsProvider(log)
978		return provider.GetSecret(ctx, path)
979	case domain.SecretsBackendUnsafe:
980		// For unsafe backend, path is relative to dataDir/secrets/
981		secretFile := filepath.Join(dataDir, "secrets", path)
982		data, err := os.ReadFile(secretFile)
983		if err != nil {
984			return "", fmt.Errorf("failed to read secret file: %w", err)
985		}
986		return string(data), nil
987	default:
988		return "", fmt.Errorf("unknown secrets backend: %s", backend)
989	}
990}
991
992// proxyConfigResult holds parsed proxy and blob chunk size config.
993type proxyConfigResult struct {
994	proxyConfig      proxy.Config
995	maxBlobChunkSize int64
996}
997
998// buildProxyConfig parses size-related config fields and builds the proxy config.
999func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, error) {
1000	maxProxyBodySize := int64(512 << 20) // 512MB default
1001	if cfg.Server.MaxProxyBodySize != "" {
1002		parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyBodySize)
1003		if err != nil {
1004			return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_body_size configuration", map[string]any{"value": cfg.Server.MaxProxyBodySize})
1005		}
1006		maxProxyBodySize = parsedSize
1007	}
1008
1009	maxBlobChunkSize := int64(registry.DefaultMaxBlobChunkSize)
1010	if cfg.Server.MaxBlobChunkSize != "" {
1011		parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobChunkSize)
1012		if err != nil {
1013			return nil, log.WrapErrWithFields(err, "invalid server.max_blob_chunk_size configuration", map[string]any{"value": cfg.Server.MaxBlobChunkSize})
1014		}
1015		maxBlobChunkSize = parsedSize
1016	}
1017
1018	maxProxyResponseSize := int64(1 << 30) // 1GB default
1019	if cfg.Server.MaxProxyResponseSize != "" {
1020		parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyResponseSize)
1021		if err != nil {
1022			return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_response_size configuration", map[string]any{"value": cfg.Server.MaxProxyResponseSize})
1023		}
1024		maxProxyResponseSize = parsedSize
1025	}
1026
1027	maxConcurrentConns := cfg.Server.MaxConcurrentConns
1028	if maxConcurrentConns < 0 {
1029		maxConcurrentConns = 10000 // default when explicitly set to -1
1030	}
1031	// 0 means no limit (as documented in proxy.Config)
1032
1033	return &proxyConfigResult{
1034		proxyConfig: proxy.Config{
1035			RegistryDomain:     cfg.Server.RegistryDomain,
1036			RegistryPort:       cfg.Server.RegistryPort,
1037			MaxBodySize:        maxProxyBodySize,
1038			MaxResponseSize:    maxProxyResponseSize,
1039			MaxConcurrentConns: maxConcurrentConns,
1040		},
1041		maxBlobChunkSize: maxBlobChunkSize,
1042	}, nil
1043}
1044
1045// createContainerService creates the container service with configuration.
1046func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
1047	containerConfig := container.Config{
1048		RegistryAuthEnabled:      cfg.Auth.Enabled,
1049		RegistryDomain:           cfg.Server.RegistryDomain,
1050		RegistryPort:             cfg.Server.RegistryPort,
1051		InternalRegistryUsername: svc.internalRegUser,
1052		InternalRegistryPassword: svc.internalRegPass,
1053		PullPolicy:               v.GetString("deploy.pull_policy"),
1054		VolumeAutoCreate:         v.GetBool("volumes.auto_create"),
1055		VolumePrefix:             v.GetString("volumes.prefix"),
1056		VolumePreserve:           v.GetBool("volumes.preserve"),
1057		NetworkIsolation:         v.GetBool("network_isolation.enabled"),
1058		NetworkPrefix:            v.GetString("network_isolation.network_prefix"),
1059		DNSSuffix:                v.GetString("network_isolation.dns_suffix"),
1060		NetworkGroups:            svc.configSvc.GetNetworkGroups(),
1061		Attachments:              svc.configSvc.GetAttachments(),
1062		ReadinessDelay:           v.GetDuration("deploy.readiness_delay"),
1063		ReadinessMode:            v.GetString("deploy.readiness_mode"),
1064		HealthTimeout:            v.GetDuration("deploy.health_timeout"),
1065		DrainDelay:               v.GetDuration("deploy.drain_delay"),
1066		DrainMode:                v.GetString("deploy.drain_mode"),
1067		DrainTimeout:             v.GetDuration("deploy.drain_timeout"),
1068	}
1069	if v.IsSet("deploy.drain_delay") {
1070		containerConfig.DrainDelayConfigured = true
1071		containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay")
1072	}
1073
1074	if cfg.Auth.Enabled && svc.authSvc != nil {
1075		expiry, err := resolveServiceTokenExpiry(cfg)
1076		if err != nil {
1077			return nil, log.WrapErr(err, "failed to resolve service token expiry")
1078		}
1079		// Note: Service tokens are not auto-refreshed. If the token expires during
1080		// container runtime, the container will need to be recreated to get a new token.
1081		serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, expiry)
1082		if err != nil {
1083			return nil, log.WrapErr(err, "failed to generate registry service token")
1084		}
1085		log.Info().
1086			Str("subject", serviceTokenSubject).
1087			Str("expiry", expiry.String()).
1088			Msg("generated service token for container registry access")
1089		containerConfig.ServiceTokenUsername = serviceTokenSubject
1090		containerConfig.ServiceToken = serviceToken
1091	}
1092
1093	return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil
1094}
1095
1096func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*filesystem.BackupStorage, *backup.Service, error) {
1097	if !cfg.Backups.Enabled {
1098		return nil, nil, nil
1099	}
1100
1101	storageDir := cfg.Backups.StorageDir
1102	if storageDir == "" {
1103		dataDir := resolveDataDir(cfg.Server.DataDir)
1104		storageDir = filepath.Join(dataDir, "backups")
1105	}
1106
1107	backupStorage, err := filesystem.NewBackupStorage(storageDir, log)
1108	if err != nil {
1109		return nil, nil, log.WrapErr(err, "failed to create backup storage")
1110	}
1111
1112	retention, err := validateBackupRetention(cfg)
1113	if err != nil {
1114		return nil, nil, log.WrapErr(err, "invalid backup retention policy")
1115	}
1116
1117	backupCfg := domain.BackupConfig{
1118		Enabled:    cfg.Backups.Enabled,
1119		StorageDir: storageDir,
1120		Retention:  retention,
1121	}
1122
1123	backupSvc := backup.NewService(svc.runtime, backupStorage, svc.containerSvc, backupCfg, log)
1124
1125	log.Info().
1126		Str("storage_dir", storageDir).
1127		Msg("backup service initialized")
1128
1129	return backupStorage, backupSvc, nil
1130}
1131
1132func validateBackupRetention(cfg Config) (domain.RetentionPolicy, error) {
1133	if cfg.Backups.Retention.Hourly < 0 {
1134		return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.hourly cannot be negative")
1135	}
1136	if cfg.Backups.Retention.Daily < 0 {
1137		return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.daily cannot be negative")
1138	}
1139	if cfg.Backups.Retention.Weekly < 0 {
1140		return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.weekly cannot be negative")
1141	}
1142	if cfg.Backups.Retention.Monthly < 0 {
1143		return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.monthly cannot be negative")
1144	}
1145
1146	return domain.RetentionPolicy{
1147		Hourly:  cfg.Backups.Retention.Hourly,
1148		Daily:   cfg.Backups.Retention.Daily,
1149		Weekly:  cfg.Backups.Retention.Weekly,
1150		Monthly: cfg.Backups.Retention.Monthly,
1151	}, nil
1152}
1153
1154// registerEventHandlers registers all event handlers.
1155func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error {
1156	imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
1157	if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil {
1158		return fmt.Errorf("failed to subscribe image pushed handler: %w", err)
1159	}
1160
1161	// Auto-route handler for creating routes from image labels
1162	autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain).
1163		WithEnvExtractor(svc.runtime, svc.envDir)
1164	if err := svc.eventBus.Subscribe(autoRouteHandler); err != nil {
1165		return fmt.Errorf("failed to subscribe auto-route handler: %w", err)
1166	}
1167
1168	configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)
1169	if err := svc.eventBus.Subscribe(configReloadHandler); err != nil {
1170		return fmt.Errorf("failed to subscribe config reload handler: %w", err)
1171	}
1172
1173	manualReloadHandler := container.NewManualReloadHandler(ctx, svc.containerSvc, svc.configSvc)
1174	if err := svc.eventBus.Subscribe(manualReloadHandler); err != nil {
1175		return fmt.Errorf("failed to subscribe manual reload handler: %w", err)
1176	}
1177
1178	manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc)
1179	if err := svc.eventBus.Subscribe(manualDeployHandler); err != nil {
1180		return fmt.Errorf("failed to subscribe manual deploy handler: %w", err)
1181	}
1182
1183	// Proxy cache invalidation on config reload (clears stale targets for removed routes)
1184	configReloadProxyHandler := proxy.NewConfigReloadProxyHandler(ctx, svc.proxySvc)
1185	if err := svc.eventBus.Subscribe(configReloadProxyHandler); err != nil {
1186		return fmt.Errorf("failed to subscribe config reload proxy handler: %w", err)
1187	}
1188
1189	return nil
1190}
1191
1192// setupConfigHotReload sets up Viper config hot reload.
1193// NOTE: This does NOT reload routes into memory. Routes are managed via API
1194// (AddRoute/UpdateRoute/RemoveRoute) and memory is the source of truth.
1195// The file watcher only updates proxy config and refreshes targets.
1196// Manual config file edits to routes require a server restart.
1197func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) {
1198	v.OnConfigChange(func(e fsnotify.Event) {
1199		log.Info().Str("file", e.Name).Msg("config file changed")
1200
1201		if err := v.ReadInConfig(); err != nil {
1202			log.Error().Err(err).Msg("failed to reload config")
1203			return
1204		}
1205
1206		// Re-unmarshal config and rebuild proxy config to pick up all changes
1207		var reloadCfg Config
1208		if err := v.Unmarshal(&reloadCfg); err != nil {
1209			log.Error().Err(err).Msg("failed to unmarshal config on reload")
1210			return
1211		}
1212		if reloadCfg.Server.GordonDomain != "" {
1213			reloadCfg.Server.RegistryDomain = reloadCfg.Server.GordonDomain
1214		}
1215		reloadedProxy, err := buildProxyConfig(reloadCfg, log)
1216		if err != nil {
1217			log.Error().Err(err).Msg("failed to parse proxy config on reload")
1218			return
1219		}
1220		svc.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)
1221
1222		// Clear proxy target cache to pick up external route changes
1223		if err := svc.proxySvc.RefreshTargets(ctx); err != nil {
1224			log.Warn().Err(err).Msg("failed to refresh proxy targets")
1225		}
1226
1227		log.Debug().Msg("config hot reload complete (routes unchanged, memory is source of truth)")
1228	})
1229	v.WatchConfig()
1230}
1231
1232// syncAndAutoStart syncs existing containers and auto-starts if configured.
1233func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
1234	if err := svc.containerSvc.SyncContainers(ctx); err != nil {
1235		log.Warn().Err(err).Msg("failed to sync existing containers")
1236	}
1237
1238	if svc.configSvc.IsAutoRouteEnabled() {
1239		routes := svc.configSvc.GetRoutes(ctx)
1240		if err := svc.containerSvc.AutoStart(domain.WithInternalDeploy(ctx), routes); err != nil {
1241			log.Warn().Err(err).Msg("failed to auto-start containers")
1242		}
1243	}
1244}
1245
1246// createHTTPHandlers creates HTTP handlers with middleware.
1247func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) {
1248	// Parse trusted proxies once for all middleware chains.
1249	// This ensures consistent IP extraction across logging, rate limiting, and auth.
1250	trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
1251
1252	registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)
1253
1254	registryMiddlewares := []func(http.Handler) http.Handler{
1255		middleware.PanicRecovery(log),
1256		middleware.RequestLogger(log, trustedNets),
1257		middleware.SecurityHeaders,
1258	}
1259
1260	// CIDR allowlist for registry access (e.g., restrict to Tailscale IPs).
1261	// Inserted before rate limiting so denied IPs never consume limiter tokens.
1262	var cidrAllowlistMiddleware func(http.Handler) http.Handler
1263	if len(cfg.Server.RegistryAllowedIPs) > 0 {
1264		allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs)
1265		if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) {
1266			for _, entry := range cfg.Server.RegistryAllowedIPs {
1267				if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 {
1268					log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry")
1269				}
1270			}
1271		}
1272		if len(allowedNets) == 0 {
1273			log.Error().
1274				Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs).
1275				Msg("registry_allowed_ips is set but no valid entries were parsed; registry will allow all traffic")
1276		}
1277		cidrAllowlistMiddleware = middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)
1278		registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware)
1279	}
1280
1281	// Create rate limiters using the factory
1282	var rateLimitMiddleware func(http.Handler) http.Handler
1283	if cfg.API.RateLimit.Enabled {
1284		globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
1285		ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1286		rateLimitMiddleware = registry.RateLimitMiddleware(
1287			globalLimiter,
1288			ipLimiter,
1289			cfg.API.RateLimit.TrustedProxies,
1290			log,
1291		)
1292	} else {
1293		rateLimitMiddleware = registry.RateLimitMiddleware(nil, nil, nil, log)
1294	}
1295	registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
1296
1297	if cfg.Auth.Enabled && svc.authSvc != nil {
1298		internalAuth := middleware.InternalRegistryAuth{
1299			Username: svc.internalRegUser,
1300			Password: svc.internalRegPass,
1301		}
1302		registryMiddlewares = append(registryMiddlewares,
1303			middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
1304	}
1305
1306	registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler)
1307
1308	// Create a mux that routes:
1309	// - /auth/* to auth handler (no auth required, for password login and token exchange)
1310	// - /v2/* to registry handler (with auth)
1311	// - /admin/* to admin handler (with auth)
1312	registryMux := http.NewServeMux()
1313	if svc.authHandler != nil {
1314		// Auth endpoints are NOT protected by auth - they're where clients authenticate
1315		// but still need rate limiting to prevent brute force attacks
1316		authMiddlewares := []func(http.Handler) http.Handler{
1317			middleware.PanicRecovery(log),
1318			middleware.RequestLogger(log, trustedNets),
1319			middleware.SecurityHeaders,
1320		}
1321		if cidrAllowlistMiddleware != nil {
1322			authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)
1323		}
1324		authMiddlewares = append(authMiddlewares, rateLimitMiddleware)
1325		authWithMiddleware := middleware.Chain(authMiddlewares...)(svc.authHandler)
1326		registryMux.Handle("/auth/", authWithMiddleware)
1327	}
1328	registryMux.Handle("/v2/", registryWithMiddleware)
1329
1330	// Add admin API handler with auth middleware
1331	if svc.adminHandler != nil {
1332		adminMiddlewares := []func(http.Handler) http.Handler{
1333			middleware.PanicRecovery(log),
1334			middleware.RequestLogger(log, trustedNets),
1335			middleware.SecurityHeaders,
1336		}
1337		// Add admin auth middleware if auth is enabled
1338		if svc.authSvc != nil {
1339			// Create rate limiters for admin API - uses same config as registry
1340			var globalLimiter, ipLimiter out.RateLimiter
1341			if cfg.API.RateLimit.Enabled {
1342				globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
1343				ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1344			}
1345			adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log))
1346		}
1347		adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
1348		registryMux.Handle("/admin/", adminWithMiddleware)
1349	}
1350
1351	// SECURITY: No CORS middleware on the proxy chain. Backend applications
1352	// should control their own CORS policies. A blanket Access-Control-Allow-Origin: *
1353	// would override backend CORS settings and allow any website to make
1354	// cross-origin authenticated requests to proxied applications.
1355	proxyMiddlewares := []func(http.Handler) http.Handler{
1356		middleware.PanicRecovery(log),
1357		middleware.RequestLogger(log, trustedNets),
1358		middleware.SecurityHeaders,
1359	}
1360
1361	proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc)
1362
1363	return registryMux, proxyWithMiddleware
1364}
1365
1366// runServers starts the HTTP servers and waits for shutdown.
1367// Signal handling notes:
1368// - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext
1369// - SIGUSR1: Triggers config reload without restart
1370// - SIGUSR2: Triggers manual deploy for a specific route
1371// The deferred signal.Stop calls ensure signal handlers are properly
1372// cleaned up before program exit, preventing signal handler leaks.
1373func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, svc *services, log zerowrap.Logger) error {
1374	ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
1375	defer cancel()
1376
1377	// Set up SIGUSR1 for reload.
1378	// Note: signal.Stop must be called (via defer) to release the channel
1379	// and prevent signal handler leaks when the function returns.
1380	reloadChan := make(chan os.Signal, 1)
1381	signal.Notify(reloadChan, syscall.SIGUSR1)
1382	defer signal.Stop(reloadChan)
1383
1384	// Set up SIGUSR2 for manual deploy.
1385	deployChan := make(chan os.Signal, 1)
1386	signal.Notify(deployChan, syscall.SIGUSR2)
1387	defer signal.Stop(deployChan)
1388
1389	errChan := make(chan error, 3)
1390
1391	registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
1392	proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
1393	var tlsReady <-chan struct{}
1394	if cfg.Server.TLSEnabled {
1395		if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" {
1396			return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file")
1397		}
1398		tlsReady = startTLSServer(
1399			fmt.Sprintf(":%d", cfg.Server.TLSPort),
1400			proxyHandler,
1401			"proxy-tls",
1402			cfg.Server.TLSCertFile,
1403			cfg.Server.TLSKeyFile,
1404			errChan,
1405			log,
1406		)
1407	}
1408
1409	// Wait for both servers to bind their ports before auto-starting containers.
1410	// This prevents the race where auto-start pulls from the registry before it's listening.
1411	select {
1412	case <-registryReady:
1413	case err := <-errChan:
1414		return err
1415	}
1416	select {
1417	case <-proxyReady:
1418	case err := <-errChan:
1419		return err
1420	}
1421	if cfg.Server.TLSEnabled {
1422		select {
1423		case <-tlsReady:
1424		case err := <-errChan:
1425			return err
1426		}
1427	}
1428
1429	logEvent := log.Info().
1430		Int("proxy_port", cfg.Server.Port).
1431		Int("registry_port", cfg.Server.RegistryPort)
1432	if cfg.Server.TLSEnabled {
1433		logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort)
1434	}
1435	logEvent.Msg("Gordon is running")
1436
1437	// Auto-start after servers are listening (registry port is now bound).
1438	syncAndAutoStart(ctx, svc, log)
1439
1440	waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log)
1441	gracefulShutdown(registrySrv, proxySrv, containerSvc, log)
1442	return nil
1443}
1444
1445// waitForShutdown blocks on the event loop, handling server errors and
1446// Unix signals (reload, deploy, shutdown) until the context is cancelled.
1447func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, deployChan <-chan os.Signal, eventBus out.EventBus, log zerowrap.Logger) {
1448	for {
1449		select {
1450		case err := <-errChan:
1451			log.Error().Err(err).Msg("server error")
1452			return
1453		case <-reloadChan:
1454			log.Info().Msg("reload signal received (SIGUSR1)")
1455			if err := eventBus.Publish(domain.EventManualReload, nil); err != nil {
1456				log.Error().Err(err).Msg("failed to publish manual reload event")
1457			}
1458		case <-deployChan:
1459			log.Info().Msg("deploy signal received (SIGUSR2)")
1460			domainName, err := readDeployRequest()
1461			if err != nil {
1462				log.Error().Err(err).Msg("failed to read deploy request")
1463				continue
1464			}
1465			payload := &domain.ManualDeployPayload{Domain: domainName}
1466			if err := eventBus.Publish(domain.EventManualDeploy, payload); err != nil {
1467				log.Error().Err(err).Str("domain", domainName).Msg("failed to publish manual deploy event")
1468			}
1469		case <-ctx.Done():
1470			log.Info().Msg("shutdown signal received")
1471			return
1472		}
1473	}
1474}
1475
1476// gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
1477// the container service and cleans up runtime files.
1478func gracefulShutdown(registrySrv, proxySrv *http.Server, containerSvc *container.Service, log zerowrap.Logger) {
1479	log.Info().Msg("shutting down Gordon...")
1480
1481	shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
1482	defer shutdownCancel()
1483
1484	if err := registrySrv.Shutdown(shutdownCtx); err != nil {
1485		log.Warn().Err(err).Msg("registry server shutdown error")
1486	}
1487	if err := proxySrv.Shutdown(shutdownCtx); err != nil {
1488		log.Warn().Err(err).Msg("proxy server shutdown error")
1489	}
1490
1491	if err := containerSvc.Shutdown(shutdownCtx); err != nil {
1492		log.Warn().Err(err).Msg("error during container shutdown")
1493	}
1494
1495	cleanupInternalCredentials()
1496	log.Info().Msg("Gordon stopped")
1497}
1498
1499// startServer starts an HTTP server, returning the server instance and a channel
1500// that closes once the listening socket is bound. This lets callers wait for the
1501// port to be ready before taking actions that depend on it (e.g. auto-start
1502// pulling from the local registry). The returned *http.Server can be used for
1503// graceful shutdown.
1504func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {
1505	ready := make(chan struct{})
1506
1507	server := &http.Server{
1508		Addr:              addr,
1509		Handler:           handler,
1510		ReadHeaderTimeout: 10 * time.Second,
1511		ReadTimeout:       5 * time.Minute,
1512		WriteTimeout:      5 * time.Minute,
1513		IdleTimeout:       120 * time.Second,
1514		MaxHeaderBytes:    1 << 20,
1515	}
1516
1517	go func() {
1518		log.Info().Str("address", addr).Msgf("%s server starting", name)
1519
1520		ln, err := net.Listen("tcp", addr)
1521		if err != nil {
1522			errChan <- fmt.Errorf("%s server error: %w", name, err)
1523			return
1524		}
1525		close(ready) // signal: port is bound and accepting connections
1526
1527		if err := server.Serve(ln); err != nil && err != http.ErrServerClosed {
1528			errChan <- fmt.Errorf("%s server error: %w", name, err)
1529		}
1530	}()
1531
1532	return server, ready
1533}
1534
1535// startTLSServer starts an HTTPS server with the provided certificate and key.
1536func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile string, errChan chan<- error, log zerowrap.Logger) <-chan struct{} {
1537	ready := make(chan struct{})
1538
1539	go func() {
1540		log.Info().
1541			Str("address", addr).
1542			Str("cert_file", certFile).
1543			Str("key_file", keyFile).
1544			Msgf("%s server starting", name)
1545
1546		server := &http.Server{
1547			Addr:              addr,
1548			Handler:           handler,
1549			ReadHeaderTimeout: 10 * time.Second,
1550			ReadTimeout:       5 * time.Minute,
1551			WriteTimeout:      5 * time.Minute,
1552			IdleTimeout:       120 * time.Second,
1553			MaxHeaderBytes:    1 << 20,
1554		}
1555
1556		ln, err := net.Listen("tcp", addr)
1557		if err != nil {
1558			errChan <- fmt.Errorf("%s server error: %w", name, err)
1559			return
1560		}
1561		close(ready)
1562
1563		if err := server.ServeTLS(ln, certFile, keyFile); err != nil && err != http.ErrServerClosed {
1564			errChan <- fmt.Errorf("%s server error: %w", name, err)
1565		}
1566	}()
1567
1568	return ready
1569}
1570
1571// SendReloadSignal sends SIGUSR1 to the running Gordon process.
1572func SendReloadSignal() error {
1573	process, _, err := findRunningProcess()
1574	if err != nil {
1575		return err
1576	}
1577
1578	if err := process.Signal(syscall.SIGUSR1); err != nil {
1579		return fmt.Errorf("failed to send reload signal: %w", err)
1580	}
1581
1582	return nil
1583}
1584
1585// getDeployRequestFile returns the path to the deploy request file.
1586func getDeployRequestFile() string {
1587	return filepath.Join(os.TempDir(), "gordon-deploy-request")
1588}
1589
1590// writeDeployRequestFile creates the deploy request file exclusively with retry.
1591// This prevents race conditions when multiple deploy commands run simultaneously.
1592func writeDeployRequestFile(path string, data []byte, timeout time.Duration) error {
1593	deadline := time.Now().Add(timeout)
1594
1595	for {
1596		f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
1597		if err == nil {
1598			_, writeErr := f.Write(data)
1599			f.Close()
1600			if writeErr != nil {
1601				_ = os.Remove(path)
1602				return writeErr
1603			}
1604			return nil
1605		}
1606
1607		if os.IsExist(err) {
1608			if time.Now().After(deadline) {
1609				return fmt.Errorf("deploy request file still present after timeout; another deploy may be in progress")
1610			}
1611			time.Sleep(100 * time.Millisecond)
1612			continue
1613		}
1614
1615		return err
1616	}
1617}
1618
1619// SendDeploySignal triggers a manual deploy for a specific route via SIGUSR2.
1620// Returns the domain name on success for the caller to display.
1621func SendDeploySignal(domain string) (string, error) {
1622	deployFile := getDeployRequestFile()
1623	if err := writeDeployRequestFile(deployFile, []byte(domain), 5*time.Second); err != nil {
1624		return "", fmt.Errorf("failed to write deploy request: %w", err)
1625	}
1626
1627	// Find PID and send SIGUSR2
1628	process, _, err := findRunningProcess()
1629	if err != nil {
1630		_ = os.Remove(deployFile)
1631		return "", err
1632	}
1633
1634	if err := process.Signal(syscall.SIGUSR2); err != nil {
1635		_ = os.Remove(deployFile)
1636		return "", fmt.Errorf("failed to send deploy signal: %w", err)
1637	}
1638
1639	return domain, nil
1640}
1641
1642// readDeployRequest reads and removes the deploy request file atomically.
1643// Returns empty string if file doesn't exist (may have been consumed by another handler).
1644func readDeployRequest() (string, error) {
1645	deployFile := getDeployRequestFile()
1646
1647	// Rename to a temp file first to make the read-and-delete atomic
1648	tmpFile := deployFile + ".processing"
1649	if err := os.Rename(deployFile, tmpFile); err != nil {
1650		if os.IsNotExist(err) {
1651			return "", fmt.Errorf("deploy request file not found (may have been processed already)")
1652		}
1653		return "", fmt.Errorf("failed to acquire deploy request: %w", err)
1654	}
1655
1656	data, err := os.ReadFile(tmpFile)
1657	_ = os.Remove(tmpFile) // Always clean up
1658	if err != nil {
1659		return "", fmt.Errorf("failed to read deploy request: %w", err)
1660	}
1661
1662	return string(data), nil
1663}
1664
1665// createPidFile creates a PID file for the Gordon process.
1666// SECURITY: Prefers secure locations (XDG_RUNTIME_DIR, ~/.gordon/run) over /tmp
1667// to prevent symlink attacks and unauthorized access.
1668func createPidFile(log zerowrap.Logger) string {
1669	pid := os.Getpid()
1670
1671	// SECURITY: Prioritize secure locations over /tmp
1672	var locations []string
1673
1674	// Try secure runtime directory first
1675	if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1676		locations = append(locations, filepath.Join(runtimeDir, "gordon.pid"))
1677	}
1678
1679	// Fall back to home directory
1680	if homeDir, err := os.UserHomeDir(); err == nil {
1681		locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid"))
1682	}
1683
1684	// Last resort: /tmp (least secure due to world-writable)
1685	locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid"))
1686
1687	for _, location := range locations {
1688		// Ensure parent directory exists with secure permissions
1689		if err := os.MkdirAll(filepath.Dir(location), 0700); err != nil {
1690			continue
1691		}
1692		if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil {
1693			log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file")
1694			return location
1695		}
1696	}
1697
1698	log.Warn().Int("pid", pid).Msg("failed to create PID file in any location")
1699	return ""
1700}
1701
1702// removePidFile removes the PID file.
1703func removePidFile(pidFile string, log zerowrap.Logger) {
1704	if err := os.Remove(pidFile); err != nil {
1705		log.Warn().Err(err).Str("pid_file", pidFile).Msg("failed to remove PID file")
1706	} else {
1707		log.Debug().Str("pid_file", pidFile).Msg("removed PID file")
1708	}
1709}
1710
1711func pidFileLocations() []string {
1712	var locations []string
1713	seen := make(map[string]struct{})
1714	add := func(path string) {
1715		if path == "" {
1716			return
1717		}
1718		if _, ok := seen[path]; ok {
1719			return
1720		}
1721		seen[path] = struct{}{}
1722		locations = append(locations, path)
1723	}
1724
1725	// Check secure runtime directory first
1726	if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1727		add(filepath.Join(runtimeDir, "gordon.pid"))
1728	}
1729
1730	// Also check canonical /run/user/<uid> runtime path. This handles cases where
1731	// Gordon started under systemd user services with runtime dir available, but
1732	// CLI invocations (e.g. non-interactive SSH) don't have XDG_RUNTIME_DIR set.
1733	runtimeByUID := filepath.Join("/run/user", strconv.Itoa(os.Getuid()), "gordon", "gordon.pid")
1734	add(runtimeByUID)
1735
1736	// Check explicit XDG_RUNTIME_DIR if present in this process env.
1737	if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
1738		add(filepath.Join(runtimeDir, "gordon.pid"))
1739	}
1740
1741	// Check home directory
1742	if homeDir, err := os.UserHomeDir(); err == nil {
1743		add(filepath.Join(homeDir, ".gordon", "gordon.pid"))
1744		// Legacy location for backward compatibility
1745		add(filepath.Join(homeDir, ".gordon.pid"))
1746	}
1747
1748	// Legacy /tmp locations for backward compatibility
1749	add(filepath.Join(os.TempDir(), "gordon.pid"))
1750	add("/tmp/gordon.pid")
1751
1752	return locations
1753}
1754
1755// findRunningPidFile returns the first PID file whose PID belongs to a live process.
1756// Stale/invalid PID files are ignored and removed when possible.
1757func findRunningPidFile() (string, int, error) {
1758	return findRunningPidFileInLocations(pidFileLocations())
1759}
1760
1761func findRunningPidFileInLocations(locations []string) (string, int, error) {
1762	foundAny := false
1763
1764	for _, location := range locations {
1765		pidBytes, err := os.ReadFile(location)
1766		if err != nil {
1767			continue
1768		}
1769
1770		foundAny = true
1771
1772		var pid int
1773		if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil || pid <= 0 {
1774			_ = os.Remove(location)
1775			continue
1776		}
1777
1778		if isProcessAlive(pid) {
1779			return location, pid, nil
1780		}
1781
1782		_ = os.Remove(location)
1783	}
1784
1785	if foundAny {
1786		return "", 0, fmt.Errorf("found stale gordon PID file(s), is Gordon running?")
1787	}
1788
1789	return "", 0, fmt.Errorf("gordon PID file not found, is Gordon running?")
1790}
1791
1792func findRunningProcess() (*os.Process, int, error) {
1793	_, pid, err := findRunningPidFile()
1794	if err != nil {
1795		return nil, 0, err
1796	}
1797
1798	process, err := os.FindProcess(pid)
1799	if err != nil {
1800		return nil, 0, fmt.Errorf("failed to find process: %w", err)
1801	}
1802
1803	return process, pid, nil
1804}
1805
1806func isProcessAlive(pid int) bool {
1807	process, err := os.FindProcess(pid)
1808	if err != nil {
1809		return false
1810	}
1811
1812	err = process.Signal(syscall.Signal(0))
1813	if err == nil {
1814		return true
1815	}
1816
1817	return errors.Is(err, syscall.EPERM)
1818}
1819
1820// loadConfig loads configuration from file and sets defaults.
1821func loadConfig(v *viper.Viper, configPath string) error {
1822	v.SetDefault("server.port", 80)
1823	v.SetDefault("server.registry_port", 5000)
1824	v.SetDefault("server.tls_enabled", false)
1825	v.SetDefault("server.tls_port", 443)
1826	v.SetDefault("server.tls_cert_file", "")
1827	v.SetDefault("server.tls_key_file", "")
1828	v.SetDefault("server.data_dir", DefaultDataDir())
1829	v.SetDefault("logging.level", "info")
1830	v.SetDefault("logging.format", "console")
1831	v.SetDefault("logging.file.enabled", false)
1832	v.SetDefault("logging.file.max_size", 100)
1833	v.SetDefault("logging.file.max_backups", 3)
1834	v.SetDefault("logging.file.max_age", 28)
1835	v.SetDefault("logging.container_logs.enabled", true)
1836	v.SetDefault("logging.container_logs.dir", "")
1837	v.SetDefault("logging.container_logs.max_size", 100)
1838	v.SetDefault("logging.container_logs.max_backups", 3)
1839	v.SetDefault("logging.container_logs.max_age", 28)
1840	v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
1841	v.SetDefault("auth.enabled", true)
1842	// Note: auth.type is intentionally not set - it's inferred from config
1843	// If password_hash is set -> password mode, otherwise -> token mode
1844	v.SetDefault("auth.secrets_backend", "unsafe")
1845	v.SetDefault("auth.token_expiry", "720h")
1846	v.SetDefault("api.rate_limit.enabled", true)
1847	v.SetDefault("api.rate_limit.global_rps", 500)
1848	v.SetDefault("api.rate_limit.per_ip_rps", 50)
1849	v.SetDefault("api.rate_limit.burst", 100)
1850	v.SetDefault("auto_route.enabled", false)
1851	v.SetDefault("network_isolation.enabled", false)
1852	v.SetDefault("network_isolation.network_prefix", "gordon")
1853	v.SetDefault("network_isolation.dns_suffix", ".internal")
1854	v.SetDefault("volumes.auto_create", true)
1855	v.SetDefault("volumes.prefix", "gordon")
1856	v.SetDefault("volumes.preserve", true)
1857	v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
1858	v.SetDefault("backups.enabled", false)
1859	v.SetDefault("backups.storage_dir", "")
1860	v.SetDefault("backups.retention.hourly", 0)
1861	v.SetDefault("backups.retention.daily", 0)
1862	v.SetDefault("backups.retention.weekly", 0)
1863	v.SetDefault("backups.retention.monthly", 0)
1864	v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
1865	v.SetDefault("server.registry_allowed_ips", []string{})
1866	v.SetDefault("deploy.readiness_delay", "5s")
1867	v.SetDefault("deploy.readiness_mode", "auto")
1868	v.SetDefault("deploy.health_timeout", "90s")
1869	v.SetDefault("deploy.drain_mode", "auto")
1870	v.SetDefault("deploy.drain_timeout", "30s")
1871
1872	ConfigureViper(v, configPath)
1873
1874	if err := v.ReadInConfig(); err != nil {
1875		if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
1876			return fmt.Errorf("failed to read config file: %w", err)
1877		}
1878	}
1879
1880	v.SetEnvPrefix("GORDON")
1881	v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
1882	v.AutomaticEnv()
1883
1884	return nil
1885}

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