Compare commits

..
Author SHA1 Message Date
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
5 changed files with 184 additions and 5 deletions
+1
View File
@@ -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.
- **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`.
- **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).
> `LIVEKIT_KEY`/`LIVEKIT_SECRET` live in `/etc/matrix-deploy.env` (0600, not in git). The same pair must
+12 -3
View File
@@ -164,9 +164,18 @@ echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploying to $WEBROOT..."
# 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.
rsync -a --delete --exclude config.json --exclude assets/ dist/ "$WEBROOT/"
rsync -a dist/assets/ "$WEBROOT/assets/"
find "$WEBROOT/assets" -type f -mtime +14 -delete
# 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
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ===== Deploy complete ($VITE_APP_VERSION) ====="
+18
View File
@@ -7,6 +7,17 @@ map $uri $lotus_shell_cache_control {
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 {
listen 80;
listen [::]:80;
@@ -23,6 +34,8 @@ server {
server_tokens off;
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_conn chat_conn 25;
index index.html;
@@ -113,6 +126,9 @@ server {
# 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; }
@@ -151,6 +167,8 @@ server {
root /var/www/html;
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_conn chat_conn 25;
+78
View File
@@ -417,3 +417,81 @@ class SlotReservationTests(unittest.TestCase):
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, [])
+75 -2
View File
@@ -405,6 +405,7 @@ def remember_room(alias: str, room_id: str) -> None:
def forget_room(alias: str) -> None:
with _alias_lock:
_alias_to_room.pop(alias, None)
_seen_users.pop(alias, None)
def forbidden_sources(policy: dict) -> set:
@@ -451,6 +452,65 @@ def normalize_permission(perm: dict) -> dict:
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:
"""Enforce the forbidden-source policy on one live participant. Returns True
if an UpdateParticipant call was issued."""
@@ -483,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.
policy = room_state(room_id, max_age=RECONCILE_INTERVAL)
forbidden = forbidden_sources(policy)
if not forbidden:
return # nothing restricted; leave permissions untouched
limit = int(policy.get("max_users", 0) or 0)
if not forbidden and limit <= 0:
_seen_users.pop(alias, None)
return # nothing restricted; leave the room alone
try:
participants = livekit_list_participants(alias)
except urllib.error.HTTPError as exc:
@@ -501,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)
# prunes — so a transient empty/race can't drop mid-call enforcement.
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:
if p.get("identity") in removals:
continue
try:
reconcile_participant(alias, p, forbidden)
except Exception as exc: