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(®istryMiddlewares, 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}