Compare commits
32 Commits
fee26621bc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b72d6620cc | |||
| 64262b39ec | |||
| 6293a62e47 | |||
| 7618b3b091 | |||
| d344b9b4b5 | |||
| d9585f13f1 | |||
| daa532835f | |||
| 8481610066 | |||
| be8e728034 | |||
| a06f2c662a | |||
| 1a7ec2b0d6 | |||
| c13549f3da | |||
| d6fd323262 | |||
| b39e3594d5 | |||
| 40ceb43672 | |||
| 45444e5118 | |||
| 3fe232a6b7 | |||
| 8c9edf60c3 | |||
| 52e9be1f8d | |||
| 442ad9b6ed | |||
| 68a6acfa24 | |||
| 295a072dc9 | |||
| b392798e3f | |||
| 130a7334a3 | |||
| 68e922d386 | |||
| 86ad28933a | |||
| 85eab8f54c | |||
| f65e56d06d | |||
| 60c215c63d | |||
| cfb8b78e7c | |||
| 12a6e7410d | |||
| 5539f1f1fd |
@@ -50,7 +50,7 @@ matrix/
|
|||||||
|
|
||||||
| Service | IP | LXC | RAM | Disk | Versions |
|
| Service | IP | LXC | RAM | Disk | Versions |
|
||||||
|---------|----|-----|-----|------|----------|
|
|---------|----|-----|-----|------|----------|
|
||||||
| Synapse | 10.10.10.29 | 151 | 8GB | 50GB | Synapse 1.149.0, LiveKit 1.9.11, hookshot 7.3.2, coturn latest |
|
| Synapse | 10.10.10.29 | 151 | 8GB | 50GB | Synapse 1.155.0, LiveKit 1.9.11, hookshot 7.3.2, coturn latest |
|
||||||
| PostgreSQL 17 | 10.10.10.44 | 109 | 6GB | 30GB | PostgreSQL 17.9 |
|
| PostgreSQL 17 | 10.10.10.44 | 109 | 6GB | 30GB | PostgreSQL 17.9 |
|
||||||
| Cinny Web | 10.10.10.6 | 106 | 2GB | 8GB | Debian 12, nginx, Node 24, Lotus Cinny fork (custom, tracks `cinnyapp/cinny` main) |
|
| Cinny Web | 10.10.10.6 | 106 | 2GB | 8GB | Debian 12, nginx, Node 24, Lotus Cinny fork (custom, tracks `cinnyapp/cinny` main) |
|
||||||
| Draupnir | 10.10.10.24 | 110 | 1GB | 10GB | Draupnir v2.9.0, Node.js v22 |
|
| Draupnir | 10.10.10.24 | 110 | 1GB | 10GB | Draupnir v2.9.0, Node.js v22 |
|
||||||
@@ -67,7 +67,8 @@ matrix/
|
|||||||
- coturn config: `/etc/turnserver.conf`
|
- coturn config: `/etc/turnserver.conf`
|
||||||
- LiveKit config: `/etc/livekit/config.yaml`
|
- LiveKit config: `/etc/livekit/config.yaml`
|
||||||
- LiveKit service: `livekit-server.service`
|
- LiveKit service: `livekit-server.service`
|
||||||
- lk-jwt-service: `lk-jwt-service.service` (binds `:8070`, serves JWT tokens for MatrixRTC at `/sfu/get` and legacy `/get_token`)
|
- 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 **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: `/opt/hookshot/`, service: `matrix-hookshot.service`
|
||||||
- Hookshot config: `/opt/hookshot/config.yml`
|
- Hookshot config: `/opt/hookshot/config.yml`
|
||||||
- Hookshot registration: `/etc/matrix-synapse/hookshot-registration.yaml`
|
- Hookshot registration: `/etc/matrix-synapse/hookshot-registration.yaml`
|
||||||
@@ -128,7 +129,7 @@ Pushes to `main` on `LotusGuild/matrix` automatically deploy to the relevant LXC
|
|||||||
|
|
||||||
| LXC | Service | IP | Port | Deploys When Changed |
|
| 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` |
|
| 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` |
|
| 139 | landing/NPM | 10.10.10.27 | 9000 | `landing/index.html` |
|
||||||
| 110 | draupnir | 10.10.10.24 | 9000 | `draupnir/production.yaml` |
|
| 110 | draupnir | 10.10.10.24 | 9000 | `draupnir/production.yaml` |
|
||||||
@@ -140,6 +141,7 @@ Pushes to `main` on `LotusGuild/matrix` automatically deploy to the relevant LXC
|
|||||||
**LXC 151 — hookshot/livekit:**
|
**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`)
|
- `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)
|
- `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:**
|
**LXC 106 — cinny:**
|
||||||
- `cinny/config.json` → copies to `/var/www/html/config.json`
|
- `cinny/config.json` → copies to `/var/www/html/config.json`
|
||||||
@@ -187,6 +189,58 @@ Killing livekit-server while a call is active drops everyone. Instead:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Voice Channel Limits & Call Permissions
|
||||||
|
|
||||||
|
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) 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 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).
|
||||||
|
|
||||||
|
> **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; 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`.
|
||||||
|
|
||||||
|
**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
|
||||||
|
install -D -m644 /opt/matrix-config/livekit/voice-limit-guard.py /opt/voice-limit-guard/voice-limit-guard.py
|
||||||
|
install -m644 /opt/matrix-config/systemd/voice-limit-guard.service /etc/systemd/system/voice-limit-guard.service
|
||||||
|
# one-time: rebind lk-jwt-service to :8071
|
||||||
|
mkdir -p /etc/systemd/system/lk-jwt-service.service.d
|
||||||
|
printf '[Service]\nEnvironment=LIVEKIT_JWT_BIND=:8071\n' > /etc/systemd/system/lk-jwt-service.service.d/override.conf
|
||||||
|
systemctl daemon-reload && systemctl restart lk-jwt-service && systemctl enable --now voice-limit-guard
|
||||||
|
```
|
||||||
|
|
||||||
|
**To fully revert** (back to lk-jwt-service directly on `:8070`): `systemctl disable --now voice-limit-guard`, remove the drop-in, `daemon-reload`, `systemctl restart lk-jwt-service`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Access Token Rotation
|
## Access Token Rotation
|
||||||
|
|
||||||
The `MATRIX_TOKEN` in `/etc/matrix-deploy.env` on LXC 151 is a Jared user token used to push hookshot transforms to Matrix room state (requires power level ≥ 50 in Spam and Stuff).
|
The `MATRIX_TOKEN` in `/etc/matrix-deploy.env` on LXC 151 is a Jared user token used to push hookshot transforms to Matrix room state (requires power level ≥ 50 in Spam and Stuff).
|
||||||
@@ -230,7 +284,8 @@ The token in `draupnir/production.yaml` in this repo is **intentionally redacted
|
|||||||
| 6789 | LiveKit metrics | 0.0.0.0 |
|
| 6789 | LiveKit metrics | 0.0.0.0 |
|
||||||
| 7880 | LiveKit HTTP | 0.0.0.0 |
|
| 7880 | LiveKit HTTP | 0.0.0.0 |
|
||||||
| 7881 | LiveKit RTC TCP | 0.0.0.0 |
|
| 7881 | LiveKit RTC TCP | 0.0.0.0 |
|
||||||
| 8070 | lk-jwt-service | 0.0.0.0 |
|
| 8070 | voice-limit-guard (fronts lk-jwt-service) | 0.0.0.0 |
|
||||||
|
| 8071 | lk-jwt-service (behind guard) | 0.0.0.0 |
|
||||||
| 8080 | synapse-admin (nginx) | 0.0.0.0 |
|
| 8080 | synapse-admin (nginx) | 0.0.0.0 |
|
||||||
| 3478 | coturn STUN/TURN | 0.0.0.0 |
|
| 3478 | coturn STUN/TURN | 0.0.0.0 |
|
||||||
| 5349 | coturn TURNS/TLS | 0.0.0.0 |
|
| 5349 | coturn TURNS/TLS | 0.0.0.0 |
|
||||||
@@ -409,26 +464,66 @@ 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.
|
**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.
|
||||||
|
|
||||||
|
### 🔱 Element Call fork — "Lotus Call" (true ownership) — LIVE
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
**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).
|
||||||
|
|
||||||
### Custom Features
|
### Custom Features
|
||||||
|
|
||||||
All custom code lives in `src/app/` on the `lotus` branch of `code.lotusguild.org/LotusGuild/cinny`. Changes survive upstream merges as long as they don't conflict with the same files upstream touched.
|
All custom code lives in `src/app/` on the `lotus` branch of `code.lotusguild.org/LotusGuild/cinny`. Changes survive upstream merges as long as they don't conflict with the same files upstream touched.
|
||||||
|
|
||||||
| Feature | Files | Notes |
|
| Feature | Files | Notes |
|
||||||
|---------|-------|-------|
|
|---------|-------|-------|
|
||||||
| **Element Call embed** | `src/app/plugins/call/`, `src/app/hooks/useCallEmbed.ts`, `src/app/components/CallEmbedProvider.tsx` | EC 0.19.3 (`@element-hq/element-call-embedded`), dist copied to `public/element-call/` by vite |
|
| **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 |
|
| **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 |
|
| **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) |
|
| **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) |
|
||||||
| **PiP screenshare focus** | `src/app/components/CallEmbedProvider.tsx`, `src/app/plugins/call/CallControl.ts` | When the floating PiP window is active and screenshare is detected (no cameras present), auto-enables EC spotlight view so the screenshare fills the PiP rather than showing avatar tiles |
|
| **PiP screenshare focus** | `src/app/components/CallEmbedProvider.tsx`, `src/app/plugins/call/CallControl.ts` | When the floating PiP window is active and screenshare is detected (no cameras present), auto-enables EC spotlight view so the screenshare fills the PiP rather than showing avatar tiles |
|
||||||
| **Screenshare audio mute** | `src/app/features/call/Controls.tsx`, `src/app/features/call/CallControls.tsx`, `src/app/plugins/call/CallControl.ts` | Dedicated button to independently mute/unmute audio from screenshares without muting microphone audio. Targets `audio[data-lk-source="screen_share_audio"]` LiveKit elements. Persists across deafen/undeafen cycles |
|
| **Screenshare audio mute** | `src/app/features/call/Controls.tsx`, `src/app/features/call/CallControls.tsx`, `src/app/plugins/call/CallControl.ts` | Dedicated button to independently mute/unmute audio from screenshares without muting microphone audio. Targets `audio[data-lk-source="screen_share_audio"]` LiveKit elements. Persists across deafen/undeafen cycles |
|
||||||
|
| **In-call soundboard (P5-15)** | `src/app/features/call/CallSoundboard.tsx`, `src/app/hooks/useSoundboard.ts`, `src/app/utils/soundboardClips.ts`, `CallControl.ts#injectAudio` | Call-bar popout of user-uploaded clips. Playing one sends the fork's `io.lotus.inject_audio` (armed via `lotusAudioInject=1`) so it publishes as a real LiveKit track heard by all, plus local playback. Clips are uploadable like emoji/sticker packs — stored in `io.lotus.soundboard` account data (synced across devices); host resolves mxc → authed download → `blob:` URL for the widget. Gated by `soundboardEnabled` setting |
|
||||||
|
| **Call quality controls (P5-31)** | `src/app/utils/callQuality.ts`, `src/app/hooks/useCallQuality.ts`, `CallControl.ts#setQuality` | Per-user mic/screenshare bitrate + screenshare framerate (Settings → Calls), applied via the fork's `io.lotus.set_quality`, clamped to any room cap (`min(user, room)`). **Client-cooperative** (numeric caps aren't SFU-enforceable). Unit-tested |
|
||||||
|
| **Room call permissions (P5-31)** | `src/app/features/common-settings/general/RoomQuality.tsx`, `types/matrix/room.ts` (`LotusRoomQuality`), `CallControls.tsx` | Admin switches in Room Settings → Voice write `io.lotus.room_quality` `allow_screenshare`/`allow_camera`; the call bar hides blocked buttons. **Hard-enforced server-side for all clients** by `voice-limit-guard` (this repo) — see [Voice Channel Limits & Call Permissions](#voice-channel-limits--call-permissions) |
|
||||||
| **Custom status message** | `src/app/features/settings/account/Profile.tsx`, `src/app/features/room/MembersDrawer.tsx`, `src/app/components/user-profile/UserHero.tsx`, `src/app/components/user-profile/UserRoomProfile.tsx`, `src/app/hooks/useUserPresence.ts` | Discord-style free-form status text. Set via Settings → Account → "Status Message" with an emoji picker (lazy-loaded `EmojiBoard`). Saved via `mx.setPresence({ status_msg })`. Displayed below the username in the members drawer and user profile popout. Syncs live via Matrix presence events |
|
| **Custom status message** | `src/app/features/settings/account/Profile.tsx`, `src/app/features/room/MembersDrawer.tsx`, `src/app/components/user-profile/UserHero.tsx`, `src/app/components/user-profile/UserRoomProfile.tsx`, `src/app/hooks/useUserPresence.ts` | Discord-style free-form status text. Set via Settings → Account → "Status Message" with an emoji picker (lazy-loaded `EmojiBoard`). Saved via `mx.setPresence({ status_msg })`. Displayed below the username in the members drawer and user profile popout. Syncs live via Matrix presence events |
|
||||||
| **PTT (Push-to-Talk)** | `src/app/features/call/CallControls.tsx`, `src/app/state/settings.ts` | Hold-to-talk key (default: Space, configurable). Mutes mic on join; holds mic open while key is held. Badge shows `PTT — Hold SPACE` / `● Live`. Listens on both main window and EC iframe `contentWindow` for key events |
|
| **PTT (Push-to-Talk)** | `src/app/features/call/CallControls.tsx`, `src/app/state/settings.ts` | Hold-to-talk key (default: Space, configurable). Mutes mic on join; holds mic open while key is held. Badge shows `PTT — Hold SPACE` / `● Live`. Listens on both main window and EC iframe `contentWindow` for key events |
|
||||||
| **PTT badge theming** | `src/app/features/call/CallControls.tsx` | Plain folds `Chip` by default; neon terminal style (`#00FF88`/`#FF6B00`, JetBrains Mono) when `lotusTerminal` setting is on |
|
| **PTT badge theming** | `src/app/features/call/CallControls.tsx` | Plain folds `Chip` by default; neon terminal style (`#00FF88`/`#FF6B00`, JetBrains Mono) when `lotusTerminal` setting is on |
|
||||||
| **GIF picker** | `src/app/components/GifPicker.tsx`, `src/app/features/room/RoomInput.tsx` | Giphy JS/React SDK (`@giphy/react-components`, `@giphy/js-fetch-api`, `styled-components`). API key in `config.json` → `gifApiKey`. GIF button appears next to Send only when `gifApiKey` is set. Sends GIF as `m.image` (fetches blob → `mx.uploadContent` → `mx.sendMessage`). `FocusTrap` handles click-outside / Escape to close |
|
| **GIF picker** | `src/app/components/GifPicker.tsx`, `src/app/features/room/RoomInput.tsx` | Giphy JS/React SDK (`@giphy/react-components`, `@giphy/js-fetch-api`, `styled-components`). API key in `config.json` → `gifApiKey`. GIF button appears next to Send only when `gifApiKey` is set. Sends GIF as `m.image` (fetches blob → `mx.uploadContent` → `mx.sendMessage`). `FocusTrap` handles click-outside / Escape to close |
|
||||||
| **GIF picker terminal theme** | `src/app/components/GifPicker.tsx` | When `lotusTerminal` is on: dark navy background (`#060c14`), orange dim border, 4px radius, `// GIF_SEARCH` header, injected `<style>` overrides Giphy SDK SearchBar input (dark bg, orange border/focus ring, JetBrains Mono), custom orange scrollbar |
|
| **GIF picker terminal theme** | `src/app/components/GifPicker.tsx` | When `lotusTerminal` is on: dark navy background (`#060c14`), orange dim border, 4px radius, `// GIF_SEARCH` header, injected `<style>` overrides Giphy SDK SearchBar input (dark bg, orange border/focus ring, JetBrains Mono), custom orange scrollbar |
|
||||||
| **Terminal Design System toggle** | `src/app/state/settings.ts`, `src/app/features/settings/` | `lotusTerminal` boolean setting. When enabled: PTT badge and GIF picker use LotusGuild Terminal Design System aesthetics |
|
| **Terminal Design System toggle** | `src/app/state/settings.ts`, `src/app/features/settings/` | `lotusTerminal` boolean setting. When enabled: PTT badge, GIF picker, and voice message recorder use LotusGuild Terminal Design System aesthetics (green #00FF88 / orange #FF6B00, JetBrains Mono) |
|
||||||
| **Presence status badges** | `src/app/features/room/MembersDrawer.tsx`, `src/app/features/common-settings/members/Members.tsx`, `src/app/hooks/useUserPresence.ts`, `src/app/components/presence/` | Online/busy/away colored dot badges shown next to verification shields for every member in the room members drawer and settings members panel. Uses `useUserPresence(userId)` hook + `PresenceBadge` component. Members.tsx wraps the hook in a `MemberPresenceBadge` child component to satisfy React hook rules inside `.map()` |
|
| **Presence status badges** | `src/app/features/room/MembersDrawer.tsx`, `src/app/features/common-settings/members/Members.tsx`, `src/app/hooks/useUserPresence.ts`, `src/app/components/presence/` | Online/busy/away colored dot badges shown next to verification shields for every member in the room members drawer and settings members panel. Uses `useUserPresence(userId)` hook + `PresenceBadge` component. Members.tsx wraps the hook in a `MemberPresenceBadge` child component to satisfy React hook rules inside `.map()` |
|
||||||
| **Per-member device sessions panel** | `src/app/components/user-profile/UserRoomProfile.tsx`, `src/app/hooks/useOtherUserDevices.ts` | Collapsible "Sessions" card in user profile popout. Lists all devices for any user with colored shield icons (green=verified, yellow=unverified). Shows per-device "Verify" button that initiates cross-signing SAS emoji verification via `crypto.requestDeviceVerification(userId, deviceId)`. Updates live via `CryptoEvent.DevicesUpdated`. Only shown when cross-signing is active |
|
| **Discord-style presence tracking** | `src/app/hooks/usePresenceUpdater.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx` | Broadcasts `online` on startup, `unavailable` after 10 min idle or tab hidden, `offline` on page close (fetch+keepalive). Activity throttled to 1 event/sec. `hidePresence` setting broadcasts offline and disables all tracking |
|
||||||
|
| **Per-member device sessions panel** | `src/app/components/user-profile/UserRoomProfile.tsx`, `src/app/hooks/useOtherUserDevices.ts` | Collapsible "Sessions" card in user profile popout. Lists all devices with colored shield icons (green=verified, yellow=unverified, loading/error states). Per-device "Verify" button initiates cross-signing SAS emoji verification. Updates live via `CryptoEvent.DevicesUpdated`. Only shown when cross-signing is active |
|
||||||
|
| **Privacy settings** | `src/app/features/settings/general/General.tsx`, `src/app/state/settings.ts` | Dedicated Privacy section in General settings. `hideActivity` suppresses typing indicators and read receipts. `hidePresence` appears offline to everyone |
|
||||||
|
| **Encrypted room search** | `src/app/features/message-search/useLocalMessageSearch.ts`, `src/app/features/message-search/MessageSearch.tsx` | Searches locally cached decrypted events in E2EE rooms alongside server-side search. Per-room "Load more" buttons paginate 100 msgs at a time; shows oldest cached date and X/Y coverage counter. Sender-aware (respects `from:@user` filter) |
|
||||||
|
| **Message search: sender filter** | `src/app/features/message-search/SearchInput.tsx`, `src/app/features/message-search/SearchFilters.tsx` | Type `from:@user` in the search box for live autocomplete of known users (homeserver-biased ranking). Selected senders shown as removable chips. Works for both server search and local encrypted search |
|
||||||
|
| **Message search: date range** | `src/app/features/message-search/SearchFilters.tsx`, `src/app/features/message-search/useMessageSearch.ts` | From/To date pickers in the filter bar. Passed as `from_ts`/`to_ts` epoch ms to Matrix `/search` |
|
||||||
|
| **Document title unread count** | `src/app/pages/client/ClientNonUIFeatures.tsx` | Tab title updates to `(N) Lotus Chat` for mentions, `· Lotus Chat` for unreads, `Lotus Chat` when clear |
|
||||||
|
| **Message draft persistence** | `src/app/features/room/RoomInput.tsx` | Unsent messages survive page reload via `localStorage` (`draft-msg-<roomId>`). Jotai in-memory atom remains the primary store; localStorage used as fallback on reload. Cleared on send |
|
||||||
|
| **PiP position persistence + snap** | `src/app/components/CallEmbedProvider.tsx` | PiP position saved to `localStorage` on drag end; restored on next PiP enter (clamped to viewport). Double-click snaps to nearest corner with 180ms CSS transition |
|
||||||
|
| **Threads (P3-8 + P4-1)** | `src/app/features/room/thread/`, `state/room/thread.ts`, `utils/threadNotifications.ts`, `hooks/useRoomsListener.ts` | Full m.thread support: side panel (own composer, per-thread drafts), "N replies" unread chips, threaded receipts; SDK `threadSupport` on, markAsRead unthreaded; replies no longer render inline. **Slack-style notifications**: default = participating-only, per-thread All/Mentions/Mute in `io.lotus.thread_notifications` account data; muted threads subtracted from room badges client-side |
|
||||||
|
| **KaTeX math + encrypted-search cache + session hardening + crypto diagnostics** | `utils/{mathParse,searchCache,cryptoDiagLog}.ts`, `state/sessions.ts`, `LOTUS_E2EE_INVESTIGATION.md` | July 2026 batch: `$…$`/`$$…$$` + `data-mx-maths` via lazy KaTeX; opt-in IndexedDB search index for E2EE rooms (wiped on logout); atomic `cinny_session_v1` blob + cross-tab logout sync; KE-1→4 diagnostics capture card in Developer Tools |
|
||||||
|
| **Desktop app (Tauri)** | `cinny-desktop` → `src-tauri/src/native/*.rs`, `src-tauri/src/lib.rs`; cinny `src/app/hooks/useTauri*.ts`, `src/app/components/TauriDesktopFeatures.tsx` | Tauri v2 native shell: rich WinRT toast notifications (click → open room, inline quick reply), Windows Focus Assist → DND sync, taskbar Jump List of recent rooms, taskbar thumbnail + volume-flyout call controls (mute/deafen/end), no-sleep during calls, network-change awareness (`mx.retryImmediately`), opt-in TDS window chrome, recursive folder drag-drop, auto-update toast. Windows-native pieces compile in CI (Gitea `windows` runner + GitHub `windows-latest`); detail in cinny `LOTUS_FEATURES.md` → Desktop App Features |
|
||||||
| **LiveKit codec config** | `/etc/livekit/config.yaml` (LXC 151) | `enabled_codecs`: VP8, H264, VP9, Opus, RED for better quality and redundancy |
|
| **LiveKit codec config** | `/etc/livekit/config.yaml` (LXC 151) | `enabled_codecs`: VP8, H264, VP9, Opus, RED for better quality and redundancy |
|
||||||
|
|
||||||
**Key config values (`/opt/lotus-cinny/config.json`, root — vite copies this to dist):**
|
**Key config values (`/opt/lotus-cinny/config.json`, root — vite copies this to dist):**
|
||||||
@@ -468,8 +563,12 @@ Periodic `TLS/TCP socket error: Connection reset by peer` in coturn logs. Normal
|
|||||||
|
|
||||||
## Server Checklist
|
## Server Checklist
|
||||||
|
|
||||||
|
## Server Checklist
|
||||||
|
|
||||||
### Quality of Life
|
### Quality of Life
|
||||||
|
- [x] **Upgrade Synapse to v1.155.0** — Done 2026-06-18. LXC 151 was already on Debian 13 Trixie; no OS migration needed.
|
||||||
- [x] Migrate from SQLite to PostgreSQL
|
- [x] Migrate from SQLite to PostgreSQL
|
||||||
|
|
||||||
- [x] TURN/STUN server (coturn) for reliable voice/video
|
- [x] TURN/STUN server (coturn) for reliable voice/video
|
||||||
- [x] URL previews
|
- [x] URL previews
|
||||||
- [x] Upload size limit 200MB
|
- [x] Upload size limit 200MB
|
||||||
@@ -681,7 +780,7 @@ All commands use the `!` prefix. Run `!help` in any room for the full list.
|
|||||||
|
|
||||||
| Component | Technology | Version |
|
| Component | Technology | Version |
|
||||||
|-----------|-----------|---------|
|
|-----------|-----------|---------|
|
||||||
| Homeserver | Synapse | 1.149.0 |
|
| Homeserver | Synapse | 1.155.0 |
|
||||||
| Database | PostgreSQL | 17.9 |
|
| Database | PostgreSQL | 17.9 |
|
||||||
| TURN | coturn | latest |
|
| TURN | coturn | latest |
|
||||||
| Video/voice calls | LiveKit SFU | 1.9.11 |
|
| Video/voice calls | LiveKit SFU | 1.9.11 |
|
||||||
@@ -691,7 +790,7 @@ All commands use the `!` prefix. Run `!help` in any room for the full list.
|
|||||||
| Webhook bridge | matrix-hookshot | 7.3.2 |
|
| Webhook bridge | matrix-hookshot | 7.3.2 |
|
||||||
| Reverse proxy | Nginx Proxy Manager | — |
|
| Reverse proxy | Nginx Proxy Manager | — |
|
||||||
| Web client | Lotus Cinny (fork of `cinnyapp/cinny` main) | custom |
|
| Web client | Lotus Cinny (fork of `cinnyapp/cinny` main) | custom |
|
||||||
| Element Call embed | `@element-hq/element-call-embedded` | 0.19.3 |
|
| 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`) | — |
|
| GIF picker | Giphy JS/React SDK (`@giphy/react-components`) | — |
|
||||||
| Auto-deploy | adnanh/webhook | 2.8.0 |
|
| Auto-deploy | adnanh/webhook | 2.8.0 |
|
||||||
| Bot language | Python 3 | 3.x |
|
| Bot language | Python 3 | 3.x |
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"defaultHomeserver": 0,
|
"defaultHomeserver": 0,
|
||||||
"homeserverList": [
|
"homeserverList": [
|
||||||
"matrix.lotusguild.org"
|
"matrix.lotusguild.org",
|
||||||
|
"matrix.org",
|
||||||
|
"mozilla.org"
|
||||||
],
|
],
|
||||||
"allowCustomHomeservers": false,
|
"allowCustomHomeservers": false,
|
||||||
"featuredCommunities": {
|
"featuredCommunities": {
|
||||||
|
|||||||
Executable
+129
@@ -0,0 +1,129 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REPO="/opt/lotus-cinny"
|
||||||
|
WEBROOT="/var/www/html"
|
||||||
|
LOCKFILE="/tmp/lotus-deploy.lock"
|
||||||
|
LOGFILE="/var/log/lotus-deploy.log"
|
||||||
|
|
||||||
|
# Prevent concurrent deploys
|
||||||
|
exec 200>"$LOCKFILE"
|
||||||
|
flock -n 200 || { echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy already in progress, skipping." >> "$LOGFILE"; exit 0; }
|
||||||
|
|
||||||
|
exec >> "$LOGFILE" 2>&1
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ===== Deploy triggered ====="
|
||||||
|
|
||||||
|
# Load secrets (auth tokens etc — not in git)
|
||||||
|
if [ -f /etc/lotus-deploy.env ]; then
|
||||||
|
set -a
|
||||||
|
# This env file only exists on the deploy host at runtime, so shellcheck
|
||||||
|
# can't follow it. The directive must sit DIRECTLY above the `source` —
|
||||||
|
# on a compound `set -a; source …` line it binds to `set -a` and the
|
||||||
|
# SC1091 finding still fails CI.
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source /etc/lotus-deploy.env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$REPO"
|
||||||
|
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Fetching origin/lotus..."
|
||||||
|
git fetch --all
|
||||||
|
COMMIT_SHA=$(git rev-parse origin/lotus)
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Commit: $COMMIT_SHA"
|
||||||
|
|
||||||
|
# ── CI gate ─────────────────────────────────────────────────────────────────
|
||||||
|
# Wait for the web build+test CI to pass before deploying. We gate ONLY on the
|
||||||
|
# "Build & Quality Checks" commit-status context (npm build + unit tests) — NOT
|
||||||
|
# the whole workflow run. This decouples the web deploy from the unrelated
|
||||||
|
# "Trigger Desktop Build" job and the slow downstream Tauri desktop builds that
|
||||||
|
# share the act_runner: web CI can sit queued behind a 30-min desktop build, so
|
||||||
|
# we keep waiting while the context is pending/absent, and only abort on an
|
||||||
|
# explicit failure or the (generous) cap. The previous version gated on the
|
||||||
|
# overall workflow run with a 15-min cap, so a web CI queued behind a desktop
|
||||||
|
# build timed out -> "result: timeout" -> deploy aborted -> the site stayed
|
||||||
|
# frozen on an old build for days.
|
||||||
|
if [ -n "${GITEA_API_TOKEN:-}" ]; then
|
||||||
|
GITEA_API="https://code.lotusguild.org/api/v1"
|
||||||
|
REPO_PATH="LotusGuild/cinny"
|
||||||
|
GATE_CONTEXT="Build & Quality Checks"
|
||||||
|
MAX_WAIT=2700 # 45 min — web CI can queue behind long Tauri desktop builds
|
||||||
|
POLL_INTERVAL=15
|
||||||
|
elapsed=0
|
||||||
|
ci_result=""
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Waiting for CI '$GATE_CONTEXT' on $COMMIT_SHA..."
|
||||||
|
|
||||||
|
while [ "$elapsed" -lt "$MAX_WAIT" ]; do
|
||||||
|
state=$(curl -s -H "Authorization: token $GITEA_API_TOKEN" \
|
||||||
|
"$GITEA_API/repos/$REPO_PATH/commits/$COMMIT_SHA/status" \
|
||||||
|
| GATE="$GATE_CONTEXT" python3 -c "
|
||||||
|
import json, os, sys
|
||||||
|
try:
|
||||||
|
d = json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
print('pending'); sys.exit(0)
|
||||||
|
gate = os.environ.get('GATE', '')
|
||||||
|
for s in d.get('statuses', []):
|
||||||
|
if gate in (s.get('context') or ''):
|
||||||
|
print(s.get('status') or 'pending'); break
|
||||||
|
else:
|
||||||
|
print('pending')
|
||||||
|
" 2>/dev/null || echo pending)
|
||||||
|
|
||||||
|
case "$state" in
|
||||||
|
success) ci_result=success; break ;;
|
||||||
|
failure|error) ci_result="$state"; break ;;
|
||||||
|
esac
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] CI not yet passed (${elapsed}s elapsed, '$GATE_CONTEXT': ${state}), waiting..."
|
||||||
|
sleep "$POLL_INTERVAL"
|
||||||
|
elapsed=$((elapsed + POLL_INTERVAL))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$ci_result" != "success" ]; then
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] CI did not pass (result: ${ci_result:-timeout}). Aborting deploy."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] CI '$GATE_CONTEXT' passed. Proceeding with deploy."
|
||||||
|
else
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARNING: GITEA_API_TOKEN not set, deploying without CI gate."
|
||||||
|
fi
|
||||||
|
|
||||||
|
git reset --hard origin/lotus
|
||||||
|
|
||||||
|
# Tag this build with the exact commit so Sentry can link errors to source
|
||||||
|
export VITE_APP_VERSION=$COMMIT_SHA
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Building commit $VITE_APP_VERSION..."
|
||||||
|
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Installing dependencies..."
|
||||||
|
npm ci --ignore-scripts
|
||||||
|
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Building..."
|
||||||
|
NODE_OPTIONS=--max_old_space_size=4096 npm run build
|
||||||
|
|
||||||
|
# The Element Call widget (the @lotusguild/element-call-embedded fork) is emitted
|
||||||
|
# into dist/public/element-call by the build itself — no manual copy is needed.
|
||||||
|
# (The old `cp node_modules/@element-hq/element-call-embedded/dist/.` step was a
|
||||||
|
# deploy-killer: the package was forked to @lotusguild, so under `set -e` that
|
||||||
|
# now-missing path aborted every deploy.) Verify the bundle actually landed
|
||||||
|
# before publishing rather than blindly copying.
|
||||||
|
if [ ! -f "$REPO/dist/public/element-call/index.html" ]; then
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: dist/public/element-call/ missing after build (check @lotusguild/element-call-embedded pin). Aborting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploying to $WEBROOT..."
|
||||||
|
# Exclude config.json: the production runtime config (homeserver list,
|
||||||
|
# allowCustomHomeservers, etc.) is owned by the matrix repo and deployed to
|
||||||
|
# /var/www/html/config.json by lxc106-cinny.sh. The build ships a DEV default
|
||||||
|
# (allowCustomHomeservers:true); rsyncing it would clobber the production config
|
||||||
|
# on every deploy. Keep the app bundle and the runtime config separate.
|
||||||
|
rsync -a --delete --exclude config.json dist/ "$WEBROOT/"
|
||||||
|
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ===== Deploy complete ($VITE_APP_VERSION) ====="
|
||||||
|
|
||||||
|
# Inject runtime secrets that are never stored in git. If the production
|
||||||
|
# config.json carries the "gifApiKey": "" placeholder, fill it from the env.
|
||||||
|
if [ -n "${GIPHY_API_KEY:-}" ]; then
|
||||||
|
sed -i "s|\"gifApiKey\": \"\"|\"gifApiKey\": \"$GIPHY_API_KEY\"|" "$WEBROOT/config.json"
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Injected GIPHY_API_KEY into config.json"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name chat.lotusguild.org;
|
||||||
|
|
||||||
|
# Brotli compression (better than gzip for modern browsers)
|
||||||
|
brotli on;
|
||||||
|
brotli_static on;
|
||||||
|
brotli_comp_level 6;
|
||||||
|
brotli_types text/plain text/css application/javascript application/json
|
||||||
|
image/svg+xml application/wasm font/woff2;
|
||||||
|
|
||||||
|
root /var/www/html;
|
||||||
|
server_tokens off;
|
||||||
|
client_max_body_size 50m;
|
||||||
|
|
||||||
|
limit_req zone=chat_limit burst=60 nodelay;
|
||||||
|
limit_conn chat_conn 25;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options SAMEORIGIN always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||||
|
# HSTS: TLS terminates upstream (this server is listen 80), so this reaches
|
||||||
|
# the browser only if the front proxy passes upstream response headers
|
||||||
|
# through; otherwise set it at the TLS terminator. includeSubDomains covers
|
||||||
|
# all *.lotusguild.org (all HTTPS); `preload` is inert until submitted to
|
||||||
|
# hstspreload.org.
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||||
|
# Permissions-Policy: allow only what the app uses (self) — calls
|
||||||
|
# (camera/microphone/display-capture), location share (geolocation), sounds
|
||||||
|
# (autoplay), Element Call (fullscreen/encrypted-media) — and deny the rest.
|
||||||
|
add_header Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()" always;
|
||||||
|
|
||||||
|
# Block all source map files and dotfiles from public access
|
||||||
|
location ~* \.(js|css)\.map$ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
location = /netlify.toml {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Content Security Policy
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https://matrix.lotusguild.org https://matrix.org https://*.matrix.org https://mozilla.org https://mozilla.modular.im https://drive.lotusguild.org https://media.giphy.com https://media0.giphy.com https://media1.giphy.com https://media2.giphy.com https://media3.giphy.com https://media4.giphy.com https://www.openstreetmap.org https://tile.openstreetmap.org; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://matrix.lotusguild.org wss://matrix.lotusguild.org https://matrix.org https://*.matrix.org https://mozilla.org https://mozilla.modular.im https://chat.mozilla.org https://vector.im https://api.giphy.com https://*.giphy.com wss:; media-src 'self' https: blob:; frame-src 'self' https:; worker-src 'self' blob:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
|
||||||
|
|
||||||
|
# Service worker must never be cached so updates are picked up immediately
|
||||||
|
location = /sw.js {
|
||||||
|
expires -1;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache content-addressed static assets aggressively
|
||||||
|
location ~* \.(?:js|css|woff2?|png|svg|ico|webp)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Never cache HTML or JSON (index.html, config.json, manifest.json)
|
||||||
|
location ~* \.(json|html)$ {
|
||||||
|
expires -1;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Auto-deploy webhook — proxied to local webhook service
|
||||||
|
location = /hooks/lotus-deploy {
|
||||||
|
proxy_pass http://127.0.0.1:9001/hooks/lotus-deploy;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 300;
|
||||||
|
proxy_connect_timeout 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
rewrite ^/config\.json$ /config.json break;
|
||||||
|
rewrite ^/manifest\.json$ /manifest.json break;
|
||||||
|
rewrite ^/sw\.js$ /sw.js break;
|
||||||
|
rewrite ^/pdf\.worker\.min\.js$ /pdf.worker.min.js break;
|
||||||
|
rewrite ^/public/(.*)$ /public/$1 break;
|
||||||
|
rewrite ^/assets/(.*)$ /assets/$1 break;
|
||||||
|
rewrite ^(.+)$ /index.html break;
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-2
@@ -1,6 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Auto-deploy script for LXC 106 (cinny)
|
# Auto-deploy script for LXC 106 (cinny)
|
||||||
# Handles: cinny/config.json, cinny/upstream-check.sh, cinny/lotus-build.sh,
|
# Handles: cinny/config.json, cinny/nginx.conf, cinny/upstream-check.sh,
|
||||||
|
# cinny/lotus-build.sh, cinny/lotus_deploy.sh,
|
||||||
# deploy/hooks-lxc106.json, systemd/cinny-upstream-check.cron
|
# deploy/hooks-lxc106.json, systemd/cinny-upstream-check.cron
|
||||||
# Triggered by: Gitea webhook on push to main
|
# Triggered by: Gitea webhook on push to main
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -14,7 +15,7 @@ echo "=== $(date) === LXC106 deploy triggered ==="
|
|||||||
|
|
||||||
if [ ! -d "$REPO_DIR/.git" ]; then
|
if [ ! -d "$REPO_DIR/.git" ]; then
|
||||||
git clone "$CLONE_URL" "$REPO_DIR"
|
git clone "$CLONE_URL" "$REPO_DIR"
|
||||||
CHANGED="cinny/config.json cinny/upstream-check.sh cinny/lotus-build.sh deploy/hooks-lxc106.json systemd/cinny-upstream-check.cron"
|
CHANGED="cinny/config.json cinny/nginx.conf cinny/upstream-check.sh cinny/lotus-build.sh cinny/lotus_deploy.sh deploy/hooks-lxc106.json systemd/cinny-upstream-check.cron"
|
||||||
else
|
else
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
git fetch --all
|
git fetch --all
|
||||||
@@ -31,6 +32,23 @@ if echo "$CHANGED" | grep -q '^cinny/config.json'; then
|
|||||||
echo "✓ config.json deployed"
|
echo "✓ config.json deployed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if echo "$CHANGED" | grep -q '^cinny/nginx.conf'; then
|
||||||
|
echo "Deploying cinny nginx site config..."
|
||||||
|
# Back up the live config, swap in the repo copy, and validate before
|
||||||
|
# reloading. If `nginx -t` fails, restore the backup and skip the reload so
|
||||||
|
# a bad config can never take the site down.
|
||||||
|
BACKUP="/etc/nginx/sites-available/cinny.bak-$(date +%Y%m%d%H%M%S)"
|
||||||
|
cp /etc/nginx/sites-available/cinny "$BACKUP"
|
||||||
|
cp "$REPO_DIR/cinny/nginx.conf" /etc/nginx/sites-available/cinny
|
||||||
|
if nginx -t; then
|
||||||
|
systemctl reload nginx
|
||||||
|
echo "✓ nginx site config deployed + reloaded"
|
||||||
|
else
|
||||||
|
echo "✗ nginx -t FAILED — restoring previous config, skipping reload"
|
||||||
|
cp "$BACKUP" /etc/nginx/sites-available/cinny
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if echo "$CHANGED" | grep -q '^cinny/upstream-check.sh'; then
|
if echo "$CHANGED" | grep -q '^cinny/upstream-check.sh'; then
|
||||||
echo "Deploying upstream-check.sh..."
|
echo "Deploying upstream-check.sh..."
|
||||||
cp "$REPO_DIR/cinny/upstream-check.sh" /usr/local/bin/cinny-upstream-check.sh
|
cp "$REPO_DIR/cinny/upstream-check.sh" /usr/local/bin/cinny-upstream-check.sh
|
||||||
@@ -45,6 +63,19 @@ if echo "$CHANGED" | grep -q '^cinny/lotus-build.sh'; then
|
|||||||
echo "✓ lotus-build.sh deployed"
|
echo "✓ lotus-build.sh deployed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if echo "$CHANGED" | grep -q '^cinny/lotus_deploy.sh'; then
|
||||||
|
echo "Deploying lotus_deploy.sh (webhook CI-gated web deploy)..."
|
||||||
|
# The `lotus-deploy` webhook hook executes /usr/local/bin/lotus_deploy.sh.
|
||||||
|
# Validate syntax before swapping so a broken script can never wedge deploys.
|
||||||
|
if bash -n "$REPO_DIR/cinny/lotus_deploy.sh"; then
|
||||||
|
cp "$REPO_DIR/cinny/lotus_deploy.sh" /usr/local/bin/lotus_deploy.sh
|
||||||
|
chmod +x /usr/local/bin/lotus_deploy.sh
|
||||||
|
echo "✓ lotus_deploy.sh deployed"
|
||||||
|
else
|
||||||
|
echo "✗ bash -n FAILED on lotus_deploy.sh — skipping install"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if echo "$CHANGED" | grep -q '^deploy/hooks-lxc106.json'; then
|
if echo "$CHANGED" | grep -q '^deploy/hooks-lxc106.json'; then
|
||||||
echo "Deploying hooks-lxc106.json..."
|
echo "Deploying hooks-lxc106.json..."
|
||||||
cp "$REPO_DIR/deploy/hooks-lxc106.json" /etc/webhook/hooks.json
|
cp "$REPO_DIR/deploy/hooks-lxc106.json" /etc/webhook/hooks.json
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Auto-deploy script for LXC 151 (matrix homeserver)
|
# Auto-deploy script for LXC 151 (matrix homeserver)
|
||||||
# Handles: hookshot transformation functions, livekit service file (graceful), matrixbot
|
# Handles: hookshot transformation functions, livekit service file (graceful),
|
||||||
|
# voice-limit-guard (livekit policy sidecar), matrixbot
|
||||||
# Triggered by: Gitea webhook on push to main
|
# Triggered by: Gitea webhook on push to main
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -46,6 +47,48 @@ else
|
|||||||
echo "Restart pending — will apply when no active calls."
|
echo "Restart pending — will apply when no active calls."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Voice-limit / quality guard (fronts lk-jwt-service on :8070)
|
||||||
|
if echo "$CHANGED" | grep -qE '^livekit/voice-limit-guard\.py|^systemd/voice-limit-guard\.service'; then
|
||||||
|
echo "Deploying voice-limit-guard..."
|
||||||
|
# Validate syntax BEFORE swapping so a broken edit can never wedge token
|
||||||
|
# issuance (a syntax error would crash the guard and block all joins).
|
||||||
|
if python3 -m py_compile "$REPO_DIR/livekit/voice-limit-guard.py"; then
|
||||||
|
GUARD_DST="/opt/voice-limit-guard/voice-limit-guard.py"
|
||||||
|
GUARD_BAK="/opt/voice-limit-guard/voice-limit-guard.py.bak"
|
||||||
|
# Back up the last-known-good guard so a runtime-broken (but
|
||||||
|
# syntactically valid) deploy can self-heal — a dead guard breaks
|
||||||
|
# ALL new joins, so we must never leave it down.
|
||||||
|
[ -f "$GUARD_DST" ] && cp "$GUARD_DST" "$GUARD_BAK"
|
||||||
|
install -D -m644 "$REPO_DIR/livekit/voice-limit-guard.py" "$GUARD_DST"
|
||||||
|
if echo "$CHANGED" | grep -q '^systemd/voice-limit-guard\.service'; then
|
||||||
|
install -m644 "$REPO_DIR/systemd/voice-limit-guard.service" /etc/systemd/system/voice-limit-guard.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
fi
|
||||||
|
# Restarting the guard only affects joins in a ~1s window (established
|
||||||
|
# calls talk directly to livekit-server); it does not drop calls.
|
||||||
|
# `|| true` so a non-zero restart can't abort the deploy under set -e.
|
||||||
|
systemctl restart voice-limit-guard || true
|
||||||
|
sleep 1
|
||||||
|
if systemctl is-active --quiet voice-limit-guard; then
|
||||||
|
echo "voice-limit-guard restarted successfully."
|
||||||
|
elif [ -f "$GUARD_BAK" ]; then
|
||||||
|
echo "ERROR: new voice-limit-guard failed to start — rolling back to last-known-good."
|
||||||
|
cp "$GUARD_BAK" "$GUARD_DST"
|
||||||
|
systemctl restart voice-limit-guard || true
|
||||||
|
sleep 1
|
||||||
|
if systemctl is-active --quiet voice-limit-guard; then
|
||||||
|
echo "voice-limit-guard rolled back and running."
|
||||||
|
else
|
||||||
|
echo "CRITICAL: voice-limit-guard down after rollback — manual intervention needed."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "CRITICAL: voice-limit-guard failed to start and no backup to roll back to."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "ERROR: py_compile failed on voice-limit-guard.py — skipping deploy."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Matrixbot source files
|
# Matrixbot source files
|
||||||
if echo "$CHANGED" | grep -q '^matrixbot/'; then
|
if echo "$CHANGED" | grep -q '^matrixbot/'; then
|
||||||
echo "Deploying matrixbot changes..."
|
echo "Deploying matrixbot changes..."
|
||||||
|
|||||||
+300
-6
File diff suppressed because one or more lines are too long
@@ -0,0 +1,364 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Unit tests for voice-limit-guard pure logic + JWT re-sign roundtrip.
|
||||||
|
|
||||||
|
Run: python3 -m unittest livekit/test_voice_limit_guard.py (from repo root)
|
||||||
|
The module has a hyphenated filename, so it's loaded via importlib.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import importlib.util
|
||||||
|
import urllib.error
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_spec = importlib.util.spec_from_file_location(
|
||||||
|
"voice_limit_guard", os.path.join(_HERE, "voice-limit-guard.py")
|
||||||
|
)
|
||||||
|
guard = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(guard)
|
||||||
|
|
||||||
|
|
||||||
|
def make_jwt(secret: str, payload: dict) -> str:
|
||||||
|
header_b64 = guard.b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
|
||||||
|
payload_b64 = guard.b64url(json.dumps(payload).encode())
|
||||||
|
signing_input = f"{header_b64}.{payload_b64}".encode()
|
||||||
|
sig = guard.b64url(hmac.new(secret.encode(), signing_input, hashlib.sha256).digest())
|
||||||
|
return f"{header_b64}.{payload_b64}.{sig}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_jwt(secret: str, token: str) -> bool:
|
||||||
|
header_b64, payload_b64, sig = token.split(".")
|
||||||
|
expected = guard.b64url(
|
||||||
|
hmac.new(secret.encode(), f"{header_b64}.{payload_b64}".encode(), hashlib.sha256).digest()
|
||||||
|
)
|
||||||
|
return hmac.compare_digest(expected, sig)
|
||||||
|
|
||||||
|
|
||||||
|
class TestShouldBlock(unittest.TestCase):
|
||||||
|
def test_no_limit_never_blocks(self):
|
||||||
|
self.assertFalse(guard.should_block(0, {"@a:x", "@b:x"}, "@c:x"))
|
||||||
|
|
||||||
|
def test_rejoin_never_blocks(self):
|
||||||
|
self.assertFalse(guard.should_block(2, {"@a:x", "@b:x"}, "@a:x"))
|
||||||
|
|
||||||
|
def test_blocks_at_capacity_for_new_user(self):
|
||||||
|
self.assertTrue(guard.should_block(2, {"@a:x", "@b:x"}, "@c:x"))
|
||||||
|
|
||||||
|
def test_allows_below_capacity(self):
|
||||||
|
self.assertFalse(guard.should_block(3, {"@a:x", "@b:x"}, "@c:x"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestMatrixUser(unittest.TestCase):
|
||||||
|
def test_strips_device(self):
|
||||||
|
self.assertEqual(guard.matrix_user("@bob:example.org:DEVICEID"), "@bob:example.org")
|
||||||
|
|
||||||
|
def test_plain_user(self):
|
||||||
|
self.assertEqual(guard.matrix_user("@bob:example.org"), "@bob:example.org")
|
||||||
|
|
||||||
|
def test_non_matrix_identity_unchanged(self):
|
||||||
|
self.assertEqual(guard.matrix_user("hashedfederatedid"), "hashedfederatedid")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAllowedSources(unittest.TestCase):
|
||||||
|
def test_all_allowed_returns_none(self):
|
||||||
|
self.assertIsNone(guard.allowed_sources({}))
|
||||||
|
self.assertIsNone(
|
||||||
|
guard.allowed_sources({"allow_camera": True, "allow_screenshare": True})
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_screenshare_drops_screen_sources_keeps_mic_cam(self):
|
||||||
|
self.assertEqual(
|
||||||
|
guard.allowed_sources({"allow_screenshare": False}),
|
||||||
|
["microphone", "camera"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_audio_only_keeps_mic_only(self):
|
||||||
|
self.assertEqual(
|
||||||
|
guard.allowed_sources({"allow_screenshare": False, "allow_camera": False}),
|
||||||
|
["microphone"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_camera_keeps_mic_and_screenshare(self):
|
||||||
|
self.assertEqual(
|
||||||
|
guard.allowed_sources({"allow_camera": False}),
|
||||||
|
["microphone", "screen_share", "screen_share_audio"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestResignJwt(unittest.TestCase):
|
||||||
|
SECRET = "test-livekit-secret"
|
||||||
|
|
||||||
|
def _make(self):
|
||||||
|
return make_jwt(
|
||||||
|
self.SECRET,
|
||||||
|
{
|
||||||
|
"iss": "APIkey",
|
||||||
|
"sub": "@alice:example.org:DEV1",
|
||||||
|
"nbf": 1000,
|
||||||
|
"exp": 5000,
|
||||||
|
"video": {"roomJoin": True, "room": "hashed-alias", "canPublish": True,
|
||||||
|
"canSubscribe": True},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resigned_token_verifies_with_same_secret(self):
|
||||||
|
token = self._make()
|
||||||
|
new = guard.resign_jwt(token, self.SECRET, ["microphone", "camera"])
|
||||||
|
self.assertTrue(verify_jwt(self.SECRET, new))
|
||||||
|
|
||||||
|
def test_sets_sources_and_preserves_identity_and_room(self):
|
||||||
|
token = self._make()
|
||||||
|
new = guard.resign_jwt(token, self.SECRET, ["microphone"])
|
||||||
|
claims = guard.jwt_payload(new)
|
||||||
|
self.assertEqual(claims["video"]["canPublishSources"], ["microphone"])
|
||||||
|
# Everything else preserved.
|
||||||
|
self.assertEqual(claims["sub"], "@alice:example.org:DEV1")
|
||||||
|
self.assertEqual(claims["video"]["room"], "hashed-alias")
|
||||||
|
self.assertEqual(claims["exp"], 5000)
|
||||||
|
self.assertTrue(claims["video"]["canPublish"])
|
||||||
|
|
||||||
|
def test_preserves_original_header_segment(self):
|
||||||
|
token = self._make()
|
||||||
|
new = guard.resign_jwt(token, self.SECRET, ["microphone"])
|
||||||
|
self.assertEqual(token.split(".")[0], new.split(".")[0])
|
||||||
|
|
||||||
|
def test_raises_without_video_grant(self):
|
||||||
|
token = make_jwt(self.SECRET, {"sub": "@x:y", "exp": 1})
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
guard.resign_jwt(token, self.SECRET, ["microphone"])
|
||||||
|
|
||||||
|
def test_tampering_detectable(self):
|
||||||
|
# A token re-signed with the WRONG secret must not verify with the real one.
|
||||||
|
token = self._make()
|
||||||
|
forged = guard.resign_jwt(token, "wrong-secret", ["microphone"])
|
||||||
|
self.assertFalse(verify_jwt(self.SECRET, forged))
|
||||||
|
|
||||||
|
|
||||||
|
class TestVerifyJwtSig(unittest.TestCase):
|
||||||
|
SECRET = "shared-livekit-secret"
|
||||||
|
|
||||||
|
def _token(self, secret):
|
||||||
|
return make_jwt(secret, {"sub": "@a:b:D", "exp": 9, "video": {"room": "r"}})
|
||||||
|
|
||||||
|
def test_verifies_token_signed_with_same_secret(self):
|
||||||
|
# Guard's own secret signed the token -> safe to re-sign.
|
||||||
|
self.assertTrue(guard.verify_jwt_sig(self._token(self.SECRET), self.SECRET))
|
||||||
|
|
||||||
|
def test_rejects_token_signed_with_different_secret(self):
|
||||||
|
# Secret drift: lk-jwt-service used a different key. Re-signing would
|
||||||
|
# produce a token the SFU rejects, so the guard must detect this and
|
||||||
|
# skip the restriction (fail open) instead.
|
||||||
|
self.assertFalse(guard.verify_jwt_sig(self._token("lk-jwt-secret"), self.SECRET))
|
||||||
|
|
||||||
|
def test_malformed_token_returns_false(self):
|
||||||
|
self.assertFalse(guard.verify_jwt_sig("not-a-jwt", self.SECRET))
|
||||||
|
self.assertFalse(guard.verify_jwt_sig("", self.SECRET))
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoomIdFromRequest(unittest.TestCase):
|
||||||
|
def test_legacy_sfu_get_reads_room(self):
|
||||||
|
self.assertEqual(guard.room_id_from_request("/sfu/get", {"room": "!a:x"}), "!a:x")
|
||||||
|
|
||||||
|
def test_new_get_token_reads_room_id(self):
|
||||||
|
self.assertEqual(guard.room_id_from_request("/get_token", {"room_id": "!b:x"}), "!b:x")
|
||||||
|
|
||||||
|
def test_both_keys_uses_endpoint_field_not_the_other(self):
|
||||||
|
# A client sending both keys must not get the wrong room's policy: each
|
||||||
|
# endpoint reads only its own field (matching lk-jwt-service).
|
||||||
|
both = {"room": "!lax:x", "room_id": "!restricted:x"}
|
||||||
|
self.assertEqual(guard.room_id_from_request("/sfu/get", both), "!lax:x")
|
||||||
|
self.assertEqual(guard.room_id_from_request("/get_token", both), "!restricted:x")
|
||||||
|
|
||||||
|
def test_missing_field_is_empty(self):
|
||||||
|
self.assertEqual(guard.room_id_from_request("/get_token", {"room": "!a:x"}), "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestForbiddenSources(unittest.TestCase):
|
||||||
|
def test_none_forbidden_when_all_allowed(self):
|
||||||
|
self.assertEqual(guard.forbidden_sources({}), set())
|
||||||
|
self.assertEqual(
|
||||||
|
guard.forbidden_sources({"allow_camera": True, "allow_screenshare": True}), set()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_screenshare_forbidden(self):
|
||||||
|
self.assertEqual(
|
||||||
|
guard.forbidden_sources({"allow_screenshare": False}),
|
||||||
|
{"SCREEN_SHARE", "SCREEN_SHARE_AUDIO"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_audio_only_forbids_cam_and_screen(self):
|
||||||
|
self.assertEqual(
|
||||||
|
guard.forbidden_sources({"allow_screenshare": False, "allow_camera": False}),
|
||||||
|
{"SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReconcilePublishSources(unittest.TestCase):
|
||||||
|
SS = {"SCREEN_SHARE", "SCREEN_SHARE_AUDIO"}
|
||||||
|
|
||||||
|
def test_empty_current_means_all_allowed_so_gets_narrowed(self):
|
||||||
|
# [] = all allowed; forbidding screenshare must produce the explicit
|
||||||
|
# non-screenshare list (never an empty list, which LK reads as "all").
|
||||||
|
result = guard.reconcile_publish_sources([], self.SS)
|
||||||
|
self.assertEqual(result, ["CAMERA", "MICROPHONE"])
|
||||||
|
|
||||||
|
def test_compliant_when_no_forbidden_present(self):
|
||||||
|
self.assertIsNone(guard.reconcile_publish_sources(["CAMERA", "MICROPHONE"], self.SS))
|
||||||
|
|
||||||
|
def test_removes_only_forbidden_never_grants(self):
|
||||||
|
result = guard.reconcile_publish_sources(["MICROPHONE", "SCREEN_SHARE"], self.SS)
|
||||||
|
self.assertEqual(result, ["MICROPHONE"])
|
||||||
|
|
||||||
|
def test_never_widens_a_narrow_set(self):
|
||||||
|
# Participant only had mic; forbidding screenshare leaves mic — camera is
|
||||||
|
# NOT granted.
|
||||||
|
self.assertIsNone(guard.reconcile_publish_sources(["MICROPHONE"], self.SS))
|
||||||
|
|
||||||
|
def test_empty_result_signals_disable_publish(self):
|
||||||
|
# If the only source they had is now forbidden, the result is [] so the
|
||||||
|
# caller sets canPublish=False (not an empty allow-list).
|
||||||
|
result = guard.reconcile_publish_sources(["SCREEN_SHARE"], self.SS)
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestReconcileParticipant(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.calls = []
|
||||||
|
self._orig = guard.livekit_update_participant
|
||||||
|
guard.livekit_update_participant = lambda a, i, p: self.calls.append((a, i, p))
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
guard.livekit_update_participant = self._orig
|
||||||
|
|
||||||
|
def test_skips_non_publisher(self):
|
||||||
|
p = {"identity": "u", "permission": {"canPublish": False}}
|
||||||
|
self.assertFalse(guard.reconcile_participant("room", p, {"SCREEN_SHARE"}))
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
|
||||||
|
def test_skips_compliant_publisher(self):
|
||||||
|
p = {
|
||||||
|
"identity": "u",
|
||||||
|
"permission": {"canPublish": True, "canPublishSources": ["MICROPHONE", "CAMERA"]},
|
||||||
|
}
|
||||||
|
self.assertFalse(guard.reconcile_participant("room", p, {"SCREEN_SHARE"}))
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
|
||||||
|
def test_revokes_and_preserves_other_permission_flags(self):
|
||||||
|
p = {
|
||||||
|
"identity": "@a:b:D",
|
||||||
|
"permission": {
|
||||||
|
"canPublish": True,
|
||||||
|
"canSubscribe": True,
|
||||||
|
"canPublishData": True,
|
||||||
|
"canPublishSources": ["MICROPHONE", "SCREEN_SHARE"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self.assertTrue(guard.reconcile_participant("room", p, {"SCREEN_SHARE", "SCREEN_SHARE_AUDIO"}))
|
||||||
|
self.assertEqual(len(self.calls), 1)
|
||||||
|
_alias, identity, perm = self.calls[0]
|
||||||
|
self.assertEqual(identity, "@a:b:D")
|
||||||
|
self.assertEqual(perm["canPublishSources"], ["MICROPHONE"])
|
||||||
|
self.assertTrue(perm["canPublish"])
|
||||||
|
# Other flags preserved (full-replace safety).
|
||||||
|
self.assertTrue(perm["canSubscribe"])
|
||||||
|
self.assertTrue(perm["canPublishData"])
|
||||||
|
|
||||||
|
def test_disables_publish_when_no_source_remains(self):
|
||||||
|
p = {"identity": "u", "permission": {"canPublish": True, "canPublishSources": ["SCREEN_SHARE"]}}
|
||||||
|
guard.reconcile_participant("room", p, {"SCREEN_SHARE", "SCREEN_SHARE_AUDIO"})
|
||||||
|
_a, _i, perm = self.calls[0]
|
||||||
|
self.assertFalse(perm["canPublish"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestReconcileRoom(unittest.TestCase):
|
||||||
|
"""End-to-end reconcile_room with LiveKit + Synapse mocked."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._orig_state = guard.room_state
|
||||||
|
self._orig_list = guard.livekit_list_participants
|
||||||
|
self._orig_update = guard.livekit_update_participant
|
||||||
|
self.updates = []
|
||||||
|
guard.livekit_update_participant = lambda a, i, p: self.updates.append((i, p))
|
||||||
|
guard._alias_to_room.clear()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
guard.room_state = self._orig_state
|
||||||
|
guard.livekit_list_participants = self._orig_list
|
||||||
|
guard.livekit_update_participant = self._orig_update
|
||||||
|
guard._alias_to_room.clear()
|
||||||
|
|
||||||
|
def test_unrestricted_room_touches_nothing(self):
|
||||||
|
guard.room_state = lambda rid, max_age=0: {"allow_screenshare": True, "allow_camera": True}
|
||||||
|
guard.livekit_list_participants = lambda a: (_ for _ in ()).throw(
|
||||||
|
AssertionError("should not list participants when unrestricted")
|
||||||
|
)
|
||||||
|
guard.reconcile_room("alias", "!room:x")
|
||||||
|
self.assertEqual(self.updates, [])
|
||||||
|
|
||||||
|
def test_screenshare_forbidden_revokes_the_sharer(self):
|
||||||
|
guard.room_state = lambda rid, max_age=0: {"allow_screenshare": False, "allow_camera": True}
|
||||||
|
guard.livekit_list_participants = lambda a: [
|
||||||
|
{"identity": "sharer", "permission": {"canPublish": True,
|
||||||
|
"canPublishSources": ["MICROPHONE", "SCREEN_SHARE"]}},
|
||||||
|
{"identity": "listener", "permission": {"canPublish": True,
|
||||||
|
"canPublishSources": ["MICROPHONE"]}},
|
||||||
|
]
|
||||||
|
guard.reconcile_room("alias", "!room:x")
|
||||||
|
self.assertEqual(len(self.updates), 1)
|
||||||
|
self.assertEqual(self.updates[0][0], "sharer")
|
||||||
|
self.assertEqual(self.updates[0][1]["canPublishSources"], ["MICROPHONE"])
|
||||||
|
|
||||||
|
def test_empty_room_is_NOT_forgotten(self):
|
||||||
|
# An empty read may be transient (room persists until empty_timeout); only
|
||||||
|
# a 404 prunes, so mid-call enforcement isn't dropped on a race.
|
||||||
|
guard._alias_to_room["alias"] = "!room:x"
|
||||||
|
guard.room_state = lambda rid, max_age=0: {"allow_screenshare": False, "allow_camera": True}
|
||||||
|
guard.livekit_list_participants = lambda a: []
|
||||||
|
guard.reconcile_room("alias", "!room:x")
|
||||||
|
self.assertIn("alias", guard._alias_to_room)
|
||||||
|
|
||||||
|
def test_room_gone_404_is_forgotten(self):
|
||||||
|
guard._alias_to_room["alias"] = "!room:x"
|
||||||
|
guard.room_state = lambda rid, max_age=0: {"allow_screenshare": False, "allow_camera": True}
|
||||||
|
|
||||||
|
def _raise_404(_alias):
|
||||||
|
raise urllib.error.HTTPError("u", 404, "not found", {}, None)
|
||||||
|
|
||||||
|
guard.livekit_list_participants = _raise_404
|
||||||
|
guard.reconcile_room("alias", "!room:x")
|
||||||
|
self.assertNotIn("alias", guard._alias_to_room)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoomStateParsing(unittest.TestCase):
|
||||||
|
"""allow_* only forbids on an explicit False; absent/other stays permissive."""
|
||||||
|
|
||||||
|
def _policy(self, content):
|
||||||
|
# Emulate the parsing block in room_state without hitting Synapse.
|
||||||
|
return {
|
||||||
|
"allow_screenshare": content.get("allow_screenshare", True) is not False,
|
||||||
|
"allow_camera": content.get("allow_camera", True) is not False,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_absent_is_allowed(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self._policy({}), {"allow_screenshare": True, "allow_camera": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_explicit_false_forbids(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self._policy({"allow_screenshare": False}),
|
||||||
|
{"allow_screenshare": False, "allow_camera": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_non_bool_stays_permissive(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self._policy({"allow_screenshare": "no"}),
|
||||||
|
{"allow_screenshare": True, "allow_camera": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,664 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
voice-limit-guard — hard, cross-client LiveKit room policy at token issue.
|
||||||
|
|
||||||
|
Sits in front of lk-jwt-service (the LiveKit MatrixRTC JWT issuer). Every
|
||||||
|
Matrix client (Element, FluffyChat, Lotus Chat, ...) must obtain a token from
|
||||||
|
this service before it can join a LiveKit room, so decisions made here are HARD
|
||||||
|
and apply to ALL clients — not just our own.
|
||||||
|
|
||||||
|
Two per-room policies are enforced, both read from Matrix room state via the
|
||||||
|
Synapse admin API (cached briefly):
|
||||||
|
|
||||||
|
1. Participant limit (`io.lotus.voice_limit` -> max_users). If the room is
|
||||||
|
full, the token is REFUSED (403), so the client cannot join.
|
||||||
|
|
||||||
|
2. Publish-source policy (`io.lotus.room_quality` -> allow_screenshare /
|
||||||
|
allow_camera). LiveKit is a pure SFU and CANNOT cap a publisher's bitrate/
|
||||||
|
framerate server-side (no such field exists in the grant, config, or admin
|
||||||
|
API — that stays a client-cooperative setting). But the JWT's
|
||||||
|
`video.canPublishSources` IS enforced by the SFU for every client, so we
|
||||||
|
can hard-block screenshare and/or camera per room. Because this guard holds
|
||||||
|
the LiveKit signing secret, it decodes the issued token, drops the
|
||||||
|
forbidden sources, and re-signs it before returning.
|
||||||
|
|
||||||
|
Enforced in TWO places so a policy applies both to new AND existing calls:
|
||||||
|
- at token issue (above), for anyone joining/rejoining, and
|
||||||
|
- LIVE, by a background reconcile loop (every GUARD_RECONCILE_INTERVAL s)
|
||||||
|
that calls LiveKit `UpdateParticipant` to narrow `canPublishSources`
|
||||||
|
for participants who were already connected when the policy changed —
|
||||||
|
which unpublishes their forbidden live track server-side for all
|
||||||
|
clients and blocks re-publish. It only ever REMOVES forbidden sources
|
||||||
|
(never grants), and no-ops once a room is compliant.
|
||||||
|
|
||||||
|
Flow for token requests (POST /sfu/get and legacy POST /get_token):
|
||||||
|
1. Read the request body and extract the Matrix room id (`room` on the legacy
|
||||||
|
endpoint, `room_id` on the newer one).
|
||||||
|
2. Forward the request to lk-jwt-service unchanged and capture its response.
|
||||||
|
3. If a token was issued (HTTP 200), look up the room policy:
|
||||||
|
- over the participant limit -> 403 (blocked).
|
||||||
|
- a publish-source restriction applies -> re-sign the JWT with a
|
||||||
|
narrowed `video.canPublishSources`.
|
||||||
|
Otherwise pass the token through untouched.
|
||||||
|
4. Anything that goes wrong FAILS OPEN: the upstream response is returned
|
||||||
|
unchanged, so calls keep working even if this guard is degraded.
|
||||||
|
|
||||||
|
All other requests (OPTIONS preflight, GET, unknown paths) are proxied
|
||||||
|
transparently so CORS and health behaviour match lk-jwt-service exactly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
# --- configuration (from environment) ---------------------------------------
|
||||||
|
BIND_HOST = os.environ.get("GUARD_BIND_HOST", "0.0.0.0")
|
||||||
|
BIND_PORT = int(os.environ.get("GUARD_BIND_PORT", "8070"))
|
||||||
|
UPSTREAM = os.environ.get("GUARD_UPSTREAM", "http://127.0.0.1:8071").rstrip("/")
|
||||||
|
LIVEKIT_API = os.environ.get("LIVEKIT_API", "http://127.0.0.1:7880").rstrip("/")
|
||||||
|
LIVEKIT_KEY = os.environ.get("LIVEKIT_KEY", "")
|
||||||
|
LIVEKIT_SECRET = os.environ.get("LIVEKIT_SECRET", "")
|
||||||
|
SYNAPSE_API = os.environ.get("SYNAPSE_API", "http://127.0.0.1:8008").rstrip("/")
|
||||||
|
MATRIX_TOKEN = os.environ.get("MATRIX_TOKEN", "")
|
||||||
|
# Live (mid-call) enforcement: a background loop that revokes forbidden sources
|
||||||
|
# from participants who joined BEFORE a policy tightened. New joins are already
|
||||||
|
# handled by the JWT re-sign at issue time; this closes the mid-call-flip gap.
|
||||||
|
RECONCILE_ENABLED = os.environ.get("GUARD_RECONCILE", "1") not in ("0", "false", "")
|
||||||
|
# Floor the interval so a misconfigured 0 can't busy-loop the admin/Synapse APIs.
|
||||||
|
RECONCILE_INTERVAL = max(0.5, float(os.environ.get("GUARD_RECONCILE_INTERVAL", "3.0")))
|
||||||
|
|
||||||
|
TOKEN_PATHS = ("/sfu/get", "/get_token")
|
||||||
|
LIMIT_STATE_TYPE = "io.lotus.voice_limit"
|
||||||
|
QUALITY_STATE_TYPE = "io.lotus.room_quality"
|
||||||
|
# JWT grant source keys (lowercase — livekit/protocol auth.VideoGrant.canPublishSources).
|
||||||
|
SOURCE_MIC = "microphone"
|
||||||
|
SOURCE_CAM = "camera"
|
||||||
|
SOURCE_SCREEN = "screen_share"
|
||||||
|
SOURCE_SCREEN_AUDIO = "screen_share_audio"
|
||||||
|
# LiveKit admin-API / ListParticipants source enum names (UPPERCASE — protojson
|
||||||
|
# of livekit.TrackSource). Distinct from the JWT keys above.
|
||||||
|
API_SOURCE_CAM = "CAMERA"
|
||||||
|
API_SOURCE_MIC = "MICROPHONE"
|
||||||
|
API_SOURCE_SCREEN = "SCREEN_SHARE"
|
||||||
|
API_SOURCE_SCREEN_AUDIO = "SCREEN_SHARE_AUDIO"
|
||||||
|
ALL_API_SOURCES = (API_SOURCE_CAM, API_SOURCE_MIC, API_SOURCE_SCREEN, API_SOURCE_SCREEN_AUDIO)
|
||||||
|
CORS_HEADERS = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "POST",
|
||||||
|
"Access-Control-Allow-Headers": "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token",
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- small helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print(f"[voice-limit-guard] {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def b64url(data: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
||||||
|
|
||||||
|
|
||||||
|
def b64url_decode(seg: str) -> bytes:
|
||||||
|
"""Decode a base64url segment that may have had its `=` padding stripped."""
|
||||||
|
seg += "=" * (-len(seg) % 4)
|
||||||
|
return base64.urlsafe_b64decode(seg)
|
||||||
|
|
||||||
|
|
||||||
|
def jwt_payload(token: str):
|
||||||
|
"""Best-effort decode of a JWT payload without verification."""
|
||||||
|
try:
|
||||||
|
return json.loads(b64url_decode(token.split(".")[1]))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def room_id_from_request(path: str, data: dict) -> str:
|
||||||
|
"""The room id field lk-jwt-service reads for this endpoint. Endpoint-specific
|
||||||
|
(`/get_token` -> room_id, `/sfu/get` -> room) so a client sending BOTH keys
|
||||||
|
can't make us enforce a different room's policy than the token is minted for.
|
||||||
|
"""
|
||||||
|
if path == "/get_token":
|
||||||
|
return data.get("room_id") or ""
|
||||||
|
return data.get("room") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def should_block(limit: int, present_users: set, requester: str) -> bool:
|
||||||
|
"""Pure decision: should this token be refused?
|
||||||
|
|
||||||
|
- limit <= 0 -> never block (no limit configured)
|
||||||
|
- requester already present -> never block (rejoin / extra device)
|
||||||
|
- distinct users >= limit -> block
|
||||||
|
"""
|
||||||
|
if limit <= 0:
|
||||||
|
return False
|
||||||
|
if requester and requester in present_users:
|
||||||
|
return False
|
||||||
|
return len(present_users) >= limit
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_sources(policy: dict):
|
||||||
|
"""Pure decision: the narrowed `canPublishSources` list, or None.
|
||||||
|
|
||||||
|
Microphone is ALWAYS allowed (this is a voice call). Camera and screenshare
|
||||||
|
are dropped when the room policy forbids them. Returns None when everything
|
||||||
|
is allowed, so the caller can skip re-signing the token entirely.
|
||||||
|
"""
|
||||||
|
allow_cam = policy.get("allow_camera", True)
|
||||||
|
allow_screen = policy.get("allow_screenshare", True)
|
||||||
|
if allow_cam and allow_screen:
|
||||||
|
return None
|
||||||
|
sources = [SOURCE_MIC]
|
||||||
|
if allow_cam:
|
||||||
|
sources.append(SOURCE_CAM)
|
||||||
|
if allow_screen:
|
||||||
|
sources.extend((SOURCE_SCREEN, SOURCE_SCREEN_AUDIO))
|
||||||
|
return sources
|
||||||
|
|
||||||
|
|
||||||
|
def verify_jwt_sig(token: str, secret: str) -> bool:
|
||||||
|
"""True if `token` is HS256-signed with `secret`.
|
||||||
|
|
||||||
|
Used to confirm OUR LiveKit secret actually signed the token lk-jwt-service
|
||||||
|
issued before we re-sign it. If the secrets have drifted, re-signing would
|
||||||
|
mint a token the SFU rejects (a fail-CLOSED break), so the caller skips the
|
||||||
|
restriction and passes the original token through instead.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
header_b64, payload_b64, sig = token.split(".")
|
||||||
|
expected = b64url(
|
||||||
|
hmac.new(secret.encode(), f"{header_b64}.{payload_b64}".encode(), hashlib.sha256).digest()
|
||||||
|
)
|
||||||
|
return hmac.compare_digest(expected, sig)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def resign_jwt(token: str, secret: str, sources: list) -> str:
|
||||||
|
"""Re-sign the issued JWT with a restricted `video.canPublishSources`.
|
||||||
|
|
||||||
|
Preserves the original JWT header segment and every claim except
|
||||||
|
`video.canPublishSources` (so `sub`, `video.room`, `exp`, etc. are
|
||||||
|
unchanged), then HS256-signs with the shared LiveKit secret. lk-jwt-service
|
||||||
|
signs with the same key, so the SFU accepts our signature identically.
|
||||||
|
"""
|
||||||
|
header_b64, payload_b64, _sig = token.split(".")
|
||||||
|
payload = json.loads(b64url_decode(payload_b64))
|
||||||
|
video = payload.get("video")
|
||||||
|
if not isinstance(video, dict):
|
||||||
|
raise ValueError("issued token has no video grant")
|
||||||
|
video["canPublishSources"] = sources
|
||||||
|
new_payload_b64 = b64url(json.dumps(payload, separators=(",", ":")).encode())
|
||||||
|
signing_input = f"{header_b64}.{new_payload_b64}".encode()
|
||||||
|
sig = b64url(hmac.new(secret.encode(), signing_input, hashlib.sha256).digest())
|
||||||
|
return f"{header_b64}.{new_payload_b64}.{sig}"
|
||||||
|
|
||||||
|
|
||||||
|
def matrix_user(identity: str) -> str:
|
||||||
|
"""Reduce a LiveKit identity (`@user:domain:DEVICE`) to `@user:domain`.
|
||||||
|
|
||||||
|
Non-Matrix identities (e.g. hashed federated identities) are returned
|
||||||
|
unchanged so they each count as a distinct user.
|
||||||
|
"""
|
||||||
|
if not identity.startswith("@"):
|
||||||
|
return identity
|
||||||
|
first = identity.find(":")
|
||||||
|
if first == -1:
|
||||||
|
return identity
|
||||||
|
second = identity.find(":", first + 1)
|
||||||
|
return identity if second == -1 else identity[:second]
|
||||||
|
|
||||||
|
|
||||||
|
# --- LiveKit admin JWT + participant counting --------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def livekit_admin_token(room: str) -> str:
|
||||||
|
now = int(time.time())
|
||||||
|
header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
|
||||||
|
payload = b64url(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"iss": LIVEKIT_KEY,
|
||||||
|
"exp": now + 120,
|
||||||
|
"nbf": now - 10,
|
||||||
|
"video": {"roomAdmin": True, "room": room, "roomList": True},
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
)
|
||||||
|
signing_input = f"{header}.{payload}".encode()
|
||||||
|
sig = b64url(hmac.new(LIVEKIT_SECRET.encode(), signing_input, hashlib.sha256).digest())
|
||||||
|
return f"{header}.{payload}.{sig}"
|
||||||
|
|
||||||
|
|
||||||
|
def _livekit_admin_post(alias: str, method: str, body: dict) -> bytes:
|
||||||
|
"""POST to a LiveKit RoomService Twirp method with a fresh admin token."""
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{LIVEKIT_API}/twirp/livekit.RoomService/{method}",
|
||||||
|
data=json.dumps(body).encode(),
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer " + livekit_admin_token(alias),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
return resp.read()
|
||||||
|
|
||||||
|
|
||||||
|
def livekit_list_participants(alias: str) -> list:
|
||||||
|
"""Return the raw list of ParticipantInfo dicts in the LiveKit room."""
|
||||||
|
data = json.loads(_livekit_admin_post(alias, "ListParticipants", {"room": alias}))
|
||||||
|
parts = data.get("participants", [])
|
||||||
|
return parts if isinstance(parts, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def livekit_present_users(alias: str):
|
||||||
|
"""Return the set of distinct Matrix users currently in the LiveKit room."""
|
||||||
|
return {matrix_user(p.get("identity", "")) for p in livekit_list_participants(alias)}
|
||||||
|
|
||||||
|
|
||||||
|
def livekit_update_participant(alias: str, identity: str, permission: dict) -> None:
|
||||||
|
"""Replace a participant's ParticipantPermission (livekit UpdateParticipant).
|
||||||
|
|
||||||
|
Narrowing `canPublishSources` unpublishes the participant's live tracks of
|
||||||
|
the removed sources server-side (for ALL clients) and blocks re-publish.
|
||||||
|
A participant who has already left yields a Twirp not_found (HTTP 404),
|
||||||
|
which is benign here.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_livekit_admin_post(
|
||||||
|
alias,
|
||||||
|
"UpdateParticipant",
|
||||||
|
{"room": alias, "identity": identity, "permission": permission},
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
if exc.code != 404:
|
||||||
|
log(f"update_participant {identity} in {alias} -> HTTP {exc.code}")
|
||||||
|
|
||||||
|
|
||||||
|
# --- per-room policy lookup (cached) -----------------------------------------
|
||||||
|
|
||||||
|
# room_id -> (state_dict, fetched_at_epoch). One Synapse /state fetch serves
|
||||||
|
# both the participant-limit and publish-policy checks. Callers pass max_age so
|
||||||
|
# the token path can tolerate 10s staleness while the reconcile loop reads fresh.
|
||||||
|
_state_cache = {}
|
||||||
|
_state_cache_lock = threading.Lock()
|
||||||
|
_STATE_TTL = 10.0
|
||||||
|
|
||||||
|
_DEFAULT_STATE = {"max_users": 0, "allow_screenshare": True, "allow_camera": True}
|
||||||
|
|
||||||
|
|
||||||
|
def room_state(room_id: str, max_age: float = _STATE_TTL) -> dict:
|
||||||
|
"""Fetch the room's Lotus policy (`io.lotus.voice_limit` +
|
||||||
|
`io.lotus.room_quality`) from Synapse admin state, cached briefly.
|
||||||
|
|
||||||
|
`max_age` is the oldest cached value the caller will accept (seconds). The
|
||||||
|
token path uses the default 10s (dedupes Element Call's per-join burst); the
|
||||||
|
reconcile loop passes a small value so a policy change is seen quickly.
|
||||||
|
|
||||||
|
Fails OPEN: on any error returns the permissive defaults (no limit, all
|
||||||
|
sources allowed) so a Synapse hiccup never blocks or restricts a call.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
with _state_cache_lock:
|
||||||
|
cached = _state_cache.get(room_id)
|
||||||
|
if cached and (now - cached[1]) < max_age:
|
||||||
|
return cached[0]
|
||||||
|
|
||||||
|
state = dict(_DEFAULT_STATE)
|
||||||
|
try:
|
||||||
|
url = (
|
||||||
|
f"{SYNAPSE_API}/_synapse/admin/v1/rooms/"
|
||||||
|
f"{urllib.parse.quote(room_id, safe='')}/state"
|
||||||
|
)
|
||||||
|
req = urllib.request.Request(url, headers={"Authorization": "Bearer " + MATRIX_TOKEN})
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
events = json.loads(resp.read()).get("state", [])
|
||||||
|
for ev in events:
|
||||||
|
if ev.get("state_key", "") != "":
|
||||||
|
continue
|
||||||
|
etype = ev.get("type")
|
||||||
|
content = ev.get("content", {}) or {}
|
||||||
|
if etype == LIMIT_STATE_TYPE:
|
||||||
|
state["max_users"] = int(content.get("max_users", 0) or 0)
|
||||||
|
elif etype == QUALITY_STATE_TYPE:
|
||||||
|
# Only an explicit `false` forbids; absent/other -> allowed.
|
||||||
|
state["allow_screenshare"] = content.get("allow_screenshare", True) is not False
|
||||||
|
state["allow_camera"] = content.get("allow_camera", True) is not False
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"room state lookup failed for {room_id}: {exc}")
|
||||||
|
return dict(_DEFAULT_STATE)
|
||||||
|
|
||||||
|
with _state_cache_lock:
|
||||||
|
# Opportunistically drop stale entries so a long-lived server that sees
|
||||||
|
# many distinct rooms doesn't grow the cache without bound.
|
||||||
|
if len(_state_cache) > 512:
|
||||||
|
for key in [k for k, v in _state_cache.items() if (now - v[1]) >= _STATE_TTL]:
|
||||||
|
del _state_cache[key]
|
||||||
|
_state_cache[room_id] = (state, now)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
# --- live (mid-call) enforcement: reconcile loop -----------------------------
|
||||||
|
|
||||||
|
# LiveKit hashed-alias -> Matrix room id, learned at token issue. Lets the
|
||||||
|
# reconcile loop map an active LiveKit room back to its Matrix policy even for
|
||||||
|
# participants who joined before a policy change.
|
||||||
|
_alias_to_room = {}
|
||||||
|
_alias_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def remember_room(alias: str, room_id: str) -> None:
|
||||||
|
# No consumer when the reconcile loop is off, so don't accumulate the map.
|
||||||
|
if not RECONCILE_ENABLED or not alias or not room_id:
|
||||||
|
return
|
||||||
|
with _alias_lock:
|
||||||
|
# Soft cap; the reconcile loop prunes ended rooms each sweep.
|
||||||
|
if len(_alias_to_room) > 4096 and alias not in _alias_to_room:
|
||||||
|
return
|
||||||
|
_alias_to_room[alias] = room_id
|
||||||
|
|
||||||
|
|
||||||
|
def forget_room(alias: str) -> None:
|
||||||
|
with _alias_lock:
|
||||||
|
_alias_to_room.pop(alias, None)
|
||||||
|
|
||||||
|
|
||||||
|
def forbidden_sources(policy: dict) -> set:
|
||||||
|
"""Pure: the set of UPPERCASE API source names this room forbids (may be
|
||||||
|
empty). Only an explicit False forbids."""
|
||||||
|
forbidden = set()
|
||||||
|
if policy.get("allow_screenshare", True) is False:
|
||||||
|
forbidden.update((API_SOURCE_SCREEN, API_SOURCE_SCREEN_AUDIO))
|
||||||
|
if policy.get("allow_camera", True) is False:
|
||||||
|
forbidden.add(API_SOURCE_CAM)
|
||||||
|
return forbidden
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_publish_sources(current, forbidden: set):
|
||||||
|
"""Pure: given a participant's current `canPublishSources` (UPPERCASE list;
|
||||||
|
[] means ALL allowed) and the forbidden set, return the new sources list to
|
||||||
|
enforce, or None if the participant is already compliant.
|
||||||
|
|
||||||
|
Never returns a wider set than `current` (we only REMOVE forbidden sources,
|
||||||
|
never grant). An empty return means "no permitted source remains" — the
|
||||||
|
caller should set canPublish=False instead of sending an empty list (which
|
||||||
|
LiveKit reads as 'all allowed').
|
||||||
|
"""
|
||||||
|
effective = set(current) if current else set(ALL_API_SOURCES)
|
||||||
|
if effective.isdisjoint(forbidden):
|
||||||
|
return None # nothing forbidden is present -> already compliant
|
||||||
|
return sorted(effective - forbidden)
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_participant(alias: str, participant: dict, forbidden: set) -> bool:
|
||||||
|
"""Enforce the forbidden-source policy on one live participant. Returns True
|
||||||
|
if an UpdateParticipant call was issued."""
|
||||||
|
perm = participant.get("permission") or {}
|
||||||
|
if not perm.get("canPublish", False):
|
||||||
|
return False # publishes nothing -> nothing to revoke
|
||||||
|
current = perm.get("canPublishSources") or []
|
||||||
|
desired = reconcile_publish_sources(current, forbidden)
|
||||||
|
if desired is None:
|
||||||
|
return False # already compliant
|
||||||
|
identity = participant.get("identity")
|
||||||
|
if not identity:
|
||||||
|
return False
|
||||||
|
# Full REPLACE: copy the existing permission and change only the publish
|
||||||
|
# fields, so canSubscribe / canPublishData / etc. are preserved.
|
||||||
|
new_perm = dict(perm)
|
||||||
|
if desired:
|
||||||
|
new_perm["canPublish"] = True
|
||||||
|
new_perm["canPublishSources"] = desired
|
||||||
|
else:
|
||||||
|
new_perm["canPublish"] = False
|
||||||
|
livekit_update_participant(alias, identity, new_perm)
|
||||||
|
log(f"revoked forbidden sources from {identity} in {alias}: keep {desired or '[]'}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_room(alias: str, room_id: str) -> None:
|
||||||
|
"""Enforce the current publish policy on every participant in one room.
|
||||||
|
Drops the room from the alias map when it is no longer active."""
|
||||||
|
# Read the policy fresh-ish so a flip is picked up within ~one interval.
|
||||||
|
policy = room_state(room_id, max_age=RECONCILE_INTERVAL)
|
||||||
|
forbidden = forbidden_sources(policy)
|
||||||
|
if not forbidden:
|
||||||
|
return # nothing restricted; leave permissions untouched
|
||||||
|
try:
|
||||||
|
participants = livekit_list_participants(alias)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
if exc.code == 404:
|
||||||
|
forget_room(alias) # room gone
|
||||||
|
else:
|
||||||
|
log(f"reconcile list_participants {alias} -> HTTP {exc.code}")
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"reconcile list_participants error for {alias}: {exc}")
|
||||||
|
return
|
||||||
|
if not participants:
|
||||||
|
# Empty, but the room may still exist (LiveKit keeps it until
|
||||||
|
# empty_timeout). Don't forget on an empty read — only a 404 (room gone)
|
||||||
|
# prunes — so a transient empty/race can't drop mid-call enforcement.
|
||||||
|
return
|
||||||
|
for p in participants:
|
||||||
|
try:
|
||||||
|
reconcile_participant(alias, p, forbidden)
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"reconcile participant error in {alias}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_all() -> None:
|
||||||
|
with _alias_lock:
|
||||||
|
items = list(_alias_to_room.items())
|
||||||
|
for alias, room_id in items:
|
||||||
|
reconcile_room(alias, room_id)
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_loop() -> None:
|
||||||
|
log(f"reconcile loop started (interval {RECONCILE_INTERVAL}s)")
|
||||||
|
while True:
|
||||||
|
time.sleep(RECONCILE_INTERVAL)
|
||||||
|
try:
|
||||||
|
reconcile_all()
|
||||||
|
except Exception as exc: # never let the loop die
|
||||||
|
log(f"reconcile sweep error: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
# --- HTTP handler ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def log_message(self, *args): # silence default request logging
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _read_body(self) -> bytes:
|
||||||
|
length = int(self.headers.get("Content-Length", 0) or 0)
|
||||||
|
return self.rfile.read(length) if length else b""
|
||||||
|
|
||||||
|
def _proxy(self, body: bytes):
|
||||||
|
"""Forward the current request to lk-jwt-service and return its response
|
||||||
|
as (status, headers_dict, body_bytes)."""
|
||||||
|
headers = {}
|
||||||
|
if self.headers.get("Content-Type"):
|
||||||
|
headers["Content-Type"] = self.headers["Content-Type"]
|
||||||
|
if self.headers.get("Accept"):
|
||||||
|
headers["Accept"] = self.headers["Accept"]
|
||||||
|
if self.headers.get("Origin"):
|
||||||
|
headers["Origin"] = self.headers["Origin"]
|
||||||
|
req = urllib.request.Request(
|
||||||
|
UPSTREAM + self.path,
|
||||||
|
data=body if self.command in ("POST", "PUT", "PATCH") else None,
|
||||||
|
headers=headers,
|
||||||
|
method=self.command,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
return resp.status, dict(resp.headers), resp.read()
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
return exc.code, dict(exc.headers), exc.read()
|
||||||
|
|
||||||
|
def _send(self, status: int, headers: dict, body: bytes):
|
||||||
|
self.send_response(status)
|
||||||
|
# send_response() already emits Server and Date; relaying them too would
|
||||||
|
# produce duplicates. Content-Length is recomputed below.
|
||||||
|
# content-encoding is stripped because we may re-serialize the body
|
||||||
|
# (a plaintext JSON body must never carry a stale gzip/deflate header).
|
||||||
|
skip = (
|
||||||
|
"transfer-encoding",
|
||||||
|
"content-length",
|
||||||
|
"content-encoding",
|
||||||
|
"connection",
|
||||||
|
"date",
|
||||||
|
"server",
|
||||||
|
)
|
||||||
|
for key, value in headers.items():
|
||||||
|
if key.lower() in skip:
|
||||||
|
continue
|
||||||
|
self.send_header(key, value)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
if body and self.command != "HEAD":
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _send_blocked(self):
|
||||||
|
body = json.dumps(
|
||||||
|
{"errcode": "M_FORBIDDEN", "error": "This voice channel is full."}
|
||||||
|
).encode()
|
||||||
|
headers = dict(CORS_HEADERS)
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
self._send(403, headers, body)
|
||||||
|
|
||||||
|
def _handle(self):
|
||||||
|
body = self._read_body()
|
||||||
|
|
||||||
|
# Only token-issuing POSTs are subject to policy.
|
||||||
|
if not (self.command == "POST" and self.path in TOKEN_PATHS):
|
||||||
|
self._send(*self._proxy(body))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extract the room id using the SAME field the endpoint's lk-jwt-service
|
||||||
|
# handler reads, so a client sending BOTH keys can't make us enforce a
|
||||||
|
# different room's policy than the token is minted for (bypass vector):
|
||||||
|
# /get_token (new) -> room_id
|
||||||
|
# /sfu/get (legacy) -> room
|
||||||
|
try:
|
||||||
|
room_id = room_id_from_request(self.path, json.loads(body))
|
||||||
|
except Exception:
|
||||||
|
room_id = ""
|
||||||
|
|
||||||
|
status, headers, resp_body = self._proxy(body)
|
||||||
|
|
||||||
|
# Only a successfully-issued token can be gated or modified.
|
||||||
|
if status != 200:
|
||||||
|
self._send(status, headers, resp_body)
|
||||||
|
return
|
||||||
|
if not room_id:
|
||||||
|
# A token was issued but we couldn't identify the room, so no policy
|
||||||
|
# is applied. Log it so silent enforcement-loss (e.g. a request
|
||||||
|
# schema change) is observable rather than invisible.
|
||||||
|
log("issued token with unparseable room id — no policy enforced")
|
||||||
|
self._send(status, headers, resp_body)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Decode the issued token once (cheap). Needed for policy enforcement AND
|
||||||
|
# to learn this room's LiveKit alias for the reconcile map, so a policy
|
||||||
|
# set AFTER this participant joins can still be enforced on them. If the
|
||||||
|
# token can't be parsed, fail open.
|
||||||
|
try:
|
||||||
|
payload = json.loads(resp_body)
|
||||||
|
token = payload.get("jwt", "")
|
||||||
|
claims = jwt_payload(token) or {}
|
||||||
|
alias = (claims.get("video") or {}).get("room", "")
|
||||||
|
requester = matrix_user(claims.get("sub", ""))
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"could not parse issued token for {room_id}: {exc}")
|
||||||
|
self._send(status, headers, resp_body)
|
||||||
|
return
|
||||||
|
|
||||||
|
remember_room(alias, room_id)
|
||||||
|
|
||||||
|
state = room_state(room_id)
|
||||||
|
limit = state["max_users"]
|
||||||
|
sources = allowed_sources(state)
|
||||||
|
|
||||||
|
# Fast path: nothing to enforce at join time for this room.
|
||||||
|
if limit <= 0 and sources is None:
|
||||||
|
self._send(status, headers, resp_body)
|
||||||
|
return
|
||||||
|
|
||||||
|
# (1) Hard participant limit — refuse the token entirely. Isolated so a
|
||||||
|
# LiveKit-admin outage here cannot skip the publish-source policy below.
|
||||||
|
if limit > 0 and alias:
|
||||||
|
try:
|
||||||
|
present = livekit_present_users(alias)
|
||||||
|
if should_block(limit, present, requester):
|
||||||
|
log(f"blocked {requester or '?'} from {room_id}: {len(present)}/{limit}")
|
||||||
|
self._send_blocked()
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
# Fail open on the limit only; still apply the policy below.
|
||||||
|
log(f"limit check error for {room_id}: {exc}")
|
||||||
|
|
||||||
|
# (2) Hard publish-source policy — re-sign with a narrowed
|
||||||
|
# canPublishSources so the SFU forbids camera/screenshare for ALL clients
|
||||||
|
# (numeric bitrate/fps caps are not SFU-enforceable). Needs no LiveKit
|
||||||
|
# call, so it is independent of (1). Verify OUR secret actually signed
|
||||||
|
# the token first: on a secret mismatch, re-signing would mint a token
|
||||||
|
# the SFU rejects (fail-CLOSED), so skip and pass the original through.
|
||||||
|
if sources is not None and token:
|
||||||
|
try:
|
||||||
|
if verify_jwt_sig(token, LIVEKIT_SECRET):
|
||||||
|
payload["jwt"] = resign_jwt(token, LIVEKIT_SECRET, sources)
|
||||||
|
resp_body = json.dumps(payload).encode()
|
||||||
|
log(f"restricted publish sources for {requester or '?'} in {room_id}: {sources}")
|
||||||
|
else:
|
||||||
|
log(f"LIVEKIT_SECRET mismatch — skipping source policy for {room_id} (fail open)")
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"publish-source policy error for {room_id}: {exc}")
|
||||||
|
|
||||||
|
self._send(status, headers, resp_body)
|
||||||
|
|
||||||
|
# Map the HTTP verbs we care about onto the shared handler.
|
||||||
|
do_GET = _handle
|
||||||
|
do_POST = _handle
|
||||||
|
do_OPTIONS = _handle
|
||||||
|
do_HEAD = _handle
|
||||||
|
do_PUT = _handle
|
||||||
|
|
||||||
|
|
||||||
|
class GuardServer(ThreadingHTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
# Element Call fires a burst of token requests per join; keep the accept
|
||||||
|
# queue generous so none are dropped.
|
||||||
|
request_queue_size = 128
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not (LIVEKIT_KEY and LIVEKIT_SECRET and MATRIX_TOKEN):
|
||||||
|
log("WARNING: missing LIVEKIT_KEY/LIVEKIT_SECRET/MATRIX_TOKEN — checks will fail open")
|
||||||
|
# Live enforcement loop (revokes forbidden sources from participants who
|
||||||
|
# joined before a policy change). Daemon thread — never blocks shutdown, and
|
||||||
|
# its errors can't take down token issuance.
|
||||||
|
if RECONCILE_ENABLED and LIVEKIT_KEY and LIVEKIT_SECRET and MATRIX_TOKEN:
|
||||||
|
threading.Thread(target=reconcile_loop, name="reconcile", daemon=True).start()
|
||||||
|
elif not RECONCILE_ENABLED:
|
||||||
|
log("reconcile loop disabled (GUARD_RECONCILE=0)")
|
||||||
|
server = GuardServer((BIND_HOST, BIND_PORT), Handler)
|
||||||
|
log(f"listening on {BIND_HOST}:{BIND_PORT} -> upstream {UPSTREAM}")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Voice Limit Guard (hard per-room voice channel participant limits, fronts lk-jwt-service)
|
||||||
|
After=network.target livekit-server.service lk-jwt-service.service
|
||||||
|
Wants=lk-jwt-service.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/env python3 /opt/voice-limit-guard/voice-limit-guard.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
# MATRIX_TOKEN (server-admin) is read from the existing deploy env file.
|
||||||
|
EnvironmentFile=/etc/matrix-deploy.env
|
||||||
|
Environment=GUARD_BIND_HOST=0.0.0.0
|
||||||
|
Environment=GUARD_BIND_PORT=8070
|
||||||
|
Environment=GUARD_UPSTREAM=http://127.0.0.1:8071
|
||||||
|
Environment=LIVEKIT_API=http://127.0.0.1:7880
|
||||||
|
Environment=SYNAPSE_API=http://127.0.0.1:8008
|
||||||
|
Environment=LIVEKIT_KEY=lotuskey
|
||||||
|
Environment=LIVEKIT_SECRET=GoI5PPLbNXZlQHlfdAzLFy0B/QoqA9uXiyb/p6dQEtc=
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Reference in New Issue
Block a user