Compare commits

...
17 Commits
Author SHA1 Message Date
jared 4bb2dd4449 Merge pull request 'feat(cinny-deploy): fail and alert when a deployed page references missing files' (#39) from fix/deploy-asset-check into main
Lint / Shell (shellcheck) (push) Successful in 11s
Lint / JS (eslint) (push) Successful in 7s
Lint / No secrets in webhook configs (push) Successful in 4s
Lint / Landing page is rendered (matrix (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 8s
Lint / Python deps (pip-audit) (push) Successful in 1m20s
Lint / Secret scan (gitleaks) (push) Successful in 15s
2026-10-20 02:38:06 -04:00
Lotus CIandClaude Opus 5.5 8aea38fc67 feat(cinny-deploy): fail and alert when a deployed page references missing files
Lint / No secrets in webhook configs (pull_request) Successful in 24s
Lint / Landing page is rendered (matrix (pull_request) Successful in 18s
Lint / Python (ruff) (pull_request) Successful in 10s
Lint / Python deps (pip-audit) (pull_request) Successful in 1m0s
Lint / Secret scan (gitleaks) (pull_request) Successful in 8s
Lint / Shell (shellcheck) (push) Successful in 15s
Lint / JS (eslint) (push) Successful in 16s
Lint / No secrets in webhook configs (push) Successful in 10s
Lint / Landing page is rendered (matrix (push) Successful in 9s
Lint / Python (ruff) (push) Successful in 10s
Lint / Python deps (pip-audit) (push) Successful in 1m17s
Lint / Shell (shellcheck) (pull_request) Successful in 13s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / Secret scan (gitleaks) (push) Successful in 5s
After copying, check every script/stylesheet the app and call pages
reference (src/href in both index.html files) exists in the webroot. If
any is missing, log it, post an alert with the upstream checker's Matrix
credentials (/etc/cinny-monitor.env, best effort) and exit 1. This would
have caught the 2026-10-20 call-page outage at deploy time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-20 02:28:52 -04:00
jared 963bc87c54 Merge pull request 'fix(cinny-deploy): the call page's scripts are deployed again (regression from #30)' (#38) from fix/deploy-call-assets into main
Lint / Shell (shellcheck) (push) Successful in 16s
Lint / JS (eslint) (push) Successful in 13s
Lint / No secrets in webhook configs (push) Successful in 10s
Lint / Landing page is rendered (matrix (push) Successful in 8s
Lint / Python (ruff) (push) Successful in 12s
Lint / Python deps (pip-audit) (push) Successful in 1m31s
Lint / Secret scan (gitleaks) (push) Successful in 7s
2026-10-20 01:55:37 -04:00
Lotus CIandClaude Opus 5.5 85c58d5ed0 fix(cinny-deploy): the call page's scripts are deployed again
Lint / No secrets in webhook configs (pull_request) Successful in 11s
Lint / Landing page is rendered (matrix (pull_request) Successful in 8s
Lint / Python (ruff) (pull_request) Successful in 7s
Lint / Python deps (pip-audit) (pull_request) Successful in 44s
Lint / Secret scan (gitleaks) (pull_request) Successful in 9s
Lint / Shell (shellcheck) (push) Successful in 14s
Lint / JS (eslint) (push) Successful in 9s
Lint / No secrets in webhook configs (push) Successful in 6s
Lint / Landing page is rendered (matrix (push) Successful in 5s
Lint / Python (ruff) (push) Successful in 7s
Lint / Python deps (pip-audit) (push) Successful in 40s
Lint / Secret scan (gitleaks) (push) Successful in 6s
Lint / Shell (shellcheck) (pull_request) Successful in 14s
Lint / JS (eslint) (pull_request) Successful in 10s
#30 added `rsync --exclude assets/` to keep old app assets, but an
unanchored exclude matches every directory named assets, including
public/element-call/assets/. Each deploy then updated the call page's
index.html but not its hashed scripts, so after the lotus.28 deploy the
call page requested files that did not exist (66 x 404 from 00:05 on
2026-10-20 until the assets were copied in by hand).

The excludes are now anchored (/assets/, /public/element-call/assets/), and
both asset directories get the same treatment: copied without --delete,
pruned after 14 days, so an open call page keeps its scripts across a
deploy. config.json stays unanchored on purpose (the production call
config lives at public/element-call/config.json).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-20 00:19:47 -04:00
jared 8e5cdf50b5 Merge pull request 'feat(voice-limit-guard): enforce the limit at connect time too (#25)' (#37) from feat/voice-limit-connect-time into main
Lint / Shell (shellcheck) (push) Successful in 8s
Lint / JS (eslint) (push) Successful in 8s
Lint / No secrets in webhook configs (push) Successful in 11s
Lint / Landing page is rendered (matrix (push) Successful in 10s
Lint / Python (ruff) (push) Successful in 12s
Lint / Python deps (pip-audit) (push) Successful in 1m9s
Lint / Secret scan (gitleaks) (push) Successful in 4s
2026-10-19 23:34:04 -04:00
Lotus CIandClaude Opus 5.5 d3f389cab1 feat(voice-limit-guard): enforce the limit at connect time too (#25)
Lint / Shell (shellcheck) (push) Successful in 39s
Lint / JS (eslint) (push) Successful in 6s
Lint / No secrets in webhook configs (push) Successful in 4s
Lint / Landing page is rendered (matrix (push) Successful in 4s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 43s
Lint / Secret scan (gitleaks) (push) Successful in 9s
Lint / Shell (shellcheck) (pull_request) Successful in 13s
Lint / JS (eslint) (pull_request) Successful in 11s
Lint / No secrets in webhook configs (pull_request) Successful in 10s
Lint / Landing page is rendered (matrix (pull_request) Successful in 11s
Lint / Python (ruff) (pull_request) Successful in 10s
Lint / Python deps (pip-audit) (pull_request) Successful in 50s
Lint / Secret scan (gitleaks) (pull_request) Successful in 5s
The limit was only checked at token issue, and lk-jwt tokens last an hour,
so a token fetched while there was space could be used to connect once the
room was full. The reconcile loop now compares each limited room's
distinct Matrix users with max_users and RemoveParticipants the people who
arrived since the previous sweep, newest first, until it is back within the
limit. People already in the call are never removed (lowering a limit
mid-call kicks nobody); extra devices of one person never count twice.

Fixes #25

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 23:30:15 -04:00
jared f7ce35c636 Merge pull request 'fix(cinny-web): per-client rate limits; own budget for decorations' (#36) from fix/nginx-real-ip-decorations into main
Lint / Shell (shellcheck) (push) Successful in 12s
Lint / JS (eslint) (push) Successful in 9s
Lint / No secrets in webhook configs (push) Successful in 4s
Lint / Landing page is rendered (matrix (push) Successful in 4s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Successful in 34s
Lint / Secret scan (gitleaks) (push) Successful in 4s
2026-10-19 12:44:26 -04:00
Lotus CIandClaude Opus 5.5 a52d6a89bc fix(cinny-web): per-client rate limits; own budget for decorations
Lint / Shell (shellcheck) (pull_request) Successful in 7s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / No secrets in webhook configs (pull_request) Successful in 3s
Lint / Landing page is rendered (matrix (pull_request) Successful in 3s
Lint / Python (ruff) (pull_request) Successful in 4s
Lint / Python deps (pip-audit) (pull_request) Successful in 49s
Lint / Secret scan (gitleaks) (pull_request) Successful in 5s
Lint / Shell (shellcheck) (push) Successful in 7s
Lint / JS (eslint) (push) Successful in 6s
Lint / No secrets in webhook configs (push) Successful in 4s
Lint / Landing page is rendered (matrix (push) Successful in 4s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Successful in 30s
Lint / Secret scan (gitleaks) (push) Successful in 4s
Every request reaches LXC 106 from Nginx Proxy Manager (10.10.10.27), and
nginx had no realip config, so chat_limit (20 r/s, burst 60, keyed by
$binary_remote_addr) was ONE bucket for the whole community: a scanner
burst 503ed real users. Trust X-Real-IP from the proxy only, in both
server blocks.

Decorations (#22) now come from this host, and the profile picker can
fetch all 629 thumbnails: ~500 of them 503ed in testing. /decorations/
gets its own zone (100 r/s, burst 700) instead of chat_limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 12:41:46 -04:00
jared adeb872abb Merge pull request 'feat(cinny-web): serve avatar decorations from chat.lotusguild.org/decorations (#22)' (#35) from feat/decorations-package into main
Lint / Shell (shellcheck) (push) Successful in 11s
Lint / JS (eslint) (push) Successful in 8s
Lint / No secrets in webhook configs (push) Successful in 8s
Lint / Landing page is rendered (matrix (push) Successful in 5s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Successful in 35s
Lint / Secret scan (gitleaks) (push) Successful in 5s
2026-10-19 12:35:22 -04:00
Lotus CIandClaude Opus 5.5 b6380b6c8a feat(cinny-web): serve avatar decorations from chat.lotusguild.org/decorations (#22)
Lint / Shell (shellcheck) (push) Successful in 14s
Lint / JS (eslint) (push) Successful in 11s
Lint / No secrets in webhook configs (push) Successful in 5s
Lint / Landing page is rendered (matrix (push) Successful in 4s
Lint / Python (ruff) (push) Successful in 6s
Lint / Python deps (pip-audit) (push) Successful in 32s
Lint / Secret scan (gitleaks) (push) Successful in 4s
Lint / Shell (shellcheck) (pull_request) Successful in 7s
Lint / JS (eslint) (pull_request) Successful in 5s
Lint / No secrets in webhook configs (pull_request) Successful in 4s
Lint / Landing page is rendered (matrix (pull_request) Successful in 3s
Lint / Python (ruff) (pull_request) Successful in 4s
Lint / Python deps (pip-audit) (pull_request) Successful in 30s
Lint / Secret scan (gitleaks) (pull_request) Successful in 4s
Decorations came through Nextcloud's public DAV: a new PHP session (four
cookies) per image and no cache headers, on every decorated avatar. The
set (629 decorations + thumbnails, 573 MB) is now a versioned Gitea
generic package (avatar-decorations 1.0.0, 4 tar parts), pinned by
sha256 in cinny/decorations.sha256. LXC 106's deploy installs it to
/var/www/decorations-<version> (verified against the pin and the
in-package manifest; previous version kept; failures leave the live set
alone), and nginx serves /decorations/ with a one-week public cache.
deploy/publish-decorations.sh publishes a new version and writes the pin.

Fixes #22

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 12:32:34 -04:00
jared 96d1778bb4 Merge pull request 'fix(voice-limit-guard): two simultaneous joins can no longer both pass a full room (#25)' (#33) from fix/voice-limit-race into main
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 8s
Lint / No secrets in webhook configs (push) Successful in 11s
Lint / Landing page is rendered (matrix (push) Successful in 8s
Lint / Python (ruff) (push) Successful in 11s
Lint / Python deps (pip-audit) (push) Successful in 48s
Lint / Secret scan (gitleaks) (push) Successful in 5s
2026-10-18 19:46:01 -04:00
jared 0aa34123a8 Merge pull request 'fix(cinny-web): CSP for mozilla.org discovery and TikTok oEmbed; featured communities (#18, #20, #21)' (#32) from fix/web-config-csp into main
Lint / Shell (shellcheck) (push) Successful in 12s
Lint / JS (eslint) (push) Successful in 10s
Lint / No secrets in webhook configs (push) Successful in 5s
Lint / Landing page is rendered (matrix (push) Successful in 4s
Lint / Python (ruff) (push) Successful in 6s
Lint / Python deps (pip-audit) (push) Successful in 1m7s
Lint / Secret scan (gitleaks) (push) Successful in 6s
2026-10-18 19:45:52 -04:00
jared 070a94d0d7 Merge pull request 'docs(livekit): bind lk-jwt-service to 127.0.0.1:8071, not all interfaces (#24)' (#31) from fix/lk-jwt-loopback into main
Lint / Shell (shellcheck) (push) Successful in 12s
Lint / JS (eslint) (push) Successful in 9s
Lint / No secrets in webhook configs (push) Successful in 6s
Lint / Landing page is rendered (matrix (push) Successful in 5s
Lint / Python (ruff) (push) Successful in 6s
Lint / Python deps (pip-audit) (push) Successful in 1m13s
Lint / Secret scan (gitleaks) (push) Successful in 15s
2026-10-18 19:45:47 -04:00
jared 9b0cbc9db1 Merge pull request 'fix(cinny-web): no-cache for the app shell on SPA routes; keep old hashed assets 14 days (#23)' (#30) from fix/shell-cache-and-assets into main
Lint / Shell (shellcheck) (push) Successful in 14s
Lint / JS (eslint) (push) Successful in 7s
Lint / No secrets in webhook configs (push) Successful in 3s
Lint / Landing page is rendered (matrix (push) Successful in 3s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 39s
Lint / Secret scan (gitleaks) (push) Successful in 9s
2026-10-18 19:44:17 -04:00
Lotus CIandClaude Opus 5.5 fa0351084b fix(voice-limit-guard): two simultaneous joins can no longer both pass a full room (#25)
Lint / Shell (shellcheck) (pull_request) Successful in 16s
Lint / JS (eslint) (pull_request) Successful in 13s
Lint / No secrets in webhook configs (pull_request) Successful in 8s
Lint / Landing page is rendered (matrix (pull_request) Successful in 10s
Lint / Python (ruff) (pull_request) Successful in 8s
Lint / Python deps (pip-audit) (pull_request) Successful in 1m25s
Lint / Secret scan (gitleaks) (pull_request) Successful in 13s
Lint / Shell (shellcheck) (push) Successful in 14s
Lint / JS (eslint) (push) Successful in 7s
Lint / No secrets in webhook configs (push) Successful in 4s
Lint / Landing page is rendered (matrix (push) Successful in 4s
Lint / Python (ruff) (push) Successful in 5s
Lint / Python deps (pip-audit) (push) Successful in 40s
Lint / Secret scan (gitleaks) (push) Successful in 6s
The limit was checked against participants LiveKit already had, so
tokens issued but not yet connected were not counted: two users
pressing Join together could both get into a 1-person channel.
Serialise the check per room and give each admitted user a 30 s
reservation, counted like a present user until LiveKit lists them or
it expires.

Refs #25

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 15:24:27 -04:00
Lotus CIandClaude Opus 5.5 076340227f docs(livekit): bind lk-jwt-service to 127.0.0.1:8071, not all interfaces (#24)
Lint / Shell (shellcheck) (pull_request) Successful in 19s
Lint / JS (eslint) (pull_request) Successful in 16s
Lint / No secrets in webhook configs (pull_request) Successful in 9s
Lint / Landing page is rendered (matrix (pull_request) Successful in 5s
Lint / Python (ruff) (pull_request) Successful in 5s
Lint / Python deps (pip-audit) (pull_request) Successful in 38s
Lint / Secret scan (gitleaks) (pull_request) Successful in 8s
Lint / Shell (shellcheck) (push) Successful in 14s
Lint / JS (eslint) (push) Successful in 9s
Lint / No secrets in webhook configs (push) Successful in 4s
Lint / Landing page is rendered (matrix (push) Successful in 4s
Lint / Python (ruff) (push) Successful in 6s
Lint / Python deps (pip-audit) (push) Successful in 40s
Lint / Secret scan (gitleaks) (push) Successful in 7s
voice-limit-guard (:8070) is meant to be the only way to get a LiveKit
token, but lk-jwt-service bound :8071 on every interface, so any LAN
host could mint an unguarded token (no max_users, full publish sources
in audio-only rooms). The guard runs on the same LXC: document the
loopback bind in the setup steps and the port table.

Refs #24

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 01:27:00 -04:00
Lotus CIandClaude Opus 5.5 bf770fed83 fix(cinny-web): no-cache for the app shell on SPA routes; keep old hashed assets 14 days (#23)
Lint / Shell (shellcheck) (push) Successful in 24s
Lint / JS (eslint) (push) Successful in 15s
Lint / No secrets in webhook configs (push) Successful in 7s
Lint / Landing page is rendered (matrix (push) Successful in 42s
Lint / Python (ruff) (push) Successful in 11s
Lint / Python deps (pip-audit) (push) Successful in 40s
Lint / Secret scan (gitleaks) (push) Successful in 6s
Lint / Shell (shellcheck) (pull_request) Successful in 9s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / No secrets in webhook configs (pull_request) Successful in 5s
Lint / Landing page is rendered (matrix (pull_request) Successful in 4s
Lint / Python (ruff) (pull_request) Successful in 6s
Lint / Python deps (pip-audit) (pull_request) Successful in 1m59s
Lint / Secret scan (gitleaks) (pull_request) Successful in 15s
SPA URLs (/, /home/…, /login/…) are rewritten to /index.html inside
location /, which set no cache headers, so browsers heuristically reused
a stale shell after a deploy, and rsync --delete had just removed the
assets it referenced: a blank app until the shell expired. Send
no-cache for the rewritten shell (map on $uri; location / now includes
the security headers, since add_header drops the inherited ones), and
stop deleting previous builds' hashed assets: prune after 14 days.

Fixes #23

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 01:26:02 -04:00
8 changed files with 453 additions and 12 deletions
+16 -3
View File
@@ -67,7 +67,7 @@ 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` (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`) - lk-jwt-service: `lk-jwt-service.service` (now binds `127.0.0.1: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) - voice-limit-guard: `voice-limit-guard.service` (binds `:8070`, fronts lk-jwt-service — enforces hard per-room voice participant limits **and publish permissions (screenshare/camera via JWT re-signing)** for ALL clients; script `/opt/voice-limit-guard/voice-limit-guard.py`) — see [Voice Channel Limits & Call Permissions](#voice-channel-limits--call-permissions)
- Hookshot: `/opt/hookshot/`, service: `matrix-hookshot.service` - Hookshot: `/opt/hookshot/`, service: `matrix-hookshot.service`
- Hookshot config: `/opt/hookshot/config.yml` - Hookshot config: `/opt/hookshot/config.yml`
@@ -206,6 +206,7 @@ Every Matrix client must fetch a LiveKit JWT from lk-jwt-service before it can j
- **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. - **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. - **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`. - **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`.
- **Connect-time limit (matrix #25)** — the token check can't stop a token fetched while there was space (lk-jwt tokens last an hour) from being used once the room is full. The same reconcile loop compares each limited room's **distinct Matrix users** with `max_users`, and when people who **arrived since the previous sweep** take it over the limit it calls LiveKit `RemoveParticipant` on them, newest first (all their devices). People already in the call are never removed, so **lowering a limit mid-call kicks nobody**; a person's extra devices never count twice. The first sweep of a room only records who is there. Simultaneous joins are already handled at token issue (per-room lock + 30 s slot reservations).
- **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). - **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).
> `LIVEKIT_KEY`/`LIVEKIT_SECRET` live in `/etc/matrix-deploy.env` (0600, not in git). The same pair must > `LIVEKIT_KEY`/`LIVEKIT_SECRET` live in `/etc/matrix-deploy.env` (0600, not in git). The same pair must
@@ -238,7 +239,9 @@ install -D -m644 /opt/matrix-config/livekit/voice-limit-guard.py /opt/voice-limi
install -m644 /opt/matrix-config/systemd/voice-limit-guard.service /etc/systemd/system/voice-limit-guard.service 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 # one-time: rebind lk-jwt-service to :8071
mkdir -p /etc/systemd/system/lk-jwt-service.service.d 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 # Loopback only (matrix #24): the guard runs on this LXC; binding all
# interfaces let any LAN host mint unguarded tokens straight from :8071.
printf '[Service]\nEnvironment=LIVEKIT_JWT_BIND=127.0.0.1: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 systemctl daemon-reload && systemctl restart lk-jwt-service && systemctl enable --now voice-limit-guard
``` ```
@@ -290,7 +293,7 @@ The token in `draupnir/production.yaml` in this repo is **intentionally redacted
| 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 | voice-limit-guard (fronts 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 | | 8071 | lk-jwt-service (behind guard) | 127.0.0.1 (matrix #24; was 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 |
@@ -470,6 +473,16 @@ chmod 600 /etc/cinny-monitor.env
**Cinny-build webhook token** (header `X-Build-Token` on the `cinny-build` hook): `CINNY_BUILD_TOKEN` in LXC 106's `/etc/webhook/secrets.env`, not in git. Reachable on `10.10.10.6:9001` (`webhook-lotus`); `:9000` is bound to `127.0.0.1`. Note: LotusBot doesn't implement `!cinny-update` at the moment, so nothing calls this hook. **Cinny-build webhook token** (header `X-Build-Token` on the `cinny-build` hook): `CINNY_BUILD_TOKEN` in LXC 106's `/etc/webhook/secrets.env`, not in git. Reachable on `10.10.10.6:9001` (`webhook-lotus`); `:9000` is bound to `127.0.0.1`. Note: LotusBot doesn't implement `!cinny-update` at the moment, so nothing calls this hook.
### Avatar decorations (matrix #22)
Served from **`https://chat.lotusguild.org/decorations/`**: `<slug>.png` and `thumbs/<slug>.webp`, `Cache-Control: public, max-age=604800`, no cookies. They used to come through Nextcloud's public DAV, which started a PHP session per image and sent no cache headers.
- **Storage:** Gitea generic package `avatar-decorations` (`https://code.lotusguild.org/api/packages/LotusGuild/generic/avatar-decorations/<version>/`), in ≤150 MB tar parts (the proxy refuses larger uploads). Nothing goes in git except the pin.
- **Pin:** `cinny/decorations.sha256`, the sha256 of each part, which names the version.
- **Deploy:** `deploy/lxc106-cinny.sh` installs the pinned version when the pin changes (or nothing is installed yet). It downloads the parts, checks them against the pin and against the `MANIFEST.sha256` inside, unpacks to `/var/www/decorations-<version>`, and atomically repoints `/var/www/decorations`. The previous version is kept for rollback; a failed download or checksum leaves the live set unchanged. nginx: `location ^~ /decorations/` in `cinny/nginx.conf`.
- **Updating the set:** put the files in a folder (`<slug>.png` + `thumbs/<slug>.webp`) and run `GITEA_TOKEN=… deploy/publish-decorations.sh <folder> <new-version>`. That rewrites the pin; commit it. Add or remove the catalog entries in cinny's `src/app/features/lotus/avatarDecorations.ts`. Use a new version for every change: an image replaced under the same name can be cached for up to a week.
- **Rollback:** revert `cinny/decorations.sha256`, or on LXC 106 run `ln -sfn /var/www/decorations-<old>/decorations /var/www/decorations`.
### Webhook secrets — rotating ### Webhook secrets — rotating
Secrets used to be committed in `deploy/hooks-lxc*.json` in this public repo; all exposed ones were rotated on 2026-09-23 and moved to `secrets.env`. To rotate one: Secrets used to be committed in `deploy/hooks-lxc*.json` in this public repo; all exposed ones were rotated on 2026-09-23 and moved to `secrets.env`. To rotate one:
+4
View File
@@ -0,0 +1,4 @@
a7aacaa65cb06e4b1c990b9253362f2eb148aae72e2e11c396d34e899792d68d avatar-decorations-1.0.0.part1.tar
a6919d8bb8e1c2f838b0f9acdbc1131cbfd7bfe5fe9c373b25f406474bd41095 avatar-decorations-1.0.0.part2.tar
fb7624f2470659d933463187d2afe73428d5554291f62484c8a28fac6196f427 avatar-decorations-1.0.0.part3.tar
909e07db0134b29258514acf4d598bb467aff8b157f0767b6c3afeadc1eb048c avatar-decorations-1.0.0.part4.tar
+60 -1
View File
@@ -160,7 +160,66 @@ echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploying to $WEBROOT..."
# /var/www/html/config.json by lxc106-cinny.sh. The build ships a DEV default # /var/www/html/config.json by lxc106-cinny.sh. The build ships a DEV default
# (allowCustomHomeservers:true); rsyncing it would clobber the production config # (allowCustomHomeservers:true); rsyncing it would clobber the production config
# on every deploy. Keep the app bundle and the runtime config separate. # on every deploy. Keep the app bundle and the runtime config separate.
rsync -a --delete --exclude config.json dist/ "$WEBROOT/" # [matrix #23] Keep the previous builds' hashed assets for a while: a browser
# still holding an older index.html (or service-worker precache) asks for its
# assets/index-<old hash>.js, and deleting them on every deploy broke the app
# for those users until their cached shell expired. Prune after 14 days.
# The asset excludes are ANCHORED (leading /): an unanchored `assets/` also
# matched public/element-call/assets/, so the call page's index.html was
# updated while its hashed scripts never were, and calls failed to load.
# config.json stays unanchored on purpose: public/element-call/config.json is
# the production call config too.
rsync -a --delete --exclude config.json \
--exclude /assets/ --exclude /public/element-call/assets/ dist/ "$WEBROOT/"
for dir in assets public/element-call/assets; do
mkdir -p "$WEBROOT/$dir"
rsync -a "dist/$dir/" "$WEBROOT/$dir/"
find "$WEBROOT/$dir" -type f -mtime +14 -delete
done
# Every script/stylesheet the app and call pages reference must exist in the
# webroot. 2026-10-20: the call page's index.html pointed at scripts the deploy
# never copied and calls failed to load; this check would have caught it.
missing_assets() { # <index.html> <its directory>
grep -oE '(src|href)="[^"]+\.(js|css)"' "$1" | sed -E 's/^(src|href)="//; s/"$//' |
while read -r ref; do
case "$ref" in
http://*|https://*|//*) continue ;;
/*) path="$WEBROOT$ref" ;;
*) path="$2/${ref#./}" ;;
esac
[ -f "$path" ] || echo "$ref"
done
}
deploy_alert() { # best effort: the upstream checker's Matrix credentials
[ -r /etc/cinny-monitor.env ] || return 0
(
set -a
# shellcheck source=/dev/null
source /etc/cinny-monitor.env
set +a
_ALERT="$1" python3 - <<'PYEOF'
import json, os, time, urllib.parse, urllib.request
server = os.environ.get("MATRIX_SERVER", "").rstrip("/")
room, token = os.environ.get("MATRIX_ROOM", ""), os.environ.get("MATRIX_TOKEN", "")
if server and room and token:
url = f"{server}/_matrix/client/v3/rooms/{urllib.parse.quote(room, safe='')}/send/m.room.message/deploy{int(time.time() * 1000)}"
req = urllib.request.Request(url, method="PUT", data=json.dumps({"msgtype": "m.text", "body": os.environ["_ALERT"]}).encode(),
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"})
try:
urllib.request.urlopen(req, timeout=10)
except Exception as exc:
print(f"alert failed: {exc}")
PYEOF
) || true
}
MISSING=$(missing_assets "$WEBROOT/index.html" "$WEBROOT"; missing_assets "$WEBROOT/public/element-call/index.html" "$WEBROOT/public/element-call")
if [ -n "$MISSING" ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: deployed pages reference missing files:"
echo "$MISSING"
deploy_alert "⚠️ Lotus Chat deploy ($VITE_APP_VERSION): the live pages reference files that are not on the server, so the app or calls may not load: $(echo "$MISSING" | tr '\n' ' ')"
exit 1
fi
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ===== Deploy complete ($VITE_APP_VERSION) =====" echo "[$(date '+%Y-%m-%d %H:%M:%S')] ===== Deploy complete ($VITE_APP_VERSION) ====="
+41
View File
@@ -1,3 +1,23 @@
# [matrix #23] SPA routes (/, /home/…, /login/…) are rewritten to /index.html
# inside `location /`, where no cache headers were set: browsers then reused a
# heuristically fresh shell after a deploy and requested deleted assets. Only
# the rewritten shell gets no-cache; other files there keep their defaults.
map $uri $lotus_shell_cache_control {
/index.html "no-cache, no-store, must-revalidate";
default "";
}
# All traffic arrives through Nginx Proxy Manager (10.10.10.27), so without
# realip every visitor shared ONE chat_limit bucket (20 r/s, keyed by
# $binary_remote_addr = the proxy): a scanner burst got real users 503s.
# Each server block below trusts X-Real-IP from the proxy only (NPM sets it
# to the client address, overwriting anything the client sent).
#
# [matrix #22] Decorations get their own, larger budget: opening the profile
# decoration picker can fetch hundreds of thumbnails, which would otherwise use
# up chat_limit and 503 the app's own files.
limit_req_zone $binary_remote_addr zone=decorations_limit:10m rate=100r/s;
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
@@ -14,6 +34,8 @@ server {
server_tokens off; server_tokens off;
client_max_body_size 50m; client_max_body_size 50m;
set_real_ip_from 10.10.10.27;
real_ip_header X-Real-IP;
limit_req zone=chat_limit burst=60 nodelay; limit_req zone=chat_limit burst=60 nodelay;
limit_conn chat_conn 25; limit_conn chat_conn 25;
index index.html; index index.html;
@@ -98,7 +120,24 @@ server {
proxy_connect_timeout 5; proxy_connect_timeout 5;
} }
# [matrix #22] Avatar decorations, installed by deploy/lxc106-cinny.sh from
# the Gitea generic package (outside the web root, so app deploys'
# rsync --delete never touches them). ^~ beats the image regex below: these
# can be replaced in place under the same name, so a week, not immutable.
location ^~ /decorations/ {
alias /var/www/decorations/;
# Replaces the server-level chat_limit here (nginx doesn't inherit
# limit_req into a location that sets its own).
limit_req zone=decorations_limit burst=700 nodelay;
include snippets/cinny-security-headers.conf;
add_header Cache-Control "public, max-age=604800" always;
location ~ /MANIFEST\.sha256$ { return 404; }
}
location / { location / {
# add_header here drops the server-level headers, so include them again.
include snippets/cinny-security-headers.conf;
add_header Cache-Control $lotus_shell_cache_control always;
rewrite ^/config\.json$ /config.json break; rewrite ^/config\.json$ /config.json break;
rewrite ^/manifest\.json$ /manifest.json break; rewrite ^/manifest\.json$ /manifest.json break;
rewrite ^/sw\.js$ /sw.js break; rewrite ^/sw\.js$ /sw.js break;
@@ -128,6 +167,8 @@ server {
root /var/www/html; root /var/www/html;
server_tokens off; server_tokens off;
set_real_ip_from 10.10.10.27;
real_ip_header X-Real-IP;
limit_req zone=chat_limit burst=60 nodelay; limit_req zone=chat_limit burst=60 nodelay;
limit_conn chat_conn 25; limit_conn chat_conn 25;
+50 -1
View File
@@ -16,7 +16,7 @@ echo "=== $(date) === LXC106 deploy triggered ==="
if [ ! -d "$REPO_DIR/.git" ]; then if [ ! -d "$REPO_DIR/.git" ]; then
git clone "$CLONE_URL" "$REPO_DIR" git clone "$CLONE_URL" "$REPO_DIR"
CHANGED="cinny/config.json cinny/nginx.conf cinny/upstream-check.sh cinny/lotus-build.sh cinny/lotus_deploy.sh deploy/hooks-lxc106.json systemd/cinny-upstream-check.cron" CHANGED="cinny/config.json cinny/decorations.sha256 cinny/nginx.conf cinny/upstream-check.sh cinny/lotus-build.sh cinny/lotus_deploy.sh deploy/hooks-lxc106.json systemd/cinny-upstream-check.cron"
else else
cd "$REPO_DIR" cd "$REPO_DIR"
git fetch --all git fetch --all
@@ -63,6 +63,55 @@ PY
fi fi
fi fi
# [matrix #22] Avatar decorations: a versioned Gitea generic package, pinned
# (version + sha256 of each part) in cinny/decorations.sha256, installed to
# /var/www/decorations-<version> and served by nginx at /decorations/. Runs on a
# pin change or when nothing is installed yet. A failed download or checksum
# leaves the live set untouched; the previous version is kept for rollback.
install_decorations() {
local pin="$REPO_DIR/cinny/decorations.sha256"
local ver base tmp dest
ver=$(grep -oE 'avatar-decorations-[0-9][^ ]*\.part1\.tar' "$pin" | sed -E 's/^avatar-decorations-(.*)\.part1\.tar$/\1/')
[ -n "$ver" ] || { echo "✗ decorations: no version in $pin"; return 1; }
dest="/var/www/decorations-$ver"
if [ -d "$dest/decorations" ] && [ "$(readlink -f /var/www/decorations)" = "$dest/decorations" ]; then
echo "decorations $ver already live"; return 0
fi
base="https://code.lotusguild.org/api/packages/LotusGuild/generic/avatar-decorations/$ver"
tmp=$(mktemp -d /var/www/.decorations-XXXXXX)
# Every step checks its own exit status: `set -e` does not apply inside a
# subshell on the left of `||`, so a failed checksum would be ignored.
if ! (
cd "$tmp" || exit 1
cp "$pin" SHA256SUMS || exit 1
while read -r _ f; do
curl -fsSL --retry 3 -o "$f" "$base/$f" || exit 1
done < SHA256SUMS
sha256sum -c --quiet SHA256SUMS || exit 1
while read -r _ f; do
tar -xf "$f" || exit 1
rm -f "$f"
done < SHA256SUMS
cd decorations || exit 1
sha256sum -c --quiet MANIFEST.sha256 || exit 1
); then
echo "✗ decorations $ver: download/verify failed — live set unchanged"
rm -rf "$tmp"
return 1
fi
rm -rf "$dest"; mkdir -p "$dest"; mv "$tmp/decorations" "$dest/decorations"; rm -rf "$tmp"
chmod -R a+rX "$dest"
ln -sfn "$dest/decorations" /var/www/decorations.new && mv -T /var/www/decorations.new /var/www/decorations
# Keep the live version and the one before it; drop older ones.
find /var/www -maxdepth 1 -name 'decorations-*' -type d -printf '%T@ %p\n' | sort -rn \
| tail -n +3 | cut -d' ' -f2- | xargs -r rm -rf
echo "✓ decorations $ver live at /var/www/decorations ($(find "$dest/decorations" -maxdepth 1 -name '*.png' | wc -l) images)"
}
if echo "$CHANGED" | grep -q '^cinny/decorations.sha256' || [ ! -e /var/www/decorations ]; then
echo "Deploying avatar decorations..."
install_decorations || true
fi
if echo "$CHANGED" | grep -q '^cinny/nginx.conf'; then if echo "$CHANGED" | grep -q '^cinny/nginx.conf'; then
echo "Deploying cinny nginx site config..." echo "Deploying cinny nginx site config..."
# Back up the live config, swap in the repo copy, and validate before # Back up the live config, swap in the repo copy, and validate before
+56
View File
@@ -0,0 +1,56 @@
#!/bin/bash
# Publish a new avatar-decorations version to the Gitea generic package
# registry and write the pin that LXC 106's deploy installs (matrix #22).
#
# GITEA_TOKEN=… deploy/publish-decorations.sh <dir> <version>
#
# <dir> holds <slug>.png files and thumbs/<slug>.webp (the same layout the app
# requests). The set is split into ≤150 MB tar parts (the registry's proxy
# refuses large uploads), each part is uploaded, and cinny/decorations.sha256
# is rewritten. Commit that file to deploy; nothing changes until you do.
set -euo pipefail
SRC=${1:?usage: publish-decorations.sh <dir> <version>}
VER=${2:?usage: publish-decorations.sh <dir> <version>}
: "${GITEA_TOKEN:?set GITEA_TOKEN (package write scope)}"
REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd)
BASE="https://code.lotusguild.org/api/packages/LotusGuild/generic/avatar-decorations/$VER"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/decorations/thumbs"
cp "$SRC"/*.png "$WORK/decorations/"
cp "$SRC"/thumbs/*.webp "$WORK/decorations/thumbs/"
cd "$WORK"
for f in decorations/*.png; do
s=$(basename "$f" .png)
[ -f "decorations/thumbs/$s.webp" ] || { echo "missing thumbs/$s.webp" >&2; exit 1; }
done
(cd decorations && find . -type f \( -name '*.png' -o -name '*.webp' \) | sort | xargs sha256sum > MANIFEST.sha256)
python3 - "$VER" <<'PY'
import os, subprocess, sys
ver, root, limit = sys.argv[1], 'decorations', 150 * 1024 * 1024
files = sorted(os.path.join(root, f) for f in os.listdir(root) if f.endswith('.png'))
first = [os.path.join(root, 'MANIFEST.sha256')] + sorted(os.path.join(root, 'thumbs', t) for t in os.listdir(os.path.join(root, 'thumbs')))
parts, cur, size = [], list(first), sum(os.path.getsize(f) for f in first)
for f in files:
if size + os.path.getsize(f) > limit and cur:
parts.append(cur); cur, size = [], 0
cur.append(f); size += os.path.getsize(f)
parts.append(cur)
for i, p in enumerate(parts, 1):
lst = f'part{i}.list'
open(lst, 'w').write('\n'.join(p) + '\n')
subprocess.run(['tar', '--sort=name', '--owner=0', '--group=0', '--mtime=@0', '-cf',
f'avatar-decorations-{ver}.part{i}.tar', '-T', lst], check=True)
PY
sha256sum avatar-decorations-"$VER".part*.tar > SHA256SUMS
for f in avatar-decorations-"$VER".part*.tar SHA256SUMS; do
code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT -H "Authorization: token $GITEA_TOKEN" \
--upload-file "$f" "$BASE/$f")
[ "$code" = 201 ] || { echo "upload of $f failed: HTTP $code" >&2; exit 1; }
echo "uploaded $f"
done
cp SHA256SUMS "$REPO_ROOT/cinny/decorations.sha256"
echo "Pinned $VER in cinny/decorations.sha256: commit it to deploy."
+106
View File
@@ -389,3 +389,109 @@ class NormalizePermissionTest(unittest.TestCase):
guard.livekit_update_participant = lambda *a: None guard.livekit_update_participant = lambda *a: None
participant = {"identity": "@a:x:DEV", "permission": {"canPublish": True, "canPublishSources": ["CAMERA"]}} participant = {"identity": "@a:x:DEV", "permission": {"canPublish": True, "canPublishSources": ["CAMERA"]}}
self.assertFalse(guard.reconcile_participant("!r:x", participant, {"SCREEN_SHARE"})) self.assertFalse(guard.reconcile_participant("!r:x", participant, {"SCREEN_SHARE"}))
class SlotReservationTests(unittest.TestCase):
"""[matrix #25] Simultaneous joins can't both pass a full room."""
def test_second_simultaneous_join_is_blocked(self):
r = guard.SlotReservations(ttl=30)
present = set()
counted = present | r.reserved("room", present, now=0)
self.assertFalse(guard.should_block(1, counted, "@a:x"))
r.reserve("room", "@a:x", now=0)
# @b asks a moment later, before @a has connected to LiveKit.
counted = present | r.reserved("room", present, now=1)
self.assertTrue(guard.should_block(1, counted, "@b:x"))
# @a asking again (second device) is still allowed.
self.assertFalse(guard.should_block(1, counted, "@a:x"))
def test_reservation_expires_and_clears_once_present(self):
r = guard.SlotReservations(ttl=30)
r.reserve("room", "@a:x", now=0)
self.assertEqual(r.reserved("room", set(), now=31), set())
r.reserve("room", "@a:x", now=40)
self.assertEqual(r.reserved("room", {"@a:x"}, now=41), set())
def test_room_lock_is_per_room(self):
r = guard.SlotReservations()
self.assertIs(r.room_lock("a"), r.room_lock("a"))
self.assertIsNot(r.room_lock("a"), r.room_lock("b"))
def _p(identity, joined_ms):
return {"identity": identity, "joined_at_ms": str(joined_ms),
"permission": {"canPublish": True, "canPublishSources": ["MICROPHONE"]}}
class TestOverLimitRemovals(unittest.TestCase):
"""[matrix #25] connect-time enforcement of the voice limit."""
def test_first_sweep_records_and_removes_nobody(self):
parts = [_p("@a:x:DEV1", 1), _p("@b:x:DEV1", 2)]
removals, seen = guard.over_limit_removals(parts, 1, None)
self.assertEqual(removals, [])
self.assertEqual(seen, {"@a:x", "@b:x"})
def test_a_newcomer_past_the_limit_is_removed_with_all_devices(self):
parts = [_p("@a:x:DEV1", 1), _p("@b:x:DEV1", 5), _p("@b:x:DEV2", 6)]
removals, seen = guard.over_limit_removals(parts, 1, {"@a:x"})
self.assertEqual(sorted(removals), ["@b:x:DEV1", "@b:x:DEV2"])
self.assertEqual(seen, {"@a:x"})
def test_newest_newcomers_go_first(self):
parts = [_p("@a:x:D", 1), _p("@b:x:D", 10), _p("@c:x:D", 20)]
removals, _ = guard.over_limit_removals(parts, 2, {"@a:x"})
self.assertEqual(removals, ["@c:x:D"])
def test_lowering_the_limit_mid_call_kicks_nobody(self):
parts = [_p("@a:x:D", 1), _p("@b:x:D", 2), _p("@c:x:D", 3)]
removals, seen = guard.over_limit_removals(parts, 1, {"@a:x", "@b:x", "@c:x"})
self.assertEqual(removals, [])
self.assertEqual(seen, {"@a:x", "@b:x", "@c:x"})
def test_extra_devices_of_a_present_user_are_not_newcomers(self):
parts = [_p("@a:x:DEV1", 1), _p("@a:x:DEV2", 9)]
removals, _ = guard.over_limit_removals(parts, 1, {"@a:x"})
self.assertEqual(removals, [])
def test_no_limit_removes_nobody(self):
parts = [_p("@a:x:D", 1), _p("@b:x:D", 2)]
self.assertEqual(guard.over_limit_removals(parts, 0, {"@a:x"})[0], [])
def test_joined_at_seconds_are_understood(self):
parts = [{"identity": "@a:x:D", "joined_at": "100"}, {"identity": "@b:x:D", "joined_at": "200"},
{"identity": "@c:x:D", "joinedAt": "150"}]
removals, _ = guard.over_limit_removals(parts, 1, set())
self.assertEqual(sorted(removals), ["@b:x:D", "@c:x:D"])
class TestReconcileRoomLimit(unittest.TestCase):
def setUp(self):
self._orig = (guard.room_state, guard.livekit_list_participants,
guard.livekit_update_participant, guard.livekit_remove_participant)
self.removed, self.updates = [], []
guard.livekit_remove_participant = lambda a, i: self.removed.append(i)
guard.livekit_update_participant = lambda a, i, p: self.updates.append(i)
guard._seen_users.clear()
def tearDown(self):
(guard.room_state, guard.livekit_list_participants,
guard.livekit_update_participant, guard.livekit_remove_participant) = self._orig
guard._seen_users.clear()
def test_a_late_connect_with_an_early_token_is_removed(self):
guard.room_state = lambda rid, max_age=0: {"max_users": 1, "allow_screenshare": True, "allow_camera": True}
guard.livekit_list_participants = lambda a: [_p("@a:x:D", 1)]
guard.reconcile_room("alias", "!room:x") # first sweep: @a is there
self.assertEqual(self.removed, [])
guard.livekit_list_participants = lambda a: [_p("@a:x:D", 1), _p("@late:x:D", 50)]
guard.reconcile_room("alias", "!room:x")
self.assertEqual(self.removed, ["@late:x:D"])
self.assertEqual(self.updates, []) # nothing forbidden in this room
def test_unlimited_unrestricted_room_is_not_listed(self):
guard.room_state = lambda rid, max_age=0: {"max_users": 0, "allow_screenshare": True, "allow_camera": True}
guard.livekit_list_participants = lambda a: (_ for _ in ()).throw(AssertionError("listed"))
guard.reconcile_room("alias", "!room:x")
self.assertEqual(self.removed, [])
+120 -7
View File
@@ -145,6 +145,41 @@ def should_block(limit: int, present_users: set, requester: str) -> bool:
return len(present_users) >= limit return len(present_users) >= limit
# [matrix #25] Tokens issued but not yet connected weren't counted, so two
# users pressing Join together could both pass a 1-person limit. Each admitted
# user holds a short reservation (counted like a present user) until LiveKit
# lists them or it expires; checks for a room are serialised with a lock.
RESERVATION_TTL = 30.0
class SlotReservations:
"""Per-room reservations for admitted-but-not-yet-connected users."""
def __init__(self, ttl: float = RESERVATION_TTL):
self.ttl = ttl
self._by_room = {} # alias -> {user: expiry}
self._guard = threading.Lock()
self._room_locks = {}
def room_lock(self, alias: str) -> threading.Lock:
with self._guard:
return self._room_locks.setdefault(alias, threading.Lock())
def reserved(self, alias: str, present: set, now: float) -> set:
"""Unexpired reservations; drops expired ones and users now present."""
room = self._by_room.get(alias, {})
for user in [u for u, exp in room.items() if exp <= now or u in present]:
del room[user]
return set(room)
def reserve(self, alias: str, user: str, now: float) -> None:
if user:
self._by_room.setdefault(alias, {})[user] = now + self.ttl
RESERVATIONS = SlotReservations()
def allowed_sources(policy: dict): def allowed_sources(policy: dict):
"""Pure decision: the narrowed `canPublishSources` list, or None. """Pure decision: the narrowed `canPublishSources` list, or None.
@@ -370,6 +405,7 @@ def remember_room(alias: str, room_id: str) -> None:
def forget_room(alias: str) -> None: def forget_room(alias: str) -> None:
with _alias_lock: with _alias_lock:
_alias_to_room.pop(alias, None) _alias_to_room.pop(alias, None)
_seen_users.pop(alias, None)
def forbidden_sources(policy: dict) -> set: def forbidden_sources(policy: dict) -> set:
@@ -416,6 +452,65 @@ def normalize_permission(perm: dict) -> dict:
return out return out
def _joined_at(participant: dict) -> float:
"""When this device joined, in ms (ParticipantInfo joined_at_ms, else s)."""
for key in ("joined_at_ms", "joinedAtMs"):
if participant.get(key) not in (None, "", 0, "0"):
return float(participant[key])
for key in ("joined_at", "joinedAt"):
if participant.get(key) not in (None, "", 0, "0"):
return float(participant[key]) * 1000.0
return 0.0
def over_limit_removals(participants: list, limit: int, seen):
"""[matrix #25] Pure: which devices to remove so the room is back within
its limit, and the set of users now known to be in the room.
Only users who ARRIVED since the previous sweep (not in `seen`) can be
removed, newest first: a token fetched while there was space can be used
to connect up to an hour later, past the limit, but lowering the limit
mid-call must not kick people already there. Users are distinct Matrix
users (matrix_user), so a person's extra devices never count twice, and
removing a user removes all their devices. `seen is None` means the room
hasn't been swept yet: record it, remove nobody.
"""
first_join = {}
identities = {}
for p in participants:
identity = p.get("identity") or ""
user = matrix_user(identity)
if not user:
continue
identities.setdefault(user, []).append(identity)
joined = _joined_at(p)
first_join[user] = min(first_join.get(user, joined), joined)
users = set(first_join)
if seen is None or limit <= 0 or len(users) <= limit:
return [], users
newcomers = sorted(
(u for u in users if u not in seen),
key=lambda u: (first_join[u], u),
reverse=True,
)
removed = newcomers[: len(users) - limit]
return [i for u in removed for i in identities[u]], users - set(removed)
def livekit_remove_participant(alias: str, identity: str) -> None:
"""Disconnect a participant (livekit RemoveParticipant). Already gone ->
Twirp not_found (HTTP 404), which is benign."""
try:
_livekit_admin_post(alias, "RemoveParticipant", {"room": alias, "identity": identity})
except urllib.error.HTTPError as exc:
if exc.code != 404:
log(f"remove_participant {identity} in {alias} -> HTTP {exc.code}")
# alias -> set of users present at the previous sweep (see over_limit_removals)
_seen_users = {}
def reconcile_participant(alias: str, participant: dict, forbidden: set) -> bool: def reconcile_participant(alias: str, participant: dict, forbidden: set) -> bool:
"""Enforce the forbidden-source policy on one live participant. Returns True """Enforce the forbidden-source policy on one live participant. Returns True
if an UpdateParticipant call was issued.""" if an UpdateParticipant call was issued."""
@@ -448,8 +543,10 @@ def reconcile_room(alias: str, room_id: str) -> None:
# Read the policy fresh-ish so a flip is picked up within ~one interval. # Read the policy fresh-ish so a flip is picked up within ~one interval.
policy = room_state(room_id, max_age=RECONCILE_INTERVAL) policy = room_state(room_id, max_age=RECONCILE_INTERVAL)
forbidden = forbidden_sources(policy) forbidden = forbidden_sources(policy)
if not forbidden: limit = int(policy.get("max_users", 0) or 0)
return # nothing restricted; leave permissions untouched if not forbidden and limit <= 0:
_seen_users.pop(alias, None)
return # nothing restricted; leave the room alone
try: try:
participants = livekit_list_participants(alias) participants = livekit_list_participants(alias)
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
@@ -466,7 +563,18 @@ def reconcile_room(alias: str, room_id: str) -> None:
# empty_timeout). Don't forget on an empty read — only a 404 (room gone) # 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. # prunes — so a transient empty/race can't drop mid-call enforcement.
return return
# [matrix #25] Connect-time limit: the token check alone can't stop a token
# fetched earlier (valid for an hour) from being used once the room is full.
removals, now_seen = over_limit_removals(participants, limit, _seen_users.get(alias))
_seen_users[alias] = now_seen
for identity in removals:
livekit_remove_participant(alias, identity)
log(f"removed {identity} from {alias}: over the voice limit of {limit}")
if not forbidden:
return
for p in participants: for p in participants:
if p.get("identity") in removals:
continue
try: try:
reconcile_participant(alias, p, forbidden) reconcile_participant(alias, p, forbidden)
except Exception as exc: except Exception as exc:
@@ -618,11 +726,16 @@ class Handler(BaseHTTPRequestHandler):
# LiveKit-admin outage here cannot skip the publish-source policy below. # LiveKit-admin outage here cannot skip the publish-source policy below.
if limit > 0 and alias: if limit > 0 and alias:
try: try:
present = livekit_present_users(alias) with RESERVATIONS.room_lock(alias):
if should_block(limit, present, requester): present = livekit_present_users(alias)
log(f"blocked {requester or '?'} from {room_id}: {len(present)}/{limit}") now = time.monotonic()
self._send_blocked() counted = present | RESERVATIONS.reserved(alias, present, now)
return if should_block(limit, counted, requester):
log(f"blocked {requester or '?'} from {room_id}: {len(counted)}/{limit}")
self._send_blocked()
return
if requester not in present:
RESERVATIONS.reserve(alias, requester, now)
except Exception as exc: except Exception as exc:
# Fail open on the limit only; still apply the policy below. # Fail open on the limit only; still apply the policy below.
log(f"limit check error for {room_id}: {exc}") log(f"limit check error for {room_id}: {exc}")