mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
feat: read-only admin dashboard and JSON API
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.
This commit is contained in:
@@ -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 = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>LLM Gateway - Admin</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #ffffff;
|
||||
--fg: #1a1a1a;
|
||||
--muted: #666666;
|
||||
--border: #d0d0d0;
|
||||
--head-bg: #f2f2f2;
|
||||
--err: #b91c1c;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111214;
|
||||
--fg: #e6e6e6;
|
||||
--muted: #9a9a9a;
|
||||
--border: #33363a;
|
||||
--head-bg: #1c1e21;
|
||||
--err: #f87171;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 1.5rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
h1 { font-size: 1.25rem; margin: 0 0 .25rem; }
|
||||
h2 { font-size: 1rem; margin: 1.5rem 0 .5rem; }
|
||||
#status-line { color: var(--muted); margin-bottom: 1rem; font-size: .85rem; }
|
||||
table { border-collapse: collapse; width: 100%; margin-bottom: .5rem; }
|
||||
th, td {
|
||||
border: 1px solid var(--border);
|
||||
padding: .4rem .6rem;
|
||||
text-align: left;
|
||||
font-size: .85rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
th { background: var(--head-bg); font-weight: 600; }
|
||||
.muted { color: var(--muted); }
|
||||
.err { color: var(--err); }
|
||||
.infra-line { margin: .25rem 0; font-size: .85rem; }
|
||||
section { margin-bottom: 1.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>LLM Gateway - Admin</h1>
|
||||
<div id="status-line">loading...</div>
|
||||
|
||||
<section>
|
||||
<h2>Providers</h2>
|
||||
<table id="providers"><thead><tr>
|
||||
<th>Name</th><th>Type</th><th>Base URL</th><th>Models</th><th>Last refresh</th><th>Last error</th>
|
||||
</tr></thead><tbody></tbody></table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Infrastructure</h2>
|
||||
<div id="infra"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Groups</h2>
|
||||
<table id="groups"><thead><tr>
|
||||
<th>Name</th><th>Members</th><th>Limits</th><th>req/min</th><th>req/day</th><th>tok/day</th><th>tok/month</th><th>cost/day</th><th>cost/month</th>
|
||||
</tr></thead><tbody></tbody></table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Users</h2>
|
||||
<table id="users"><thead><tr>
|
||||
<th>Name</th><th>Group</th><th>Limits</th><th>req/min</th><th>req/day</th><th>tok/day</th><th>tok/month</th><th>cost/day</th><th>cost/month</th>
|
||||
</tr></thead><tbody></tbody></table>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var POLL_MS = 5000;
|
||||
var OVERVIEW_URL = "/admin/api/overview";
|
||||
var USAGE_URL = "/admin/api/usage";
|
||||
var ZERO_TIME = "0001-01-01T00:00:00Z";
|
||||
var groupMeta = {};
|
||||
|
||||
function el(tag, text, cls) {
|
||||
var e = document.createElement(tag);
|
||||
if (text !== undefined && text !== null) e.textContent = text;
|
||||
if (cls) e.className = cls;
|
||||
return e;
|
||||
}
|
||||
|
||||
function fmtCost(micros) {
|
||||
return "$" + (micros / 1000000).toFixed(4);
|
||||
}
|
||||
|
||||
function fmtLimits(l) {
|
||||
if (!l) return "none";
|
||||
var parts = [];
|
||||
if (l.requestsPerMinute) parts.push("req/min " + l.requestsPerMinute);
|
||||
if (l.requestsPerDay) parts.push("req/day " + l.requestsPerDay);
|
||||
if (l.tokensPerDay) parts.push("tok/day " + l.tokensPerDay);
|
||||
if (l.tokensPerMonth) parts.push("tok/month " + l.tokensPerMonth);
|
||||
if (l.costPerDayUSD) parts.push("cost/day $" + l.costPerDayUSD);
|
||||
if (l.costPerMonthUSD) parts.push("cost/month $" + l.costPerMonthUSD);
|
||||
return parts.length ? parts.join(", ") : "none";
|
||||
}
|
||||
|
||||
function setRows(tableId, rows, buildRow) {
|
||||
var tbody = document.querySelector("#" + tableId + " tbody");
|
||||
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
||||
if (!rows || rows.length === 0) {
|
||||
var tr = document.createElement("tr");
|
||||
tr.appendChild(el("td", "none", "muted"));
|
||||
tbody.appendChild(tr);
|
||||
return;
|
||||
}
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
tbody.appendChild(buildRow(rows[i]));
|
||||
}
|
||||
}
|
||||
|
||||
function renderOverview(data) {
|
||||
setRows("providers", data.providers, function (p) {
|
||||
var tr = document.createElement("tr");
|
||||
tr.appendChild(el("td", p.name));
|
||||
tr.appendChild(el("td", p.type));
|
||||
tr.appendChild(el("td", p.baseUrl));
|
||||
tr.appendChild(el("td", String(p.modelCount)));
|
||||
tr.appendChild(el("td", p.lastRefresh && p.lastRefresh !== ZERO_TIME ? p.lastRefresh : "never"));
|
||||
tr.appendChild(el("td", p.lastErr || "", p.lastErr ? "err" : "muted"));
|
||||
return tr;
|
||||
});
|
||||
|
||||
var infra = document.getElementById("infra");
|
||||
while (infra.firstChild) infra.removeChild(infra.firstChild);
|
||||
var redisText = "Redis: " + (data.redis.configured ? "configured" : "not configured");
|
||||
if (data.redis.lastErr) redisText += " - last error: " + data.redis.lastErr;
|
||||
infra.appendChild(el("div", redisText, "infra-line"));
|
||||
var cacheText = "Cache: " + (data.cache.enabled ? "enabled (ttl " + data.cache.ttl + ")" : "disabled");
|
||||
infra.appendChild(el("div", cacheText, "infra-line"));
|
||||
infra.appendChild(el("div", "Version: " + data.version, "infra-line muted"));
|
||||
|
||||
groupMeta = {};
|
||||
(data.groups || []).forEach(function (g) {
|
||||
groupMeta[g.name] = g;
|
||||
});
|
||||
}
|
||||
|
||||
function renderUsageTable(tableId, entries, withGroupColumn) {
|
||||
setRows(tableId, entries, function (entry) {
|
||||
var tr = document.createElement("tr");
|
||||
if (withGroupColumn) {
|
||||
tr.appendChild(el("td", entry.id));
|
||||
tr.appendChild(el("td", entry.groupName || ""));
|
||||
} else {
|
||||
var meta = groupMeta[entry.id] || {};
|
||||
tr.appendChild(el("td", entry.id));
|
||||
tr.appendChild(el("td", meta.memberCount !== undefined ? String(meta.memberCount) : ""));
|
||||
}
|
||||
tr.appendChild(el("td", fmtLimits(entry.limits)));
|
||||
tr.appendChild(el("td", entry.storeDown ? "?" : String(entry.requestsPerMinute)));
|
||||
tr.appendChild(el("td", entry.storeDown ? "?" : String(entry.requestsPerDay)));
|
||||
tr.appendChild(el("td", entry.storeDown ? "?" : String(entry.tokensPerDay)));
|
||||
tr.appendChild(el("td", entry.storeDown ? "?" : String(entry.tokensPerMonth)));
|
||||
tr.appendChild(el("td", entry.storeDown ? "?" : fmtCost(entry.costPerDayMicroUsd)));
|
||||
tr.appendChild(el("td", entry.storeDown ? "?" : fmtCost(entry.costPerMonthMicroUsd)));
|
||||
if (entry.storeDown) tr.className = "err";
|
||||
return tr;
|
||||
});
|
||||
}
|
||||
|
||||
function renderUsage(data) {
|
||||
renderUsageTable("groups", data.groups || [], false);
|
||||
renderUsageTable("users", data.users || [], true);
|
||||
}
|
||||
|
||||
function setStatus(text, isErr) {
|
||||
var s = document.getElementById("status-line");
|
||||
s.textContent = text;
|
||||
s.className = isErr ? "err" : "";
|
||||
}
|
||||
|
||||
function fetchJSON(url) {
|
||||
return fetch(url, { credentials: "same-origin" }).then(function (resp) {
|
||||
if (!resp.ok) throw new Error(url + ": HTTP " + resp.status);
|
||||
return resp.json();
|
||||
});
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
Promise.all([fetchJSON(OVERVIEW_URL), fetchJSON(USAGE_URL)]).then(function (results) {
|
||||
renderOverview(results[0]);
|
||||
renderUsage(results[1]);
|
||||
setStatus("last updated " + new Date().toLocaleTimeString(), false);
|
||||
}).catch(function (err) {
|
||||
setStatus("refresh failed: " + err.message, true);
|
||||
});
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, POLL_MS);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
+489
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+63
-3
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user