detached de49ef1

fix(auth): support local-only mode when auth is disabled

When auth.enabled=false, run /v2 as loopback-only and do not register /admin routes so Gordon can be safely local-only. Refactor handler wiring and update IP parsing tests/docs to keep lint and test suites green.
commit
de49ef18d5737bf09ba870437fb04411b7562363
author
brice · · 2026-02-09 04:57 UTC
parent
18fc433
signature
unsigned
8 files · +251 -135
M docs/config/auth.md +8 -2
@@ -152,9 +152,15 @@ gordon auth token generate --subject temp --expiry 30d
152152 - Tokens from one Gordon instance won't work on another
153153 - Changing `token_secret` invalidates all existing tokens
154154
155-## Authentication Required
155+## Local-only Mode
156156
157-Authentication is always required. `auth.enabled=false` is no longer supported.
157+Authentication is enabled by default. If you set `auth.enabled=false`, Gordon switches to local-only mode:
158+
159+- `/admin/*` endpoints are not registered.
160+- `/v2/*` registry endpoints are restricted to loopback (`127.0.0.1` / `::1`).
161+- Remote registry and remote admin access are disabled.
162+
163+Use this only when Gordon is intended for local machine usage.
158164
159165 ## Internal Registry Auth
160166
@@ -152,9 +152,15 @@ gordon auth token generate --subject temp --expiry 30d
152 - Tokens from one Gordon instance won't work on another152 - Tokens from one Gordon instance won't work on another
153 - Changing `token_secret` invalidates all existing tokens153 - Changing `token_secret` invalidates all existing tokens
154 154
155-## Authentication Required155+## Local-only Mode
156 156
157-Authentication is always required. `auth.enabled=false` is no longer supported.157+Authentication is enabled by default. If you set `auth.enabled=false`, Gordon switches to local-only mode:
158+
159+- `/admin/*` endpoints are not registered.
160+- `/v2/*` registry endpoints are restricted to loopback (`127.0.0.1` / `::1`).
161+- Remote registry and remote admin access are disabled.
162+
163+Use this only when Gordon is intended for local machine usage.
158 164
159 ## Internal Registry Auth165 ## Internal Registry Auth
160 166
M docs/config/index.md +2 -0
@@ -183,6 +183,8 @@ monthly = 12
183183 | `auto_route.enabled` | `false` |
184184 | `backups.enabled` | `false` |
185185
186+When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is disabled and `/v2/*` is loopback-only.
187+
186188 ## Hot Reload
187189
188190 Gordon watches the configuration file and reloads automatically when changes are detected. You can also trigger a manual reload:
@@ -183,6 +183,8 @@ monthly = 12
183 | `auto_route.enabled` | `false` |183 | `auto_route.enabled` | `false` |
184 | `backups.enabled` | `false` |184 | `backups.enabled` | `false` |
185 185
186+When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is disabled and `/v2/*` is loopback-only.
187+
186 ## Hot Reload188 ## Hot Reload
187 189
188 Gordon watches the configuration file and reloads automatically when changes are detected. You can also trigger a manual reload:190 Gordon watches the configuration file and reloads automatically when changes are detected. You can also trigger a manual reload:
M docs/config/reference.md +1 -1
@@ -159,7 +159,7 @@ monthly = 0 # Keep N monthly backups per DB
159159 | `server.data_dir` | `~/.gordon` | Data directory |
160160 | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk |
161161 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
162-| `auth.enabled` | `true` | Enable authentication |
162+| `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) |
163163 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
164164 | `auth.token_expiry` | `"720h"` | 30 days |
165165 | `api.rate_limit.enabled` | `true` | Enable rate limiting |
@@ -159,7 +159,7 @@ monthly = 0 # Keep N monthly backups per DB
159 | `server.data_dir` | `~/.gordon` | Data directory |159 | `server.data_dir` | `~/.gordon` | Data directory |
160 | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk |160 | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk |
161 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |161 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
162-| `auth.enabled` | `true` | Enable authentication |162+| `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) |
163 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |163 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
164 | `auth.token_expiry` | `"720h"` | 30 days |164 | `auth.token_expiry` | `"720h"` | 30 days |
165 | `api.rate_limit.enabled` | `true` | Enable rate limiting |165 | `api.rate_limit.enabled` | `true` | Enable rate limiting |
M docs/upgrading.md +1 -1
@@ -121,7 +121,7 @@ password_hash = "gordon/auth/password_hash"
121121
122122 ### Breaking: Auth Enabled by Default
123123
124-Gordon now requires explicit auth configuration. Set `auth.enabled = false` to disable.
124+Gordon keeps auth enabled by default. If you set `auth.enabled = false`, Gordon runs in local-only mode (`/admin/*` disabled, `/v2/*` loopback-only).
125125
126126 ### Breaking: Secret Paths Changed
127127
@@ -121,7 +121,7 @@ password_hash = "gordon/auth/password_hash"
121 121
122 ### Breaking: Auth Enabled by Default122 ### Breaking: Auth Enabled by Default
123 123
124-Gordon now requires explicit auth configuration. Set `auth.enabled = false` to disable.124+Gordon keeps auth enabled by default. If you set `auth.enabled = false`, Gordon runs in local-only mode (`/admin/*` disabled, `/v2/*` loopback-only).
125 125
126 ### Breaking: Secret Paths Changed126 ### Breaking: Secret Paths Changed
127 127
M internal/adapters/in/http/auth/handler.go +21 -42
@@ -2,6 +2,7 @@
22 package auth
33
44 import (
5+ "context"
56 "crypto/subtle"
67 "encoding/json"
78 "net/http"
@@ -214,29 +215,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
214215 return
215216 }
216217
217- // Check for internal registry auth (localhost requests with internal creds)
218- var (
219- authenticated bool
220- parentClaims *domain.TokenClaims
221- )
222- if isLocalhostRequest(r) && h.isInternalAuth(username, password) {
223- authenticated = true
224- log.Debug().Str("username", username).Msg("internal registry auth accepted")
225- } else {
226- // Validate credentials based on auth type
227- switch h.authSvc.GetAuthType() {
228- case domain.AuthTypePassword:
229- authenticated = h.authSvc.ValidatePassword(ctx, username, password)
230- case domain.AuthTypeToken:
231- // For token auth, the password might be an existing JWT
232- claims, err := h.authSvc.ValidateToken(ctx, password)
233- if err == nil && claims.Subject == username {
234- authenticated = true
235- parentClaims = claims
236- }
237- }
238- }
239-
218+ authenticated, parentClaims := h.authenticateTokenCredentials(ctx, r, username, password, log)
240219 if !authenticated {
241220 log.Debug().
242221 Str("username", username).
@@ -289,6 +268,25 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
289268 Msg("access token issued")
290269 }
291270
271+func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) {
272+ if isLocalhostRequest(r) && h.isInternalAuth(username, password) {
273+ log.Debug().Str("username", username).Msg("internal registry auth accepted")
274+ return true, nil
275+ }
276+
277+ switch h.authSvc.GetAuthType() {
278+ case domain.AuthTypePassword:
279+ return h.authSvc.ValidatePassword(ctx, username, password), nil
280+ case domain.AuthTypeToken:
281+ claims, err := h.authSvc.ValidateToken(ctx, password)
282+ if err == nil && claims.Subject == username {
283+ return true, claims
284+ }
285+ }
286+
287+ return false, nil
288+}
289+
292290 func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []string, log zerowrap.Logger) []string {
293291 var effective []string
294292
@@ -352,25 +350,6 @@ func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) b
352350 return false
353351 }
354352
355-// sendAnonymousToken sends a token for anonymous/unauthenticated access.
356-func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) {
357- // For anonymous access, we issue a very short-lived token with limited scope
358- response := dto.TokenResponse{
359- Token: "", // Empty token indicates limited access
360- ExpiresIn: 60, // 1 minute
361- IssuedAt: time.Now().UTC().Format(time.RFC3339),
362- }
363-
364- w.Header().Set("Content-Type", "application/json")
365- w.WriteHeader(http.StatusOK)
366-
367- if err := json.NewEncoder(w).Encode(response); err != nil {
368- log.Error().Err(err).Msg("failed to encode anonymous token response")
369- }
370-
371- log.Debug().Msg("anonymous token issued")
372-}
373-
374353 // isLocalhostRequest checks if the request originates from localhost.
375354 // SECURITY: Uses RemoteAddr (server-set) instead of Host header (client-spoofable).
376355 func isLocalhostRequest(r *http.Request) bool {
@@ -2,6 +2,7 @@
2 package auth2 package auth
3 3
4 import (4 import (
5+ "context"
5 "crypto/subtle"6 "crypto/subtle"
6 "encoding/json"7 "encoding/json"
7 "net/http"8 "net/http"
@@ -214,29 +215,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
214 return215 return
215 }216 }
216 217
217- // Check for internal registry auth (localhost requests with internal creds)218+ authenticated, parentClaims := h.authenticateTokenCredentials(ctx, r, username, password, log)
218- var (
219- authenticated bool
220- parentClaims *domain.TokenClaims
221- )
222- if isLocalhostRequest(r) && h.isInternalAuth(username, password) {
223- authenticated = true
224- log.Debug().Str("username", username).Msg("internal registry auth accepted")
225- } else {
226- // Validate credentials based on auth type
227- switch h.authSvc.GetAuthType() {
228- case domain.AuthTypePassword:
229- authenticated = h.authSvc.ValidatePassword(ctx, username, password)
230- case domain.AuthTypeToken:
231- // For token auth, the password might be an existing JWT
232- claims, err := h.authSvc.ValidateToken(ctx, password)
233- if err == nil && claims.Subject == username {
234- authenticated = true
235- parentClaims = claims
236- }
237- }
238- }
239-
240 if !authenticated {219 if !authenticated {
241 log.Debug().220 log.Debug().
242 Str("username", username).221 Str("username", username).
@@ -289,6 +268,25 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
289 Msg("access token issued")268 Msg("access token issued")
290 }269 }
291 270
271+func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) {
272+ if isLocalhostRequest(r) && h.isInternalAuth(username, password) {
273+ log.Debug().Str("username", username).Msg("internal registry auth accepted")
274+ return true, nil
275+ }
276+
277+ switch h.authSvc.GetAuthType() {
278+ case domain.AuthTypePassword:
279+ return h.authSvc.ValidatePassword(ctx, username, password), nil
280+ case domain.AuthTypeToken:
281+ claims, err := h.authSvc.ValidateToken(ctx, password)
282+ if err == nil && claims.Subject == username {
283+ return true, claims
284+ }
285+ }
286+
287+ return false, nil
288+}
289+
292 func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []string, log zerowrap.Logger) []string {290 func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []string, log zerowrap.Logger) []string {
293 var effective []string291 var effective []string
294 292
@@ -352,25 +350,6 @@ func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) b
352 return false350 return false
353 }351 }
354 352
355-// sendAnonymousToken sends a token for anonymous/unauthenticated access.
356-func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) {
357- // For anonymous access, we issue a very short-lived token with limited scope
358- response := dto.TokenResponse{
359- Token: "", // Empty token indicates limited access
360- ExpiresIn: 60, // 1 minute
361- IssuedAt: time.Now().UTC().Format(time.RFC3339),
362- }
363-
364- w.Header().Set("Content-Type", "application/json")
365- w.WriteHeader(http.StatusOK)
366-
367- if err := json.NewEncoder(w).Encode(response); err != nil {
368- log.Error().Err(err).Msg("failed to encode anonymous token response")
369- }
370-
371- log.Debug().Msg("anonymous token issued")
372-}
373-
374 // isLocalhostRequest checks if the request originates from localhost.353 // isLocalhostRequest checks if the request originates from localhost.
375 // SECURITY: Uses RemoteAddr (server-set) instead of Host header (client-spoofable).354 // SECURITY: Uses RemoteAddr (server-set) instead of Host header (client-spoofable).
376 func isLocalhostRequest(r *http.Request) bool {355 func isLocalhostRequest(r *http.Request) bool {
M internal/adapters/in/http/registry/ratelimit_test.go +2 -2
@@ -266,11 +266,11 @@ func TestGetClientIP(t *testing.T) {
266266 wantIP: "203.0.113.50",
267267 },
268268 {
269- name: "XFF honored from trusted proxy (multiple IPs)",
269+ name: "XFF returns first untrusted IP from right",
270270 remoteAddr: "127.0.0.1:12345",
271271 xff: "203.0.113.50, 10.0.0.1, 172.16.0.1",
272272 trustedNets: trustedNets,
273- wantIP: "203.0.113.50",
273+ wantIP: "172.16.0.1",
274274 },
275275 {
276276 name: "XFF honored from trusted CIDR",
@@ -266,11 +266,11 @@ func TestGetClientIP(t *testing.T) {
266 wantIP: "203.0.113.50",266 wantIP: "203.0.113.50",
267 },267 },
268 {268 {
269- name: "XFF honored from trusted proxy (multiple IPs)",269+ name: "XFF returns first untrusted IP from right",
270 remoteAddr: "127.0.0.1:12345",270 remoteAddr: "127.0.0.1:12345",
271 xff: "203.0.113.50, 10.0.0.1, 172.16.0.1",271 xff: "203.0.113.50, 10.0.0.1, 172.16.0.1",
272 trustedNets: trustedNets,272 trustedNets: trustedNets,
273- wantIP: "203.0.113.50",273+ wantIP: "172.16.0.1",
274 },274 },
275 {275 {
276 name: "XFF honored from trusted CIDR",276 name: "XFF honored from trusted CIDR",
M internal/app/run.go +148 -87
@@ -758,7 +758,8 @@ func GetInternalCredentials() (*InternalCredentials, error) {
758758 // createAuthService creates the authentication service and token store.
759759 func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
760760 if !cfg.Auth.Enabled {
761- return nil, nil, fmt.Errorf("auth.enabled=false is no longer supported; authentication is always required")
761+ log.Warn().Msg("auth.enabled=false detected: running in local-only mode (registry loopback-only, admin API disabled)")
762+ return nil, nil, nil
762763 }
763764
764765 authType := resolveAuthType(cfg)
@@ -1042,7 +1043,7 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
10421043 // createContainerService creates the container service with configuration.
10431044 func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
10441045 containerConfig := container.Config{
1045- RegistryAuthEnabled: true,
1046+ RegistryAuthEnabled: cfg.Auth.Enabled,
10461047 RegistryDomain: cfg.Server.RegistryDomain,
10471048 RegistryPort: cfg.Server.RegistryPort,
10481049 InternalRegistryUsername: svc.internalRegUser,
@@ -1068,7 +1069,10 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
10681069 containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay")
10691070 }
10701071
1071- if svc.authSvc != nil {
1072+ if containerConfig.RegistryAuthEnabled {
1073+ if svc.authSvc == nil {
1074+ return nil, fmt.Errorf("authentication service unavailable: cannot generate registry service token")
1075+ }
10721076 expiry, err := resolveServiceTokenExpiry(cfg)
10731077 if err != nil {
10741078 return nil, log.WrapErr(err, "failed to resolve service token expiry")
@@ -1086,7 +1090,7 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
10861090 containerConfig.ServiceTokenUsername = serviceTokenSubject
10871091 containerConfig.ServiceToken = serviceToken
10881092 } else {
1089- return nil, fmt.Errorf("authentication service unavailable: cannot generate registry service token")
1093+ log.Warn().Msg("registry auth disabled; container image pulls will use unauthenticated mode")
10901094 }
10911095
10921096 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil
@@ -1271,60 +1275,111 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
12711275 trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
12721276
12731277 registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)
1278+ registryWithMiddleware, cidrAllowlistMiddleware, rateLimitMiddleware := buildRegistryHandlerWithMiddleware(
1279+ svc,
1280+ cfg,
1281+ trustedNets,
1282+ registryHandler,
1283+ log,
1284+ )
12741285
1286+ registryMux := http.NewServeMux()
1287+ registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, log)
1288+ registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log))
1289+ registerAdminRoutes(registryMux, svc, cfg, trustedNets, log)
1290+
1291+ // SECURITY: No CORS middleware on the proxy chain. Backend applications
1292+ // should control their own CORS policies. A blanket Access-Control-Allow-Origin: *
1293+ // would override backend CORS settings and allow any website to make
1294+ // cross-origin authenticated requests to proxied applications.
1295+ proxyMiddlewares := []func(http.Handler) http.Handler{
1296+ middleware.PanicRecovery(log),
1297+ middleware.RequestLogger(log, trustedNets),
1298+ middleware.SecurityHeaders,
1299+ }
1300+
1301+ proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc)
1302+
1303+ return registryMux, proxyWithMiddleware
1304+}
1305+
1306+func buildRegistryHandlerWithMiddleware(
1307+ svc *services,
1308+ cfg Config,
1309+ trustedNets []*net.IPNet,
1310+ registryHandler http.Handler,
1311+ log zerowrap.Logger,
1312+) (http.Handler, func(http.Handler) http.Handler, func(http.Handler) http.Handler) {
12751313 registryMiddlewares := []func(http.Handler) http.Handler{
12761314 middleware.PanicRecovery(log),
12771315 middleware.RequestLogger(log, trustedNets),
12781316 middleware.SecurityHeaders,
12791317 }
12801318
1281- // CIDR allowlist for registry access (e.g., restrict to Tailscale IPs).
1282- // Inserted before rate limiting so denied IPs never consume limiter tokens.
1283- var cidrAllowlistMiddleware func(http.Handler) http.Handler
1284- if len(cfg.Server.RegistryAllowedIPs) > 0 {
1285- allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs)
1286- if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) {
1287- for _, entry := range cfg.Server.RegistryAllowedIPs {
1288- if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 {
1289- log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry")
1290- }
1319+ cidrAllowlistMiddleware := buildRegistryCIDRAllowlistMiddleware(cfg, trustedNets, log)
1320+ if cidrAllowlistMiddleware != nil {
1321+ registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware)
1322+ }
1323+
1324+ rateLimitMiddleware := buildRegistryRateLimitMiddleware(cfg, log)
1325+ registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
1326+
1327+ appendRegistryAuthMiddleware(&registryMiddlewares, svc, cfg, log)
1328+
1329+ return middleware.Chain(registryMiddlewares...)(registryHandler), cidrAllowlistMiddleware, rateLimitMiddleware
1330+}
1331+
1332+func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
1333+ if len(cfg.Server.RegistryAllowedIPs) == 0 {
1334+ return nil
1335+ }
1336+
1337+ allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs)
1338+ if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) {
1339+ for _, entry := range cfg.Server.RegistryAllowedIPs {
1340+ if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 {
1341+ log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry")
12911342 }
12921343 }
1293- if len(allowedNets) == 0 {
1294- log.Error().
1295- Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs).
1296- Msg("registry_allowed_ips is set but no valid entries were parsed; registry will allow all traffic")
1297- }
1298- cidrAllowlistMiddleware = middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)
1299- registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware)
13001344 }
13011345
1302- // Create rate limiters using the factory
1303- var rateLimitMiddleware func(http.Handler) http.Handler
1346+ if len(allowedNets) == 0 {
1347+ log.Error().
1348+ Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs).
1349+ Msg("registry_allowed_ips is set but no valid entries were parsed; registry will allow all traffic")
1350+ }
1351+
1352+ return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)
1353+}
1354+
1355+func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler {
13041356 if cfg.API.RateLimit.Enabled {
13051357 globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
13061358 ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1307- rateLimitMiddleware = registry.RateLimitMiddleware(
1359+ return registry.RateLimitMiddleware(
13081360 globalLimiter,
13091361 ipLimiter,
13101362 cfg.API.RateLimit.TrustedProxies,
13111363 log,
13121364 )
1313- } else {
1314- rateLimitMiddleware = registry.RateLimitMiddleware(nil, nil, nil, log)
13151365 }
1316- registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
13171366
1367+ return registry.RateLimitMiddleware(nil, nil, nil, log)
1368+}
1369+
1370+func appendRegistryAuthMiddleware(registryMiddlewares *[]func(http.Handler) http.Handler, svc *services, cfg Config, log zerowrap.Logger) {
13181371 if svc.authSvc != nil {
13191372 internalAuth := middleware.InternalRegistryAuth{
13201373 Username: svc.internalRegUser,
13211374 Password: svc.internalRegPass,
13221375 }
1323- registryMiddlewares = append(registryMiddlewares,
1324- middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
1325- } else {
1376+ *registryMiddlewares = append(*registryMiddlewares, middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
1377+ return
1378+ }
1379+
1380+ if cfg.Auth.Enabled {
13261381 log.Error().Msg("authentication service unavailable; registry requests will be denied")
1327- registryMiddlewares = append(registryMiddlewares, func(next http.Handler) http.Handler {
1382+ *registryMiddlewares = append(*registryMiddlewares, func(next http.Handler) http.Handler {
13281383 return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
13291384 w.Header().Set("Content-Type", "application/json")
13301385 w.WriteHeader(http.StatusServiceUnavailable)
@@ -1332,72 +1387,78 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
13321387 })
13331388 })
13341389 }
1390+}
13351391
1336- registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler)
1392+func registerAuthRoutes(
1393+ registryMux *http.ServeMux,
1394+ svc *services,
1395+ trustedNets []*net.IPNet,
1396+ cidrAllowlistMiddleware func(http.Handler) http.Handler,
1397+ rateLimitMiddleware func(http.Handler) http.Handler,
1398+ log zerowrap.Logger,
1399+) {
1400+ if svc.authHandler == nil {
1401+ return
1402+ }
13371403
1338- // Create a mux that routes:
1339- // - /auth/* to auth handler (no auth required, for password login and token exchange)
1340- // - /v2/* to registry handler (with auth)
1341- // - /admin/* to admin handler (with auth)
1342- registryMux := http.NewServeMux()
1343- if svc.authHandler != nil {
1344- // Auth endpoints are NOT protected by auth - they're where clients authenticate
1345- // but still need rate limiting to prevent brute force attacks
1346- authMiddlewares := []func(http.Handler) http.Handler{
1347- middleware.PanicRecovery(log),
1348- middleware.RequestLogger(log, trustedNets),
1349- middleware.SecurityHeaders,
1350- }
1351- if cidrAllowlistMiddleware != nil {
1352- authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)
1353- }
1354- authMiddlewares = append(authMiddlewares, rateLimitMiddleware)
1355- authWithMiddleware := middleware.Chain(authMiddlewares...)(svc.authHandler)
1356- registryMux.Handle("/auth/", authWithMiddleware)
1357- }
1358- registryMux.Handle("/v2/", registryWithMiddleware)
1359-
1360- // Add admin API handler with auth middleware
1361- if svc.adminHandler != nil {
1362- adminMiddlewares := []func(http.Handler) http.Handler{
1363- middleware.PanicRecovery(log),
1364- middleware.RequestLogger(log, trustedNets),
1365- middleware.SecurityHeaders,
1366- }
1367- if svc.authSvc != nil {
1368- // Create rate limiters for admin API - uses same config as registry
1369- var globalLimiter, ipLimiter out.RateLimiter
1370- if cfg.API.RateLimit.Enabled {
1371- globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
1372- ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1373- }
1374- adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log))
1375- } else {
1376- adminMiddlewares = append(adminMiddlewares, func(next http.Handler) http.Handler {
1377- return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1378- w.Header().Set("Content-Type", "application/json")
1379- w.WriteHeader(http.StatusServiceUnavailable)
1380- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"})
1381- })
1382- })
1383- }
1384- adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
1385- registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log))
1404+ // Auth endpoints are NOT protected by auth - they're where clients authenticate
1405+ // but still need rate limiting to prevent brute force attacks.
1406+ authMiddlewares := []func(http.Handler) http.Handler{
1407+ middleware.PanicRecovery(log),
1408+ middleware.RequestLogger(log, trustedNets),
1409+ middleware.SecurityHeaders,
13861410 }
1411+ if cidrAllowlistMiddleware != nil {
1412+ authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)
1413+ }
1414+ authMiddlewares = append(authMiddlewares, rateLimitMiddleware)
1415+ authWithMiddleware := middleware.Chain(authMiddlewares...)(svc.authHandler)
1416+ registryMux.Handle("/auth/", authWithMiddleware)
1417+}
13871418
1388- // SECURITY: No CORS middleware on the proxy chain. Backend applications
1389- // should control their own CORS policies. A blanket Access-Control-Allow-Origin: *
1390- // would override backend CORS settings and allow any website to make
1391- // cross-origin authenticated requests to proxied applications.
1392- proxyMiddlewares := []func(http.Handler) http.Handler{
1419+func wrapRegistryForLocalMode(registryWithMiddleware http.Handler, cfg Config, log zerowrap.Logger) http.Handler {
1420+ if !cfg.Auth.Enabled {
1421+ return loopbackOnly(registryWithMiddleware, log)
1422+ }
1423+ return registryWithMiddleware
1424+}
1425+
1426+func registerAdminRoutes(registryMux *http.ServeMux, svc *services, cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) {
1427+ if svc.adminHandler == nil {
1428+ return
1429+ }
1430+
1431+ if !cfg.Auth.Enabled {
1432+ log.Warn().Msg("auth disabled: admin API endpoints are not registered")
1433+ return
1434+ }
1435+
1436+ adminMiddlewares := []func(http.Handler) http.Handler{
13931437 middleware.PanicRecovery(log),
13941438 middleware.RequestLogger(log, trustedNets),
13951439 middleware.SecurityHeaders,
13961440 }
13971441
1398- proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc)
1442+ if svc.authSvc != nil {
1443+ // Create rate limiters for admin API - uses same config as registry.
1444+ var globalLimiter, ipLimiter out.RateLimiter
1445+ if cfg.API.RateLimit.Enabled {
1446+ globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
1447+ ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1448+ }
1449+ adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log))
1450+ } else {
1451+ adminMiddlewares = append(adminMiddlewares, func(next http.Handler) http.Handler {
1452+ return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1453+ w.Header().Set("Content-Type", "application/json")
1454+ w.WriteHeader(http.StatusServiceUnavailable)
1455+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"})
1456+ })
1457+ })
1458+ }
13991459
1400- return registryMux, proxyWithMiddleware
1460+ adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
1461+ registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log))
14011462 }
14021463
14031464 // runServers starts the HTTP servers and waits for shutdown.
@@ -758,7 +758,8 @@ func GetInternalCredentials() (*InternalCredentials, error) {
758 // createAuthService creates the authentication service and token store.758 // createAuthService creates the authentication service and token store.
759 func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {759 func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
760 if !cfg.Auth.Enabled {760 if !cfg.Auth.Enabled {
761- return nil, nil, fmt.Errorf("auth.enabled=false is no longer supported; authentication is always required")761+ log.Warn().Msg("auth.enabled=false detected: running in local-only mode (registry loopback-only, admin API disabled)")
762+ return nil, nil, nil
762 }763 }
763 764
764 authType := resolveAuthType(cfg)765 authType := resolveAuthType(cfg)
@@ -1042,7 +1043,7 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
1042 // createContainerService creates the container service with configuration.1043 // createContainerService creates the container service with configuration.
1043 func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {1044 func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
1044 containerConfig := container.Config{1045 containerConfig := container.Config{
1045- RegistryAuthEnabled: true,1046+ RegistryAuthEnabled: cfg.Auth.Enabled,
1046 RegistryDomain: cfg.Server.RegistryDomain,1047 RegistryDomain: cfg.Server.RegistryDomain,
1047 RegistryPort: cfg.Server.RegistryPort,1048 RegistryPort: cfg.Server.RegistryPort,
1048 InternalRegistryUsername: svc.internalRegUser,1049 InternalRegistryUsername: svc.internalRegUser,
@@ -1068,7 +1069,10 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
1068 containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay")1069 containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay")
1069 }1070 }
1070 1071
1071- if svc.authSvc != nil {1072+ if containerConfig.RegistryAuthEnabled {
1073+ if svc.authSvc == nil {
1074+ return nil, fmt.Errorf("authentication service unavailable: cannot generate registry service token")
1075+ }
1072 expiry, err := resolveServiceTokenExpiry(cfg)1076 expiry, err := resolveServiceTokenExpiry(cfg)
1073 if err != nil {1077 if err != nil {
1074 return nil, log.WrapErr(err, "failed to resolve service token expiry")1078 return nil, log.WrapErr(err, "failed to resolve service token expiry")
@@ -1086,7 +1090,7 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
1086 containerConfig.ServiceTokenUsername = serviceTokenSubject1090 containerConfig.ServiceTokenUsername = serviceTokenSubject
1087 containerConfig.ServiceToken = serviceToken1091 containerConfig.ServiceToken = serviceToken
1088 } else {1092 } else {
1089- return nil, fmt.Errorf("authentication service unavailable: cannot generate registry service token")1093+ log.Warn().Msg("registry auth disabled; container image pulls will use unauthenticated mode")
1090 }1094 }
1091 1095
1092 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil1096 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil
@@ -1271,60 +1275,111 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
1271 trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)1275 trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
1272 1276
1273 registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)1277 registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)
1278+ registryWithMiddleware, cidrAllowlistMiddleware, rateLimitMiddleware := buildRegistryHandlerWithMiddleware(
1279+ svc,
1280+ cfg,
1281+ trustedNets,
1282+ registryHandler,
1283+ log,
1284+ )
1274 1285
1286+ registryMux := http.NewServeMux()
1287+ registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, log)
1288+ registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log))
1289+ registerAdminRoutes(registryMux, svc, cfg, trustedNets, log)
1290+
1291+ // SECURITY: No CORS middleware on the proxy chain. Backend applications
1292+ // should control their own CORS policies. A blanket Access-Control-Allow-Origin: *
1293+ // would override backend CORS settings and allow any website to make
1294+ // cross-origin authenticated requests to proxied applications.
1295+ proxyMiddlewares := []func(http.Handler) http.Handler{
1296+ middleware.PanicRecovery(log),
1297+ middleware.RequestLogger(log, trustedNets),
1298+ middleware.SecurityHeaders,
1299+ }
1300+
1301+ proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc)
1302+
1303+ return registryMux, proxyWithMiddleware
1304+}
1305+
1306+func buildRegistryHandlerWithMiddleware(
1307+ svc *services,
1308+ cfg Config,
1309+ trustedNets []*net.IPNet,
1310+ registryHandler http.Handler,
1311+ log zerowrap.Logger,
1312+) (http.Handler, func(http.Handler) http.Handler, func(http.Handler) http.Handler) {
1275 registryMiddlewares := []func(http.Handler) http.Handler{1313 registryMiddlewares := []func(http.Handler) http.Handler{
1276 middleware.PanicRecovery(log),1314 middleware.PanicRecovery(log),
1277 middleware.RequestLogger(log, trustedNets),1315 middleware.RequestLogger(log, trustedNets),
1278 middleware.SecurityHeaders,1316 middleware.SecurityHeaders,
1279 }1317 }
1280 1318
1281- // CIDR allowlist for registry access (e.g., restrict to Tailscale IPs).1319+ cidrAllowlistMiddleware := buildRegistryCIDRAllowlistMiddleware(cfg, trustedNets, log)
1282- // Inserted before rate limiting so denied IPs never consume limiter tokens.1320+ if cidrAllowlistMiddleware != nil {
1283- var cidrAllowlistMiddleware func(http.Handler) http.Handler1321+ registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware)
1284- if len(cfg.Server.RegistryAllowedIPs) > 0 {1322+ }
1285- allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs)1323+
1286- if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) {1324+ rateLimitMiddleware := buildRegistryRateLimitMiddleware(cfg, log)
1287- for _, entry := range cfg.Server.RegistryAllowedIPs {1325+ registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
1288- if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 {1326+
1289- log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry")1327+ appendRegistryAuthMiddleware(&registryMiddlewares, svc, cfg, log)
1290- }1328+
1329+ return middleware.Chain(registryMiddlewares...)(registryHandler), cidrAllowlistMiddleware, rateLimitMiddleware
1330+}
1331+
1332+func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
1333+ if len(cfg.Server.RegistryAllowedIPs) == 0 {
1334+ return nil
1335+ }
1336+
1337+ allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs)
1338+ if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) {
1339+ for _, entry := range cfg.Server.RegistryAllowedIPs {
1340+ if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 {
1341+ log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry")
1291 }1342 }
1292 }1343 }
1293- if len(allowedNets) == 0 {
1294- log.Error().
1295- Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs).
1296- Msg("registry_allowed_ips is set but no valid entries were parsed; registry will allow all traffic")
1297- }
1298- cidrAllowlistMiddleware = middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)
1299- registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware)
1300 }1344 }
1301 1345
1302- // Create rate limiters using the factory1346+ if len(allowedNets) == 0 {
1303- var rateLimitMiddleware func(http.Handler) http.Handler1347+ log.Error().
1348+ Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs).
1349+ Msg("registry_allowed_ips is set but no valid entries were parsed; registry will allow all traffic")
1350+ }
1351+
1352+ return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)
1353+}
1354+
1355+func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler {
1304 if cfg.API.RateLimit.Enabled {1356 if cfg.API.RateLimit.Enabled {
1305 globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)1357 globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
1306 ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)1358 ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1307- rateLimitMiddleware = registry.RateLimitMiddleware(1359+ return registry.RateLimitMiddleware(
1308 globalLimiter,1360 globalLimiter,
1309 ipLimiter,1361 ipLimiter,
1310 cfg.API.RateLimit.TrustedProxies,1362 cfg.API.RateLimit.TrustedProxies,
1311 log,1363 log,
1312 )1364 )
1313- } else {
1314- rateLimitMiddleware = registry.RateLimitMiddleware(nil, nil, nil, log)
1315 }1365 }
1316- registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
1317 1366
1367+ return registry.RateLimitMiddleware(nil, nil, nil, log)
1368+}
1369+
1370+func appendRegistryAuthMiddleware(registryMiddlewares *[]func(http.Handler) http.Handler, svc *services, cfg Config, log zerowrap.Logger) {
1318 if svc.authSvc != nil {1371 if svc.authSvc != nil {
1319 internalAuth := middleware.InternalRegistryAuth{1372 internalAuth := middleware.InternalRegistryAuth{
1320 Username: svc.internalRegUser,1373 Username: svc.internalRegUser,
1321 Password: svc.internalRegPass,1374 Password: svc.internalRegPass,
1322 }1375 }
1323- registryMiddlewares = append(registryMiddlewares,1376+ *registryMiddlewares = append(*registryMiddlewares, middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
1324- middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))1377+ return
1325- } else {1378+ }
1379+
1380+ if cfg.Auth.Enabled {
1326 log.Error().Msg("authentication service unavailable; registry requests will be denied")1381 log.Error().Msg("authentication service unavailable; registry requests will be denied")
1327- registryMiddlewares = append(registryMiddlewares, func(next http.Handler) http.Handler {1382+ *registryMiddlewares = append(*registryMiddlewares, func(next http.Handler) http.Handler {
1328 return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {1383 return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1329 w.Header().Set("Content-Type", "application/json")1384 w.Header().Set("Content-Type", "application/json")
1330 w.WriteHeader(http.StatusServiceUnavailable)1385 w.WriteHeader(http.StatusServiceUnavailable)
@@ -1332,72 +1387,78 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
1332 })1387 })
1333 })1388 })
1334 }1389 }
1390+}
1335 1391
1336- registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler)1392+func registerAuthRoutes(
1393+ registryMux *http.ServeMux,
1394+ svc *services,
1395+ trustedNets []*net.IPNet,
1396+ cidrAllowlistMiddleware func(http.Handler) http.Handler,
1397+ rateLimitMiddleware func(http.Handler) http.Handler,
1398+ log zerowrap.Logger,
1399+) {
1400+ if svc.authHandler == nil {
1401+ return
1402+ }
1337 1403
1338- // Create a mux that routes:1404+ // Auth endpoints are NOT protected by auth - they're where clients authenticate
1339- // - /auth/* to auth handler (no auth required, for password login and token exchange)1405+ // but still need rate limiting to prevent brute force attacks.
1340- // - /v2/* to registry handler (with auth)1406+ authMiddlewares := []func(http.Handler) http.Handler{
1341- // - /admin/* to admin handler (with auth)1407+ middleware.PanicRecovery(log),
1342- registryMux := http.NewServeMux()1408+ middleware.RequestLogger(log, trustedNets),
1343- if svc.authHandler != nil {1409+ middleware.SecurityHeaders,
1344- // Auth endpoints are NOT protected by auth - they're where clients authenticate
1345- // but still need rate limiting to prevent brute force attacks
1346- authMiddlewares := []func(http.Handler) http.Handler{
1347- middleware.PanicRecovery(log),
1348- middleware.RequestLogger(log, trustedNets),
1349- middleware.SecurityHeaders,
1350- }
1351- if cidrAllowlistMiddleware != nil {
1352- authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)
1353- }
1354- authMiddlewares = append(authMiddlewares, rateLimitMiddleware)
1355- authWithMiddleware := middleware.Chain(authMiddlewares...)(svc.authHandler)
1356- registryMux.Handle("/auth/", authWithMiddleware)
1357- }
1358- registryMux.Handle("/v2/", registryWithMiddleware)
1359-
1360- // Add admin API handler with auth middleware
1361- if svc.adminHandler != nil {
1362- adminMiddlewares := []func(http.Handler) http.Handler{
1363- middleware.PanicRecovery(log),
1364- middleware.RequestLogger(log, trustedNets),
1365- middleware.SecurityHeaders,
1366- }
1367- if svc.authSvc != nil {
1368- // Create rate limiters for admin API - uses same config as registry
1369- var globalLimiter, ipLimiter out.RateLimiter
1370- if cfg.API.RateLimit.Enabled {
1371- globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
1372- ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1373- }
1374- adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log))
1375- } else {
1376- adminMiddlewares = append(adminMiddlewares, func(next http.Handler) http.Handler {
1377- return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1378- w.Header().Set("Content-Type", "application/json")
1379- w.WriteHeader(http.StatusServiceUnavailable)
1380- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"})
1381- })
1382- })
1383- }
1384- adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
1385- registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log))
1386 }1410 }
1411+ if cidrAllowlistMiddleware != nil {
1412+ authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)
1413+ }
1414+ authMiddlewares = append(authMiddlewares, rateLimitMiddleware)
1415+ authWithMiddleware := middleware.Chain(authMiddlewares...)(svc.authHandler)
1416+ registryMux.Handle("/auth/", authWithMiddleware)
1417+}
1387 1418
1388- // SECURITY: No CORS middleware on the proxy chain. Backend applications1419+func wrapRegistryForLocalMode(registryWithMiddleware http.Handler, cfg Config, log zerowrap.Logger) http.Handler {
1389- // should control their own CORS policies. A blanket Access-Control-Allow-Origin: *1420+ if !cfg.Auth.Enabled {
1390- // would override backend CORS settings and allow any website to make1421+ return loopbackOnly(registryWithMiddleware, log)
1391- // cross-origin authenticated requests to proxied applications.1422+ }
1392- proxyMiddlewares := []func(http.Handler) http.Handler{1423+ return registryWithMiddleware
1424+}
1425+
1426+func registerAdminRoutes(registryMux *http.ServeMux, svc *services, cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) {
1427+ if svc.adminHandler == nil {
1428+ return
1429+ }
1430+
1431+ if !cfg.Auth.Enabled {
1432+ log.Warn().Msg("auth disabled: admin API endpoints are not registered")
1433+ return
1434+ }
1435+
1436+ adminMiddlewares := []func(http.Handler) http.Handler{
1393 middleware.PanicRecovery(log),1437 middleware.PanicRecovery(log),
1394 middleware.RequestLogger(log, trustedNets),1438 middleware.RequestLogger(log, trustedNets),
1395 middleware.SecurityHeaders,1439 middleware.SecurityHeaders,
1396 }1440 }
1397 1441
1398- proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc)1442+ if svc.authSvc != nil {
1443+ // Create rate limiters for admin API - uses same config as registry.
1444+ var globalLimiter, ipLimiter out.RateLimiter
1445+ if cfg.API.RateLimit.Enabled {
1446+ globalLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
1447+ ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1448+ }
1449+ adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log))
1450+ } else {
1451+ adminMiddlewares = append(adminMiddlewares, func(next http.Handler) http.Handler {
1452+ return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1453+ w.Header().Set("Content-Type", "application/json")
1454+ w.WriteHeader(http.StatusServiceUnavailable)
1455+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"})
1456+ })
1457+ })
1458+ }
1399 1459
1400- return registryMux, proxyWithMiddleware1460+ adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
1461+ registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log))
1401 }1462 }
1402 1463
1403 // runServers starts the HTTP servers and waits for shutdown.1464 // runServers starts the HTTP servers and waits for shutdown.
A internal/app/run_http_handlers_test.go +68 -0
@@ -0,0 +1,68 @@
1+package app
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+
9+ adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin"
10+ "github.com/bnema/zerowrap"
11+)
12+
13+func TestCreateAuthService_Disabled_ReturnsNilServices(t *testing.T) {
14+ t.Parallel()
15+
16+ cfg := Config{}
17+ cfg.Auth.Enabled = false
18+
19+ store, authSvc, err := createAuthService(context.Background(), cfg, zerowrap.Default())
20+ if err != nil {
21+ t.Fatalf("expected no error when auth is disabled, got %v", err)
22+ }
23+ if store != nil {
24+ t.Fatalf("expected nil token store when auth is disabled")
25+ }
26+ if authSvc != nil {
27+ t.Fatalf("expected nil auth service when auth is disabled")
28+ }
29+}
30+
31+func TestCreateHTTPHandlers_LocalMode_DisablesAdminRoutes(t *testing.T) {
32+ t.Parallel()
33+
34+ cfg := Config{}
35+ cfg.Auth.Enabled = false
36+
37+ svc := &services{adminHandler: &adminhttp.Handler{}}
38+ registryHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
39+
40+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
41+ req.RemoteAddr = "192.0.2.10:12345"
42+
43+ rec := httptest.NewRecorder()
44+ registryHandler.ServeHTTP(rec, req)
45+
46+ if rec.Code != http.StatusNotFound {
47+ t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code)
48+ }
49+}
50+
51+func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T) {
52+ t.Parallel()
53+
54+ cfg := Config{}
55+ cfg.Auth.Enabled = false
56+
57+ registryHandler, _ := createHTTPHandlers(&services{}, cfg, zerowrap.Default())
58+
59+ req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil)
60+ req.RemoteAddr = "192.0.2.20:12345"
61+
62+ rec := httptest.NewRecorder()
63+ registryHandler.ServeHTTP(rec, req)
64+
65+ if rec.Code != http.StatusForbidden {
66+ t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)
67+ }
68+}
@@ -0,0 +1,68 @@
1+package app
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+
9+ adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin"
10+ "github.com/bnema/zerowrap"
11+)
12+
13+func TestCreateAuthService_Disabled_ReturnsNilServices(t *testing.T) {
14+ t.Parallel()
15+
16+ cfg := Config{}
17+ cfg.Auth.Enabled = false
18+
19+ store, authSvc, err := createAuthService(context.Background(), cfg, zerowrap.Default())
20+ if err != nil {
21+ t.Fatalf("expected no error when auth is disabled, got %v", err)
22+ }
23+ if store != nil {
24+ t.Fatalf("expected nil token store when auth is disabled")
25+ }
26+ if authSvc != nil {
27+ t.Fatalf("expected nil auth service when auth is disabled")
28+ }
29+}
30+
31+func TestCreateHTTPHandlers_LocalMode_DisablesAdminRoutes(t *testing.T) {
32+ t.Parallel()
33+
34+ cfg := Config{}
35+ cfg.Auth.Enabled = false
36+
37+ svc := &services{adminHandler: &adminhttp.Handler{}}
38+ registryHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
39+
40+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
41+ req.RemoteAddr = "192.0.2.10:12345"
42+
43+ rec := httptest.NewRecorder()
44+ registryHandler.ServeHTTP(rec, req)
45+
46+ if rec.Code != http.StatusNotFound {
47+ t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code)
48+ }
49+}
50+
51+func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T) {
52+ t.Parallel()
53+
54+ cfg := Config{}
55+ cfg.Auth.Enabled = false
56+
57+ registryHandler, _ := createHTTPHandlers(&services{}, cfg, zerowrap.Default())
58+
59+ req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil)
60+ req.RemoteAddr = "192.0.2.20:12345"
61+
62+ rec := httptest.NewRecorder()
63+ registryHandler.ServeHTTP(rec, req)
64+
65+ if rec.Code != http.StatusForbidden {
66+ t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)
67+ }
68+}

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