v2.9.0 8ebdfe0

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

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 8

Tags 106

↑↓ moveenter openesc close