Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96d1778bb4 | ||
|
|
0aa34123a8 | ||
|
|
070a94d0d7 | ||
|
|
9b0cbc9db1 | ||
|
|
fa0351084b | ||
|
|
61ed3c64e3 | ||
|
|
be0ff3b854 | ||
|
|
93078858c1 | ||
|
|
076340227f | ||
|
|
bf770fed83 | ||
|
|
b1335a830f | ||
|
|
cf42d0aab0 | ||
|
|
6c50f51feb | ||
|
|
cabc393b6b | ||
|
|
72a3e3ba17 | ||
|
|
162d8b5a80 | ||
|
|
77e719183a |
@@ -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`
|
||||||
@@ -84,6 +84,7 @@ matrix/
|
|||||||
- Service: `draupnir.service`
|
- Service: `draupnir.service`
|
||||||
- Management room: `#management:matrix.lotusguild.org` (`!mEvR5fe3jMmzwd-FwNygD72OY_yu8H3UP_N-57oK7MI`)
|
- Management room: `#management:matrix.lotusguild.org` (`!mEvR5fe3jMmzwd-FwNygD72OY_yu8H3UP_N-57oK7MI`)
|
||||||
- Bot account: `@draupnir:matrix.lotusguild.org` (power level 100 in all protected rooms and the Lotus Guild space)
|
- Bot account: `@draupnir:matrix.lotusguild.org` (power level 100 in all protected rooms and the Lotus Guild space)
|
||||||
|
- Encryption: off (`experimentalRustCrypto: false`), so Draupnir cannot read commands sent in the encrypted management room; send them unencrypted for now. Turning it on needs a device-bound access token first: with the current token, 2.9.0 exits with "Encryption not possible: server not revealing device ID" (tried and reverted 2026-10-16, #26 / #28).
|
||||||
- Subscribed ban lists: `#community-moderation-effort-bl:neko.dev`, `#matrix-org-coc-bl:matrix.org`
|
- Subscribed ban lists: `#community-moderation-effort-bl:neko.dev`, `#matrix-org-coc-bl:matrix.org`
|
||||||
- Rebuild: `NODE_OPTIONS="--max_old_space_size=6144" npm run build`
|
- Rebuild: `NODE_OPTIONS="--max_old_space_size=6144" npm run build`
|
||||||
- Healthz endpoint: `http://10.10.10.24:8081/healthz` (200 = healthy, 418 = disconnected)
|
- Healthz endpoint: `http://10.10.10.24:8081/healthz` (200 = healthy, 418 = disconnected)
|
||||||
@@ -237,7 +238,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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -289,7 +292,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 |
|
||||||
@@ -798,7 +801,7 @@ Leaving a space does **not** leave its child rooms; they are independent rooms w
|
|||||||
- [ ] `synchronous_commit = off` — **deliberately NOT applied.** Would be a real write-latency win, but this cluster hard-resets every few hours (HA self-fences on corosync quorum loss + the large1 DIMM), so it would drop up to ~600ms of committed messages *per reset* rather than once in a blue moon. Revisit once the cluster is stable.
|
- [ ] `synchronous_commit = off` — **deliberately NOT applied.** Would be a real write-latency win, but this cluster hard-resets every few hours (HA self-fences on corosync quorum loss + the large1 DIMM), so it would drop up to ~600ms of committed messages *per reset* rather than once in a blue moon. Revisit once the cluster is stable.
|
||||||
|
|
||||||
### Auth & SSO
|
### Auth & SSO
|
||||||
- [x] Token-based registration
|
- [x] Open registration disabled (`enable_registration: false`, 2026-10-16): accounts come from LLDAP and are created on first sign-in through Lotus SSO (Authelia OIDC, which keeps its own registration on). The old registration token was deleted.
|
||||||
- [x] SSO/OIDC via Authelia
|
- [x] SSO/OIDC via Authelia
|
||||||
- [x] `allow_existing_users: true` for linking accounts to SSO
|
- [x] `allow_existing_users: true` for linking accounts to SSO
|
||||||
- [x] Password auth alongside SSO
|
- [x] Password auth alongside SSO
|
||||||
|
|||||||
+10
-3
@@ -8,9 +8,16 @@
|
|||||||
"allowCustomHomeservers": false,
|
"allowCustomHomeservers": false,
|
||||||
"featuredCommunities": {
|
"featuredCommunities": {
|
||||||
"openAsDefault": false,
|
"openAsDefault": false,
|
||||||
"spaces": [],
|
"spaces": [
|
||||||
"rooms": [],
|
"!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc",
|
||||||
"servers": []
|
"#homelab:codestorm.net"
|
||||||
|
],
|
||||||
|
"rooms": [
|
||||||
|
"#jellyfin:matrix.org"
|
||||||
|
],
|
||||||
|
"servers": [
|
||||||
|
"matrixrooms.info"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"hashRouter": {
|
"hashRouter": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|||||||
@@ -160,7 +160,13 @@ 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.
|
||||||
|
rsync -a --delete --exclude config.json --exclude assets/ dist/ "$WEBROOT/"
|
||||||
|
rsync -a dist/assets/ "$WEBROOT/assets/"
|
||||||
|
find "$WEBROOT/assets" -type f -mtime +14 -delete
|
||||||
|
|
||||||
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) ====="
|
||||||
|
|
||||||
|
|||||||
@@ -12,4 +12,4 @@ add_header X-XSS-Protection "1; mode=block" always;
|
|||||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||||
add_header Permissions-Policy 'accelerometer=(), autoplay=(self "https://call.chat.lotusguild.org"), camera=(self "https://call.chat.lotusguild.org"), display-capture=(self "https://call.chat.lotusguild.org"), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self "https://call.chat.lotusguild.org"), midi=(), payment=(), usb=()' always;
|
add_header Permissions-Policy 'accelerometer=(), autoplay=(self "https://call.chat.lotusguild.org"), camera=(self "https://call.chat.lotusguild.org"), display-capture=(self "https://call.chat.lotusguild.org"), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self "https://call.chat.lotusguild.org"), midi=(), payment=(), usb=()' always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; 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; font-src 'self' data:; connect-src 'self' https://matrix.lotusguild.org wss://matrix.lotusguild.org https://isitup.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:; worker-src 'self' blob:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; 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; font-src 'self' data:; connect-src 'self' https://matrix.lotusguild.org wss://matrix.lotusguild.org https://isitup.lotusguild.org https://matrix.org https://*.matrix.org https://mozilla.org https://mozilla.modular.im https://chat.mozilla.org https://www.mozilla.org https://vector.im https://api.giphy.com https://*.giphy.com https://www.tiktok.com wss:; media-src 'self' https: blob:; frame-src 'self' https:; worker-src 'self' blob:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
# [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 "";
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
@@ -99,6 +108,9 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -389,3 +389,31 @@ 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"))
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -618,11 +653,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}")
|
||||||
|
|||||||
Reference in New Issue
Block a user