From 6f2b231938f7684cc32b253138b125864eb243b2 Mon Sep 17 00:00:00 2001 From: Shane Date: Sat, 22 Aug 2026 08:31:19 -0400 Subject: [PATCH] feat: HTTPS web frontend (self-signed) + server-side token endpoint - nginx serves the UI over HTTPS on 8090 with a self-signed cert (browsers require a secure context for microphone access) - added /token endpoint (tiny Python HTTP server) that signs LiveKit JWTs server-side, keeping the API secret out of the browser - app.js now fetches a signed token from /token and uses wss:// when the page is served over HTTPS - supervisord runs the token-server as a fourth process --- Dockerfile | 14 ++++-- supervisord.conf | 11 +++++ web/app.js | 113 +++++++++++++++++--------------------------- web/token_server.py | 81 +++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 73 deletions(-) create mode 100644 web/token_server.py diff --git a/Dockerfile b/Dockerfile index ac78fc4..2e769b3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,16 +44,24 @@ RUN curl -sSL "https://github.com/livekit/livekit/releases/download/${LIVEKIT_VE COPY --from=build /app/agent/.venv /opt/voice-agent/.venv COPY agent/agent.py /opt/voice-agent/agent.py -# Copy web frontend +# Copy web frontend + token endpoint COPY web/ /var/www/voice/ +COPY web/token_server.py /opt/voice/token_server.py # Config files COPY livekit.yaml /etc/livekit.yaml COPY supervisord.conf /etc/supervisor/conf.d/voice.conf -# Configure nginx to serve the voice UI on port 8090 +# Configure nginx to serve the voice UI on port 8090 over HTTPS (self-signed) +# Browsers require a secure context (HTTPS or localhost) for microphone access. RUN rm -f /etc/nginx/sites-enabled/default \ - && printf 'server {\n listen 8090;\n root /var/www/voice;\n index index.html;\n location / {\n try_files $uri $uri/ =404;\n }\n}\n' \ + && mkdir -p /etc/voice/certs \ + && openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ + -keyout /etc/voice/certs/key.pem \ + -out /etc/voice/certs/cert.pem \ + -subj "/CN=voice.local" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \ + && printf 'server {\n listen 8090;\n root /var/www/voice;\n index index.html;\n ssl_certificate /etc/voice/certs/cert.pem;\n ssl_certificate_key /etc/voice/certs/key.pem;\n location /token {\n proxy_pass http://127.0.0.1:8091/token;\n proxy_set_header Content-Type application/json;\n }\n location / {\n try_files $uri $uri/ =404;\n }\n}\n' \ > /etc/nginx/sites-available/voice \ && ln -sf /etc/nginx/sites-available/voice /etc/nginx/sites-enabled/voice diff --git a/supervisord.conf b/supervisord.conf index 5c10d05..7d6f017 100644 --- a/supervisord.conf +++ b/supervisord.conf @@ -36,3 +36,14 @@ autostart=true autorestart=true stdout_logfile=/var/log/supervisor/web.log stderr_logfile=/var/log/supervisor/web_err.log + +[program:token-server] +command=/opt/voice-agent/.venv/bin/python /opt/voice/token_server.py +user=voiceuser +autostart=true +autorestart=true +stdout_logfile=/var/log/supervisor/token.log +stderr_logfile=/var/log/supervisor/token_err.log +environment= + LIVEKIT_API_KEY="%(ENV_LIVEKIT_API_KEY)s", + LIVEKIT_API_SECRET="%(ENV_LIVEKIT_API_SECRET)s" diff --git a/web/app.js b/web/app.js index 67f2ae1..d13552f 100644 --- a/web/app.js +++ b/web/app.js @@ -1,35 +1,30 @@ // Voice Assistant — LiveKit client -const { Room, RoomEvent, RemoteParticipant, AudioPlaybackStats } = LivekitClient; +const { Room, RoomEvent } = LivekitClient; -// ── Config (injected from environment at build time, or hardcoded for LAN) ── -const LIVEKIT_URL = `ws://${window.location.hostname}:7880`; -const API_KEY = "devkey"; +// ── Config ────────────────────────────────────────────────────────────────── +// LiveKit server runs on the same host. When served over HTTPS, use wss://. +const LIVEKIT_URL = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:7880`; const ROOM_NAME = "voice-room"; const AGENT_NAME = "voice-assistant"; -// ── State ── +// ── State ─────────────────────────────────────────────────────────────────── let room = null; let agentParticipant = null; -// ── DOM ── +// ── DOM ───────────────────────────────────────────────────────────────────── const startBtn = document.getElementById("startBtn"); const stopBtn = document.getElementById("stopBtn"); const statusEl = document.getElementById("status"); const messagesEl = document.getElementById("messages"); const voiceSelect = document.getElementById("voiceSelect"); -// ── Voice switching ── +// ── Voice switching ───────────────────────────────────────────────────────── voiceSelect.addEventListener("change", () => { const voice = voiceSelect.value; if (room && agentParticipant) { - // Send data message to agent to switch voice - room.localParticipant.publishData( - JSON.stringify({ type: "set_voice", voice }).encodeInto(new Uint8Array()), - [agentParticipant] - ); + sendVoiceMessage(voice); addMessage("system", `Voice changed to ${voice}`); } else { - // Save preference for next session localStorage.setItem("preferred_voice", voice); } }); @@ -40,31 +35,32 @@ if (savedVoice) { voiceSelect.value = savedVoice; } -// ── Start ── +// ── Start ─────────────────────────────────────────────────────────────────── startBtn.addEventListener("click", async () => { try { statusEl.textContent = "Connecting..."; - + + // Get a signed access token from the token endpoint + const token = await fetchToken(ROOM_NAME); + room = new Room({ adaptiveStream: true, dynacast: true, }); - // Listen for transcripts from the agent + // Listen for agent participant joining room.on(RoomEvent.ParticipantConnected, (participant) => { if (participant.identity === AGENT_NAME || participant.kind === "agent") { agentParticipant = participant; - statusEl.textContent = "Agent connected"; - - // Send current voice selection + statusEl.textContent = "Agent connected — speak now"; + + // Send current voice selection to the agent const voice = voiceSelect.value; - room.localParticipant.publishData( - JSON.stringify({ type: "set_voice", voice }).encodeInto(new Uint8Array()), - [participant] - ); + sendVoiceMessage(voice); } }); + // Listen for transcripts from the agent room.on(RoomEvent.DataReceived, (payload, participant) => { try { const msg = JSON.parse(payload.decodeToString()); @@ -86,16 +82,14 @@ startBtn.addEventListener("click", async () => { statusEl.textContent = "Disconnected"; startBtn.disabled = false; stopBtn.disabled = true; + agentParticipant = null; }); await room.connect(LIVEKIT_URL, { - accessToken: generateToken(), + accessToken: token, autoSubscribe: true, }); - // Request agent join - await requestAgentJoin(); - statusEl.textContent = "Connected — speak now"; startBtn.disabled = true; stopBtn.disabled = false; @@ -106,7 +100,7 @@ startBtn.addEventListener("click", async () => { } }); -// ── Stop ── +// ── Stop ──────────────────────────────────────────────────────────────────── stopBtn.addEventListener("click", () => { if (room) { room.disconnect(); @@ -118,55 +112,34 @@ stopBtn.addEventListener("click", () => { stopBtn.disabled = true; }); -// ── Helper: generate a LiveKit access token (simplified for LAN dev) ── -// In production, this would be done server-side. For LAN dev, we use a static token. -function generateToken() { - // The LiveKit server with devkey/devsecret will accept any token signed with that key. - // For simplicity in this LAN setup, we'll let the server handle auth. - return "dev-token"; +// ── Helper: fetch a signed access token from the token endpoint ──────────── +async function fetchToken(roomName) { + const resp = await fetch("/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ room: roomName }), + }); + if (!resp.ok) throw new Error(`Token endpoint returned ${resp.status}`); + const data = await resp.json(); + return data.token; } -// ── Helper: request agent to join the room ── -async function requestAgentJoin() { - // Send a data message to trigger agent dispatch - if (room && room.localParticipant) { - const msg = JSON.stringify({ type: "join_agent", agent: AGENT_NAME }); - room.localParticipant.publishData( - msg.encodeInto(new Uint8Array()), - [] // broadcast to all participants - ); - } +// ── Helper: send voice selection to the agent via data channel ───────────── +function sendVoiceMessage(voice) { + if (!room || !room.localParticipant) return; + const payload = new TextEncoder().encode(JSON.stringify({ type: "set_voice", voice })); + room.localParticipant.publishData(payload, { + topic: "voice-control", + reliable: true, + }); } -// ── Helper: add a message to the transcript ── +// ── Helper: add a message to the transcript ───────────────────────────────── function addMessage(role, text) { const div = document.createElement("div"); div.className = `message ${role}`; - div.innerHTML = `${role === "user" ? "You" : role === "agent" ? "Assistant" : "System"} ${text}`; + const label = role === "user" ? "You" : role === "agent" ? "Assistant" : "System"; + div.innerHTML = `${label} ${text}`; messagesEl.appendChild(div); messagesEl.scrollTop = messagesEl.scrollHeight; } - -// ── Capture user speech for transcript display ── -// We'll use the Web Speech API as a fallback for showing what the user said. -// The actual STT is handled by the agent's Azure STT pipeline. -let recognition = null; -if ("webkitSpeechRecognition" in window || "SpeechRecognition" in window) { - const SpeechRec = window.SpeechRecognition || window.webkitSpeechRecognition; - recognition = new SpeechRec(); - recognition.continuous = true; - recognition.interimResults = true; - recognition.lang = "en-US"; - - recognition.onresult = (event) => { - for (let i = event.resultIndex; i < event.results.length; i++) { - if (event.results[i].isFinal) { - addMessage("user", event.results[i][0].transcript); - } - } - }; - - // Start/stop recognition with the room connection - const origConnect = Room.prototype.connect; - // We'll start recognition when room connects -} diff --git a/web/token_server.py b/web/token_server.py new file mode 100644 index 0000000..5fae708 --- /dev/null +++ b/web/token_server.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Tiny token endpoint for the voice UI. + +Serves POST /token -> { "token": "" } +Signs a LiveKit access token with HS256 using the API key/secret from env. +Runs as a separate supervisord process; nginx proxies /token to it. +""" +import base64 +import hashlib +import hmac +import json +import os +import time +import uuid + +from http.server import BaseHTTPRequestHandler, HTTPServer + +API_KEY = os.environ.get("LIVEKIT_API_KEY", "devkey") +API_SECRET = os.environ.get("LIVEKIT_API_SECRET", "devsecret") + + +def b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode() + + +def make_token(room_name: str) -> str: + now = int(time.time()) + header = {"alg": "HS256", "typ": "JWT"} + payload = { + "iss": API_KEY, + "sub": room_name, + "nbf": now - 5, + "exp": now + 3600, + "jti": str(uuid.uuid4()), + "video_grants": { + "room_join": True, + "room": room_name, + }, + } + 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)}" + + +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)) + body = json.loads(self.rfile.read(length) or b"{}") + room_name = body.get("room", "voice-room") + + token = make_token(room_name) + resp = json.dumps({"token": token}).encode() + 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(resp))) + self.end_headers() + self.wfile.write(resp) + + def do_OPTIONS(self): + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + + def log_message(self, format, *args): + pass # silence request logging + + +if __name__ == "__main__": + server = HTTPServer(("127.0.0.1", 8091), Handler) + print("token endpoint listening on 127.0.0.1:8091") + server.serve_forever()