UwuDash MK3
A single Python app that is both the frontend and the backend for a dashboard of internal services. The sidebar has one button per service; clicking it loads the service in an iframe through the dashboard's built-in reverse proxy, so the upstream services never need to be public.
browser ──► uwudash (host:port) ──► /proxy/<id>/<path> ──► upstream origin
Run
uv sync
uv run uwudash-mk3
# http://127.0.0.1:8000
Development reload:
uv run uwudash-mk3 --reload
Command line flags
| Flag | Default | Meaning |
|---|---|---|
--host |
127.0.0.1 |
bind address |
--port |
8000 |
bind port |
--data-dir |
data |
directory holding services.json |
--services-file |
<data-dir>/services.json |
explicit store path |
--oidc-issuer |
unset | OIDC issuer; enables sign-in together with the client env vars |
--public-url |
request origin | external base URL used for the OIDC redirect_uri |
--reload |
off | uvicorn reload on source changes |
Environment variables
| Variable | Default | Meaning |
|---|---|---|
UWUDASH_HOST |
127.0.0.1 |
bind address |
UWUDASH_PORT |
8000 |
bind port |
UWUDASH_DATA_DIR |
data |
data directory |
UWUDASH_SERVICES_FILE |
data/services.json |
services store path |
UWUDASH_PROXY_TIMEOUT |
30.0 |
upstream request timeout (seconds) |
UWUDASH_STRIP_FRAME_OPTIONS |
true |
strip X-Frame-Options and CSP frame-ancestors from proxied responses |
UWUDASH_OIDC_ISSUER |
unset | OIDC issuer, e.g. https://id.example.com (auth stays off unless issuer, client id and secret are all set) |
UWUDASH_OIDC_CLIENT_ID |
unset | OIDC client id |
UWUDASH_OIDC_CLIENT_SECRET |
unset | OIDC client secret |
UWUDASH_OIDC_SCOPE |
openid email profile |
requested scope |
UWUDASH_OIDC_ADMIN_CLAIM |
unset | claim to inspect for admin group membership, e.g. groups |
UWUDASH_OIDC_ADMIN_VALUES |
admin |
comma-separated claim values that grant admin |
UWUDASH_SESSION_SECRET |
random per start | signed-session cookie secret (set it, or sessions die on restart) |
UWUDASH_SESSION_TTL |
3600 |
session lifetime in seconds |
UWUDASH_USERS_FILE |
<data-dir>/users.json |
user store path |
UWUDASH_PUBLIC_URL |
request origin | external base URL for the OIDC redirect_uri |
CLI flags override environment variables.
Authentication (opt-in)
With no issuer set the dashboard is open, exactly as before: no /auth/* routes
are registered and every API route is reachable from the LAN.
Setting UWUDASH_OIDC_ISSUER + UWUDASH_OIDC_CLIENT_ID + UWUDASH_OIDC_CLIENT_SECRET
turns sign-in on. The provider is discovered from
<issuer>/.well-known/openid-configuration (Authlib reads the authorize, token,
JWKS and end_session_endpoint URLs), so nothing here is provider-specific -
Keycloak, Authentik, Auth0 and the like all work.
export UWUDASH_OIDC_ISSUER=https://id.example.com
export UWUDASH_OIDC_CLIENT_ID=uwudash
export UWUDASH_OIDC_CLIENT_SECRET=...
export UWUDASH_OIDC_ADMIN_CLAIM=groups
export UWUDASH_OIDC_ADMIN_VALUES=dash-admin
export UWUDASH_SESSION_SECRET=$(openssl rand -hex 32)
uv run uwudash-mk3 --public-url https://dash.example.com
Flow: / with no session redirects to /auth/login → Sign in →
/auth/authorize → the provider → /auth/callback validates the id_token
against the issuer's JWKS → a signed session cookie (HttpOnly, SameSite=lax,
max_age=UWUDASH_SESSION_TTL) holds the user id → /.
/auth/callback also copes with providers whose id_token authlib cannot read:
an encrypted one (a 5-segment JWE, e.g. an Authentik provider with an
Encryption Key) is resolved through the issuer's userinfo_endpoint, and an
HS256 one (what Authentik issues when no Signing Key is configured) is
verified with the client secret. Claims that fail validation - wrong iss,
aud, exp or nonce - are rejected outright: the login redirects to
/auth/login?error=... instead of returning a 500.
Once auth is on, every route except /health, /auth/*, /static and FastAPI's
own /docs / /openapi.json requires a session: unauthenticated requests get
401 (API) or a redirect to /auth/login (the HTML page).
Roles
| Role | Can |
|---|---|
admin |
everything a viewer can, plus create/update/delete services and list or change user roles |
viewer |
read the service list, open services through the proxy, see their own profile |
The role is recomputed on every login, but a login only ever promotes: a
matching UWUDASH_OIDC_ADMIN_CLAIM turns an existing viewer into admin,
while an existing admin is never demoted by a login - demotions go through
PATCH /api/users/{id} (or the Settings page), so a misconfigured claim
cannot lock the dashboard out:
- if
UWUDASH_OIDC_ADMIN_CLAIMis set and the token's claim (scalar or list) contains one ofUWUDASH_OIDC_ADMIN_VALUES→admin; - otherwise, if the user store is empty →
admin(the first person to log in bootstraps the dashboard); - otherwise →
viewer.
Users are stored in data/users.json (same atomic-write format as
data/services.json), keyed by the OIDC sub; email and name are refreshed
on each login.
Signing out
/auth/logout (GET or POST) always clears the local session. If the issuer
advertises an end_session_endpoint, it then does RP-initiated logout with the
saved id_token and returns to /auth/login afterwards; otherwise it returns to
/auth/login directly.
Services
Services live in data/services.json:
{
"version": 1,
"services": [
{
"id": "grafana",
"name": "Grafana",
"url": "http://10.6.9.199:3000/dashboards",
"description": "Metrics dashboards",
"icon": "G",
"open_in": "iframe",
"enabled": true,
"headers": { "Authorization": "Bearer ..." }
}
]
}
idis generated from the name (slugified, unique) and stays stable across edits.urlmay include a path; that path becomes the landing path of the proxy.open_inisiframe(default) ornewtab.headersare injected into every proxied request to that service.enabled: falsekeeps the entry in the sidebar but the proxy returns403.
Edit them from the Settings page in the UI, or via the API.
How the proxy works
Only the upstream origin (scheme://host:port) is mapped onto the dashboard:
/proxy/<id> -> <origin>/
/proxy/<id>/<path> -> <origin>/<path>
So a service with url = http://internal-host:8080/sub/page is served at
/proxy/<id>/sub/page, and any link the upstream page emits to
http://internal-host:8080/... or //internal-host:8080/... is rewritten to
/proxy/<id>/.... Root-relative links (/foo/bar) are pulled back onto the
proxy prefix too - they would otherwise resolve against the dashboard and 404.
Links relative to the current page (foo/bar) need no rewriting.
Details:
Locationheaders on3xxresponses are rewritten back onto the proxy prefix; redirects are not followed, so the browser stays on the proxy.text/htmlresponses are buffered and rewritten.application/jsonis rewritten as well, but only values that start with the service's own base path: single-page apps read their base path from an API config (Sonarr'sinitialize.jsonreturnsurlBase/apiRoot), while plain data fields such as filesystem paths are left alone. Everything else (CSS, images, downloads) is streamed through unchanged.- Upstream compression (
gzip,deflate,br) is decoded by httpx, soContent-EncodingandContent-Lengthare never forwarded - the body the browser receives is always uncompressed and header-consistent. X-Frame-Optionsand CSPframe-ancestorsare stripped from proxied responses so the iframe can render (disable withUWUDASH_STRIP_FRAME_OPTIONS=false).- Hop-by-hop request/response headers are dropped; per-service
headersare merged into the upstream request. - Request bodies are buffered with a 10 MB cap (
413above it). - WebSocket upgrades are rejected with
400- the proxy is HTTP only.
API
| Method | Path | Purpose |
|---|---|---|
GET |
/api/services |
list services |
POST |
/api/services |
create |
GET |
/api/services/{id} |
read |
PATCH / PUT |
/api/services/{id} |
update |
DELETE |
/api/services/{id} |
delete |
GET |
/api/services/{id}/health |
upstream reachability check |
GET |
/api/config |
effective config (never includes the OIDC secret) |
GET |
/api/me |
current user (only when auth is enabled) |
GET |
/api/users |
list users - admin only |
PATCH |
/api/users/{id} |
change a role - admin only |
GET |
/auth/login |
sign-in page |
GET |
/auth/authorize |
start the OIDC flow |
GET |
/auth/callback |
provider redirect target |
GET / POST |
/auth/logout |
clear the session, then RP-initiated logout |
GET |
/health |
dashboard health |
ANY |
/proxy/{id} and /proxy/{id}/{path} |
reverse proxy |
Layout
src/uwudash_mk3/
config.py env-driven Config dataclass
models.py Service / ServiceInput / ServicePatch / User / UserPatch + URL helpers
store.py JSON file store (atomic writes, lock)
users.py JSON user store (same pattern as store.py)
auth.py OIDC client, login/callback/logout, role dependencies
proxy.py reverse proxy: header filtering, HTML/redirect rewriting
api.py /api routes
app.py FastAPI factory, session middleware, static mount, proxy routes
main.py CLI entrypoint
templates/ index.html, login.html
static/ styles.css, app.js
Limitations
- Authentication is opt-in OIDC (see above). Without an issuer the dashboard is
still open by design - it is meant for a trusted LAN. Sign-in gates the
dashboard, not the upstream services: the proxy does not forward the dashboard
session, so each service still needs its own login or a per-service
headerstoken. - Roles are decided at first login and only change through the admin API; there is no group sync after that, and no per-service permissions.
- No WebSocket proxying. Apps that use WebSockets for live updates (Sonarr's SignalR) fall back to SSE/long-polling through the proxy.
- Upstream cookies work, but they are scoped to the dashboard origin, so cross-service cookie sharing is possible if two services share a proxy host.