# 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// ──► 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` | `/services.json` | explicit store path | | `--widgets-file` | `/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` | `/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 `/.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 (`/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 `@`, 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: ` 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/ -> / /proxy// -> / ``` So a service with `url = http://internal-host:8080/sub/page` is served at `/proxy//sub/page`, and any link the upstream page emits to `http://internal-host:8080/...` or `//internal-host:8080/...` is rewritten to `/proxy//...`. 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.