v2.7.0 fac7705

run.go RawHistoryFolder

1// Package app provides the application initialization and wiring.
2package app
3
4import (
5	"context"
6	"crypto/rand"
7	"encoding/hex"
8	"encoding/json"
9	"fmt"
10	"net"
11	"net/http"
12	"os"
13	"os/signal"
14	"path/filepath"
15	"strings"
16	"syscall"
17	"time"
18
19	"github.com/bnema/zerowrap"
20	"github.com/fsnotify/fsnotify"
21	"github.com/spf13/viper"
22
23	// Adapters - Output
24	"gordon/internal/adapters/out/docker"
25	"gordon/internal/adapters/out/domainsecrets"
26	"gordon/internal/adapters/out/envloader"
27	"gordon/internal/adapters/out/eventbus"
28	"gordon/internal/adapters/out/filesystem"
29	"gordon/internal/adapters/out/httpprober"
30	"gordon/internal/adapters/out/logwriter"
31	"gordon/internal/adapters/out/ratelimit"
32	"gordon/internal/adapters/out/secrets"
33	"gordon/internal/adapters/out/tokenstore"
34
35	// Adapters - Input
36	"gordon/internal/adapters/in/http/admin"
37	authhandler "gordon/internal/adapters/in/http/auth"
38	"gordon/internal/adapters/in/http/middleware"
39	"gordon/internal/adapters/in/http/registry"
40
41	// Boundaries
42	"gordon/internal/boundaries/out"
43
44	// Domain
45	"gordon/internal/domain"
46
47	// Use cases
48	"gordon/internal/usecase/auth"
49	"gordon/internal/usecase/config"
50	"gordon/internal/usecase/container"
51	"gordon/internal/usecase/health"
52	"gordon/internal/usecase/logs"
53	"gordon/internal/usecase/proxy"
54	registrySvc "gordon/internal/usecase/registry"
55	secretsSvc "gordon/internal/usecase/secrets"
56
57	// Pkg
58	"gordon/pkg/duration"
59)
60
61// Config holds the application configuration.
62type Config struct {
63	Server struct {
64		Port           int    `mapstructure:"port"`
65		RegistryPort   int    `mapstructure:"registry_port"`
66		GordonDomain   string `mapstructure:"gordon_domain"`
67		RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
68		DataDir        string `mapstructure:"data_dir"`
69	} `mapstructure:"server"`
70
71	Logging struct {
72		Level  string `mapstructure:"level"`
73		Format string `mapstructure:"format"`
74		File   struct {
75			Enabled    bool   `mapstructure:"enabled"`
76			Path       string `mapstructure:"path"`
77			MaxSize    int    `mapstructure:"max_size"`
78			MaxBackups int    `mapstructure:"max_backups"`
79			MaxAge     int    `mapstructure:"max_age"`
80		} `mapstructure:"file"`
81		ContainerLogs struct {
82			Enabled    bool   `mapstructure:"enabled"`
83			Dir        string `mapstructure:"dir"`
84			MaxSize    int    `mapstructure:"max_size"`
85			MaxBackups int    `mapstructure:"max_backups"`
86			MaxAge     int    `mapstructure:"max_age"`
87		} `mapstructure:"container_logs"`
88	} `mapstructure:"logging"`
89
90	Env struct {
91		Dir string `mapstructure:"dir"`
92	} `mapstructure:"env"`
93
94	Auth struct {
95		Enabled        bool   `mapstructure:"enabled"`
96		Type           string `mapstructure:"type"`            // "password" or "token"
97		SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe"
98		Username       string `mapstructure:"username"`
99		Password       string `mapstructure:"password"`      // deprecated: use password_hash
100		PasswordHash   string `mapstructure:"password_hash"` // path in secrets backend
101		TokenSecret    string `mapstructure:"token_secret"`  // path in secrets backend
102		TokenExpiry    string `mapstructure:"token_expiry"`  // e.g., "720h", "30d"
103	} `mapstructure:"auth"`
104
105	API struct {
106		RateLimit struct {
107			Enabled        bool     `mapstructure:"enabled"`
108			GlobalRPS      float64  `mapstructure:"global_rps"`
109			PerIPRPS       float64  `mapstructure:"per_ip_rps"`
110			Burst          int      `mapstructure:"burst"`
111			TrustedProxies []string `mapstructure:"trusted_proxies"`
112		} `mapstructure:"rate_limit"`
113	} `mapstructure:"api"`
114}
115
116// services holds all the services used by the application.
117type services struct {
118	runtime         *docker.Runtime
119	eventBus        *eventbus.InMemory
120	blobStorage     *filesystem.BlobStorage
121	manifestStorage *filesystem.ManifestStorage
122	envLoader       *envloader.FileLoader
123	logWriter       *logwriter.LogWriter
124	tokenStore      out.TokenStore
125	configSvc       *config.Service
126	containerSvc    *container.Service
127	registrySvc     *registrySvc.Service
128	proxySvc        *proxy.Service
129	authSvc         *auth.Service
130	authHandler     *authhandler.Handler
131	adminHandler    *admin.Handler
132	internalRegUser string
133	internalRegPass string
134	envDir          string
135}
136
137// Run initializes and starts the Gordon application.
138func Run(ctx context.Context, configPath string) error {
139	// Load configuration
140	v, cfg, err := initConfig(configPath)
141	if err != nil {
142		return err
143	}
144
145	// Initialize logger
146	log, cleanup, err := initLogger(cfg)
147	if err != nil {
148		return err
149	}
150	if cleanup != nil {
151		defer cleanup()
152	}
153
154	ctx = zerowrap.WithCtx(ctx, log)
155	log.Info().Msg("Gordon starting")
156
157	// Create PID file
158	pidFile := createPidFile(log)
159	if pidFile != "" {
160		defer removePidFile(pidFile, log)
161	}
162
163	// Create all services
164	svc, err := createServices(ctx, v, cfg, log)
165	if err != nil {
166		return err
167	}
168
169	// Register event handlers
170	if err := registerEventHandlers(ctx, svc, cfg); err != nil {
171		return err
172	}
173
174	// Set up config hot reload
175	setupConfigHotReload(ctx, v, svc, log)
176
177	// Start event bus
178	if err := svc.eventBus.Start(); err != nil {
179		return log.WrapErr(err, "failed to start event bus")
180	}
181	defer svc.eventBus.Stop()
182
183	// Sync and auto-start containers
184	syncAndAutoStart(ctx, svc, log)
185
186	// Create HTTP handlers
187	registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)
188
189	// Start servers and wait for shutdown
190	return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, log)
191}
192
193// initConfig loads configuration from file.
194func initConfig(configPath string) (*viper.Viper, Config, error) {
195	v := viper.New()
196	if err := loadConfig(v, configPath); err != nil {
197		return nil, Config{}, fmt.Errorf("failed to load config: %w", err)
198	}
199
200	var cfg Config
201	if err := v.Unmarshal(&cfg); err != nil {
202		return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
203	}
204
205	// Normalize domain config: prefer gordon_domain over registry_domain
206	if cfg.Server.GordonDomain != "" {
207		cfg.Server.RegistryDomain = cfg.Server.GordonDomain
208	}
209
210	return v, cfg, nil
211}
212
213// initLogger initializes the zerowrap logger.
214func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
215	logConfig := zerowrap.Config{
216		Level:  cfg.Logging.Level,
217		Format: cfg.Logging.Format,
218	}
219
220	if cfg.Logging.File.Enabled {
221		logPath := cfg.Logging.File.Path
222		if logPath == "" {
223			// Default to {data_dir}/logs/gordon.log
224			logPath = filepath.Join(cfg.Server.DataDir, "logs", "gordon.log")
225		}
226
227		log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{
228			Enabled:    true,
229			Path:       logPath,
230			MaxSize:    cfg.Logging.File.MaxSize,
231			MaxBackups: cfg.Logging.File.MaxBackups,
232			MaxAge:     cfg.Logging.File.MaxAge,
233			Compress:   true,
234		})
235		if err != nil {
236			return zerowrap.Default(), nil, fmt.Errorf("failed to create logger with file: %w", err)
237		}
238		return log, cleanup, nil
239	}
240
241	return zerowrap.New(logConfig), nil, nil
242}
243
244// createServices creates all the application services.
245func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (*services, error) {
246	svc := &services{}
247	var err error
248
249	// Create output adapters
250	if svc.runtime, svc.eventBus, err = createOutputAdapters(ctx, log); err != nil {
251		return nil, err
252	}
253
254	// Create storage
255	if svc.blobStorage, svc.manifestStorage, err = createStorage(cfg, log); err != nil {
256		return nil, err
257	}
258
259	// Create env loader
260	if svc.envLoader, err = createEnvLoader(cfg, log); err != nil {
261		return nil, err
262	}
263
264	// Create log writer
265	if svc.logWriter, err = createLogWriter(cfg, log); err != nil {
266		return nil, err
267	}
268
269	// Create auth service (if enabled)
270	if svc.tokenStore, svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil {
271		return nil, err
272	}
273
274	// Generate internal registry credentials for loopback-only pulls
275	if cfg.Auth.Enabled {
276		svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
277		if err != nil {
278			return nil, log.WrapErr(err, "failed to generate internal registry credentials")
279		}
280
281		// Persist credentials to file for CLI access (gordon auth internal)
282		if err := persistInternalCredentials(svc.internalRegUser, svc.internalRegPass); err != nil {
283			log.Warn().Err(err).Msg("failed to persist internal credentials for CLI access")
284		}
285
286		log.Debug().Msg("internal registry auth generated for loopback pulls")
287	}
288
289	// Create use case services
290	svc.configSvc = config.NewService(v, svc.eventBus)
291	if err := svc.configSvc.Load(ctx); err != nil {
292		return nil, log.WrapErr(err, "failed to load configuration")
293	}
294
295	svc.containerSvc = createContainerService(v, cfg, svc)
296	svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
297	svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{
298		RegistryDomain: cfg.Server.RegistryDomain,
299		RegistryPort:   cfg.Server.RegistryPort,
300	})
301
302	// Create token handler for registry token endpoint
303	if svc.authSvc != nil {
304		internalAuth := authhandler.InternalAuth{
305			Username: svc.internalRegUser,
306			Password: svc.internalRegPass,
307		}
308		svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log)
309	}
310
311	// Determine env directory for admin API
312	dataDir := cfg.Server.DataDir
313	if dataDir == "" {
314		dataDir = DefaultDataDir()
315	}
316	svc.envDir = cfg.Env.Dir
317	if svc.envDir == "" {
318		svc.envDir = filepath.Join(dataDir, "env")
319	}
320
321	// Create domain secret store and service
322	domainSecretStore, err := domainsecrets.NewFileStore(svc.envDir, log)
323	if err != nil {
324		return nil, log.WrapErr(err, "failed to create domain secret store")
325	}
326	secretSvc := secretsSvc.NewService(domainSecretStore, log)
327
328	// Create health service for route health checking
329	prober := httpprober.New()
330	healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)
331
332	// Create log service for accessing logs via admin API
333	logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log)
334
335	// Create admin handler for admin API
336	svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.eventBus, log)
337
338	return svc, nil
339}
340
341// resolveLogFilePath returns the configured log file path or a default.
342func resolveLogFilePath(cfg Config) string {
343	if cfg.Logging.File.Path != "" {
344		return cfg.Logging.File.Path
345	}
346	if cfg.Logging.File.Enabled {
347		return filepath.Join(cfg.Server.DataDir, "logs", "gordon.log")
348	}
349	return ""
350}
351
352// createOutputAdapters creates the Docker runtime and event bus.
353func createOutputAdapters(ctx context.Context, log zerowrap.Logger) (*docker.Runtime, *eventbus.InMemory, error) {
354	runtime, err := docker.NewRuntime()
355	if err != nil {
356		return nil, nil, log.WrapErr(err, "failed to create Docker runtime")
357	}
358
359	if err := runtime.Ping(ctx); err != nil {
360		return nil, nil, log.WrapErr(err, "Docker is not available")
361	}
362
363	dockerVersion, _ := runtime.Version(ctx)
364	log.Info().Str("docker_version", dockerVersion).Msg("Docker runtime initialized")
365
366	eventBus := eventbus.NewInMemory(100, log)
367
368	return runtime, eventBus, nil
369}
370
371// createStorage creates blob and manifest storage.
372func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *filesystem.ManifestStorage, error) {
373	dataDir := cfg.Server.DataDir
374	if dataDir == "" {
375		dataDir = DefaultDataDir()
376	}
377
378	registryDir := filepath.Join(dataDir, "registry")
379
380	blobStorage, err := filesystem.NewBlobStorage(registryDir, log)
381	if err != nil {
382		return nil, nil, log.WrapErr(err, "failed to create blob storage")
383	}
384
385	manifestStorage, err := filesystem.NewManifestStorage(registryDir, log)
386	if err != nil {
387		return nil, nil, log.WrapErr(err, "failed to create manifest storage")
388	}
389
390	return blobStorage, manifestStorage, nil
391}
392
393// createEnvLoader creates the environment loader with secret providers.
394func createEnvLoader(cfg Config, log zerowrap.Logger) (*envloader.FileLoader, error) {
395	dataDir := cfg.Server.DataDir
396	if dataDir == "" {
397		dataDir = DefaultDataDir()
398	}
399
400	envDir := cfg.Env.Dir
401	if envDir == "" {
402		envDir = filepath.Join(dataDir, "env")
403	}
404
405	envLoader, err := envloader.NewFileLoader(envDir, log)
406	if err != nil {
407		return nil, log.WrapErr(err, "failed to create env loader")
408	}
409
410	// Register secret providers
411	passProvider := secrets.NewPassProvider(log)
412	if passProvider.IsAvailable() {
413		envLoader.RegisterSecretProvider(passProvider)
414		log.Debug().Msg("pass secret provider registered")
415	}
416
417	sopsProvider := secrets.NewSopsProvider(log)
418	if sopsProvider.IsAvailable() {
419		envLoader.RegisterSecretProvider(sopsProvider)
420		log.Debug().Msg("sops secret provider registered")
421	}
422
423	return envLoader, nil
424}
425
426// createLogWriter creates the container log writer.
427func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, error) {
428	if !cfg.Logging.ContainerLogs.Enabled {
429		log.Debug().Msg("container log collection disabled")
430		return nil, nil
431	}
432
433	// Determine log directory
434	logDir := cfg.Logging.ContainerLogs.Dir
435	if logDir == "" {
436		dataDir := cfg.Server.DataDir
437		if dataDir == "" {
438			dataDir = DefaultDataDir()
439		}
440		logDir = filepath.Join(dataDir, "logs", "containers")
441	}
442
443	writer, err := logwriter.New(logwriter.Config{
444		Dir:        logDir,
445		MaxSize:    cfg.Logging.ContainerLogs.MaxSize,
446		MaxBackups: cfg.Logging.ContainerLogs.MaxBackups,
447		MaxAge:     cfg.Logging.ContainerLogs.MaxAge,
448	})
449	if err != nil {
450		return nil, log.WrapErr(err, "failed to create container log writer")
451	}
452
453	log.Info().Str("dir", logDir).Msg("container log collection enabled")
454	return writer, nil
455}
456
457const internalRegistryUsername = "gordon-internal"
458
459func generateInternalRegistryAuth() (string, string, error) {
460	password, err := randomTokenHex(32)
461	if err != nil {
462		return "", "", err
463	}
464	return internalRegistryUsername, password, nil
465}
466
467func randomTokenHex(size int) (string, error) {
468	buf := make([]byte, size)
469	if _, err := rand.Read(buf); err != nil {
470		return "", err
471	}
472	return hex.EncodeToString(buf), nil
473}
474
475// InternalCredentials holds the internal registry credentials for CLI access.
476type InternalCredentials struct {
477	Username string `json:"username"`
478	Password string `json:"password"`
479}
480
481// getSecureRuntimeDir returns a secure directory for runtime files.
482// Priority: XDG_RUNTIME_DIR > ~/.gordon/run
483func getSecureRuntimeDir() (string, error) {
484	// Try XDG_RUNTIME_DIR first (typically /run/user/<uid> on Linux)
485	if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
486		gordonDir := filepath.Join(runtimeDir, "gordon")
487		if err := os.MkdirAll(gordonDir, 0700); err == nil {
488			return gordonDir, nil
489		}
490	}
491
492	// Fall back to ~/.gordon/run
493	homeDir, err := os.UserHomeDir()
494	if err != nil {
495		return "", fmt.Errorf("failed to get home directory: %w", err)
496	}
497
498	gordonDir := filepath.Join(homeDir, ".gordon", "run")
499	if err := os.MkdirAll(gordonDir, 0700); err != nil {
500		return "", fmt.Errorf("failed to create runtime directory: %w", err)
501	}
502
503	return gordonDir, nil
504}
505
506// getInternalCredentialsFile returns the path to the internal credentials file.
507// SECURITY: Credentials are stored in a secure location with restricted permissions.
508func getInternalCredentialsFile() string {
509	runtimeDir, err := getSecureRuntimeDir()
510	if err != nil {
511		// Fall back to temp dir if we can't get secure dir (shouldn't happen)
512		return filepath.Join(os.TempDir(), "gordon-internal-creds.json")
513	}
514	return filepath.Join(runtimeDir, "internal-creds.json")
515}
516
517// persistInternalCredentials saves the internal registry credentials to a secure file.
518// SECURITY: Credentials are stored in XDG_RUNTIME_DIR or ~/.gordon/run with 0600 permissions.
519// The file is cleaned up on graceful shutdown but may persist if Gordon crashes.
520// These credentials are for internal loopback communication only and are regenerated on each start.
521func persistInternalCredentials(username, password string) error {
522	creds := InternalCredentials{
523		Username: username,
524		Password: password,
525	}
526	data, err := json.Marshal(creds)
527	if err != nil {
528		return fmt.Errorf("failed to marshal credentials: %w", err)
529	}
530
531	credFile := getInternalCredentialsFile()
532
533	// Ensure parent directory exists with secure permissions
534	if err := os.MkdirAll(filepath.Dir(credFile), 0700); err != nil {
535		return fmt.Errorf("failed to create credentials directory: %w", err)
536	}
537
538	// Write file with restrictive permissions (owner read/write only)
539	if err := os.WriteFile(credFile, data, 0600); err != nil {
540		return fmt.Errorf("failed to write credentials file: %w", err)
541	}
542	return nil
543}
544
545// cleanupInternalCredentials removes the internal credentials file.
546func cleanupInternalCredentials() {
547	_ = os.Remove(getInternalCredentialsFile())
548}
549
550// GetInternalCredentials reads the internal registry credentials from file.
551// This is used by the CLI to display credentials for manual recovery.
552func GetInternalCredentials() (*InternalCredentials, error) {
553	credFile := getInternalCredentialsFile()
554	data, err := os.ReadFile(credFile)
555	if err != nil {
556		return nil, fmt.Errorf("failed to read credentials file (is Gordon running?): %w", err)
557	}
558	var creds InternalCredentials
559	if err := json.Unmarshal(data, &creds); err != nil {
560		return nil, fmt.Errorf("failed to parse credentials: %w", err)
561	}
562	return &creds, nil
563}
564
565// createAuthService creates the authentication service and token store.
566func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
567	if !cfg.Auth.Enabled {
568		return nil, nil, nil
569	}
570
571	authType := resolveAuthType(cfg)
572	backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)
573	dataDir := resolveDataDir(cfg.Server.DataDir)
574
575	store, err := createTokenStore(backend, dataDir, log)
576	if err != nil {
577		return nil, nil, err
578	}
579
580	authConfig, err := buildAuthConfig(ctx, cfg, authType, backend, dataDir, log)
581	if err != nil {
582		return nil, nil, err
583	}
584
585	authSvc := auth.NewService(authConfig, store, log)
586
587	log.Info().
588		Str("type", string(authType)).
589		Str("backend", string(backend)).
590		Msg("registry authentication enabled")
591
592	return store, authSvc, nil
593}
594
595// resolveAuthType determines the auth type from config.
596// If password_hash is configured, password auth is available (plus tokens).
597// If only token_secret is configured, token-only mode.
598// The explicit "type" field is deprecated but still respected for backwards compat.
599func resolveAuthType(cfg Config) domain.AuthType {
600	// Explicit type takes precedence (backwards compatibility)
601	if cfg.Auth.Type == "token" {
602		return domain.AuthTypeToken
603	}
604	if cfg.Auth.Type == "password" {
605		return domain.AuthTypePassword
606	}
607
608	// Infer from config: password auth if password_hash is configured
609	if cfg.Auth.PasswordHash != "" || cfg.Auth.Password != "" {
610		return domain.AuthTypePassword
611	}
612
613	// Default to token-only
614	return domain.AuthTypeToken
615}
616
617func resolveSecretsBackend(backend string) domain.SecretsBackend {
618	switch backend {
619	case "pass":
620		return domain.SecretsBackendPass
621	case "sops":
622		return domain.SecretsBackendSops
623	case "unsafe", "":
624		return domain.SecretsBackendUnsafe
625	default:
626		return domain.SecretsBackendUnsafe
627	}
628}
629
630func resolveDataDir(dataDir string) string {
631	if dataDir == "" {
632		return DefaultDataDir()
633	}
634	return dataDir
635}
636
637func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
638	// Token store is always created since tokens work in both auth modes
639	store, err := tokenstore.NewStore(backend, dataDir, log)
640	if err != nil {
641		return nil, log.WrapErr(err, "failed to create token store")
642	}
643	return store, nil
644}
645
646func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
647	authConfig := auth.Config{
648		Enabled:  cfg.Auth.Enabled,
649		AuthType: authType,
650		Username: cfg.Auth.Username,
651	}
652
653	// Token config is always required (tokens work in all auth modes)
654	secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log)
655	if err != nil {
656		return auth.Config{}, err
657	}
658	authConfig.TokenSecret = secret
659	authConfig.TokenExpiry = expiry
660
661	// Password config only needed for password auth mode
662	if authType == domain.AuthTypePassword {
663		hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log)
664		if err != nil {
665			return auth.Config{}, err
666		}
667		if hash == "" {
668			return auth.Config{}, errAuthNotConfigured()
669		}
670		authConfig.PasswordHash = hash
671	}
672
673	return authConfig, nil
674}
675
676// errAuthNotConfigured returns an error when auth is enabled but credentials are not configured.
677func errAuthNotConfigured() error {
678	return fmt.Errorf("auth is enabled by default, configure auth type and secrets backend. See: https://gordon.bnema.dev/docs/config/auth")
679}
680
681func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) {
682	if cfg.Auth.PasswordHash != "" {
683		hash, err := loadSecret(ctx, backend, cfg.Auth.PasswordHash, dataDir, log)
684		if err != nil {
685			return "", log.WrapErr(err, "failed to load password hash")
686		}
687		return hash, nil
688	}
689
690	if cfg.Auth.Password != "" {
691		log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend")
692		return cfg.Auth.Password, nil
693	}
694
695	return "", nil
696}
697
698func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) {
699	secret, err := loadTokenSecret(ctx, cfg, backend, dataDir, log)
700	if err != nil {
701		return nil, 0, err
702	}
703
704	expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
705	if err != nil {
706		return nil, 0, err
707	}
708
709	return secret, expiry, nil
710}
711
712// TokenSecretEnvVar is the environment variable for the JWT signing secret.
713// SECURITY: This takes priority over config file to allow secure secret injection.
714const TokenSecretEnvVar = "GORDON_AUTH_TOKEN_SECRET" //nolint:gosec // This is an env var name, not a credential
715
716func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {
717	// SECURITY: Priority order for token secret:
718	// 1. Environment variable (most secure - no disk exposure)
719	// 2. Secrets backend (pass/sops - encrypted)
720	// 3. Config file path (least preferred)
721
722	// Check environment variable first
723	if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" {
724		log.Debug().Msg("using token secret from environment variable")
725		return []byte(envSecret), nil
726	}
727
728	// Fall back to config-specified path via secrets backend
729	if cfg.Auth.TokenSecret == "" {
730		return nil, fmt.Errorf("token_secret is required for JWT token generation; set %s environment variable or configure auth.token_secret", TokenSecretEnvVar)
731	}
732
733	secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log)
734	if err != nil {
735		return nil, log.WrapErr(err, "failed to load token secret")
736	}
737
738	return []byte(secret), nil
739}
740
741func parseTokenExpiry(expiry string) (time.Duration, error) {
742	if expiry == "" {
743		return 0, nil
744	}
745
746	parsed, err := duration.Parse(expiry)
747	if err != nil {
748		return 0, fmt.Errorf("invalid token_expiry: %w", err)
749	}
750
751	return parsed, nil
752}
753
754// loadSecret loads a secret from the configured backend.
755func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) {
756	switch backend {
757	case domain.SecretsBackendPass:
758		provider := secrets.NewPassProvider(log)
759		return provider.GetSecret(ctx, path)
760	case domain.SecretsBackendSops:
761		provider := secrets.NewSopsProvider(log)
762		return provider.GetSecret(ctx, path)
763	case domain.SecretsBackendUnsafe:
764		// For unsafe backend, path is relative to dataDir/secrets/
765		secretFile := filepath.Join(dataDir, "secrets", path)
766		data, err := os.ReadFile(secretFile)
767		if err != nil {
768			return "", fmt.Errorf("failed to read secret file: %w", err)
769		}
770		return string(data), nil
771	default:
772		return "", fmt.Errorf("unknown secrets backend: %s", backend)
773	}
774}
775
776// createContainerService creates the container service with configuration.
777func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service {
778	containerConfig := container.Config{
779		RegistryAuthEnabled:      cfg.Auth.Enabled,
780		RegistryDomain:           cfg.Server.RegistryDomain,
781		RegistryPort:             cfg.Server.RegistryPort,
782		RegistryUsername:         cfg.Auth.Username,
783		RegistryPassword:         cfg.Auth.Password,
784		InternalRegistryUsername: svc.internalRegUser,
785		InternalRegistryPassword: svc.internalRegPass,
786		PullPolicy:               v.GetString("deploy.pull_policy"),
787		VolumeAutoCreate:         v.GetBool("volumes.auto_create"),
788		VolumePrefix:             v.GetString("volumes.prefix"),
789		VolumePreserve:           v.GetBool("volumes.preserve"),
790		NetworkIsolation:         v.GetBool("network_isolation.enabled"),
791		NetworkPrefix:            v.GetString("network_isolation.network_prefix"),
792		DNSSuffix:                v.GetString("network_isolation.dns_suffix"),
793		NetworkGroups:            svc.configSvc.GetNetworkGroups(),
794		Attachments:              svc.configSvc.GetAttachments(),
795	}
796	return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig)
797}
798
799// registerEventHandlers registers all event handlers.
800func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error {
801	imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
802	if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil {
803		return fmt.Errorf("failed to subscribe image pushed handler: %w", err)
804	}
805
806	// Auto-route handler for creating routes from image labels
807	autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain).
808		WithEnvExtractor(svc.runtime, svc.envDir)
809	if err := svc.eventBus.Subscribe(autoRouteHandler); err != nil {
810		return fmt.Errorf("failed to subscribe auto-route handler: %w", err)
811	}
812
813	configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)
814	if err := svc.eventBus.Subscribe(configReloadHandler); err != nil {
815		return fmt.Errorf("failed to subscribe config reload handler: %w", err)
816	}
817
818	manualReloadHandler := container.NewManualReloadHandler(ctx, svc.containerSvc, svc.configSvc)
819	if err := svc.eventBus.Subscribe(manualReloadHandler); err != nil {
820		return fmt.Errorf("failed to subscribe manual reload handler: %w", err)
821	}
822
823	manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc)
824	if err := svc.eventBus.Subscribe(manualDeployHandler); err != nil {
825		return fmt.Errorf("failed to subscribe manual deploy handler: %w", err)
826	}
827
828	// Proxy cache invalidation on container deployment (for zero-downtime)
829	containerDeployedHandler := proxy.NewContainerDeployedHandler(ctx, svc.proxySvc)
830	if err := svc.eventBus.Subscribe(containerDeployedHandler); err != nil {
831		return fmt.Errorf("failed to subscribe container deployed handler: %w", err)
832	}
833
834	return nil
835}
836
837// setupConfigHotReload sets up Viper config hot reload.
838// NOTE: This does NOT reload routes into memory. Routes are managed via API
839// (AddRoute/UpdateRoute/RemoveRoute) and memory is the source of truth.
840// The file watcher only updates proxy config and refreshes targets.
841// Manual config file edits to routes require a server restart.
842func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) {
843	v.OnConfigChange(func(e fsnotify.Event) {
844		log.Info().Str("file", e.Name).Msg("config file changed")
845
846		if err := v.ReadInConfig(); err != nil {
847			log.Error().Err(err).Msg("failed to reload config")
848			return
849		}
850
851		// Update proxy config from viper (reads directly from viper, not memory)
852		svc.proxySvc.UpdateConfig(proxy.Config{
853			RegistryDomain: v.GetString("server.registry_domain"),
854			RegistryPort:   v.GetInt("server.registry_port"),
855		})
856
857		// Clear proxy target cache to pick up external route changes
858		if err := svc.proxySvc.RefreshTargets(ctx); err != nil {
859			log.Warn().Err(err).Msg("failed to refresh proxy targets")
860		}
861
862		log.Debug().Msg("config hot reload complete (routes unchanged, memory is source of truth)")
863	})
864	v.WatchConfig()
865}
866
867// syncAndAutoStart syncs existing containers and auto-starts if configured.
868func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
869	if err := svc.containerSvc.SyncContainers(ctx); err != nil {
870		log.Warn().Err(err).Msg("failed to sync existing containers")
871	}
872
873	if svc.configSvc.IsAutoRouteEnabled() {
874		routes := svc.configSvc.GetRoutes(ctx)
875		if err := svc.containerSvc.AutoStart(ctx, routes); err != nil {
876			log.Warn().Err(err).Msg("failed to auto-start containers")
877		}
878	}
879}
880
881// createHTTPHandlers creates HTTP handlers with middleware.
882func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) {
883	registryHandler := registry.NewHandler(svc.registrySvc, log)
884
885	registryMiddlewares := []func(http.Handler) http.Handler{
886		middleware.PanicRecovery(log),
887		middleware.RequestLogger(log),
888		middleware.SecurityHeaders,
889	}
890
891	// Create rate limiters using the factory
892	var rateLimitMiddleware func(http.Handler) http.Handler
893	if cfg.API.RateLimit.Enabled {
894		globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
895		ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
896		rateLimitMiddleware = registry.RateLimitMiddleware(
897			globalLimiter,
898			ipLimiter,
899			cfg.API.RateLimit.TrustedProxies,
900			log,
901		)
902	} else {
903		rateLimitMiddleware = registry.RateLimitMiddleware(nil, nil, nil, log)
904	}
905	registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
906
907	if cfg.Auth.Enabled && svc.authSvc != nil {
908		internalAuth := middleware.InternalRegistryAuth{
909			Username: svc.internalRegUser,
910			Password: svc.internalRegPass,
911		}
912		registryMiddlewares = append(registryMiddlewares,
913			middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
914	}
915
916	registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler)
917
918	// Create a mux that routes:
919	// - /auth/* to auth handler (no auth required, for password login and token exchange)
920	// - /v2/* to registry handler (with auth)
921	// - /admin/* to admin handler (with auth)
922	registryMux := http.NewServeMux()
923	if svc.authHandler != nil {
924		// Auth endpoints are NOT protected by auth - they're where clients authenticate
925		// but still need rate limiting to prevent brute force attacks
926		authWithMiddleware := middleware.Chain(
927			middleware.PanicRecovery(log),
928			middleware.RequestLogger(log),
929			middleware.SecurityHeaders,
930			rateLimitMiddleware,
931		)(svc.authHandler)
932		registryMux.Handle("/auth/", authWithMiddleware)
933	}
934	registryMux.Handle("/v2/", registryWithMiddleware)
935
936	// Add admin API handler with auth middleware
937	if svc.adminHandler != nil {
938		adminMiddlewares := []func(http.Handler) http.Handler{
939			middleware.PanicRecovery(log),
940			middleware.RequestLogger(log),
941			middleware.SecurityHeaders,
942		}
943		// Add admin auth middleware if auth is enabled
944		if svc.authSvc != nil {
945			// Create rate limiters for admin API - uses same config as registry
946			var globalLimiter, ipLimiter out.RateLimiter
947			var trustedNets []*net.IPNet
948			if cfg.API.RateLimit.Enabled {
949				globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
950				ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
951				trustedNets = middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
952			}
953			adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log))
954		}
955		adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
956		registryMux.Handle("/admin/", adminWithMiddleware)
957	}
958
959	proxyMiddlewares := []func(http.Handler) http.Handler{
960		middleware.PanicRecovery(log),
961		middleware.RequestLogger(log),
962		middleware.SecurityHeaders,
963		middleware.CORS,
964	}
965
966	proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc)
967
968	return registryMux, proxyWithMiddleware
969}
970
971// runServers starts the HTTP servers and waits for shutdown.
972// Signal handling notes:
973// - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext
974// - SIGUSR1: Triggers config reload without restart
975// - SIGUSR2: Triggers manual deploy for a specific route
976// The deferred signal.Stop calls ensure signal handlers are properly
977// cleaned up before program exit, preventing signal handler leaks.
978func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, log zerowrap.Logger) error {
979	ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
980	defer cancel()
981
982	// Set up SIGUSR1 for reload.
983	// Note: signal.Stop must be called (via defer) to release the channel
984	// and prevent signal handler leaks when the function returns.
985	reloadChan := make(chan os.Signal, 1)
986	signal.Notify(reloadChan, syscall.SIGUSR1)
987	defer signal.Stop(reloadChan)
988
989	// Set up SIGUSR2 for manual deploy.
990	deployChan := make(chan os.Signal, 1)
991	signal.Notify(deployChan, syscall.SIGUSR2)
992	defer signal.Stop(deployChan)
993
994	errChan := make(chan error, 2)
995
996	go startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
997	go startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
998
999	log.Info().
1000		Int("proxy_port", cfg.Server.Port).
1001		Int("registry_port", cfg.Server.RegistryPort).
1002		Msg("Gordon is running")
1003
1004	for {
1005		select {
1006		case err := <-errChan:
1007			return err
1008		case <-reloadChan:
1009			log.Info().Msg("reload signal received (SIGUSR1)")
1010			if err := eventBus.Publish(domain.EventManualReload, nil); err != nil {
1011				log.Error().Err(err).Msg("failed to publish manual reload event")
1012			}
1013		case <-deployChan:
1014			log.Info().Msg("deploy signal received (SIGUSR2)")
1015			domainName, err := readDeployRequest()
1016			if err != nil {
1017				log.Error().Err(err).Msg("failed to read deploy request")
1018				continue
1019			}
1020			payload := &domain.ManualDeployPayload{Domain: domainName}
1021			if err := eventBus.Publish(domain.EventManualDeploy, payload); err != nil {
1022				log.Error().Err(err).Str("domain", domainName).Msg("failed to publish manual deploy event")
1023			}
1024		case <-ctx.Done():
1025			log.Info().Msg("shutdown signal received")
1026			goto shutdown
1027		}
1028	}
1029
1030shutdown:
1031	log.Info().Msg("shutting down Gordon...")
1032
1033	if err := containerSvc.Shutdown(ctx); err != nil {
1034		log.Warn().Err(err).Msg("error during container shutdown")
1035	}
1036
1037	// Clean up internal credentials file
1038	cleanupInternalCredentials()
1039
1040	log.Info().Msg("Gordon stopped")
1041	return nil
1042}
1043
1044// startServer starts an HTTP server.
1045func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) {
1046	log.Info().Str("address", addr).Msgf("%s server starting", name)
1047
1048	server := &http.Server{
1049		Addr:              addr,
1050		Handler:           handler,
1051		ReadHeaderTimeout: 10 * time.Second,
1052		ReadTimeout:       5 * time.Minute,   // Timeout for reading entire request
1053		WriteTimeout:      5 * time.Minute,   // Timeout for writing response
1054		IdleTimeout:       120 * time.Second, // Timeout for idle keep-alive connections
1055		MaxHeaderBytes:    1 << 20,           // 1MB max header size
1056	}
1057
1058	if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
1059		errChan <- fmt.Errorf("%s server error: %w", name, err)
1060	}
1061}
1062
1063// SendReloadSignal sends SIGUSR1 to the running Gordon process.
1064func SendReloadSignal() error {
1065	pidFile := findPidFile()
1066	if pidFile == "" {
1067		return fmt.Errorf("gordon PID file not found, is Gordon running?")
1068	}
1069
1070	pidBytes, err := os.ReadFile(pidFile)
1071	if err != nil {
1072		return fmt.Errorf("failed to read PID file: %w", err)
1073	}
1074
1075	var pid int
1076	if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil {
1077		return fmt.Errorf("failed to parse PID: %w", err)
1078	}
1079
1080	process, err := os.FindProcess(pid)
1081	if err != nil {
1082		return fmt.Errorf("failed to find process: %w", err)
1083	}
1084
1085	if err := process.Signal(syscall.SIGUSR1); err != nil {
1086		return fmt.Errorf("failed to send reload signal: %w", err)
1087	}
1088
1089	return nil
1090}
1091
1092// getDeployRequestFile returns the path to the deploy request file.
1093func getDeployRequestFile() string {
1094	return filepath.Join(os.TempDir(), "gordon-deploy-request")
1095}
1096
1097// writeDeployRequestFile creates the deploy request file exclusively with retry.
1098// This prevents race conditions when multiple deploy commands run simultaneously.
1099func writeDeployRequestFile(path string, data []byte, timeout time.Duration) error {
1100	deadline := time.Now().Add(timeout)
1101
1102	for {
1103		f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
1104		if err == nil {
1105			_, writeErr := f.Write(data)
1106			f.Close()
1107			if writeErr != nil {
1108				_ = os.Remove(path)
1109				return writeErr
1110			}
1111			return nil
1112		}
1113
1114		if os.IsExist(err) {
1115			if time.Now().After(deadline) {
1116				return fmt.Errorf("deploy request file still present after timeout; another deploy may be in progress")
1117			}
1118			time.Sleep(100 * time.Millisecond)
1119			continue
1120		}
1121
1122		return err
1123	}
1124}
1125
1126// SendDeploySignal triggers a manual deploy for a specific route via SIGUSR2.
1127// Returns the domain name on success for the caller to display.
1128func SendDeploySignal(domain string) (string, error) {
1129	deployFile := getDeployRequestFile()
1130	if err := writeDeployRequestFile(deployFile, []byte(domain), 5*time.Second); err != nil {
1131		return "", fmt.Errorf("failed to write deploy request: %w", err)
1132	}
1133
1134	// Find PID and send SIGUSR2
1135	pidFile := findPidFile()
1136	if pidFile == "" {
1137		_ = os.Remove(deployFile)
1138		return "", fmt.Errorf("gordon PID file not found, is Gordon running?")
1139	}
1140
1141	pidBytes, err := os.ReadFile(pidFile)
1142	if err != nil {
1143		_ = os.Remove(deployFile)
1144		return "", fmt.Errorf("failed to read PID file: %w", err)
1145	}
1146
1147	var pid int
1148	if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil {
1149		_ = os.Remove(deployFile)
1150		return "", fmt.Errorf("failed to parse PID: %w", err)
1151	}
1152
1153	process, err := os.FindProcess(pid)
1154	if err != nil {
1155		_ = os.Remove(deployFile)
1156		return "", fmt.Errorf("failed to find process: %w", err)
1157	}
1158
1159	if err := process.Signal(syscall.SIGUSR2); err != nil {
1160		_ = os.Remove(deployFile)
1161		return "", fmt.Errorf("failed to send deploy signal: %w", err)
1162	}
1163
1164	return domain, nil
1165}
1166
1167// readDeployRequest reads and removes the deploy request file atomically.
1168// Returns empty string if file doesn't exist (may have been consumed by another handler).
1169func readDeployRequest() (string, error) {
1170	deployFile := getDeployRequestFile()
1171
1172	// Rename to a temp file first to make the read-and-delete atomic
1173	tmpFile := deployFile + ".processing"
1174	if err := os.Rename(deployFile, tmpFile); err != nil {
1175		if os.IsNotExist(err) {
1176			return "", fmt.Errorf("deploy request file not found (may have been processed already)")
1177		}
1178		return "", fmt.Errorf("failed to acquire deploy request: %w", err)
1179	}
1180
1181	data, err := os.ReadFile(tmpFile)
1182	_ = os.Remove(tmpFile) // Always clean up
1183	if err != nil {
1184		return "", fmt.Errorf("failed to read deploy request: %w", err)
1185	}
1186
1187	return string(data), nil
1188}
1189
1190// createPidFile creates a PID file for the Gordon process.
1191// SECURITY: Prefers secure locations (XDG_RUNTIME_DIR, ~/.gordon/run) over /tmp
1192// to prevent symlink attacks and unauthorized access.
1193func createPidFile(log zerowrap.Logger) string {
1194	pid := os.Getpid()
1195
1196	// SECURITY: Prioritize secure locations over /tmp
1197	var locations []string
1198
1199	// Try secure runtime directory first
1200	if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1201		locations = append(locations, filepath.Join(runtimeDir, "gordon.pid"))
1202	}
1203
1204	// Fall back to home directory
1205	if homeDir, err := os.UserHomeDir(); err == nil {
1206		locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid"))
1207	}
1208
1209	// Last resort: /tmp (least secure due to world-writable)
1210	locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid"))
1211
1212	for _, location := range locations {
1213		// Ensure parent directory exists with secure permissions
1214		if err := os.MkdirAll(filepath.Dir(location), 0700); err != nil {
1215			continue
1216		}
1217		if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil {
1218			log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file")
1219			return location
1220		}
1221	}
1222
1223	log.Warn().Int("pid", pid).Msg("failed to create PID file in any location")
1224	return ""
1225}
1226
1227// removePidFile removes the PID file.
1228func removePidFile(pidFile string, log zerowrap.Logger) {
1229	if err := os.Remove(pidFile); err != nil {
1230		log.Warn().Err(err).Str("pid_file", pidFile).Msg("failed to remove PID file")
1231	} else {
1232		log.Debug().Str("pid_file", pidFile).Msg("removed PID file")
1233	}
1234}
1235
1236// findPidFile finds the Gordon PID file.
1237// Checks secure locations first, then falls back to legacy /tmp location.
1238func findPidFile() string {
1239	var locations []string
1240
1241	// Check secure runtime directory first
1242	if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1243		locations = append(locations, filepath.Join(runtimeDir, "gordon.pid"))
1244	}
1245
1246	// Check home directory
1247	if homeDir, err := os.UserHomeDir(); err == nil {
1248		locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid"))
1249		// Legacy location for backward compatibility
1250		locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
1251	}
1252
1253	// Legacy /tmp locations for backward compatibility
1254	locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid"))
1255	locations = append(locations, "/tmp/gordon.pid")
1256
1257	for _, location := range locations {
1258		if _, err := os.Stat(location); err == nil {
1259			return location
1260		}
1261	}
1262
1263	return ""
1264}
1265
1266// loadConfig loads configuration from file and sets defaults.
1267func loadConfig(v *viper.Viper, configPath string) error {
1268	v.SetDefault("server.port", 80)
1269	v.SetDefault("server.registry_port", 5000)
1270	v.SetDefault("server.data_dir", DefaultDataDir())
1271	v.SetDefault("logging.level", "info")
1272	v.SetDefault("logging.format", "console")
1273	v.SetDefault("logging.file.enabled", false)
1274	v.SetDefault("logging.file.max_size", 100)
1275	v.SetDefault("logging.file.max_backups", 3)
1276	v.SetDefault("logging.file.max_age", 28)
1277	v.SetDefault("logging.container_logs.enabled", true)
1278	v.SetDefault("logging.container_logs.dir", "")
1279	v.SetDefault("logging.container_logs.max_size", 100)
1280	v.SetDefault("logging.container_logs.max_backups", 3)
1281	v.SetDefault("logging.container_logs.max_age", 28)
1282	v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
1283	v.SetDefault("auth.enabled", true)
1284	// Note: auth.type is intentionally not set - it's inferred from config
1285	// If password_hash is set -> password mode, otherwise -> token mode
1286	v.SetDefault("auth.secrets_backend", "unsafe")
1287	v.SetDefault("auth.token_expiry", "720h")
1288	v.SetDefault("api.rate_limit.enabled", true)
1289	v.SetDefault("api.rate_limit.global_rps", 500)
1290	v.SetDefault("api.rate_limit.per_ip_rps", 50)
1291	v.SetDefault("api.rate_limit.burst", 100)
1292	v.SetDefault("auto_route.enabled", false)
1293	v.SetDefault("network_isolation.enabled", false)
1294	v.SetDefault("network_isolation.network_prefix", "gordon")
1295	v.SetDefault("network_isolation.dns_suffix", ".internal")
1296	v.SetDefault("volumes.auto_create", true)
1297	v.SetDefault("volumes.prefix", "gordon")
1298	v.SetDefault("volumes.preserve", true)
1299	v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
1300
1301	ConfigureViper(v, configPath)
1302
1303	if err := v.ReadInConfig(); err != nil {
1304		if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
1305			return fmt.Errorf("failed to read config file: %w", err)
1306		}
1307	}
1308
1309	v.SetEnvPrefix("GORDON")
1310	v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
1311	v.AutomaticEnv()
1312
1313	return nil
1314}

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