From da2d7a7d4f2b128481e9b6833d61bf3c773cf1e8 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 18 Sep 2026 22:29:47 -0400 Subject: [PATCH] dev: one-command local calls stack (LiveKit + JWT issuer + voice-limit guard + https well-known) and a seeded voice room Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- LOTUS_TESTING.md | 2 ++ scripts/dev-homeserver.sh | 62 +++++++++++++++++++++++++++++++++++++-- scripts/dev-lk-jwt.py | 52 ++++++++++++++++++++++++++++++++ scripts/dev-seed.py | 29 ++++++++++++++---- scripts/dev-wellknown.py | 15 ++++++++++ 5 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 scripts/dev-lk-jwt.py create mode 100644 scripts/dev-wellknown.py diff --git a/LOTUS_TESTING.md b/LOTUS_TESTING.md index b59a7abd2..2a2ae0f30 100644 --- a/LOTUS_TESTING.md +++ b/LOTUS_TESTING.md @@ -19,6 +19,8 @@ python3 scripts/dev-seed.py 400 # alice + bob, "Busy Room": 400 messages, an npm start # Vite on :5173 ``` +**Calls too:** `scripts/dev-homeserver.sh calls` adds a LiveKit SFU, a JWT issuer, the real `voice-limit-guard` from the `matrix` checkout and an https well-known — real two-party calls in headless Chromium with fake mic/camera (`--use-fake-device-for-media-stream`, `--use-file-for-fake-audio-capture=` to trigger speaking detection, `--auto-select-desktop-capture-source="Entire screen"` for screenshare; `ignoreHTTPSErrors: true`). This is how #29, #161, #173/#174 and the guard's live-revoke bug were found and fixed on 2026-09-18. + Log in at `http://127.0.0.1:5173/login/http%3A%2F%2Flocalhost%3A8008/` as `alice` / `password123` (bob is the second participant; both can also be driven over the client API with their tokens). Playwright is installed (`npm run test:e2e:install`), so a scripted reproduction is `node` + `chromium.launch()` against `:5173` — this is how Gitea #163 was reproduced and its fix verified in both plain and encrypted rooms. `scripts/dev-homeserver.sh reset` wipes the database; `stop` shuts it down. ## Automated coverage map — what the unit tests already pin (2026-07) diff --git a/scripts/dev-homeserver.sh b/scripts/dev-homeserver.sh index 1f37b9419..1eb0e3150 100755 --- a/scripts/dev-homeserver.sh +++ b/scripts/dev-homeserver.sh @@ -4,9 +4,19 @@ # SQLite, media served. Everything lives in .dev-homeserver/ (gitignored). # # scripts/dev-homeserver.sh start # install (first run) + start on :8008 +# scripts/dev-homeserver.sh calls # + LiveKit SFU, JWT issuer, voice-limit guard, https well-known # scripts/dev-homeserver.sh stop # scripts/dev-homeserver.sh reset # wipe the database and media -# python3 scripts/dev-seed.py 400 # alice/bob + "Busy Room" with images +# python3 scripts/dev-seed.py 400 # alice/bob + "Busy Room" with images (+ "Voice Lounge" call room) +# +# Calls: `calls` downloads livekit-server (v1.13.7 release binary) into +# .dev-homeserver/, runs it on :7880 with key devkey, a minimal lk-jwt-service +# clone (scripts/dev-lk-jwt.py, :8071), the real voice-limit-guard from the +# sibling matrix repo in front of it (:8070, needs ../matrix checked out and an +# admin user — dev-seed makes alice one), and a self-signed https server on +# :443 serving /.well-known/matrix/client with the LiveKit focus (the client +# autodiscovers the server NAME, not :8008). Drive it with Playwright using +# --use-fake-device-for-media-stream and ignoreHTTPSErrors. # # Then `npm start` and log in at http://127.0.0.1:5173/login/http%3A%2F%2Flocalhost%3A8008/ # as alice / password123. @@ -32,7 +42,7 @@ install() { python3 - "$CFG" <<'PY' import sys, re p = sys.argv[1]; s = open(p).read() -s = s.replace(" - client\n", " - client\n - media\n", 1) +s = s.replace(" - client\n", " - client\n - media\n - federation\n", 1) s += """ enable_registration: true enable_registration_without_verification: true @@ -46,10 +56,16 @@ suppress_key_server_warning: true # URL previews (embed facades use the homeserver's cached thumbnail) url_preview_enabled: true url_preview_ip_range_blacklist: ['127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '100.64.0.0/10', '169.254.0.0/16', '::1/128', 'fe80::/10', 'fc00::/7'] -# scheduled messages (MSC4140 delayed events) +# scheduled messages (MSC4140 delayed events) + MatrixRTC transports (MSC4143) + room summaries (MSC3266) max_event_delay_duration: 24h experimental_features: msc4140_enabled: true + msc4143_enabled: true + msc3266_enabled: true +matrix_rtc: + transports: + - type: livekit + livekit_service_url: http://127.0.0.1:8070 """ open(p, "w").write(s) PY @@ -72,10 +88,50 @@ start() { stop() { if [ -f "$PIDFILE" ]; then kill "$(cat "$PIDFILE")" 2>/dev/null || true; rm -f "$PIDFILE"; echo stopped; fi + for f in "$DIR"/calls-*.pid; do [ -f "$f" ] && { kill "$(cat "$f")" 2>/dev/null || true; rm -f "$f"; }; done +} + +calls() { + start + local LK="$DIR/livekit-server" + if [ ! -x "$LK" ]; then + curl -sL "https://github.com/livekit/livekit/releases/download/v1.13.7/livekit_1.13.7_linux_amd64.tar.gz" | tar xz -C "$DIR" livekit-server + fi + cat > "$DIR/livekit.yaml" <<'YAML' +port: 7880 +bind_addresses: ["127.0.0.1"] +rtc: + tcp_port: 7881 + port_range_start: 50000 + port_range_end: 50100 + use_external_ip: false + node_ip: 127.0.0.1 +keys: + devkey: devsecretdevsecretdevsecretdevsecret +room: + auto_create: true +YAML + local bg + bg() { local name="$1"; shift; ( setsid nohup "$@" > "$DIR/calls-$name.log" 2>&1 < /dev/null & echo $! > "$DIR/calls-$name.pid" ); } + bg livekit "$LK" --config "$DIR/livekit.yaml" + bg jwt env PORT=8071 LIVEKIT_KEY=devkey LIVEKIT_SECRET=devsecretdevsecretdevsecretdevsecret LIVEKIT_URL=ws://127.0.0.1:7880 SYNAPSE_API=http://127.0.0.1:8008 python3 "$ROOT/scripts/dev-lk-jwt.py" + if [ ! -f "$DIR/wk.crt" ]; then openssl req -x509 -newkey rsa:2048 -nodes -keyout "$DIR/wk.key" -out "$DIR/wk.crt" -days 365 -subj "/CN=localhost" 2>/dev/null; fi + bg wellknown env CERT="$DIR/wk.crt" KEY="$DIR/wk.key" python3 "$ROOT/scripts/dev-wellknown.py" + local GUARD="$ROOT/../matrix/livekit/voice-limit-guard.py" + local TOKEN_FILE="$DIR/admin.token" + if [ -f "$GUARD" ] && [ -f "$TOKEN_FILE" ]; then + bg guard env GUARD_BIND_HOST=127.0.0.1 GUARD_BIND_PORT=8070 GUARD_UPSTREAM=http://127.0.0.1:8071 LIVEKIT_API=http://127.0.0.1:7880 LIVEKIT_KEY=devkey LIVEKIT_SECRET=devsecretdevsecretdevsecretdevsecret SYNAPSE_API=http://127.0.0.1:8008 MATRIX_TOKEN="$(cat "$TOKEN_FILE")" python3 "$GUARD" + else + echo "voice-limit-guard not started (need ../matrix checkout and $TOKEN_FILE from dev-seed.py); pointing the focus straight at the issuer" + bg guard env PORT=8070 LIVEKIT_KEY=devkey LIVEKIT_SECRET=devsecretdevsecretdevsecretdevsecret LIVEKIT_URL=ws://127.0.0.1:7880 SYNAPSE_API=http://127.0.0.1:8008 python3 "$ROOT/scripts/dev-lk-jwt.py" + fi + sleep 2 + echo "calls stack up: livekit :7880, jwt :8071, guard :8070, well-known https://localhost/.well-known/matrix/client" } case "${1:-}" in start) start ;; + calls) calls ;; stop) stop ;; reset) stop; rm -f "$DIR"/homeserver.db* ; rm -rf "$DIR/media_store"; echo "database wiped"; ;; *) echo "usage: $0 start|stop|reset" >&2; exit 2 ;; diff --git a/scripts/dev-lk-jwt.py b/scripts/dev-lk-jwt.py new file mode 100644 index 000000000..bc82170ae --- /dev/null +++ b/scripts/dev-lk-jwt.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Minimal lk-jwt-service clone for local testing. +POST /sfu/get {room, openid_token{access_token, matrix_server_name}, device_id} + -> validates the OpenID token against Synapse (federation openid/userinfo), + mints a LiveKit JWT for identity ":" and returns {url, jwt}. +""" +import base64, hashlib, hmac, json, os, time, urllib.parse, urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +PORT = int(os.environ.get("PORT", "8071")) +LK_KEY = os.environ.get("LIVEKIT_KEY", "devkey") +LK_SECRET = os.environ.get("LIVEKIT_SECRET", "devsecretdevsecretdevsecretdevsecret") +LK_URL = os.environ.get("LIVEKIT_URL", "ws://127.0.0.1:7880") +SYNAPSE = os.environ.get("SYNAPSE_API", "http://127.0.0.1:8008") + +def b64(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode() +def jwt(payload): + h = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()); p = b64(json.dumps(payload).encode()) + sig = b64(hmac.new(LK_SECRET.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest()) + return f"{h}.{p}.{sig}" + +def userinfo(access_token): + q = urllib.parse.urlencode({"access_token": access_token}) + with urllib.request.urlopen(f"{SYNAPSE}/_matrix/federation/v1/openid/userinfo?{q}", timeout=5) as r: + return json.load(r)["sub"] + +class H(BaseHTTPRequestHandler): + def _cors(self): + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Headers", "*") + self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") + def do_OPTIONS(self): + self.send_response(204); self._cors(); self.end_headers() + def do_POST(self): + n = int(self.headers.get("Content-Length") or 0) + data = json.loads(self.rfile.read(n) or b"{}") + room = data.get("room") or data.get("room_id") or "" + oid = data.get("openid_token") or {} + try: + user = userinfo(oid.get("access_token", "")) + except Exception as e: # noqa + self.send_response(401); self._cors(); self.end_headers(); self.wfile.write(json.dumps({"errcode": "M_LOOKUP_FAILED", "error": str(e)}).encode()); return + identity = f"{user}:{data.get('device_id', '')}" + now = int(time.time()) + token = jwt({"iss": LK_KEY, "sub": identity, "name": user, "nbf": now - 10, "exp": now + 3600, + "video": {"room": room, "roomJoin": True, "roomCreate": True, "canPublish": True, "canSubscribe": True, "canPublishData": True}}) + body = json.dumps({"url": LK_URL, "jwt": token}).encode() + self.send_response(200); self._cors(); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body) + print(f"issued {identity} room={room}", flush=True) + def log_message(self, *a): pass + +ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever() diff --git a/scripts/dev-seed.py b/scripts/dev-seed.py index abfc34f96..ffe1735e0 100755 --- a/scripts/dev-seed.py +++ b/scripts/dev-seed.py @@ -1,11 +1,15 @@ #!/usr/bin/env python3 -"""Seed the local dev homeserver (scripts/dev-homeserver.sh) with two users -and a busy unencrypted room: alice + bob, "Busy Room", N messages, one image -every 10th message. Idempotent for the users; every run creates a new room. +"""Seed the local dev homeserver (scripts/dev-homeserver.sh) with two users, +a busy unencrypted room and a voice room: alice + bob, "Busy Room" (N +messages, one image every 10th), "Voice Lounge" (org.matrix.msc3417.call with +the call-member power level Lotus applies). Makes alice a server admin (the +voice-limit guard reads room state through the admin API) and writes her +token to .dev-homeserver/admin.token for `dev-homeserver.sh calls`. +Idempotent for the users; every run creates new rooms. python3 scripts/dev-seed.py [N=400] """ -import json, struct, sys, time, urllib.error, urllib.parse, urllib.request, zlib +import json, os, sqlite3, struct, sys, time, urllib.error, urllib.parse, urllib.request, zlib HS = "http://127.0.0.1:8008" PASSWORD = "password123" @@ -56,7 +60,22 @@ def main(): content = {"msgtype": "m.text", "body": f"message #{i}"} txn += 1 req("PUT", f"/_matrix/client/v3/rooms/{urllib.parse.quote(room)}/send/m.room.message/{txn}", content, tok) - print(json.dumps({"room": room, "alice": alice["user_id"], "bob": bob["user_id"], "count": n})) + voice = req("POST", "/_matrix/client/v3/createRoom", { + "name": "Voice Lounge", "preset": "public_chat", + "creation_content": {"type": "org.matrix.msc3417.call"}, + "initial_state": [{"type": "org.matrix.msc3401.call", "state_key": "", "content": {}}], + "power_level_content_override": {"events": {"org.matrix.msc3401.call.member": 0}}, + }, ta)["room_id"] + req("POST", f"/_matrix/client/v3/join/{urllib.parse.quote(voice)}", {}, tb) + # admin flag for the guard (takes effect for tokens issued after a Synapse restart-free + # cache miss; the guard only needs it for the admin state API) + here = os.path.dirname(os.path.abspath(__file__)) + db = os.path.join(here, "..", ".dev-homeserver", "homeserver.db") + if os.path.exists(db): + c = sqlite3.connect(db); c.execute("UPDATE users SET admin=1 WHERE name=?", (alice["user_id"],)); c.commit(); c.close() + with open(os.path.join(here, "..", ".dev-homeserver", "admin.token"), "w") as f: + f.write(ta) + print(json.dumps({"room": room, "voice_room": voice, "alice": alice["user_id"], "bob": bob["user_id"], "count": n})) if __name__ == "__main__": diff --git a/scripts/dev-wellknown.py b/scripts/dev-wellknown.py new file mode 100644 index 000000000..898397ba2 --- /dev/null +++ b/scripts/dev-wellknown.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Self-signed https server on :443 serving the client well-known with the +LiveKit focus for the local calls stack (see scripts/dev-homeserver.sh calls).""" +import http.server, os, ssl, json +BODY = json.dumps({"m.homeserver": {"base_url": "http://localhost:8008"}, "org.matrix.msc4143.rtc_foci": [{"type": "livekit", "livekit_service_url": "http://127.0.0.1:8070"}]}).encode() +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path.startswith('/.well-known/matrix/client'): + self.send_response(200); self.send_header('Content-Type','application/json'); self.send_header('Access-Control-Allow-Origin','*'); self.send_header('Content-Length',str(len(BODY))); self.end_headers(); self.wfile.write(BODY) + else: + self.send_response(404); self.end_headers() + def log_message(self,*a): pass +srv = http.server.ThreadingHTTPServer(('127.0.0.1', 443), H) +ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); ctx.load_cert_chain(os.environ.get('CERT', 'wk.crt'), os.environ.get('KEY', 'wk.key')); srv.socket = ctx.wrap_socket(srv.socket, server_side=True) +srv.serve_forever()