# ADR-0142: Stalwart WebAdmin reverse-proxy (opt-in, default-off)

**Date:** 2026-06-21
**Status:** Accepted — live-verified on 10.0.3.14 (jabali-panel.local) 2026-06-22
**Owner:** shuki
**Issue:** GH #243
**Companion plan:** [`plans/stalwart-webadmin-toggle-243.md`](../../plans/stalwart-webadmin-toggle-243.md)

## Context

Stalwart ships a native WebAdmin UI (queues, tracing, fine-grained settings the
panel doesn't surface), served on its management HTTP listener at
`127.0.0.1:8446`. M25 / ADR-0050 deliberately pinned that listener to loopback
so the mail-server admin is unreachable from outside the box. GH #243 asks to
*optionally* expose it via a Server Settings → Email toggle.

This is the **highest-value target on the host** — whoever reaches the admin can
read every mailbox, send as any hosted domain, and rewrite DKIM. So exposure
must be opt-in, defence-in-depth, and reversible.

## Decision

1. **Stalwart stays loopback (`127.0.0.1:8446`).** The M25 invariant is
   untouched — confirmed by `ss -ltnp` showing 8446 on 127.0.0.1 after enable.
   nginx is the only door.
2. **Default OFF.** `server_settings.stalwart_webadmin_enabled` defaults `0`
   (migration 000182). Existing + fresh installs expose nothing.
3. **nginx reverse-proxy vhost** (`mail.webadmin.apply` agent verb) on a
   **dedicated port of the panel hostname** (`https://<panel-hostname>:8449/`),
   reusing the panel cert. Rationale: a `admin.<host>` *subdomain* was tried
   first but browsers hard-refuse a self-signed `.local` subdomain cert
   (non-overridable), and minting a matching cert is a yak-shave. A port on the
   already-trusted panel hostname sidesteps the name-mismatch entirely, and
   Stalwart owns the origin root so its absolute asset paths (`/admin`, `/api`,
   `/logo`) resolve without rewriting. The bare root redirects to `/admin/`
   (`location = / { return 302 /admin/; }`) — Stalwart serves its WebAdmin SPA
   at `/admin/` (base href=/admin/), not `/`, which 404s.
4. **Defence in depth:**
   - **TLS** (panel cert, reused on the panel hostname:8449 — name already
     matches, no warning, no extra SAN needed).
   - **Source-IP allowlist** (`stalwart_webadmin_allow_cidrs` → nginx
     `allow`/`deny`), validated as IP/CIDR before write. This is now the primary
     network gate — strongly recommended; empty means any IP can reach the login.
   - **Stalwart's own admin login** underneath.
   - **nginx basic-auth was REMOVED** (2026-06-22). It collided with Stalwart's
     own admin auth: once the browser answered the basic-auth challenge it
     auto-sent `Authorization: Basic <gateway>` on the SPA's API XHRs, nginx
     forwarded it, and Stalwart 401'd the (wrong) credential — the WebAdmin
     could never load its config ("Failed to load the admin panel configuration.
     API error 401"). Two auth schemes can't share one `Authorization` header.
     The gate is now TLS + IP allowlist + Stalwart's own login. The agent
     deletes any stale htpasswd on apply.
   - **CrowdSec exemption (server-scope `access_by_lua_block { return }`).** The
     global CrowdSec nginx bouncer/AppSec WAF false-positives on the WebAdmin's
     own admin paths (`/admin`, `/login`, `/webadmin`, `/api/auth` read as
     admin-panel scans → 403), so the SPA never loaded. A no-op access_by_lua
     override exempts *only* this vhost; every other server still runs CrowdSec.
     Acceptable because the door is already IP-allowlisted to operator IPs
     (stricter than CrowdSec's banlist) and behind Stalwart's own login.
5. **Apply on the PATCH** (synchronous) + idempotent enable/disable. Enable
   opens UFW `8449/tcp`; disable removes the vhost + symlink + UFW rule. The
   agent writes `sites-available/` and symlinks into `sites-enabled/` only when
   absent, and deletes any stale basic-auth htpasswd on every apply.

**Rejected:** raw public bind of `:8446` (no gate); a sub-path on the panel host
(SPA base-path breakage + cookie-scope coupling); a `admin.<host>` subdomain
(browsers hard-refuse a self-signed `.local` subdomain cert, non-overridable —
abandoned for the port-on-panel-hostname approach above); panel-SSO
`auth_request` as the gateway (cross-host cookie/CORS against a unix-socket
panel-api — flagged dicey in the plan); and **nginx basic-auth** (shipped first,
then removed — it collides with Stalwart's own `Authorization`-header auth, see
Decision 4).

## Security checklist status

- [x] Stalwart listener still loopback-only (verified on mx).
- [x] Stalwart listener stays loopback-only; nginx is the only door.
- [x] TLS enforced (panel cert on :8449).
- [x] Source-IP allowlist emits `allow … ; deny all;` when set (unit-tested);
      it is the primary network gate now that basic-auth is gone.
- [x] Stalwart's own admin login gates the app itself.
- [x] basic-auth removed — no Authorization-header collision; the WebAdmin SPA
      can establish its own session (unit test asserts the vhost has no
      `auth_basic`).
- [x] Disable removes the vhost; apply deletes any stale htpasswd (unit-tested).
- [x] Off by default on fresh install + upgrade (migration default 0).

## Admin-login reveal / rotate (follow-up, shipped)

The WebAdmin's *second* prompt is Stalwart's own admin login — the recovery
admin seeded as `STALWART_RECOVERY_ADMIN=admin:<token>` in
`/etc/jabali-panel/stalwart.env`, whose token also lives in
`/etc/jabali-panel/stalwart-admin.token`. That same token is the **panel's
management credential**: `panel-agent` reads the `.token` file fresh on every
`mail.*` call; `panel-api` caches the env value at boot. So there is one shared
secret, not a GUI-only password.

Agent verb `mail.admin_cred.manage {action:"reveal"|"rotate"}` (panel route
`POST /admin/settings/stalwart-admin-credential`, card buttons "Reveal admin
login" / "Rotate admin password"):

- **reveal** returns `admin:<current token>` so the operator needn't SSH.
- **rotate** is **self-protecting**, ordered for crash-safety because the agent
  reads the `.token` file per call:
  1. back up `stalwart.env`, rewrite the `STALWART_RECOVERY_ADMIN` line,
  2. restart `jabali-stalwart`,
  3. **poll-verify** the new password authenticates against loopback
     `:8446/jmap/session` — a transport error is "not ready yet" (Stalwart
     rebinding), retried to a 25s deadline; only a live `401` is a real fail,
  4. **only then** publish the new token to `stalwart-admin.token` (the file
     `panel-agent` reads),
  5. schedule a `jabali-panel` restart via `systemd-run --on-active=3s` so the
     PATCH response (carrying the new password) returns before panel-api bounces.
  On any failure before step 4 the env is restored + Stalwart restarted, leaving
  the still-valid old token file untouched — the mail plane never breaks.

Verified live on 10.0.3.14 (2026-06-22): rotate → Stalwart accepts NEW, rejects
OLD (401), `domain.list` via the agent succeeds post-rotate, all three services
active. Confirmed (the dangerous case) that `STALWART_RECOVERY_ADMIN` is
re-read from env each boot, **not** persisted — so an env rewrite + restart is
sufficient to change the accepted password.

## Consequences

- New opt-in attack surface, but gated by TLS + IP allowlist + Stalwart's own login, and off by default. Net posture
  is stricter than the existing phpMyAdmin/Adminer exposure (mail admin > DB admin).
- No DNS record or extra cert needed: the vhost lives on the panel hostname's
  port 8449 and reuses the panel cert. Operator just opens the port (automatic
  via UFW on enable) and reaches `https://<panel-hostname>:8449/`.
- No reconciler convergence in v1 — the vhost is a file on disk that survives
  restarts; drift-repair is a follow-up.
