detached 326215c

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
326215cd73e6a564000f85003d6fa6dcfae6b223
author
brice · · 2026-02-09 05:41 UTC
parent
de49ef1
signature
unsigned
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
3131
3232 | Option | Type | Default | Description |
3333 |--------|------|---------|-------------|
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) |
3535 | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` |
3636 | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend |
3737 | `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 {
155155 logWriter *logwriter.LogWriter
156156 tokenStore out.TokenStore
157157 configSvc *config.Service
158+ secretSvc *secretsSvc.Service
158159 containerSvc *container.Service
159160 backupSvc *backup.Service
160161 registrySvc *registrySvc.Service
162+ healthSvc *health.Service
163+ logSvc *logs.Service
161164 proxySvc *proxy.Service
162165 authSvc *auth.Service
163166 authHandler *authhandler.Handler
@@ -442,7 +445,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
442445 return nil, err
443446 }
444447
445- secretSvc := secretsSvc.NewService(domainSecretStore, log)
448+ svc.secretSvc = secretsSvc.NewService(domainSecretStore, log)
446449
447450 if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil {
448451 return nil, err
@@ -477,13 +480,13 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
477480
478481 // Create health service for route health checking
479482 prober := httpprober.New()
480- healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)
483+ svc.healthSvc = health.NewService(svc.configSvc, svc.containerSvc, prober, log)
481484
482485 // 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)
484487
485488 // 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)
487490
488491 return svc, nil
489492 }
@@ -1286,7 +1289,6 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
12861289 registryMux := http.NewServeMux()
12871290 registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, log)
12881291 registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log))
1289- registerAdminRoutes(registryMux, svc, cfg, trustedNets, log)
12901292
12911293 // SECURITY: No CORS middleware on the proxy chain. Backend applications
12921294 // 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
12991301 }
13001302
13011303 proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc)
1304+ proxyMux := http.NewServeMux()
1305+ proxyMux.Handle("/", proxyWithMiddleware)
13021306
1303- return registryMux, proxyWithMiddleware
1307+ registerAdminRoutes(registryMux, proxyMux, svc, cfg, trustedNets, log)
1308+
1309+ return registryMux, proxyMux
13041310 }
13051311
13061312 func buildRegistryHandlerWithMiddleware(
@@ -1423,7 +1429,7 @@ func wrapRegistryForLocalMode(registryWithMiddleware http.Handler, cfg Config, l
14231429 return registryWithMiddleware
14241430 }
14251431
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) {
14271433 if svc.adminHandler == nil {
14281434 return
14291435 }
@@ -1459,6 +1465,7 @@ func registerAdminRoutes(registryMux *http.ServeMux, svc *services, cfg Config,
14591465
14601466 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
14611467 registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log))
1468+ proxyMux.Handle("/admin/", adminWithMiddleware)
14621469 }
14631470
14641471 // runServers starts the HTTP servers and waits for shutdown.
@@ -155,9 +155,12 @@ type services struct {
155 logWriter *logwriter.LogWriter155 logWriter *logwriter.LogWriter
156 tokenStore out.TokenStore156 tokenStore out.TokenStore
157 configSvc *config.Service157 configSvc *config.Service
158+ secretSvc *secretsSvc.Service
158 containerSvc *container.Service159 containerSvc *container.Service
159 backupSvc *backup.Service160 backupSvc *backup.Service
160 registrySvc *registrySvc.Service161 registrySvc *registrySvc.Service
162+ healthSvc *health.Service
163+ logSvc *logs.Service
161 proxySvc *proxy.Service164 proxySvc *proxy.Service
162 authSvc *auth.Service165 authSvc *auth.Service
163 authHandler *authhandler.Handler166 authHandler *authhandler.Handler
@@ -442,7 +445,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
442 return nil, err445 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, err451 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 checking481 // 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 API485 // 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 API488 // 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, nil491 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 applications1293 // 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, proxyWithMiddleware1307+ 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 registryWithMiddleware1429 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 return1434 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)
6666 t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)
6767 }
6868 }
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+}

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