384 lines
17 KiB
Markdown
384 lines
17 KiB
Markdown
# 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. The sidebar is one viewport tall -
|
|
once the buttons overflow it, its own list scrolls and the rest of the page
|
|
stays put.
|
|
|
|
```
|
|
browser ──► uwudash (host:port) ──► /proxy/<id>/<path> ──► upstream origin
|
|
```
|
|
|
|
## Run
|
|
|
|
```bash
|
|
uv sync
|
|
uv run uwudash-mk3
|
|
# http://127.0.0.1:8000
|
|
```
|
|
|
|
Development reload:
|
|
|
|
```bash
|
|
uv run uwudash-mk3 --reload
|
|
```
|
|
|
|
### Container
|
|
|
|
```bash
|
|
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`, `widgets.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`):
|
|
|
|
```bash
|
|
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 |
|
|
| `--widgets-file` | `<data-dir>/widgets.json` | explicit widget 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_WIDGETS_FILE` | `data/widgets.json` | widget store path |
|
|
| `UWUDASH_WIDGET_CACHE_TTL` | `300` | seconds a live widget's upstream payload is reused (0 = fetch every render) |
|
|
| `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.
|
|
|
|
```bash
|
|
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:
|
|
|
|
1. if `UWUDASH_OIDC_ADMIN_CLAIM` is set and the token's claim (scalar or list)
|
|
contains one of `UWUDASH_OIDC_ADMIN_VALUES` → `admin`;
|
|
2. otherwise, if the user store is empty → `admin` (the first person to log in
|
|
bootstraps the dashboard);
|
|
3. 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`:
|
|
|
|
```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 ..." }
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
- `id` is generated from the name (slugified, unique) and stays stable across edits.
|
|
- `url` may include a path; that path becomes the landing path of the proxy.
|
|
- `open_in` is `iframe` (default) or `newtab`.
|
|
- `headers` are injected into every proxied request to that service.
|
|
- `enabled: false` keeps the entry in the sidebar but the proxy returns `403`.
|
|
|
|
Edit them from the **Settings** page in the UI, or via the API.
|
|
|
|
## Widgets
|
|
|
|
Widgets are native dashboard content - no iframe, no URL bar. They live in
|
|
`data/widgets.json` (same atomic-write/lock format as `services.json`) and come
|
|
in three kinds:
|
|
|
|
| kind | what it shows | what it needs |
|
|
| --- | --- | --- |
|
|
| `notice` | pinned text, nothing fetched | `body` (≤ 5000 chars, line breaks kept), `severity` (`info`/`heads-up`/`urgent`, default `info`) |
|
|
| `calendar` | a release window from a Sonarr-family API | `base_url`, `api_prefix` (default `/api/v3`), `days` (1-90), `date_type` (`auto`/`physical`/`digital`/`cinemas`/`release`) |
|
|
| `recent` | what just landed (`<base_url>/library/recentlyAdded`) | `base_url`, `limit` (1-50) |
|
|
|
|
```json
|
|
{
|
|
"version": 1,
|
|
"widgets": [
|
|
{
|
|
"id": "upcoming",
|
|
"name": "Upcoming",
|
|
"icon": "",
|
|
"kind": "calendar",
|
|
"body": null,
|
|
"severity": "info",
|
|
"base_url": "http://internal-host:8080",
|
|
"headers": { "X-Api-Key": "redacted" },
|
|
"api_prefix": "/api/v3",
|
|
"days": 14,
|
|
"date_type": "auto",
|
|
"limit": 12,
|
|
"created_at": "2026-10-28T09:29:31+00:00",
|
|
"updated_at": "2026-10-28T09:29:31+00:00"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
- `id` is slugified from the name and stays stable across edits.
|
|
- Live widgets are fetched **at render time**, never at save time: the first
|
|
render after a save fetches, later renders reuse the payload until
|
|
`UWUDASH_WIDGET_CACHE_TTL` (default 300 s) expires. The cache key is
|
|
`<id>@<updated_at>`, so editing a widget invalidates it immediately. Errors
|
|
cache for at most 60 s so a fixed upstream recovers quickly.
|
|
- Upstream keys go in `headers` (the form's "Headers" textarea, one `Key: value`
|
|
per line) - they stay on the server-to-server hop and never appear in the
|
|
browser.
|
|
- Unreachable upstreams degrade instead of blanking the board: the card shows
|
|
`unreachable: <error>` and the Settings table's Test button relays the exact
|
|
upstream message (`connection refused`, Sonarr's `ApiKey was not provided`...).
|
|
- Artwork (Radarr posters, Plex thumbs) is relayed through
|
|
`/api/widgets/{id}/image?u=...` - the browser cannot reach the internal host,
|
|
and the relay only ever fetches from that widget's own origin.
|
|
- Calendar cards always name the show, not just the episode: Sonarr's calendar
|
|
items carry only `seriesId` plus an episode `title`, so the request asks for
|
|
`includeSeries=true` and reads the name off the nested `series` object (Radarr
|
|
ignores that parameter). Plex episodes take the show from `grandparentTitle`
|
|
(`parentTitle` is the season), skipped when it repeats the episode title.
|
|
- `severity` is notice-only: `urgent`/`heads-up`/`info` tints the card edge, the
|
|
chip, and the icon on Home. Setting it on a live widget is a validation error
|
|
(live rows have no severity to sort by).
|
|
- Home renders one merged release group (every `calendar` widget, ascending by
|
|
date) and one group per `recent` widget (newest first) - each group is a header
|
|
(title, refresh stamp, per-widget errors) followed by one card per release,
|
|
laid out as a wrapping grid - then notices, urgent first, then heads-up then
|
|
info, each group in creation order. Cards are tinted by the app that produced
|
|
them (Sonarr teal, Radarr violet, Plex orange - separate light/dark shades of
|
|
each), on the card edge, the source chip, and a legend chip per origin in the
|
|
group header, so a merged timeline says which app each card came from. The
|
|
next upcoming release that carries artwork also gets a full-width hero tile
|
|
above the groups (its relayed thumb plus title and date), ringed in the same
|
|
origin hue. Each widget gets its own sidebar button for a
|
|
single-widget view, its left edge tinted with the same origin hue once the
|
|
widget has returned rows (an errored or empty widget stays accent-blue).
|
|
- Writes are admin-only when OIDC is on, reads any session - same gate as
|
|
services.
|
|
|
|
Edit them from the **Settings** page (the "Add widget" form and the widget
|
|
table, which carries a Test button per live widget), 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:
|
|
|
|
- `Location` headers on `3xx` responses are rewritten back onto the proxy prefix;
|
|
redirects are **not** followed, so the browser stays on the proxy.
|
|
- `text/html` responses are buffered and rewritten. `application/json` is
|
|
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's
|
|
`initialize.json` returns `urlBase`/`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, so
|
|
`Content-Encoding` and `Content-Length` are never forwarded - the body the
|
|
browser receives is always uncompressed and header-consistent.
|
|
- `X-Frame-Options` and CSP `frame-ancestors` are stripped from proxied responses
|
|
so the iframe can render (disable with `UWUDASH_STRIP_FRAME_OPTIONS=false`).
|
|
- Hop-by-hop request/response headers are dropped; per-service `headers` are merged
|
|
into the upstream request.
|
|
- Request bodies are buffered with a 10 MB cap (`413` above 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/widgets` | every widget, with live data inlined for `calendar`/`recent` |
|
|
| `POST` | `/api/widgets` | create - admin only |
|
|
| `GET` | `/api/widgets/{id}` | read the stored config |
|
|
| `PATCH` | `/api/widgets/{id}` | update - admin only |
|
|
| `POST` | `/api/widgets/{id}/refresh` | bypass the cache and fetch now - admin only |
|
|
| `GET` | `/api/widgets/{id}/image?u=...` | relay one artwork URL from that widget's own origin |
|
|
| `DELETE` | `/api/widgets/{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 / Widget / WidgetInput / WidgetPatch / User / UserPatch + URL helpers
|
|
store.py JSON file store (atomic writes, lock)
|
|
widgets.py JSON widget store + server-side fetch/cache/relay layer
|
|
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 `headers`
|
|
token.
|
|
- 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.
|