From 547b61f5a9f05b64293a7bdd2f79d7c548113362 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Thu, 20 Aug 2026 19:26:22 +0100 Subject: [PATCH] feat: read-only admin dashboard and JSON API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET /admin (single-page dashboard), GET /admin/api/overview, and GET /admin/api/usage per spec §4: providers/redis/cache/groups summary and per-user/group current-window usage+limits. Gated by Config.Admin (default off, not registered when disabled) and UserConfig.Admin. Gate order: disabled -> 404 fall-through; unauthenticated -> 401; authenticated non-admin -> 403; admin -> serve, counted against the admin's own request limits like any other route. Adds small, read-only, mutex-guarded hooks: modelRegistry.snapshot(), limiter.currentUsage()/redisStatus(), authStore.snapshot(). No secret field (API key, digest, redis password) is ever read into any admin response type; verified by a canary-literal redaction test. HTML/CSS/JS ship as a single Go raw-string const (Yaegi forbids go:embed); confirmed interpretable via make yaegi-check. --- admin.go | 491 ++++++++++++++++++++++++++++++++++++++++++++++++++ admin_test.go | 489 +++++++++++++++++++++++++++++++++++++++++++++++++ auth.go | 53 +++++- limits.go | 74 +++++++- llmgateway.go | 31 ++++ registry.go | 66 ++++++- 6 files changed, 1198 insertions(+), 6 deletions(-) create mode 100644 admin.go create mode 100644 admin_test.go diff --git a/admin.go b/admin.go new file mode 100644 index 0000000..c518115 --- /dev/null +++ b/admin.go @@ -0,0 +1,491 @@ +package traefikllmgateway + +import ( + "encoding/json" + "io" + "net/http" + "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 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. +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 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"). +func (g *Gateway) handleAdmin(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 adminPagePath: + g.serveAdminPage(sw) + case adminOverviewPath: + g.serveAdminOverview(sw) + case adminUsagePath: + g.serveAdminUsage(sw) + } +} + +// serveAdminPage writes the single-page dashboard (adminPageHTML, below) +// with its Content-Security-Policy header. +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) +} + +// 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. +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. +type adminRedisView struct { + 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: s.baseURL, + ModelCount: s.modelCount, + LastRefresh: s.lastRefresh, + LastErr: s.lastErr, + } + } + + configured, redisLastErr := 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}, + Cache: adminCacheView{Enabled: cacheEnabled, TTL: ttl}, + Groups: groups, + Version: pluginVersion, + } +} + +// serveAdminOverview writes buildAdminOverview's result as JSON. +func (g *Gateway) serveAdminOverview(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + _ = 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 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. +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) { + w.Header().Set("Content-Type", "application/json") + _ = 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. +const adminPageHTML = ` + + + + +LLM Gateway - Admin + + + +

LLM Gateway - Admin

+
loading...
+ +
+

Providers

+ + +
NameTypeBase URLModelsLast refreshLast error
+
+ +
+

Infrastructure

+
+
+ +
+

Groups

+ + +
NameMembersLimitsreq/minreq/daytok/daytok/monthcost/daycost/month
+
+ +
+

Users

+ + +
NameGroupLimitsreq/minreq/daytok/daytok/monthcost/daycost/month
+
+ + + + +` diff --git a/admin_test.go b/admin_test.go new file mode 100644 index 0000000..77fed57 --- /dev/null +++ b/admin_test.go @@ -0,0 +1,489 @@ +package traefikllmgateway + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// newAdminTestConfig builds a Config with two providers ("zeta", "alpha" — +// deliberately out of sorted order, to prove snapshot sorting), two +// groups ("zgroup", "agroup", same reason), a non-admin user in one group +// and an admin user in the other, and Admin.Enabled true. Callers may +// mutate the returned Config before calling New. +func newAdminTestConfig() *Config { + cfg := CreateConfig() + cfg.Admin = &AdminConfig{Enabled: true} + cfg.Providers = map[string]*ProviderConfig{ + "zeta": {Type: "openai", BaseURL: "http://zeta.invalid", APIKey: "sk-zeta", Models: []string{"z-model"}}, + "alpha": {Type: "openai", BaseURL: "http://alpha.invalid", APIKey: "sk-alpha", Models: []string{"a-model-1", "a-model-2"}}, + } + cfg.Groups = map[string]*GroupConfig{ + "zgroup": {Limits: &LimitsConfig{RequestsPerDay: 500}}, + "agroup": {}, + } + cfg.Users = &UsersConfig{Inline: []*UserConfig{ + {Name: "alice", Group: "agroup", APIKey: "sk-alice", Limits: &LimitsConfig{RequestsPerDay: 100}}, + {Name: "admin1", Group: "zgroup", APIKey: "sk-admin1", Admin: true}, + }} + return cfg +} + +// newAdminGatewayHandle builds the Gateway from cfg and returns both the +// http.Handler ServeHTTP uses and the concrete *Gateway for tests that +// need to reach into its internals (limiter, registry, auth). +func newAdminGatewayHandle(t *testing.T, cfg *Config) (http.Handler, *Gateway) { + t.Helper() + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) }) + h, err := New(context.Background(), next, cfg, "llmgw") + if err != nil { + t.Fatalf("New: %v", err) + } + gw, ok := h.(*Gateway) + if !ok { + t.Fatal("handler is not *Gateway") + } + return h, gw +} + +func adminRequest(method, path, apiKey string) *http.Request { + req := httptest.NewRequest(method, path, nil) + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + return req +} + +// --- gate: disabled/unauth/non-admin/admin (spec §4) --- + +func TestAdmin_Disabled_FallsThroughTo404(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + cfg.Admin = nil // not registered at all + h, _ := newAdminGatewayHandle(t, cfg) + + for _, p := range []string{adminPagePath, adminOverviewPath, adminUsagePath} { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, p, "")) + if rec.Code != http.StatusNotFound { + t.Errorf("path %q: status = %d, want 404 (admin nil)", p, rec.Code) + } + } +} + +func TestAdmin_DisabledExplicitly_FallsThroughTo404(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + cfg.Admin = &AdminConfig{Enabled: false} + h, _ := newAdminGatewayHandle(t, cfg) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminPagePath, "sk-admin1")) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (admin disabled, even with a valid admin key)", rec.Code) + } +} + +func TestAdmin_Disabled_HonorsPassthroughUnknown(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + cfg.Admin = nil + cfg.PassthroughUnknown = true + h, _ := newAdminGatewayHandle(t, cfg) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminPagePath, "")) + // The stub "next" handler (newAdminGatewayHandle) answers 418 — proves + // /admin got no special-casing at all when disabled, it fell through + // exactly like any other unregistered path. + if rec.Code != http.StatusTeapot { + t.Fatalf("status = %d, want 418 from next handler (passthroughUnknown)", rec.Code) + } +} + +func TestAdmin_GateMatrix(t *testing.T) { + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + paths := []string{adminPagePath, adminOverviewPath, adminUsagePath} + for _, p := range paths { + t.Run(p+"/unauthenticated", func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, p, "")) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + assertOAIErrorEnvelope(t, rec) + }) + t.Run(p+"/authenticated_non_admin", func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, p, "sk-alice")) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + assertOAIErrorEnvelope(t, rec) + }) + t.Run(p+"/admin", func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, p, "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + }) + } +} + +// assertOAIErrorEnvelope checks rec's body decodes as writeOAIError's +// envelope shape. +func assertOAIErrorEnvelope(t *testing.T, rec *httptest.ResponseRecorder) { + t.Helper() + var body struct { + Error struct { + Message string `json:"message"` + Type string `json:"type"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode error envelope: %v (body=%s)", err, rec.Body.String()) + } + if body.Error.Message == "" { + t.Error("error envelope has empty message") + } +} + +// --- overview: shape, sorting, provider snapshot correctness --- + +func TestAdminOverview_SortedShapeAndVersion(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminOverviewPath, "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + + var got adminOverviewResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + + if got.Version != pluginVersion { + t.Errorf("version = %q, want %q", got.Version, pluginVersion) + } + + if len(got.Providers) != 2 || got.Providers[0].Name != "alpha" || got.Providers[1].Name != "zeta" { + t.Fatalf("providers not sorted by name: %+v", got.Providers) + } + if got.Providers[0].Type != "openai" || got.Providers[0].BaseURL != "http://alpha.invalid" { + t.Errorf("alpha provider view = %+v, want type openai baseUrl http://alpha.invalid", got.Providers[0]) + } + if got.Providers[0].ModelCount != 2 { + t.Errorf("alpha modelCount = %d, want 2 (a-model-1, a-model-2)", got.Providers[0].ModelCount) + } + if got.Providers[1].ModelCount != 1 { + t.Errorf("zeta modelCount = %d, want 1 (z-model)", got.Providers[1].ModelCount) + } + if got.Providers[0].LastErr != "" || got.Providers[1].LastErr != "" { + t.Errorf("providers with discovery disabled must have empty lastErr, got %+v", got.Providers) + } + + if len(got.Groups) != 2 || got.Groups[0].Name != "agroup" || got.Groups[1].Name != "zgroup" { + t.Fatalf("groups not sorted by name: %+v", got.Groups) + } + if got.Groups[1].MemberCount != 1 { // admin1 + t.Errorf("zgroup memberCount = %d, want 1", got.Groups[1].MemberCount) + } + if got.Groups[0].MemberCount != 1 { // alice + t.Errorf("agroup memberCount = %d, want 1", got.Groups[0].MemberCount) + } + if got.Groups[1].Limits == nil || got.Groups[1].Limits.RequestsPerDay != 500 { + t.Errorf("zgroup limits = %+v, want RequestsPerDay 500", got.Groups[1].Limits) + } + + if got.Cache.Enabled { + t.Error("cache.enabled must be false: cfg.Cache was never configured") + } + if got.Redis.Configured { + t.Error("redis.configured must be false: cfg.Redis was never configured") + } +} + +func TestAdminOverview_ProviderLastErrAfterFailedRefresh(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + cfg := CreateConfig() + cfg.Admin = &AdminConfig{Enabled: true} + cfg.Providers = map[string]*ProviderConfig{ + "broken": {Type: "openai", BaseURL: srv.URL, APIKey: "sk", Discovery: true}, + } + cfg.Groups = map[string]*GroupConfig{"g": {}} + cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "admin1", Group: "g", APIKey: "sk-admin1", Admin: true}}} + + // newGateway's warmFill runs the first discovery fetch synchronously + // during New, so the failure is already recorded before ServeHTTP is + // ever called. + h, _ := newAdminGatewayHandle(t, cfg) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminOverviewPath, "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got adminOverviewResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Providers) != 1 { + t.Fatalf("providers = %+v, want 1 entry", got.Providers) + } + p := got.Providers[0] + if p.LastErr == "" { + t.Error("lastErr must be non-empty after a failed discovery refresh") + } + if p.ModelCount != 0 { + t.Errorf("modelCount = %d, want 0 (no explicit models, discovery failed)", p.ModelCount) + } + if p.LastRefresh.IsZero() { + t.Error("lastRefresh must be set even on a failed refresh (finishRefresh always advances it)") + } +} + +// --- usage: math against seeded counters --- + +func TestAdminUsage_MathAgainstSeededCounters(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, gw := newAdminGatewayHandle(t, cfg) + + fixedNow := time.Date(2026, 8, 20, 12, 30, 0, 0, time.UTC) + gw.limiter.nowFn = func() time.Time { return fixedNow } + + // Seed alice's (user) and agroup's (group) counters directly, bypassing + // checkAndCount/account's own request-shaped call pattern so the + // seeded values are exact and independent of admin1's own traffic. + gw.limiter.incrCounter("user", "alice", metricReq, windowMin, fixedNow, 3, minWindowTTL) + gw.limiter.incrCounter("user", "alice", metricReq, windowDay, fixedNow, 7, dayWindowTTL) + gw.limiter.account([]limitScope{{kind: "user", id: "alice"}}, usage{prompt: 40, completion: 10}, 2_500_000) + + gw.limiter.incrCounter("group", "agroup", metricReq, windowMin, fixedNow, 5, minWindowTTL) + gw.limiter.incrCounter("group", "agroup", metricReq, windowDay, fixedNow, 9, dayWindowTTL) + gw.limiter.account([]limitScope{{kind: "group", id: "agroup"}}, usage{prompt: 100, completion: 50}, 9_000_000) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsagePath, "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + + var got adminUsageResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + + alice := findUsageEntry(t, got.Users, "alice") + if alice.RequestsPerMinute != 3 || alice.RequestsPerDay != 7 { + t.Errorf("alice requests = min:%d day:%d, want min:3 day:7", alice.RequestsPerMinute, alice.RequestsPerDay) + } + if alice.TokensPerDay != 50 || alice.TokensPerMonth != 50 { + t.Errorf("alice tokens = day:%d month:%d, want 50/50", alice.TokensPerDay, alice.TokensPerMonth) + } + if alice.CostPerDayMicroUSD != 2_500_000 || alice.CostPerMonthMicroUSD != 2_500_000 { + t.Errorf("alice cost = day:%d month:%d, want 2500000/2500000", alice.CostPerDayMicroUSD, alice.CostPerMonthMicroUSD) + } + if alice.Limits == nil || alice.Limits.RequestsPerDay != 100 { + t.Errorf("alice limits = %+v, want RequestsPerDay 100 echoed", alice.Limits) + } + if alice.GroupName != "agroup" { + t.Errorf("alice groupName = %q, want agroup", alice.GroupName) + } + if alice.StoreDown { + t.Error("alice.storeDown must be false") + } + + agroup := findUsageEntry(t, got.Groups, "agroup") + if agroup.RequestsPerMinute != 5 || agroup.RequestsPerDay != 9 { + t.Errorf("agroup requests = min:%d day:%d, want min:5 day:9", agroup.RequestsPerMinute, agroup.RequestsPerDay) + } + if agroup.TokensPerDay != 150 || agroup.TokensPerMonth != 150 { + t.Errorf("agroup tokens = day:%d month:%d, want 150/150", agroup.TokensPerDay, agroup.TokensPerMonth) + } + if agroup.CostPerDayMicroUSD != 9_000_000 { + t.Errorf("agroup cost/day = %d, want 9000000", agroup.CostPerDayMicroUSD) + } + + // admin1 itself must also be listed (buildAdminUsage lists every + // active user/group, not just ones with seeded traffic): admin1 has + // no user-level limits configured, so buildLimitScopes never builds a + // "user"/admin1 scope for its own requests (ruling e) and its own + // counters stay at zero — but the row must still be present. + admin1 := findUsageEntry(t, got.Users, "admin1") + if admin1.RequestsPerMinute != 0 { + t.Errorf("admin1 requestsPerMinute = %d, want 0 (admin1 has no user-level limits, so its own requests never build a user scope)", admin1.RequestsPerMinute) + } + if admin1.StoreDown { + t.Error("admin1.storeDown must be false") + } +} + +func findUsageEntry(t *testing.T, entries []adminUsageEntryView, id string) adminUsageEntryView { + t.Helper() + for _, e := range entries { + if e.ID == id { + return e + } + } + t.Fatalf("no usage entry for id %q in %+v", id, entries) + return adminUsageEntryView{} +} + +// TestLimiterCurrentUsage_StoreDown drives limiter.currentUsage's +// fail-closed path directly: a configured store whose every operation +// errors, with failOpen false, must report storeDown and zero values +// rather than a silently wrong reading. alwaysErrStore/errStoreDownStub +// are defined in routes_unified_test.go. +func TestLimiterCurrentUsage_StoreDown(t *testing.T) { + t.Parallel() + l := newLimiter(alwaysErrStore{}, false) + scopes := []limitScope{{kind: "user", id: "x", limits: &LimitsConfig{RequestsPerMinute: 10}}} + + got := l.currentUsage(scopes) + if len(got) != 1 { + t.Fatalf("len(got) = %d, want 1", len(got)) + } + su := got[0] + if !su.storeDown { + t.Fatal("want storeDown true when the configured store errors and failOpen is false") + } + if su.requestsPerMinute != 0 || su.requestsPerDay != 0 || su.tokensPerDay != 0 || + su.tokensPerMonth != 0 || su.costPerDayMicros != 0 || su.costPerMonthMicros != 0 { + t.Errorf("storeDown entry must report zero values, got %+v", su) + } +} + +// --- rate limit applies to admin routes --- + +func TestAdmin_RateLimitApplies(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + cfg.Users.Inline[1].Limits = &LimitsConfig{RequestsPerMinute: 1} // admin1 + h, gw := newAdminGatewayHandle(t, cfg) + + fixedNow := time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC) + gw.limiter.nowFn = func() time.Time { return fixedNow } + + rec1 := httptest.NewRecorder() + h.ServeHTTP(rec1, adminRequest(http.MethodGet, adminOverviewPath, "sk-admin1")) + if rec1.Code != http.StatusOK { + t.Fatalf("1st request status = %d, want 200, body=%s", rec1.Code, rec1.Body.String()) + } + + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, adminRequest(http.MethodGet, adminOverviewPath, "sk-admin1")) + if rec2.Code != http.StatusTooManyRequests { + t.Fatalf("2nd request status = %d, want 429 (admin over req/min limit)", rec2.Code) + } + if rec2.Header().Get("Retry-After") == "" { + t.Error("429 response must carry a Retry-After header") + } +} + +// --- HTML page: CSP header + fetch URLs, no external assets --- + +func TestAdminPage_CSPHeaderAndFetchURLs(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminPagePath, "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") { + t.Errorf("content-type = %q, want text/html", ct) + } + wantCSP := "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'" + if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP { + t.Errorf("CSP = %q, want %q", got, wantCSP) + } + + body := rec.Body.String() + for _, want := range []string{adminOverviewPath, adminUsagePath, "setInterval"} { + if !strings.Contains(body, want) { + t.Errorf("admin page body missing %q", want) + } + } + if strings.Contains(body, "http://") || strings.Contains(body, "https://") { + t.Error("admin page must load zero external assets (no http(s):// reference of any kind)") + } +} + +// --- redaction: no secret literal, "apiKey", or key digest ever appears --- + +func TestAdmin_SecretRedaction(t *testing.T) { + t.Parallel() + const ( + providerKey = "SECRET-CANARY-PROVIDERKEY" + redisPass = "SECRET-CANARY-REDISPW" + aliceKey = "SECRET-CANARY-USERKEY" + adminKey = "SECRET-CANARY-ADMINKEY" + ) + + cfg := CreateConfig() + cfg.Admin = &AdminConfig{Enabled: true} + cfg.Providers = map[string]*ProviderConfig{ + "openai": {Type: "openai", BaseURL: "http://openai.invalid", APIKey: providerKey, Models: []string{"gpt-test"}}, + } + cfg.Redis = &RedisConfig{Address: "127.0.0.1:1", Password: redisPass} + cfg.Groups = map[string]*GroupConfig{"g": {}} + cfg.Users = &UsersConfig{Inline: []*UserConfig{ + {Name: "alice", Group: "g", APIKey: aliceKey, Limits: &LimitsConfig{}}, + {Name: "admin1", Group: "g", APIKey: adminKey, Admin: true}, + }} + + h, _ := newAdminGatewayHandle(t, cfg) + + overviewRec := httptest.NewRecorder() + h.ServeHTTP(overviewRec, adminRequest(http.MethodGet, adminOverviewPath, adminKey)) + if overviewRec.Code != http.StatusOK { + t.Fatalf("overview status = %d, want 200, body=%s", overviewRec.Code, overviewRec.Body.String()) + } + + usageRec := httptest.NewRecorder() + h.ServeHTTP(usageRec, adminRequest(http.MethodGet, adminUsagePath, adminKey)) + if usageRec.Code != http.StatusOK { + t.Fatalf("usage status = %d, want 200, body=%s", usageRec.Code, usageRec.Body.String()) + } + + canaries := []string{providerKey, redisPass, aliceKey, adminKey} + for _, name := range []string{"overview", "usage"} { + body := overviewRec.Body.String() + if name == "usage" { + body = usageRec.Body.String() + } + for _, secret := range canaries { + if strings.Contains(body, secret) { + t.Errorf("%s response leaks secret literal %q", name, secret) + } + digest := sha256.Sum256([]byte(secret)) + if strings.Contains(body, hex.EncodeToString(digest[:])) { + t.Errorf("%s response leaks sha256 digest of secret %q", name, secret) + } + } + if strings.Contains(strings.ToLower(body), "apikey") { + t.Errorf("%s response contains an apiKey field", name) + } + } +} diff --git a/auth.go b/auth.go index 568ca1c..d2fe148 100644 --- a/auth.go +++ b/auth.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "path" + "sort" "strings" "sync" "time" @@ -15,6 +16,10 @@ import ( type user struct { limits *LimitsConfig name, groupName string + // admin grants access to the read-only admin dashboard (spec §4, + // v0.2) — an admin user is otherwise ordinary: their own limits and + // group authorization still apply, including to the admin routes. + admin bool } // group describes a group's access rules and default limits. Empty @@ -189,7 +194,7 @@ func (a *authStore) buildEntry(uc *UserConfig) (*authEntry, error) { digest := sha256.Sum256([]byte(key)) return &authEntry{ digest: digest, - user: &user{limits: uc.Limits, name: uc.Name, groupName: uc.Group}, + user: &user{limits: uc.Limits, name: uc.Name, groupName: uc.Group, admin: uc.Admin}, group: grp, }, nil } @@ -287,3 +292,49 @@ func presentedKey(r *http.Request) (string, bool) { } return "", false } + +// userSummary and groupSummary are the read-only, redaction-safe listing +// authStore.snapshot returns for the admin dashboard (spec §4, v0.2): +// names, group membership, and limits only — never an API key or its +// digest, matching the package-wide "raw keys are never retained past +// construction" rule this file's own doc comment already states for +// byDigest itself. +type userSummary struct { + limits *LimitsConfig + name string + groupName string +} + +// groupSummary mirrors userSummary for one configured group, plus its +// current member count (how many active users, inline or file-sourced, +// currently reference it). +type groupSummary struct { + limits *LimitsConfig + name string + memberCount int +} + +// snapshot returns a sorted, redaction-safe listing of every currently +// active user and every configured group. a.mu is read-locked only for +// the byDigest walk, mirroring identify's own RLock usage; a.groups is +// built once by newAuthStore and never mutated afterward (see this +// file's authStore doc comment), so reading it needs no lock. +func (a *authStore) snapshot() ([]userSummary, []groupSummary) { + a.mu.RLock() + users := make([]userSummary, 0, len(a.byDigest)) + memberCounts := make(map[string]int, len(a.groups)) + for _, entry := range a.byDigest { + users = append(users, userSummary{limits: entry.user.limits, name: entry.user.name, groupName: entry.user.groupName}) + memberCounts[entry.user.groupName]++ + } + a.mu.RUnlock() + sort.Slice(users, func(i, j int) bool { return users[i].name < users[j].name }) + + groups := make([]groupSummary, 0, len(a.groups)) + for name, grp := range a.groups { + groups = append(groups, groupSummary{limits: grp.limits, name: name, memberCount: memberCounts[name]}) + } + sort.Slice(groups, func(i, j int) bool { return groups[i].name < groups[j].name }) + + return users, groups +} diff --git a/limits.go b/limits.go index 00338b1..c9f2c1a 100644 --- a/limits.go +++ b/limits.go @@ -266,8 +266,15 @@ type limiter struct { logf func(format string, args ...any) lastLogAt time.Time // guarded by logMu; last time a store error was logged lastStoreFailure time.Time // guarded by logMu; zero means the store-down latch is not open (see storeLatched) - logMu sync.Mutex - failOpen bool // store-error policy: true falls back to fallback, false refuses the request + // lastErrMsg is the message of the most recent store operation + // failure, guarded by logMu alongside lastStoreFailure. It is never + // cleared on a later success — "last store error" for the admin + // dashboard (spec §4, v0.2) means exactly that, a persisting fact, + // not "is the store currently failing" (storeLatched already answers + // that question for the enforcement path). + lastErrMsg string + logMu sync.Mutex + failOpen bool // store-error policy: true falls back to fallback, false refuses the request } // newLimiter returns a limiter. A nil store means every operation uses the @@ -328,9 +335,21 @@ func (l *limiter) recordStoreFailure(err error) { l.logStoreError(err) l.logMu.Lock() l.lastStoreFailure = l.now() + l.lastErrMsg = err.Error() l.logMu.Unlock() } +// redisStatus reports whether l has a configured (non-fallback-only) +// store, and that store's most recent operation failure message — the +// admin dashboard's redis status line (spec §4, v0.2). lastErr is "" when +// no store operation has ever failed. Guarded by logMu, the same mutex +// lastStoreFailure/lastErrMsg already use. +func (l *limiter) redisStatus() (configured bool, lastErr string) { + l.logMu.Lock() + defer l.logMu.Unlock() + return l.store != nil, l.lastErrMsg +} + // storeIncrBy increments key by n with the given ttl, applying the // limiter's fail-open/fail-closed policy when a configured store errors // or when the store-down latch (storeLatched) is already open from a @@ -602,3 +621,54 @@ func (l *limiter) account(scopes []limitScope, u usage, costMicros int64) { } } } + +// scopeUsage is one scope's (a user's or a group's) current-window +// counter values — the admin dashboard's usage table (spec §4, v0.2). The +// six value fields mirror LimitsConfig's six limit kinds exactly, so the +// admin API can echo a usage value beside its matching limit. +type scopeUsage struct { + kind string // "user" or "group", mirroring limitScope.kind + id string + requestsPerMinute int64 + requestsPerDay int64 + tokensPerDay int64 + tokensPerMonth int64 + costPerDayMicros int64 + costPerMonthMicros int64 + // storeDown reports whether reading any of the six counters above + // failed closed (a configured store errored and failOpen is false) — + // mirrors limitViolation.storeDown. Every value field is 0 in that + // case; a caller must not present them as "confirmed zero usage". + storeDown bool +} + +// currentUsage reads every scope's six current-window counters — the +// same (kind, id, metric, window) combinations checkAndCount/account +// already write — via the limiter's own getCounter, so it applies the +// identical fail-open/fail-closed policy every enforcement read already +// does. It is read-only: unlike checkAndCount, it never increments +// anything. Order is preserved: currentUsage(scopes)[i] corresponds to +// scopes[i]. +func (l *limiter) currentUsage(scopes []limitScope) []scopeUsage { + now := l.now() + out := make([]scopeUsage, len(scopes)) + for i, sc := range scopes { + reqMin, ok1 := l.getCounter(sc.kind, sc.id, metricReq, windowMin, now) + reqDay, ok2 := l.getCounter(sc.kind, sc.id, metricReq, windowDay, now) + tokDay, ok3 := l.getCounter(sc.kind, sc.id, metricTok, windowDay, now) + tokMonth, ok4 := l.getCounter(sc.kind, sc.id, metricTok, windowMonth, now) + costDay, ok5 := l.getCounter(sc.kind, sc.id, metricCost, windowDay, now) + costMonth, ok6 := l.getCounter(sc.kind, sc.id, metricCost, windowMonth, now) + if !ok1 || !ok2 || !ok3 || !ok4 || !ok5 || !ok6 { + out[i] = scopeUsage{kind: sc.kind, id: sc.id, storeDown: true} + continue + } + out[i] = scopeUsage{ + kind: sc.kind, id: sc.id, + requestsPerMinute: reqMin, requestsPerDay: reqDay, + tokensPerDay: tokDay, tokensPerMonth: tokMonth, + costPerDayMicros: costDay, costPerMonthMicros: costMonth, + } + } + return out +} diff --git a/llmgateway.go b/llmgateway.go index eac05a0..1a19f8a 100644 --- a/llmgateway.go +++ b/llmgateway.go @@ -22,6 +22,11 @@ type Config struct { Agents map[string]*AgentConfig `json:"agents,omitempty"` Users *UsersConfig `json:"users,omitempty"` Redis *RedisConfig `json:"redis,omitempty"` + // Admin gates the read-only admin dashboard (spec §4, v0.2): nil or + // Admin.Enabled false means the /admin* routes are not registered at + // all — ServeHTTP falls through to its existing 404/passthroughUnknown + // handling for those paths, preserving v0.1 behavior exactly. + Admin *AdminConfig `json:"admin,omitempty"` // Retry is a struct value, not a pointer, because its own Enabled // field is the on/off signal (unlike Redis/Users, where the block's // mere presence is the signal) — so its tag omits "omitempty": @@ -84,6 +89,13 @@ type UserConfig struct { Name string `json:"name"` Group string `json:"group"` APIKey string `json:"apiKey"` + // Admin grants this user access to the read-only admin dashboard + // (spec §4, v0.2), whether the user is inline or file-sourced — + // file-users granting admin is operator-controlled via the Secret + // backing Users.File, which is an accepted trust boundary. An admin + // user is otherwise an ordinary user: their own keys, group, and + // limits still apply, including to the admin routes themselves. + Admin bool `json:"admin,omitempty"` } // RedisConfig configures the distributed limit-state backend. @@ -136,6 +148,15 @@ type CacheConfig struct { Enabled bool `json:"enabled,omitempty"` } +// AdminConfig configures the read-only admin dashboard (spec §4, v0.2). +// The zero value (Enabled: false) disables it, preserving v0.1 behavior +// exactly: no /admin* route is registered, so those paths fall through +// to ServeHTTP's existing 404/passthroughUnknown handling like any other +// unrecognized path. +type AdminConfig struct { + Enabled bool `json:"enabled,omitempty"` +} + // ModelPricing overrides the built-in per-model price table. type ModelPricing struct { InputPerM float64 `json:"inputPerM"` @@ -383,6 +404,16 @@ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Admin routes (spec §4, v0.2) are matched only when adminEnabled: a + // nil or disabled Config.Admin means none of these paths are + // registered at all, so a request to /admin* falls through to the + // same 404/passthroughUnknown handling as any other unrecognized + // path — no special-casing needed for the disabled case. + if adminEnabled(g.cfg) && r.Method == http.MethodGet && isAdminPath(r.URL.Path) { + g.handleAdmin(sw, r) + return + } + if r.Method == http.MethodGet && (r.URL.Path == "/v1/mcp/servers" || r.URL.Path == "/v1/agents") { u, grp, ok := g.auth.identify(r) g.logAuthEvent(ok, authEventUserName(u), r) diff --git a/registry.go b/registry.go index 1e72dee..21f232d 100644 --- a/registry.go +++ b/registry.go @@ -42,9 +42,17 @@ var errModelDenied = errors.New("llmgateway: model access denied") // stale-while-error — a failed refresh leaves the previous discovered set // in place rather than clearing it. mu guards every mutable field below it. type providerState struct { - lastRefresh time.Time - explicit map[string]bool - discovered map[string]bool + lastRefresh time.Time + explicit map[string]bool + discovered map[string]bool + // lastErr is the most recent finishRefresh call's error message, or + // "" when that call succeeded (or discovery is disabled and + // finishRefresh was never called at all). Read by snapshot for the + // admin dashboard (spec §4, v0.2) — it reflects the latest attempt's + // outcome, not the stale-while-error discovered set: a provider can + // show a non-empty lastErr while modelCount still reflects its last + // successful discovery. + lastErr string interval time.Duration mu sync.Mutex discoveryEnabled bool @@ -106,8 +114,10 @@ func (st *providerState) finishRefresh(now time.Time, ids []string, err error) { st.inFlight = false st.lastRefresh = now if err != nil { + st.lastErr = err.Error() return } + st.lastErr = "" discovered := make(map[string]bool, len(ids)) for _, id := range ids { discovered[id] = true @@ -115,6 +125,22 @@ func (st *providerState) finishRefresh(now time.Time, ids []string, err error) { st.discovered = discovered } +// snapshot returns st's read-only view for the admin dashboard (spec §4, +// v0.2): its known model count (explicit ∪ discovered, mirroring +// knownIDs), last refresh time, and last refresh error message. +func (st *providerState) snapshot() (modelCount int, lastRefresh time.Time, lastErr string) { + st.mu.Lock() + defer st.mu.Unlock() + set := make(map[string]bool, len(st.explicit)+len(st.discovered)) + for id := range st.explicit { + set[id] = true + } + for id := range st.discovered { + set[id] = true + } + return len(set), st.lastRefresh, st.lastErr +} + // modelRegistry aggregates every configured provider's explicit and // discovered model ids into the gateway's model-resolution and /v1/models // listing surfaces. adapters, providerNames, and states are all fixed at @@ -443,3 +469,37 @@ func (m *modelRegistry) listFor(grp *group) []map[string]any { func modelObject(id, ownedBy string) map[string]any { return map[string]any{"id": id, "object": "model", "owned_by": ownedBy} } + +// providerSnapshot is one provider's read-only view for the admin +// dashboard (spec §4, v0.2). baseURL is not secret (the spec's NEVER- +// exposed list is API keys, digests, redis password, and users-file path +// contents only) — it is included so an operator can see which upstream +// a provider actually targets. +type providerSnapshot struct { + lastRefresh time.Time + name string + typeName string + baseURL string + lastErr string + modelCount int +} + +// snapshot returns every configured provider's read-only view, sorted by +// name (m.providerNames is already sorted at construction) — the admin +// dashboard's provider table (spec §4, v0.2). +func (m *modelRegistry) snapshot() []providerSnapshot { + out := make([]providerSnapshot, 0, len(m.providerNames)) + for _, name := range m.providerNames { + adapter := m.adapters[name] + modelCount, lastRefresh, lastErr := m.states[name].snapshot() + out = append(out, providerSnapshot{ + name: adapter.name(), + typeName: adapter.typeName(), + baseURL: adapter.base(), + modelCount: modelCount, + lastRefresh: lastRefresh, + lastErr: lastErr, + }) + } + return out +}