package traefikllmgateway import ( "encoding/json" "io" "net/http" "net/url" "time" ) // pluginVersion is the gateway's own build/plugin version string, surfaced // read-only in GET /admin/api/overview (spec §4, v0.2). Bumped alongside // the module's own release process — not read from anywhere else in the // package, since Yaegi interpretation has no build-info mechanism to pull // it from automatically. const pluginVersion = "0.2.0" // 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. 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 — // 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() } // 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"` } // 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"` } // 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"` Redis adminRedisView `json:"redis"` Cache adminCacheView `json:"cache"` } // 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: s.lastErr, } } configured, redisLastErr, redisLastErrAt := g.limiter.redisStatus() cacheEnabled := g.cache != nil var ttl string if cacheEnabled { ttl = g.cache.ttl.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} } return adminOverviewResponse{ Providers: providers, Redis: adminRedisView{Configured: configured, LastErr: redisLastErr, LastErrAt: redisLastErrAt}, Cache: adminCacheView{Enabled: cacheEnabled, TTL: ttl}, Groups: groups, 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 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() userScopes := make([]limitScope, len(userSummaries)) for i, us := range userSummaries { userScopes[i] = limitScope{kind: "user", id: us.name, limits: us.limits} } groupScopes := make([]limitScope, len(groupSummaries)) for i, gs := range groupSummaries { groupScopes[i] = limitScope{kind: "group", id: gs.name, limits: gs.limits} } userUsage := g.limiter.currentUsage(userScopes) groupUsage := g.limiter.currentUsage(groupScopes) 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.

`