v3.0.0 f19f453

http_edge.go RawHistoryFolder

1package app
2
3import (
4	"context"
5	"encoding/json"
6	"net"
7	"net/http"
8	"strings"
9
10	"github.com/bnema/zerowrap"
11	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
12
13	"github.com/bnema/gordon/internal/adapters/dto"
14	acmehttp "github.com/bnema/gordon/internal/adapters/in/http/acme"
15	"github.com/bnema/gordon/internal/adapters/in/http/admin"
16	"github.com/bnema/gordon/internal/adapters/in/http/httphelper"
17	"github.com/bnema/gordon/internal/adapters/in/http/middleware"
18	"github.com/bnema/gordon/internal/adapters/in/http/onboarding"
19	proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy"
20	"github.com/bnema/gordon/internal/adapters/in/http/registry"
21	pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
22	"github.com/bnema/gordon/internal/adapters/out/ratelimit"
23	"github.com/bnema/gordon/internal/boundaries/out"
24	"github.com/bnema/gordon/internal/domain"
25)
26
27func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler {
28	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29		host, _, err := net.SplitHostPort(r.RemoteAddr)
30		if err != nil {
31			host = r.RemoteAddr
32		}
33
34		ip := net.ParseIP(host)
35		if ip == nil || !ip.IsLoopback() {
36			log.Warn().
37				Str("path", r.URL.Path).
38				Str("remote_addr", r.RemoteAddr).
39				Msg("blocked non-loopback access on internal admin route")
40			w.Header().Set("Content-Type", "application/json")
41			w.WriteHeader(http.StatusForbidden)
42			_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"})
43			return
44		}
45
46		next.ServeHTTP(w, r)
47	})
48}
49
50// hostRedirectEligible reports whether a known host should be redirected
51// from plaintext to HTTPS: app hosts declared tls=never stay plain HTTP.
52func hostRedirectEligible(svc *services, host string) bool {
53	if svc.appHostIndex == nil {
54		return true
55	}
56	entry, ok := svc.appHostIndex.Lookup(host)
57	return !ok || entry.TLSMode != domain.AppTLSNever
58}
59
60// hostRequiresTLS reports whether an app host declared tls=always, which
61// must never be served over plaintext. An unwired index has no always hosts.
62func hostRequiresTLS(svc *services, host string) bool {
63	if svc.appHostIndex == nil {
64		return false
65	}
66	entry, ok := svc.appHostIndex.Lookup(host)
67	return ok && entry.TLSMode == domain.AppTLSAlways
68}
69
70// createHTTPHandlers creates HTTP handlers with middleware.
71// Returns three handlers: registry, HTTP proxy (with CIDR + onboarding), and HTTPS proxy.
72func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWriter out.AccessLogWriter) (http.Handler, http.Handler, http.Handler) {
73	// Parse trusted proxies once for all middleware chains.
74	// This ensures consistent IP extraction across logging, rate limiting, and auth.
75	trustedNets := httphelper.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
76
77	// Registry handler
78	registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize, svc.maxBlobSize)
79	svc.registryHandler = registryHandler
80	if svc.reloadCoordinator != nil {
81		svc.reloadCoordinator.SetRegistryLimits(registryHandler)
82	}
83	registryWithMiddleware, cidrAllowlistMiddleware, rateLimitMiddleware := buildRegistryHandlerWithMiddleware(
84		svc,
85		cfg,
86		trustedNets,
87		registryHandler,
88		log,
89	)
90
91	registryMux := http.NewServeMux()
92	registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, cfg, log)
93	registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log))
94	registerAdminRoutes(registryMux, svc, cfg, trustedNets, log)
95
96	// Proxy handler. Registry-domain forwarding is gated on auth: with
97	// auth disabled the registry stays local-only and public ingress can
98	// never reach it through this proxy.
99	proxyHandler := proxyadapter.NewHandler(svc.proxySvc, trustedNets, log).
100		WithRegistryForwarding(cfg.Auth.Enabled)
101
102	// HTTP proxy handler chain: HTTPS redirect for non-proxy clients, then CIDR allowlist
103	proxyAllowedNets, proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log)
104
105	httpProxyMiddlewares := []func(http.Handler) http.Handler{
106		middleware.PanicRecovery(log),
107		middleware.RequestLogger(log, trustedNets),
108		middleware.SecurityHeaders,
109		middleware.HTTPSRedirectWithEligibility(proxyAllowedNets, effectiveProxyHTTPPort(cfg), effectiveProxyTLSPort(cfg), cfg.Server.ForceHTTPSRedirect, log, func(host string) bool {
110			return svc.proxySvc.IsKnownHost(context.Background(), host)
111		}, func(host string) bool {
112			return hostRedirectEligible(svc, host)
113		}, func(host string) bool {
114			return hostRequiresTLS(svc, host)
115		}),
116	}
117	if proxyCIDRMiddleware != nil {
118		httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware)
119	}
120
121	httpProxyWithMiddleware := otelhttp.NewHandler(
122		middleware.Chain(httpProxyMiddlewares...)(proxyHandler),
123		"gordon.proxy",
124	)
125
126	// Build the onboarding handler once if internal CA is available and TLS is enabled.
127	var obHandler *onboarding.Handler
128	if svc.caAdapter != nil && effectiveProxyTLSPort(cfg) != 0 {
129		mobileconfigBytes := pkiadapter.GenerateMobileconfig(
130			svc.caAdapter.RootCertificateDER(),
131			svc.caAdapter.RootCommonName(),
132		)
133		obHandler = onboarding.NewHandler(
134			svc.caAdapter.RootCertificate(),
135			mobileconfigBytes,
136			svc.caAdapter.RootFingerprint(),
137			effectiveProxyHTTPPort(cfg),
138			effectiveProxyTLSPort(cfg),
139		)
140	}
141
142	// HTTP proxyMux: trusted proxy traffic flows through the normal proxy chain.
143	// Direct clients get an onboarding gate (when CA is available) placed BEFORE
144	// HTTPSRedirect so force_https_redirect cannot bypass onboarding.
145	// ACME HTTP-01 challenge handler is registered before the catch-all "/" so
146	// it gets first chance regardless of source IP.
147	proxyMux := http.NewServeMux()
148
149	// Register ACME HTTP-01 challenge handler before all other routes so
150	// Let's Encrypt validation always succeeds, even for onboarding clients.
151	if svc.publicTLSSvc != nil {
152		proxyMux.Handle(acmehttp.Prefix, acmehttp.NewHandler(svc.publicTLSSvc))
153	}
154
155	if proxyCIDRMiddleware != nil && proxyAllowedNets == nil {
156		// Invalid proxy_allowed_ips: deny all traffic (fail-closed).
157		proxyMux.Handle("/", proxyCIDRMiddleware(httpProxyWithMiddleware))
158	} else if obHandler != nil {
159		proxyMux.Handle("/", directHTTPOnboardingGate(obHandler, proxyAllowedNets, httpProxyWithMiddleware, log))
160	} else {
161		proxyMux.Handle("/", httpProxyWithMiddleware)
162	}
163
164	// HTTPS proxy handler chain: security headers + proxy + CA onboarding
165	// Onboarding routes live on the TLS port so Tailnet / direct clients
166	// can click through the initial cert warning, install the CA, and
167	// then trust all subsequent connections.
168	// The middleware chain wraps the entire mux so onboarding routes also
169	// get PanicRecovery, RequestLogger, and SecurityHeaders.
170	httpsProxyMiddlewares := []func(http.Handler) http.Handler{
171		middleware.PanicRecovery(log),
172		middleware.RequestLogger(log, trustedNets),
173		middleware.SecurityHeaders,
174	}
175
176	httpsMux := http.NewServeMux()
177	if obHandler != nil && cfg.Server.GordonDomain != "" {
178		gordonDomain := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(cfg.Server.GordonDomain)), ".")
179		onboardingMux := http.NewServeMux()
180		registerOnboardingRoutes(onboardingMux, obHandler)
181		// Register onboarding paths host-gated so normal traffic hits
182		// proxyHandler directly through the catch-all / pattern.
183		httpsMux.Handle("GET /.well-known/gordon/", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
184		httpsMux.Handle("GET /.well-known/gordon/ca", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
185		httpsMux.Handle("GET /.well-known/gordon/ca.crt", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
186		httpsMux.Handle("GET /.well-known/gordon/ca.mobileconfig", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
187		httpsMux.Handle("/", proxyHandler)
188	} else {
189		httpsMux.Handle("/", proxyHandler)
190	}
191
192	httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls")
193
194	// Wrap top-level handlers with access logging outside all gates
195	// (loopbackOnly, denyAllHandler, CIDR allowlist) so every request —
196	// including rejected probes — produces exactly one access-log line.
197	var registryOut, proxyOut, httpsOut http.Handler = registryMux, proxyMux, httpsHandler
198	if accessWriter != nil {
199		excludeHC := cfg.Logging.AccessLog.ExcludeHealthChecks
200		registryOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(registryOut)
201		proxyOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(proxyOut)
202		httpsOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(httpsOut)
203	}
204	svc.httpsProxyHandler = httpsOut
205
206	return registryOut, proxyOut, httpsOut
207}
208
209// registerOnboardingRoutes registers CA onboarding well-known HTTP routes on
210// the given mux. Both direct-HTTP and Gordon-domain HTTPS onboarding use this.
211func registerOnboardingRoutes(mux *http.ServeMux, ob *onboarding.Handler) {
212	mux.HandleFunc("GET /.well-known/gordon/", ob.ServeOnboardingPage)
213	mux.HandleFunc("GET /.well-known/gordon/ca", ob.ServeOnboardingPage)
214	mux.HandleFunc("GET /.well-known/gordon/ca.crt", ob.ServeCACert)
215	mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", ob.ServeMobileconfig)
216}
217
218// directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic
219// by source IP. Trusted proxy IPs flow through to the normal proxy chain.
220// Direct clients are served the CA onboarding flow on allowed paths and
221// receive 403 on everything else. This gate runs BEFORE HTTPSRedirect so
222// force_https_redirect cannot bypass onboarding for direct clients.
223func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, proxyChain http.Handler, log zerowrap.Logger) http.Handler {
224	// Build a small mux for direct-client onboarding paths.
225	onboardingMux := http.NewServeMux()
226	registerOnboardingRoutes(onboardingMux, ob)
227
228	// Reserve ACME challenge path for future use.
229	onboardingMux.HandleFunc("/.well-known/acme-challenge/", func(w http.ResponseWriter, _ *http.Request) {
230		http.Error(w, "not found", http.StatusNotFound)
231	})
232
233	// Catch-all: reject any other direct HTTP request.
234	// Uses a method-aware split: GET writes a body, HEAD gets an empty 403.
235	onboardingMux.HandleFunc("/", directHTTPForbidden)
236
237	onboardingWithMiddleware := middleware.Chain(
238		middleware.PanicRecovery(log),
239		middleware.RequestLogger(log), // Intentionally omit trusted proxy nets so direct onboarding logs use RemoteAddr only.
240		middleware.SecurityHeaders,
241	)(onboardingMux)
242
243	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
244		remoteIP := httphelper.ExtractRemoteIP(r.RemoteAddr)
245		if httphelper.IsTrustedOrLocal(remoteIP, proxyNets) {
246			proxyChain.ServeHTTP(w, r)
247			return
248		}
249		onboardingWithMiddleware.ServeHTTP(w, r)
250	})
251}
252
253// canonicalHostsEqual compares two hosts after normalising both: stripping
254// port, trimming spaces, lowercasing, and removing trailing dot.
255func canonicalHostsEqual(host, expected string) bool {
256	host = strings.TrimSpace(host)
257	if h, _, err := net.SplitHostPort(host); err == nil {
258		host = h
259	}
260	host = strings.TrimSuffix(strings.ToLower(host), ".")
261	expected = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(expected)), ".")
262	return host == expected
263}
264
265// gordonDomainOnboardingGate returns a handler that serves onboarding routes
266// only when the request host matches gordonDomain. For mismatched hosts it
267// delegates to proxyHandler. gordonDomain must already be canonicalised
268// (trimmed, lowered, trailing dot removed).
269func gordonDomainOnboardingGate(gordonDomain string, onboardingMux, proxyHandler http.Handler) http.Handler {
270	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
271		if gordonDomain != "" && canonicalHostsEqual(r.Host, gordonDomain) {
272			onboardingMux.ServeHTTP(w, r)
273			return
274		}
275		proxyHandler.ServeHTTP(w, r)
276	})
277}
278
279// directHTTPForbidden responds with 403 for non-onboarding HTTP paths.
280// HEAD requests get an empty body per HTTP semantics.
281func directHTTPForbidden(w http.ResponseWriter, r *http.Request) {
282	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
283	w.WriteHeader(http.StatusForbidden)
284	if r.Method != http.MethodHead {
285		_, _ = w.Write([]byte("Only certificate onboarding is available over HTTP.\n"))
286	}
287}
288
289func buildRegistryHandlerWithMiddleware(
290	svc *services,
291	cfg Config,
292	trustedNets []*net.IPNet,
293	registryHandler http.Handler,
294	log zerowrap.Logger,
295) (http.Handler, func(http.Handler) http.Handler, func(http.Handler) http.Handler) {
296	registryMiddlewares := []func(http.Handler) http.Handler{
297		middleware.PanicRecovery(log),
298		middleware.RequestLogger(log, trustedNets),
299		middleware.SecurityHeaders,
300	}
301
302	cidrAllowlistMiddleware := buildRegistryCIDRAllowlistMiddleware(cfg, trustedNets, log)
303	if cidrAllowlistMiddleware != nil {
304		registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware)
305	}
306
307	rateLimitMiddleware := buildRegistryRateLimitMiddleware(cfg, log)
308	registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
309
310	appendRegistryAuthMiddleware(&registryMiddlewares, svc, cfg, trustedNets, log)
311
312	registryWithOtel := otelhttp.NewHandler(
313		middleware.Chain(registryMiddlewares...)(registryHandler),
314		"gordon.registry",
315	)
316	return registryWithOtel, cidrAllowlistMiddleware, rateLimitMiddleware
317}
318
319// parseCIDRAllowlist parses a list of IPs/CIDRs, logs warnings for invalid entries,
320// and returns the parsed nets. label is used in log messages (e.g. "registry_allowed_ips").
321func parseCIDRAllowlist(ips []string, label string, log zerowrap.Logger) ([]*net.IPNet, bool) {
322	if len(ips) == 0 {
323		return nil, false
324	}
325
326	allowedNets := httphelper.ParseTrustedProxies(ips)
327	if len(allowedNets) != len(ips) {
328		for _, entry := range ips {
329			if nets := httphelper.ParseTrustedProxies([]string{entry}); len(nets) == 0 {
330				log.Warn().Str("entry", entry).Msgf("ignoring invalid %s entry", label)
331			}
332		}
333	}
334
335	if len(allowedNets) == 0 {
336		log.Error().
337			Strs(label, ips).
338			Msgf("%s is set but no valid entries were parsed; will deny all traffic (fail-closed)", label)
339		return nil, true // allInvalid
340	}
341
342	return allowedNets, false
343}
344
345// denyAllHandler returns a middleware that rejects every request with 403 Forbidden.
346func denyAllHandler(label string, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
347	return func(next http.Handler) http.Handler {
348		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
349			log.Warn().
350				Str(zerowrap.FieldClientIP, middleware.GetClientIP(r, trustedNets)).
351				Msgf("access denied due to invalid %s configuration", label)
352			w.Header().Set("Content-Type", "application/json")
353			w.WriteHeader(http.StatusForbidden)
354			_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"})
355		})
356	}
357}
358
359func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
360	allowedNets, allInvalid := parseCIDRAllowlist(cfg.Server.RegistryAllowedIPs, "registry_allowed_ips", log)
361	if allInvalid {
362		return denyAllHandler("registry_allowed_ips", trustedNets, log)
363	}
364	if allowedNets == nil {
365		return nil
366	}
367	return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)
368}
369
370func buildProxyCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) ([]*net.IPNet, func(http.Handler) http.Handler) {
371	allowedNets, allInvalid := parseCIDRAllowlist(cfg.Server.ProxyAllowedIPs, "proxy_allowed_ips", log)
372	if allInvalid {
373		return nil, denyAllHandler("proxy_allowed_ips", trustedNets, log)
374	}
375	if allowedNets == nil {
376		return nil, nil
377	}
378
379	log.Info().
380		Strs("proxy_allowed_ips", cfg.Server.ProxyAllowedIPs).
381		Msg("proxy origin IP allowlist enabled")
382
383	return allowedNets, middleware.ProxyCIDRAllowlist(allowedNets, log)
384}
385
386func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler {
387	if cfg.API.RateLimit.Enabled {
388		globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
389		ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
390		return registry.RateLimitMiddleware(
391			globalLimiter,
392			ipLimiter,
393			cfg.API.RateLimit.TrustedProxies,
394			log,
395		)
396	}
397
398	return registry.RateLimitMiddleware(nil, nil, nil, log)
399}
400
401func appendRegistryAuthMiddleware(registryMiddlewares *[]func(http.Handler) http.Handler, svc *services, cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) {
402	if svc.authSvc != nil {
403		internalAuth := middleware.InternalRegistryAuth{
404			Username: svc.internalRegUser,
405			Password: svc.internalRegPass,
406		}
407		*registryMiddlewares = append(*registryMiddlewares, middleware.RegistryAuthV2(svc.authSvc, internalAuth, trustedNets, log))
408		return
409	}
410
411	if cfg.Auth.Enabled {
412		log.Error().Msg("authentication service unavailable; registry requests will be denied")
413		*registryMiddlewares = append(*registryMiddlewares, func(next http.Handler) http.Handler {
414			return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
415				w.Header().Set("Content-Type", "application/json")
416				w.WriteHeader(http.StatusServiceUnavailable)
417				_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"})
418			})
419		})
420	}
421}
422
423func registerAuthRoutes(
424	registryMux *http.ServeMux,
425	svc *services,
426	trustedNets []*net.IPNet,
427	cidrAllowlistMiddleware func(http.Handler) http.Handler,
428	rateLimitMiddleware func(http.Handler) http.Handler,
429	cfg Config,
430	log zerowrap.Logger,
431) {
432	if svc.authHandler == nil {
433		return
434	}
435
436	// Auth endpoints always get rate limiting, even if global rate limiting is disabled.
437	// This prevents brute-force attacks against password/token endpoints.
438	authRateLimitMiddleware := rateLimitMiddleware
439	if !cfg.API.RateLimit.Enabled {
440		authGlobalLimiter := ratelimit.NewMemoryStore(50, 100, log)
441		authIPLimiter := ratelimit.NewMemoryStore(5, 10, log)
442		authRateLimitMiddleware = registry.RateLimitMiddleware(authGlobalLimiter, authIPLimiter, cfg.API.RateLimit.TrustedProxies, log)
443	}
444
445	// Auth endpoints are NOT protected by auth - they're where clients authenticate
446	// but still need rate limiting to prevent brute force attacks.
447	authMiddlewares := []func(http.Handler) http.Handler{
448		middleware.PanicRecovery(log),
449		middleware.RequestLogger(log, trustedNets),
450		middleware.SecurityHeaders,
451	}
452	if cidrAllowlistMiddleware != nil {
453		authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)
454	}
455	authMiddlewares = append(authMiddlewares, authRateLimitMiddleware)
456	authWithMiddleware := otelhttp.NewHandler(
457		middleware.Chain(authMiddlewares...)(svc.authHandler),
458		"gordon.auth",
459	)
460	registryMux.Handle("/auth/", authWithMiddleware)
461}
462
463func wrapRegistryForLocalMode(registryWithMiddleware http.Handler, cfg Config, log zerowrap.Logger) http.Handler {
464	if !cfg.Auth.Enabled {
465		return loopbackOnly(registryWithMiddleware, log)
466	}
467	return registryWithMiddleware
468}
469
470func registerAdminRoutes(registryMux *http.ServeMux, svc *services, cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) {
471	if svc.adminHandler == nil {
472		return
473	}
474
475	if !cfg.Auth.Enabled {
476		log.Warn().Msg("auth disabled: admin API endpoints are not registered")
477		return
478	}
479
480	adminMiddlewares := []func(http.Handler) http.Handler{
481		middleware.PanicRecovery(log),
482		middleware.RequestLogger(log, trustedNets),
483		middleware.SecurityHeaders,
484	}
485
486	if svc.authSvc != nil {
487		// Create rate limiters for admin API - uses same config as registry.
488		var globalLimiter, ipLimiter out.RateLimiter
489		if cfg.API.RateLimit.Enabled {
490			globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
491			ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
492		}
493		adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log))
494	} else {
495		adminMiddlewares = append(adminMiddlewares, func(next http.Handler) http.Handler {
496			return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
497				w.Header().Set("Content-Type", "application/json")
498				w.WriteHeader(http.StatusServiceUnavailable)
499				_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"})
500			})
501		})
502	}
503
504	adminWithMiddleware := otelhttp.NewHandler(
505		middleware.Chain(adminMiddlewares...)(svc.adminHandler),
506		"gordon.admin",
507	)
508	registryMux.Handle("/admin/", adminWithMiddleware)
509}

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