# Web UI — Management Dashboard Server-rendered dashboard (`ci web serve`) for the local CI/CD system: recent CI executions with their live VMs (§9), cargo cache, disk, and act_runner status, plus confirm-gated destructive actions (VM remove/cleanup, cache purge, template backup) with an audit trail. In-process only — it imports `operations/` and `backends.load_backend` directly and never shells out to the CLI. See `plans/web-ui-management-plan.md` (steps W0–W7) for the design history and `config.example.toml`'s `[web]` / `[web.oidc]` section for every config key referenced below. --- ## 0. Current deployment on this host Live since 2026-07-23: `ci-web.service` is enabled + running, bound to the LAN (`192.168.10.11:8800`), authenticating against `https://gitea.emulab.it` via OIDC. Only the allowlisted Gitea user(s) in `/var/lib/ci/config.toml`'s `[web].allowed_users` can log in. Manage it exactly as described in §4 below (`systemctl status/restart ci-web.service`, `journalctl -u ci-web.service -f`). `WebSessionSecret` and `WebOidcClientSecret` are already stored in the `ci-runner` keyring — no action needed unless they're rotated (§3/§6). --- ## 1. Prerequisites - The Linux host already bootstrapped by `setup-host-linux.sh` (`ci-runner` service user, `/opt/ci/venv`, `/var/lib/ci/config.toml`). - A reachable Gitea instance to authenticate against (OIDC/OAuth2) — only required if you intend to bind the dashboard on the LAN; a loopback-only install can skip Gitea entirely (see §5, "open mode"). - Outbound network from the host to Gitea's `/.well-known/openid-configuration` endpoint, if OIDC is enabled. Nothing here needs internet access — `ci_orchestrator[web]`'s dependencies (FastAPI, Jinja2, Authlib, uvicorn, ...) install from the same package index already used for the core install, and every frontend asset (`web/static/app.css`, `web/static/htmx.min.js`) is vendored in the repo, no CDN. --- ## 2. Running the installer ```bash cd /opt/ci/local-ci-cd-system sudo bash setup-web-linux.sh --host 127.0.0.1 # loopback-only, no OIDC needed # or, once OIDC is registered (see §3): sudo bash setup-web-linux.sh --host 192.168.1.50 # LAN ``` `setup-web-linux.sh` is idempotent — every mutating step checks its own state first and skips (with a message) when already satisfied, so re-running it (e.g. after `git pull` + a code change) is always safe: 1. `pip install '[web]'` into `/opt/ci/venv`, **non-editable** — per `CLAUDE.md`, the production venv can't resolve editable installs when `act_runner`/systemd launches Python from a transient working directory. 2. Renders `deploy/systemd/ci-web.service` into `/etc/systemd/system/`, substituting the bind IP you passed via `--host`. 3. Appends a **commented** `[web]` / `[web.oidc]` template to `/var/lib/ci/config.toml` if no such section exists yet — it never overwrites an existing one, and never writes a secret value. 4. Prompts (interactively, one at a time) to store `WebSessionSecret` and `WebOidcClientSecret` in the keyring, if they aren't already set. The secret is only ever typed at the terminal prompt — the installer never accepts it as a command-line argument and never echoes or logs it. 5. Prints the manual Gitea OIDC application registration steps (see §3). 6. `systemctl daemon-reload` then enables + starts (or restarts, if already running) `ci-web.service`. Run `setup-web-linux.sh --help` for the full flag list (`--skip-pip`, `--skip-secrets`, `--config-path`). After step 3/4, edit `/var/lib/ci/config.toml`: uncomment the appended `[web]` / `[web.oidc]` block, fill in `issuer`, `client_id`, `allowed_users`, then restart the service (`systemctl restart ci-web.service`). --- ## 3. Gitea OAuth2 application registration The dashboard authenticates against Gitea via OIDC (Authlib + Gitea's `/.well-known/openid-configuration` discovery document). Register the application **before** enabling `[web.oidc]`: 1. In Gitea: **Settings → Applications → "Manage OAuth2 Applications" → Create a new OAuth2 Application**. 2. **Application Name**: anything recognizable, e.g. `ci-web`. 3. **Redirect URI**: `http://:8800/auth/callback` — this **must match `[web.oidc].redirect_url` exactly** (scheme, host, port, and path). A mismatch is the #1 cause of a login redirect loop (see §6). 4. Gitea shows a **Client ID** and **Client Secret** exactly once: - Client ID → `[web.oidc].client_id` in `config.toml` (not a secret, plain text is fine). - Client Secret → **never put it in `config.toml`**. Store it in the keyring instead: ```bash sudo -u ci-runner /opt/ci/venv/bin/python -m ci_orchestrator creds set \ --target WebOidcClientSecret --user web-oidc-client ``` `config.toml` only ever holds the keyring *key name* (`client_secret_cred = "WebOidcClientSecret"`), never the value. 5. Add the Gitea username(s) allowed to log in to `[web].allowed_users`. **The allowlist is fail-closed**: an empty list denies everyone, it never means "allow all". 6. For the Executions card (§9), add an API read scope to `[web.oidc].scopes` — `read:repository`, plus `read:admin` if the account is a Gitea admin and you want runs from every repo on the instance. New scopes take effect after one fresh login (Gitea shows the consent screen again); existing sessions keep working with the old scopes until then. --- ## 4. Operating ```bash # Start / stop / restart sudo systemctl start ci-web.service sudo systemctl stop ci-web.service sudo systemctl restart ci-web.service # after a config.toml edit or a code upgrade # Status systemctl status ci-web.service # Logs (uvicorn access log + app stdout/stderr go to the journal) journalctl -u ci-web.service -f journalctl -u ci-web.service -n 100 --no-pager # Health check (unauthenticated, always 200). Use the address the service is # bound to -- uvicorn listens on `[web].bind_host` only, so on this host # (192.168.10.11) a loopback request gets connection-refused, not a 200. curl -s http://192.168.10.11:8800/healthz ``` Once code changes: `sudo bash setup-web-linux.sh --host ` reinstalls the `[web]` extra and restarts the service in one step (steps 2–6 no-op if nothing else changed). --- ## 5. Firewall / security - **Bind only a specific LAN IP, never `0.0.0.0`.** Neither `ci web serve` nor `setup-web-linux.sh` will ever write `0.0.0.0` — the installer prompts for (or accepts via `--host`) one explicit address, defaulting to `127.0.0.1`. - **Open mode (no `[web.oidc]` configured) is localhost-only, enforced at startup.** `ci web serve` refuses to start if you bind a non-loopback address while `[web.oidc]` is absent — an unauthenticated dashboard that can destroy VMs and purge caches must never be reachable from the LAN. Configure `[web.oidc]` first, or bind `127.0.0.1`. - **`/docs`, `/redoc`, `/openapi.json` are gated behind login too.** FastAPI auto-registers these with no auth of their own; the app disables its built-in ones (`docs_url=None` etc.) and re-adds them behind `require_user`, same as every data route — otherwise anyone who can reach the port gets a full map of every route (including every destructive one) with no session needed. - Every mutating route (VM remove/cleanup, cache purge, template backup, template delete, backup delete) is behind a synchronizer-token CSRF check and a two-step confirm gate (a preview/dry-run, then an explicit "Confirm" that actually executes) — there is no one-click destroy. - **Template delete has no backup-required safety rail.** It removes the entire template directory — the golden image VM clones are made from, not a single backup archive — behind the same confirm gate as everything else, but nothing stops you from deleting a template with zero backups. Use the adjacent Backup button first if you want a way back. - Every mutating action appends exactly one entry to the audit log (`[web].audit_log`, default `/var/lib/ci/logs/web-audit.jsonl`), viewable at `/audit` in the dashboard. - If you run a host firewall (ufw/nftables/iptables), only open the chosen port (default `8800`) to the specific LAN/VLAN that needs dashboard access, not the whole network. --- ## 6. Keyring / headless note `ci-web.service` runs as `ci-runner` under systemd — there is no desktop session and no D-Bus, so the GNOME Secret Service backend (`secret-tool`) does not work here. This host uses **`keyrings.alt.file.PlaintextKeyring`** instead (already the credential backend used for guest/Gitea credentials — see `plans/archived/2026-05-23/B3-closeout.md`): - Config: `/home/ci-runner/.local/share/python_keyring/keyringrc.cfg` (`[backend] default-keyring=keyrings.alt.file.PlaintextKeyring`) - Store: `/home/ci-runner/.local/share/python_keyring/keyring_pass.cfg` — plain text, readable only by `ci-runner` (file permissions), no D-Bus session required. Verified to work both from `sudo -u ci-runner ...` and from inside a plain systemd service. - This is why `ci-web.service`'s hardening uses `ProtectHome=read-only` and **not** `ProtectHome=yes`/`true`: the latter hides `/home` entirely from the unit's mount namespace, which would break every OIDC/session secret lookup and make login fail even though the keyring itself is fine. `read-only` keeps `/home/ci-runner` readable (the app never writes to the keyring at request time — secrets are set once, out-of-band, by `ci creds set`) while still blocking writes. - `WebSessionSecret` / `WebOidcClientSecret` are keyring key *names* in `config.toml` (`session_secret_cred`, `client_secret_cred`) — the actual values live only in the keyring, set via `ci creds set --target --user