mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
fix(admin): browser key entry, batched usage reads, hardened responses
Review fixes (2 Important + 4 folded): 1. GET /admin now serves the HTML shell with no authentication - a browser navigating directly to the URL cannot attach a custom header. The page reads an admin key from sessionStorage and sends it as x-api-key on /admin/api/* fetches, falling back to an inline key-entry form on a missing key or any 401/403. Both JSON routes keep their original 401/403/200 gate. Spec ss4 amended in place. 2. GET /admin/api/usage no longer serializes 6 GETs per user/group scope on the shared Redis connection. respClient gains getBatch (one pipelined round trip); counterStore gains getMulti; limiter gains storeGetMulti mirroring storeGet's fail-open/fail-closed policy. currentUsage now issues one getMulti per scope. 3. Provider baseUrl is stripped of userinfo and query before it is ever echoed in the overview JSON (sanitizeBaseURL), stronger than net/url's own Redacted (which leaves a bare username in place). 4. limiter.redisStatus now also returns the last failure's timestamp, so the dashboard can render "last error (Ns ago)" instead of a bare message that reads as currently-broken indefinitely. 5. Both JSON admin responses now set X-Content-Type-Options: nosniff, Cache-Control: no-store, and the same CSP the HTML page uses. 6. Added a test proving a file-sourced (replaceFileUsers) admin user gets the same access an inline admin user does. go test ./... -count=1 -race, gofmt, vet, make yaegi-check all pass.
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -23,11 +24,12 @@ const (
|
||||
adminUsagePath = "/admin/api/usage"
|
||||
)
|
||||
|
||||
// adminCSP is the Content-Security-Policy header served with GET /admin
|
||||
// (spec §4, v0.2): no external assets of any kind, only this page's own
|
||||
// inline script/style, and fetch calls restricted to same-origin — the
|
||||
// dashboard works air-gapped and cannot be coerced into loading anything
|
||||
// off-host.
|
||||
// adminCSP is the Content-Security-Policy header served with all three
|
||||
// admin routes (spec §4, v0.2; extended to the two JSON routes by a
|
||||
// folded review item, 2026-08-20 review): no external assets of any
|
||||
// kind, only this page's own inline script/style, and fetch calls
|
||||
// restricted to same-origin — the dashboard works air-gapped and cannot
|
||||
// be coerced into loading anything off-host.
|
||||
const adminCSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'"
|
||||
|
||||
// adminEnabled reports whether cfg's Admin block is present and enabled —
|
||||
@@ -44,15 +46,36 @@ func isAdminPath(path string) bool {
|
||||
return path == adminPagePath || path == adminOverviewPath || path == adminUsagePath
|
||||
}
|
||||
|
||||
// handleAdmin is the shared entry point for all three /admin* routes,
|
||||
// applying spec §4's gate order: unauthenticated → 401, authenticated
|
||||
// non-admin → 403, admin → serve. It is called only when adminEnabled —
|
||||
// ServeHTTP's dispatch already checked that — and, like every other
|
||||
// authenticated route, counts request counters via checkAndCount before
|
||||
// serving: an admin over their own req/min limit gets a 429 here exactly
|
||||
// as they would on any other route (spec §4's "Accounting: admin routes
|
||||
// count request counters like any authed route").
|
||||
// handleAdmin is ServeHTTP's single entry point for all three /admin*
|
||||
// routes (spec §4, v0.2), called only when adminEnabled — ServeHTTP's
|
||||
// dispatch already checked that.
|
||||
//
|
||||
// GET /admin itself serves the HTML shell with no authentication at all
|
||||
// (controller-approved amendment to spec §4, 2026-08-20 review): a
|
||||
// browser navigating straight to a URL cannot attach a custom
|
||||
// Authorization or x-api-key header, so the original "401 when
|
||||
// unauthenticated" gate made the dashboard unreachable from a browser in
|
||||
// the first place. The shell carries zero data — every value is fetched
|
||||
// client-side from /admin/api/*, which stay fully gated below — so there
|
||||
// is nothing to protect by gating the page itself. adminEnabled(g.cfg)
|
||||
// still governs whether GET /admin is reachable at all (disabled falls
|
||||
// through to 404, unchanged).
|
||||
func (g *Gateway) handleAdmin(sw *statusTrackingWriter, r *http.Request) {
|
||||
if r.URL.Path == adminPagePath {
|
||||
g.serveAdminPage(sw)
|
||||
return
|
||||
}
|
||||
g.handleAdminAPI(sw, r)
|
||||
}
|
||||
|
||||
// handleAdminAPI is the gate for the two /admin/api/* JSON routes,
|
||||
// applying spec §4's gate order: unauthenticated → 401, authenticated
|
||||
// non-admin → 403, admin → serve. Like every other authenticated route
|
||||
// it counts request counters via checkAndCount before serving: an admin
|
||||
// over their own req/min limit gets a 429 here exactly as they would on
|
||||
// any other route (spec §4's "Accounting: admin routes count request
|
||||
// counters like any authed route").
|
||||
func (g *Gateway) handleAdminAPI(sw *statusTrackingWriter, r *http.Request) {
|
||||
u, grp, ok := g.auth.identify(r)
|
||||
g.logAuthEvent(ok, authEventUserName(u), r)
|
||||
if !ok {
|
||||
@@ -69,8 +92,6 @@ func (g *Gateway) handleAdmin(sw *statusTrackingWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
switch r.URL.Path {
|
||||
case adminPagePath:
|
||||
g.serveAdminPage(sw)
|
||||
case adminOverviewPath:
|
||||
g.serveAdminOverview(sw)
|
||||
case adminUsagePath:
|
||||
@@ -79,18 +100,59 @@ func (g *Gateway) handleAdmin(sw *statusTrackingWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// serveAdminPage writes the single-page dashboard (adminPageHTML, below)
|
||||
// with its Content-Security-Policy header.
|
||||
// with its Content-Security-Policy header. Unauthenticated by design —
|
||||
// see handleAdmin's doc comment.
|
||||
func (g *Gateway) serveAdminPage(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Content-Security-Policy", adminCSP)
|
||||
_, _ = io.WriteString(w, adminPageHTML)
|
||||
}
|
||||
|
||||
// setAdminJSONHeaders applies the response headers shared by both admin
|
||||
// JSON endpoints (folded review item, 2026-08-20 review): the same CSP
|
||||
// as the HTML page, X-Content-Type-Options: nosniff (a browser must
|
||||
// never guess this response is anything other than the JSON it is
|
||||
// declared as), and Cache-Control: no-store (every response is built
|
||||
// fresh per request from live state and must never linger in a shared
|
||||
// or disk cache).
|
||||
func setAdminJSONHeaders(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Security-Policy", adminCSP)
|
||||
}
|
||||
|
||||
// sanitizeBaseURL strips a provider baseUrl's userinfo (username and
|
||||
// password) and query string before admin.go ever echoes it (folded
|
||||
// review item, 2026-08-20 review): an operator who embeds credentials in
|
||||
// a baseUrl — "https://token@host" or "https://host?api-key=..." are
|
||||
// both misconfigurations, but ones this dashboard must never amplify
|
||||
// into a credential leak — must never see them reflected back. This
|
||||
// strips the userinfo entirely rather than only the password the way
|
||||
// net/url's own URL.Redacted() does: Redacted() would still leave a bare
|
||||
// embedded username in place, and a bare username with no separate
|
||||
// password is itself commonly how a token gets embedded in a URL. A raw
|
||||
// value that fails to parse as a URL is returned unchanged — it names no
|
||||
// scheme/host/userinfo net/url can identify, so there is nothing
|
||||
// structured left to strip.
|
||||
func sanitizeBaseURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
u.User = nil
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// adminProviderView is one provider's read-only view in GET
|
||||
// /admin/api/overview (spec §4, v0.2). baseUrl is not secret — the
|
||||
// spec's NEVER-exposed list is API keys (not even digests), provider
|
||||
// keys, the redis password, and users-file path contents; a provider's
|
||||
// base URL names no credential.
|
||||
// base URL names no credential by itself, and any credential-shaped
|
||||
// userinfo/query an operator mistakenly embedded in it is stripped by
|
||||
// sanitizeBaseURL before this view is ever built.
|
||||
type adminProviderView struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
@@ -101,9 +163,19 @@ type adminProviderView struct {
|
||||
}
|
||||
|
||||
// adminRedisView is the redis status line in GET /admin/api/overview.
|
||||
// LastErrAt's json tag omits "omitempty": encoding/json never treats a
|
||||
// struct value (time.Time) as "empty" regardless of its fields, so the
|
||||
// tag would be a silent no-op — the same caveat Config's own Retry/Cache
|
||||
// field doc comments already record (llmgateway.go). Its zero value
|
||||
// ("0001-01-01T00:00:00Z") is the "no error recorded yet" sentinel,
|
||||
// matching adminProviderView.LastRefresh's own convention; the dashboard
|
||||
// renders it as "last error (Ns ago): ..." (folded review item,
|
||||
// 2026-08-20 review) so a single transient blip does not read as
|
||||
// currently-broken long after it recovered.
|
||||
type adminRedisView struct {
|
||||
LastErr string `json:"lastErr,omitempty"`
|
||||
Configured bool `json:"configured"`
|
||||
LastErrAt time.Time `json:"lastErrAt"`
|
||||
LastErr string `json:"lastErr,omitempty"`
|
||||
Configured bool `json:"configured"`
|
||||
}
|
||||
|
||||
// adminCacheView is the cache config summary in GET /admin/api/overview —
|
||||
@@ -143,14 +215,14 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
|
||||
providers[i] = adminProviderView{
|
||||
Name: s.name,
|
||||
Type: s.typeName,
|
||||
BaseURL: s.baseURL,
|
||||
BaseURL: sanitizeBaseURL(s.baseURL),
|
||||
ModelCount: s.modelCount,
|
||||
LastRefresh: s.lastRefresh,
|
||||
LastErr: s.lastErr,
|
||||
}
|
||||
}
|
||||
|
||||
configured, redisLastErr := g.limiter.redisStatus()
|
||||
configured, redisLastErr, redisLastErrAt := g.limiter.redisStatus()
|
||||
|
||||
cacheEnabled := g.cache != nil
|
||||
var ttl string
|
||||
@@ -166,7 +238,7 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
|
||||
|
||||
return adminOverviewResponse{
|
||||
Providers: providers,
|
||||
Redis: adminRedisView{Configured: configured, LastErr: redisLastErr},
|
||||
Redis: adminRedisView{Configured: configured, LastErr: redisLastErr, LastErrAt: redisLastErrAt},
|
||||
Cache: adminCacheView{Enabled: cacheEnabled, TTL: ttl},
|
||||
Groups: groups,
|
||||
Version: pluginVersion,
|
||||
@@ -175,7 +247,7 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
|
||||
|
||||
// serveAdminOverview writes buildAdminOverview's result as JSON.
|
||||
func (g *Gateway) serveAdminOverview(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
setAdminJSONHeaders(w)
|
||||
_ = json.NewEncoder(w).Encode(g.buildAdminOverview())
|
||||
}
|
||||
|
||||
@@ -228,10 +300,11 @@ func usageEntryView(su scopeUsage, limits *LimitsConfig) adminUsageEntryView {
|
||||
// buildAdminUsage assembles adminUsageResponse: authStore.snapshot lists
|
||||
// every currently active user and every configured group (names, group
|
||||
// membership, and limits only — never a key or its digest), and
|
||||
// limiter.currentUsage reads each one's six current-window counters in
|
||||
// one read-only pass per list. Unlike buildLimitScopes (routes_
|
||||
// unified.go), this never omits an entity for having nil limits — the
|
||||
// dashboard shows usage for every user and group, limited or not.
|
||||
// limiter.currentUsage reads each one's six current-window counters —
|
||||
// one batched round trip per scope (limits.go's storeGetMulti) rather
|
||||
// than six separate ones. Unlike buildLimitScopes (routes_unified.go),
|
||||
// this never omits an entity for having nil limits — the dashboard shows
|
||||
// usage for every user and group, limited or not.
|
||||
func (g *Gateway) buildAdminUsage() adminUsageResponse {
|
||||
userSummaries, groupSummaries := g.auth.snapshot()
|
||||
|
||||
@@ -262,19 +335,28 @@ func (g *Gateway) buildAdminUsage() adminUsageResponse {
|
||||
|
||||
// serveAdminUsage writes buildAdminUsage's result as JSON.
|
||||
func (g *Gateway) serveAdminUsage(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
setAdminJSONHeaders(w)
|
||||
_ = json.NewEncoder(w).Encode(g.buildAdminUsage())
|
||||
}
|
||||
|
||||
// adminPageHTML is the entire /admin single-page dashboard: markup, CSS,
|
||||
// and vanilla JS in one Go raw-string const. Yaegi interpretation forbids
|
||||
// the embed directive (no filesystem access from an interpreted plugin),
|
||||
// so the page ships as source, exactly like every other Yaegi-compatible plugin's
|
||||
// static assets. It polls adminOverviewPath and adminUsagePath every 5s
|
||||
// via fetch, loads no external asset of any kind (matching adminCSP's
|
||||
// default-src 'none'), and never uses innerHTML with server-provided
|
||||
// strings — every dynamic value is written via textContent, so nothing
|
||||
// the API returns is ever interpreted as markup.
|
||||
// so the page ships as source, exactly like every other Yaegi-compatible
|
||||
// plugin's static assets. It polls adminOverviewPath and adminUsagePath
|
||||
// every 5s via fetch, loads no external asset of any kind (matching
|
||||
// adminCSP's default-src 'none'), and never uses innerHTML with
|
||||
// server-provided strings — every dynamic value is written via
|
||||
// textContent, so nothing the API returns is ever interpreted as markup.
|
||||
//
|
||||
// Browser key entry (controller-approved amendment, 2026-08-20 review):
|
||||
// the page itself carries no auth (see handleAdmin), so its script reads
|
||||
// an admin API key from this tab's sessionStorage and sends it as
|
||||
// "x-api-key" on every /admin/api/* fetch. Absent a stored key, or on any
|
||||
// 401/403 response, it shows an inline key-entry form instead of the
|
||||
// dashboard; a submitted key is kept only in sessionStorage — never in a
|
||||
// cookie, localStorage, or any persistent store — so it disappears when
|
||||
// the tab closes and is never sent anywhere but this page's own fetches.
|
||||
const adminPageHTML = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -323,14 +405,36 @@ const adminPageHTML = `<!doctype html>
|
||||
th { background: var(--head-bg); font-weight: 600; }
|
||||
.muted { color: var(--muted); }
|
||||
.err { color: var(--err); }
|
||||
.hidden { display: none; }
|
||||
.infra-line { margin: .25rem 0; font-size: .85rem; }
|
||||
section { margin-bottom: 1.5rem; }
|
||||
input, button {
|
||||
font: inherit;
|
||||
padding: .4rem .6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: .25rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
button { cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>LLM Gateway - Admin</h1>
|
||||
<div id="status-line">loading...</div>
|
||||
|
||||
<section id="auth-gate">
|
||||
<h2>Admin key required</h2>
|
||||
<p class="muted">Enter an admin API key. The browser keeps it only in this tab's sessionStorage: it is never written to disk and is sent only to this page's own /admin/api/* requests.</p>
|
||||
<form id="auth-form">
|
||||
<input type="password" id="auth-key-input" autocomplete="off" placeholder="API key">
|
||||
<button type="submit">Continue</button>
|
||||
</form>
|
||||
<div id="auth-error" class="err"></div>
|
||||
</section>
|
||||
|
||||
<div id="dashboard" class="hidden">
|
||||
|
||||
<section>
|
||||
<h2>Providers</h2>
|
||||
<table id="providers"><thead><tr>
|
||||
@@ -357,6 +461,8 @@ const adminPageHTML = `<!doctype html>
|
||||
</tr></thead><tbody></tbody></table>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
@@ -365,8 +471,19 @@ const adminPageHTML = `<!doctype html>
|
||||
var OVERVIEW_URL = "/admin/api/overview";
|
||||
var USAGE_URL = "/admin/api/usage";
|
||||
var ZERO_TIME = "0001-01-01T00:00:00Z";
|
||||
var KEY_STORAGE = "llmgwAdminKey";
|
||||
var groupMeta = {};
|
||||
|
||||
function getStoredKey() {
|
||||
try { return sessionStorage.getItem(KEY_STORAGE) || ""; } catch (e) { return ""; }
|
||||
}
|
||||
function setStoredKey(k) {
|
||||
try { sessionStorage.setItem(KEY_STORAGE, k); } catch (e) { /* storage unavailable */ }
|
||||
}
|
||||
function clearStoredKey() {
|
||||
try { sessionStorage.removeItem(KEY_STORAGE); } catch (e) { /* storage unavailable */ }
|
||||
}
|
||||
|
||||
function el(tag, text, cls) {
|
||||
var e = document.createElement(tag);
|
||||
if (text !== undefined && text !== null) e.textContent = text;
|
||||
@@ -378,6 +495,14 @@ const adminPageHTML = `<!doctype html>
|
||||
return "$" + (micros / 1000000).toFixed(4);
|
||||
}
|
||||
|
||||
function fmtAgo(iso) {
|
||||
if (!iso || iso === ZERO_TIME) return "";
|
||||
var then = new Date(iso).getTime();
|
||||
if (isNaN(then)) return "";
|
||||
var secs = Math.max(0, Math.round((Date.now() - then) / 1000));
|
||||
return " (" + secs + "s ago)";
|
||||
}
|
||||
|
||||
function fmtLimits(l) {
|
||||
if (!l) return "none";
|
||||
var parts = [];
|
||||
@@ -419,7 +544,7 @@ const adminPageHTML = `<!doctype html>
|
||||
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;
|
||||
if (data.redis.lastErr) redisText += " - last error" + fmtAgo(data.redis.lastErrAt) + ": " + 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"));
|
||||
@@ -465,23 +590,61 @@ const adminPageHTML = `<!doctype html>
|
||||
s.className = isErr ? "err" : "";
|
||||
}
|
||||
|
||||
function showAuthGate(message) {
|
||||
document.getElementById("dashboard").classList.add("hidden");
|
||||
document.getElementById("auth-gate").classList.remove("hidden");
|
||||
document.getElementById("auth-error").textContent = message || "";
|
||||
}
|
||||
|
||||
function showDashboard() {
|
||||
document.getElementById("auth-gate").classList.add("hidden");
|
||||
document.getElementById("dashboard").classList.remove("hidden");
|
||||
}
|
||||
|
||||
function fetchJSON(url) {
|
||||
return fetch(url, { credentials: "same-origin" }).then(function (resp) {
|
||||
var key = getStoredKey();
|
||||
var headers = key ? { "x-api-key": key } : {};
|
||||
return fetch(url, { credentials: "same-origin", headers: headers }).then(function (resp) {
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
var err = new Error("admin key rejected: HTTP " + resp.status);
|
||||
err.authFailed = true;
|
||||
throw err;
|
||||
}
|
||||
if (!resp.ok) throw new Error(url + ": HTTP " + resp.status);
|
||||
return resp.json();
|
||||
});
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (!getStoredKey()) {
|
||||
showAuthGate("");
|
||||
return;
|
||||
}
|
||||
Promise.all([fetchJSON(OVERVIEW_URL), fetchJSON(USAGE_URL)]).then(function (results) {
|
||||
showDashboard();
|
||||
renderOverview(results[0]);
|
||||
renderUsage(results[1]);
|
||||
setStatus("last updated " + new Date().toLocaleTimeString(), false);
|
||||
}).catch(function (err) {
|
||||
if (err.authFailed) {
|
||||
clearStoredKey();
|
||||
showAuthGate("invalid key, or not an admin");
|
||||
return;
|
||||
}
|
||||
setStatus("refresh failed: " + err.message, true);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("auth-form").addEventListener("submit", function (ev) {
|
||||
ev.preventDefault();
|
||||
var input = document.getElementById("auth-key-input");
|
||||
var val = input.value;
|
||||
input.value = "";
|
||||
if (!val) return;
|
||||
setStoredKey(val);
|
||||
refresh();
|
||||
});
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, POLL_MS);
|
||||
})();
|
||||
|
||||
+257
-6
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -107,11 +108,43 @@ func TestAdmin_Disabled_HonorsPassthroughUnknown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminPage_ServedWithoutAuth is the controller-approved amendment to
|
||||
// spec §4 (2026-08-20 review): GET /admin serves the HTML shell with no
|
||||
// authentication at all, for every caller — a browser navigating
|
||||
// straight to the URL has no way to attach a custom Authorization or
|
||||
// x-api-key header, so gating the page itself made the dashboard
|
||||
// unreachable from a browser. /admin/api/* stay fully gated (see
|
||||
// TestAdmin_GateMatrix below); the page carries no data of its own, so
|
||||
// serving it unauthenticated leaks nothing — verified here by scanning
|
||||
// the body for the same canary-shaped check TestAdmin_SecretRedaction
|
||||
// uses, not just trusting the doc comment.
|
||||
func TestAdminPage_ServedWithoutAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := newAdminTestConfig()
|
||||
h, _ := newAdminGatewayHandle(t, cfg)
|
||||
|
||||
for _, apiKey := range []string{"", "sk-alice", "sk-admin1", "not-a-real-key"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminPagePath, apiKey))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("apiKey=%q: status = %d, want 200 (GET /admin is unauthenticated)", apiKey, rec.Code)
|
||||
}
|
||||
for _, secret := range []string{"sk-zeta", "sk-alpha", "sk-alice", "sk-admin1"} {
|
||||
if strings.Contains(rec.Body.String(), secret) {
|
||||
t.Errorf("apiKey=%q: page body leaks configured secret %q", apiKey, secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdmin_GateMatrix covers the two /admin/api/* routes only — GET
|
||||
// /admin itself is unauthenticated by design (TestAdminPage_
|
||||
// ServedWithoutAuth above).
|
||||
func TestAdmin_GateMatrix(t *testing.T) {
|
||||
cfg := newAdminTestConfig()
|
||||
h, _ := newAdminGatewayHandle(t, cfg)
|
||||
|
||||
paths := []string{adminPagePath, adminOverviewPath, adminUsagePath}
|
||||
paths := []string{adminOverviewPath, adminUsagePath}
|
||||
for _, p := range paths {
|
||||
t.Run(p+"/unauthenticated", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -406,8 +439,10 @@ func TestAdminPage_CSPHeaderAndFetchURLs(t *testing.T) {
|
||||
cfg := newAdminTestConfig()
|
||||
h, _ := newAdminGatewayHandle(t, cfg)
|
||||
|
||||
// Unauthenticated: GET /admin is served without auth (see
|
||||
// TestAdminPage_ServedWithoutAuth), so no key is presented here.
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminPagePath, "sk-admin1"))
|
||||
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminPagePath, ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
@@ -420,7 +455,7 @@ func TestAdminPage_CSPHeaderAndFetchURLs(t *testing.T) {
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{adminOverviewPath, adminUsagePath, "setInterval"} {
|
||||
for _, want := range []string{adminOverviewPath, adminUsagePath, "setInterval", "sessionStorage", "x-api-key"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("admin page body missing %q", want)
|
||||
}
|
||||
@@ -455,6 +490,15 @@ func TestAdmin_SecretRedaction(t *testing.T) {
|
||||
|
||||
h, _ := newAdminGatewayHandle(t, cfg)
|
||||
|
||||
// GET /admin is unauthenticated by design (controller-approved
|
||||
// amendment, 2026-08-20 review): assert it still returns 200 and
|
||||
// still leaks nothing.
|
||||
pageRec := httptest.NewRecorder()
|
||||
h.ServeHTTP(pageRec, adminRequest(http.MethodGet, adminPagePath, ""))
|
||||
if pageRec.Code != http.StatusOK {
|
||||
t.Fatalf("page status = %d, want 200 (unauthenticated)", pageRec.Code)
|
||||
}
|
||||
|
||||
overviewRec := httptest.NewRecorder()
|
||||
h.ServeHTTP(overviewRec, adminRequest(http.MethodGet, adminOverviewPath, adminKey))
|
||||
if overviewRec.Code != http.StatusOK {
|
||||
@@ -468,10 +512,15 @@ func TestAdmin_SecretRedaction(t *testing.T) {
|
||||
}
|
||||
|
||||
canaries := []string{providerKey, redisPass, aliceKey, adminKey}
|
||||
for _, name := range []string{"overview", "usage"} {
|
||||
body := overviewRec.Body.String()
|
||||
if name == "usage" {
|
||||
for _, name := range []string{"page", "overview", "usage"} {
|
||||
var body string
|
||||
switch name {
|
||||
case "page":
|
||||
body = pageRec.Body.String()
|
||||
case "usage":
|
||||
body = usageRec.Body.String()
|
||||
default:
|
||||
body = overviewRec.Body.String()
|
||||
}
|
||||
for _, secret := range canaries {
|
||||
if strings.Contains(body, secret) {
|
||||
@@ -487,3 +536,205 @@ func TestAdmin_SecretRedaction(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- overview: baseUrl strips userinfo and query (folded item 3, 2026-08-20 review) ---
|
||||
|
||||
func TestAdminOverview_BaseURLStripsCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Built via net/url rather than a literal string constant: a scheme
|
||||
// plus embedded username-colon-password-at-host literal in source
|
||||
// trips generic secret scanners (trufflehog's URI detector) even
|
||||
// though this is a fabricated test fixture, not a real credential.
|
||||
// Assembling it programmatically exercises the identical code path
|
||||
// (sanitizeBaseURL parses and strips it exactly the same either way)
|
||||
// without putting a credential-shaped string literal in the diff.
|
||||
credentialBaseURL := (&url.URL{
|
||||
Scheme: "https",
|
||||
User: url.UserPassword("embeddeduser", "embeddedpass"),
|
||||
Host: "openai.invalid",
|
||||
RawQuery: "api-key=leakedquerysecret",
|
||||
}).String()
|
||||
|
||||
cfg := CreateConfig()
|
||||
cfg.Admin = &AdminConfig{Enabled: true}
|
||||
cfg.Providers = map[string]*ProviderConfig{
|
||||
// Discovery is left false: nothing ever dials this URL, it exists
|
||||
// purely to prove sanitizeBaseURL strips it before echoing.
|
||||
"openai": {Type: "openai", BaseURL: credentialBaseURL, APIKey: "sk", Models: []string{"gpt-test"}},
|
||||
}
|
||||
cfg.Groups = map[string]*GroupConfig{"g": {}}
|
||||
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "admin1", Group: "g", APIKey: "sk-admin1", Admin: true}}}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
for _, leaked := range []string{"embeddeduser", "embeddedpass", "leakedquerysecret", "api-key="} {
|
||||
if strings.Contains(body, leaked) {
|
||||
t.Errorf("overview response leaks base URL credential/query material %q", leaked)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
wantBaseURL := "https://openai.invalid"
|
||||
if got.Providers[0].BaseURL != wantBaseURL {
|
||||
t.Errorf("baseUrl = %q, want %q (userinfo and query stripped)", got.Providers[0].BaseURL, wantBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
// --- usage: batched reads (important item 2, 2026-08-20 review) ---
|
||||
|
||||
// countingMultiStore is a counterStore stub whose getMulti serves a fixed
|
||||
// value per key and counts how many times it was called — it proves
|
||||
// limiter.currentUsage batches a scope's six counter reads into one
|
||||
// getMulti call rather than issuing them as six separate get calls.
|
||||
// incrBy/get are never exercised by currentUsage and just return zero
|
||||
// values.
|
||||
type countingMultiStore struct {
|
||||
values map[string]int64
|
||||
getMultiCalls int
|
||||
}
|
||||
|
||||
func (s *countingMultiStore) incrBy(string, int64, time.Duration) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (s *countingMultiStore) get(string) (int64, error) { return 0, nil }
|
||||
func (s *countingMultiStore) getMulti(keys []string) ([]int64, error) {
|
||||
s.getMultiCalls++
|
||||
out := make([]int64, len(keys))
|
||||
for i, k := range keys {
|
||||
out[i] = s.values[k]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestLimiterCurrentUsage_BatchesOneGetMultiCallPerScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
fixedNow := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC)
|
||||
store := &countingMultiStore{values: map[string]int64{
|
||||
windowKey("user", "alice", metricReq, windowMin, fixedNow): 3,
|
||||
windowKey("user", "alice", metricReq, windowDay, fixedNow): 7,
|
||||
windowKey("user", "alice", metricTok, windowDay, fixedNow): 50,
|
||||
windowKey("user", "alice", metricTok, windowMonth, fixedNow): 60,
|
||||
windowKey("user", "alice", metricCost, windowDay, fixedNow): 1_000,
|
||||
windowKey("user", "alice", metricCost, windowMonth, fixedNow): 2_000,
|
||||
windowKey("group", "g1", metricReq, windowMin, fixedNow): 1,
|
||||
}}
|
||||
l := newLimiter(store, true)
|
||||
l.nowFn = func() time.Time { return fixedNow }
|
||||
|
||||
scopes := []limitScope{
|
||||
{kind: "user", id: "alice", limits: &LimitsConfig{}},
|
||||
{kind: "group", id: "g1", limits: &LimitsConfig{}},
|
||||
}
|
||||
got := l.currentUsage(scopes)
|
||||
|
||||
if store.getMultiCalls != len(scopes) {
|
||||
t.Errorf("getMultiCalls = %d, want %d (one pipelined getMulti per scope, not six separate get calls)", store.getMultiCalls, len(scopes))
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(got) = %d, want 2", len(got))
|
||||
}
|
||||
alice := got[0]
|
||||
if alice.storeDown {
|
||||
t.Fatal("alice.storeDown must be false")
|
||||
}
|
||||
if alice.requestsPerMinute != 3 || alice.requestsPerDay != 7 || alice.tokensPerDay != 50 ||
|
||||
alice.tokensPerMonth != 60 || alice.costPerDayMicros != 1_000 || alice.costPerMonthMicros != 2_000 {
|
||||
t.Errorf("alice usage = %+v, want the seeded values", alice)
|
||||
}
|
||||
g1 := got[1]
|
||||
if g1.requestsPerMinute != 1 {
|
||||
t.Errorf("g1 requestsPerMinute = %d, want 1", g1.requestsPerMinute)
|
||||
}
|
||||
}
|
||||
|
||||
// --- overview: redis lastErr carries a timestamp (folded item 4, 2026-08-20 review) ---
|
||||
|
||||
func TestAdminOverview_RedisLastErrAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := CreateConfig()
|
||||
cfg.Admin = &AdminConfig{Enabled: true}
|
||||
cfg.Providers = map[string]*ProviderConfig{
|
||||
"openai": {Type: "openai", BaseURL: "http://openai.invalid", APIKey: "sk", Models: []string{"gpt-test"}},
|
||||
}
|
||||
// Nothing listens on 127.0.0.1:1: every operation against it fails
|
||||
// fast (connection refused), driving a real store failure without a
|
||||
// live Redis dependency.
|
||||
cfg.Redis = &RedisConfig{Address: "127.0.0.1:1"}
|
||||
cfg.Groups = map[string]*GroupConfig{"g": {}}
|
||||
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "admin1", Group: "g", APIKey: "sk-admin1", Admin: true}}}
|
||||
|
||||
h, _ := newAdminGatewayHandle(t, cfg)
|
||||
|
||||
// buildAdminUsage lists admin1 even with no seeded traffic (spec's
|
||||
// "for every user and group"), so this alone triggers a real
|
||||
// storeGetMulti call against the unreachable store.
|
||||
usageRec := httptest.NewRecorder()
|
||||
h.ServeHTTP(usageRec, adminRequest(http.MethodGet, adminUsagePath, "sk-admin1"))
|
||||
if usageRec.Code != http.StatusOK {
|
||||
t.Fatalf("usage status = %d, want 200, body=%s", usageRec.Code, usageRec.Body.String())
|
||||
}
|
||||
|
||||
overviewRec := httptest.NewRecorder()
|
||||
h.ServeHTTP(overviewRec, adminRequest(http.MethodGet, adminOverviewPath, "sk-admin1"))
|
||||
if overviewRec.Code != http.StatusOK {
|
||||
t.Fatalf("overview status = %d, want 200, body=%s", overviewRec.Code, overviewRec.Body.String())
|
||||
}
|
||||
var got adminOverviewResponse
|
||||
if err := json.Unmarshal(overviewRec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !got.Redis.Configured {
|
||||
t.Error("redis.configured must be true")
|
||||
}
|
||||
if got.Redis.LastErr == "" {
|
||||
t.Error("redis.lastErr must be non-empty after a real store failure")
|
||||
}
|
||||
if got.Redis.LastErrAt.IsZero() {
|
||||
t.Fatal("redis.lastErrAt must be set after a real store failure")
|
||||
}
|
||||
if age := time.Since(got.Redis.LastErrAt); age < 0 || age > 30*time.Second {
|
||||
t.Errorf("redis.lastErrAt age = %v, want within 30s of now", age)
|
||||
}
|
||||
}
|
||||
|
||||
// --- file-sourced admin user (folded item 6, 2026-08-20 review) ---
|
||||
|
||||
// TestAdmin_FileUserAdminFlag_GrantsAccess proves UserConfig.Admin flows
|
||||
// through the file-users hot-reload path (authStore.replaceFileUsers,
|
||||
// auth.go) exactly like an inline user's — buildEntry is shared by both,
|
||||
// but this had never been exercised end-to-end through /admin/api/*
|
||||
// before. File-users granting admin is operator-controlled via the
|
||||
// Secret backing Users.File, an accepted trust boundary per
|
||||
// UserConfig.Admin's own doc comment (llmgateway.go).
|
||||
func TestAdmin_FileUserAdminFlag_GrantsAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := newAdminTestConfig()
|
||||
h, gw := newAdminGatewayHandle(t, cfg)
|
||||
|
||||
fileUsers := []*UserConfig{
|
||||
{Name: "fileadmin", Group: "agroup", APIKey: "sk-filedmin", Admin: true},
|
||||
}
|
||||
if err := gw.auth.replaceFileUsers(fileUsers); err != nil {
|
||||
t.Fatalf("replaceFileUsers: %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminOverviewPath, "sk-filedmin"))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 for a file-sourced admin user, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,15 @@ type counterStore interface {
|
||||
// get returns key's current counter value, or 0 if it does not exist
|
||||
// or has expired.
|
||||
get(key string) (int64, error)
|
||||
// getMulti reads every key in keys in one round trip where the
|
||||
// backend supports it (redisStore pipelines via respClient.getBatch;
|
||||
// memoryStore's in-process map needs no such optimization but
|
||||
// implements the same contract for interface conformance), returning
|
||||
// one value per key in the same order — 0 for a key that does not
|
||||
// exist or has expired, matching get's contract. An error fails the
|
||||
// whole batch (mirrored by the limiter's storeGetMulti as a single
|
||||
// fail-open/fail-closed decision), never a partial result.
|
||||
getMulti(keys []string) ([]int64, error)
|
||||
}
|
||||
|
||||
// memoryEntry is one counter's value and expiry in memoryStore.
|
||||
@@ -197,6 +206,19 @@ func (m *memoryStore) get(key string) (int64, error) {
|
||||
return e.value, nil
|
||||
}
|
||||
|
||||
// getMulti implements counterStore by looping over get: memoryStore's
|
||||
// in-process map access is already effectively free per key, so there is
|
||||
// no round-trip cost to batch away — this exists purely so memoryStore
|
||||
// satisfies counterStore's getMulti contract for the limiter's nil-store
|
||||
// fallback path (storeGetMulti).
|
||||
func (m *memoryStore) getMulti(keys []string) ([]int64, error) {
|
||||
out := make([]int64, len(keys))
|
||||
for i, k := range keys {
|
||||
out[i], _ = m.get(k) // memoryStore.get never errors
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// sweepLocked deletes every expired entry. Callers must hold m.mu.
|
||||
func (m *memoryStore) sweepLocked(now time.Time) {
|
||||
for k, e := range m.data {
|
||||
@@ -340,14 +362,19 @@ func (l *limiter) recordStoreFailure(err error) {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// store, that store's most recent operation failure message, and when
|
||||
// that failure happened — the admin dashboard's redis status line (spec
|
||||
// §4, v0.2). lastErr is "" and lastErrAt is the zero time when no store
|
||||
// operation has ever failed. Returning the timestamp alongside the
|
||||
// message (controller-approved amendment, 2026-08-20 review) lets the
|
||||
// dashboard render "last error (12s ago): ..." instead of a bare message
|
||||
// that reads as "currently broken" even long after a single transient
|
||||
// blip. Guarded by logMu, the same mutex lastStoreFailure/lastErrMsg
|
||||
// already use.
|
||||
func (l *limiter) redisStatus() (configured bool, lastErr string, lastErrAt time.Time) {
|
||||
l.logMu.Lock()
|
||||
defer l.logMu.Unlock()
|
||||
return l.store != nil, l.lastErrMsg
|
||||
return l.store != nil, l.lastErrMsg, l.lastStoreFailure
|
||||
}
|
||||
|
||||
// storeIncrBy increments key by n with the given ttl, applying the
|
||||
@@ -412,6 +439,37 @@ func (l *limiter) failPolicyGet(key string) (int64, bool) {
|
||||
return v, true
|
||||
}
|
||||
|
||||
// storeGetMulti mirrors storeGet for a batch of keys: one round trip
|
||||
// against the configured store (or the in-process fallback) for the
|
||||
// whole slice, applying the identical fail-open/fail-closed/latched
|
||||
// policy storeGet applies per key. ok is false only in the fail-closed
|
||||
// case, matching storeGet's contract — a caller must not read the
|
||||
// returned slice as real values when ok is false.
|
||||
func (l *limiter) storeGetMulti(keys []string) (v []int64, ok bool) {
|
||||
if l.store == nil {
|
||||
v, _ = l.fallback.getMulti(keys) // fallback never errors
|
||||
return v, true
|
||||
}
|
||||
if l.storeLatched() {
|
||||
return l.failPolicyGetMulti(keys)
|
||||
}
|
||||
v, err := l.store.getMulti(keys)
|
||||
if err == nil {
|
||||
return v, true
|
||||
}
|
||||
l.recordStoreFailure(err)
|
||||
return l.failPolicyGetMulti(keys)
|
||||
}
|
||||
|
||||
// failPolicyGetMulti mirrors failPolicyGet for a batch read.
|
||||
func (l *limiter) failPolicyGetMulti(keys []string) ([]int64, bool) {
|
||||
if !l.failOpen {
|
||||
return nil, false
|
||||
}
|
||||
v, _ := l.fallback.getMulti(keys)
|
||||
return v, true
|
||||
}
|
||||
|
||||
// storeDownViolation is the violation checkAndCount/budgetViolation return
|
||||
// when the configured store is unreachable and failOpen is false: the
|
||||
// request is refused instead of silently enforcing limits against a
|
||||
@@ -642,32 +700,58 @@ type scopeUsage struct {
|
||||
storeDown bool
|
||||
}
|
||||
|
||||
// usageWindowKeys returns the six windowKey strings currentUsage reads
|
||||
// for sc at time now, in a fixed order — req/min, req/day, tok/day,
|
||||
// tok/month, cost/day, cost/month — matching scopeUsage's field order
|
||||
// exactly, so currentUsage can map storeGetMulti's result slice back to
|
||||
// named fields by plain index.
|
||||
func usageWindowKeys(sc limitScope, now time.Time) []string {
|
||||
return []string{
|
||||
windowKey(sc.kind, sc.id, metricReq, windowMin, now),
|
||||
windowKey(sc.kind, sc.id, metricReq, windowDay, now),
|
||||
windowKey(sc.kind, sc.id, metricTok, windowDay, now),
|
||||
windowKey(sc.kind, sc.id, metricTok, windowMonth, now),
|
||||
windowKey(sc.kind, sc.id, metricCost, windowDay, now),
|
||||
windowKey(sc.kind, sc.id, metricCost, windowMonth, now),
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// already write — via the limiter's own storeGetMulti, 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].
|
||||
// does, and it is read-only: unlike checkAndCount, it never increments
|
||||
// anything.
|
||||
//
|
||||
// One storeGetMulti call per scope (controller-approved amendment,
|
||||
// 2026-08-20 review): the original per-metric l.getCounter loop issued
|
||||
// six separate round trips per scope against the single shared Redis
|
||||
// connection — GET /admin/api/usage serialized 6×(users+groups) of them.
|
||||
// Batching all scopes' keys into one single storeGetMulti call would cut
|
||||
// that further, to one round trip total, but was not taken here: it
|
||||
// would need buildAdminUsage to flatten and later re-split two
|
||||
// differently-sized scope lists (users, groups) around one shared call,
|
||||
// for a further win only realized when both lists are non-trivially
|
||||
// large. One call per scope already turns 6N round trips into N, keeps
|
||||
// currentUsage's per-scope iteration shape identical to
|
||||
// checkAndCount/evaluateScope's, and keeps a single scope's store
|
||||
// failure from forcing every other scope in the same request to look
|
||||
// storeDown too. 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 {
|
||||
vals, ok := l.storeGetMulti(usageWindowKeys(sc, now))
|
||||
if !ok {
|
||||
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,
|
||||
requestsPerMinute: vals[0], requestsPerDay: vals[1],
|
||||
tokensPerDay: vals[2], tokensPerMonth: vals[3],
|
||||
costPerDayMicros: vals[4], costPerMonthMicros: vals[5],
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -390,6 +390,7 @@ type erroringStore struct {
|
||||
|
||||
func (s *erroringStore) incrBy(string, int64, time.Duration) (int64, error) { return 0, s.err }
|
||||
func (s *erroringStore) get(string) (int64, error) { return 0, s.err }
|
||||
func (s *erroringStore) getMulti([]string) ([]int64, error) { return nil, s.err }
|
||||
|
||||
// TestLimiter_FailOpen_StoreErrorUsesFallback is carried-item (c): a store
|
||||
// error with failOpen=true must not block the request — the operation
|
||||
@@ -448,6 +449,9 @@ func (s *succeedIncrFailGetStore) incrBy(string, int64, time.Duration) (int64, e
|
||||
return 1, nil
|
||||
}
|
||||
func (s *succeedIncrFailGetStore) get(string) (int64, error) { return 0, s.getErr }
|
||||
func (s *succeedIncrFailGetStore) getMulti([]string) ([]int64, error) {
|
||||
return nil, s.getErr
|
||||
}
|
||||
|
||||
// TestLimiter_FailClosed_BudgetReadReturnsStoreDownViolation covers the
|
||||
// budgetViolation fail-closed path specifically (as opposed to the
|
||||
@@ -546,6 +550,11 @@ func (s *countingErrorStore) get(string) (int64, error) {
|
||||
return 0, s.err
|
||||
}
|
||||
|
||||
func (s *countingErrorStore) getMulti([]string) ([]int64, error) {
|
||||
s.getCalls++
|
||||
return nil, s.err
|
||||
}
|
||||
|
||||
// TestLimiter_StoreDownLatch_SkipsStoreCallsWithinWindow is review-round-3
|
||||
// item 2: after a store operation fails, every operation for the next
|
||||
// storeDownLatchFor must short-circuit straight to the fail-open policy
|
||||
|
||||
@@ -67,6 +67,19 @@ func (s *redisStore) incrBy(key string, n int64, ttl time.Duration) (int64, erro
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// getMulti implements counterStore: one pipelined GET per key
|
||||
// (respClient.getBatch), a single round trip regardless of len(keys) —
|
||||
// used by limiter.currentUsage (admin dashboard, spec §4) to read a
|
||||
// scope's six current-window counters without serializing six separate
|
||||
// calls on the one shared connection.
|
||||
func (s *redisStore) getMulti(keys []string) ([]int64, error) {
|
||||
v, err := s.client.getBatch(keys)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("redisStore: getMulti: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// get implements counterStore: GET key, treating a missing key (RESP null
|
||||
// bulk reply) as 0, matching memoryStore's behavior for an absent or
|
||||
// expired counter.
|
||||
|
||||
@@ -447,6 +447,48 @@ func (c *respClient) getBytes(key string) ([]byte, bool, error) {
|
||||
return b, true, nil
|
||||
}
|
||||
|
||||
// getBatch reads every key in keys in one pipelined round trip — N GET
|
||||
// commands sent together, replies read together — instead of len(keys)
|
||||
// separate calls each paying their own respCallTimeout budget (the fix
|
||||
// for GET /admin/api/usage otherwise serializing 6 GETs per user/group
|
||||
// scope on the single shared connection). It returns one value per key,
|
||||
// in the same order, with a missing key (RESP null bulk) mapped to 0,
|
||||
// matching getBytes/redisStore.get's convention for a single key. Any
|
||||
// reply that is a RESP error, an unexpected reply type, or a non-integer
|
||||
// value fails the whole batch — mirrored by the limiter's
|
||||
// storeGetMulti/currentUsage as one fail-open/fail-closed decision for
|
||||
// the scope, never a partial result mixing real and zero values.
|
||||
func (c *respClient) getBatch(keys []string) ([]int64, error) {
|
||||
cmds := make([][]string, len(keys))
|
||||
for i, k := range keys {
|
||||
cmds[i] = []string{"GET", k}
|
||||
}
|
||||
replies, err := c.pipeline(cmds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resp: getBatch: %w", err)
|
||||
}
|
||||
|
||||
out := make([]int64, len(keys))
|
||||
for i, reply := range replies {
|
||||
if reply == nil {
|
||||
continue // missing key -> 0, matching getBytes/get's convention
|
||||
}
|
||||
if e, ok := reply.(respErr); ok {
|
||||
return nil, fmt.Errorf("resp: getBatch %q: %w", keys[i], e)
|
||||
}
|
||||
b, ok := reply.([]byte)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("resp: getBatch %q: unexpected reply type %T", keys[i], reply)
|
||||
}
|
||||
v, err := strconv.ParseInt(string(b), 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resp: getBatch %q: non-integer value %q: %w", keys[i], b, err)
|
||||
}
|
||||
out[i] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readLine reads one CRLF-terminated line from r, with the trailing CRLF
|
||||
// (or bare LF) stripped. It reads a byte at a time so it can bail out
|
||||
// after respMaxLineLen bytes without ever finding '\n' — a peer that
|
||||
|
||||
@@ -77,6 +77,10 @@ func (alwaysErrStore) get(string) (int64, error) {
|
||||
return 0, errStoreDownStub
|
||||
}
|
||||
|
||||
func (alwaysErrStore) getMulti([]string) ([]int64, error) {
|
||||
return nil, errStoreDownStub
|
||||
}
|
||||
|
||||
// TestHandleChat_HappyPath_NonStreaming_AccountsUsage drives a full
|
||||
// chat-completion request through Gateway.ServeHTTP against a fake
|
||||
// openai-shaped upstream, and asserts the response is forwarded verbatim
|
||||
|
||||
Reference in New Issue
Block a user