feat: provision private Hermes assistants for authenticated Hope accounts
This commit is contained in:
1 parent
da65da68b3
commit
2aedea84b5
33 files changed
+3063
-1743
No files matched your search
+58
-100
@@ -1,15 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tiny token endpoint for the voice UI.
|
||||
|
||||
Serves POST /token -> { "token": "<signed JWT>" }
|
||||
Signs a LiveKit access token with HS256 using the API key/secret from env.
|
||||
|
||||
The token carries a roomConfig claim requesting the voice-assistant agent, so
|
||||
LiveKit dispatches the agent when the participant joins. Without this, the
|
||||
auto-created room would get no agent at all.
|
||||
|
||||
Runs as a separate supervisord process; nginx proxies /token to it.
|
||||
"""
|
||||
"""Authenticated room bootstrap. All identity/routing is resolved server-side."""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
@@ -18,22 +8,17 @@ import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
import httpx
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
API_KEY = os.environ.get("LIVEKIT_API_KEY", "devkey")
|
||||
API_SECRET = os.environ.get("LIVEKIT_API_SECRET", "devsecret")
|
||||
AGENT_NAME = os.environ.get("VOICE_AGENT_NAME", "voice-assistant")
|
||||
USERS_FILE = os.environ.get("VOICE_USERS_FILE", "/etc/hope/users.json")
|
||||
DEFAULT_USER = os.environ.get("VOICE_DEFAULT_USER", "shane")
|
||||
|
||||
|
||||
def load_default_user() -> tuple[str, dict]:
|
||||
with open(USERS_FILE, encoding="utf-8") as users_file:
|
||||
registry = json.load(users_file)
|
||||
profile = registry.get("users", {}).get(DEFAULT_USER)
|
||||
if not isinstance(profile, dict):
|
||||
raise ValueError(f"unknown default voice user: {DEFAULT_USER}")
|
||||
return DEFAULT_USER, profile
|
||||
API_KEY = os.getenv("LIVEKIT_API_KEY", "")
|
||||
API_SECRET = os.getenv("LIVEKIT_API_SECRET", "")
|
||||
AGENT_NAME = os.getenv("VOICE_AGENT_NAME", "voice-assistant")
|
||||
CONTROL_URL = os.getenv("CONTROL_URL", "http://127.0.0.1:8092").rstrip("/")
|
||||
CONTROL_SECRET = os.getenv("CONTROL_SERVICE_SECRET", "")
|
||||
COOKIE_NAME = "hope_session"
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
|
||||
def b64url(data: bytes) -> str:
|
||||
@@ -41,82 +26,55 @@ def b64url(data: bytes) -> str:
|
||||
|
||||
|
||||
def make_token(room_name: str, identity: str) -> str:
|
||||
if not API_KEY or not API_SECRET:
|
||||
raise RuntimeError("LiveKit credentials are not configured")
|
||||
now = int(time.time())
|
||||
header = {"alg": "HS256", "typ": "JWT"}
|
||||
payload = {
|
||||
"iss": API_KEY,
|
||||
"sub": identity,
|
||||
"nbf": now - 5,
|
||||
"exp": now + 3600,
|
||||
"jti": str(uuid.uuid4()),
|
||||
"identity": identity,
|
||||
"video": {
|
||||
"roomJoin": True,
|
||||
"room": room_name,
|
||||
"canPublish": True,
|
||||
"canSubscribe": True,
|
||||
"canPublishData": True,
|
||||
},
|
||||
"roomConfig": {
|
||||
"agents": [{"agentName": AGENT_NAME, "jobType": "JT_ROOM"}],
|
||||
},
|
||||
}
|
||||
h = b64url(json.dumps(header).encode())
|
||||
p = b64url(json.dumps(payload).encode())
|
||||
signing_input = f"{h}.{p}".encode()
|
||||
sig = hmac.new(API_SECRET.encode(), signing_input, hashlib.sha256).digest()
|
||||
return f"{h}.{p}.{b64url(sig)}"
|
||||
payload = {"iss": API_KEY, "sub": identity, "nbf": now - 5, "exp": now + 300,
|
||||
"jti": str(uuid.uuid4()), "video": {"roomJoin": True, "room": room_name,
|
||||
"canPublish": True, "canSubscribe": True, "canPublishData": True,
|
||||
"canPublishSources": ["microphone"]},
|
||||
"roomConfig": {"agents": [{"agentName": AGENT_NAME, "jobType": "JT_ROOM"}]}}
|
||||
head = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
|
||||
body = b64url(json.dumps(payload).encode())
|
||||
sig = hmac.new(API_SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()
|
||||
return f"{head}.{body}.{b64url(sig)}"
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
if self.path != "/token":
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
# Consume the body for protocol compatibility, but never trust a
|
||||
# browser-supplied room or identity. This LAN bootstrap deployment is
|
||||
# pinned to VOICE_DEFAULT_USER until the planned login flow is added.
|
||||
self.rfile.read(length)
|
||||
try:
|
||||
user_id, profile = load_default_user()
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
resp = json.dumps({"error": str(exc)}).encode()
|
||||
self.send_response(503)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(resp)))
|
||||
self.end_headers()
|
||||
self.wfile.write(resp)
|
||||
return
|
||||
|
||||
room_name = f"voice-{user_id}-{uuid.uuid4().hex[:8]}"
|
||||
identity = profile.get("livekit_identity") or f"user:{user_id}"
|
||||
token = make_token(room_name, identity)
|
||||
resp = json.dumps(
|
||||
{
|
||||
"token": token,
|
||||
# Returned for diagnostics and reconnects. Music deliberately
|
||||
# uses its own stable scope in the separate Hope Music service.
|
||||
"room": room_name,
|
||||
"user": {
|
||||
"id": user_id,
|
||||
"display_name": profile.get("display_name") or user_id,
|
||||
},
|
||||
}
|
||||
).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(resp)))
|
||||
self.end_headers()
|
||||
self.wfile.write(resp)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass # silence request logging
|
||||
|
||||
@app.post("/token")
|
||||
async def token(request: Request):
|
||||
headers = {"Cache-Control": "no-store"}
|
||||
if not CONTROL_SECRET or not API_KEY or not API_SECRET:
|
||||
return JSONResponse({"error": "Service configuration unavailable"}, 503, headers=headers)
|
||||
auth = request.headers.get("authorization", "")
|
||||
bearer = auth[7:] if auth.startswith("Bearer ") else ""
|
||||
credential = bearer or request.cookies.get(COOKIE_NAME, "")
|
||||
if not credential or len(credential) > 512:
|
||||
return JSONResponse({"error": "Sign in required"}, 401, headers=headers)
|
||||
if not bearer:
|
||||
origin = request.headers.get("origin", "")
|
||||
allowed = set(filter(None, os.getenv("CONTROL_ALLOWED_ORIGINS", "").split(",")))
|
||||
if origin not in allowed:
|
||||
return JSONResponse({"error": "Invalid request origin"}, 403, headers=headers)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
control_headers = {"X-Control-Secret": CONTROL_SECRET}
|
||||
response = await client.post(f"{CONTROL_URL}/internal/resolve-session", headers=control_headers,
|
||||
json={"token": credential})
|
||||
if response.status_code != 200:
|
||||
status = response.status_code if response.status_code in (401,403,503) else 503
|
||||
return JSONResponse({"error": "Sign in required" if status==401 else "Your private agent is not ready"}, status, headers=headers)
|
||||
account = response.json()
|
||||
user = account["user"]
|
||||
room_name = f"voice-{user['id']}-{uuid.uuid4().hex}"
|
||||
grant = await client.post(f"{CONTROL_URL}/internal/rooms", headers=control_headers,
|
||||
json={"user_id": user["id"], "room_name": room_name, "ttl": 86400,
|
||||
"session_token": credential})
|
||||
grant.raise_for_status()
|
||||
return JSONResponse({"token": make_token(room_name, f"user:{user['id']}"), "room": room_name,
|
||||
"user": {"id": user["id"], "display_name": user["display_name"]}}, headers=headers)
|
||||
except (httpx.HTTPError, ValueError, KeyError):
|
||||
return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers)
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = HTTPServer(("127.0.0.1", 8091), Handler)
|
||||
print("token endpoint listening on 127.0.0.1:8091")
|
||||
server.serve_forever()
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="127.0.0.1", port=8091, access_log=False)
|
||||
Reference in new issue
Block a user