v2.10.2 705579a

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

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