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 (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
--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:

  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:

{
  "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/<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/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.
S
Description
No description provided
Readme
461 KiB
0 Stars 1 Watchers 0 Forks
Languages
Python 60%
JavaScript 22%
CSS 10.7%
HTML 5.5%
Dockerfile 1.8%