package traefikllmgateway import ( "encoding/json" "io" "net/http" "net/url" "strings" "time" ) // The three routes the read-only admin dashboard registers (spec §4, // v0.2), matched only when adminEnabled(g.cfg) — see llmgateway.go's // ServeHTTP dispatch. const ( adminPagePath = "/admin" adminOverviewPath = "/admin/api/overview" adminUsagePath = "/admin/api/usage" ) // 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. frame-ancestors/base-uri/ // form-action are all 'none' (v0.2 final review wave, 2026-08-20): the // page carries a password-type key-entry input, so it must never be // embeddable in another site's frame (clickjacking), never have its // href hijacked to retarget a relative script/fetch URL, and never // have its auth-form submitted anywhere but nowhere at all — the form's // own submit handler already intercepts and cancels the real submit // (adminPageHTML's "submit" listener calls preventDefault()), so // form-action has nothing legitimate to allow. const adminCSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" // adminEnabled reports whether cfg's Admin block is present and enabled — // the single gate ServeHTTP checks before matching any /admin* route at // all. A nil or disabled block means those paths are simply not // registered, so they fall through to ServeHTTP's existing 404/ // passthroughUnknown handling, exactly like any other unrecognized path. func adminEnabled(cfg *Config) bool { return cfg.Admin != nil && cfg.Admin.Enabled } // isAdminPath reports whether path is one of the three admin routes. func isAdminPath(path string) bool { return path == adminPagePath || path == adminOverviewPath || path == adminUsagePath } // 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 { writeOAIError(sw, http.StatusUnauthorized, "authentication_error", "invalid or missing API key") return } if !u.admin { writeOAIError(sw, http.StatusForbidden, "invalid_request_error", "admin access required") return } if violation := g.limiter.checkAndCount(buildLimitScopes(u, grp)); violation != nil { writeLimitViolation(sw, violation) return } switch r.URL.Path { case adminOverviewPath: g.serveAdminOverview(sw) case adminUsagePath: g.serveAdminUsage(sw) } } // serveAdminPage writes the single-page dashboard (adminPageHTML, below) // 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() } // sanitizeProviderErr applies sanitizeBaseURL's userinfo/query-stripping // treatment to lastErr before it is ever echoed by GET /admin/api/overview // (v0.2 final review wave, 2026-08-20): a provider's discovery/refresh // error commonly embeds the request URL it failed against (Go's // net/http wraps a *url.Error carrying it verbatim), so a baseUrl // misconfigured with embedded credentials would otherwise leak straight // back out through this field, even though adminProviderView.BaseURL // itself is already sanitized. // // This is best-effort, not a general URL scrubber: it looks only for // rawBaseURL appearing verbatim inside lastErr and replaces every such // occurrence with sanitizeBaseURL's cleaned form. An error naming some // OTHER URL — a redirect target, a differently-formatted variant of the // same host — is not caught; recognizing every URL shape a wrapped error // might embed, without risking mangling ordinary error text, is not // attempted here. func sanitizeProviderErr(lastErr, rawBaseURL string) string { if lastErr == "" || rawBaseURL == "" || !strings.Contains(lastErr, rawBaseURL) { return lastErr } return strings.ReplaceAll(lastErr, rawBaseURL, sanitizeBaseURL(rawBaseURL)) } // 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 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"` BaseURL string `json:"baseUrl"` LastRefresh time.Time `json:"lastRefresh"` LastErr string `json:"lastErr,omitempty"` ModelCount int `json:"modelCount"` } // 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 { 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 — // enabled/ttl only, per spec §4: no live hit counters (the spec makes no // such promise). type adminCacheView struct { TTL string `json:"ttl,omitempty"` Enabled bool `json:"enabled"` } // adminRetryView is the retry config summary in GET /admin/api/overview // (v0.2 final review wave, 2026-08-20): the EFFECTIVE enabled/attempts/ // backoff a `retry: {enabled: true}` block resolves to, after // newRetryPolicy's own zero-value defaulting (retry.go) — not the raw, // possibly-empty RetryConfig fields, so an operator sees what actually // governs upstream calls, not just what they left unset. Attempts and // Backoff are both omitted (zero value / empty string) when retry is // disabled, mirroring adminCacheView's own omitempty convention for a // disabled feature's now-meaningless detail fields. type adminRetryView struct { Backoff string `json:"backoff,omitempty"` Enabled bool `json:"enabled"` Attempts int `json:"attempts,omitempty"` } // adminGroupView is one group's summary in GET /admin/api/overview. type adminGroupView struct { Limits *LimitsConfig `json:"limits,omitempty"` Name string `json:"name"` MemberCount int `json:"memberCount"` } // adminAliasView is one configured model alias's row in GET // /admin/api/overview (spec §5, v0.2): the alias->target pair exactly as // an operator wrote it. No secrets involved. type adminAliasView struct { Alias string `json:"alias"` Target string `json:"target"` } // adminOverviewResponse is the full body of GET /admin/api/overview (spec // §4, v0.2). Every slice is sorted and built fresh per request — no // caching of the response itself — so the dashboard's 5s poll always // reflects current state. type adminOverviewResponse struct { Version string `json:"version"` Providers []adminProviderView `json:"providers"` Groups []adminGroupView `json:"groups"` Aliases []adminAliasView `json:"aliases"` Redis adminRedisView `json:"redis"` Cache adminCacheView `json:"cache"` Retry adminRetryView `json:"retry"` } // buildAdminOverview assembles adminOverviewResponse from the registry, // limiter, cache, and authStore read hooks — nothing here mutates any of // them. func (g *Gateway) buildAdminOverview() adminOverviewResponse { snaps := g.registry.snapshot() providers := make([]adminProviderView, len(snaps)) for i, s := range snaps { providers[i] = adminProviderView{ Name: s.name, Type: s.typeName, BaseURL: sanitizeBaseURL(s.baseURL), ModelCount: s.modelCount, LastRefresh: s.lastRefresh, LastErr: sanitizeProviderErr(s.lastErr, s.baseURL), } } configured, redisLastErr, redisLastErrAt := g.limiter.redisStatus() cacheEnabled := g.cache != nil var ttl string if cacheEnabled { ttl = g.cache.ttl.String() } // newRetryPolicy is pure (no I/O) and already ran once, successfully, // when this Gateway was constructed (providers.go's buildAdapters) — // g.cfg.Retry cannot have changed since a running Gateway never // mutates its own config, so this second call is guaranteed to return // the identical result with a nil error; the error return is // intentionally discarded rather than propagated. rp, _ := newRetryPolicy(g.cfg.Retry) retryView := adminRetryView{Enabled: rp.enabled} if rp.enabled { retryView.Attempts = rp.attempts retryView.Backoff = rp.backoff.String() } _, groupSummaries := g.auth.snapshot() groups := make([]adminGroupView, len(groupSummaries)) for i, gs := range groupSummaries { groups[i] = adminGroupView{Name: gs.name, Limits: gs.limits, MemberCount: gs.memberCount} } aliasSnaps := g.registry.aliasSnapshot() aliases := make([]adminAliasView, len(aliasSnaps)) for i, a := range aliasSnaps { aliases[i] = adminAliasView(a) // identical underlying field shape (alias, target string), differing only in json tags } return adminOverviewResponse{ Providers: providers, Redis: adminRedisView{Configured: configured, LastErr: redisLastErr, LastErrAt: redisLastErrAt}, Cache: adminCacheView{Enabled: cacheEnabled, TTL: ttl}, Retry: retryView, Groups: groups, Aliases: aliases, Version: pluginVersion, } } // serveAdminOverview writes buildAdminOverview's result as JSON. func (g *Gateway) serveAdminOverview(w http.ResponseWriter) { setAdminJSONHeaders(w) _ = json.NewEncoder(w).Encode(g.buildAdminOverview()) } // adminUsageEntryView is one user's or group's row in GET // /admin/api/usage (spec §4, v0.2): its current-window counter values, // with its configured limit echoed beside each. GroupName is set only for // a user entry (empty for a group entry) — the group a user currently // belongs to, for display; it carries no access-control meaning of its // own. type adminUsageEntryView struct { Limits *LimitsConfig `json:"limits,omitempty"` Kind string `json:"kind"` ID string `json:"id"` GroupName string `json:"groupName,omitempty"` RequestsPerMinute int64 `json:"requestsPerMinute"` RequestsPerDay int64 `json:"requestsPerDay"` TokensPerDay int64 `json:"tokensPerDay"` TokensPerMonth int64 `json:"tokensPerMonth"` CostPerDayMicroUSD int64 `json:"costPerDayMicroUsd"` CostPerMonthMicroUSD int64 `json:"costPerMonthMicroUsd"` StoreDown bool `json:"storeDown,omitempty"` } // adminUsageResponse is the full body of GET /admin/api/usage: every // currently active user, and every configured group, each with its own // usage+limits row. Both slices are sorted (authStore.snapshot's own // contract) and built fresh per request. type adminUsageResponse struct { Users []adminUsageEntryView `json:"users"` Groups []adminUsageEntryView `json:"groups"` } // usageEntryView converts one limiter.currentUsage result plus its // matching limit into the JSON view. func usageEntryView(su scopeUsage, limits *LimitsConfig) adminUsageEntryView { return adminUsageEntryView{ Kind: su.kind, ID: su.id, Limits: limits, RequestsPerMinute: su.requestsPerMinute, RequestsPerDay: su.requestsPerDay, TokensPerDay: su.tokensPerDay, TokensPerMonth: su.tokensPerMonth, CostPerDayMicroUSD: su.costPerDayMicros, CostPerMonthMicroUSD: su.costPerMonthMicros, StoreDown: su.storeDown, } } // 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 every one's six current-window counters in // ONE storeGetMulti round trip total (v0.2 final review wave, 2026-08-20; // see limiter.currentUsage's own doc comment for the tradeoff this // supersedes) — users and groups are concatenated into a single scopes // slice before that one call, then the flat result is sliced back into // the two response sections at the same split point, order preserved. // 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() scopes := make([]limitScope, 0, len(userSummaries)+len(groupSummaries)) for _, us := range userSummaries { scopes = append(scopes, limitScope{kind: "user", id: us.name, limits: us.limits}) } for _, gs := range groupSummaries { scopes = append(scopes, limitScope{kind: "group", id: gs.name, limits: gs.limits}) } allUsage := g.limiter.currentUsage(scopes) userUsage, groupUsage := allUsage[:len(userSummaries)], allUsage[len(userSummaries):] users := make([]adminUsageEntryView, len(userUsage)) for i, su := range userUsage { users[i] = usageEntryView(su, userSummaries[i].limits) users[i].GroupName = userSummaries[i].groupName } groups := make([]adminUsageEntryView, len(groupUsage)) for i, su := range groupUsage { groups[i] = usageEntryView(su, groupSummaries[i].limits) } return adminUsageResponse{Users: users, Groups: groups} } // serveAdminUsage writes buildAdminUsage's result as JSON. func (g *Gateway) serveAdminUsage(w http.ResponseWriter) { 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. // // 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 = ` LLM Gateway - Admin

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.

`