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 | |||
| 152 | 152 | - Tokens from one Gordon instance won't work on another | |
| 153 | 153 | - Changing `token_secret` invalidates all existing tokens | |
| 154 | 154 | ||
| 155 | - | ## Authentication Required | |
| 155 | + | ## 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 | 165 | ## Internal Registry Auth | |
| 160 | 166 | ||
| @@ -152,9 +152,15 @@ gordon auth token generate --subject temp --expiry 30d | |||||
| 152 | - Tokens from one Gordon instance won't work on another | 152 | - Tokens from one Gordon instance won't work on another | ||
| 153 | - Changing `token_secret` invalidates all existing tokens | 153 | - Changing `token_secret` invalidates all existing tokens | ||
| 154 | 154 | ||||
| 155 | - | ## Authentication Required | 155 | + | ## 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 Auth | 165 | ## Internal Registry Auth | ||
| 160 | 166 | ||||
M docs/config/index.md +2 -0
| @@ -183,6 +183,8 @@ monthly = 12 | |||
| 183 | 183 | | `auto_route.enabled` | `false` | | |
| 184 | 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 | 188 | ## Hot Reload | |
| 187 | 189 | ||
| 188 | 190 | 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 Reload | 188 | ## 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 | |||
| 159 | 159 | | `server.data_dir` | `~/.gordon` | Data directory | | |
| 160 | 160 | | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk | | |
| 161 | 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 | 163 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | |
| 164 | 164 | | `auth.token_expiry` | `"720h"` | 30 days | | |
| 165 | 165 | | `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" | |||
| 121 | 121 | ||
| 122 | 122 | ### 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 | 126 | ### Breaking: Secret Paths Changed | |
| 127 | 127 | ||
| @@ -121,7 +121,7 @@ password_hash = "gordon/auth/password_hash" | |||||
| 121 | 121 | ||||
| 122 | ### Breaking: Auth Enabled by Default | 122 | ### 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 Changed | 126 | ### Breaking: Secret Paths Changed | ||
| 127 | 127 | ||||
M internal/adapters/in/http/auth/handler.go +21 -42
| @@ -2,6 +2,7 @@ | |||
| 2 | 2 | package auth | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "context" | |
| 5 | 6 | "crypto/subtle" | |
| 6 | 7 | "encoding/json" | |
| 7 | 8 | "net/http" | |
| @@ -214,29 +215,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 214 | 215 | return | |
| 215 | 216 | } | |
| 216 | 217 | ||
| 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) | |
| 240 | 219 | if !authenticated { | |
| 241 | 220 | log.Debug(). | |
| 242 | 221 | Str("username", username). | |
| @@ -289,6 +268,25 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 289 | 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 | 290 | func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []string, log zerowrap.Logger) []string { | |
| 293 | 291 | var effective []string | |
| 294 | 292 | ||
| @@ -352,25 +350,6 @@ func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) b | |||
| 352 | 350 | 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 | 353 | // isLocalhostRequest checks if the request originates from localhost. | |
| 375 | 354 | // SECURITY: Uses RemoteAddr (server-set) instead of Host header (client-spoofable). | |
| 376 | 355 | func isLocalhostRequest(r *http.Request) bool { | |
| @@ -2,6 +2,7 @@ | |||||
| 2 | package auth | 2 | 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 | return | 215 | 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 []string | 291 | var effective []string | ||
| 294 | 292 | ||||
| @@ -352,25 +350,6 @@ func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) b | |||||
| 352 | return false | 350 | 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) { | |||
| 266 | 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 | 270 | remoteAddr: "127.0.0.1:12345", | |
| 271 | 271 | xff: "203.0.113.50, 10.0.0.1, 172.16.0.1", | |
| 272 | 272 | trustedNets: trustedNets, | |
| 273 | - | wantIP: "203.0.113.50", | |
| 273 | + | wantIP: "172.16.0.1", | |
| 274 | 274 | }, | |
| 275 | 275 | { | |
| 276 | 276 | 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) { | |||
| 758 | 758 | // createAuthService creates the authentication service and token store. | |
| 759 | 759 | func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) { | |
| 760 | 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 | 765 | authType := resolveAuthType(cfg) | |
| @@ -1042,7 +1043,7 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro | |||
| 1042 | 1043 | // createContainerService creates the container service with configuration. | |
| 1043 | 1044 | func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) { | |
| 1044 | 1045 | containerConfig := container.Config{ | |
| 1045 | - | RegistryAuthEnabled: true, | |
| 1046 | + | RegistryAuthEnabled: cfg.Auth.Enabled, | |
| 1046 | 1047 | RegistryDomain: cfg.Server.RegistryDomain, | |
| 1047 | 1048 | RegistryPort: cfg.Server.RegistryPort, | |
| 1048 | 1049 | InternalRegistryUsername: svc.internalRegUser, | |
| @@ -1068,7 +1069,10 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 1068 | 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 | 1076 | expiry, err := resolveServiceTokenExpiry(cfg) | |
| 1073 | 1077 | if err != nil { | |
| 1074 | 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 | 1090 | containerConfig.ServiceTokenUsername = serviceTokenSubject | |
| 1087 | 1091 | containerConfig.ServiceToken = serviceToken | |
| 1088 | 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 | 1096 | 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 | 1275 | trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | |
| 1272 | 1276 | ||
| 1273 | 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 | 1313 | registryMiddlewares := []func(http.Handler) http.Handler{ | |
| 1276 | 1314 | middleware.PanicRecovery(log), | |
| 1277 | 1315 | middleware.RequestLogger(log, trustedNets), | |
| 1278 | 1316 | middleware.SecurityHeaders, | |
| 1279 | 1317 | } | |
| 1280 | 1318 | ||
| 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(®istryMiddlewares, 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") | |
| 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 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 { | |
| 1304 | 1356 | if cfg.API.RateLimit.Enabled { | |
| 1305 | 1357 | globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log) | |
| 1306 | 1358 | ipLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log) | |
| 1307 | - | rateLimitMiddleware = registry.RateLimitMiddleware( | |
| 1359 | + | return registry.RateLimitMiddleware( | |
| 1308 | 1360 | globalLimiter, | |
| 1309 | 1361 | ipLimiter, | |
| 1310 | 1362 | cfg.API.RateLimit.TrustedProxies, | |
| 1311 | 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 | 1371 | if svc.authSvc != nil { | |
| 1319 | 1372 | internalAuth := middleware.InternalRegistryAuth{ | |
| 1320 | 1373 | Username: svc.internalRegUser, | |
| 1321 | 1374 | Password: svc.internalRegPass, | |
| 1322 | 1375 | } | |
| 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 { | |
| 1326 | 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 | 1383 | return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 1329 | 1384 | w.Header().Set("Content-Type", "application/json") | |
| 1330 | 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: | |
| 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, | |
| 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 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{ | |
| 1393 | 1437 | middleware.PanicRecovery(log), | |
| 1394 | 1438 | middleware.RequestLogger(log, trustedNets), | |
| 1395 | 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, proxyWithMiddleware | |
| 1460 | + | adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler) | |
| 1461 | + | registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log)) | |
| 1401 | 1462 | } | |
| 1402 | 1463 | ||
| 1403 | 1464 | // 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 = serviceTokenSubject | 1090 | containerConfig.ServiceTokenUsername = serviceTokenSubject | ||
| 1087 | containerConfig.ServiceToken = serviceToken | 1091 | 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), nil | 1096 | 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.Handler | 1321 | + | 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(®istryMiddlewares, 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 factory | 1346 | + | if len(allowedNets) == 0 { |
| 1303 | - | var rateLimitMiddleware func(http.Handler) http.Handler | 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 { | |||
| 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 applications | 1419 | + | 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 make | 1421 | + | 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, proxyWithMiddleware | 1460 | + | 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 | + | } | |||