diff --git a/admin.go b/admin.go index c518115..10524ac 100644 --- a/admin.go +++ b/admin.go @@ -4,6 +4,7 @@ import ( "encoding/json" "io" "net/http" + "net/url" "time" ) @@ -23,11 +24,12 @@ const ( adminUsagePath = "/admin/api/usage" ) -// adminCSP is the Content-Security-Policy header served with GET /admin -// (spec §4, v0.2): no external assets of any kind, only this page's own -// inline script/style, and fetch calls restricted to same-origin — the -// dashboard works air-gapped and cannot be coerced into loading anything -// off-host. +// adminCSP is the Content-Security-Policy header served with all three +// admin routes (spec §4, v0.2; extended to the two JSON routes by a +// folded review item, 2026-08-20 review): no external assets of any +// kind, only this page's own inline script/style, and fetch calls +// restricted to same-origin — the dashboard works air-gapped and cannot +// be coerced into loading anything off-host. const adminCSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'" // adminEnabled reports whether cfg's Admin block is present and enabled — @@ -44,15 +46,36 @@ func isAdminPath(path string) bool { return path == adminPagePath || path == adminOverviewPath || path == adminUsagePath } -// handleAdmin is the shared entry point for all three /admin* routes, -// applying spec §4's gate order: unauthenticated → 401, authenticated -// non-admin → 403, admin → serve. It is called only when adminEnabled — -// ServeHTTP's dispatch already checked that — and, like every other -// authenticated route, counts request counters via checkAndCount before -// serving: an admin over their own req/min limit gets a 429 here exactly -// as they would on any other route (spec §4's "Accounting: admin routes -// count request counters like any authed route"). +// handleAdmin is ServeHTTP's single entry point for all three /admin* +// routes (spec §4, v0.2), called only when adminEnabled — ServeHTTP's +// dispatch already checked that. +// +// GET /admin itself serves the HTML shell with no authentication at all +// (controller-approved amendment to spec §4, 2026-08-20 review): a +// browser navigating straight to a URL cannot attach a custom +// Authorization or x-api-key header, so the original "401 when +// unauthenticated" gate made the dashboard unreachable from a browser in +// the first place. The shell carries zero data — every value is fetched +// client-side from /admin/api/*, which stay fully gated below — so there +// is nothing to protect by gating the page itself. adminEnabled(g.cfg) +// still governs whether GET /admin is reachable at all (disabled falls +// through to 404, unchanged). func (g *Gateway) handleAdmin(sw *statusTrackingWriter, r *http.Request) { + if r.URL.Path == adminPagePath { + g.serveAdminPage(sw) + return + } + g.handleAdminAPI(sw, r) +} + +// handleAdminAPI is the gate for the two /admin/api/* JSON routes, +// applying spec §4's gate order: unauthenticated → 401, authenticated +// non-admin → 403, admin → serve. Like every other authenticated route +// it counts request counters via checkAndCount before serving: an admin +// over their own req/min limit gets a 429 here exactly as they would on +// any other route (spec §4's "Accounting: admin routes count request +// counters like any authed route"). +func (g *Gateway) handleAdminAPI(sw *statusTrackingWriter, r *http.Request) { u, grp, ok := g.auth.identify(r) g.logAuthEvent(ok, authEventUserName(u), r) if !ok { @@ -69,8 +92,6 @@ func (g *Gateway) handleAdmin(sw *statusTrackingWriter, r *http.Request) { } switch r.URL.Path { - case adminPagePath: - g.serveAdminPage(sw) case adminOverviewPath: g.serveAdminOverview(sw) case adminUsagePath: @@ -79,18 +100,59 @@ func (g *Gateway) handleAdmin(sw *statusTrackingWriter, r *http.Request) { } // serveAdminPage writes the single-page dashboard (adminPageHTML, below) -// with its Content-Security-Policy header. +// with its Content-Security-Policy header. Unauthenticated by design — +// see handleAdmin's doc comment. func (g *Gateway) serveAdminPage(w http.ResponseWriter) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Security-Policy", adminCSP) _, _ = io.WriteString(w, adminPageHTML) } +// setAdminJSONHeaders applies the response headers shared by both admin +// JSON endpoints (folded review item, 2026-08-20 review): the same CSP +// as the HTML page, X-Content-Type-Options: nosniff (a browser must +// never guess this response is anything other than the JSON it is +// declared as), and Cache-Control: no-store (every response is built +// fresh per request from live state and must never linger in a shared +// or disk cache). +func setAdminJSONHeaders(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Security-Policy", adminCSP) +} + +// sanitizeBaseURL strips a provider baseUrl's userinfo (username and +// password) and query string before admin.go ever echoes it (folded +// review item, 2026-08-20 review): an operator who embeds credentials in +// a baseUrl — "https://token@host" or "https://host?api-key=..." are +// both misconfigurations, but ones this dashboard must never amplify +// into a credential leak — must never see them reflected back. This +// strips the userinfo entirely rather than only the password the way +// net/url's own URL.Redacted() does: Redacted() would still leave a bare +// embedded username in place, and a bare username with no separate +// password is itself commonly how a token gets embedded in a URL. A raw +// value that fails to parse as a URL is returned unchanged — it names no +// scheme/host/userinfo net/url can identify, so there is nothing +// structured left to strip. +func sanitizeBaseURL(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return raw + } + u.User = nil + u.RawQuery = "" + u.Fragment = "" + return u.String() +} + // adminProviderView is one provider's read-only view in GET // /admin/api/overview (spec §4, v0.2). baseUrl is not secret — the // spec's NEVER-exposed list is API keys (not even digests), provider // keys, the redis password, and users-file path contents; a provider's -// base URL names no credential. +// base URL names no credential by itself, and any credential-shaped +// userinfo/query an operator mistakenly embedded in it is stripped by +// sanitizeBaseURL before this view is ever built. type adminProviderView struct { Name string `json:"name"` Type string `json:"type"` @@ -101,9 +163,19 @@ type adminProviderView struct { } // adminRedisView is the redis status line in GET /admin/api/overview. +// LastErrAt's json tag omits "omitempty": encoding/json never treats a +// struct value (time.Time) as "empty" regardless of its fields, so the +// tag would be a silent no-op — the same caveat Config's own Retry/Cache +// field doc comments already record (llmgateway.go). Its zero value +// ("0001-01-01T00:00:00Z") is the "no error recorded yet" sentinel, +// matching adminProviderView.LastRefresh's own convention; the dashboard +// renders it as "last error (Ns ago): ..." (folded review item, +// 2026-08-20 review) so a single transient blip does not read as +// currently-broken long after it recovered. type adminRedisView struct { - LastErr string `json:"lastErr,omitempty"` - Configured bool `json:"configured"` + LastErrAt time.Time `json:"lastErrAt"` + LastErr string `json:"lastErr,omitempty"` + Configured bool `json:"configured"` } // adminCacheView is the cache config summary in GET /admin/api/overview — @@ -143,14 +215,14 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse { providers[i] = adminProviderView{ Name: s.name, Type: s.typeName, - BaseURL: s.baseURL, + BaseURL: sanitizeBaseURL(s.baseURL), ModelCount: s.modelCount, LastRefresh: s.lastRefresh, LastErr: s.lastErr, } } - configured, redisLastErr := g.limiter.redisStatus() + configured, redisLastErr, redisLastErrAt := g.limiter.redisStatus() cacheEnabled := g.cache != nil var ttl string @@ -166,7 +238,7 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse { return adminOverviewResponse{ Providers: providers, - Redis: adminRedisView{Configured: configured, LastErr: redisLastErr}, + Redis: adminRedisView{Configured: configured, LastErr: redisLastErr, LastErrAt: redisLastErrAt}, Cache: adminCacheView{Enabled: cacheEnabled, TTL: ttl}, Groups: groups, Version: pluginVersion, @@ -175,7 +247,7 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse { // serveAdminOverview writes buildAdminOverview's result as JSON. func (g *Gateway) serveAdminOverview(w http.ResponseWriter) { - w.Header().Set("Content-Type", "application/json") + setAdminJSONHeaders(w) _ = json.NewEncoder(w).Encode(g.buildAdminOverview()) } @@ -228,10 +300,11 @@ func usageEntryView(su scopeUsage, limits *LimitsConfig) adminUsageEntryView { // buildAdminUsage assembles adminUsageResponse: authStore.snapshot lists // every currently active user and every configured group (names, group // membership, and limits only — never a key or its digest), and -// limiter.currentUsage reads each one's six current-window counters in -// one read-only pass per list. Unlike buildLimitScopes (routes_ -// unified.go), this never omits an entity for having nil limits — the -// dashboard shows usage for every user and group, limited or not. +// limiter.currentUsage reads each one's six current-window counters — +// one batched round trip per scope (limits.go's storeGetMulti) rather +// than six separate ones. Unlike buildLimitScopes (routes_unified.go), +// this never omits an entity for having nil limits — the dashboard shows +// usage for every user and group, limited or not. func (g *Gateway) buildAdminUsage() adminUsageResponse { userSummaries, groupSummaries := g.auth.snapshot() @@ -262,19 +335,28 @@ func (g *Gateway) buildAdminUsage() adminUsageResponse { // serveAdminUsage writes buildAdminUsage's result as JSON. func (g *Gateway) serveAdminUsage(w http.ResponseWriter) { - w.Header().Set("Content-Type", "application/json") + setAdminJSONHeaders(w) _ = json.NewEncoder(w).Encode(g.buildAdminUsage()) } // adminPageHTML is the entire /admin single-page dashboard: markup, CSS, // and vanilla JS in one Go raw-string const. Yaegi interpretation forbids // the embed directive (no filesystem access from an interpreted plugin), -// so the page ships as source, exactly like every other Yaegi-compatible plugin's -// static assets. It polls adminOverviewPath and adminUsagePath every 5s -// via fetch, loads no external asset of any kind (matching adminCSP's -// default-src 'none'), and never uses innerHTML with server-provided -// strings — every dynamic value is written via textContent, so nothing -// the API returns is ever interpreted as markup. +// so the page ships as source, exactly like every other Yaegi-compatible +// plugin's static assets. It polls adminOverviewPath and adminUsagePath +// every 5s via fetch, loads no external asset of any kind (matching +// adminCSP's default-src 'none'), and never uses innerHTML with +// server-provided strings — every dynamic value is written via +// textContent, so nothing the API returns is ever interpreted as markup. +// +// Browser key entry (controller-approved amendment, 2026-08-20 review): +// the page itself carries no auth (see handleAdmin), so its script reads +// an admin API key from this tab's sessionStorage and sends it as +// "x-api-key" on every /admin/api/* fetch. Absent a stored key, or on any +// 401/403 response, it shows an inline key-entry form instead of the +// dashboard; a submitted key is kept only in sessionStorage — never in a +// cookie, localStorage, or any persistent store — so it disappears when +// the tab closes and is never sent anywhere but this page's own fetches. const adminPageHTML = ` @@ -323,14 +405,36 @@ const adminPageHTML = ` th { background: var(--head-bg); font-weight: 600; } .muted { color: var(--muted); } .err { color: var(--err); } + .hidden { display: none; } .infra-line { margin: .25rem 0; font-size: .85rem; } section { margin-bottom: 1.5rem; } + input, button { + font: inherit; + padding: .4rem .6rem; + border: 1px solid var(--border); + border-radius: .25rem; + background: var(--bg); + color: var(--fg); + } + button { cursor: pointer; }

LLM Gateway - Admin

loading...
+
+

Admin key required

+

Enter an admin API key. The browser keeps it only in this tab's sessionStorage: it is never written to disk and is sent only to this page's own /admin/api/* requests.

+
+ + +
+
+
+ + +