fix(app): expose admin API on proxy listener
Route /admin requests through the proxy listener when auth is enabled so remote management works through auth middleware. Keep local-only behavior unchanged when auth is disabled.
- commit
- 2cefdb1b2857c336c97b41d0b01226eac5393c7d
- author
- brice · · 2026-02-09 05:41 UTC
- parent
- 7026805
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +35 -8
M docs/config/auth.md +1 -1
| @@ -31,7 +31,7 @@ Token auth always works. Adding `username` + `password_hash` enables interactive | |||
| 31 | 31 | ||
| 32 | 32 | | Option | Type | Default | Description | | |
| 33 | 33 | |--------|------|---------|-------------| | |
| 34 | - | | `enabled` | bool | `true` | Deprecated. Authentication is always required; setting `false` is rejected | | |
| 34 | + | | `enabled` | bool | `true` | Authentication toggle. `false` enables local-only mode (see below) | | |
| 35 | 35 | | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` | | |
| 36 | 36 | | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend | | |
| 37 | 37 | | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) | | |
| @@ -31,7 +31,7 @@ Token auth always works. Adding `username` + `password_hash` enables interactive | |||||
| 31 | 31 | ||||
| 32 | | Option | Type | Default | Description | | 32 | | Option | Type | Default | Description | | ||
| 33 | |--------|------|---------|-------------| | 33 | |--------|------|---------|-------------| | ||
| 34 | - | | `enabled` | bool | `true` | Deprecated. Authentication is always required; setting `false` is rejected | | 34 | + | | `enabled` | bool | `true` | Authentication toggle. `false` enables local-only mode (see below) | |
| 35 | | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` | | 35 | | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` | | ||
| 36 | | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend | | 36 | | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend | | ||
| 37 | | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) | | 37 | | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) | | ||
M internal/app/run.go +14 -7
| @@ -155,9 +155,12 @@ type services struct { | |||
| 155 | 155 | logWriter *logwriter.LogWriter | |
| 156 | 156 | tokenStore out.TokenStore | |
| 157 | 157 | configSvc *config.Service | |
| 158 | + | secretSvc *secretsSvc.Service | |
| 158 | 159 | containerSvc *container.Service | |
| 159 | 160 | backupSvc *backup.Service | |
| 160 | 161 | registrySvc *registrySvc.Service | |
| 162 | + | healthSvc *health.Service | |
| 163 | + | logSvc *logs.Service | |
| 161 | 164 | proxySvc *proxy.Service | |
| 162 | 165 | authSvc *auth.Service | |
| 163 | 166 | authHandler *authhandler.Handler | |
| @@ -442,7 +445,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 442 | 445 | return nil, err | |
| 443 | 446 | } | |
| 444 | 447 | ||
| 445 | - | secretSvc := secretsSvc.NewService(domainSecretStore, log) | |
| 448 | + | svc.secretSvc = secretsSvc.NewService(domainSecretStore, log) | |
| 446 | 449 | ||
| 447 | 450 | if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil { | |
| 448 | 451 | return nil, err | |
| @@ -477,13 +480,13 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 477 | 480 | ||
| 478 | 481 | // Create health service for route health checking | |
| 479 | 482 | prober := httpprober.New() | |
| 480 | - | healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log) | |
| 483 | + | svc.healthSvc = health.NewService(svc.configSvc, svc.containerSvc, prober, log) | |
| 481 | 484 | ||
| 482 | 485 | // Create log service for accessing logs via admin API | |
| 483 | - | logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log) | |
| 486 | + | svc.logSvc = logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log) | |
| 484 | 487 | ||
| 485 | 488 | // Create admin handler for admin API | |
| 486 | - | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc) | |
| 489 | + | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, svc.healthSvc, svc.secretSvc, svc.logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc) | |
| 487 | 490 | ||
| 488 | 491 | return svc, nil | |
| 489 | 492 | } | |
| @@ -1286,7 +1289,6 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1286 | 1289 | registryMux := http.NewServeMux() | |
| 1287 | 1290 | registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, log) | |
| 1288 | 1291 | registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log)) | |
| 1289 | - | registerAdminRoutes(registryMux, svc, cfg, trustedNets, log) | |
| 1290 | 1292 | ||
| 1291 | 1293 | // SECURITY: No CORS middleware on the proxy chain. Backend applications | |
| 1292 | 1294 | // should control their own CORS policies. A blanket Access-Control-Allow-Origin: * | |
| @@ -1299,8 +1301,12 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1299 | 1301 | } | |
| 1300 | 1302 | ||
| 1301 | 1303 | proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc) | |
| 1304 | + | proxyMux := http.NewServeMux() | |
| 1305 | + | proxyMux.Handle("/", proxyWithMiddleware) | |
| 1302 | 1306 | ||
| 1303 | - | return registryMux, proxyWithMiddleware | |
| 1307 | + | registerAdminRoutes(registryMux, proxyMux, svc, cfg, trustedNets, log) | |
| 1308 | + | ||
| 1309 | + | return registryMux, proxyMux | |
| 1304 | 1310 | } | |
| 1305 | 1311 | ||
| 1306 | 1312 | func buildRegistryHandlerWithMiddleware( | |
| @@ -1423,7 +1429,7 @@ func wrapRegistryForLocalMode(registryWithMiddleware http.Handler, cfg Config, l | |||
| 1423 | 1429 | return registryWithMiddleware | |
| 1424 | 1430 | } | |
| 1425 | 1431 | ||
| 1426 | - | func registerAdminRoutes(registryMux *http.ServeMux, svc *services, cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) { | |
| 1432 | + | func registerAdminRoutes(registryMux, proxyMux *http.ServeMux, svc *services, cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) { | |
| 1427 | 1433 | if svc.adminHandler == nil { | |
| 1428 | 1434 | return | |
| 1429 | 1435 | } | |
| @@ -1459,6 +1465,7 @@ func registerAdminRoutes(registryMux *http.ServeMux, svc *services, cfg Config, | |||
| 1459 | 1465 | ||
| 1460 | 1466 | adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler) | |
| 1461 | 1467 | registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log)) | |
| 1468 | + | proxyMux.Handle("/admin/", adminWithMiddleware) | |
| 1462 | 1469 | } | |
| 1463 | 1470 | ||
| 1464 | 1471 | // runServers starts the HTTP servers and waits for shutdown. | |
| @@ -155,9 +155,12 @@ type services struct { | |||||
| 155 | logWriter *logwriter.LogWriter | 155 | logWriter *logwriter.LogWriter | ||
| 156 | tokenStore out.TokenStore | 156 | tokenStore out.TokenStore | ||
| 157 | configSvc *config.Service | 157 | configSvc *config.Service | ||
| 158 | + | secretSvc *secretsSvc.Service | |||
| 158 | containerSvc *container.Service | 159 | containerSvc *container.Service | ||
| 159 | backupSvc *backup.Service | 160 | backupSvc *backup.Service | ||
| 160 | registrySvc *registrySvc.Service | 161 | registrySvc *registrySvc.Service | ||
| 162 | + | healthSvc *health.Service | |||
| 163 | + | logSvc *logs.Service | |||
| 161 | proxySvc *proxy.Service | 164 | proxySvc *proxy.Service | ||
| 162 | authSvc *auth.Service | 165 | authSvc *auth.Service | ||
| 163 | authHandler *authhandler.Handler | 166 | authHandler *authhandler.Handler | ||
| @@ -442,7 +445,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 442 | return nil, err | 445 | return nil, err | ||
| 443 | } | 446 | } | ||
| 444 | 447 | ||||
| 445 | - | secretSvc := secretsSvc.NewService(domainSecretStore, log) | 448 | + | svc.secretSvc = secretsSvc.NewService(domainSecretStore, log) |
| 446 | 449 | ||||
| 447 | if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil { | 450 | if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil { | ||
| 448 | return nil, err | 451 | return nil, err | ||
| @@ -477,13 +480,13 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 477 | 480 | ||||
| 478 | // Create health service for route health checking | 481 | // Create health service for route health checking | ||
| 479 | prober := httpprober.New() | 482 | prober := httpprober.New() | ||
| 480 | - | healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log) | 483 | + | svc.healthSvc = health.NewService(svc.configSvc, svc.containerSvc, prober, log) |
| 481 | 484 | ||||
| 482 | // Create log service for accessing logs via admin API | 485 | // Create log service for accessing logs via admin API | ||
| 483 | - | logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log) | 486 | + | svc.logSvc = logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log) |
| 484 | 487 | ||||
| 485 | // Create admin handler for admin API | 488 | // Create admin handler for admin API | ||
| 486 | - | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc) | 489 | + | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, svc.healthSvc, svc.secretSvc, svc.logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc) |
| 487 | 490 | ||||
| 488 | return svc, nil | 491 | return svc, nil | ||
| 489 | } | 492 | } | ||
| @@ -1286,7 +1289,6 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1286 | registryMux := http.NewServeMux() | 1289 | registryMux := http.NewServeMux() | ||
| 1287 | registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, log) | 1290 | registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, log) | ||
| 1288 | registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log)) | 1291 | registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log)) | ||
| 1289 | - | registerAdminRoutes(registryMux, svc, cfg, trustedNets, log) | |||
| 1290 | 1292 | ||||
| 1291 | // SECURITY: No CORS middleware on the proxy chain. Backend applications | 1293 | // SECURITY: No CORS middleware on the proxy chain. Backend applications | ||
| 1292 | // should control their own CORS policies. A blanket Access-Control-Allow-Origin: * | 1294 | // should control their own CORS policies. A blanket Access-Control-Allow-Origin: * | ||
| @@ -1299,8 +1301,12 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1299 | } | 1301 | } | ||
| 1300 | 1302 | ||||
| 1301 | proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc) | 1303 | proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc) | ||
| 1304 | + | proxyMux := http.NewServeMux() | |||
| 1305 | + | proxyMux.Handle("/", proxyWithMiddleware) | |||
| 1302 | 1306 | ||||
| 1303 | - | return registryMux, proxyWithMiddleware | 1307 | + | registerAdminRoutes(registryMux, proxyMux, svc, cfg, trustedNets, log) |
| 1308 | + | ||||
| 1309 | + | return registryMux, proxyMux | |||
| 1304 | } | 1310 | } | ||
| 1305 | 1311 | ||||
| 1306 | func buildRegistryHandlerWithMiddleware( | 1312 | func buildRegistryHandlerWithMiddleware( | ||
| @@ -1423,7 +1429,7 @@ func wrapRegistryForLocalMode(registryWithMiddleware http.Handler, cfg Config, l | |||||
| 1423 | return registryWithMiddleware | 1429 | return registryWithMiddleware | ||
| 1424 | } | 1430 | } | ||
| 1425 | 1431 | ||||
| 1426 | - | func registerAdminRoutes(registryMux *http.ServeMux, svc *services, cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) { | 1432 | + | func registerAdminRoutes(registryMux, proxyMux *http.ServeMux, svc *services, cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) { |
| 1427 | if svc.adminHandler == nil { | 1433 | if svc.adminHandler == nil { | ||
| 1428 | return | 1434 | return | ||
| 1429 | } | 1435 | } | ||
| @@ -1459,6 +1465,7 @@ func registerAdminRoutes(registryMux *http.ServeMux, svc *services, cfg Config, | |||||
| 1459 | 1465 | ||||
| 1460 | adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler) | 1466 | adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler) | ||
| 1461 | registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log)) | 1467 | registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log)) | ||
| 1468 | + | proxyMux.Handle("/admin/", adminWithMiddleware) | |||
| 1462 | } | 1469 | } | ||
| 1463 | 1470 | ||||
| 1464 | // runServers starts the HTTP servers and waits for shutdown. | 1471 | // runServers starts the HTTP servers and waits for shutdown. | ||
M internal/app/run_http_handlers_test.go +20 -0
| @@ -66,3 +66,23 @@ func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T) | |||
| 66 | 66 | t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code) | |
| 67 | 67 | } | |
| 68 | 68 | } | |
| 69 | + | ||
| 70 | + | func TestCreateHTTPHandlers_AuthEnabled_ExposesAdminOnProxy(t *testing.T) { | |
| 71 | + | t.Parallel() | |
| 72 | + | ||
| 73 | + | cfg := Config{} | |
| 74 | + | cfg.Auth.Enabled = true | |
| 75 | + | ||
| 76 | + | svc := &services{adminHandler: &adminhttp.Handler{}} | |
| 77 | + | _, proxyHandler := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 78 | + | ||
| 79 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 80 | + | req.RemoteAddr = "192.0.2.30:12345" | |
| 81 | + | ||
| 82 | + | rec := httptest.NewRecorder() | |
| 83 | + | proxyHandler.ServeHTTP(rec, req) | |
| 84 | + | ||
| 85 | + | if rec.Code != http.StatusServiceUnavailable { | |
| 86 | + | t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, rec.Code) | |
| 87 | + | } | |
| 88 | + | } | |
| @@ -66,3 +66,23 @@ func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T) | |||||
| 66 | t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code) | 66 | t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code) | ||
| 67 | } | 67 | } | ||
| 68 | } | 68 | } | ||
| 69 | + | ||||
| 70 | + | func TestCreateHTTPHandlers_AuthEnabled_ExposesAdminOnProxy(t *testing.T) { | |||
| 71 | + | t.Parallel() | |||
| 72 | + | ||||
| 73 | + | cfg := Config{} | |||
| 74 | + | cfg.Auth.Enabled = true | |||
| 75 | + | ||||
| 76 | + | svc := &services{adminHandler: &adminhttp.Handler{}} | |||
| 77 | + | _, proxyHandler := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |||
| 78 | + | ||||
| 79 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |||
| 80 | + | req.RemoteAddr = "192.0.2.30:12345" | |||
| 81 | + | ||||
| 82 | + | rec := httptest.NewRecorder() | |||
| 83 | + | proxyHandler.ServeHTTP(rec, req) | |||
| 84 | + | ||||
| 85 | + | if rec.Code != http.StatusServiceUnavailable { | |||
| 86 | + | t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, rec.Code) | |||
| 87 | + | } | |||
| 88 | + | } | |||