Compare commits

..

20 Commits

Author SHA1 Message Date
jared 7618b3b091 docs: Slack-style per-thread notifications (P4-1)
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 31s
Lint / Secret scan (gitleaks) (push) Successful in 5s
Landing: thread row + prose note the participating-default notifications with
per-thread All/Mentions/Mute. README: Lotus Cinny threads row extended.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:41:04 -04:00
jared d344b9b4b5 fix(ci): make the SC1091 suppression in lotus_deploy.sh actually apply
Lint / Shell (shellcheck) (push) Successful in 7s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 32s
Lint / Secret scan (gitleaks) (push) Successful in 4s
Shellcheck directives bind to the NEXT command; on the compound line
`set -a; source /etc/lotus-deploy.env; set +a` the existing
`# shellcheck disable=SC1091` bound to `set -a`, so the info-level SC1091
finding on the runtime-only env file still failed the lint workflow
(find -exec shellcheck exits non-zero on any finding). Split the line so the
directive sits directly above `source` (as `source=/dev/null`, the standard
idiom for host-only env files). Verified with CI's exact invocation:
`find . -name "*.sh" -exec shellcheck {} +` now exits 0 (shellcheck 0.9.0).

No runtime behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 22:19:39 -04:00
jared d9585f13f1 docs: July 2026 client batch — threads, math, search cache, session hardening
- landing: Threads row upgraded to ✓ (full side panel + unread chips); prose
  sentence for the new batch (threads, KaTeX math, opt-in encrypted-search
  index, session hardening, crypto diagnostics).
- README: two new rows in the Lotus Cinny custom-features table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:48:34 -04:00
jared daa532835f docs: advertise the Lotus Chat desktop app (native features)
Lint / Shell (shellcheck) (push) Failing after 8s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 37s
Lint / Secret scan (gitleaks) (push) Successful in 8s
- landing/index.html: desktop-app sentence in the feature prose + a "Desktop App"
  comparison-table section (rich toasts click/reply, jump list, taskbar + volume
  controls, Focus Assist DND, no-sleep, recursive folder upload).
- README.md: a "Desktop app (Tauri)" row in the Lotus Cinny custom-features table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:06:13 -04:00
jared 8481610066 docs(call): document soundboard/quality/permissions (README table + landing)
Lint / Shell (shellcheck) (push) Failing after 32s
Lint / Python (ruff) (push) Successful in 10s
Lint / Python deps (pip-audit) (push) Successful in 36s
Lint / Secret scan (gitleaks) (push) Successful in 8s
Lint / JS (eslint) (push) Failing after 13m15s
- README Custom Features table: add rows for the in-call soundboard (P5-15),
  call quality controls, and room call-permissions (P5-31).
- landing: mention the soundboard, per-user quality controls, and
  server-enforced room call-permissions in the feature blurb.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:43:49 -04:00
jared be8e728034 docs(landing): note self-built Element Call fork + 4 on-device denoise models
Lint / Shell (shellcheck) (push) Failing after 10s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 37s
Lint / Secret scan (gitleaks) (push) Successful in 6s
Update the client-comparison copy to reflect that calls run our self-built
Element Call fork and that the ML noise-suppression tier offers RNNoise, Speex,
DTLN, and DeepFilterNet 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:34:34 -04:00
jared a06f2c662a feat(livekit-guard): enforce per-room call permissions (screenshare/camera)
Extend voice-limit-guard to enforce a per-room publish-source policy
(io.lotus.room_quality allow_screenshare/allow_camera) for ALL Matrix clients,
alongside the existing participant limit.

- At token issue, re-sign the LiveKit JWT's canPublishSources to drop forbidden
  sources (microphone always kept). Verifies our own secret signed the token
  first and fails open on mismatch, so a secret drift can never mint a token the
  SFU rejects. Limit check and source policy are independent (one's outage can't
  skip the other).
- Live (mid-call) enforcement: a background reconcile loop calls LiveKit
  UpdateParticipant to revoke a forbidden source from participants who joined
  before the policy changed -- which unpublishes their in-progress
  screenshare/camera server-side within ~3s and blocks re-publish. Only removes
  sources (never grants), preserves other permission flags, fails open, and runs
  as a daemon thread that cannot crash or block token issuance.
- Endpoint-specific room-id extraction (/get_token->room_id, /sfu/get->room) so
  a client sending both keys can't get a different room's policy applied.
- Auto-deploy the guard on LXC 151 (py_compile-gated, backup + rollback).
- Unit tests: JWT re-sign/verify + tamper, secret-mismatch, source narrowing,
  reconcile (never-grant / preserve-flags / disable-on-empty), fail-open.

Numeric bitrate/fps caps are NOT server-enforceable on an SFU (LiveKit forwards,
never transcodes) and remain a Lotus-client-cooperative setting; the
screenshare/camera permission is the hard cross-client lever.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:34:34 -04:00
jared 1a7ec2b0d6 cinny: drop dead lotus-deploy.sh install block from lxc106-cinny.sh
The cinny/lotus-deploy.sh (hyphen) force-deploy variant was an untracked,
redundant duplicate of the CI-gated cinny/lotus_deploy.sh (underscore) added in
c13549f. It's been removed, so its install block here referenced a file that no
longer exists. The CI-gated lotus_deploy.sh is the single source of truth for
the webhook web deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:28:02 -04:00
jared c13549f3da cinny: harden + version-control the webhook web-deploy (lotus_deploy.sh)
Lint / Python (ruff) (push) Successful in 21s
Lint / Python deps (pip-audit) (push) Successful in 50s
Lint / Secret scan (gitleaks) (push) Successful in 7s
Lint / Shell (shellcheck) (push) Failing after 14s
Lint / JS (eslint) (push) Successful in 24s
The live /usr/local/bin/lotus_deploy.sh (the `lotus-deploy` webhook target) was
never under version control and had rotted into two deploy-killing bugs that
froze chat.lotusguild.org on an old build:

1. CI gate: it waited on the WHOLE workflow run with a 15-min cap. Web CI shares
   the single act_runner with the slow Tauri desktop builds, so a web run could
   sit queued >15 min -> "result: timeout" -> deploy aborted. Now it gates only
   on the "Build & Quality Checks" commit-status context (build + unit tests),
   decoupled from "Trigger Desktop Build", and waits up to 45 min.

2. Dead element-call copy: `cp node_modules/@element-hq/element-call-embedded/...`
   under `set -e` aborted every deploy after the widget was forked to
   @lotusguild/element-call-embedded. The build already emits dist/public/
   element-call; replaced the copy with a presence check.

Also: rsync now excludes config.json so the app deploy stops clobbering the
production runtime config (homeserver list / allowCustomHomeservers) that the
matrix repo owns. lxc106-cinny.sh now installs this script (syntax-checked).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:10:10 -04:00
jared d6fd323262 cinny: enable mozilla.org (OIDC/next-gen-auth homeserver)
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 21s
Lint / Python (ruff) (push) Successful in 15s
Lint / Python deps (pip-audit) (push) Successful in 59s
Lint / Secret scan (gitleaks) (push) Successful in 9s
Now that the client supports MSC3861 OIDC login, add mozilla.org to the
homeserverList and its origins to the CSP. mozilla delegates: homeserver ->
mozilla.modular.im, OIDC issuer -> chat.mozilla.org, identity -> vector.im.
- connect-src += mozilla.org mozilla.modular.im chat.mozilla.org vector.im
- img-src += mozilla.org mozilla.modular.im
Applied live to LXC 106 and synced here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:58:48 -04:00
jared b39e3594d5 cinny: allow matrix.org media in CSP img-src
Lint / Shell (shellcheck) (push) Successful in 9s
Lint / JS (eslint) (push) Successful in 5s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 36s
Lint / Secret scan (gitleaks) (push) Successful in 6s
Federated matrix.org users load avatars/images from their own media endpoint
(matrix-client.matrix.org), which img-src still blocked — so every avatar
tripped a CSP violation. Add https://matrix.org + https://*.matrix.org to
img-src to match connect-src. (media-src already allows https: so video/audio
were fine.) Applied live to LXC 106 and synced here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:49:08 -04:00
jared 40ceb43672 cinny: version-control the production nginx site config
Lint / Shell (shellcheck) (push) Successful in 7s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 50s
Lint / Secret scan (gitleaks) (push) Successful in 9s
The chat.lotusguild.org nginx config (LXC 106) was edited directly on the box
and never tracked — which is how its CSP drifted (kept a dead Sentry URL and
blocked matrix.org logins). Snapshot it as cinny/nginx.conf (verbatim from prod,
incl. the corrected connect-src that now allows matrix.org/*.matrix.org) and
deploy it via lxc106-cinny.sh: back up the live file, swap, `nginx -t`, and
reload only on success (auto-restore the backup if validation fails, so a bad
config can't take the site down). TLS terminates at the NPM proxy, so this is a
plain HTTP server block with no secrets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:14:49 -04:00
jared 45444e5118 cinny: allow matrix.org logins on the Lotus client
Lint / Shell (shellcheck) (push) Successful in 12s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 1m14s
Lint / Secret scan (gitleaks) (push) Successful in 9s
Add matrix.org to homeserverList so federated friends with matrix.org accounts
can sign into chat.lotusguild.org. defaultHomeserver stays 0 (lotusguild), and
allowCustomHomeservers stays false — only the two listed servers are selectable,
so the client isn't opened up to arbitrary homeservers.

Deploys via lxc106-cinny.sh (cp -> /var/www/html/config.json); lotus-build.sh
preserves the live config across app rebuilds, so this is the authoritative copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 11:27:43 -04:00
jared 3fe232a6b7 docs: note planned Element Call fork (Lotus Call)
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 34s
Lint / Secret scan (gitleaks) (push) Successful in 5s
Tag the EC embed row and add a callout explaining the plan to fork
element-hq/element-call and self-build it for true ownership (decorations,
focus/screenshare, reconnect mic, theming, call-audio injection — all unfixable
against the prebuilt @element-hq/element-call-embedded bundle). Infra notes:
EC uses our LiveKit SFU (livekit/, LXC 151) + lk-jwt-service; a new build/deploy
pipeline will be needed. Full plan: LotusGuild/cinny → HANDOFF_ELEMENT_CALL_FORK.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 20:57:44 -04:00
jared 8c9edf60c3 docs: bump Element Call reference to 0.20.1
Lint / Shell (shellcheck) (push) Successful in 7s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 44s
Lint / Secret scan (gitleaks) (push) Successful in 11s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 04:18:43 -04:00
jared 52e9be1f8d docs: bump Element Call to 0.19.4; add noise suppression to landing
Lint / Shell (shellcheck) (push) Successful in 8s
Lint / JS (eslint) (push) Successful in 6s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Successful in 33s
Lint / Secret scan (gitleaks) (push) Successful in 6s
- README: correct embedded Element Call version 0.19.3 -> 0.19.4 in the
  Custom Features and Tech Stack tables
- landing/index.html: add a "Noise suppression" row to the Voice & Video
  comparison table (Lotus = 3 tiers incl. on-device RNNoise ML) and note
  the feature in the June 2026 narrative

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:30:07 -04:00
jared 442ad9b6ed docs: add avatar decorations to Lotus Chat feature description
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 12s
Lint / Python (ruff) (push) Successful in 24s
Lint / Python deps (pip-audit) (push) Successful in 1m25s
Lint / Secret scan (gitleaks) (push) Successful in 4s
99 curated APNG overlay frames stored in user Matrix profile (MSC4133),
visible to other Lotus Chat users in real time across timeline, members
list, and @mention autocomplete. Includes the Lotus Flower decoration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 12:03:00 -04:00
jared 68a6acfa24 feat: hard cross-client voice channel limits via voice-limit-guard
Lint / Shell (shellcheck) (push) Successful in 7s
Lint / JS (eslint) (push) Successful in 5s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Successful in 1m1s
Lint / Secret scan (gitleaks) (push) Successful in 4s
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>
2026-06-12 23:45:41 -04:00
jared 295a072dc9 docs: add voice channel user limit + call join/leave sounds to landing page
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:20:46 -04:00
jared b392798e3f docs: add AFK auto-mute and knock admin badge to landing page
- Feature description paragraph: added AFK auto-mute (1–30 min voice idle
  timeout) and knock-to-join admin badge (live count on Members button)
- Comparison table: new AFK auto-mute row in Voice & Video section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 21:38:20 -04:00
10 changed files with 1541 additions and 13 deletions
+98 -7
View File
@@ -50,7 +50,7 @@ matrix/
| 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 |
| 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 |
@@ -67,7 +67,8 @@ matrix/
- coturn config: `/etc/turnserver.conf`
- LiveKit config: `/etc/livekit/config.yaml`
- 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 config: `/opt/hookshot/config.yml`
- 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 |
|-----|---------|----|----|----------------------|
| 151 | matrix/hookshot | 10.10.10.29 | **9500** | `hookshot/*.js`, `systemd/livekit-server.service` |
| 151 | matrix/hookshot | 10.10.10.29 | **9500** | `hookshot/*.js`, `systemd/livekit-server.service`, `livekit/voice-limit-guard.py`, `systemd/voice-limit-guard.service`, `matrixbot/*` |
| 106 | cinny | 10.10.10.6 | 9000 | `cinny/config.json`, `cinny/upstream-check.sh`, `cinny/lotus-build.sh`, `deploy/hooks-lxc106.json`, `systemd/cinny-upstream-check.cron` |
| 139 | landing/NPM | 10.10.10.27 | 9000 | `landing/index.html` |
| 110 | draupnir | 10.10.10.24 | 9000 | `draupnir/production.yaml` |
@@ -140,6 +141,7 @@ Pushes to `main` on `LotusGuild/matrix` automatically deploy to the relevant LXC
**LXC 151 — hookshot/livekit:**
- `hookshot/*.js` changed → runs `hookshot/deploy.sh` (pushes transform functions to Matrix room state via API, requires `MATRIX_TOKEN` in `/etc/matrix-deploy.env`)
- `systemd/livekit-server.service` changed → copies file, `daemon-reload`, sets `/run/livekit-restart-pending` flag (actual restart deferred — see Livekit Graceful Restart below)
- `livekit/voice-limit-guard.py` / `systemd/voice-limit-guard.service` changed → `py_compile`-validates, installs to `/opt/voice-limit-guard/`, `daemon-reload` (if unit changed), and restarts `voice-limit-guard` (restart only affects joins in a ~1s window; established calls talk directly to livekit-server, so no call is dropped)
**LXC 106 — cinny:**
- `cinny/config.json` → copies to `/var/www/html/config.json`
@@ -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
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 |
| 7880 | LiveKit HTTP | 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 |
| 3478 | coturn STUN/TURN | 0.0.0.0 |
| 5349 | coturn TURNS/TLS | 0.0.0.0 |
@@ -409,18 +464,47 @@ 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.
### 🔱 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
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 |
|---------|-------|-------|
| **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 |
| **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) |
| **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 |
| **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 |
| **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 |
@@ -437,6 +521,9 @@ All custom code lives in `src/app/` on the `lotus` branch of `code.lotusguild.or
| **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 |
**Key config values (`/opt/lotus-cinny/config.json`, root — vite copies this to dist):**
@@ -476,8 +563,12 @@ Periodic `TLS/TCP socket error: Connection reset by peer` in coturn logs. Normal
## Server Checklist
## Server Checklist
### 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] TURN/STUN server (coturn) for reliable voice/video
- [x] URL previews
- [x] Upload size limit 200MB
@@ -689,7 +780,7 @@ All commands use the `!` prefix. Run `!help` in any room for the full list.
| Component | Technology | Version |
|-----------|-----------|---------|
| Homeserver | Synapse | 1.149.0 |
| Homeserver | Synapse | 1.155.0 |
| Database | PostgreSQL | 17.9 |
| TURN | coturn | latest |
| Video/voice calls | LiveKit SFU | 1.9.11 |
@@ -699,7 +790,7 @@ All commands use the `!` prefix. Run `!help` in any room for the full list.
| Webhook bridge | matrix-hookshot | 7.3.2 |
| Reverse proxy | Nginx Proxy Manager | — |
| Web client | Lotus Cinny (fork of `cinnyapp/cinny` main) | custom |
| Element Call embed | `@element-hq/element-call-embedded` | 0.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`) | — |
| Auto-deploy | adnanh/webhook | 2.8.0 |
| Bot language | Python 3 | 3.x |
+3 -1
View File
@@ -1,7 +1,9 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"matrix.lotusguild.org"
"matrix.lotusguild.org",
"matrix.org",
"mozilla.org"
],
"allowCustomHomeservers": false,
"featuredCommunities": {
+129
View File
@@ -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
+79
View File
@@ -0,0 +1,79 @@
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;
# 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 https://api.qrserver.com; 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://www.openstreetmap.org; 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
View File
@@ -1,6 +1,7 @@
#!/bin/bash
# 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
# Triggered by: Gitea webhook on push to main
set -euo pipefail
@@ -14,7 +15,7 @@ echo "=== $(date) === LXC106 deploy triggered ==="
if [ ! -d "$REPO_DIR/.git" ]; then
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
cd "$REPO_DIR"
git fetch --all
@@ -31,6 +32,23 @@ if echo "$CHANGED" | grep -q '^cinny/config.json'; then
echo "✓ config.json deployed"
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
echo "Deploying 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"
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
echo "Deploying hooks-lxc106.json..."
cp "$REPO_DIR/deploy/hooks-lxc106.json" /etc/webhook/hooks.json
+44 -1
View File
@@ -1,6 +1,7 @@
#!/bin/bash
# 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
set -euo pipefail
@@ -46,6 +47,48 @@ else
echo "Restart pending — will apply when no active calls."
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
if echo "$CHANGED" | grep -q '^matrixbot/'; then
echo "Deploying matrixbot changes..."
+105 -2
View File
File diff suppressed because one or more lines are too long
+364
View File
@@ -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()
+664
View File
@@ -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()
+22
View File
@@ -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