Pages are a second item type beside services: title + icon + plain-text body, stored in data/pages.json (PageStore mirrors ServiceStore: lock, atomic temp + os.replace, slugify-collision ids) and served by the /api/pages family with the same admin-write / any-session-read gate. The viewer area opens on the Home notice board instead of "Nothing selected": state.selected starts at the HOME sentinel, the startup chain ends in openHome(), and deleting whatever was being viewed falls back to the board via showHome(), which repaints without touching which section is active so a delete from the Settings table leaves Home ready for settingsClose. PUT is omitted on purpose: the UI only posts/patches and pages have no non-patchable fields.
14 KiB
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
Container
docker build -t uwudash-mk3 .
docker run -d -p 8000:8000 -v uwudash-data:/app/data uwudash-mk3
# git.lovelynet.net also serves a container registry on the same host
docker login https://git.lovelynet.net
docker tag uwudash-mk3 git.lovelynet.net/benjamyn/uwudash_mk3:latest
docker push git.lovelynet.net/benjamyn/uwudash_mk3:latest
docker pull git.lovelynet.net/benjamyn/uwudash_mk3:latest # any other machine
docker login wants the forge's registry credentials, not the OIDC sign-in
password - a wrong password fails with unauthorized at /v2/, and a
successful login is remembered in ~/.docker/config.json for later pushes.
The image is two-stage: uv build produces the wheel from the src/ layout, a
clean stage installs it into /app/.venv, and the entrypoint is the
uwudash-mk3 console script. It runs as uid 10001 and binds 0.0.0.0:8000
(UWUDASH_HOST / UWUDASH_PORT are baked in as ENV defaults - override both
the --env and the -p mapping if you change the port).
/app/data is a VOLUME holding services.json, pages.json (and users.json
once auth is on): a named volume keeps your service list across restarts, a bind mount
(-v ./data:/app/data) seeds the container from the host file instead. data/
is in .dockerignore, so internal URLs and per-service headers tokens never
land in an image layer. With a bind mount the directory must be writable by uid
10001 (chown -R 10001 data); a named volume inherits the image's ownership.
Sign-in from a container - secrets come from the environment, never the build
context (.dockerignore excludes .secrets, .oauth_creds and start.sh):
docker run -d -p 8000:8000 -v uwudash-data:/app/data \
--env UWUDASH_OIDC_ISSUER=https://id.example.com \
--env UWUDASH_OIDC_CLIENT_ID=uwudash \
--env UWUDASH_OIDC_CLIENT_SECRET=... \
--env UWUDASH_SESSION_SECRET="$(openssl rand -hex 32)" \
--env UWUDASH_PUBLIC_URL=https://dash.example.com \
uwudash-mk3
HEALTHCHECK polls /health, the one route that needs no session, so the
container stays healthy with auth on.
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 |
--pages-file |
<data-dir>/pages.json |
explicit notice 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_PAGES_FILE |
data/pages.json |
notice 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.
Notice pages
Notices are native dashboard content - no URL, no proxy. They live in
data/pages.json (same atomic-write format as services.json):
{
"version": 1,
"pages": [
{
"id": "maintenance",
"name": "Maintenance",
"icon": "🔧",
"body": "Server down tonight\nBring snacks",
"created_at": "2026-10-28T09:29:31+00:00",
"updated_at": "2026-10-28T09:29:31+00:00"
}
]
}
idis slugified from the name and stays stable across edits;bodyis capped at 5000 characters and keeps its line breaks (white-space: pre-wrap, escaped).- Each notice gets a sidebar button; clicking it shows a single card in the viewer area instead of the iframe.
- The notice board is the default view: landing on
/already shows it in the viewer area (banner plus every notice, newest first), no click needed. The pinned Home button (🏠, always present) re-opens it, and deleting whatever you were viewing falls back to it./itself stays the dashboard. - Writes are admin-only when OIDC is on, reads any session - same gate as services.
Edit them from the Settings page (the "Add notice" form and the notice table), 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/pages |
list notices |
POST |
/api/pages |
create - admin only |
GET |
/api/pages/{id} |
read |
PATCH |
/api/pages/{id} |
update - admin only |
DELETE |
/api/pages/{id} |
delete - admin only |
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 / Page / PageInput / PagePatch / User / UserPatch + URL helpers
store.py JSON file store (atomic writes, lock)
pages.py JSON notice store (same pattern as store.py)
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.