detached 2604a62

fix: harden PKI lifecycle, middleware coverage, and startup cleanup

- Guard CLI CA commands when internal TLS is disabled (tls_port=0)
- Log when HTTP→HTTPS redirect is a no-op due to missing config
- Fix loadOrGenerateIntermediate to return non-NotExist read errors
  instead of silently regenerating (mirrors loadOrGenerateRoot)
- Wrap entire httpsMux with middleware chain so onboarding routes
  get PanicRecovery, RequestLogger, and SecurityHeaders
- Stop pkiSvc goroutine on createServices error via deferred cleanup
- Shut down partially-started servers on startup failure in runServers
- Check isDomainAllowed before returning cached certs
- Use leaf cert NotAfter for cache TTL instead of synthetic lifetime
- Call renewIntermediateIfNeeded synchronously at startup
- Use assert.Same for pointer-identity cache test
- Extract createServices into serviceInit methods to reduce complexity
commit
2604a6216fa7dfe6e0518c5e026ede6618f6b10c
author
brice · · 2026-03-31 04:31 UTC
parent
67e87d2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +183 -83
M internal/adapters/in/cli/ca.go +3 -0
@@ -99,6 +99,9 @@ func resolveCADataDir() (string, error) {
9999 if err != nil {
100100 return "", err
101101 }
102+ if local.GetTLSPort() == 0 {
103+ return "", fmt.Errorf("internal TLS is disabled (server.tls_port=0); CA commands are not available — set server.tls_port to enable")
104+ }
102105 return local.GetDataDir(), nil
103106 }
104107
@@ -99,6 +99,9 @@ func resolveCADataDir() (string, error) {
99 if err != nil {99 if err != nil {
100 return "", err100 return "", err
101 }101 }
102+ if local.GetTLSPort() == 0 {
103+ return "", fmt.Errorf("internal TLS is disabled (server.tls_port=0); CA commands are not available — set server.tls_port to enable")
104+ }
102 return local.GetDataDir(), nil105 return local.GetDataDir(), nil
103 }106 }
104 107
M internal/adapters/in/cli/local.go +7 -0
@@ -24,6 +24,7 @@ type LocalServices struct {
2424 configSvc in.ConfigService
2525 secretSvc in.SecretService
2626 dataDir string
27+ tlsPort int
2728 }
2829
2930 // GetConfigService returns the config service.
@@ -41,6 +42,11 @@ func (l *LocalServices) GetDataDir() string {
4142 return l.dataDir
4243 }
4344
45+// GetTLSPort returns the configured TLS port (0 means internal TLS is disabled).
46+func (l *LocalServices) GetTLSPort() int {
47+ return l.tlsPort
48+}
49+
4450 // GetLocalServices creates local services for CLI operations.
4551 // It loads the config and initializes services without starting the server.
4652 func GetLocalServices(configPath string) (*LocalServices, error) {
@@ -96,6 +102,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
96102 configSvc: configSvc,
97103 secretSvc: secretSvc,
98104 dataDir: dataDir,
105+ tlsPort: v.GetInt("server.tls_port"),
99106 }, nil
100107 }
101108
@@ -24,6 +24,7 @@ type LocalServices struct {
24 configSvc in.ConfigService24 configSvc in.ConfigService
25 secretSvc in.SecretService25 secretSvc in.SecretService
26 dataDir string26 dataDir string
27+ tlsPort int
27 }28 }
28 29
29 // GetConfigService returns the config service.30 // GetConfigService returns the config service.
@@ -41,6 +42,11 @@ func (l *LocalServices) GetDataDir() string {
41 return l.dataDir42 return l.dataDir
42 }43 }
43 44
45+// GetTLSPort returns the configured TLS port (0 means internal TLS is disabled).
46+func (l *LocalServices) GetTLSPort() int {
47+ return l.tlsPort
48+}
49+
44 // GetLocalServices creates local services for CLI operations.50 // GetLocalServices creates local services for CLI operations.
45 // It loads the config and initializes services without starting the server.51 // It loads the config and initializes services without starting the server.
46 func GetLocalServices(configPath string) (*LocalServices, error) {52 func GetLocalServices(configPath string) (*LocalServices, error) {
@@ -96,6 +102,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
96 configSvc: configSvc,102 configSvc: configSvc,
97 secretSvc: secretSvc,103 secretSvc: secretSvc,
98 dataDir: dataDir,104 dataDir: dataDir,
105+ tlsPort: v.GetInt("server.tls_port"),
99 }, nil106 }, nil
100 }107 }
101 108
M internal/adapters/in/http/middleware/cidr.go +4 -0
@@ -80,6 +80,10 @@ func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerow
8080 return next
8181 }
8282 if !forceAll && len(proxyNets) == 0 {
83+ log.Info().
84+ Str(zerowrap.FieldLayer, "adapter").
85+ Str(zerowrap.FieldAdapter, "http").
86+ Msg("HTTP→HTTPS redirect disabled: proxy_allowed_ips is empty and force_https_redirect is false; set either to enable redirects")
8387 return next
8488 }
8589 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -80,6 +80,10 @@ func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerow
80 return next80 return next
81 }81 }
82 if !forceAll && len(proxyNets) == 0 {82 if !forceAll && len(proxyNets) == 0 {
83+ log.Info().
84+ Str(zerowrap.FieldLayer, "adapter").
85+ Str(zerowrap.FieldAdapter, "http").
86+ Msg("HTTP→HTTPS redirect disabled: proxy_allowed_ips is empty and force_https_redirect is false; set either to enable redirects")
83 return next87 return next
84 }88 }
85 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {89 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
M internal/adapters/out/pki/ca.go +18 -5
@@ -234,7 +234,11 @@ func (ca *CA) loadOrGenerateIntermediate() error {
234234 certPEM, certErr := os.ReadFile(certPath)
235235 keyPEM, keyErr := os.ReadFile(keyPath)
236236
237- if certErr == nil && keyErr == nil {
237+ certMissing := certErr != nil && errors.Is(certErr, os.ErrNotExist)
238+ keyMissing := keyErr != nil && errors.Is(keyErr, os.ErrNotExist)
239+
240+ switch {
241+ case certErr == nil && keyErr == nil:
238242 cert, key, err := parseCertAndKey(certPEM, keyPEM)
239243 if err != nil {
240244 ca.log.Warn().Err(err).Msg("failed to parse intermediate CA, regenerating")
@@ -253,13 +257,22 @@ func (ca *CA) loadOrGenerateIntermediate() error {
253257 } else {
254258 ca.log.Warn().Msg("intermediate CA expired, regenerating")
255259 }
256- }
257260
258- if (certErr == nil) != (keyErr == nil) {
261+ case certMissing && keyMissing:
262+ // Both files absent — generate fresh intermediate below.
263+
264+ case certMissing || keyMissing:
259265 ca.log.Warn().
260- Bool("cert_missing", certErr != nil).
261- Bool("key_missing", keyErr != nil).
266+ Bool("cert_missing", certMissing).
267+ Bool("key_missing", keyMissing).
262268 Msg("intermediate CA files incomplete, regenerating")
269+
270+ default:
271+ // At least one ReadFile returned a non-NotExist error (e.g. EPERM, EIO).
272+ if certErr != nil {
273+ return fmt.Errorf("read intermediate cert: %w", certErr)
274+ }
275+ return fmt.Errorf("read intermediate key: %w", keyErr)
263276 }
264277
265278 cert, key, err := ca.generateIntermediate()
@@ -234,7 +234,11 @@ func (ca *CA) loadOrGenerateIntermediate() error {
234 certPEM, certErr := os.ReadFile(certPath)234 certPEM, certErr := os.ReadFile(certPath)
235 keyPEM, keyErr := os.ReadFile(keyPath)235 keyPEM, keyErr := os.ReadFile(keyPath)
236 236
237- if certErr == nil && keyErr == nil {237+ certMissing := certErr != nil && errors.Is(certErr, os.ErrNotExist)
238+ keyMissing := keyErr != nil && errors.Is(keyErr, os.ErrNotExist)
239+
240+ switch {
241+ case certErr == nil && keyErr == nil:
238 cert, key, err := parseCertAndKey(certPEM, keyPEM)242 cert, key, err := parseCertAndKey(certPEM, keyPEM)
239 if err != nil {243 if err != nil {
240 ca.log.Warn().Err(err).Msg("failed to parse intermediate CA, regenerating")244 ca.log.Warn().Err(err).Msg("failed to parse intermediate CA, regenerating")
@@ -253,13 +257,22 @@ func (ca *CA) loadOrGenerateIntermediate() error {
253 } else {257 } else {
254 ca.log.Warn().Msg("intermediate CA expired, regenerating")258 ca.log.Warn().Msg("intermediate CA expired, regenerating")
255 }259 }
256- }
257 260
258- if (certErr == nil) != (keyErr == nil) {261+ case certMissing && keyMissing:
262+ // Both files absent — generate fresh intermediate below.
263+
264+ case certMissing || keyMissing:
259 ca.log.Warn().265 ca.log.Warn().
260- Bool("cert_missing", certErr != nil).266+ Bool("cert_missing", certMissing).
261- Bool("key_missing", keyErr != nil).267+ Bool("key_missing", keyMissing).
262 Msg("intermediate CA files incomplete, regenerating")268 Msg("intermediate CA files incomplete, regenerating")
269+
270+ default:
271+ // At least one ReadFile returned a non-NotExist error (e.g. EPERM, EIO).
272+ if certErr != nil {
273+ return fmt.Errorf("read intermediate cert: %w", certErr)
274+ }
275+ return fmt.Errorf("read intermediate key: %w", keyErr)
263 }276 }
264 277
265 cert, key, err := ca.generateIntermediate()278 cert, key, err := ca.generateIntermediate()
M internal/app/run.go +138 -76
@@ -342,134 +342,179 @@ func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
342342 return log, cleanup, nil
343343 }
344344
345+// serviceInit holds the shared context for service initialization helpers.
346+type serviceInit struct {
347+ ctx context.Context
348+ v *viper.Viper
349+ cfg Config
350+ log zerowrap.Logger
351+ svc *services
352+}
353+
345354 // createServices creates all the application services.
346-func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (*services, error) {
347- svc := &services{}
355+func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) {
356+ si := &serviceInit{
357+ ctx: ctx,
358+ v: v,
359+ cfg: cfg,
360+ log: log,
361+ svc: &services{},
362+ }
363+ defer func() {
364+ if retErr != nil && si.svc.pkiSvc != nil {
365+ si.svc.pkiSvc.Stop()
366+ }
367+ }()
348368 var err error
349369
350370 // Create output adapters
351371 runtimeSocket := resolveRuntimeConfig(v.GetString("server.runtime"))
352- if svc.runtime, svc.eventBus, err = createOutputAdapters(ctx, log, runtimeSocket); err != nil {
372+ if si.svc.runtime, si.svc.eventBus, err = createOutputAdapters(ctx, log, runtimeSocket); err != nil {
353373 return nil, err
354374 }
355375
356376 // Create storage
357- if svc.blobStorage, svc.manifestStorage, err = createStorage(cfg, log); err != nil {
377+ if si.svc.blobStorage, si.svc.manifestStorage, err = createStorage(cfg, log); err != nil {
358378 return nil, err
359379 }
360380
361381 // Create log writer
362- if svc.logWriter, err = createLogWriter(cfg, log); err != nil {
382+ if si.svc.logWriter, err = createLogWriter(cfg, log); err != nil {
363383 return nil, err
364384 }
365385
366386 // Create auth service (if enabled)
367- if svc.tokenStore, svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil {
387+ if si.svc.tokenStore, si.svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil {
368388 return nil, err
369389 }
370390
371- if err := setupInternalRegistryAuth(svc, log); err != nil {
391+ if err := setupInternalRegistryAuth(si.svc, log); err != nil {
372392 return nil, err
373393 }
374394
375- // Create use case services
376- svc.configSvc = config.NewService(v, svc.eventBus)
377- if err := svc.configSvc.Load(ctx); err != nil {
395+ // Create config service
396+ si.svc.configSvc = config.NewService(v, si.svc.eventBus)
397+ if err := si.svc.configSvc.Load(ctx); err != nil {
378398 return nil, log.WrapErr(err, "failed to load configuration")
379399 }
380400
381- if (cfg.Server.TLSCertFile == "") != (cfg.Server.TLSKeyFile == "") {
382- return nil, fmt.Errorf("both tls_cert_file and tls_key_file must be set, or neither")
401+ if err := si.initPKI(); err != nil {
402+ return nil, err
383403 }
384404
385- if cfg.Server.TLSPort != 0 {
386- caAdapter, err := pkiadapter.NewCA(resolveDataDir(cfg.Server.DataDir), log)
387- if err != nil {
388- return nil, log.WrapErr(err, "failed to initialize internal CA")
389- }
390- svc.caAdapter = caAdapter
391- svc.pkiSvc = pkiusecase.NewService(ctx, caAdapter, svc.configSvc, log)
392- } else {
393- log.Info().Msg("internal CA disabled (server.tls_port=0)")
405+ if err := si.initSecrets(); err != nil {
406+ return nil, err
407+ }
408+
409+ if err := si.initRuntimeAndProxy(); err != nil {
410+ return nil, err
394411 }
395412
396- var backend domain.SecretsBackend
397- var passStore *domainsecrets.PassStore
398- var domainSecretStore out.DomainSecretStore
413+ si.initHandlers()
399414
400- svc.envDir, backend, passStore, domainSecretStore, err = createDomainSecretStore(cfg, log)
415+ return si.svc, nil
416+}
417+
418+// initPKI initialises the internal CA and PKI service when TLS is enabled.
419+func (si *serviceInit) initPKI() error {
420+ if (si.cfg.Server.TLSCertFile == "") != (si.cfg.Server.TLSKeyFile == "") {
421+ return fmt.Errorf("both tls_cert_file and tls_key_file must be set, or neither")
422+ }
423+
424+ if si.cfg.Server.TLSPort == 0 {
425+ si.log.Info().Msg("internal CA disabled (server.tls_port=0)")
426+ return nil
427+ }
428+
429+ caAdapter, err := pkiadapter.NewCA(resolveDataDir(si.cfg.Server.DataDir), si.log)
401430 if err != nil {
402- return nil, err
431+ return si.log.WrapErr(err, "failed to initialize internal CA")
403432 }
433+ si.svc.caAdapter = caAdapter
434+ si.svc.pkiSvc = pkiusecase.NewService(si.ctx, caAdapter, si.svc.configSvc, si.log)
435+ return nil
436+}
404437
405- if svc.envLoader, err = createEnvLoader(backend, svc.envDir, passStore, log); err != nil {
406- return nil, err
438+// initSecrets creates the domain secret store, env loader, and secret service.
439+func (si *serviceInit) initSecrets() error {
440+ envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log)
441+ if err != nil {
442+ return err
407443 }
444+ si.svc.envDir = envDir
408445
409- svc.secretSvc = secretsSvc.NewService(domainSecretStore, log, svc.eventBus)
446+ if si.svc.envLoader, err = createEnvLoader(backend, envDir, passStore, si.log); err != nil {
447+ return err
448+ }
410449
411- if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil {
412- return nil, err
450+ si.svc.secretSvc = secretsSvc.NewService(domainSecretStore, si.log, si.svc.eventBus)
451+ return nil
452+}
453+
454+// initRuntimeAndProxy creates container, backup, registry, image, volume, and proxy services.
455+func (si *serviceInit) initRuntimeAndProxy() error {
456+ var err error
457+
458+ if si.svc.containerSvc, err = createContainerService(si.ctx, si.v, si.cfg, si.svc, si.log); err != nil {
459+ return err
413460 }
414461
415- if svc.backupStorage, svc.backupSvc, err = createBackupService(cfg, svc, log); err != nil {
416- return nil, err
462+ if si.svc.backupStorage, si.svc.backupSvc, err = createBackupService(si.cfg, si.svc, si.log); err != nil {
463+ return err
417464 }
418465
419- svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
420- svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log)
421- svc.volumeSvc = volumesSvc.NewService(svc.runtime)
466+ si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus)
467+ si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log)
468+ si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime)
422469
423- injectTelemetryMetrics(cfg, svc, log)
470+ injectTelemetryMetrics(si.cfg, si.svc, si.log)
424471
425- proxyCfg, err := buildProxyConfig(cfg, log)
472+ proxyCfg, err := buildProxyConfig(si.cfg, si.log)
426473 if err != nil {
427- return nil, err
474+ return err
428475 }
429- svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
430- svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxyCfg.proxyConfig)
476+ si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
477+ si.svc.proxySvc = proxy.NewService(si.svc.runtime, si.svc.containerSvc, si.svc.configSvc, proxyCfg.proxyConfig)
431478
432479 // Wire synchronous proxy cache invalidation for zero-downtime deployments.
433480 // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget().
434- svc.containerSvc.SetProxyCacheInvalidator(svc.proxySvc)
435- svc.containerSvc.SetProxyDrainWaiter(svc.proxySvc)
481+ si.svc.containerSvc.SetProxyCacheInvalidator(si.svc.proxySvc)
482+ si.svc.containerSvc.SetProxyDrainWaiter(si.svc.proxySvc)
483+ return nil
484+}
436485
437- // Create token handler for registry token endpoint
438- if svc.authSvc != nil {
486+// initHandlers creates the auth, health, log, preview, and admin handlers.
487+func (si *serviceInit) initHandlers() {
488+ if si.svc.authSvc != nil {
439489 internalAuth := authhandler.InternalAuth{
440- Username: svc.internalRegUser,
441- Password: svc.internalRegPass,
490+ Username: si.svc.internalRegUser,
491+ Password: si.svc.internalRegPass,
442492 }
443- svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log)
493+ si.svc.authHandler = authhandler.NewHandler(si.svc.authSvc, internalAuth, si.log)
444494 }
445495
446- // Create health service for route health checking
447496 prober := httpprober.New()
448- svc.healthSvc = health.NewService(svc.configSvc, svc.containerSvc, prober, log)
449-
450- // Create log service for accessing logs via admin API
451- svc.logSvc = logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log)
452-
453- initPreviewService(ctx, cfg, svc, log)
454-
455- // Create admin handler for admin API
456- svc.adminHandler = admin.NewHandler(admin.HandlerDeps{
457- ConfigSvc: svc.configSvc,
458- AuthSvc: svc.authSvc,
459- ContainerSvc: svc.containerSvc,
460- HealthSvc: svc.healthSvc,
461- SecretSvc: svc.secretSvc,
462- LogSvc: svc.logSvc,
463- RegistrySvc: svc.registrySvc,
464- EventBus: svc.eventBus,
465- Log: log,
466- BackupSvc: svc.backupSvc,
467- PreviewSvc: svc.previewService,
468- ImageSvc: svc.imageSvc,
469- VolumeSvc: svc.volumeSvc,
497+ si.svc.healthSvc = health.NewService(si.svc.configSvc, si.svc.containerSvc, prober, si.log)
498+
499+ si.svc.logSvc = logs.NewService(resolveLogFilePath(si.cfg), si.cfg.Logging.File.Enabled, si.svc.containerSvc, si.svc.runtime, si.log)
500+
501+ initPreviewService(si.ctx, si.cfg, si.svc, si.log)
502+
503+ si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{
504+ ConfigSvc: si.svc.configSvc,
505+ AuthSvc: si.svc.authSvc,
506+ ContainerSvc: si.svc.containerSvc,
507+ HealthSvc: si.svc.healthSvc,
508+ SecretSvc: si.svc.secretSvc,
509+ LogSvc: si.svc.logSvc,
510+ RegistrySvc: si.svc.registrySvc,
511+ EventBus: si.svc.eventBus,
512+ Log: si.log,
513+ BackupSvc: si.svc.backupSvc,
514+ PreviewSvc: si.svc.previewService,
515+ ImageSvc: si.svc.imageSvc,
516+ VolumeSvc: si.svc.volumeSvc,
470517 })
471-
472- return svc, nil
473518 }
474519
475520 // initPreviewService sets up the preview store, service, and TTL ticker.
@@ -1615,12 +1660,13 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
16151660 // Onboarding routes live on the TLS port so Tailnet / direct clients
16161661 // can click through the initial cert warning, install the CA, and
16171662 // then trust all subsequent connections.
1663+ // The middleware chain wraps the entire mux so onboarding routes also
1664+ // get PanicRecovery, RequestLogger, and SecurityHeaders.
16181665 httpsProxyMiddlewares := []func(http.Handler) http.Handler{
16191666 middleware.PanicRecovery(log),
16201667 middleware.RequestLogger(log, trustedNets),
16211668 middleware.SecurityHeaders,
16221669 }
1623- httpsProxyWithMiddleware := middleware.Chain(httpsProxyMiddlewares...)(proxyHandler)
16241670
16251671 httpsMux := http.NewServeMux()
16261672 if svc.caAdapter != nil {
@@ -1637,9 +1683,9 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
16371683 httpsMux.HandleFunc("GET /ca.crt", onboardingHandler.ServeCACert)
16381684 httpsMux.HandleFunc("GET /ca.mobileconfig", onboardingHandler.ServeMobileconfig)
16391685 }
1640- httpsMux.Handle("/", httpsProxyWithMiddleware)
1686+ httpsMux.Handle("/", proxyHandler)
16411687
1642- httpsHandler := otelhttp.NewHandler(httpsMux, "gordon.proxy.tls")
1688+ httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls")
16431689
16441690 return registryMux, proxyMux, httpsHandler
16451691 }
@@ -1896,21 +1942,37 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
18961942 registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort))
18971943 registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log)
18981944
1945+ // closeStarted shuts down any servers that were started before an error occurred,
1946+ // preventing leaked listeners during partial startup failures.
1947+ closeStarted := func(servers ...*http.Server) {
1948+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
1949+ defer shutdownCancel()
1950+ for _, srv := range servers {
1951+ if srv != nil {
1952+ srv.Shutdown(shutdownCtx)
1953+ }
1954+ }
1955+ }
1956+
18991957 proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, errChan, log)
19001958 if err != nil {
1959+ closeStarted(registrySrv)
19011960 return err
19021961 }
19031962
19041963 // Wait for all servers to bind their ports before auto-starting containers.
19051964 // This prevents the race where auto-start pulls from the registry before it's listening.
19061965 if err := waitForServerReady(registryReady, errChan); err != nil {
1966+ closeStarted(registrySrv, proxySrv, tlsSrv)
19071967 return err
19081968 }
19091969 if err := waitForServerReady(proxyReady, errChan); err != nil {
1970+ closeStarted(registrySrv, proxySrv, tlsSrv)
19101971 return err
19111972 }
19121973 if tlsReady != nil {
19131974 if err := waitForServerReady(tlsReady, errChan); err != nil {
1975+ closeStarted(registrySrv, proxySrv, tlsSrv)
19141976 return err
19151977 }
19161978 }
@@ -342,134 +342,179 @@ func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
342 return log, cleanup, nil342 return log, cleanup, nil
343 }343 }
344 344
345+// serviceInit holds the shared context for service initialization helpers.
346+type serviceInit struct {
347+ ctx context.Context
348+ v *viper.Viper
349+ cfg Config
350+ log zerowrap.Logger
351+ svc *services
352+}
353+
345 // createServices creates all the application services.354 // createServices creates all the application services.
346-func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (*services, error) {355+func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) {
347- svc := &services{}356+ si := &serviceInit{
357+ ctx: ctx,
358+ v: v,
359+ cfg: cfg,
360+ log: log,
361+ svc: &services{},
362+ }
363+ defer func() {
364+ if retErr != nil && si.svc.pkiSvc != nil {
365+ si.svc.pkiSvc.Stop()
366+ }
367+ }()
348 var err error368 var err error
349 369
350 // Create output adapters370 // Create output adapters
351 runtimeSocket := resolveRuntimeConfig(v.GetString("server.runtime"))371 runtimeSocket := resolveRuntimeConfig(v.GetString("server.runtime"))
352- if svc.runtime, svc.eventBus, err = createOutputAdapters(ctx, log, runtimeSocket); err != nil {372+ if si.svc.runtime, si.svc.eventBus, err = createOutputAdapters(ctx, log, runtimeSocket); err != nil {
353 return nil, err373 return nil, err
354 }374 }
355 375
356 // Create storage376 // Create storage
357- if svc.blobStorage, svc.manifestStorage, err = createStorage(cfg, log); err != nil {377+ if si.svc.blobStorage, si.svc.manifestStorage, err = createStorage(cfg, log); err != nil {
358 return nil, err378 return nil, err
359 }379 }
360 380
361 // Create log writer381 // Create log writer
362- if svc.logWriter, err = createLogWriter(cfg, log); err != nil {382+ if si.svc.logWriter, err = createLogWriter(cfg, log); err != nil {
363 return nil, err383 return nil, err
364 }384 }
365 385
366 // Create auth service (if enabled)386 // Create auth service (if enabled)
367- if svc.tokenStore, svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil {387+ if si.svc.tokenStore, si.svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil {
368 return nil, err388 return nil, err
369 }389 }
370 390
371- if err := setupInternalRegistryAuth(svc, log); err != nil {391+ if err := setupInternalRegistryAuth(si.svc, log); err != nil {
372 return nil, err392 return nil, err
373 }393 }
374 394
375- // Create use case services395+ // Create config service
376- svc.configSvc = config.NewService(v, svc.eventBus)396+ si.svc.configSvc = config.NewService(v, si.svc.eventBus)
377- if err := svc.configSvc.Load(ctx); err != nil {397+ if err := si.svc.configSvc.Load(ctx); err != nil {
378 return nil, log.WrapErr(err, "failed to load configuration")398 return nil, log.WrapErr(err, "failed to load configuration")
379 }399 }
380 400
381- if (cfg.Server.TLSCertFile == "") != (cfg.Server.TLSKeyFile == "") {401+ if err := si.initPKI(); err != nil {
382- return nil, fmt.Errorf("both tls_cert_file and tls_key_file must be set, or neither")402+ return nil, err
383 }403 }
384 404
385- if cfg.Server.TLSPort != 0 {405+ if err := si.initSecrets(); err != nil {
386- caAdapter, err := pkiadapter.NewCA(resolveDataDir(cfg.Server.DataDir), log)406+ return nil, err
387- if err != nil {407+ }
388- return nil, log.WrapErr(err, "failed to initialize internal CA")408+
389- }409+ if err := si.initRuntimeAndProxy(); err != nil {
390- svc.caAdapter = caAdapter410+ return nil, err
391- svc.pkiSvc = pkiusecase.NewService(ctx, caAdapter, svc.configSvc, log)
392- } else {
393- log.Info().Msg("internal CA disabled (server.tls_port=0)")
394 }411 }
395 412
396- var backend domain.SecretsBackend413+ si.initHandlers()
397- var passStore *domainsecrets.PassStore
398- var domainSecretStore out.DomainSecretStore
399 414
400- svc.envDir, backend, passStore, domainSecretStore, err = createDomainSecretStore(cfg, log)415+ return si.svc, nil
416+}
417+
418+// initPKI initialises the internal CA and PKI service when TLS is enabled.
419+func (si *serviceInit) initPKI() error {
420+ if (si.cfg.Server.TLSCertFile == "") != (si.cfg.Server.TLSKeyFile == "") {
421+ return fmt.Errorf("both tls_cert_file and tls_key_file must be set, or neither")
422+ }
423+
424+ if si.cfg.Server.TLSPort == 0 {
425+ si.log.Info().Msg("internal CA disabled (server.tls_port=0)")
426+ return nil
427+ }
428+
429+ caAdapter, err := pkiadapter.NewCA(resolveDataDir(si.cfg.Server.DataDir), si.log)
401 if err != nil {430 if err != nil {
402- return nil, err431+ return si.log.WrapErr(err, "failed to initialize internal CA")
403 }432 }
433+ si.svc.caAdapter = caAdapter
434+ si.svc.pkiSvc = pkiusecase.NewService(si.ctx, caAdapter, si.svc.configSvc, si.log)
435+ return nil
436+}
404 437
405- if svc.envLoader, err = createEnvLoader(backend, svc.envDir, passStore, log); err != nil {438+// initSecrets creates the domain secret store, env loader, and secret service.
406- return nil, err439+func (si *serviceInit) initSecrets() error {
440+ envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log)
441+ if err != nil {
442+ return err
407 }443 }
444+ si.svc.envDir = envDir
408 445
409- svc.secretSvc = secretsSvc.NewService(domainSecretStore, log, svc.eventBus)446+ if si.svc.envLoader, err = createEnvLoader(backend, envDir, passStore, si.log); err != nil {
447+ return err
448+ }
410 449
411- if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil {450+ si.svc.secretSvc = secretsSvc.NewService(domainSecretStore, si.log, si.svc.eventBus)
412- return nil, err451+ return nil
452+}
453+
454+// initRuntimeAndProxy creates container, backup, registry, image, volume, and proxy services.
455+func (si *serviceInit) initRuntimeAndProxy() error {
456+ var err error
457+
458+ if si.svc.containerSvc, err = createContainerService(si.ctx, si.v, si.cfg, si.svc, si.log); err != nil {
459+ return err
413 }460 }
414 461
415- if svc.backupStorage, svc.backupSvc, err = createBackupService(cfg, svc, log); err != nil {462+ if si.svc.backupStorage, si.svc.backupSvc, err = createBackupService(si.cfg, si.svc, si.log); err != nil {
416- return nil, err463+ return err
417 }464 }
418 465
419- svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)466+ si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus)
420- svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log)467+ si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log)
421- svc.volumeSvc = volumesSvc.NewService(svc.runtime)468+ si.svc.volumeSvc = volumesSvc.NewService(si.svc.runtime)
422 469
423- injectTelemetryMetrics(cfg, svc, log)470+ injectTelemetryMetrics(si.cfg, si.svc, si.log)
424 471
425- proxyCfg, err := buildProxyConfig(cfg, log)472+ proxyCfg, err := buildProxyConfig(si.cfg, si.log)
426 if err != nil {473 if err != nil {
427- return nil, err474+ return err
428 }475 }
429- svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize476+ si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
430- svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxyCfg.proxyConfig)477+ si.svc.proxySvc = proxy.NewService(si.svc.runtime, si.svc.containerSvc, si.svc.configSvc, proxyCfg.proxyConfig)
431 478
432 // Wire synchronous proxy cache invalidation for zero-downtime deployments.479 // Wire synchronous proxy cache invalidation for zero-downtime deployments.
433 // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget().480 // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget().
434- svc.containerSvc.SetProxyCacheInvalidator(svc.proxySvc)481+ si.svc.containerSvc.SetProxyCacheInvalidator(si.svc.proxySvc)
435- svc.containerSvc.SetProxyDrainWaiter(svc.proxySvc)482+ si.svc.containerSvc.SetProxyDrainWaiter(si.svc.proxySvc)
483+ return nil
484+}
436 485
437- // Create token handler for registry token endpoint486+// initHandlers creates the auth, health, log, preview, and admin handlers.
438- if svc.authSvc != nil {487+func (si *serviceInit) initHandlers() {
488+ if si.svc.authSvc != nil {
439 internalAuth := authhandler.InternalAuth{489 internalAuth := authhandler.InternalAuth{
440- Username: svc.internalRegUser,490+ Username: si.svc.internalRegUser,
441- Password: svc.internalRegPass,491+ Password: si.svc.internalRegPass,
442 }492 }
443- svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log)493+ si.svc.authHandler = authhandler.NewHandler(si.svc.authSvc, internalAuth, si.log)
444 }494 }
445 495
446- // Create health service for route health checking
447 prober := httpprober.New()496 prober := httpprober.New()
448- svc.healthSvc = health.NewService(svc.configSvc, svc.containerSvc, prober, log)497+ si.svc.healthSvc = health.NewService(si.svc.configSvc, si.svc.containerSvc, prober, si.log)
449-498+
450- // Create log service for accessing logs via admin API499+ si.svc.logSvc = logs.NewService(resolveLogFilePath(si.cfg), si.cfg.Logging.File.Enabled, si.svc.containerSvc, si.svc.runtime, si.log)
451- svc.logSvc = logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log)500+
452-501+ initPreviewService(si.ctx, si.cfg, si.svc, si.log)
453- initPreviewService(ctx, cfg, svc, log)502+
454-503+ si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{
455- // Create admin handler for admin API504+ ConfigSvc: si.svc.configSvc,
456- svc.adminHandler = admin.NewHandler(admin.HandlerDeps{505+ AuthSvc: si.svc.authSvc,
457- ConfigSvc: svc.configSvc,506+ ContainerSvc: si.svc.containerSvc,
458- AuthSvc: svc.authSvc,507+ HealthSvc: si.svc.healthSvc,
459- ContainerSvc: svc.containerSvc,508+ SecretSvc: si.svc.secretSvc,
460- HealthSvc: svc.healthSvc,509+ LogSvc: si.svc.logSvc,
461- SecretSvc: svc.secretSvc,510+ RegistrySvc: si.svc.registrySvc,
462- LogSvc: svc.logSvc,511+ EventBus: si.svc.eventBus,
463- RegistrySvc: svc.registrySvc,512+ Log: si.log,
464- EventBus: svc.eventBus,513+ BackupSvc: si.svc.backupSvc,
465- Log: log,514+ PreviewSvc: si.svc.previewService,
466- BackupSvc: svc.backupSvc,515+ ImageSvc: si.svc.imageSvc,
467- PreviewSvc: svc.previewService,516+ VolumeSvc: si.svc.volumeSvc,
468- ImageSvc: svc.imageSvc,
469- VolumeSvc: svc.volumeSvc,
470 })517 })
471-
472- return svc, nil
473 }518 }
474 519
475 // initPreviewService sets up the preview store, service, and TTL ticker.520 // initPreviewService sets up the preview store, service, and TTL ticker.
@@ -1615,12 +1660,13 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
1615 // Onboarding routes live on the TLS port so Tailnet / direct clients1660 // Onboarding routes live on the TLS port so Tailnet / direct clients
1616 // can click through the initial cert warning, install the CA, and1661 // can click through the initial cert warning, install the CA, and
1617 // then trust all subsequent connections.1662 // then trust all subsequent connections.
1663+ // The middleware chain wraps the entire mux so onboarding routes also
1664+ // get PanicRecovery, RequestLogger, and SecurityHeaders.
1618 httpsProxyMiddlewares := []func(http.Handler) http.Handler{1665 httpsProxyMiddlewares := []func(http.Handler) http.Handler{
1619 middleware.PanicRecovery(log),1666 middleware.PanicRecovery(log),
1620 middleware.RequestLogger(log, trustedNets),1667 middleware.RequestLogger(log, trustedNets),
1621 middleware.SecurityHeaders,1668 middleware.SecurityHeaders,
1622 }1669 }
1623- httpsProxyWithMiddleware := middleware.Chain(httpsProxyMiddlewares...)(proxyHandler)
1624 1670
1625 httpsMux := http.NewServeMux()1671 httpsMux := http.NewServeMux()
1626 if svc.caAdapter != nil {1672 if svc.caAdapter != nil {
@@ -1637,9 +1683,9 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
1637 httpsMux.HandleFunc("GET /ca.crt", onboardingHandler.ServeCACert)1683 httpsMux.HandleFunc("GET /ca.crt", onboardingHandler.ServeCACert)
1638 httpsMux.HandleFunc("GET /ca.mobileconfig", onboardingHandler.ServeMobileconfig)1684 httpsMux.HandleFunc("GET /ca.mobileconfig", onboardingHandler.ServeMobileconfig)
1639 }1685 }
1640- httpsMux.Handle("/", httpsProxyWithMiddleware)1686+ httpsMux.Handle("/", proxyHandler)
1641 1687
1642- httpsHandler := otelhttp.NewHandler(httpsMux, "gordon.proxy.tls")1688+ httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls")
1643 1689
1644 return registryMux, proxyMux, httpsHandler1690 return registryMux, proxyMux, httpsHandler
1645 }1691 }
@@ -1896,21 +1942,37 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
1896 registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort))1942 registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort))
1897 registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log)1943 registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log)
1898 1944
1945+ // closeStarted shuts down any servers that were started before an error occurred,
1946+ // preventing leaked listeners during partial startup failures.
1947+ closeStarted := func(servers ...*http.Server) {
1948+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
1949+ defer shutdownCancel()
1950+ for _, srv := range servers {
1951+ if srv != nil {
1952+ srv.Shutdown(shutdownCtx)
1953+ }
1954+ }
1955+ }
1956+
1899 proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, errChan, log)1957 proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, errChan, log)
1900 if err != nil {1958 if err != nil {
1959+ closeStarted(registrySrv)
1901 return err1960 return err
1902 }1961 }
1903 1962
1904 // Wait for all servers to bind their ports before auto-starting containers.1963 // Wait for all servers to bind their ports before auto-starting containers.
1905 // This prevents the race where auto-start pulls from the registry before it's listening.1964 // This prevents the race where auto-start pulls from the registry before it's listening.
1906 if err := waitForServerReady(registryReady, errChan); err != nil {1965 if err := waitForServerReady(registryReady, errChan); err != nil {
1966+ closeStarted(registrySrv, proxySrv, tlsSrv)
1907 return err1967 return err
1908 }1968 }
1909 if err := waitForServerReady(proxyReady, errChan); err != nil {1969 if err := waitForServerReady(proxyReady, errChan); err != nil {
1970+ closeStarted(registrySrv, proxySrv, tlsSrv)
1910 return err1971 return err
1911 }1972 }
1912 if tlsReady != nil {1973 if tlsReady != nil {
1913 if err := waitForServerReady(tlsReady, errChan); err != nil {1974 if err := waitForServerReady(tlsReady, errChan); err != nil {
1975+ closeStarted(registrySrv, proxySrv, tlsSrv)
1914 return err1976 return err
1915 }1977 }
1916 }1978 }
M internal/usecase/pki/service.go +12 -1
@@ -39,6 +39,8 @@ type Service struct {
3939 }
4040
4141 // NewService creates a PKI service and starts background maintenance goroutines.
42+// It performs an initial intermediate renewal check synchronously so the first
43+// TLS handshakes never use a nearly-expired intermediate.
4244 func NewService(ctx context.Context, ca out.CertificateAuthority, routes out.RouteChecker, log zerowrap.Logger) *Service {
4345 ctx, cancel := context.WithCancel(ctx)
4446 svc := &Service{
@@ -48,6 +50,7 @@ func NewService(ctx context.Context, ca out.CertificateAuthority, routes out.Rou
4850 cancel: cancel,
4951 done: make(chan struct{}),
5052 }
53+ svc.renewIntermediateIfNeeded()
5154 go svc.maintenanceLoop(ctx)
5255 return svc
5356 }
@@ -80,6 +83,10 @@ func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate,
8083 if entry, ok := s.cache.Load(domain); ok {
8184 if cached, ok := entry.(*cachedCert); ok {
8285 if time.Now().Before(cached.expiresAt) {
86+ if !s.isDomainAllowed(hello.Context(), domain) {
87+ s.cache.Delete(domain)
88+ return nil, nil
89+ }
8390 return cached.cert, nil
8491 }
8592 }
@@ -107,9 +114,13 @@ func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate,
107114 return nil, err
108115 }
109116
117+ expiresAt := time.Now().Add(s.leafLifetime())
118+ if cert.Leaf != nil {
119+ expiresAt = cert.Leaf.NotAfter
120+ }
110121 s.cache.Store(domain, &cachedCert{
111122 cert: cert,
112- expiresAt: time.Now().Add(s.leafLifetime()),
123+ expiresAt: expiresAt,
113124 })
114125
115126 s.log.Debug().Str("domain", domain).Msg("issued new leaf certificate")
@@ -39,6 +39,8 @@ type Service struct {
39 }39 }
40 40
41 // NewService creates a PKI service and starts background maintenance goroutines.41 // NewService creates a PKI service and starts background maintenance goroutines.
42+// It performs an initial intermediate renewal check synchronously so the first
43+// TLS handshakes never use a nearly-expired intermediate.
42 func NewService(ctx context.Context, ca out.CertificateAuthority, routes out.RouteChecker, log zerowrap.Logger) *Service {44 func NewService(ctx context.Context, ca out.CertificateAuthority, routes out.RouteChecker, log zerowrap.Logger) *Service {
43 ctx, cancel := context.WithCancel(ctx)45 ctx, cancel := context.WithCancel(ctx)
44 svc := &Service{46 svc := &Service{
@@ -48,6 +50,7 @@ func NewService(ctx context.Context, ca out.CertificateAuthority, routes out.Rou
48 cancel: cancel,50 cancel: cancel,
49 done: make(chan struct{}),51 done: make(chan struct{}),
50 }52 }
53+ svc.renewIntermediateIfNeeded()
51 go svc.maintenanceLoop(ctx)54 go svc.maintenanceLoop(ctx)
52 return svc55 return svc
53 }56 }
@@ -80,6 +83,10 @@ func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate,
80 if entry, ok := s.cache.Load(domain); ok {83 if entry, ok := s.cache.Load(domain); ok {
81 if cached, ok := entry.(*cachedCert); ok {84 if cached, ok := entry.(*cachedCert); ok {
82 if time.Now().Before(cached.expiresAt) {85 if time.Now().Before(cached.expiresAt) {
86+ if !s.isDomainAllowed(hello.Context(), domain) {
87+ s.cache.Delete(domain)
88+ return nil, nil
89+ }
83 return cached.cert, nil90 return cached.cert, nil
84 }91 }
85 }92 }
@@ -107,9 +114,13 @@ func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate,
107 return nil, err114 return nil, err
108 }115 }
109 116
117+ expiresAt := time.Now().Add(s.leafLifetime())
118+ if cert.Leaf != nil {
119+ expiresAt = cert.Leaf.NotAfter
120+ }
110 s.cache.Store(domain, &cachedCert{121 s.cache.Store(domain, &cachedCert{
111 cert: cert,122 cert: cert,
112- expiresAt: time.Now().Add(s.leafLifetime()),123+ expiresAt: expiresAt,
113 })124 })
114 125
115 s.log.Debug().Str("domain", domain).Msg("issued new leaf certificate")126 s.log.Debug().Str("domain", domain).Msg("issued new leaf certificate")
M internal/usecase/pki/service_test.go +1 -1
@@ -94,5 +94,5 @@ func TestService_GetCertificate_Caching(t *testing.T) {
9494 require.NoError(t, err)
9595
9696 // Same pointer = served from cache
97- assert.Equal(t, cert1, cert2, "second call should return cached cert")
97+ assert.Same(t, cert1, cert2, "second call should return cached cert")
9898 }
@@ -94,5 +94,5 @@ func TestService_GetCertificate_Caching(t *testing.T) {
94 require.NoError(t, err)94 require.NoError(t, err)
95 95
96 // Same pointer = served from cache96 // Same pointer = served from cache
97- assert.Equal(t, cert1, cert2, "second call should return cached cert")97+ assert.Same(t, cert1, cert2, "second call should return cached cert")
98 }98 }

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