v2.13.0 7748089

run.go RawHistoryFolder

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

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