titan-iac/services/jitsi/launcher-configmap.yaml

124 lines
4.0 KiB
YAML

# services/jitsi/launcher-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: jitsi-launcher
namespace: jitsi
data:
app.py: |
import base64
import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
ISSUER = os.getenv("JWT_ISSUER", "https://sso.bstein.dev/realms/atlas")
AUDIENCE = os.getenv("JWT_AUDIENCE", "jitsi")
APP_ID = os.getenv("JWT_APP_ID", "jitsi")
PUBLIC_URL = os.getenv("PUBLIC_URL", "https://meet.bstein.dev")
SECRET_FILE = os.getenv("JWT_SECRET_FILE", "/var/lib/jitsi-jwt/jwt")
ALLOWED_GROUPS = {g for g in os.getenv("ALLOWED_GROUPS", "").split(",") if g}
TOKEN_TTL = int(os.getenv("JWT_TTL_SECONDS", "600"))
app = FastAPI()
def _b64url(data: bytes) -> bytes:
return base64.urlsafe_b64encode(data).rstrip(b"=")
def _read_secret() -> bytes:
raw = open(SECRET_FILE, "rb").read().strip()
try:
return bytes.fromhex(raw.decode())
except ValueError:
return raw
def _sign(room: str, user: str, groups: list[str]) -> str:
now = int(time.time())
header = {"alg": "HS256", "typ": "JWT"}
payload = {
"iss": ISSUER,
"aud": AUDIENCE,
"sub": "meet.jitsi",
"room": room,
"exp": now + TOKEN_TTL,
"nbf": now - 10,
"context": {
"user": {
"name": user,
"email": user,
"affiliation": "owner",
"groups": groups,
}
},
"app_id": APP_ID,
}
secret = _read_secret()
signing_input = b".".join(
[
_b64url(json.dumps(header, separators=(",", ":")).encode()),
_b64url(json.dumps(payload, separators=(",", ":")).encode()),
]
)
sig = _b64url(hmac.new(secret, signing_input, hashlib.sha256).digest())
return b".".join([signing_input, sig]).decode()
def _render_form(message: str = "") -> HTMLResponse:
body = f"""
<html>
<body>
<h2>Start a Jitsi room</h2>
{'<p style="color:red;">'+message+'</p>' if message else ''}
<form action="/launch" method="get">
<label>Room name:</label>
<input name="room" required autofocus />
<button type="submit">Launch</button>
</form>
</body>
</html>
"""
return HTMLResponse(body)
def _extract_groups(request: Request) -> set[str]:
raw = request.headers.get("x-auth-request-groups", "")
# Traefik forwardAuth returns comma-separated groups
return {g.strip() for g in raw.split(",") if g.strip()}
@app.get("/launch")
async def launch(request: Request, room: str | None = None):
user = request.headers.get("x-auth-request-email") or request.headers.get(
"x-auth-request-user", ""
)
groups = _extract_groups(request)
if ALLOWED_GROUPS and not (groups & ALLOWED_GROUPS):
raise HTTPException(status_code=403, detail="forbidden")
if not room:
return _render_form()
room = room.strip()
if not room or "/" in room or ".." in room:
raise HTTPException(status_code=400, detail="invalid room")
token = _sign(room, user or "moderator", sorted(groups))
join_url = f"{PUBLIC_URL}/{room}#config.jwt={token}"
accept = request.headers.get("accept", "")
if "text/html" in accept:
return RedirectResponse(join_url, status_code=302)
return JSONResponse({"room": room, "join_url": join_url, "token": token})
@app.get("/")
async def root():
return RedirectResponse("/launch")
@app.get("/health")
async def health():
return {"status": "ok"}