feat: hard cross-client voice channel limits via voice-limit-guard
Add a fail-open Python sidecar (livekit/voice-limit-guard.py) that fronts lk-jwt-service to enforce per-room voice participant caps for ALL Matrix clients, not just Lotus Chat: - lk-jwt-service moved to :8071 (systemd drop-in), guard owns :8070 so NPM's existing /sfu/get + /get_token proxy targets are unchanged - guard reads io.lotus.voice_limit.max_users (Synapse admin API, cached), forwards to lk-jwt-service, and on an issued token decodes the LiveKit alias + requester, counts distinct Matrix users via LiveKit ListParticipants, and returns 403 when the room is full (rejoins/extra devices allowed) - any error fails open (returns upstream response) so calls never break - systemd/voice-limit-guard.service; README documents ports, setup, revert Also update landing page: voice limit is now server-enforced for all clients. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 for ALL clients; script `/opt/voice-limit-guard/voice-limit-guard.py`) — see [Voice Channel Limits](#voice-channel-limits)
|
||||||
- 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`
|
||||||
@@ -187,6 +188,49 @@ Killing livekit-server while a call is active drops everyone. Instead:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Voice Channel Limits
|
||||||
|
|
||||||
|
Per-room voice participant caps are enforced **server-side for every client** (Element, FluffyChat, Lotus Chat, …), not just our own web client.
|
||||||
|
|
||||||
|
**How it works**
|
||||||
|
|
||||||
|
Every Matrix client must fetch a LiveKit JWT from lk-jwt-service before it can join a call. `voice-limit-guard` (a small fail-open Python sidecar, `livekit/voice-limit-guard.py` in this repo) sits in front of that service:
|
||||||
|
|
||||||
|
- lk-jwt-service was moved off `:8070` to `:8071` (systemd drop-in). The guard now owns `:8070`, so NPM's existing `/sfu/get` + `/get_token` proxy targets are unchanged.
|
||||||
|
- On each token request the guard reads `io.lotus.voice_limit` → `max_users` for the room (Synapse admin API, cached 10 s). `0` / absent = no limit.
|
||||||
|
- It forwards the request to lk-jwt-service, and if a token is issued it decodes the JWT to get the LiveKit alias (`video.room`) + requester identity (`sub`), then asks LiveKit `ListParticipants` how many **distinct Matrix users** are in the room.
|
||||||
|
- requester already present (rejoin / extra device) → allow
|
||||||
|
- distinct users ≥ limit → **403** (the client cannot get a token, so it cannot join)
|
||||||
|
- otherwise → allow
|
||||||
|
- **Fail-open:** any error (admin API down, bad token, LiveKit unreachable) returns the upstream response unchanged, so calls keep working even if enforcement is degraded.
|
||||||
|
|
||||||
|
**Setting a limit:** room admins set it from Lotus Chat → Room Settings → General → **Voice** (writes the `io.lotus.voice_limit` state event). Any tool that can send room state works too:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# max 5 participants in <roomId>; 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}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Config:** the guard reads `MATRIX_TOKEN` (server-admin) from `/etc/matrix-deploy.env`; LiveKit key/secret + ports are set in `systemd/voice-limit-guard.service`.
|
||||||
|
|
||||||
|
**Manual (re)deploy** (the file-specific auto-deploy pipeline does not cover this service):
|
||||||
|
|
||||||
|
```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 +274,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 |
|
||||||
|
|||||||
+2
-2
@@ -555,7 +555,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="also-available">
|
<p class="also-available">
|
||||||
Our Lotus Guild fork of Cinny adds: voice message recording & playback with 0.75×/1×/1.5×/2× speed control (MSC3245, E2EE), device verification fix (cross-client SAS emoji + inline cards), per-member device session panel with per-device verify buttons, full Discord-style presence tracking (online on startup, idle/away after 10 min inactivity, unavailable when tab hidden, offline on close — with a “Hide Online Status” privacy toggle), presence status indicators (online/busy/away dots) in member lists, incoming call ring + Answer/Decline (DMs & group chats), GIF picker (Giphy), emoji & sticker picker (custom packs, stickers send as <code style="font-size:0.8em;color:#e88;">m.sticker</code> events), pinned messages panel (pin icon in room header, pin/unpin from message menu), who-reacted viewer (hover any reaction for a name tooltip; right-click for a full avatar list), draggable+resizable picture-in-picture call window, poll creation & voting (single or multiple choice, 2–10 options), message forwarding, image/video captions, location sharing (map view + send), deleted message placeholders, per-message read receipt avatars (click for full list with timestamps), private read receipts toggle (Settings → Privacy), screenshare fullscreen button, screenshare audio mute (mute a screenshare’s audio without leaving the call), PTT (Push-to-Talk with configurable hold key), push-to-deafen (<kbd style="font-size:0.8em;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.12);border-radius:3px;padding:1px 5px;">M</kbd> key, configurable in Settings → Calls), custom status messages with emoji picker + auto-clear timer (30 min – 7 days) shown below usernames, encrypted room search via local cache scan with per-room “Load more” history buttons, a dedicated Privacy settings section (hide typing, hide online status), sidebar room filter (search rooms by name in Home and DMs tabs), favorite rooms (star any room, syncs across devices via m.favourite tag), media gallery drawer (browse all images/videos/files shared in a room), invite link + QR code (in both invite modal and room settings), knock-to-join support (Request to Join button + admin Approve/Deny panel), code syntax highlighting in Lotus Terminal mode (keywords, strings, numbers, comments, functions), night light / blue light filter (warm orange overlay with adjustable intensity in Settings → Appearance), message length counter in the composer, and the Lotus Terminal design theme (with TDS-styled orange typing indicator dots).
|
Our Lotus Guild fork of Cinny adds: voice message recording & playback with 0.75×/1×/1.5×/2× speed control (MSC3245, E2EE), device verification fix (cross-client SAS emoji + inline cards), per-member device session panel with per-device verify buttons, full Discord-style presence tracking (online on startup, idle/away after 10 min inactivity, unavailable when tab hidden, offline on close — with a “Hide Online Status” privacy toggle), presence status indicators (online/busy/away dots) in member lists, incoming call ring + Answer/Decline (DMs & group chats), GIF picker (Giphy), emoji & sticker picker (custom packs, stickers send as <code style="font-size:0.8em;color:#e88;">m.sticker</code> events), pinned messages panel (pin icon in room header, pin/unpin from message menu), who-reacted viewer (hover any reaction for a name tooltip; right-click for a full avatar list), draggable+resizable picture-in-picture call window, poll creation & voting (single or multiple choice, 2–10 options), message forwarding, image/video captions, location sharing (map view + send), deleted message placeholders, per-message read receipt avatars (click for full list with timestamps), private read receipts toggle (Settings → Privacy), screenshare fullscreen button, screenshare audio mute (mute a screenshare’s audio without leaving the call), PTT (Push-to-Talk with configurable hold key), push-to-deafen (<kbd style="font-size:0.8em;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.12);border-radius:3px;padding:1px 5px;">M</kbd> key, configurable in Settings → Calls), custom status messages with emoji picker + auto-clear timer (30 min – 7 days) shown below usernames, encrypted room search via local cache scan with per-room “Load more” history buttons, a dedicated Privacy settings section (hide typing, hide online status), sidebar room filter (search rooms by name in Home and DMs tabs), favorite rooms (star any room, syncs across devices via m.favourite tag), media gallery drawer (browse all images/videos/files shared in a room), invite link + QR code (in both invite modal and room settings), knock-to-join support (Request to Join button + admin Approve/Deny panel), code syntax highlighting in Lotus Terminal mode (keywords, strings, numbers, comments, functions), night light / blue light filter (warm orange overlay with adjustable intensity in Settings → Appearance), message length counter in the composer, and the Lotus Terminal design theme (with TDS-styled orange typing indicator dots).
|
||||||
Also added in June 2026: message scheduling (MSC4140, datetime picker, cancel tray), saved messages / bookmarks (right-click any message, sidebar panel, syncs across devices), room history export (txt/json/html, date range, E2EE-aware), room activity & mod log (joins, kicks, bans, power level changes), server ACL editor (allow/deny lists, wildcard validation, power-level gated), room stats panel (top members, top reactions, media breakdown, 24h activity heatmap), opt-in image compression (Canvas API at 0.82 quality, shows before/after sizes), 13 domain-specific URL preview cards (YouTube, Vimeo, Twitch, Reddit, X/Twitter, Spotify, Steam, IMDb, Wikipedia, GitHub, Discord, npm, Stack Overflow), inline GIF preview (Giphy & Tenor share links auto-embed as animated GIFs via the homeserver proxy), policy list viewer (admin panel for <code style="font-size:0.8em;color:#e88;">m.policy.rule.*</code> ban list rooms — complements Draupnir), collapsible long messages (auto-collapse > 20 lines with “Read more” toggle, threshold configurable), message send animation (0.15 s fade+scale on own messages, respects prefers-reduced-motion), right-click room context menu improvements (Mute with duration submenu 15 min–indefinite, Copy Room Link, Mark as Read, Leave Room, Room Settings), quick emoji reactions directly on message hover (3 most-recent emoji in the hover bar, single click to react), in-app notification toasts (TDS-styled slide-in card from bottom-right when the window is focused, 4 s auto-dismiss, click to navigate), presence avatar border ring (2px green/yellow/red ring on user avatars in the timeline, members list, @mention autocomplete, and notifications), room emoji prefix support (leading emoji renders at 1.15× in the sidebar; emoji picker button on all room name inputs), glassmorphism sidebar toggle (Settings → Appearance, off by default; frosted blur effect lets chat backgrounds show through the sidebar — fixed in June 2026 to mirror the background onto <code style="font-size:0.8em;color:#e88;">document.body</code> so the blur has content to work through), and 5 CSS-only animated chat backgrounds: Digital Rain (two-layer vertical stripe scroll with parallax), Star Drift (three-layer radial-gradient dots drifting diagonally), Grid Pulse (neon grid lines expanding/contracting), Aurora Flow (sweeping radial gradient ellipses on a 200% canvas), and Fireflies (three layers of glowing dots drifting). All respect <code style="font-size:0.8em;color:#e88;">prefers-reduced-motion</code> and include a "Pause Background Animations" toggle in Settings → Appearance. Also added: AFK auto-mute for voice calls (mic silenced after a configurable idle timeout of 1–30 min detected via Web Audio AnalyserNode; in-app toast confirms the action; toggle + duration selector in Settings → Calls), knock-to-join admin badge (a live warning badge on the Members button in the room header counts pending knock requests in real time, visible only to users with sufficient invite permissions), voice channel user limit (admins set a max-participant cap per room via the <code style="font-size:0.8em;color:#e88;">io.lotus.voice_limit</code> state event; the Join button is disabled with a “Channel Full (N/N)” message once capacity is reached, while members already in the call can always rejoin), and custom call join/leave sound effects (a local cue plays when someone enters or leaves a call you’re in — tracked via MatrixRTC membership changes, synthesized in-browser with the Web Audio API so no assets are bundled; choose Chime, Soft, Retro, or off in Settings → Calls).
|
Also added in June 2026: message scheduling (MSC4140, datetime picker, cancel tray), saved messages / bookmarks (right-click any message, sidebar panel, syncs across devices), room history export (txt/json/html, date range, E2EE-aware), room activity & mod log (joins, kicks, bans, power level changes), server ACL editor (allow/deny lists, wildcard validation, power-level gated), room stats panel (top members, top reactions, media breakdown, 24h activity heatmap), opt-in image compression (Canvas API at 0.82 quality, shows before/after sizes), 13 domain-specific URL preview cards (YouTube, Vimeo, Twitch, Reddit, X/Twitter, Spotify, Steam, IMDb, Wikipedia, GitHub, Discord, npm, Stack Overflow), inline GIF preview (Giphy & Tenor share links auto-embed as animated GIFs via the homeserver proxy), policy list viewer (admin panel for <code style="font-size:0.8em;color:#e88;">m.policy.rule.*</code> ban list rooms — complements Draupnir), collapsible long messages (auto-collapse > 20 lines with “Read more” toggle, threshold configurable), message send animation (0.15 s fade+scale on own messages, respects prefers-reduced-motion), right-click room context menu improvements (Mute with duration submenu 15 min–indefinite, Copy Room Link, Mark as Read, Leave Room, Room Settings), quick emoji reactions directly on message hover (3 most-recent emoji in the hover bar, single click to react), in-app notification toasts (TDS-styled slide-in card from bottom-right when the window is focused, 4 s auto-dismiss, click to navigate), presence avatar border ring (2px green/yellow/red ring on user avatars in the timeline, members list, @mention autocomplete, and notifications), room emoji prefix support (leading emoji renders at 1.15× in the sidebar; emoji picker button on all room name inputs), glassmorphism sidebar toggle (Settings → Appearance, off by default; frosted blur effect lets chat backgrounds show through the sidebar — fixed in June 2026 to mirror the background onto <code style="font-size:0.8em;color:#e88;">document.body</code> so the blur has content to work through), and 5 CSS-only animated chat backgrounds: Digital Rain (two-layer vertical stripe scroll with parallax), Star Drift (three-layer radial-gradient dots drifting diagonally), Grid Pulse (neon grid lines expanding/contracting), Aurora Flow (sweeping radial gradient ellipses on a 200% canvas), and Fireflies (three layers of glowing dots drifting). All respect <code style="font-size:0.8em;color:#e88;">prefers-reduced-motion</code> and include a "Pause Background Animations" toggle in Settings → Appearance. Also added: AFK auto-mute for voice calls (mic silenced after a configurable idle timeout of 1–30 min detected via Web Audio AnalyserNode; in-app toast confirms the action; toggle + duration selector in Settings → Calls), knock-to-join admin badge (a live warning badge on the Members button in the room header counts pending knock requests in real time, visible only to users with sufficient invite permissions), voice channel user limit (admins set a max-participant cap per room via the <code style="font-size:0.8em;color:#e88;">io.lotus.voice_limit</code> state event; enforced server-side for <em>every</em> Matrix client by a guard that fronts the LiveKit JWT issuer and refuses tokens once a room is full, with a “Channel Full (N/N)” message and disabled Join button in Lotus Chat, while members already in the call can always rejoin), and custom call join/leave sound effects (a local cue plays when someone enters or leaves a call you’re in — tracked via MatrixRTC membership changes, synthesized in-browser with the Web Audio API so no assets are bundled; choose Chime, Soft, Retro, or off in Settings → Calls).
|
||||||
Prefer the unmodified upstream? <a href="https://cinny.in" target="_blank" rel="noopener">cinny.in</a> works with our homeserver — set it to <code style="font-size:0.8em;color:#e88;">matrix.lotusguild.org</code>.
|
Prefer the unmodified upstream? <a href="https://cinny.in" target="_blank" rel="noopener">cinny.in</a> works with our homeserver — set it to <code style="font-size:0.8em;color:#e88;">matrix.lotusguild.org</code>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -824,7 +824,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Voice channel user limit<small>admin-set max participants</small></td>
|
<td>Voice channel user limit<small>admin-set max participants</small></td>
|
||||||
<td class="ours"><span class="yes">✓</span><small>per-room cap,<br>“Channel Full” gate</small></td>
|
<td class="ours"><span class="yes">✓</span><small>per-room cap,<br>server-enforced<br>for all clients</small></td>
|
||||||
<td><span class="no">✗</span></td>
|
<td><span class="no">✗</span></td>
|
||||||
<td><span class="no">✗</span></td>
|
<td><span class="no">✗</span></td>
|
||||||
<td><span class="no">✗</span></td>
|
<td><span class="no">✗</span></td>
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
voice-limit-guard — hard, cross-client voice channel participant limits.
|
||||||
|
|
||||||
|
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 refusing the token here is a
|
||||||
|
hard block that applies to ALL clients — not just our own.
|
||||||
|
|
||||||
|
Flow for token requests (POST /sfu/get and legacy POST /get_token):
|
||||||
|
1. Read the request body and extract the Matrix `room` id (clients send the
|
||||||
|
*raw* room id here; lk-jwt-service maps it to the hashed LiveKit alias).
|
||||||
|
2. Look up `io.lotus.voice_limit` -> max_users for that room via the Synapse
|
||||||
|
admin API (cached briefly). 0 / absent => no limit.
|
||||||
|
3. Forward the request to lk-jwt-service unchanged and capture its response.
|
||||||
|
4. If a limit applies and the token was issued (HTTP 200), decode the JWT to
|
||||||
|
read the LiveKit alias (`video.room`) and the requester identity (`sub`),
|
||||||
|
then ask LiveKit how many distinct Matrix users are currently in the room.
|
||||||
|
- requester already present (rejoin / extra device) -> allow
|
||||||
|
- distinct users >= limit -> 403 (blocked)
|
||||||
|
- otherwise -> allow
|
||||||
|
5. Anything that goes wrong in steps 2-4 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", "")
|
||||||
|
|
||||||
|
TOKEN_PATHS = ("/sfu/get", "/get_token")
|
||||||
|
LIMIT_STATE_TYPE = "io.lotus.voice_limit"
|
||||||
|
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 jwt_payload(token: str):
|
||||||
|
"""Best-effort decode of a JWT payload without verification."""
|
||||||
|
try:
|
||||||
|
payload_b64 = token.split(".")[1]
|
||||||
|
payload_b64 += "=" * (-len(payload_b64) % 4)
|
||||||
|
return json.loads(base64.urlsafe_b64decode(payload_b64))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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 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_present_users(alias: str):
|
||||||
|
"""Return the set of distinct Matrix users currently in the LiveKit room."""
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{LIVEKIT_API}/twirp/livekit.RoomService/ListParticipants",
|
||||||
|
data=json.dumps({"room": alias}).encode(),
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer " + livekit_admin_token(alias),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
return {matrix_user(p.get("identity", "")) for p in data.get("participants", [])}
|
||||||
|
|
||||||
|
|
||||||
|
# --- per-room limit lookup (cached) ------------------------------------------
|
||||||
|
|
||||||
|
_limit_cache = {} # room_id -> (max_users, expiry_epoch)
|
||||||
|
_limit_cache_lock = threading.Lock()
|
||||||
|
_LIMIT_TTL = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def room_limit(room_id: str) -> int:
|
||||||
|
now = time.time()
|
||||||
|
with _limit_cache_lock:
|
||||||
|
cached = _limit_cache.get(room_id)
|
||||||
|
if cached and cached[1] > now:
|
||||||
|
return cached[0]
|
||||||
|
|
||||||
|
limit = 0
|
||||||
|
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("type") == LIMIT_STATE_TYPE and ev.get("state_key", "") == "":
|
||||||
|
limit = int(ev.get("content", {}).get("max_users", 0) or 0)
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
# Fail open: treat lookup failure as "no limit".
|
||||||
|
log(f"limit lookup failed for {room_id}: {exc}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with _limit_cache_lock:
|
||||||
|
_limit_cache[room_id] = (limit, now + _LIMIT_TTL)
|
||||||
|
return limit
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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.
|
||||||
|
skip = ("transfer-encoding", "content-length", "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 the limit check.
|
||||||
|
if not (self.command == "POST" and self.path in TOKEN_PATHS):
|
||||||
|
self._send(*self._proxy(body))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Determine the room and its limit before bothering upstream.
|
||||||
|
try:
|
||||||
|
room_id = json.loads(body).get("room", "")
|
||||||
|
except Exception:
|
||||||
|
room_id = ""
|
||||||
|
|
||||||
|
limit = room_limit(room_id) if room_id else 0
|
||||||
|
|
||||||
|
status, headers, resp_body = self._proxy(body)
|
||||||
|
|
||||||
|
# No limit, or upstream didn't issue a token -> pass through unchanged.
|
||||||
|
if limit <= 0 or status != 200:
|
||||||
|
self._send(status, headers, resp_body)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Limit applies and a token was issued — decide whether to allow it.
|
||||||
|
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", ""))
|
||||||
|
if not alias:
|
||||||
|
raise ValueError("no alias in issued token")
|
||||||
|
|
||||||
|
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: never break a join because the guard had a problem.
|
||||||
|
log(f"limit check 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 — limit checks will fail open")
|
||||||
|
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