# 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// ──► upstream origin ``` ## Run ```bash uv sync uv run uwudash-mk3 # http://127.0.0.1:8000 ``` Development reload: ```bash 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` | `/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` | `/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 → `/`. 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 decided at **first** login and never changed by logging in again - promotions and demotions go through `PATCH /api/users/{id}` (or the Settings page): 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. ## 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/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 `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.