fix: security hardening, LiveKit class refactor, and eng review fixes

Server:
- Fix YAML injection in LiveKit config generation (quote values)
- Revert token TTL to 4h (no server-side JWT revocation)
- Derive LiveKit publish permissions from user role (prevent SFU bypass)
- Add CAS guard for webhook/voice_leave race condition
- Add voice_leave broadcast to rollbackVoiceJoin (prevent ghost state)
- Limit webhook body to 64KB (prevent memory abuse)
- Add rate limit to voice_token_refresh handler (1/60s)
- Add LiveKit health check endpoint (GET /api/v1/livekit/health, 503 on degraded)
- Add voice_token_refresh WS handler for client-initiated token refresh
- Consolidate voice quality constants (single source of truth)
- Fix video limit TOCTOU race (count from DB instead of LiveKit API)
- Raise default voice_max_video from 10 to 25 (Discord parity)
- Add CountActiveCameras DB query
- Non-blocking broadcast send, circuit breaker, exponential backoff
- Close send channel before context cancel in serve.go
- Guard voice mute/deafen for active channel
- Delete orphaned message on attachment link failure
- Redact query string from proxy logs (prevent token leak)
- Use instance-level HTTP client for health checks (no redirect following)
- Set cmd.WaitDelay to prevent goroutine leak on Windows
- Log buildJSON marshal errors

Client:
- Refactor livekitSession.ts from singleton module to LiveKitSession class
- Share single AudioContext for all analysers (was 1 per participant)
- Extract createRoom() helper (DRY)
- Add token refresh timer (3.5h interval, re-arms on failure)
- Skip setSpeakers if unchanged (sort in-place, no allocations)
- Distinguish user-initiated leave from connection error in retry
- Add YouTube videoId validation (prevent iframe src injection)
- Add try/finally to disableCamera
- Wrap store subscription callbacks in try/catch
- Track and cancel initial scroll RAF on cleanup
- Add 5s timeout + encodeURIComponent to YouTube oEmbed fetch
- Clean raw mic stream on RNNoise suppressor failure
- Full voice cleanup on logout via cleanupAll()

Tests:
- Add 7 new server tests (webhook parsing, voice guards, quality fallback)
- Fix 2 pre-existing test failures (mute/deafen invalid payload)
This commit is contained in:
jevb
2026-03-21 11:59:14 +01:00
parent 3236918012
commit 7978ec40e8
26 changed files with 1217 additions and 868 deletions
@@ -379,7 +379,8 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
// doesn't cause jumps from estimate→measured height corrections.
premeasureAll();
scrollToBottom();
requestAnimationFrame(() => scrollToBottom());
const initialScrollRaf = requestAnimationFrame(() => scrollToBottom());
ac.signal.addEventListener("abort", () => cancelAnimationFrame(initialScrollRaf));
unsubscribers.push(messagesStore.subscribeSelector(
(s) => s.messagesByChannel,
@@ -66,8 +66,19 @@ export function extractYouTubeId(url: string): string | null {
/** Cache for YouTube video titles to avoid re-fetching on every re-render. */
const ytTitleCache = new Map<string, string>();
/** Strict pattern for YouTube video IDs (alphanumeric, hyphens, underscores). */
const YOUTUBE_ID_RE = /^[\w-]{1,20}$/;
/** Render a YouTube embed player with title header. */
export function renderYouTubeEmbed(videoId: string, originalUrl: string): HTMLDivElement {
// Validate videoId to prevent injection into iframe src / img src.
if (!YOUTUBE_ID_RE.test(videoId)) {
const fallback = createElement("div", { class: "msg-embed" });
const link = createElement("a", { href: originalUrl, target: "_blank", rel: "noopener noreferrer" });
setText(link, originalUrl);
fallback.appendChild(link);
return fallback;
}
const wrap = createElement("div", { class: "msg-embed msg-embed-youtube" });
// Header: channel name + video title
@@ -85,10 +96,10 @@ export function renderYouTubeEmbed(videoId: string, originalUrl: string): HTMLDi
setText(titleLink, cached);
} else {
setText(titleLink, "Loading...");
const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`;
fetch(oembedUrl)
.then((res) => (res.ok ? res.json() : null))
.then((data: { title?: string } | null) => {
const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}&format=json`;
fetch(oembedUrl, { signal: AbortSignal.timeout(5000) })
.then((res) => (res.ok ? (res.json() as Promise<{ title?: string } | null>) : null))
.then((data) => {
const title = data?.title ?? "YouTube Video";
ytTitleCache.set(videoId, title);
setText(titleLink, title);
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -430,7 +430,8 @@ export type ClientMessage =
| (WsEnvelope<VoiceDeafenPayload> & { readonly type: "voice_deafen" })
| (WsEnvelope<VoiceCameraPayload> & { readonly type: "voice_camera" })
| (WsEnvelope<VoiceScreensharePayload> & { readonly type: "voice_screenshare" })
| (WsEnvelope<SoundboardPlayPayload> & { readonly type: "soundboard_play" });
| (WsEnvelope<SoundboardPlayPayload> & { readonly type: "soundboard_play" })
| (WsEnvelope<Record<string, never>> & { readonly type: "voice_token_refresh" });
// -----------------------------------------------------------------------------
// REST API Response Types
+5 -1
View File
@@ -96,7 +96,11 @@ export function createWsClient() {
if (state !== newState) {
state = newState;
for (const listener of stateListeners) {
listener(state);
try {
listener(state);
} catch (err) {
log.error("State listener error", err);
}
}
}
}
+73 -52
View File
@@ -20,6 +20,7 @@ import { channelsStore, getActiveChannel } from "@stores/channels.store";
import { voiceStore } from "@stores/voice.store";
import {
leaveVoice as voiceSessionLeave,
cleanupAll as voiceCleanupAll,
setOnRemoteVideo,
setOnRemoteVideoRemoved,
clearOnRemoteVideo,
@@ -126,19 +127,27 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
unsubscribers.push(
ws.onStateChange((wsState) => {
if (banner === null) return;
if (wsState === "reconnecting") {
banner.showReconnecting();
} else if (wsState === "connected") {
banner.hide();
try {
if (banner === null) return;
if (wsState === "reconnecting") {
banner.showReconnecting();
} else if (wsState === "connected") {
banner.hide();
}
} catch (err) {
log.error("State change handler error", err);
}
}),
);
unsubscribers.push(
ws.on("server_restart", (payload) => {
if (banner !== null) {
banner.showRestart(payload.delay_seconds);
try {
if (banner !== null) {
banner.showRestart(payload.delay_seconds);
}
} catch (err) {
log.error("Server restart handler error", err);
}
}),
);
@@ -279,21 +288,25 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
let prevLocalCamera = voiceStore.getState().localCamera;
let prevCameraSignature = "";
unsubscribers.push(voiceStore.subscribe((state) => {
// Build a lightweight signature of camera-relevant state
let sig = state.localCamera ? "1" : "0";
const channelId = state.currentChannelId;
if (channelId !== null) {
const users = state.voiceUsers.get(channelId);
if (users) {
for (const [uid, u] of users) {
if (u.camera) sig += `:${uid}`;
try {
// Build a lightweight signature of camera-relevant state
let sig = state.localCamera ? "1" : "0";
const channelId = state.currentChannelId;
if (channelId !== null) {
const users = state.voiceUsers.get(channelId);
if (users) {
for (const [uid, u] of users) {
if (u.camera) sig += `:${uid}`;
}
}
}
}
if (sig !== prevCameraSignature || state.localCamera !== prevLocalCamera) {
prevCameraSignature = sig;
prevLocalCamera = state.localCamera;
videoModeCtrl?.checkVideoMode();
if (sig !== prevCameraSignature || state.localCamera !== prevLocalCamera) {
prevCameraSignature = sig;
prevLocalCamera = state.localCamera;
videoModeCtrl?.checkVideoMode();
}
} catch (err) {
log.error("Voice store subscription error", err);
}
}));
@@ -327,42 +340,50 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
function destroy(): void {
log.info("MainPage destroying");
// Clean up voice session before destroying UI — prevents stale
// module-level state persisting across logout/reconnect cycles.
voiceSessionLeave(false);
clearVoiceOnError();
clearOnRemoteVideo();
channelCtrl?.destroyChannel();
channelCtrl = null;
try {
// Full voice cleanup — tears down room, callbacks, ws ref, serverHost.
// Prevents stale module-level state persisting across logout/reconnect cycles.
voiceCleanupAll();
channelCtrl?.destroyChannel();
channelCtrl = null;
reactionCtrl?.destroy();
reactionCtrl = null;
msgCtrl = null;
videoModeCtrl?.destroy();
videoModeCtrl = null;
reactionCtrl?.destroy();
reactionCtrl = null;
msgCtrl = null;
videoModeCtrl?.destroy();
videoModeCtrl = null;
videoGrid = null;
videoGrid = null;
for (const child of children) {
child.destroy?.();
for (const child of children) {
try {
child.destroy?.();
} catch (err) {
log.error("Child destroy error", err);
}
}
children = [];
for (const unsub of unsubscribers) {
try {
unsub();
} catch (err) {
log.error("Unsubscribe error", err);
}
}
unsubscribers = [];
if (banner !== null) {
banner.destroy();
banner = null;
}
} finally {
if (root !== null) {
root.remove();
root = null;
}
container = null;
}
children = [];
for (const unsub of unsubscribers) {
unsub();
}
unsubscribers = [];
if (banner !== null) {
banner.destroy();
banner = null;
}
if (root !== null) {
root.remove();
root = null;
}
container = null;
}
return { mount, destroy };
@@ -74,7 +74,7 @@ export function createChatArea(opts: ChatAreaOptions): ChatAreaResult {
getCurrentChannelId: () => getChannelCtrl()?.currentChannelId ?? null,
onJumpToMessage: (msgId: number) => {
const ctrl = getChannelCtrl();
if (ctrl?.messageList === null || ctrl?.messageList === undefined) return false;
if (ctrl == null || ctrl.messageList == null) return false;
return ctrl.messageList.scrollToMessage(msgId);
},
});
@@ -87,7 +87,7 @@ export function createChatArea(opts: ChatAreaOptions): ChatAreaResult {
getCurrentChannelId: () => getChannelCtrl()?.currentChannelId ?? null,
onJumpToMessage: (_channelId: number, msgId: number) => {
const ctrl = getChannelCtrl();
if (ctrl?.messageList === null || ctrl?.messageList === undefined) return false;
if (ctrl == null || ctrl.messageList == null) return false;
return ctrl.messageList.scrollToMessage(msgId);
},
});
+13 -7
View File
@@ -19,7 +19,7 @@ import (
//
// The client connects to wss://server:8443/livekit/ which is proxied to
// ws://localhost:7880/ on the LiveKit server.
func NewLiveKitProxy(livekitURL string) http.Handler {
func NewLiveKitProxy(livekitURL string, allowedOrigins []string) http.Handler {
target, err := url.Parse(livekitURL)
if err != nil {
target, _ = url.Parse("http://localhost:7880")
@@ -54,7 +54,7 @@ func NewLiveKitProxy(livekitURL string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Detect WebSocket upgrade requests.
if isWebSocketUpgrade(r) {
proxyWebSocket(w, r, &wsTarget)
proxyWebSocket(w, r, &wsTarget, allowedOrigins)
return
}
httpProxy.ServeHTTP(w, r)
@@ -72,7 +72,7 @@ func isWebSocketUpgrade(r *http.Request) bool {
// proxyWebSocket opens a backend WS connection and shovels data in both
// directions until either side closes.
func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL) {
func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL, allowedOrigins []string) {
// Build backend URL preserving the request path and query.
backendURL := *target
backendURL.Path = r.URL.Path
@@ -83,7 +83,7 @@ func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL) {
Subprotocols: r.Header.Values("Sec-WebSocket-Protocol"),
})
if err != nil {
slog.Warn("livekit proxy: backend dial failed", "url", backendURL.String(), "err", err)
slog.Warn("livekit proxy: backend dial failed", "host", backendURL.Host, "path", backendURL.Path, "err", err)
http.Error(w, "backend unavailable", http.StatusBadGateway)
return
}
@@ -92,7 +92,7 @@ func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL) {
// Accept the frontend WebSocket.
frontConn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
Subprotocols: []string{backConn.Subprotocol()},
OriginPatterns: []string{"*"},
OriginPatterns: allowedOrigins,
})
if err != nil {
slog.Warn("livekit proxy: frontend accept failed", "err", err)
@@ -100,7 +100,11 @@ func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL) {
}
defer frontConn.Close(websocket.StatusNormalClosure, "")
ctx := r.Context()
// Use a cancellable context so when one direction finishes, the other
// goroutine's copyWS read/write is unblocked and can drain cleanly.
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
errc := make(chan error, 2)
// Frontend → Backend
@@ -113,7 +117,9 @@ func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL) {
errc <- copyWS(ctx, frontConn, backConn)
}()
// Wait for either direction to finish.
// Wait for either direction to finish, then cancel+drain both.
<-errc
cancel()
<-errc
}
+35 -1
View File
@@ -89,10 +89,14 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri
r.Post("/api/v1/livekit/webhook",
ws.MountWebhookRoute(hub, cfg.Voice.LiveKitAPIKey, cfg.Voice.LiveKitAPISecret))
// LiveKit health check — admin-IP-restricted.
r.With(AdminIPRestrict(cfg.Server.AdminAllowedCIDRs)).
Get("/api/v1/livekit/health", handleLiveKitHealth(hub))
// Reverse proxy LiveKit signaling through OwnCord's HTTPS server.
// This avoids mixed-content blocks (secure page → insecure WS).
// Client connects to wss://server:8443/livekit/* → ws://localhost:7880/*
r.Handle("/livekit/*", http.StripPrefix("/livekit", NewLiveKitProxy(cfg.Voice.LiveKitURL)))
r.Handle("/livekit/*", http.StripPrefix("/livekit", NewLiveKitProxy(cfg.Voice.LiveKitURL, cfg.Server.AllowedOrigins)))
}
go hub.Run()
@@ -152,6 +156,36 @@ func handleInfo(cfg *config.Config, ver string) http.HandlerFunc {
}
}
// livekitHealthResponse is the JSON shape returned by GET /api/v1/livekit/health.
type livekitHealthResponse struct {
Status string `json:"status"`
LiveKitReachable bool `json:"livekit_reachable"`
Error string `json:"error,omitempty"`
}
func handleLiveKitHealth(hub *ws.Hub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ok, err := hub.LiveKitHealthCheck()
if ok {
writeJSON(w, http.StatusOK, livekitHealthResponse{
Status: "ok",
LiveKitReachable: true,
})
return
}
errMsg := "unknown"
if err != nil {
errMsg = err.Error()
}
writeJSON(w, http.StatusServiceUnavailable, livekitHealthResponse{
Status: "degraded",
LiveKitReachable: false,
Error: errMsg,
})
}
}
// setRequestIDHeader copies the request ID from context into the response header.
func setRequestIDHeader(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+15
View File
@@ -170,6 +170,21 @@ func (d *DB) ClearAllVoiceStates() error {
return nil
}
// CountActiveCameras returns the number of users with camera enabled in the
// given voice channel. Uses the DB as source of truth (race-free via SQLite
// serialization) rather than querying LiveKit.
func (d *DB) CountActiveCameras(channelID int64) (int, error) {
var count int
err := d.sqlDB.QueryRow(
`SELECT COUNT(*) FROM voice_states WHERE channel_id = ? AND camera = 1`,
channelID,
).Scan(&count)
if err != nil {
return 0, fmt.Errorf("CountActiveCameras: %w", err)
}
return count, nil
}
// UpdateVoiceCamera sets the camera field for the given user's voice state.
func (d *DB) UpdateVoiceCamera(userID int64, camera bool) error {
_, err := d.sqlDB.Exec(
+23 -1
View File
@@ -48,7 +48,10 @@ func main() {
// run is the real entrypoint — separated for testability.
func run(log *slog.Logger, logBuf *admin.RingBuffer) error {
// Clean up old binary from a previous update.
if exePath, err := os.Executable(); err == nil {
exePath, exeErr := os.Executable()
if exeErr != nil {
log.Warn("failed to determine executable path", "error", exeErr)
} else {
oldPath := exePath + ".old"
if _, statErr := os.Stat(oldPath); statErr == nil {
if rmErr := os.Remove(oldPath); rmErr != nil {
@@ -148,11 +151,23 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error {
go func() {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
consecutiveFailures := 0
const maxConsecutiveFailures = 5
for {
select {
case <-ticker.C:
if consecutiveFailures >= maxConsecutiveFailures {
log.Error("maintenance loop: circuit breaker open, skipping tick",
"consecutive_failures", consecutiveFailures)
// Reset after one skip to allow retry next tick.
consecutiveFailures = maxConsecutiveFailures - 1
continue
}
tickFailed := false
if err := database.DeleteExpiredSessions(); err != nil {
log.Warn("failed to delete expired sessions", "error", err)
tickFailed = true
}
// Clean up orphaned attachments (uploaded but never linked to a message).
@@ -160,6 +175,7 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error {
orphanFiles, orphanErr := database.DeleteOrphanedAttachments(cutoff)
if orphanErr != nil {
log.Warn("failed to delete orphaned attachments", "error", orphanErr)
tickFailed = true
} else if len(orphanFiles) > 0 {
// Best-effort file cleanup.
if fileStorage != nil {
@@ -171,6 +187,12 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error {
}
log.Info("cleaned up orphaned attachments", "count", len(orphanFiles))
}
if tickFailed {
consecutiveFailures++
} else {
consecutiveFailures = 0
}
case <-stopMaintenance:
return
}
+1 -1
View File
@@ -6,4 +6,4 @@ ALTER TABLE voice_states ADD COLUMN screenshare INTEGER NOT NULL DEFAULT 0;
ALTER TABLE channels ADD COLUMN voice_max_users INTEGER NOT NULL DEFAULT 0;
ALTER TABLE channels ADD COLUMN voice_quality TEXT;
ALTER TABLE channels ADD COLUMN mixing_threshold INTEGER;
ALTER TABLE channels ADD COLUMN voice_max_video INTEGER NOT NULL DEFAULT 10;
ALTER TABLE channels ADD COLUMN voice_max_video INTEGER NOT NULL DEFAULT 25;
+133
View File
@@ -592,6 +592,9 @@ func TestHandleVoiceMute_InvalidPayload(t *testing.T) {
hub.Register(c)
time.Sleep(20 * time.Millisecond)
// Put client in voice so the "not in voice" guard doesn't fire first.
ws.SetClientVoiceChID(c, 999)
raw, _ := json.Marshal(map[string]any{
"type": "voice_mute",
"payload": "not-an-object",
@@ -613,6 +616,9 @@ func TestHandleVoiceDeafen_InvalidPayload(t *testing.T) {
hub.Register(c)
time.Sleep(20 * time.Millisecond)
// Put client in voice so the "not in voice" guard doesn't fire first.
ws.SetClientVoiceChID(c, 999)
raw, _ := json.Marshal(map[string]any{
"type": "voice_deafen",
"payload": "not-an-object",
@@ -2055,3 +2061,130 @@ func TestHandleChatSend_WithNonNilAvatar(t *testing.T) {
t.Error("expected chat_message with non-nil avatar")
}
}
// ─── Webhook parse helpers ──────────────────────────────────────────────────
func TestWebhookParseIdentity_Valid(t *testing.T) {
id, err := ws.ParseIdentityForTest("user-42")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if id != 42 {
t.Errorf("id = %d, want 42", id)
}
}
func TestWebhookParseIdentity_Invalid(t *testing.T) {
_, err := ws.ParseIdentityForTest("invalid")
if err == nil {
t.Fatal("expected error for invalid identity, got nil")
}
}
func TestWebhookParseRoomChannelID_Valid(t *testing.T) {
id, err := ws.ParseRoomChannelIDForTest("channel-5")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if id != 5 {
t.Errorf("id = %d, want 5", id)
}
}
func TestWebhookParseRoomChannelID_Invalid(t *testing.T) {
_, err := ws.ParseRoomChannelIDForTest("bad")
if err == nil {
t.Fatal("expected error for invalid room name, got nil")
}
}
// ─── Voice control "not in voice" guards ────────────────────────────────────
func TestHandleVoiceMute_NotInVoice(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vm-not-in-voice")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "voice_mute",
"payload": map[string]any{
"muted": true,
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "VOICE_ERROR" {
t.Errorf("error code = %q, want VOICE_ERROR", code)
}
}
func TestHandleVoiceDeafen_NotInVoice(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vd-not-in-voice")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "voice_deafen",
"payload": map[string]any{
"deafened": true,
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(50 * time.Millisecond)
code := drainForErrorCode(send, 200*time.Millisecond)
if code != "VOICE_ERROR" {
t.Errorf("error code = %q, want VOICE_ERROR", code)
}
}
// ─── Voice join with invalid quality fallback ───────────────────────────────
func TestHandleVoiceJoin_InvalidQualityFallsBackToMedium(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "vj-badquality-user")
vcID, err := database.CreateChannel("badquality-vc", "voice", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
_, err = database.Exec("UPDATE channels SET voice_quality = 'garbage' WHERE id = ?", vcID)
if err != nil {
t.Fatalf("UPDATE: %v", err)
}
send := make(chan []byte, 64)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
raw, _ := json.Marshal(map[string]any{
"type": "voice_join",
"payload": map[string]any{
"channel_id": vcID,
},
})
hub.HandleMessageForTest(c, raw)
time.Sleep(100 * time.Millisecond)
msgs := drainChanTimeout(send, 300*time.Millisecond)
for _, msg := range msgs {
var env map[string]any
if json.Unmarshal(msg, &env) == nil && env["type"] == "voice_config" {
p := env["payload"].(map[string]any)
if p["quality"] != "medium" {
t.Errorf("voice_config quality = %v, want medium", p["quality"])
}
return
}
}
t.Error("expected voice_config with medium quality fallback")
}
+10
View File
@@ -41,3 +41,13 @@ func ParseChannelIDForTest(payload json.RawMessage) (int64, error) {
func BuildJSONForTest(v any) []byte {
return buildJSON(v)
}
// ParseIdentityForTest exposes parseIdentity for external tests.
func ParseIdentityForTest(identity string) (int64, error) {
return parseIdentity(identity)
}
// ParseRoomChannelIDForTest exposes parseRoomChannelID for external tests.
func ParseRoomChannelIDForTest(roomName string) (int64, error) {
return parseRoomChannelID(roomName)
}
+9 -1
View File
@@ -121,6 +121,8 @@ func (h *Hub) handleMessage(c *Client, raw []byte) {
h.handleVoiceJoin(c, env.Payload)
case "voice_leave":
h.handleVoiceLeave(c)
case "voice_token_refresh":
h.handleVoiceTokenRefresh(c)
case "voice_mute":
h.handleVoiceMute(c, env.Payload)
case "voice_deafen":
@@ -214,7 +216,13 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) {
if len(p.Attachments) > 0 {
linked, linkErr := h.db.LinkAttachmentsToMessage(msgID, p.Attachments)
if linkErr != nil {
slog.Error("ws handleChatSend LinkAttachments", "err", linkErr)
slog.Error("ws handleChatSend LinkAttachments", "err", linkErr, "msg_id", msgID)
// Delete the orphaned message so it doesn't persist without its attachments.
if delErr := h.db.DeleteMessage(msgID, c.userID, true); delErr != nil {
slog.Error("ws handleChatSend DeleteMessage (cleanup)", "err", delErr, "msg_id", msgID)
}
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to send message with attachments"))
return
}
if linked > 0 {
attMap, attErr := h.db.GetAttachmentsByMessageIDs([]int64{msgID})
+28 -5
View File
@@ -104,6 +104,17 @@ func (h *Hub) SetLiveKit(lk *LiveKitClient) {
h.livekit = lk
}
// LiveKitHealthCheck probes the LiveKit server for connectivity.
// It tries the SDK client first (ListRooms), and falls back to an HTTP probe
// if a managed process is configured. Returns false with a reason if LiveKit
// is not configured or unreachable.
func (h *Hub) LiveKitHealthCheck() (bool, error) {
if h.livekit == nil {
return false, fmt.Errorf("not configured")
}
return h.livekit.HealthCheck()
}
// SetLiveKitProcess sets the LiveKit process manager on the hub.
func (h *Hub) SetLiveKitProcess(p *LiveKitProcess) {
h.lkProcess = p
@@ -113,7 +124,7 @@ func (h *Hub) SetLiveKitProcess(p *LiveKitProcess) {
// Must be called in its own goroutine.
//
// A panic recovery wrapper restarts the select loop automatically. If the hub
// panics more than 5 times within a 60-second window it stops permanently to
// panics more than 3 times within a 60-second window it stops permanently to
// avoid a tight crash loop.
func (h *Hub) Run() {
var panicCount int
@@ -140,7 +151,7 @@ func (h *Hub) Run() {
"panic_count", panicCount,
"stack", string(buf[:n]))
if panicCount >= 5 {
if panicCount >= 3 {
slog.Error("hub: too many panics in 60s, stopping")
return
}
@@ -172,7 +183,7 @@ func (h *Hub) Run() {
}()
// If we reach here without a panic recovery continuing, stop.
if panicCount >= 5 {
if panicCount >= 3 {
return
}
// If stop was signaled, exit.
@@ -278,13 +289,25 @@ func (h *Hub) Unregister(c *Client) {
// BroadcastToChannel enqueues msg for delivery to all clients subscribed to
// channelID. When channelID is 0 the message is sent to every connected client.
// Non-blocking: if the broadcast channel is full the message is dropped with a warning.
func (h *Hub) BroadcastToChannel(channelID int64, msg []byte) {
h.broadcast <- broadcastMsg{channelID: channelID, msg: msg}
select {
case h.broadcast <- broadcastMsg{channelID: channelID, msg: msg}:
default:
slog.Warn("hub: broadcast channel full, dropping message",
"channel_id", channelID, "msg_len", len(msg))
}
}
// BroadcastToAll enqueues msg for delivery to every connected client.
// Non-blocking: if the broadcast channel is full the message is dropped with a warning.
func (h *Hub) BroadcastToAll(msg []byte) {
h.broadcast <- broadcastMsg{channelID: 0, msg: msg}
select {
case h.broadcast <- broadcastMsg{channelID: 0, msg: msg}:
default:
slog.Warn("hub: broadcast channel full, dropping global message",
"msg_len", len(msg))
}
}
// BroadcastServerRestart sends a server_restart message to all connected clients.
+17
View File
@@ -19,6 +19,9 @@ import (
)
// tokenTTL is the validity duration for generated LiveKit access tokens.
// Kept at 4h to limit exposure if a token is leaked — there is no server-side
// revocation for LiveKit JWTs. The client can request a refresh via
// voice_token_refresh before expiry.
const tokenTTL = 4 * time.Hour
// LiveKitClient provides token generation and room management via
@@ -157,6 +160,20 @@ func (c *LiveKitClient) CountVideoTracks(channelID int64) (int, error) {
return count, nil
}
// HealthCheck verifies connectivity to the LiveKit server by listing rooms.
// Returns true if the server responds successfully.
func (c *LiveKitClient) HealthCheck() (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := c.roomSvc.ListRooms(ctx, &livekit.ListRoomsRequest{})
if err != nil {
return false, fmt.Errorf("livekit health check failed: %w", err)
}
return true, nil
}
// wsToHTTP converts a WebSocket URL to an HTTP URL.
func wsToHTTP(wsURL string) string {
switch {
+71 -8
View File
@@ -9,6 +9,7 @@ import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
@@ -20,9 +21,10 @@ import (
// LiveKitProcess manages the companion livekit-server binary.
type LiveKitProcess struct {
cfg *config.VoiceConfig
tlsCfg *config.TLSConfig
dataDir string
cfg *config.VoiceConfig
tlsCfg *config.TLSConfig
dataDir string
httpClient *http.Client // for health checks — no redirect following
mu sync.Mutex
cmd *exec.Cmd
@@ -39,6 +41,11 @@ func NewLiveKitProcess(cfg *config.VoiceConfig, tlsCfg *config.TLSConfig, dataDi
cfg: cfg,
tlsCfg: tlsCfg,
dataDir: dataDir,
httpClient: &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
}
@@ -56,7 +63,7 @@ rtc:
port_range_end: 60000
use_external_ip: true
keys:
%s: %s
"%s": "%s"
logging:
level: info
`, p.cfg.LiveKitAPIKey, p.cfg.LiveKitAPISecret)
@@ -100,8 +107,18 @@ func (p *LiveKitProcess) Start() error {
}
// runLoop starts and restarts the process until stopped or context cancelled.
// Uses exponential backoff (3s → 6s → 12s … up to 60s) and stops after 10
// consecutive rapid failures (process exits within 30 seconds).
func (p *LiveKitProcess) runLoop(ctx context.Context, cfgPath string) {
const restartDelay = 3 * time.Second
const (
baseDelay = 3 * time.Second
maxDelay = 60 * time.Second
maxRetries = 10
stableAfter = 30 * time.Second // reset counter if process runs longer than this
)
rapidFailures := 0
delay := baseDelay
for {
if ctx.Err() != nil {
@@ -111,6 +128,7 @@ func (p *LiveKitProcess) runLoop(ctx context.Context, cfgPath string) {
cmd := exec.CommandContext(ctx, p.cfg.LiveKitBinaryPath, "--config", cfgPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.WaitDelay = 6 * time.Second // bound Wait to prevent goroutine leak on Windows
p.mu.Lock()
if p.stopped {
@@ -122,8 +140,10 @@ func (p *LiveKitProcess) runLoop(ctx context.Context, cfgPath string) {
slog.Info("livekit: starting process",
"binary", p.cfg.LiveKitBinaryPath,
"config", cfgPath)
"config", cfgPath,
"rapid_failures", rapidFailures)
startTime := time.Now()
err := cmd.Run()
p.mu.Lock()
@@ -136,18 +156,39 @@ func (p *LiveKitProcess) runLoop(ctx context.Context, cfgPath string) {
return
}
// If the process ran for a while, it was stable — reset backoff.
if time.Since(startTime) > stableAfter {
rapidFailures = 0
delay = baseDelay
} else {
rapidFailures++
}
if err != nil {
slog.Error("livekit: process exited unexpectedly",
"error", err,
"restart_delay", restartDelay)
"rapid_failures", rapidFailures,
"restart_delay", delay)
}
if rapidFailures >= maxRetries {
slog.Error("livekit: too many rapid failures, giving up",
"rapid_failures", rapidFailures)
return
}
select {
case <-time.After(restartDelay):
case <-time.After(delay):
slog.Info("livekit: restarting process")
case <-ctx.Done():
return
}
// Exponential backoff capped at maxDelay.
delay *= 2
if delay > maxDelay {
delay = maxDelay
}
}
}
@@ -158,6 +199,28 @@ func (p *LiveKitProcess) IsRunning() bool {
return p.cmd != nil && p.cmd.Process != nil
}
// HealthCheck probes the LiveKit HTTP endpoint to verify it is accepting
// connections. Returns true if the server responds (any status code).
func (p *LiveKitProcess) HealthCheck() (bool, error) {
httpURL := wsToHTTP(p.cfg.LiveKitURL)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, httpURL, nil)
if err != nil {
return false, fmt.Errorf("creating health check request: %w", err)
}
resp, err := p.httpClient.Do(req)
if err != nil {
return false, fmt.Errorf("livekit health check failed: %w", err)
}
resp.Body.Close()
return true, nil
}
// Stop gracefully stops the companion process.
func (p *LiveKitProcess) Stop() {
p.mu.Lock()
+4 -2
View File
@@ -22,7 +22,7 @@ import (
// RoomEvent.ActiveSpeakersChanged (lower latency than webhooks).
func (h *Hub) NewLiveKitWebhookHandler(apiKey, apiSecret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024))
if err != nil {
slog.Error("livekit webhook: read body failed", "error", err)
http.Error(w, "bad request", http.StatusBadRequest)
@@ -151,9 +151,11 @@ func (h *Hub) handleWebhookParticipantLeft(event *livekit.WebhookEvent) {
h.mu.RUnlock()
if exists {
// Only clean up if the user is still in the channel that fired the
// webhook. If they've already moved or left, don't touch their state.
currentChID := c.getVoiceChID()
if currentChID == channelID {
c.setVoiceChID(0)
c.clearVoiceChID()
if h.db != nil {
_ = h.db.LeaveVoiceChannel(userID)
+2
View File
@@ -3,6 +3,7 @@ package ws
import (
"encoding/json"
"fmt"
"log/slog"
"github.com/owncord/server/db"
)
@@ -155,6 +156,7 @@ type serverRestartPayload struct {
func buildJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
slog.Error("buildJSON marshal failed", "error", err, "type", fmt.Sprintf("%T", v))
// Fallback: send a generic error rather than panicking.
b, _ = json.Marshal(map[string]string{"type": "error", "message": "internal marshal error"})
}
+4
View File
@@ -92,6 +92,7 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun
writeCtx, writeCancel := context.WithCancel(ctx)
go writePump(writeCtx, conn, c)
readPump(ctx, conn, hub, c)
c.closeSend()
writeCancel()
return
}
@@ -120,9 +121,12 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun
hub.BroadcastToAll(buildPresenceMsg(user.ID, "online"))
// writePump runs in background; readPump blocks.
// When readPump returns (disconnect), close the send channel first
// so writePump drains any remaining messages, then cancel its context.
writeCtx, writeCancel := context.WithCancel(ctx)
go writePump(writeCtx, conn, c)
readPump(ctx, conn, hub, c)
c.closeSend()
writeCancel()
}
}
+12 -7
View File
@@ -13,16 +13,21 @@ const (
voiceScreenshareWindow = time.Second
)
// voiceQualities maps accepted voice quality presets to their target bitrate
// in bits/s. This is the single source of truth — voice_join.go validates
// against these keys, qualityBitrate looks up the value.
var voiceQualities = map[string]int{
"low": 32000,
"medium": 64000,
"high": 128000,
}
// qualityBitrate returns the target audio bitrate in bits/s based on a quality preset.
func qualityBitrate(quality string) int {
switch quality {
case "low":
return 32000
case "high":
return 128000
default:
return 64000
if bitrate, ok := voiceQualities[quality]; ok {
return bitrate
}
return voiceQualities["medium"]
}
// broadcastVoiceStateUpdate fetches the current voice state for the client
+15 -4
View File
@@ -13,6 +13,11 @@ import (
// 2. Updates DB.
// 3. Broadcasts voice_state update to channel.
func (h *Hub) handleVoiceMute(c *Client, payload json.RawMessage) {
if c.getVoiceChID() == 0 {
c.sendMsg(buildErrorMsg(ErrCodeVoiceError, "not in a voice channel"))
return
}
var p struct {
Muted bool `json:"muted"`
}
@@ -36,6 +41,11 @@ func (h *Hub) handleVoiceMute(c *Client, payload json.RawMessage) {
// 2. Updates DB.
// 3. Broadcasts voice_state update to channel.
func (h *Hub) handleVoiceDeafen(c *Client, payload json.RawMessage) {
if c.getVoiceChID() == 0 {
c.sendMsg(buildErrorMsg(ErrCodeVoiceError, "not in a voice channel"))
return
}
var p struct {
Deafened bool `json:"deafened"`
}
@@ -58,7 +68,7 @@ func (h *Hub) handleVoiceDeafen(c *Client, payload json.RawMessage) {
// 1. Rate limits at 2/sec per user.
// 2. Checks USE_VIDEO permission.
// 3. Parses enabled bool.
// 4. Enforces MaxVideo limit via LiveKit.
// 4. Enforces MaxVideo limit via DB count (race-free).
// 5. Updates DB.
// 6. Broadcasts voice_state update to channel.
func (h *Hub) handleVoiceCamera(c *Client, payload json.RawMessage) {
@@ -87,12 +97,13 @@ func (h *Hub) handleVoiceCamera(c *Client, payload json.RawMessage) {
}
// Enforce MaxVideo limit when enabling camera.
// Count from DB (race-free via SQLite serialization) instead of LiveKit API.
if p.Enabled {
ch, chErr := h.db.GetChannel(voiceChID)
if chErr == nil && ch != nil && ch.VoiceMaxVideo > 0 && h.livekit != nil {
videoCount, countErr := h.livekit.CountVideoTracks(voiceChID)
if chErr == nil && ch != nil && ch.VoiceMaxVideo > 0 {
videoCount, countErr := h.db.CountActiveCameras(voiceChID)
if countErr != nil {
slog.Error("handleVoiceCamera CountVideoTracks", "err", countErr, "channel_id", voiceChID)
slog.Error("handleVoiceCamera CountActiveCameras", "err", countErr, "channel_id", voiceChID)
} else if videoCount >= ch.VoiceMaxVideo {
c.sendMsg(buildErrorMsg(ErrCodeVideoLimit,
fmt.Sprintf("maximum %d video streams reached", ch.VoiceMaxVideo)))
+85 -9
View File
@@ -2,11 +2,20 @@ package ws
import (
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/owncord/server/permissions"
)
// validVoiceQuality returns true if q is an accepted voice quality preset.
// Uses voiceQualities (defined in voice_broadcast.go) as the single source of truth.
func validVoiceQuality(q string) bool {
_, ok := voiceQualities[q]
return ok
}
// handleVoiceJoin processes a voice_join message.
// 1. Parses channel_id.
// 2. Checks CONNECT_VOICE permission.
@@ -73,30 +82,39 @@ func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) {
c.setVoiceChID(channelID)
// Generate LiveKit token if LiveKit client is available.
// Token generation failure is fatal — without a token the client cannot
// connect to the SFU, so we must roll back the DB join.
if h.livekit != nil {
if c.user == nil {
slog.Error("handleVoiceJoin: nil user on client", "user_id", c.userID)
h.rollbackVoiceJoin(c, channelID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "not authenticated"))
return
}
canPublish := true
// Derive publish permissions from role — prevents SFU-level bypass
// when client connects directly via direct_url.
canPublish := h.hasChannelPerm(c, channelID, permissions.SpeakVoice)
canSubscribe := true
token, tokenErr := h.livekit.GenerateToken(c.userID, c.user.Username, channelID, canPublish, canSubscribe)
if tokenErr != nil {
slog.Error("ws handleVoiceJoin GenerateToken", "err", tokenErr, "user_id", c.userID)
// Non-fatal: voice join still succeeds at the DB/state level.
} else {
// Send both proxy path and direct URL. The client uses direct_url
// when on localhost (avoids self-signed TLS issues with WebView
// fetch) and falls back to the /livekit proxy for remote clients.
c.sendMsg(buildVoiceToken(channelID, token, "/livekit", h.livekit.URL()))
h.rollbackVoiceJoin(c, channelID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to generate voice token"))
return
}
// Send both proxy path and direct URL. The client uses direct_url
// when on localhost (avoids self-signed TLS issues with WebView
// fetch) and falls back to the /livekit proxy for remote clients.
c.sendMsg(buildVoiceToken(channelID, token, "/livekit", h.livekit.URL()))
}
// Get and broadcast the joiner's state.
// Get and broadcast the joiner's state. Failure here means other users
// won't see the join (ghost state), so roll back to avoid inconsistency.
state, err := h.db.GetVoiceState(c.userID)
if err != nil || state == nil {
slog.Error("ws handleVoiceJoin GetVoiceState", "err", err, "user_id", c.userID)
h.rollbackVoiceJoin(c, channelID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to join voice channel"))
return
}
@@ -119,10 +137,68 @@ func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) {
// Send voice_config to the joiner.
quality := "medium"
if ch.VoiceQuality != nil && *ch.VoiceQuality != "" {
quality = *ch.VoiceQuality
q := *ch.VoiceQuality
if validVoiceQuality(q) {
quality = q
} else {
slog.Warn("ws handleVoiceJoin invalid voice quality, using default",
"quality", q, "channel_id", channelID)
}
}
bitrate := qualityBitrate(quality)
c.sendMsg(buildVoiceConfig(channelID, quality, bitrate, maxUsers))
slog.Info("voice join", "user_id", c.userID, "channel_id", channelID)
}
// handleVoiceTokenRefresh generates a fresh LiveKit token for a client
// that is already in a voice channel. This lets clients request a new token
// (e.g. before a manual reconnect) without leaving and rejoining voice.
func (h *Hub) handleVoiceTokenRefresh(c *Client) {
ratKey := fmt.Sprintf("voice_token_refresh:%d", c.userID)
if !h.limiter.Allow(ratKey, 1, 60*time.Second) {
c.sendMsg(buildRateLimitError("token refresh rate limited", 60))
return
}
channelID := c.getVoiceChID()
if channelID == 0 {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "not in voice"))
return
}
if h.livekit == nil {
c.sendMsg(buildErrorMsg(ErrCodeInternal, "voice not configured"))
return
}
if c.user == nil {
slog.Error("handleVoiceTokenRefresh: nil user on client", "user_id", c.userID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "not authenticated"))
return
}
canPublish := h.hasChannelPerm(c, channelID, permissions.SpeakVoice)
canSubscribe := true
token, err := h.livekit.GenerateToken(c.userID, c.user.Username, channelID, canPublish, canSubscribe)
if err != nil {
slog.Error("ws handleVoiceTokenRefresh GenerateToken", "err", err, "user_id", c.userID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to generate voice token"))
return
}
c.sendMsg(buildVoiceToken(channelID, token, "/livekit", h.livekit.URL()))
slog.Info("voice token refreshed", "user_id", c.userID, "channel_id", channelID)
}
// rollbackVoiceJoin undoes a partially-completed voice join: clears the
// client's voice channel ID, removes the DB voice state row, and broadcasts
// voice_leave so other clients don't see a ghost participant.
func (h *Hub) rollbackVoiceJoin(c *Client, channelID int64) {
c.clearVoiceChID()
if err := h.db.LeaveVoiceChannel(c.userID); err != nil {
slog.Error("ws rollbackVoiceJoin LeaveVoiceChannel", "err", err,
"user_id", c.userID, "channel_id", channelID)
}
h.BroadcastToAll(buildVoiceLeave(channelID, c.userID))
}
+1 -1
View File
@@ -25,7 +25,7 @@ func (h *Hub) handleVoiceLeave(c *Client) {
// Remove from LiveKit (best-effort).
if h.livekit != nil {
if err := h.livekit.RemoveParticipant(oldChID, c.userID); err != nil {
slog.Debug("handleVoiceLeave RemoveParticipant (may already be gone)",
slog.Warn("handleVoiceLeave RemoveParticipant failed (may already be gone)",
"err", err, "user_id", c.userID, "channel_id", oldChID)
}
}
+13
View File
@@ -0,0 +1,13 @@
# TODOS
Deferred work items from engineering review (2026-03-21).
## Voice E2E Test Infrastructure
**What:** Add E2E test infrastructure for voice flows (voice_join → LiveKit connect → audio → voice_leave).
**Why:** The voice path is critical UX with zero automated E2E coverage. Unit tests cover handlers and controllers, but nothing tests the full integration.
**Pros:** Catches integration bugs between server + LiveKit + client.
**Cons:** Requires LiveKit binary in CI, WebRTC support in test browser, ~200 lines of test infra.
**Context:** The existing native E2E infrastructure (WebView2 CDP) could be extended. Needs CI setup first.
**Depends on:** LiveKit binary available in CI environment.
**Added:** 2026-03-21 (eng review of feature/livekit-migration)