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
This commit is contained in:
Lotus CI
2026-10-19 23:30:15 -04:00
co-authored by Claude Opus 5.5
parent f7ce35c636
commit d3f389cab1
3 changed files with 154 additions and 2 deletions
+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: