Admin: embedded Grafana dashboards tab (same-origin reverse proxy) #152
Labels
No labels
agent
blocked
foreground
needs-andrew
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
Tabled
needs-andrew
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
kreeader/kreeader-server#152
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Andrew wants Grafana dashboards embedded as a tab in the Admin section. Probed: hound Grafana sends X-Frame-Options: SAMEORIGIN, so direct iframes are blocked — the design is an admin-gated reverse proxy under the server origin (satisfies SAMEORIGIN by construction) with server-side service-account credential injection (keys/grafana_service_account, token line only). Configurable dashboard list. Build-tagged with the existing kreeader_admin console. If dashboard page loads still require a Grafana-side toggle (anonymous viewer / auth proxy), the exact one-line ask goes to questions/ rather than being worked around.
Live probe findings (read-only GETs) — two premises in the issue body corrected
1. The Grafana host in the issue body is wrong
http://192.168.3.9:3000is Forgejo, not Grafana —GET /api/healththere returnsNot found.andGET /returns the Forgejo app shell (<title>Forgejo: Beyond coding. We Forge.</title>).The real instance, per the already-working
kreeader-docs/scripts/progress-report/grafana.py(DEFAULT_GRAFANA_URL), ishttps://grafana.jiggl.in(resolves to 135.131.74.147, fronted by Caddy):2.
X-Frame-Options: SAMEORIGINis NOT sent by the real GrafanaThe SAMEORIGIN header came from the Forgejo box.
grafana.jiggl.insends noX-Frame-Optionsand noContent-Security-Policy: frame-ancestors:The reverse-proxy design is still correct, but for the authentication reason rather than the framing reason: a cross-origin iframe to
grafana.jiggl.inhas no Grafana session cookie and would redirect to/login. The proxy exists to inject the service-account credential server-side.3. Bearer auth on the dashboard page route — measured, it works
This was the open question. It answers yes:
GET /d/{uid}/xwithAuthorization: Bearer <sa-token>GET /d/{uid}/xwithout auth/login?redirectTo=...GET /d/{uid}/x?kioskwith BearerGET /api/dashboards/uid/{uid}with BearerGET /api/search?type=dash-dbwith Bearerkreeader)So service-account tokens authenticate Grafana's HTML page routes, not just
/api/*. No anonymous-viewer or auth-proxy toggle is needed.Dashboards visible to this token:
kreeader-overview,kreeader-detection,kreeader-detection-quality,kreeader-metadata(all taggedkreeader), plus an untaggedcustom.4. Subpath embedding works with response rewriting alone — no Grafana-side config change
I checked what actually breaks a subpath mount. Grafana 13's app shell references every asset relatively (
public/build/...); the sole absolute reference in the document is the<base>tag:So two textual rewrites of the HTML response are sufficient:
<base href="/" />→<base href="/api/admin/grafana/" />"appSubUrl":""→"appSubUrl":"/api/admin/grafana"I built a throwaway prototype proxy doing exactly that and loaded it in a real headless browser. The full SPA boots, the router rebases itself, and every panel renders with live Prometheus data — the URL self-corrected to
/api/admin/grafana/d/kreeader-overview/kreeader-e28094-overview?kiosk=true&orgId=1&from=now-30d&to=now&..., which only happens after the dashboard JSON loads and the router initialises against the right basename. Zero asset 404s, zero API errors.Conclusion: full-embed ships as the default mode.
grafana.root_url/serve_from_sub_pathdo NOT need changing, so there is no Grafana config ask to file — thequestions/file records the two operator decisions that remain (base URL to configure, and the renderer below) instead.The only console errors were the Grafana Live websocket:
— an artefact of the Python prototype not upgrading. Go's
httputil.ReverseProxyhandles the upgrade natively, so the real implementation passes it through.5. The image-renderer fallback is not available
grafana-image-rendereris not installed, so a PNG-based static fallback is impossible. The fallback mode is therefore theprogress-reportpattern — reproduce panels from/api/dashboards/uid/{uid}JSON — and it ships behind the config flag, not as default, since full-embed is proven working.Implementation proceeding on
feat/admin-grafana, all new code inapi/admingrafana.go.Implemented — full embed ships as the default. Branch
feat/admin-grafana@5b45241(Work paused mid-task when the machine rebooted unexpectedly; the worktree survived intact and everything below was completed after resuming. The probe comment above was posted before the crash.)
Commits
8a56e844fafbec[admin.grafana]in config.toml.example55f5445bc89d925b45241Plus kreeader-ui
feat/admin-grafana-dashboards@e8137e2(the Admin → Dashboards tab).The one design problem the probe findings did not anticipate
The proxy landed gated on the ordinary admin check — which an iframe cannot pass. A frame sends no
Authorizationheader, and in the two auth modes that check one (local_multiusers,oidc)isAdminreads a bearer token. The embed would have 401'd for an admin who was perfectly well logged in. Onlysingle_user, which authorizes any private-network caller regardless of headers, would have worked by accident.The fix follows
api/ssetickets.go, which exists in this package for exactly the same reason (EventSource cannot set headers either), and borrows its key property intact: the ticket is bound to the caller's own bearer token and redeems back into it rather than being an authorization of its own. Redeeming re-injects the header the frame could not send, and then the normal admin gate decides, every request. So a ticket confers nothing by existing — demote the minting admin and the next frame request fails. That is asserted directly by flipping the role off mid-test.It differs from an SSE ticket in lifetime only, because the workloads differ: an EventSource redeems once, an iframe makes hundreds of requests. So this one is multi-use and hours long, and becomes an HttpOnly cookie scoped to
/api/admin/grafana/on first use — the ticket appears in a URL exactly once and is stripped before anything reaches Grafana. Stored hashed, in memory only.Shipped mode:
embed(default). Verified against live Grafana, not a prototypeThe screenshot evidence in the probe comment was from a throwaway Python proxy. I re-ran it against the shipped Go code serving
https://grafana.jiggl.in:GET /api/admin/grafana/d/kreeader-overview/x?kiosk→ 200, full dashboard rendering live Prometheus data in a headless browser (6953 comics, 196540 pages, 89.2% match rate, timeseries + piechart + gauges all drawing)./api/admin/grafana/api/live/ws→101 Switching Protocols. Go'shttputil.ReverseProxyhandles the upgrade natively, so the one console error the Python prototype produced is gone — Grafana Live works.panelsmode ships behind the config flag as designed: it reproduces a dashboard from its own JSON (the kreeader-docs progress-report approach). No image-renderer path, becauserendererAvailable=falseand/render/d-solo500s on this instance — a PNG fallback would be one that does not work.Endpoints (all admin-gated,
kreeader_adminonly, all registered only when[admin.grafana] enabled)Credential handling
Never logged, never returned by any endpoint, never stored in the DB. Read from
token_file/token_envat request time and cached against mtime, so rotation needs no restart.readGrafanaTokenaccepts both layouts and thetoken:-line lesson is now encoded in the parser and its test — a whole-file token is rejected rather than sent upstream to earn a 401.At the proxy boundary the client's
Authorizationand every non-Grafana cookie are stripped before the request goes upstream (a browser's kreeader session must never reach Grafana, and a client-chosenAuthorizationmust never override the injected one). Grafana's own cookies are forwarded by strict name allowlist and returned rescoped to the proxy subtree. UpstreamX-Frame-Options/CSP are dropped; root-relative redirects re-anchored. Onlytext/htmlis rewritten, and only such paths force identity encoding —/publicassets stream through compressed.UI
New
DashboardsAdminSettings.svelte+ one TABS entry. This is the first tab inAdmin.sveltethat is not unconditional, soTABSgained acapabilityfield and the list renders from a filtered derivation; every existing tab omits it and is unaffected.admin_grafanadefaults false inCAPABILITIES_FALLBACK(unlike its neighbours — an older server reporting no such key genuinely has no such surface), so the tab is hidden rather than shown-and-404ing.Gates — all green, both flavours
Tests cover the gate on every route (anonymous 401 / non-admin 403, and a refused PUT writing nothing), credential injection and client-credential stripping against a fake upstream, both token layouts, the HTML rebase end-to-end, assets left alone, redirect/cookie rescoping, settings round-trip, degradation when Grafana is unreachable, the panels fallback, non-HTTP upstream and unknown-mode rejection, and the full ticket lifecycle including forgery, expiry, hashed-at-rest storage and revocation-by-demotion.
Open for Andrew —
questions/grafana-embed.mdNo Grafana config ask (none is needed). What is there instead:
keys/grafana_service_account, but the server must not read from a home directory, and a separate service account from the docs reporter would be cleaner.single_usergives every LAN device an authenticated Grafana, sinceisAdminis true for any private-network caller there. Not new — but this is the first admin surface proxying a third-party system's credential, so it deserves a deliberate decision. The config example carries the warning.grafana-image-renderer— only worth installing ifpanelsmode is ever wanted.Not merged, not closed, nothing deployed. All hound access was read-only GETs.
Merged: kreeader-ui c9f9c45 (Dashboards tab) and kreeader-server feat/admin-grafana; submodule pointers moved in server and client in the same pass. Ships OFF until [grafana] is configured; the single_user-mode exposure in questions/grafana-embed.md needs Andrew before enabling on butcher. Closing on deploy.
Deployed in 30.76.1-1, ships OFF (no [grafana] config). Enabling on butcher waits for Andrew's decision on the single_user-mode exposure (questions/grafana-embed.md); Sol finding 12 (ticket in nested URL) tracked in #156. Closing the build; enablement is config.