dev: one-command local calls stack (LiveKit + JWT issuer + voice-limit guard + https well-known) and a seeded voice room
CI / Build & Quality Checks (push) Successful in 1m42s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 2m22s
CI / Build & Quality Checks (push) Successful in 1m42s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 2m22s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -19,6 +19,8 @@ python3 scripts/dev-seed.py 400 # alice + bob, "Busy Room": 400 messages, an
|
|||||||
npm start # Vite on :5173
|
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=<tone.wav>` 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.
|
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)
|
## Automated coverage map — what the unit tests already pin (2026-07)
|
||||||
|
|||||||
@@ -4,9 +4,19 @@
|
|||||||
# SQLite, media served. Everything lives in .dev-homeserver/ (gitignored).
|
# SQLite, media served. Everything lives in .dev-homeserver/ (gitignored).
|
||||||
#
|
#
|
||||||
# scripts/dev-homeserver.sh start # install (first run) + start on :8008
|
# 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 stop
|
||||||
# scripts/dev-homeserver.sh reset # wipe the database and media
|
# 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/
|
# Then `npm start` and log in at http://127.0.0.1:5173/login/http%3A%2F%2Flocalhost%3A8008/
|
||||||
# as alice / password123.
|
# as alice / password123.
|
||||||
@@ -32,7 +42,7 @@ install() {
|
|||||||
python3 - "$CFG" <<'PY'
|
python3 - "$CFG" <<'PY'
|
||||||
import sys, re
|
import sys, re
|
||||||
p = sys.argv[1]; s = open(p).read()
|
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 += """
|
s += """
|
||||||
enable_registration: true
|
enable_registration: true
|
||||||
enable_registration_without_verification: 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 previews (embed facades use the homeserver's cached thumbnail)
|
||||||
url_preview_enabled: true
|
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']
|
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
|
max_event_delay_duration: 24h
|
||||||
experimental_features:
|
experimental_features:
|
||||||
msc4140_enabled: true
|
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)
|
open(p, "w").write(s)
|
||||||
PY
|
PY
|
||||||
@@ -72,10 +88,50 @@ start() {
|
|||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
if [ -f "$PIDFILE" ]; then kill "$(cat "$PIDFILE")" 2>/dev/null || true; rm -f "$PIDFILE"; echo stopped; fi
|
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
|
case "${1:-}" in
|
||||||
start) start ;;
|
start) start ;;
|
||||||
|
calls) calls ;;
|
||||||
stop) stop ;;
|
stop) stop ;;
|
||||||
reset) stop; rm -f "$DIR"/homeserver.db* ; rm -rf "$DIR/media_store"; echo "database wiped"; ;;
|
reset) stop; rm -f "$DIR"/homeserver.db* ; rm -rf "$DIR/media_store"; echo "database wiped"; ;;
|
||||||
*) echo "usage: $0 start|stop|reset" >&2; exit 2 ;;
|
*) echo "usage: $0 start|stop|reset" >&2; exit 2 ;;
|
||||||
|
|||||||
@@ -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 "<user_id>:<device_id>" 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()
|
||||||
+24
-5
@@ -1,11 +1,15 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Seed the local dev homeserver (scripts/dev-homeserver.sh) with two users
|
"""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
|
a busy unencrypted room and a voice room: alice + bob, "Busy Room" (N
|
||||||
every 10th message. Idempotent for the users; every run creates a new room.
|
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]
|
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"
|
HS = "http://127.0.0.1:8008"
|
||||||
PASSWORD = "password123"
|
PASSWORD = "password123"
|
||||||
@@ -56,7 +60,22 @@ def main():
|
|||||||
content = {"msgtype": "m.text", "body": f"message #{i}"}
|
content = {"msgtype": "m.text", "body": f"message #{i}"}
|
||||||
txn += 1
|
txn += 1
|
||||||
req("PUT", f"/_matrix/client/v3/rooms/{urllib.parse.quote(room)}/send/m.room.message/{txn}", content, tok)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user