recipe-maintainer: public snapshot (secrets + deployment plans removed, single commit)

Sanitized single-commit public mirror of recipe-maintainer.
- Removed test-ssh/.testenv (live creds); added test-ssh/.testenv.example placeholders.
- Removed plans/ and planned-updates/ (deployment-planning docs) so no client/
  deployment domains appear in the public repo.
- All other secret stores were already gitignored.
- docs.coopcloud.tech retained as a submodule (public upstream).
This commit is contained in:
2026-06-16 20:18:24 +00:00
commit f283a371bb
253 changed files with 15975 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
name = "lasuite-meet"
[dependencies]
requires = ["keycloak"]
[sso]
provider = "keycloak"
setup_script = "setup/sso_integration.py"
+46
View File
@@ -0,0 +1,46 @@
# La Suite Meet — First-Time Setup
## Prerequisites
- DNS: `lasuite-meet.<domain_suffix>` must resolve to the server
- DNS: `livekit-meet.<domain_suffix>` must resolve to the server (LiveKit signaling)
- **Keycloak** must be deployed and running (dependency)
- Firewall must allow TCP 7881, UDP 7882 (WebRTC), and UDP 443 (TURN relay)
## Steps
1. **Create the app:**
```bash
abra app new lasuite-meet --server <SERVER> --domain lasuite-meet.<DOMAIN_SUFFIX> --no-input
```
2. **Generate secrets:**
```bash
abra app secret generate lasuite-meet.<DOMAIN_SUFFIX> --all -m --no-input
```
Save output to `recipe-info/testsecrets/lasuite-meet.<DOMAIN_SUFFIX>`.
3. **Deploy:**
```bash
abra app deploy lasuite-meet.<DOMAIN_SUFFIX> --chaos --force --no-input
```
4. **Keycloak SSO integration:**
```bash
python3 recipe-info/lasuite-meet/setup_keycloak_integration.py
```
This creates a `lasuite-meet` realm, OIDC client, and two test users in Keycloak. It also inserts the client secret and updates the env file.
5. **Redeploy with SSO config:**
```bash
abra app deploy lasuite-meet.<DOMAIN_SUFFIX> --chaos --force --no-input
```
6. **Verify:** curl `https://lasuite-meet.<DOMAIN_SUFFIX>` returns HTTP 200.
## Notes
- lasuite-meet has no published versions yet — must use `--chaos` for all commands.
- Credentials are saved to `recipe-info/lasuite-meet/keycloak-test-credentials.<DOMAIN_SUFFIX>.toml`.
- OIDC test users: `testuser` / `testpass123` and `testuser2` / `testpass123`.
- TURN relay requires the server to have a direct public IP (not behind NAT). See test.md for details.
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Setup Keycloak OIDC integration for La Suite Meet.
Creates a Keycloak realm, OIDC client, and two test users, then inserts
the client secret and updates the Meet env file with OIDC settings.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from lib.abra import app_secret_insert
from lib.env import apply_env_overrides, get_abra_env_path, read_env_file
from lib.keycloak import KeycloakAdmin
from lib.models import load_default_instance
from lib.secrets import load_secrets
# Configuration
REALM = "lasuite-meet"
CLIENT_ID = "meet"
TEST_USER = "testuser"
TEST_PASS = "testpass123"
TEST_EMAIL = f"{TEST_USER}@test.example.com"
TEST_USER2 = "testuser2"
TEST_PASS2 = "testpass456"
TEST_EMAIL2 = f"{TEST_USER2}@test.example.com"
def main():
inst = load_default_instance()
meet_domain = inst.default_domain("lasuite-meet")
kc_domain = inst.default_domain("keycloak")
# Get Keycloak admin password from synced secrets
kc_secrets = load_secrets(kc_domain)
kc_admin_pass = kc_secrets["admin_password"]
kc = KeycloakAdmin(f"https://{kc_domain}", "admin", kc_admin_pass)
# Step 1: Create realm
kc.ensure_realm(REALM)
# Step 2: Create OIDC client
_, client_secret = kc.ensure_client(
REALM, CLIENT_ID,
redirect_uris=[f"https://{meet_domain}/*"],
web_origins=[f"https://{meet_domain}"],
)
# Step 3: Create test users
kc.ensure_user(REALM, TEST_USER, TEST_EMAIL, TEST_PASS)
kc.ensure_user(REALM, TEST_USER2, TEST_EMAIL2, TEST_PASS2,
last_name="User Two")
# Step 4: Insert client secret via abra
print("=== Insert OIDC client secret into Meet ===", flush=True)
env_path = get_abra_env_path(inst.server, meet_domain)
env_data = read_env_file(env_path)
current_version = env_data.get("SECRET_OIDC_RPCS_VERSION", "v1")
next_num = int(current_version.lstrip("v")) + 1
next_version = f"v{next_num}"
print(f" Current secret version: {current_version}", flush=True)
print(f" Inserting as: {next_version}", flush=True)
app_secret_insert(meet_domain, "oidc_rpcs", next_version, client_secret)
# Step 5: Update Meet env with OIDC settings
print("=== Update Meet OIDC settings in env file ===", flush=True)
apply_env_overrides(env_path, {
"SECRET_OIDC_RPCS_VERSION": next_version,
"OIDC_REALM": REALM,
"AUTH_DOMAIN": kc_domain,
"OIDC_RP_CLIENT_ID": CLIENT_ID,
})
# Step 6: Write credentials file
script_dir = os.path.dirname(os.path.abspath(__file__))
creds_file = os.path.join(script_dir, f"keycloak-test-credentials.{inst.domain_suffix}.toml")
print(f"=== Write credentials to {creds_file} ===", flush=True)
with open(creds_file, "w") as f:
f.write(f'# Keycloak OIDC credentials for lasuite-meet test instance\n')
f.write(f'#\n')
f.write(f'# Keycloak instance: {kc_domain}\n')
f.write(f'# Realm: {REALM}\n')
f.write(f'# Created by: setup_keycloak_integration.py\n')
f.write(f'\n')
f.write(f'# Keycloak admin (master realm)\n')
f.write(f'kc_admin_user = "admin"\n')
f.write(f'kc_admin_pass = "{kc_admin_pass}"\n')
f.write(f'\n')
f.write(f'# OIDC client\n')
f.write(f'kc_realm = "{REALM}"\n')
f.write(f'kc_client_id = "{CLIENT_ID}"\n')
f.write(f'kc_client_secret = "{client_secret}"\n')
f.write(f'\n')
f.write(f'# Test user 1 (in {REALM} realm)\n')
f.write(f'kc_test_user = "{TEST_USER}"\n')
f.write(f'kc_test_pass = "{TEST_PASS}"\n')
f.write(f'kc_test_email = "{TEST_EMAIL}"\n')
f.write(f'\n')
f.write(f'# Test user 2 (in {REALM} realm)\n')
f.write(f'kc_test_user2 = "{TEST_USER2}"\n')
f.write(f'kc_test_pass2 = "{TEST_PASS2}"\n')
f.write(f'kc_test_email2 = "{TEST_EMAIL2}"\n')
print(f" Written to {creds_file}", flush=True)
print("", flush=True)
print("=== Keycloak integration setup complete ===", flush=True)
print("", flush=True)
print("Next steps:", flush=True)
print(f" 1. Redeploy Meet: abra app deploy {meet_domain} --chaos --force --no-input", flush=True)
print(f" 2. Run OIDC test: python3 recipe-info/lasuite-meet/tests/oidc_login.py", flush=True)
if __name__ == "__main__":
main()
+77
View File
@@ -0,0 +1,77 @@
# La Suite Meet — Test Documentation
## Test instance
- **Domain:** lasuite-meet.<DOMAIN_SUFFIX>
- **LiveKit domain:** livekit-meet.<DOMAIN_SUFFIX>
- **Keycloak:** keycloak.<DOMAIN_SUFFIX> (shared with other lasuite recipes)
## Services
| Service | Health check | Notes |
|---------|-------------|-------|
| app (frontend) | curl http://localhost:8080 | React SPA served by nginx |
| backend | python manage.py check | Django + Gunicorn on port 8000 |
| celery | celery inspect ping | Async task worker |
| db | pg_isready | PostgreSQL 18 |
| redis | redis-cli ping | Cache + Celery broker + LiveKit coordination |
| livekit | N/A (external ports) | WebRTC SFU on 7880 (signaling), 7881 (TCP), 7882 (UDP) |
| web (nginx) | curl http://localhost:8083 | Reverse proxy |
## Automated tests
| Script | What it tests |
|--------|--------------|
| `tests/health_check.py` | HTTP 200 from the main domain |
| `tests/oidc_login.py` | Full OIDC flow: redirect to Keycloak, obtain token, call API |
| `tests/meeting_flow.py` | Two users create, join, and delete a room; verifies LiveKit tokens |
| `tests/webrtc-media.py` | End-to-end WebRTC: TURN/STUN probe, two users publish/receive audio via LiveKit SDK |
### Network requirements for webrtc-media.py
The WebRTC media test requires:
- Python 3 with `livekit` and `requests` packages (`pip install livekit requests`)
- Either direct ICE connectivity (TCP 7881 / UDP 7882) or TURN relay (UDP 443)
- With TURN enabled, clients behind CGNAT/symmetric NAT can connect via relay
## Manual checks
- Visit `https://lasuite-meet.<DOMAIN_SUFFIX>` — should show Meet login page
- Click login — should redirect to Keycloak
- After OIDC login — should be able to create/join a meeting room
- Check LiveKit signaling: `wss://livekit-meet.<DOMAIN_SUFFIX>` should be reachable
## TURN server
TURN is enabled by default via `compose.turn.yml` and `LIVEKIT_TURN_ENABLED=true`. It publishes UDP 443 on the host for TURN relay traffic, improving connectivity for users behind CGNAT/symmetric NAT.
### Verifying TURN
1. Check LiveKit logs for TURN startup:
```bash
ssh <server> "docker service logs <stack>_livekit --since 5m 2>&1 | grep -i turn"
```
2. Verify UDP 443 is listening on the server:
```bash
ssh <server> "ss -ulnp | grep 443"
```
3. Run `webrtc-media.py` — it sends a STUN Binding Request to UDP 443 and verifies a response
4. Check LiveKit logs for `connectionType` to confirm relay vs direct ICE
### Disabling TURN
Remove `compose.turn.yml` from `COMPOSE_FILE` in the app `.env` and set `LIVEKIT_TURN_ENABLED=false`.
### TURN and servers behind NAT
LiveKit's built-in TURN server requires the server to have a **direct public IP**. On servers behind NAT (where `LIVEKIT_NODE_IP` is the gateway's public IP, not the server's own), TURN relay traffic hits a "hairpin NAT" problem: the TURN relay inside the container sends to the public IP, but the packet exits through the NAT gateway which doesn't route it back.
**Symptoms:** TURN allocations succeed (relay candidates appear in LiveKit logs), but ICE connection never succeeds (`connectionType: "unknown"`).
**Workaround for NAT servers:** Use an external TURN server (e.g., coturn) instead of LiveKit's built-in TURN, or configure the NAT gateway to do hairpin NAT for the relay ports.
## Dependencies
- Keycloak must be deployed and configured (run `setup_keycloak_integration.py`)
- Firewall must allow TCP 7881 and UDP 7882 on the server
- For TURN: firewall must also allow UDP 443 and UDP 30000-30009 (relay ports)
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1771848320,
"narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "2fc6539b481e1d2569f25f8799236694180c0993",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+40
View File
@@ -0,0 +1,40 @@
{
description = "La Suite Meet test environment";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { nixpkgs, ... }:
let
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f {
pkgs = import nixpkgs { inherit system; };
});
in {
devShells = forAllSystems ({ pkgs }: {
default = pkgs.mkShell {
packages = with pkgs; [
(python3.withPackages (ps: with ps; [
requests
numpy
pip
]))
libva
];
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.libva ];
shellHook = ''
export VENV_DIR="$PWD/.venv"
if [ ! -d "$VENV_DIR" ]; then
echo "Creating venv and installing livekit SDK..."
python3 -m venv "$VENV_DIR" --system-site-packages
"$VENV_DIR/bin/pip" install --quiet livekit
fi
source "$VENV_DIR/bin/activate"
echo "La Suite Meet test shell ready"
echo " python3 webrtc-media.py"
'';
};
});
};
}
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Health check for La Suite Meet."""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import http_get, resolve_domain
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
domain = args.domain or resolve_domain('lasuite-meet')
url = f"https://{domain}"
print(f"Checking La Suite Meet at {url} ...")
status, _ = http_get(url)
if status == 200:
print(f"PASS: La Suite Meet returned HTTP {status}")
else:
print(f"FAIL: La Suite Meet returned HTTP {status} (expected 200)")
sys.exit(1)
if __name__ == '__main__':
main()
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Meeting flow test for La Suite Meet — create room, join as two users, clean up."""
import argparse
import base64
import json
import os
import sys
import time
import urllib.error
import urllib.request
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import (
http_get, http_post, load_toml_credentials, resolve_domain,
)
def get_meet_token(kc_url, creds, username, password):
"""Obtain a Keycloak token for the given user."""
status, data = http_post(
f"{kc_url}/realms/{creds['kc_realm']}/protocol/openid-connect/token",
data={
"grant_type": "password",
"client_id": creds["kc_client_id"],
"client_secret": creds["kc_client_secret"],
"username": username,
"password": password,
"scope": "openid email",
},
content_type="application/x-www-form-urlencoded",
)
return (data or {}).get("access_token", "")
def decode_jwt_payload(token):
"""Decode the payload segment of a JWT token."""
try:
payload_b64 = token.split('.')[1]
# Add padding
payload_b64 += '=' * (4 - len(payload_b64) % 4)
# Replace URL-safe chars
payload_b64 = payload_b64.replace('-', '+').replace('_', '/')
decoded = base64.b64decode(payload_b64)
return json.loads(decoded)
except Exception:
return {}
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
recipe_dir = os.path.join(os.path.dirname(__file__), '..')
creds = load_toml_credentials(recipe_dir, 'keycloak')
if creds is None:
print("FAIL: Credentials file not found: keycloak-test-credentials.<domain_suffix>.toml")
print("Run setup_keycloak_integration.py first.")
sys.exit(1)
meet_domain = args.domain or resolve_domain('lasuite-meet')
kc_domain = resolve_domain('keycloak')
meet_url = f"https://{meet_domain}"
kc_url = f"https://{kc_domain}"
room_name = f"autotest-{int(time.time())}"
print("Testing meeting flow: create room, join as two users, clean up")
print()
# Step 1: Authenticate both users via Keycloak
print("Step 1: Authenticating both users via Keycloak ...")
token_user1 = get_meet_token(kc_url, creds, creds["kc_test_user"], creds["kc_test_pass"])
if not token_user1:
print(f" FAIL: Could not authenticate {creds['kc_test_user']}")
sys.exit(1)
print(f" PASS: {creds['kc_test_user']} authenticated ({len(token_user1)} chars)")
token_user2 = get_meet_token(kc_url, creds, creds["kc_test_user2"], creds["kc_test_pass2"])
if not token_user2:
print(f" FAIL: Could not authenticate {creds['kc_test_user2']}")
sys.exit(1)
print(f" PASS: {creds['kc_test_user2']} authenticated ({len(token_user2)} chars)")
# Step 2: User 1 creates a room
print(f"Step 2: User 1 creates room '{room_name}' ...")
status, create_body = http_post(
f"{meet_url}/api/v1.0/rooms/",
data={"name": room_name, "access_level": "public"},
headers={"Authorization": f"Bearer {token_user1}"},
)
if status != 201:
print(f" FAIL: Room creation returned HTTP {status} (expected 201)")
sys.exit(1)
room_id = create_body["id"]
room_slug = create_body["slug"]
user1_lk_room = create_body["livekit"]["room"]
user1_lk_token = create_body["livekit"]["token"]
if not room_id or not user1_lk_token:
print(" FAIL: Room created but missing id or LiveKit token")
sys.exit(1)
print(f" PASS: Room created (id={room_id}, slug={room_slug})")
print(f" PASS: User 1 received LiveKit token (room={user1_lk_room})")
# Step 3: User 2 joins the room
print(f"Step 3: User 2 joins room '{room_slug}' ...")
status, join_body = http_get(
f"{meet_url}/api/v1.0/rooms/{room_id}/",
headers={"Authorization": f"Bearer {token_user2}"},
)
if status != 200:
print(f" FAIL: Room retrieval returned HTTP {status} (expected 200)")
# Clean up
req = urllib.request.Request(f"{meet_url}/api/v1.0/rooms/{room_id}/", method="DELETE")
req.add_header("Authorization", f"Bearer {token_user1}")
try:
urllib.request.urlopen(req, timeout=10)
except Exception:
pass
sys.exit(1)
user2_lk_room = (join_body or {}).get("livekit", {}).get("room", "")
user2_lk_token = (join_body or {}).get("livekit", {}).get("token", "")
if not user2_lk_token:
print(" FAIL: User 2 did not receive a LiveKit token")
# Clean up
req = urllib.request.Request(f"{meet_url}/api/v1.0/rooms/{room_id}/", method="DELETE")
req.add_header("Authorization", f"Bearer {token_user1}")
try:
urllib.request.urlopen(req, timeout=10)
except Exception:
pass
sys.exit(1)
print(f" PASS: User 2 received LiveKit token (room={user2_lk_room})")
# Step 4: Verify both tokens reference the same room
print("Step 4: Verifying LiveKit tokens ...")
if user1_lk_room != user2_lk_room:
print(f" FAIL: LiveKit room mismatch: user1={user1_lk_room} user2={user2_lk_room}")
sys.exit(1)
print(f" PASS: Both users have tokens for the same LiveKit room ({user1_lk_room})")
# Decode JWT payloads and verify distinct identities
user1_payload = decode_jwt_payload(user1_lk_token)
user2_payload = decode_jwt_payload(user2_lk_token)
user1_identity = user1_payload.get("sub", "")
user2_identity = user2_payload.get("sub", "")
if user1_identity and user2_identity and user1_identity != user2_identity:
print(f" PASS: Tokens have distinct identities (user1={user1_identity[:12]}..., user2={user2_identity[:12]}...)")
else:
print(" WARN: Could not verify distinct JWT identities (non-fatal)")
# Step 5: User 1 deletes the room
print("Step 5: User 1 deletes the room ...")
req = urllib.request.Request(f"{meet_url}/api/v1.0/rooms/{room_id}/", method="DELETE")
req.add_header("Authorization", f"Bearer {token_user1}")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
delete_status = resp.getcode()
except urllib.error.HTTPError as e:
delete_status = e.code
if delete_status != 204:
print(f" FAIL: Room deletion returned HTTP {delete_status} (expected 204)")
sys.exit(1)
print(" PASS: Room deleted (HTTP 204)")
# Step 6: Verify room is gone
print("Step 6: Verifying room no longer exists ...")
status, _ = http_get(
f"{meet_url}/api/v1.0/rooms/{room_id}/",
headers={"Authorization": f"Bearer {token_user1}"},
)
if status == 404:
print(" PASS: Room returns 404 (deleted)")
elif status == 200:
print(" PASS: Room ID no longer resolves to original room")
else:
print(f" WARN: Unexpected HTTP {status} when checking deleted room (non-fatal)")
print()
print("PASS: Meeting flow test passed — two users can create, join, and clean up rooms")
if __name__ == '__main__':
main()
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""OIDC integration test for La Suite Meet + Keycloak."""
import argparse
import os
import sys
import urllib.request
import urllib.error
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
from utils.tests.helpers import (
http_get, http_post, load_toml_credentials, resolve_domain,
)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--domain', default=os.environ.get('TEST_DOMAIN'))
args = parser.parse_args()
recipe_dir = os.path.join(os.path.dirname(__file__), '..')
creds = load_toml_credentials(recipe_dir, 'keycloak')
if creds is None:
print("FAIL: Credentials file not found: keycloak-test-credentials.<domain_suffix>.toml")
print("Run setup_keycloak_integration.py first.")
sys.exit(1)
meet_domain = args.domain or resolve_domain('lasuite-meet')
kc_domain = resolve_domain('keycloak')
meet_url = f"https://{meet_domain}"
kc_url = f"https://{kc_domain}"
print("Testing OIDC integration: La Suite Meet <-> Keycloak")
print()
# Step 1: Verify Meet redirects to Keycloak
print("Step 1: Checking Meet OIDC redirect ...")
try:
req = urllib.request.Request(f"{meet_url}/api/v1.0/authenticate/")
opener = urllib.request.build_opener(NoRedirectHandler())
resp = opener.open(req, timeout=15)
redirect_url = resp.headers.get('Location', '')
except urllib.error.HTTPError as e:
redirect_url = e.headers.get('Location', '') if e.headers else ''
expected_prefix = f"{kc_url}/realms/{creds['kc_realm']}/protocol/openid-connect/auth"
if expected_prefix in (redirect_url or ''):
print(f" PASS: Meet redirects to Keycloak realm '{creds['kc_realm']}'")
else:
print(f" FAIL: Expected redirect to Keycloak, got: {redirect_url}")
sys.exit(1)
# Step 2: Obtain token from Keycloak
print("Step 2: Obtaining token from Keycloak ...")
token_url = f"{kc_url}/realms/{creds['kc_realm']}/protocol/openid-connect/token"
status, data = http_post(
token_url,
data={
"grant_type": "password",
"client_id": creds["kc_client_id"],
"client_secret": creds["kc_client_secret"],
"username": creds["kc_test_user"],
"password": creds["kc_test_pass"],
"scope": "openid email",
},
content_type="application/x-www-form-urlencoded",
)
access_token = (data or {}).get("access_token", "")
if not access_token:
error = (data or {}).get("error_description", (data or {}).get("error", "unknown"))
print(f" FAIL: Could not obtain token from Keycloak: {error}")
sys.exit(1)
print(f" PASS: Obtained access token from Keycloak ({len(access_token)} chars)")
# Step 3: Use token to access Meet API
print("Step 3: Accessing Meet API with Keycloak token ...")
status, body = http_get(
f"{meet_url}/api/v1.0/users/me/",
headers={"Authorization": f"Bearer {access_token}"},
)
if status != 200:
print(f" FAIL: Meet API returned HTTP {status} (expected 200)")
sys.exit(1)
user_email = (body or {}).get("email", "")
expected_email = f"{creds['kc_test_user']}@test.example.com"
if user_email == expected_email:
print(f" PASS: Meet API returned user with email '{user_email}'")
else:
print(f" FAIL: Expected email '{expected_email}', got '{user_email}'")
sys.exit(1)
print()
print("PASS: OIDC integration test passed — Meet authenticates via Keycloak")
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise urllib.error.HTTPError(newurl, code, msg, headers, fp)
if __name__ == '__main__':
main()
@@ -0,0 +1,413 @@
#!/usr/bin/env python3
"""
WebRTC media flow test for La Suite Meet + LiveKit.
Tests the full media path between two participants:
1. Preliminary connectivity checks (TCP 7881, UDP 7882, UDP 443 TURN, WSS signaling)
2. Both users authenticate via Keycloak OIDC
3. User 1 creates a room via the Meet API
4. User 2 joins the room
5. Both connect to LiveKit via the SDK
6. User 1 publishes a dummy audio track
7. User 2 verifies audio frames arrive
8. Clean up: disconnect both, delete the room
This verifies:
- WSS signaling through Traefik (port 7880)
- ICE negotiation (direct or TURN relay)
- Media transport over host-exposed ports (TCP 7881 / UDP 7882)
- TURN relay via UDP 443 for clients behind restrictive NATs
Network requirements:
The test can succeed via direct ICE (ports 7881/7882) or via TURN relay
(UDP 443). Users behind CGNAT/symmetric NAT will use TURN automatically.
The preliminary checks report which paths are available.
Prerequisites:
pip install livekit requests
Usage:
python3 webrtc-media.py
"""
import asyncio
import json
import os
import socket
import ssl
import struct
import sys
import time
from pathlib import Path
from urllib.parse import urlparse
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
import requests
from livekit import rtc
from utils.tests.helpers import resolve_domain
SAMPLE_RATE = 48000
NUM_CHANNELS = 1
SAMPLES_PER_CHANNEL = 480 # 10ms at 48kHz
TIMEOUT = 45 # seconds to wait for audio frames (allow time for ICE TCP fallback)
def load_credentials():
"""Load Keycloak test credentials from domain-specific TOML file."""
import tomllib
from utils.tests.helpers import resolve_domain_suffix
suffix = resolve_domain_suffix()
creds_file = Path(__file__).resolve().parent.parent / f"keycloak-test-credentials.{suffix}.toml"
if not creds_file.exists():
print(f"FAIL: Credentials file not found: {creds_file}")
print("Run setup_keycloak_integration.py first.")
sys.exit(1)
with open(creds_file, 'rb') as f:
return tomllib.load(f)
# ---------------------------------------------------------------------------
# Preliminary connectivity checks
# ---------------------------------------------------------------------------
def check_tcp(host, port, timeout=5):
"""Check if a TCP port is reachable."""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(timeout)
try:
s.connect((host, port))
s.close()
return True
except (socket.timeout, ConnectionRefusedError, OSError):
return False
def check_udp(host, port, timeout=5):
"""Check if a UDP port accepts packets (send-only, best effort)."""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(timeout)
try:
s.sendto(b"\x00", (host, port))
s.close()
return True
except OSError:
return False
def check_stun(host, port, timeout=5):
"""Send a STUN Binding Request and verify we get a valid response.
This proves the TURN/STUN server is actually listening and responding,
not just that a UDP send succeeded (which almost always does).
"""
txn_id = os.urandom(12)
# STUN Binding Request: type=0x0001, length=0, magic=0x2112A442
msg = struct.pack("!HHI", 0x0001, 0, 0x2112A442) + txn_id
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(timeout)
try:
s.sendto(msg, (host, port))
data, _ = s.recvfrom(1024)
except (socket.timeout, OSError):
return False
finally:
s.close()
if len(data) < 20:
return False
resp_type, _, magic = struct.unpack("!HHI", data[:8])
resp_txn = data[8:20]
# Valid STUN Binding Response: type=0x0101, correct magic + transaction ID
return resp_type == 0x0101 and magic == 0x2112A442 and resp_txn == txn_id
def check_wss(host, port=443, timeout=5):
"""Check if a WSS (TLS) connection can be established."""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(timeout)
ctx = ssl.create_default_context()
ss = ctx.wrap_socket(s, server_hostname=host)
try:
ss.connect((host, port))
ss.close()
return True
except (socket.timeout, ConnectionRefusedError, OSError, ssl.SSLError):
return False
def run_connectivity_checks(meet_domain, livekit_domain):
"""Run preliminary connectivity checks. Returns (all_critical_pass, turn_available)."""
server_ip = socket.gethostbyname(meet_domain)
livekit_ip = socket.gethostbyname(livekit_domain)
print(f" Server IP: {server_ip} ({meet_domain})")
print(f" LiveKit IP: {livekit_ip} ({livekit_domain})")
all_pass = True
turn_available = False
# HTTPS / Traefik
if check_tcp(server_ip, 443):
print(f" PASS: TCP {server_ip}:443 (HTTPS/Traefik)")
else:
print(f" FAIL: TCP {server_ip}:443 (HTTPS/Traefik) unreachable")
all_pass = False
# WSS signaling
if check_wss(livekit_domain):
print(f" PASS: WSS {livekit_domain}:443 (LiveKit signaling)")
else:
print(f" FAIL: WSS {livekit_domain}:443 (LiveKit signaling) unreachable")
all_pass = False
# LiveKit TCP media port
if check_tcp(server_ip, 7881):
print(f" PASS: TCP {server_ip}:7881 (LiveKit media/TCP)")
else:
print(f" WARN: TCP {server_ip}:7881 (LiveKit media/TCP) unreachable")
# LiveKit UDP media port
if check_udp(server_ip, 7882):
print(f" PASS: UDP {server_ip}:7882 (LiveKit media/UDP) send OK")
else:
print(f" WARN: UDP {server_ip}:7882 (LiveKit media/UDP) send failed")
# TURN/UDP port — use STUN probe to verify the server actually responds
if check_stun(server_ip, 443):
print(f" PASS: UDP {server_ip}:443 (TURN/UDP) STUN response received")
turn_available = True
else:
print(f" WARN: UDP {server_ip}:443 (TURN/UDP) no STUN response — TURN relay unavailable")
if turn_available:
print(" => TURN relay available — clients behind restrictive NATs can connect")
else:
print(" => TURN relay NOT available — only direct ICE connections will work")
return all_pass, turn_available
# ---------------------------------------------------------------------------
# API helpers
# ---------------------------------------------------------------------------
def get_oidc_token(kc_url, realm, client_id, client_secret, username, password):
"""Get an access token from Keycloak via direct access grant."""
resp = requests.post(
f"{kc_url}/realms/{realm}/protocol/openid-connect/token",
data={
"grant_type": "password",
"client_id": client_id,
"client_secret": client_secret,
"username": username,
"password": password,
"scope": "openid email",
},
)
resp.raise_for_status()
return resp.json()["access_token"]
def create_room(meet_url, token, room_name):
"""Create a public room. Returns (room_id, livekit_config)."""
resp = requests.post(
f"{meet_url}/api/v1.0/rooms/",
headers={"Authorization": f"Bearer {token}"},
json={"name": room_name, "access_level": "public"},
)
if resp.status_code != 201:
print(f" FAIL: Room creation returned HTTP {resp.status_code}: {resp.text}")
sys.exit(1)
data = resp.json()
return data["id"], data["livekit"]
def join_room(meet_url, token, room_id):
"""Join an existing room. Returns livekit config."""
resp = requests.get(
f"{meet_url}/api/v1.0/rooms/{room_id}/",
headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
return resp.json()["livekit"]
def delete_room(meet_url, token, room_id):
"""Delete a room."""
requests.delete(
f"{meet_url}/api/v1.0/rooms/{room_id}/",
headers={"Authorization": f"Bearer {token}"},
)
# ---------------------------------------------------------------------------
# WebRTC test
# ---------------------------------------------------------------------------
async def push_audio(source, stop_event):
"""Push silent PCM frames until stopped."""
frame = rtc.AudioFrame.create(SAMPLE_RATE, NUM_CHANNELS, SAMPLES_PER_CHANNEL)
while not stop_event.is_set():
await source.capture_frame(frame)
async def run_webrtc_test(lk_url, token_a, token_b):
"""
Connect two participants to LiveKit. A publishes audio, B verifies reception.
Returns (success, frames_received, publisher_identity, error_message).
"""
ws_url = lk_url.replace("https://", "wss://").replace("http://", "ws://")
room_opts = rtc.RoomOptions(auto_subscribe=True)
room_a = rtc.Room()
room_b = rtc.Room()
stop_audio = asyncio.Event()
track_received = asyncio.Event()
result = {"frames": 0, "publisher": ""}
@room_b.on("track_subscribed")
def on_track_subscribed(track, publication, participant):
result["publisher"] = participant.identity
async def read_frames():
stream = rtc.AudioStream(track)
async for _ in stream:
result["frames"] += 1
if result["frames"] >= 5:
break
await stream.aclose()
track_received.set()
asyncio.ensure_future(read_frames())
try:
# Participant A connects and publishes
await room_a.connect(ws_url, token_a, options=room_opts)
source = rtc.AudioSource(SAMPLE_RATE, NUM_CHANNELS)
audio_track = rtc.LocalAudioTrack.create_audio_track("test-audio", source)
options = rtc.TrackPublishOptions()
options.source = rtc.TrackSource.SOURCE_MICROPHONE
await room_a.local_participant.publish_track(audio_track, options)
audio_task = asyncio.ensure_future(push_audio(source, stop_audio))
await asyncio.sleep(1)
# Participant B connects and waits for track
await room_b.connect(ws_url, token_b, options=room_opts)
await asyncio.wait_for(track_received.wait(), timeout=TIMEOUT)
return True, result["frames"], result["publisher"], ""
except asyncio.TimeoutError:
return False, 0, "", "timed out waiting for audio frames"
except rtc.ConnectError as e:
return False, 0, "", f"LiveKit connection failed: {e}"
except Exception as e:
return False, 0, "", f"unexpected error: {e}"
finally:
stop_audio.set()
try:
await room_b.disconnect()
except Exception:
pass
try:
await room_a.disconnect()
except Exception:
pass
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
creds = load_credentials()
meet_domain = resolve_domain("lasuite-meet")
# LiveKit signaling runs on its own subdomain (LIVEKIT_DOMAIN in the recipe
# .env), which is `livekit-meet.<suffix>` — not a `livekit.` prefix on the
# meet domain.
from utils.tests.helpers import resolve_domain_suffix
livekit_domain = f"livekit-meet.{resolve_domain_suffix()}"
meet_url = f"https://{meet_domain}"
kc_url = f"https://{resolve_domain('keycloak')}"
room_name = f"webrtc-test-{int(time.time())}"
print("Testing WebRTC media flow: publish audio, verify reception")
print()
# Preliminary checks
print("Step 1: Connectivity checks ...")
critical_pass, turn_available = run_connectivity_checks(meet_domain, livekit_domain)
if not critical_pass:
print()
print("FAIL: Critical connectivity checks failed — cannot proceed with WebRTC test")
sys.exit(1)
# Authenticate
print("Step 2: Authenticating both users ...")
token1 = get_oidc_token(
kc_url, creds["kc_realm"], creds["kc_client_id"], creds["kc_client_secret"],
creds["kc_test_user"], creds["kc_test_pass"],
)
token2 = get_oidc_token(
kc_url, creds["kc_realm"], creds["kc_client_id"], creds["kc_client_secret"],
creds["kc_test_user2"], creds["kc_test_pass2"],
)
print(" PASS: Both users authenticated")
# Create room
print(f"Step 3: Creating room '{room_name}' ...")
room_id, lk1 = create_room(meet_url, token1, room_name)
lk2 = join_room(meet_url, token2, room_id)
print(f" PASS: Room created (id={room_id})")
print(f" PASS: LiveKit tokens obtained for both users")
print(f" LiveKit URL: {lk1['url']}")
# WebRTC media test
print("Step 4: Testing WebRTC media flow ...")
print(" Connecting participant A, publishing audio ...")
print(" Connecting participant B, waiting for audio frames ...")
success, frames, publisher, error = asyncio.run(
run_webrtc_test(lk1["url"], lk1["token"], lk2["token"])
)
# Clean up
print("Step 5: Cleaning up ...")
delete_room(meet_url, token1, room_id)
print(" Room deleted")
# Result
print()
if success:
print("PASS: WebRTC media flow verified")
print(f" Audio frames received: {frames}")
print(f" Publisher identity: {publisher}")
if turn_available:
print(" TURN was available — media may have used relay or direct ICE")
print(" (check LiveKit logs for connectionType to confirm relay vs direct)")
else:
print(f"FAIL: WebRTC media flow test failed — {error}")
if "timed out" in error or "connection" in error.lower():
print()
if not turn_available:
print(" Hint: Neither direct ICE (7881/7882) nor TURN relay (UDP 443)")
print(" are reachable. If the server has TURN enabled, verify UDP 443")
print(" is published and not blocked by firewall.")
else:
print(" Hint: TURN relay port (UDP 443) is reachable but media still")
print(" failed. Check LiveKit logs to verify TURN server started.")
print(" Run: ssh <server> docker service logs <stack>_livekit | grep -i turn")
print()
print(" Try running from a machine with less restrictive network access,")
print(" or verify TURN is enabled in the LiveKit config.")
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""
TURN relay test for La Suite Meet + LiveKit.
Forces relay-only ICE transport (TRANSPORT_RELAY) to verify the TURN relay
data path works end-to-end. This simulates clients behind restrictive NATs
(CGNAT, symmetric NAT, corporate firewalls) that cannot establish direct
ICE connections and must use TURN relay.
The test:
1. Verifies TURN server responds to STUN probe (UDP 443)
2. Both users authenticate via Keycloak OIDC
3. User 1 creates a room, User 2 joins
4. Both connect to LiveKit with ice_transport_type=TRANSPORT_RELAY
5. User 1 publishes a dummy audio track
6. User 2 verifies audio frames arrive via relay
7. Clean up
This test will FAIL if the TURN relay data path is broken (e.g., due to
docker-proxy source address mangling in Docker Swarm).
Prerequisites:
pip install livekit requests
Usage:
python3 webrtc-relay.py
"""
import asyncio
import os
import socket
import struct
import sys
import time
from pathlib import Path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
import requests
from livekit import rtc
from utils.tests.helpers import resolve_domain
SAMPLE_RATE = 48000
NUM_CHANNELS = 1
SAMPLES_PER_CHANNEL = 480 # 10ms at 48kHz
TIMEOUT = 30 # seconds — relay should connect quickly if it works at all
def load_credentials():
"""Load Keycloak test credentials from domain-specific TOML file."""
import tomllib
from utils.tests.helpers import resolve_domain_suffix
suffix = resolve_domain_suffix()
creds_file = Path(__file__).resolve().parent.parent / f"keycloak-test-credentials.{suffix}.toml"
if not creds_file.exists():
print(f"FAIL: Credentials file not found: {creds_file}")
print("Run setup_keycloak_integration.py first.")
sys.exit(1)
with open(creds_file, 'rb') as f:
return tomllib.load(f)
def check_stun(host, port, timeout=5):
"""Send a STUN Binding Request and verify we get a valid response."""
txn_id = os.urandom(12)
msg = struct.pack("!HHI", 0x0001, 0, 0x2112A442) + txn_id
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(timeout)
try:
s.sendto(msg, (host, port))
data, _ = s.recvfrom(1024)
except (socket.timeout, OSError):
return False
finally:
s.close()
if len(data) < 20:
return False
resp_type, _, magic = struct.unpack("!HHI", data[:8])
resp_txn = data[8:20]
return resp_type == 0x0101 and magic == 0x2112A442 and resp_txn == txn_id
def get_oidc_token(kc_url, realm, client_id, client_secret, username, password):
"""Get an access token from Keycloak via direct access grant."""
resp = requests.post(
f"{kc_url}/realms/{realm}/protocol/openid-connect/token",
data={
"grant_type": "password",
"client_id": client_id,
"client_secret": client_secret,
"username": username,
"password": password,
"scope": "openid email",
},
)
resp.raise_for_status()
return resp.json()["access_token"]
def create_room(meet_url, token, room_name):
"""Create a public room. Returns (room_id, livekit_config)."""
resp = requests.post(
f"{meet_url}/api/v1.0/rooms/",
headers={"Authorization": f"Bearer {token}"},
json={"name": room_name, "access_level": "public"},
)
if resp.status_code != 201:
print(f" FAIL: Room creation returned HTTP {resp.status_code}: {resp.text}")
sys.exit(1)
data = resp.json()
return data["id"], data["livekit"]
def join_room(meet_url, token, room_id):
"""Join an existing room. Returns livekit config."""
resp = requests.get(
f"{meet_url}/api/v1.0/rooms/{room_id}/",
headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
return resp.json()["livekit"]
def delete_room(meet_url, token, room_id):
"""Delete a room."""
requests.delete(
f"{meet_url}/api/v1.0/rooms/{room_id}/",
headers={"Authorization": f"Bearer {token}"},
)
async def push_audio(source, stop_event):
"""Push silent PCM frames until stopped."""
frame = rtc.AudioFrame.create(SAMPLE_RATE, NUM_CHANNELS, SAMPLES_PER_CHANNEL)
while not stop_event.is_set():
await source.capture_frame(frame)
async def run_relay_test(lk_url, token_a, token_b):
"""
Connect two participants using TURN relay only.
A publishes audio, B verifies reception.
Returns (success, frames_received, publisher_identity, error_message).
"""
ws_url = lk_url.replace("https://", "wss://").replace("http://", "ws://")
# Force relay-only ICE — no direct host/srflx candidates
relay_config = rtc.RtcConfiguration(
ice_transport_type=rtc.IceTransportType.TRANSPORT_RELAY,
)
room_opts = rtc.RoomOptions(
auto_subscribe=True,
rtc_config=relay_config,
)
room_a = rtc.Room()
room_b = rtc.Room()
stop_audio = asyncio.Event()
track_received = asyncio.Event()
result = {"frames": 0, "publisher": ""}
@room_b.on("track_subscribed")
def on_track_subscribed(track, publication, participant):
result["publisher"] = participant.identity
async def read_frames():
stream = rtc.AudioStream(track)
async for _ in stream:
result["frames"] += 1
if result["frames"] >= 5:
break
await stream.aclose()
track_received.set()
asyncio.ensure_future(read_frames())
try:
# Participant A connects and publishes
await room_a.connect(ws_url, token_a, options=room_opts)
print(" Participant A connected (relay-only)")
source = rtc.AudioSource(SAMPLE_RATE, NUM_CHANNELS)
audio_track = rtc.LocalAudioTrack.create_audio_track("test-audio", source)
options = rtc.TrackPublishOptions()
options.source = rtc.TrackSource.SOURCE_MICROPHONE
await room_a.local_participant.publish_track(audio_track, options)
audio_task = asyncio.ensure_future(push_audio(source, stop_audio))
await asyncio.sleep(1)
# Participant B connects and waits for track
await room_b.connect(ws_url, token_b, options=room_opts)
print(" Participant B connected (relay-only)")
await asyncio.wait_for(track_received.wait(), timeout=TIMEOUT)
return True, result["frames"], result["publisher"], ""
except asyncio.TimeoutError:
return False, 0, "", "timed out waiting for audio frames (relay path broken?)"
except rtc.ConnectError as e:
return False, 0, "", f"LiveKit connection failed: {e}"
except Exception as e:
return False, 0, "", f"unexpected error: {e}"
finally:
stop_audio.set()
try:
await room_b.disconnect()
except Exception:
pass
try:
await room_a.disconnect()
except Exception:
pass
def main():
creds = load_credentials()
meet_domain = resolve_domain("lasuite-meet")
livekit_domain = f"livekit.{meet_domain}"
meet_url = f"https://{meet_domain}"
kc_url = f"https://{resolve_domain('keycloak')}"
room_name = f"relay-test-{int(time.time())}"
print("Testing TURN relay path: force relay-only ICE, publish audio, verify reception")
print()
# Check TURN is available
print("Step 1: Verify TURN server responds ...")
server_ip = socket.gethostbyname(livekit_domain)
print(f" LiveKit IP: {server_ip} ({livekit_domain})")
if not check_stun(server_ip, 443):
print(f" FAIL: No STUN response from {server_ip}:443 — TURN server not reachable")
print(" Cannot run relay test without TURN.")
sys.exit(1)
print(f" PASS: TURN server responds on UDP {server_ip}:443")
# Authenticate
print("Step 2: Authenticating both users ...")
token1 = get_oidc_token(
kc_url, creds["kc_realm"], creds["kc_client_id"], creds["kc_client_secret"],
creds["kc_test_user"], creds["kc_test_pass"],
)
token2 = get_oidc_token(
kc_url, creds["kc_realm"], creds["kc_client_id"], creds["kc_client_secret"],
creds["kc_test_user2"], creds["kc_test_pass2"],
)
print(" PASS: Both users authenticated")
# Create room
print(f"Step 3: Creating room '{room_name}' ...")
room_id, lk1 = create_room(meet_url, token1, room_name)
lk2 = join_room(meet_url, token2, room_id)
print(f" PASS: Room created (id={room_id})")
print(f" LiveKit URL: {lk1['url']}")
# Relay test
print("Step 4: Testing TURN relay media flow (relay-only ICE) ...")
success, frames, publisher, error = asyncio.run(
run_relay_test(lk1["url"], lk1["token"], lk2["token"])
)
# Clean up
print("Step 5: Cleaning up ...")
delete_room(meet_url, token1, room_id)
print(" Room deleted")
# Result
print()
if success:
print("PASS: TURN relay media flow verified")
print(f" Audio frames received: {frames}")
print(f" Publisher identity: {publisher}")
print(" Media was transported entirely via TURN relay (no direct ICE)")
else:
print(f"FAIL: TURN relay test failed — {error}")
print()
print(" The TURN server is reachable and relay candidates are generated,")
print(" but media cannot flow through the relay path. This is typically")
print(" caused by docker-proxy source address mangling in Docker Swarm:")
print(" the TURN relay and SFU are in the same container but communicate")
print(" via the external IP through docker-proxy, which changes the source")
print(" address and breaks TURN permission checks.")
sys.exit(1)
if __name__ == "__main__":
main()
+20
View File
@@ -0,0 +1,20 @@
# La Suite Meet — Upstream Info
## Main Project
- **Repository:** https://github.com/suitenumerique/meet
- **Releases:** https://github.com/suitenumerique/meet/releases
- **Changelog:** https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md
- **Docker Compose docs:** https://github.com/suitenumerique/meet/blob/main/docs/installation/compose.md
## Images
| Service | Image | Release Notes |
|---------|-------|---------------|
| app | `lasuite/meet-frontend` | https://github.com/suitenumerique/meet/releases |
| backend | `lasuite/meet-backend` | https://github.com/suitenumerique/meet/releases |
| celery | `lasuite/meet-backend` | https://github.com/suitenumerique/meet/releases |
| livekit | `livekit/livekit-server` | https://github.com/livekit/livekit/releases |
| db | `pgautoupgrade/pgautoupgrade` | https://github.com/pgautoupgrade/docker-pgautoupgrade/releases |
| redis | `redis` | https://github.com/redis/redis/releases |
| web | `nginx` | https://nginx.org/en/CHANGES |