Multi-tenant hardening for a hosted instance #47

Open
opened 2026-06-27 16:46:55 +00:00 by robocub · 3 comments
Collaborator

Roadmap: Stage 5 — the hardening work that makes the public instance (#43) safe to actually run. Depends on the lean ghosts (#44), runtime provisioning (Stage 2), and per-guild multiplexing (Stage 3).

  • Per-tenant resource isolation (process/runtime per tenant) so one tenant's call can't starve another's FD/CPU/homeserver budget. Made affordable by #44 shrinking the per-ghost footprint from "~32 FDs + a sync loop" to "some memory + occasional HTTP".
  • Automatic room-prerequisite detection with friendly errors: PL 0 for org.matrix.msc3401.call.member, and whether the tenant's homeserver actually runs MatrixRTC / Element Call (no EC support => no call to join).
  • Discord ToS review for a public voice relay (vs. a private self-hosted one), plus bot-verification logistics past the 100-guild-per-application gate.
  • Quotas / abuse-surface controls (rate limits, per-tenant caps).

Background analysis in docs/nether-voicebridge-public-instance-feasibility.md (Tier 2).

**Roadmap: Stage 5 — the hardening work that makes the public instance (#43) safe to actually run.** Depends on the lean ghosts (#44), runtime provisioning (Stage 2), and per-guild multiplexing (Stage 3). - **Per-tenant resource isolation** (process/runtime per tenant) so one tenant's call can't starve another's FD/CPU/homeserver budget. Made affordable by #44 shrinking the per-ghost footprint from "~32 FDs + a sync loop" to "some memory + occasional HTTP". - **Automatic room-prerequisite detection** with friendly errors: PL 0 for `org.matrix.msc3401.call.member`, and whether the tenant's homeserver actually runs MatrixRTC / Element Call (no EC support => no call to join). - **Discord ToS review** for a *public* voice relay (vs. a private self-hosted one), plus bot-verification logistics past the 100-guild-per-application gate. - **Quotas / abuse-surface controls** (rate limits, per-tenant caps). Background analysis in `docs/nether-voicebridge-public-instance-feasibility.md` (Tier 2).
dark added this to the Roadmap project 2026-06-27 16:48:04 +00:00
Author
Collaborator

Slice 1 shipped: link now probes room prerequisites before writing (merged to master, 34bed3b + docs ad30670).

The management bot's link no longer replies with an advisory checklist — it verifies up front, and a failed probe writes nothing:

  1. Join probe (hard, 15s) — appservice-asserted join; restricted/invite-only rooms get "invite @voicebridge_as first, then run link again".
  2. Power levels (hard, 10s) — reads raw m.room.power_levels, evaluates events["org.matrix.msc3401.call.member"]state_default fallback → spec default 50, against users_default (what ghosts join at). The error explains the Element Call / devtools fix and warns that Element's "Roles & Permissions" UI label is unreliable. Absent event fails safe.
  3. MatrixRTC focus (hard, 10s) — .well-known RTC-foci discovery, exactly what the spawned bridge will do.
  4. Discord guild coverage (warn only) — new ControlMsg::GuildCoverage answered by the supervisor from pool memory; warn-only because bots learn guilds at gateway ready, so a fresh process can't tell "not invited" from "still connecting".

Live-tested on staging 2026-07-05 (all four scenarios): positive link against Auto Test 01 → all ✓ and bridge up; fresh default-PL room → the PL error verbatim, tenants.toml untouched (bonus: Tuwunel's createRoom seeds an explicit events[call.member]=50, so the events-override path got exercised live); restricted room without invite → M_FORBIDDEN with the invite hint, nothing written; bogus guild → link succeeds with the ⚠️ coverage warning. Zero errors in the log; unit tests cover the PL evaluation matrix (override / fallback / absent / string-encoded legacy levels).

Remaining #47 scope: Discord ToS research doc (slice 2), per-tenant isolation design doc (slice 3); link authority model + quotas still deferred.

**Slice 1 shipped: `link` now probes room prerequisites before writing (merged to master, `34bed3b` + docs `ad30670`).** The management bot's `link` no longer replies with an advisory checklist — it verifies up front, and a failed probe writes nothing: 1. **Join probe** (hard, 15s) — appservice-asserted join; restricted/invite-only rooms get "invite `@voicebridge_as` first, then run `link` again". 2. **Power levels** (hard, 10s) — reads raw `m.room.power_levels`, evaluates `events["org.matrix.msc3401.call.member"]` → `state_default` fallback → spec default 50, against `users_default` (what ghosts join at). The error explains the Element Call / devtools fix and warns that Element's "Roles & Permissions" UI label is unreliable. Absent event fails safe. 3. **MatrixRTC focus** (hard, 10s) — `.well-known` RTC-foci discovery, exactly what the spawned bridge will do. 4. **Discord guild coverage** (warn only) — new `ControlMsg::GuildCoverage` answered by the supervisor from pool memory; warn-only because bots learn guilds at gateway `ready`, so a fresh process can't tell "not invited" from "still connecting". **Live-tested on staging 2026-07-05** (all four scenarios): positive link against Auto Test 01 → all ✓ and bridge up; fresh default-PL room → the PL error verbatim, `tenants.toml` untouched (bonus: Tuwunel's createRoom seeds an explicit `events[call.member]=50`, so the events-override path got exercised live); restricted room without invite → M_FORBIDDEN with the invite hint, nothing written; bogus guild → link succeeds with the ⚠️ coverage warning. Zero errors in the log; unit tests cover the PL evaluation matrix (override / fallback / absent / string-encoded legacy levels). Remaining #47 scope: Discord ToS research doc (slice 2), per-tenant isolation design doc (slice 3); link authority model + quotas still deferred.
Author
Collaborator

Slice 3 (per-tenant isolation) — first hardening shipped: per-bridge panic boundary, merged to master 2b56935 (unreleased, rides the next release).

Finding: every bridge future and the shared puppet-pool future are polled on ONE task (they're !Send), and nothing caught panics — so a panic in a single bridge's nvb_matrix_rtc::run unwound the whole supervisor task and took down every co-resident tenant plus the pool. Error isolation was already per-bridge and bounded; panic isolation was absent. That's both a fault-containment hole and a trivial whole-service DoS for a hosted instance.

Fix: guard_bridge_panic (AssertUnwindSafe + catch_unwind) wraps each bridge future at the supervisor push site. A caught panic logs and resolves to the bridge label exactly like a normal exit, so the existing on_bridge_complete teardown (unregister_channel, WAV finalize) runs for just that tenant and its neighbours keep running. The pool future is deliberately left fail-fast (a pool panic degrades everyone anyway — clean crash + restart beats limping on). Relies on the default panic=unwind; documented in-code.

Verification: full workspace cargo test 196/196 green + clippy --workspace --all-targets -- -D warnings clean, incl. 3 new unit tests (pass-through, panic containment, and a FuturesUnordered driving-loop-survives test mirroring the real run() seam). No live smoke (unit tests replicate the unwinding semantics faithfully).

Remaining slice-3/hardening work, in order: admission budgets/quotas at the reconcile_tenants/add_tenant_entry chokepoints (max ghosts per tenant is currently unbounded), owner records + audit log + ban list, two-sided link authority; process-per-tenant (broker/worker split) stays gated on the ToS verdict (#61) + pool economics.

**Slice 3 (per-tenant isolation) — first hardening shipped: per-bridge panic boundary, merged to master `2b56935` (unreleased, rides the next release).** Finding: every bridge future *and* the shared puppet-pool future are polled on ONE task (they're `!Send`), and nothing caught panics — so a panic in a single bridge's `nvb_matrix_rtc::run` unwound the whole supervisor task and took down every co-resident tenant plus the pool. Error isolation was already per-bridge and bounded; panic isolation was absent. That's both a fault-containment hole and a trivial whole-service DoS for a hosted instance. Fix: `guard_bridge_panic` (`AssertUnwindSafe` + `catch_unwind`) wraps each bridge future at the supervisor push site. A caught panic logs and resolves to the bridge label exactly like a normal exit, so the existing `on_bridge_complete` teardown (`unregister_channel`, WAV finalize) runs for just that tenant and its neighbours keep running. The pool future is deliberately left fail-fast (a pool panic degrades everyone anyway — clean crash + restart beats limping on). Relies on the default `panic=unwind`; documented in-code. Verification: full workspace `cargo test` 196/196 green + `clippy --workspace --all-targets -- -D warnings` clean, incl. 3 new unit tests (pass-through, panic containment, and a FuturesUnordered driving-loop-survives test mirroring the real `run()` seam). No live smoke (unit tests replicate the unwinding semantics faithfully). Remaining slice-3/hardening work, in order: admission budgets/quotas at the `reconcile_tenants`/`add_tenant_entry` chokepoints (max ghosts per tenant is currently unbounded), owner records + audit log + ban list, two-sided link authority; process-per-tenant (broker/worker split) stays gated on the ToS verdict (#61) + pool economics.
Author
Collaborator

Two-sided link auth shipped: merged to master c6c02ef (unreleased, rides the next release).

Two optional [management] knobs, both default off (existing deployments unchanged):

  • require_room_authority — Matrix side: the link sender must be a joined member of the target room with PL ≥ its state_default (a room moderator), not just an allowlisted operator. Evaluated inside the slice-1 preflight probe, reusing the fetched m.room.power_levels plus one m.room.member GET; fail-safe (absent PL event ⇒ required 50).
  • require_discord_approval — Discord side: a link that passes every probe is held pending (nothing written) under a one-time NVB-XXXXXX code (16-entry cap, approval_timeout_secs TTL, default 600 s). Someone with Manage Server in the target guild must run /bridge approve code:<code> — a global slash command registered by the pool bot at approval_bot_index (default 0), hidden from non-managers via default_member_permissions and permission-re-checked server-side from the interaction payload. Approval re-checks the ban list fail-closed, commits through the same tenants.toml + reconcile path as a direct link, and notifies both sides. Audit gains link_pending / approve_rejected (with the real refusal reason); link_ok records approved_by.

Layering: nvb-discord and nvb-matrix-rtc stay decoupled — the binary adapts the pool's LinkApproval into the bot's DiscordApproval (reply oneshot passes through). get_room_state gained a state_key param; serenity gained the builder feature (dm_permission is deprecated in 0.12.5 → contexts(Guild)).

Live-tested on staging 2026-07-06 (prod down 21 min, restored clean), all five scenarios exact:

  1. Unknown code → friendly refusal, audited with the Discord actor.
  2. Authority negative (sender not a member of the room) → "not a joined member" refusal, nothing written.
  3. Code TTL expiry → expired code refused as unknown.
  4. Full positive flow → pending code → guild manager's /bridge approve → ephemeral success + owner-recorded tenants.toml entry + link_pendinglink_ok(approved_by) audit chain + ops-DM notification + bridge fully spawned.
  5. Wrong-guild code → "different Discord server" refusal, code not burned.

Runner-verified: 230 workspace tests + clippy --workspace --all-targets -D warnings clean (rebased onto the #38 Phase A/B commits; zero overlap).

Deployment gotcha (now in the README): the bot-invite OAuth URL must join scopes with %20, not + — the + form silently drops applications.commands and the slash command never appears client-side (registration still succeeds server-side, which makes it confusing). Also: Discord clients cache the command list — Ctrl+R after scope changes.

Remaining #47 scope: process-per-tenant isolation stays gated on the ToS follow-ups (privacy policy, disclosure/consent UX, 100-server verification plan). Known v1 gaps, deliberate: no pending list/cancel command (codes expire on their own), unlink authority unchanged (allowlist-gated), a pending link doesn't reserve its tenant name (duplicate caught cleanly at commit).

**Two-sided link auth shipped: merged to master `c6c02ef` (unreleased, rides the next release).** Two optional `[management]` knobs, both default **off** (existing deployments unchanged): - **`require_room_authority`** — Matrix side: the `link` sender must be a joined member of the target room with PL ≥ its `state_default` (a room moderator), not just an allowlisted operator. Evaluated inside the slice-1 preflight probe, reusing the fetched `m.room.power_levels` plus one `m.room.member` GET; fail-safe (absent PL event ⇒ required 50). - **`require_discord_approval`** — Discord side: a `link` that passes every probe is held **pending** (nothing written) under a one-time `NVB-XXXXXX` code (16-entry cap, `approval_timeout_secs` TTL, default 600 s). Someone with **Manage Server** in the target guild must run `/bridge approve code:<code>` — a global slash command registered by the pool bot at `approval_bot_index` (default 0), hidden from non-managers via `default_member_permissions` and permission-re-checked server-side from the interaction payload. Approval re-checks the ban list **fail-closed**, commits through the same tenants.toml + reconcile path as a direct link, and notifies both sides. Audit gains `link_pending` / `approve_rejected` (with the real refusal reason); `link_ok` records `approved_by`. Layering: `nvb-discord` and `nvb-matrix-rtc` stay decoupled — the binary adapts the pool's `LinkApproval` into the bot's `DiscordApproval` (reply oneshot passes through). `get_room_state` gained a `state_key` param; serenity gained the `builder` feature (`dm_permission` is deprecated in 0.12.5 → `contexts(Guild)`). **Live-tested on staging 2026-07-06** (prod down 21 min, restored clean), all five scenarios exact: 1. Unknown code → friendly refusal, audited with the Discord actor. 2. Authority negative (sender not a member of the room) → "not a joined member" refusal, nothing written. 3. Code TTL expiry → expired code refused as unknown. 4. Full positive flow → pending code → guild manager's `/bridge approve` → ephemeral success + owner-recorded tenants.toml entry + `link_pending`→`link_ok(approved_by)` audit chain + ops-DM notification + bridge fully spawned. 5. Wrong-guild code → "different Discord server" refusal, code not burned. Runner-verified: 230 workspace tests + `clippy --workspace --all-targets -D warnings` clean (rebased onto the #38 Phase A/B commits; zero overlap). **Deployment gotcha (now in the README):** the bot-invite OAuth URL must join scopes with `%20`, not `+` — the `+` form silently drops `applications.commands` and the slash command never appears client-side (registration still succeeds server-side, which makes it confusing). Also: Discord clients cache the command list — Ctrl+R after scope changes. Remaining #47 scope: process-per-tenant isolation stays gated on the ToS follow-ups (privacy policy, disclosure/consent UX, 100-server verification plan). Known v1 gaps, deliberate: no `pending` list/cancel command (codes expire on their own), `unlink` authority unchanged (allowlist-gated), a pending link doesn't reserve its tenant name (duplicate caught cleanly at commit).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
dark/nether-voicebridge#47
No description provided.