feat(livekit-guard): enforce per-room call permissions (screenshare/camera)
Extend voice-limit-guard to enforce a per-room publish-source policy (io.lotus.room_quality allow_screenshare/allow_camera) for ALL Matrix clients, alongside the existing participant limit. - At token issue, re-sign the LiveKit JWT's canPublishSources to drop forbidden sources (microphone always kept). Verifies our own secret signed the token first and fails open on mismatch, so a secret drift can never mint a token the SFU rejects. Limit check and source policy are independent (one's outage can't skip the other). - Live (mid-call) enforcement: a background reconcile loop calls LiveKit UpdateParticipant to revoke a forbidden source from participants who joined before the policy changed -- which unpublishes their in-progress screenshare/camera server-side within ~3s and blocks re-publish. Only removes sources (never grants), preserves other permission flags, fails open, and runs as a daemon thread that cannot crash or block token issuance. - Endpoint-specific room-id extraction (/get_token->room_id, /sfu/get->room) so a client sending both keys can't get a different room's policy applied. - Auto-deploy the guard on LXC 151 (py_compile-gated, backup + rollback). - Unit tests: JWT re-sign/verify + tamper, secret-mismatch, source narrowing, reconcile (never-grant / preserve-flags / disable-on-empty), fail-open. Numeric bitrate/fps caps are NOT server-enforceable on an SFU (LiveKit forwards, never transcodes) and remain a Lotus-client-cooperative setting; the screenshare/camera permission is the hard cross-client lever. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -68,7 +68,7 @@ matrix/
|
||||
- LiveKit config: `/etc/livekit/config.yaml`
|
||||
- LiveKit service: `livekit-server.service`
|
||||
- lk-jwt-service: `lk-jwt-service.service` (now binds `:8071` via drop-in `/etc/systemd/system/lk-jwt-service.service.d/override.conf`; serves JWT tokens for MatrixRTC at `/sfu/get` and legacy `/get_token`)
|
||||
- voice-limit-guard: `voice-limit-guard.service` (binds `:8070`, fronts lk-jwt-service — enforces hard per-room voice participant limits for ALL clients; script `/opt/voice-limit-guard/voice-limit-guard.py`) — see [Voice Channel Limits](#voice-channel-limits)
|
||||
- voice-limit-guard: `voice-limit-guard.service` (binds `:8070`, fronts lk-jwt-service — enforces hard per-room voice participant limits **and publish permissions (screenshare/camera via JWT re-signing)** for ALL clients; script `/opt/voice-limit-guard/voice-limit-guard.py`) — see [Voice Channel Limits & Call Permissions](#voice-channel-limits--call-permissions)
|
||||
- Hookshot: `/opt/hookshot/`, service: `matrix-hookshot.service`
|
||||
- Hookshot config: `/opt/hookshot/config.yml`
|
||||
- Hookshot registration: `/etc/matrix-synapse/hookshot-registration.yaml`
|
||||
@@ -129,7 +129,7 @@ Pushes to `main` on `LotusGuild/matrix` automatically deploy to the relevant LXC
|
||||
|
||||
| LXC | Service | IP | Port | Deploys When Changed |
|
||||
|-----|---------|----|----|----------------------|
|
||||
| 151 | matrix/hookshot | 10.10.10.29 | **9500** | `hookshot/*.js`, `systemd/livekit-server.service` |
|
||||
| 151 | matrix/hookshot | 10.10.10.29 | **9500** | `hookshot/*.js`, `systemd/livekit-server.service`, `livekit/voice-limit-guard.py`, `systemd/voice-limit-guard.service`, `matrixbot/*` |
|
||||
| 106 | cinny | 10.10.10.6 | 9000 | `cinny/config.json`, `cinny/upstream-check.sh`, `cinny/lotus-build.sh`, `deploy/hooks-lxc106.json`, `systemd/cinny-upstream-check.cron` |
|
||||
| 139 | landing/NPM | 10.10.10.27 | 9000 | `landing/index.html` |
|
||||
| 110 | draupnir | 10.10.10.24 | 9000 | `draupnir/production.yaml` |
|
||||
@@ -141,6 +141,7 @@ Pushes to `main` on `LotusGuild/matrix` automatically deploy to the relevant LXC
|
||||
**LXC 151 — hookshot/livekit:**
|
||||
- `hookshot/*.js` changed → runs `hookshot/deploy.sh` (pushes transform functions to Matrix room state via API, requires `MATRIX_TOKEN` in `/etc/matrix-deploy.env`)
|
||||
- `systemd/livekit-server.service` changed → copies file, `daemon-reload`, sets `/run/livekit-restart-pending` flag (actual restart deferred — see Livekit Graceful Restart below)
|
||||
- `livekit/voice-limit-guard.py` / `systemd/voice-limit-guard.service` changed → `py_compile`-validates, installs to `/opt/voice-limit-guard/`, `daemon-reload` (if unit changed), and restarts `voice-limit-guard` (restart only affects joins in a ~1s window; established calls talk directly to livekit-server, so no call is dropped)
|
||||
|
||||
**LXC 106 — cinny:**
|
||||
- `cinny/config.json` → copies to `/var/www/html/config.json`
|
||||
@@ -188,34 +189,43 @@ Killing livekit-server while a call is active drops everyone. Instead:
|
||||
|
||||
---
|
||||
|
||||
## Voice Channel Limits
|
||||
## Voice Channel Limits & Call Permissions
|
||||
|
||||
Per-room voice participant caps are enforced **server-side for every client** (Element, FluffyChat, Lotus Chat, …), not just our own web client.
|
||||
Per-room voice **participant caps** and **publish permissions** (screenshare / camera) are enforced **server-side for every client** (Element, FluffyChat, Lotus Chat, …), not just our own web client. Both are enforced by the same `voice-limit-guard` sidecar (`livekit/voice-limit-guard.py`), which fronts lk-jwt-service at token issue.
|
||||
|
||||
**How it works**
|
||||
|
||||
Every Matrix client must fetch a LiveKit JWT from lk-jwt-service before it can join a call. `voice-limit-guard` (a small fail-open Python sidecar, `livekit/voice-limit-guard.py` in this repo) sits in front of that service:
|
||||
Every Matrix client must fetch a LiveKit JWT from lk-jwt-service before it can join a call. `voice-limit-guard` (a small fail-open Python sidecar) sits in front of that service:
|
||||
|
||||
- lk-jwt-service was moved off `:8070` to `:8071` (systemd drop-in). The guard now owns `:8070`, so NPM's existing `/sfu/get` + `/get_token` proxy targets are unchanged.
|
||||
- On each token request the guard reads `io.lotus.voice_limit` → `max_users` for the room (Synapse admin API, cached 10 s). `0` / absent = no limit.
|
||||
- It forwards the request to lk-jwt-service, and if a token is issued it decodes the JWT to get the LiveKit alias (`video.room`) + requester identity (`sub`), then asks LiveKit `ListParticipants` how many **distinct Matrix users** are in the room.
|
||||
- requester already present (rejoin / extra device) → allow
|
||||
- distinct users ≥ limit → **403** (the client cannot get a token, so it cannot join)
|
||||
- otherwise → allow
|
||||
- **Fail-open:** any error (admin API down, bad token, LiveKit unreachable) returns the upstream response unchanged, so calls keep working even if enforcement is degraded.
|
||||
- On each token request the guard reads the room's Lotus policy from Synapse admin state (one `/state` fetch, cached 10 s): `io.lotus.voice_limit` → `max_users`, and `io.lotus.room_quality` → `allow_screenshare` / `allow_camera`. The room id is taken from the **endpoint's own field** (`/get_token` → `room_id`, `/sfu/get` → `room`) exactly as lk-jwt-service reads it, so a client sending both keys can't get a different room's policy applied than the token is minted for.
|
||||
- **Participant limit** — it forwards to lk-jwt-service, and if a token is issued decodes the JWT to get the LiveKit alias (`video.room`) + requester (`sub`), then asks LiveKit `ListParticipants` how many **distinct Matrix users** are in the room. requester already present (rejoin) → allow · distinct users ≥ limit → **403** · otherwise → allow.
|
||||
- **Publish permissions (screenshare / camera)** — LiveKit is a pure SFU and **cannot cap a publisher's bitrate/framerate** (no such field exists in the grant/config/API — that stays a Lotus-client-cooperative setting). But the JWT's `video.canPublishSources` **is** SFU-enforced for every client. Since the guard holds the LiveKit signing secret, when a room forbids a source it **decodes the issued token, drops `screen_share`/`screen_share_audio` (and/or `camera`) from `canPublishSources`, and re-signs it** (HS256, same key). Microphone is always kept. The SFU then rejects those tracks for **all** clients — nothing to opt into.
|
||||
- **Live (mid-call) enforcement** — the JWT re-sign covers anyone *joining* after a policy change. For people **already in the call**, a background **reconcile loop** (every `GUARD_RECONCILE_INTERVAL`, default 3 s) calls LiveKit `UpdateParticipant` to narrow their `canPublishSources`, which **unpublishes an in-progress screenshare/camera server-side for all clients** and blocks re-publish (confirmed LiveKit 1.9.11 behavior: reducing `can_publish_sources` removes the offending live track). So flipping a room to audio-only kills existing cameras/screenshares within ~one interval. The loop learns each LiveKit room's Matrix id from tokens it issues, only ever **removes** forbidden sources (never grants), preserves every other permission flag (full-replace safety), and no-ops once compliant. Disable with `GUARD_RECONCILE=0`.
|
||||
- **Fail-open:** any error (admin API down, bad/absent token, LiveKit unreachable, unparseable room id, unexpected JWT shape) returns the upstream response **unchanged**, so calls keep working even if enforcement is degraded. The limit check and the source-policy re-sign are **independent** (a LiveKit-admin outage during the limit count can't skip the source restriction, and vice-versa). Before re-signing, the guard **verifies its own secret actually signed the token** — on a `LIVEKIT_SECRET` mismatch it skips the restriction and passes the original token through (so a secret drift can never emit a token the SFU rejects). A room with no policy set takes a zero-overhead fast path (token untouched).
|
||||
|
||||
**Setting a limit:** room admins set it from Lotus Chat → Room Settings → General → **Voice** (writes the `io.lotus.voice_limit` state event). Any tool that can send room state works too:
|
||||
> **Security note:** `LIVEKIT_KEY`/`LIVEKIT_SECRET` are currently hardcoded in `systemd/voice-limit-guard.service` (pre-existing). Since this secret now also signs re-issued join tokens, it should be moved into `/etc/matrix-deploy.env` (already an `EnvironmentFile` on LXC 151) and the exposed value rotated. Not changed automatically to avoid a deploy breaking before the env file carries it.
|
||||
|
||||
Pure logic (limit decision, source narrowing, JWT re-sign/verify roundtrip, tamper detection) is unit-tested in `livekit/test_voice_limit_guard.py` (`python3 -m unittest livekit.test_voice_limit_guard`).
|
||||
|
||||
**Setting policy:** room admins use Lotus Chat → Room Settings → General → **Voice** (Call Permissions switches + Quality Caps). Any tool that can send room state works too:
|
||||
|
||||
```bash
|
||||
# max 5 participants in <roomId>; send {} to remove the limit
|
||||
# max 5 participants; send {} to remove the limit
|
||||
curl -X PUT -H "Authorization: Bearer <admin_token>" -H "Content-Type: application/json" \
|
||||
"https://matrix.lotusguild.org/_matrix/client/v3/rooms/<roomId>/state/io.lotus.voice_limit/" \
|
||||
-d '{"max_users": 5}'
|
||||
|
||||
# forbid screenshare + make it audio-only (hard, all clients); numeric caps are
|
||||
# Lotus-client-cooperative hints in the same event
|
||||
curl -X PUT -H "Authorization: Bearer <admin_token>" -H "Content-Type: application/json" \
|
||||
"https://matrix.lotusguild.org/_matrix/client/v3/rooms/<roomId>/state/io.lotus.room_quality/" \
|
||||
-d '{"allow_screenshare": false, "allow_camera": false, "audio_max_kbps": 32}'
|
||||
```
|
||||
|
||||
**Config:** the guard reads `MATRIX_TOKEN` (server-admin) from `/etc/matrix-deploy.env`; LiveKit key/secret + ports are set in `systemd/voice-limit-guard.service`.
|
||||
|
||||
**Manual (re)deploy** (the file-specific auto-deploy pipeline does not cover this service):
|
||||
**Deploy:** auto-deploys on push (LXC 151 handler `py_compile`-validates then restarts the guard). Manual (re)deploy / first-time setup:
|
||||
|
||||
```bash
|
||||
# On LXC 151
|
||||
@@ -454,27 +464,31 @@ chmod 600 /etc/cinny-monitor.env
|
||||
|
||||
**Why 8GB RAM:** Vite's build process needs ~6GB Node heap (`--max_old_space_size=6144`) for the rendering-chunks phase. Previously at 4GB — OOM killed during render.
|
||||
|
||||
### 🔱 Planned: Element Call fork — "Lotus Call" (true ownership)
|
||||
### 🔱 Element Call fork — "Lotus Call" (true ownership) — LIVE
|
||||
|
||||
We currently embed Element Call as a **pre-built npm bundle**
|
||||
(`@element-hq/element-call-embedded` 0.20.1, copied to cinny `public/element-call/`).
|
||||
We can steer it (widget API + same-origin DOM hacks) but **cannot change its
|
||||
compiled logic** — so several in-call issues (avatar decorations on tiles, camera
|
||||
focus/fullscreen during screenshare, mic-after-reconnect, native theming, real
|
||||
call-audio injection for a soundboard) are unfixable from outside.
|
||||
We **self-build** Element Call from a fork (`LotusGuild/element-call`) and publish
|
||||
it to our private Gitea npm registry as `@lotusguild/element-call-embedded`
|
||||
(`0.20.1-lotus.1`); cinny consumes that instead of the upstream
|
||||
`@element-hq/element-call-embedded` bundle. In-call behavior is now editable
|
||||
source, not just widget-API + DOM steering. This is AGPL (same license).
|
||||
|
||||
**Plan: fork `element-hq/element-call` → a new `LotusGuild/element-call` repo,
|
||||
build it from source, host our build, and replace the npm bundle.** This is AGPL
|
||||
(same license, compatible). Infra implications for THIS repo:
|
||||
- EC talks to our **LiveKit SFU** (`livekit/`, LXC 151) + `lk-jwt-service` — the
|
||||
fork's runtime `config.json` must point at `matrix.lotusguild.org` + our
|
||||
LiveKit. The current cinny EC `config.json` lives in `cinny/config.json` here.
|
||||
- A new build/deploy pipeline for the EC fork will be needed (likely its own LXC
|
||||
or CI artifact), analogous to the cinny build on LXC 106.
|
||||
**Shipped via the fork:** in-source denoise (a LiveKit `TrackProcessor` that
|
||||
survives reconnects), in-call speaking/mute events, focus-a-participant during
|
||||
screenshare, avatar decorations on EC video tiles, native transparent background.
|
||||
**Built but dormant (need cinny UI):** call-audio injection
|
||||
(`io.lotus.inject_audio`, unblocks a real in-call soundboard) and quality controls
|
||||
(`io.lotus.set_quality`).
|
||||
|
||||
Infra notes for THIS repo:
|
||||
- EC talks to our **LiveKit SFU** (`livekit/`, LXC 151) + `lk-jwt-service`; the
|
||||
fork's runtime `config.json` points at `matrix.lotusguild.org` + our LiveKit.
|
||||
The cinny EC `config.json` lives in `cinny/config.json` here.
|
||||
- **Build/deploy:** the fork builds in the cinny pipeline (its `dist/` is bundled
|
||||
into the cinny build that LXC 106 serves) — no separate EC LXC. A future quality
|
||||
controls feature (P5-31) would add a `voice-limit-guard`-style sidecar on LXC 151.
|
||||
|
||||
**Full handoff & step-by-step plan:** `LotusGuild/cinny` →
|
||||
[`HANDOFF_ELEMENT_CALL_FORK.md`](https://code.lotusguild.org/LotusGuild/cinny/src/branch/lotus/HANDOFF_ELEMENT_CALL_FORK.md).
|
||||
Start a fresh session with that doc.
|
||||
|
||||
### Custom Features
|
||||
|
||||
@@ -482,7 +496,7 @@ All custom code lives in `src/app/` on the `lotus` branch of `code.lotusguild.or
|
||||
|
||||
| Feature | Files | Notes |
|
||||
|---------|-------|-------|
|
||||
| **Element Call embed** | `src/app/plugins/call/`, `src/app/hooks/useCallEmbed.ts`, `src/app/components/CallEmbedProvider.tsx` | EC 0.20.1 (`@element-hq/element-call-embedded`), **prebuilt** dist copied to `public/element-call/` by vite. Same-origin (`allow-same-origin`), controlled via `matrix-widget-api` + DOM-poking. 🔱 **[EC-FORK]** planned — see `LotusGuild/cinny` → `HANDOFF_ELEMENT_CALL_FORK.md` |
|
||||
| **Element Call embed** | `src/app/plugins/call/`, `src/app/hooks/useCallEmbed.ts`, `src/app/components/CallEmbedProvider.tsx` | 🔱 **[EC-FORK] LIVE** — self-built fork `@lotusguild/element-call-embedded@0.20.1-lotus.1` (source `LotusGuild/element-call`), bundled into the cinny build, served same-origin. Steered via `matrix-widget-api` + custom `io.lotus.*` actions (call_state, focus_participant, decorations, inject_audio, set_quality) — DOM-poking retained only as fallback. See `LotusGuild/cinny` → `HANDOFF_ELEMENT_CALL_FORK.md` |
|
||||
| **DM calls** | `src/app/features/room/Room.tsx`, `src/app/features/room/RoomViewHeader.tsx` | Phone button in DM room header; `useCallStart(true)` passes `intent: StartedByUser`; Room.tsx switches to CallView layout when DM has active call |
|
||||
| **Picture-in-picture call** | `src/app/components/CallEmbedProvider.tsx` | When navigating away from the call room, the embed shrinks to a 280×158px PiP in the bottom-right. Click navigates back. Implemented via `useEffect` imperatively overriding styles on `callEmbedRef.current` — cannot use a wrapper div because `useCallEmbedPlacementSync` writes `top/left/width/height` directly onto that element |
|
||||
| **Screenshare fullscreen** | `src/app/features/call/CallControls.tsx`, `src/app/features/call/Controls.tsx` | When screensharing, a fullscreen button appears in call controls. Calls `callEmbedRef.current?.requestFullscreen()` on the Cinny call container. EC naturally spotlights the screenshare — the old 600ms grid-revert code was removed (it caused fullscreen to show avatars instead of the screen) |
|
||||
@@ -770,7 +784,7 @@ All commands use the `!` prefix. Run `!help` in any room for the full list.
|
||||
| Webhook bridge | matrix-hookshot | 7.3.2 |
|
||||
| Reverse proxy | Nginx Proxy Manager | — |
|
||||
| Web client | Lotus Cinny (fork of `cinnyapp/cinny` main) | custom |
|
||||
| Element Call embed | `@element-hq/element-call-embedded` | 0.20.1 |
|
||||
| Element Call embed | `@lotusguild/element-call-embedded` (self-built fork of `element-hq/element-call`) | 0.20.1-lotus.1 |
|
||||
| GIF picker | Giphy JS/React SDK (`@giphy/react-components`) | — |
|
||||
| Auto-deploy | adnanh/webhook | 2.8.0 |
|
||||
| Bot language | Python 3 | 3.x |
|
||||
|
||||
Reference in New Issue
Block a user