v2.11.0 dca6537

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

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