From c3a8aa477cc9ef47f2dd3ed94a59e5c3e2180e66 Mon Sep 17 00:00:00 2001 From: J3vb Date: Fri, 3 Apr 2026 23:18:06 +0200 Subject: [PATCH] fix: resolve 20 code review bugs across Rust, TypeScript, and Go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical/High Rust (Tauri client): - BUG-140: replace .run() with .build() + RunEvent::Exit handler; native error dialog on startup failure - BUG-141: eliminate PTT thread TOCTOU race with Mutex critical section; add AtomicBool shutdown and catch_unwind - BUG-144: fix TOFU cert store corruption — read-before-write rollback restores previous fingerprint on save failure (all 3 write sites) - BUG-145: add VK code range guard (1..=254) in is_key_down; fix cast to (state as i16) < 0 - BUG-147: replace bare spawns with JoinSet; abort_all + drain on exit; unconditional closed event - BUG-150: add CRLF guard in handle_connection before header rewriting - BUG-151: wrap header read loop in tokio::time::timeout(10s) - BUG-158: extract CERTS_STORE/SETTINGS_STORE to constants.rs (eliminate 3 duplicates) - HIGH-2: PTT thread self-cleanup uses unwrap_or_else defensive pattern - HIGH-4: ws_send distinguishes Full vs Closed errors; warn log on backpressure Critical/High TypeScript (Tauri client): - BUG-142: join-generation counter prevents stale connectAndSetup completions - BUG-143: replace 8 mutable LiveKit session fields with discriminated union SessionState - BUG-146: 60s token refresh deadline; cleared on reply or voice leave - BUG-148: ResizeObserver hoisted to outer scope; disconnect() in destroy() before ac.abort() - BUG-152: dismissSignal.aborted guard already present (no change needed) - BUG-153: measureRendered split into two-pass read-then-write; eliminates per-message reflow - BUG-154: WS dedup cache batch-evicts to 80% on overflow (amortised O(1)) - BUG-157: pendingUpdates replaced with coalesced function-composition slot (O(1) queue depth) Go server: - BUG-149: safe two-value type assertion in getOutboundIP with localhost fallback - BUG-155: broadcast buffer 256→1024; broadcastDrops atomic counter exposed in /api/v1/metrics - BUG-156: LiveKitHealthCheck and implementations accept ctx context.Context; all call sites pass r.Context() (12 files) - BUG-159: MaxMessageBytes constant in config/constants.go; replaces 1<<20 literals in serve.go and updater.go - HIGH-1: cert store rollback reads old value before write; restores previous cert on save failure All validation passes: go build, go vet, cargo check, npm typecheck --- .github/workflows/claude-code-review.yml | 42 -- Client/tauri-client/src-tauri/Cargo.lock | 1 + Client/tauri-client/src-tauri/Cargo.toml | 1 + Client/tauri-client/src-tauri/src/commands.rs | 18 +- .../tauri-client/src-tauri/src/constants.rs | 5 + Client/tauri-client/src-tauri/src/lib.rs | 30 +- .../src-tauri/src/livekit_proxy.rs | 49 +- Client/tauri-client/src-tauri/src/ptt.rs | 99 ++- Client/tauri-client/src-tauri/src/ws_proxy.rs | 145 +++-- .../src/components/MessageList.ts | 34 +- Client/tauri-client/src/lib/livekitSession.ts | 563 ++++++++++++++---- Client/tauri-client/src/lib/store.ts | 26 +- Client/tauri-client/src/lib/ws.ts | 21 +- README.md | 390 ++++-------- Server/api/constants.go | 8 +- Server/api/coverage_push_test.go | 9 +- Server/api/diagnostics_handler.go | 2 +- Server/api/export_test.go | 9 +- Server/api/metrics_handler.go | 8 +- Server/api/metrics_handler_test.go | 7 +- Server/api/middleware_test.go | 7 +- Server/api/router.go | 6 +- Server/config/constants.go | 8 + Server/main.go | 7 +- Server/updater/updater.go | 11 +- Server/ws/client.go | 2 +- Server/ws/hub.go | 20 +- Server/ws/hub_test.go | 3 +- Server/ws/livekit.go | 4 +- Server/ws/livekit_process.go | 4 +- Server/ws/livekit_test.go | 7 +- Server/ws/serve.go | 7 +- docs/deployment.md | 6 +- docs/port-forwarding.md | 60 +- docs/quick-start.md | 116 ++-- docs/tailscale.md | 38 +- 36 files changed, 1087 insertions(+), 686 deletions(-) delete mode 100644 .github/workflows/claude-code-review.yml create mode 100644 Client/tauri-client/src-tauri/src/constants.rs create mode 100644 Server/config/constants.go diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index 1ce53ebb..00000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Claude Code Review - -on: - pull_request_target: - types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' - plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - diff --git a/Client/tauri-client/src-tauri/Cargo.lock b/Client/tauri-client/src-tauri/Cargo.lock index da6295a3..208dccc8 100644 --- a/Client/tauri-client/src-tauri/Cargo.lock +++ b/Client/tauri-client/src-tauri/Cargo.lock @@ -2949,6 +2949,7 @@ dependencies = [ "futures-util", "keyring", "log", + "rfd", "ring", "rustls", "serde", diff --git a/Client/tauri-client/src-tauri/Cargo.toml b/Client/tauri-client/src-tauri/Cargo.toml index f194bf9c..f33837a0 100644 --- a/Client/tauri-client/src-tauri/Cargo.toml +++ b/Client/tauri-client/src-tauri/Cargo.toml @@ -39,6 +39,7 @@ ring = "0.17" log = "0.4" env_logger = "0.11" keyring = "3" +rfd = { version = "0.16", default-features = false } [target.'cfg(windows)'.dependencies] windows = { version = "0.58", features = ["Win32_UI_Input_KeyboardAndMouse"] } diff --git a/Client/tauri-client/src-tauri/src/commands.rs b/Client/tauri-client/src-tauri/src/commands.rs index e7e24565..9f1f901d 100644 --- a/Client/tauri-client/src-tauri/src/commands.rs +++ b/Client/tauri-client/src-tauri/src/commands.rs @@ -1,8 +1,7 @@ use serde_json::Value; use tauri_plugin_store::StoreExt; -const SETTINGS_STORE: &str = "settings.json"; -const CERTS_STORE: &str = "certs.json"; +use crate::constants::{CERTS_STORE, SETTINGS_STORE}; /// Maximum length for a settings key to prevent denial-of-service. const MAX_SETTINGS_KEY_LEN: usize = 128; @@ -107,10 +106,19 @@ pub fn store_cert_fingerprint( .store(CERTS_STORE) .map_err(|e| format!("failed to open certs store: {e}"))?; + // Capture old value before mutating so we can restore it if save fails. + let old_value = store.get(&host); store.set(&host, Value::String(fingerprint)); - store - .save() - .map_err(|e| format!("failed to persist cert fingerprint: {e}"))?; + if let Err(e) = store.save() { + // Restore previous in-memory state: put back old fingerprint if one + // existed, or delete if there was none. Without this, a failed save + // during cert rotation would silently lose the previously trusted cert. + match old_value { + Some(v) => { let _ = store.set(&host, v); } + None => { let _ = store.delete(&host); } + } + return Err(format!("failed to persist cert fingerprint: {e}")); + } Ok(()) } diff --git a/Client/tauri-client/src-tauri/src/constants.rs b/Client/tauri-client/src-tauri/src/constants.rs new file mode 100644 index 00000000..95a32016 --- /dev/null +++ b/Client/tauri-client/src-tauri/src/constants.rs @@ -0,0 +1,5 @@ +/// Tauri store file for persisted certificate fingerprints (TOFU pinning). +pub const CERTS_STORE: &str = "certs.json"; + +/// Tauri store file for user settings and preferences. +pub const SETTINGS_STORE: &str = "settings.json"; diff --git a/Client/tauri-client/src-tauri/src/lib.rs b/Client/tauri-client/src-tauri/src/lib.rs index a818d061..5b40fc3e 100644 --- a/Client/tauri-client/src-tauri/src/lib.rs +++ b/Client/tauri-client/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod commands; +mod constants; mod credentials; mod livekit_proxy; mod ptt; @@ -8,7 +9,7 @@ mod ws_proxy; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() + match tauri::Builder::default() .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_notification::init()) @@ -56,6 +57,29 @@ pub fn run() { tray::create_tray(app.handle())?; Ok(()) }) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + { + Ok(app) => { + app.run(|_app, event| { + if let tauri::RunEvent::Exit = event { + // Stop the PTT polling thread before the process tears down. + // This ensures the AppHandle held inside the thread is released + // cleanly and the thread does not call app.emit on a dead runtime. + ptt::ptt_stop_internal(); + } + }); + } + Err(e) => { + eprintln!("Fatal startup error: {e}"); + #[cfg(not(target_os = "linux"))] + rfd::MessageDialog::new() + .set_title("OwnCord failed to start") + .set_description(&format!( + "The application encountered a startup error and cannot continue.\n\n{e}" + )) + .set_level(rfd::MessageLevel::Error) + .show(); + std::process::exit(1); + } + } } diff --git a/Client/tauri-client/src-tauri/src/livekit_proxy.rs b/Client/tauri-client/src-tauri/src/livekit_proxy.rs index 7547143e..f06d5f1d 100644 --- a/Client/tauri-client/src-tauri/src/livekit_proxy.rs +++ b/Client/tauri-client/src-tauri/src/livekit_proxy.rs @@ -36,6 +36,7 @@ use tauri_plugin_store::StoreExt; use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::Mutex; +use tokio::time::{timeout, Duration}; /// Tauri-managed state for the LiveKit TLS proxy. pub struct LiveKitProxyState { @@ -67,8 +68,7 @@ impl LiveKitProxyState { // TLS certificate verifier — pinned fingerprint check // --------------------------------------------------------------------------- -/// Tauri store file for certificate fingerprints (shared with ws_proxy). -pub(crate) const CERTS_STORE: &str = "certs.json"; +use crate::constants::CERTS_STORE; /// Verifies the server certificate against a known SHA-256 fingerprint. /// Reuses the fingerprint stored by ws_proxy's TOFU handshake for the same @@ -326,22 +326,39 @@ async fn handle_connection( pinned_fingerprint: &str, ) -> Result<(), Box> { // ── 1. Read HTTP request headers (up to \r\n\r\n) ──────────────────── + // Guarded by a 10-second timeout so a slow or stalled client cannot + // hold the Tokio task open indefinitely (BUG-151). let mut buf = Vec::with_capacity(4096); - let mut trailer = [0u8; 4]; - loop { - let mut byte = [0u8; 1]; - local.read_exact(&mut byte).await?; - buf.push(byte[0]); - trailer[0] = trailer[1]; - trailer[1] = trailer[2]; - trailer[2] = trailer[3]; - trailer[3] = byte[0]; - if trailer == *b"\r\n\r\n" { - break; - } - if buf.len() > 16_384 { - return Err("HTTP request headers too large".into()); + timeout(Duration::from_secs(10), async { + let mut trailer = [0u8; 4]; + loop { + let mut byte = [0u8; 1]; + local.read_exact(&mut byte).await?; + buf.push(byte[0]); + trailer[0] = trailer[1]; + trailer[1] = trailer[2]; + trailer[2] = trailer[3]; + trailer[3] = byte[0]; + if trailer == *b"\r\n\r\n" { + break; + } + if buf.len() > 16_384 { + return Err(Box::::from( + "HTTP request headers too large", + )); + } } + Ok::<(), Box>(()) + }) + .await + .map_err(|_| Box::::from( + "upstream header read timed out", + ))??; + + // Reject CRLF in remote_host before header insertion (defense-in-depth; + // primary validation is in start_livekit_proxy). + if remote_host.contains('\r') || remote_host.contains('\n') { + return Err("remote_host contains CRLF — header injection rejected".into()); } // ── 2. Rewrite Host and Origin headers ─────────────────────────────── diff --git a/Client/tauri-client/src-tauri/src/ptt.rs b/Client/tauri-client/src-tauri/src/ptt.rs index 854d7eb8..46c073b4 100644 --- a/Client/tauri-client/src-tauri/src/ptt.rs +++ b/Client/tauri-client/src-tauri/src/ptt.rs @@ -9,13 +9,21 @@ //! This ensures the stored integer is consistent on both Windows and Linux. use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; +use std::sync::Mutex; use std::time::Duration; use tauri::{AppHandle, Emitter, Runtime}; /// Virtual key code for the PTT key. 0 = disabled. static PTT_VKEY: AtomicI32 = AtomicI32::new(0); -/// Whether the polling loop is running. +/// Whether the polling loop is running (intent flag, kept for backwards compat). static PTT_RUNNING: AtomicBool = AtomicBool::new(false); +/// Shutdown signal sent into the polling thread. Separate from PTT_RUNNING so +/// that "stop the loop now" and "should a loop be running" are distinct. +static PTT_SHUTDOWN: AtomicBool = AtomicBool::new(false); +/// Handle to the polling thread. `Some` means a thread is alive; `None` means +/// no thread exists. This Mutex is the authoritative critical section that +/// prevents duplicate thread spawns. +static PTT_THREAD: Mutex>> = Mutex::new(None); // --------------------------------------------------------------------------- // Platform-specific key detection @@ -23,9 +31,15 @@ static PTT_RUNNING: AtomicBool = AtomicBool::new(false); #[cfg(windows)] fn is_key_down(vk: i32) -> bool { + // VK codes 1-254 are valid; 0 and 255 are reserved/undefined + if !(1..=254).contains(&vk) { + return false; + } + // SAFETY: GetAsyncKeyState is safe to call with valid VK codes 1-254 let state = unsafe { windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState(vk) }; - (state as u16 & 0x8000) != 0 + // High-order bit set (negative when interpreted as i16) = key is down + (state as i16) < 0 } #[cfg(target_os = "linux")] @@ -241,33 +255,88 @@ mod linux { // --------------------------------------------------------------------------- /// Start the PTT polling loop. Emits `ptt-state` (bool) events. +/// +/// Uses `PTT_THREAD`'s Mutex as the critical section to prevent duplicate +/// thread spawns. The `PTT_SHUTDOWN` flag is passed into the thread loop so +/// it can be stopped cleanly from `ptt_stop` or `ptt_stop_internal`. #[tauri::command] pub fn ptt_start(app: AppHandle) { - if PTT_RUNNING.swap(true, Ordering::SeqCst) { - return; // already running + let mut guard = PTT_THREAD.lock().unwrap_or_else(|e| e.into_inner()); + if guard.is_some() { + return; // thread already alive — Mutex is the authoritative check } - std::thread::spawn(move || { - let mut was_pressed = false; + // Reset the shutdown flag before spawning so the loop doesn't exit + // immediately if a previous ptt_stop set it. + PTT_SHUTDOWN.store(false, Ordering::SeqCst); + PTT_RUNNING.store(true, Ordering::SeqCst); - while PTT_RUNNING.load(Ordering::SeqCst) { - let vk = PTT_VKEY.load(Ordering::SeqCst); - if vk != 0 { - let pressed = is_key_down(vk); - if pressed != was_pressed { - was_pressed = pressed; - let _ = app.emit("ptt-state", pressed); + let handle = std::thread::spawn(move || { + // Wrap the entire loop body in catch_unwind so that panics from + // is_key_down (unsafe FFI) or app.emit do not leave PTT_RUNNING + // stuck at true with no way to recover. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut was_pressed = false; + + while !PTT_SHUTDOWN.load(Ordering::SeqCst) { + let vk = PTT_VKEY.load(Ordering::SeqCst); + if vk != 0 { + let pressed = is_key_down(vk); + if pressed != was_pressed { + was_pressed = pressed; + let _ = app.emit("ptt-state", pressed); + } } + std::thread::sleep(Duration::from_millis(20)); } - std::thread::sleep(Duration::from_millis(20)); + })); + + // Clear the thread handle slot so ptt_start can spawn a replacement. + // Use unwrap_or_else to handle a poisoned Mutex defensively, matching + // the pattern used in ptt_stop_internal. + let mut g = PTT_THREAD.lock().unwrap_or_else(|e| e.into_inner()); + *g = None; + PTT_RUNNING.store(false, Ordering::SeqCst); + + if result.is_err() { + log::error!("PTT polling thread panicked — PTT is no longer active"); + // Notify the frontend so it can surface a warning and offer retry. + let _ = app.emit("ptt-error", "PTT thread panicked"); } }); + + *guard = Some(handle); } -/// Stop the PTT polling loop. +/// Stop the PTT polling loop (IPC-callable command). #[tauri::command] pub fn ptt_stop() { + ptt_stop_internal(); +} + +/// Stop the PTT polling thread and block until it has fully exited. +/// +/// This is the internal, non-IPC version called from the Tauri lifecycle +/// handler (`RunEvent::Exit`) to guarantee the thread is gone before the +/// process tears down, preventing the `AppHandle` from being used against +/// a half-torn-down runtime. +pub fn ptt_stop_internal() { + // Signal the thread to exit. + PTT_SHUTDOWN.store(true, Ordering::SeqCst); PTT_RUNNING.store(false, Ordering::SeqCst); + + // Take the handle out of the Mutex so we can join it outside the lock, + // avoiding a potential deadlock if the thread itself tries to lock + // PTT_THREAD on exit. + let handle = { + let mut guard = PTT_THREAD.lock().unwrap_or_else(|e| e.into_inner()); + guard.take() + }; + + if let Some(h) = handle { + // Best-effort join — ignore if the thread already exited or panicked. + let _ = h.join(); + } } /// Set the PTT virtual key code. Pass 0 to disable. diff --git a/Client/tauri-client/src-tauri/src/ws_proxy.rs b/Client/tauri-client/src-tauri/src/ws_proxy.rs index 111c9be8..7327e885 100644 --- a/Client/tauri-client/src-tauri/src/ws_proxy.rs +++ b/Client/tauri-client/src-tauri/src/ws_proxy.rs @@ -15,23 +15,25 @@ use std::time::Duration; use tauri::{AppHandle, Emitter, Runtime}; use tauri_plugin_store::StoreExt; use tokio::sync::{mpsc, Mutex}; +use tokio::task::JoinSet; use tokio_tungstenite::tungstenite::Message; /// Maximum time to wait for the WebSocket handshake to complete. const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -/// Tauri store file for certificate fingerprints. -const CERTS_STORE: &str = "certs.json"; +use crate::constants::CERTS_STORE; /// Sender half kept in Tauri state so `ws_send` can push messages. +/// `tx` is wrapped in `Arc` so the monitoring task can clone a reference +/// into its closure and clear the sender even after a worker task panic. pub struct WsState { - tx: Mutex>>, + tx: Arc>>>, } impl WsState { pub fn new() -> Self { Self { - tx: Mutex::new(None), + tx: Arc::new(Mutex::new(None)), } } } @@ -158,8 +160,16 @@ fn tofu_check( match stored { None => { // First use — store the fingerprint. + // Capture old value before mutating (None here, but consistent pattern). + let old_value = store.get(host); store.set(host, Value::String(fingerprint.to_string())); if let Err(e) = store.save() { + // Restore previous in-memory state: put back old value or delete + // if there was none, keeping in-memory consistent with on-disk. + match old_value { + Some(v) => { let _ = store.set(host, v); } + None => { let _ = store.delete(host); } + } return Err(format!("failed to persist cert fingerprint: {e}")); } Ok("trusted_first_use".to_string()) @@ -300,50 +310,72 @@ pub async fn ws_connect( let app_read = app.clone(); let app_state = app.clone(); + // Clone the Arc so the monitoring closure can clear tx on any exit path, + // including worker task panics, without needing tauri::State. + let tx_arc = Arc::clone(&state.tx); - // Task: forward server → JS - let mut read_task = tokio::spawn(async move { - while let Some(msg) = stream.next().await { - match msg { - Ok(Message::Text(text)) => { - let _ = app_read.emit("ws-message", text.to_string()); - } - Ok(Message::Close(frame)) => { - debug!("[ws_proxy] server sent Close frame: {:?}", frame); - break; - } - Err(e) => { - warn!("[ws_proxy] read error: {}", e); - let _ = app_read.emit("ws-error", format!("{e}")); - break; - } - _ => {} // ignore binary/ping/pong - } - } - }); - - // Task: forward JS → server - let mut write_task = tokio::spawn(async move { - while let Some(msg) = rx.recv().await { - if sink.send(Message::Text(msg.into())).await.is_err() { - break; - } - } - }); - - // When either task ends, abort sibling and emit closed + // Single outer task owns a JoinSet containing read and write workers. + // join_next() blocks until the first worker finishes (normally or via panic), + // then abort_all() + drain guarantees both workers and their sockets are + // cleaned up before the closed event is emitted. tokio::spawn(async move { - tokio::select! { - _ = &mut read_task => { - debug!("[ws_proxy] read task ended, aborting write task"); - write_task.abort(); + let mut set = JoinSet::new(); + + // Task: forward server → JS + set.spawn(async move { + while let Some(msg) = stream.next().await { + match msg { + Ok(Message::Text(text)) => { + let _ = app_read.emit("ws-message", text.to_string()); + } + Ok(Message::Close(frame)) => { + debug!("[ws_proxy] server sent Close frame: {:?}", frame); + break; + } + Err(e) => { + warn!("[ws_proxy] read error: {}", e); + let _ = app_read.emit("ws-error", format!("{e}")); + break; + } + _ => {} // ignore binary/ping/pong + } } - _ = &mut write_task => { - debug!("[ws_proxy] write task ended, aborting read task"); - read_task.abort(); + }); + + // Task: forward JS → server + set.spawn(async move { + while let Some(msg) = rx.recv().await { + if sink.send(Message::Text(msg.into())).await.is_err() { + break; + } + } + }); + + // Block until the first worker finishes (normal exit or panic). + let first = set.join_next().await; + + // Cancel the sibling and drain it so sockets close cleanly before + // emitting state. abort_all() is a no-op if only one task remains. + set.abort_all(); + while set.join_next().await.is_some() {} + + match first { + Some(Err(ref e)) if e.is_panic() => { + error!("[ws_proxy] worker task panicked: {:?}", e); + } + _ => { + info!("[ws_proxy] connection closed"); } } - info!("[ws_proxy] connection closed"); + + // Clear the sender so ws_send returns "not connected". This runs on + // every exit path — normal close, graceful disconnect, and panic. + { + let mut tx_lock = tx_arc.lock().await; + *tx_lock = None; + } + + // Always emit closed, even after a panic. emit_ws_state(&app_state, "closed"); }); @@ -358,7 +390,16 @@ pub async fn ws_send( ) -> Result<(), String> { let tx_lock = state.tx.lock().await; if let Some(tx) = tx_lock.as_ref() { - tx.try_send(message).map_err(|e| format!("ws send failed: {e}")) + match tx.try_send(message) { + Ok(()) => Ok(()), + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + warn!("[ws_proxy] ws_send: outbound channel full, message dropped"); + Err("ws_send: channel full, message dropped".into()) + } + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => { + Err("ws_send: channel closed".into()) + } + } } else { Err("WebSocket not connected".into()) } @@ -401,10 +442,20 @@ pub fn accept_cert_fingerprint( .store(CERTS_STORE) .map_err(|e| format!("failed to open certs store: {e}"))?; + // Capture old value before mutating so we can restore it if save fails. + let old_value = store.get(&host); store.set(&host, Value::String(fingerprint)); - store - .save() - .map_err(|e| format!("failed to persist cert fingerprint: {e}"))?; + if let Err(e) = store.save() { + // Restore previous in-memory state: put back old fingerprint if one + // existed, or delete if there was none. Without this, the new + // fingerprint would be trusted in-process even though it was never + // persisted to certs.json. + match old_value { + Some(v) => { let _ = store.set(&host, v); } + None => { let _ = store.delete(&host); } + } + return Err(format!("failed to persist cert fingerprint: {e}")); + } Ok(()) } diff --git a/Client/tauri-client/src/components/MessageList.ts b/Client/tauri-client/src/components/MessageList.ts index 140829e7..0dd363b4 100644 --- a/Client/tauri-client/src/components/MessageList.ts +++ b/Client/tauri-client/src/components/MessageList.ts @@ -233,6 +233,17 @@ export function createMessageList(options: MessageListOptions): MessageListCompo function measureRendered(): void { if (contentContainer === null || renderedStart < 0) return; const children = contentContainer.children; + + // Pass 1 — pure reads: collect all heights without touching any styles. + // Batching all getComputedStyle / offsetHeight reads before any writes + // allows the browser to satisfy them with a single layout calculation + // instead of forcing a synchronous reflow on every iteration. + interface Measurement { + readonly key: string; + readonly idx: number; + readonly h: number; + } + const measurements: Measurement[] = []; for (let i = 0; i < children.length; i++) { const globalIdx = renderedStart + i; if (globalIdx < 0 || (tree !== null && globalIdx >= tree.size)) continue; @@ -240,11 +251,16 @@ export function createMessageList(options: MessageListOptions): MessageListCompo const style = getComputedStyle(el); const h = el.offsetHeight + parseFloat(style.marginTop) + parseFloat(style.marginBottom); if (h > 0) { - const key = itemKey(globalIdx); - heightCache.set(key, h); - if (tree !== null) { - tree.set(globalIdx, h); - } + measurements.push({ key: itemKey(globalIdx), idx: globalIdx, h }); + } + } + + // Pass 2 — pure writes: apply all cached heights to heightCache and the + // Fenwick tree. No DOM reads here, so no additional reflow is triggered. + for (const { key, idx, h } of measurements) { + heightCache.set(key, h); + if (tree !== null) { + tree.set(idx, h); } } } @@ -453,6 +469,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo let scrollRafId = 0; let resizeRafId = 0; + let resizeObserver: ResizeObserver | null = null; // resizeDirty tracking removed — resize observer batches via RAF directly function handleScroll(): void { if (root === null) return; @@ -515,7 +532,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo // Watch for height changes in rendered items (images loading, embeds expanding). // Batched via RAF with anchor-based scroll preservation. - const resizeObserver = new ResizeObserver(() => { + resizeObserver = new ResizeObserver(() => { if (root === null || contentContainer === null) return; if (resizeRafId !== 0) return; @@ -544,7 +561,6 @@ export function createMessageList(options: MessageListOptions): MessageListCompo }); }); resizeObserver.observe(contentContainer); - ac.signal.addEventListener("abort", () => resizeObserver.disconnect()); parentContainer.appendChild(root); @@ -579,6 +595,10 @@ export function createMessageList(options: MessageListOptions): MessageListCompo } function destroy(): void { + if (resizeObserver !== null) { + resizeObserver.disconnect(); + resizeObserver = null; + } ac.abort(); if (scrollRafId !== 0) { cancelAnimationFrame(scrollRafId); diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index 33bc1ba7..a60b7fc2 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -70,43 +70,119 @@ type PendingVoiceJoin = { readonly directUrl?: string; }; -/** Read pendingJoin from an instance — bypasses TS control-flow narrowing - * that incorrectly assumes the field is still null after an async interleave. */ -function getPendingJoin(session: LiveKitSession): PendingVoiceJoin | null { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TS narrowing workaround - return (session as any).pendingJoin as PendingVoiceJoin | null; -} +// --- State machine --- + +/** Discriminated-union session state. All connection-lifecycle fields live here. + * The "connecting" variant also carries the BUG-142 monotonic generation counter + * (joinGeneration) so superseded-join detection is co-located with the state. */ +type SessionState = + | { readonly type: "idle" } + | { + readonly type: "connecting"; + readonly pendingJoin: PendingVoiceJoin | null; + readonly joinGeneration: number; + } + | { + readonly type: "connected"; + readonly room: Room; + readonly channelId: number; + readonly latestToken: string; + readonly lastUrl: string; + readonly lastDirectUrl: string | undefined; + } + | { + readonly type: "reconnecting"; + readonly channelId: number; + readonly latestToken: string; + readonly lastUrl: string; + readonly lastDirectUrl: string | undefined; + readonly ac: AbortController; + }; // --- LiveKitSession class --- export class LiveKitSession { - private room: Room | null = null; + /** Single source of truth for all connection-lifecycle state. */ + private _state: SessionState = { type: "idle" }; + + // --- Non-connection fields (configuration / callbacks / infrastructure) --- private ws: WsClient | null = null; private onErrorCallback: ((message: string) => void) | null = null; - private currentChannelId: number | null = null; private serverHost: string | null = null; private onRemoteVideoCallback: RemoteVideoCallback | null = null; private onRemoteVideoRemovedCallback: RemoteVideoRemovedCallback | null = null; private tokenRefreshTimer: ReturnType | null = null; - /** Latest token received from server (used for reconnection after token refresh). */ - private latestToken: string | null = null; - /** Guard: true while handleVoiceToken is connecting — prevents concurrent joins. */ - private connecting = false; - /** Latest join request received while a connection attempt is already running. */ - private pendingJoin: PendingVoiceJoin | null = null; - /** Last known LiveKit URL and directUrl for auto-reconnect on unexpected disconnect. */ - private lastUrl: string | null = null; - private lastDirectUrl: string | undefined = undefined; + /** BUG-146: Guard timer — fires if the server never responds to voice_token_refresh. */ + private tokenRefreshTimeoutTimer: ReturnType | null = null; /** Max auto-reconnect attempts before giving up and showing error. */ private static readonly MAX_RECONNECT_ATTEMPTS = 2; private static readonly RECONNECT_DELAY_MS = 3000; - /** Aborted by leaveVoice() to cancel a pending auto-reconnect loop. */ - private reconnectAc: AbortController | null = null; /** Master output volume multiplier (0-2.0). Per-user volumes are scaled by this. */ private outputVolumeMultiplier = loadPref("outputVolume", 100) / 100; /** Cached port for the local LiveKit TLS proxy (Rust-side, for self-signed cert support). */ private liveKitProxyPort: number | null = null; + // --- State transition (single writer) --- + + private setState(next: SessionState): void { + const prev = this._state.type; + this._state = next; + log.debug("Session state transition", { from: prev, to: next.type }); + } + + // --- Typed state accessors (replace scattered field reads) --- + + /** Room from state, or null when idle/connecting/reconnecting. */ + private get _room(): Room | null { + return this._state.type === "connected" ? this._state.room : null; + } + + /** Channel ID from state, or null when idle/connecting. */ + private get _currentChannelId(): number | null { + return this._state.type === "connected" || this._state.type === "reconnecting" + ? this._state.channelId + : null; + } + + /** Latest token from state, or null when idle/connecting. */ + private get _latestToken(): string | null { + return this._state.type === "connected" || this._state.type === "reconnecting" + ? this._state.latestToken + : null; + } + + /** Last URL from state, or null when idle/connecting. */ + private get _lastUrl(): string | null { + return this._state.type === "connected" || this._state.type === "reconnecting" + ? this._state.lastUrl + : null; + } + + /** Last direct URL from state. */ + private get _lastDirectUrl(): string | undefined { + return this._state.type === "connected" || this._state.type === "reconnecting" + ? this._state.lastDirectUrl + : undefined; + } + + /** True while a connect attempt is running. */ + private get _connecting(): boolean { + return this._state.type === "connecting"; + } + + /** The abort controller for an in-flight reconnect, or null. */ + private get _reconnectAc(): AbortController | null { + return this._state.type === "reconnecting" ? this._state.ac : null; + } + + /** Helper to check state is "connected" for a specific channelId, reading + * through a method call so TS control-flow narrowing cannot cache the result. + * Used in connectAndSetup() checkpoints after setState() transitions. */ + private isStateConnected(channelId: number): boolean { + const s: SessionState = this._state; + return s.type === "connected" && s.channelId === channelId; + } + // --- Extracted modules (facade pattern) --- private _audioPipeline = new AudioPipeline(); private _audioElements = new AudioElements(); @@ -120,7 +196,7 @@ export class LiveKitSession { /** Lazily built deps for the extracted video track functions. */ private get _videoTrackDeps(): VideoTrackDeps { return { - getRoom: () => this.room, + getRoom: () => this._room, getWs: () => this.ws, onError: (msg) => { this.onErrorCallback?.(msg); @@ -134,29 +210,60 @@ export class LiveKitSession { constructor() { this._eventHandlers = createRoomEventHandlers({ - getRoom: () => this.room, + getRoom: () => this._room, setRoom: (r) => { - this.room = r; + // Called by handleDisconnected immediately before setReconnectAc. + // Capture the reconnect fields from the current "connected" state + // while we still have them, then clear the room (transition to idle). + // setReconnectAc will pick up _pendingReconnectFields to form the + // "reconnecting" state atomically. + if (r === null && this._state.type === "connected") { + this._pendingReconnectFields = { + channelId: this._state.channelId, + latestToken: this._state.latestToken, + lastUrl: this._state.lastUrl, + lastDirectUrl: this._state.lastDirectUrl, + }; + this.setState({ type: "idle" }); + } }, - getCurrentChannelId: () => this.currentChannelId, + getCurrentChannelId: () => this._currentChannelId, getAudioElements: () => this._audioElements, getOnRemoteVideoCallback: () => this.onRemoteVideoCallback, getOnRemoteVideoRemovedCallback: () => this.onRemoteVideoRemovedCallback, getOnErrorCallback: () => this.onErrorCallback, - isConnecting: () => this.connecting, - getLatestToken: () => this.latestToken, - getLastUrl: () => this.lastUrl, - getLastDirectUrl: () => this.lastDirectUrl, + isConnecting: () => this._connecting, + getLatestToken: () => this._latestToken, + getLastUrl: () => this._lastUrl, + getLastDirectUrl: () => this._lastDirectUrl, setReconnectAc: (ac) => { - this.reconnectAc = ac; + if (ac !== null && this._pendingReconnectFields !== null) { + // Transition from idle → reconnecting atomically using the fields + // captured in setRoom() above. + const { channelId, latestToken, lastUrl, lastDirectUrl } = this._pendingReconnectFields; + this._pendingReconnectFields = null; + this.setState({ + type: "reconnecting", + channelId, + latestToken, + lastUrl, + lastDirectUrl, + ac, + }); + } + // ac === null: reconnect succeeded — connectAndSetup already set "connected". + // No transition needed; just discard stale pending fields if any. + if (ac === null) { + this._pendingReconnectFields = null; + } }, syncModuleRooms: () => this.syncModuleRooms(), teardownForReconnect: () => { this._audioPipeline.teardownAudioPipeline(); this.clearTokenRefreshTimer(); // BUG-098: Stop leaked camera/screen tracks before room is nulled. - stopManualCameraTrack(this._cameraState, this.room); - stopManualScreenTracks(this._screenState, this.room); + stopManualCameraTrack(this._cameraState, this._room); + stopManualScreenTracks(this._screenState, this._room); setLocalCamera(false); setLocalScreenshare(false); }, @@ -167,6 +274,15 @@ export class LiveKitSession { }); } + /** Temporary holding field: populated by setRoom(null) in handleDisconnected's + * callback sequence so setReconnectAc can form the "reconnecting" state atomically. */ + private _pendingReconnectFields: { + channelId: number; + latestToken: string; + lastUrl: string; + lastDirectUrl: string | undefined; + } | null = null; + // --- Room factory --- private createRoom(): Room { @@ -212,10 +328,11 @@ export class LiveKitSession { /** Update all extracted modules with the current room reference. */ private syncModuleRooms(): void { - this._audioPipeline.setRoom(this.room); - this._audioElements.setRoom(this.room); - this._deviceManager.setRoom(this.room); - this._deviceManager.setAudioPipeline(this.room !== null ? this._audioPipeline : null); + const room = this._room; + this._audioPipeline.setRoom(room); + this._audioElements.setRoom(room); + this._deviceManager.setRoom(room); + this._deviceManager.setAudioPipeline(room !== null ? this._audioPipeline : null); this._deviceManager.setOnError(this.onErrorCallback); this._deviceManager.setOnToast(this.onErrorCallback); } @@ -238,20 +355,39 @@ export class LiveKitSession { // eslint-disable-next-line no-await-in-loop -- intentional sequential polling with backoff delay await new Promise((r) => setTimeout(r, LiveKitSession.RECONNECT_DELAY_MS)); // If user manually left or joined a different channel during the delay, abort. - if (signal.aborted || this.currentChannelId !== channelId) { + if (signal.aborted || this._currentChannelId !== channelId) { log.info("Auto-reconnect aborted — user left or channel changed"); return; } try { - this.room = this.createRoom(); - this.syncModuleRooms(); + const newRoom = this.createRoom(); + // Set state to reconnecting with the fresh room-less attempt info; + // the actual room appears in "connected" state after connect succeeds. + if (this._state.type === "reconnecting") { + this.setState({ ...this._state, ac: this._state.ac }); + } + this._audioPipeline.setRoom(newRoom); + this._audioElements.setRoom(newRoom); + this._deviceManager.setRoom(newRoom); + this._deviceManager.setAudioPipeline(this._audioPipeline); // eslint-disable-next-line no-await-in-loop -- sequential reconnect: resolve URL then connect const resolvedUrl = await this.resolveLiveKitUrl(url, directUrl); // eslint-disable-next-line no-await-in-loop -- sequential reconnect: must connect before restoring state - await this.room.connect(resolvedUrl, token); + await newRoom.connect(resolvedUrl, token); log.info("Auto-reconnect succeeded", { attempt, channelId, url: resolvedUrl }); - logIceConnectionInfo(this.room); - this.room + // Transition to "connected" — this is the single atomic write. + this.setState({ + type: "connected", + room: newRoom, + channelId, + latestToken: token, + lastUrl: url, + lastDirectUrl: directUrl, + }); + this._deviceManager.setOnError(this.onErrorCallback); + this._deviceManager.setOnToast(this.onErrorCallback); + logIceConnectionInfo(newRoom); + newRoom .startAudio() .catch((err) => log.debug("Failed to start audio after reconnect", err)); // eslint-disable-next-line no-await-in-loop -- sequential reconnect: must restore voice state after connect @@ -260,7 +396,7 @@ export class LiveKitSession { const savedInput = loadPref("audioInputDevice", ""); if (savedInput) { try { - await this.room.switchActiveDevice("audioinput", savedInput); + await newRoom.switchActiveDevice("audioinput", savedInput); } catch (err) { log.warn("Reconnect: saved input device unavailable, using default", err); } @@ -268,7 +404,7 @@ export class LiveKitSession { const savedOutput = loadPref("audioOutputDevice", ""); if (savedOutput) { try { - await this.room.switchActiveDevice("audiooutput", savedOutput); + await newRoom.switchActiveDevice("audiooutput", savedOutput); } catch (err) { log.warn("Reconnect: saved output device unavailable, using default", err); } @@ -276,24 +412,38 @@ export class LiveKitSession { this._audioPipeline.setupAudioPipeline(); this.reapplyMuteGain(); this.startTokenRefreshTimer(); - // Clear the abort controller after all post-connect work is done so - // leaveVoice() can still abort during restoreLocalVoiceState above. - this.reconnectAc = null; + // Signal the setReconnectAc callback that the reconnect is done. + // ac === null clears the pending state in the callback. + this._pendingReconnectFields = null; // Request a fresh token since the stored one may be close to expiry. this.requestTokenRefresh(); return; } catch (err) { log.warn("Auto-reconnect failed", { attempt, url, error: err }); - if (this.room !== null) { - this.room.removeAllListeners(); - this.room + const failedRoom = this._room; + if (failedRoom !== null) { + failedRoom.removeAllListeners(); + failedRoom .disconnect() .catch((disconnectErr) => log.warn("Failed to disconnect room after reconnect failure", disconnectErr), ); - this.room = null; - this.syncModuleRooms(); } + // Return to idle so the next attempt starts fresh. + if (this._state.type === "reconnecting") { + this.setState({ + type: "reconnecting", + channelId: this._state.channelId, + latestToken: this._state.latestToken, + lastUrl: this._state.lastUrl, + lastDirectUrl: this._state.lastDirectUrl, + ac: this._state.ac, + }); + } + this._audioPipeline.setRoom(null); + this._audioElements.setRoom(null); + this._deviceManager.setRoom(null); + this._deviceManager.setAudioPipeline(null); } } // All attempts exhausted — give up and clean up. @@ -384,10 +534,17 @@ export class LiveKitSession { clearTimeout(this.tokenRefreshTimer); this.tokenRefreshTimer = null; } + // BUG-146: Also cancel any in-flight refresh response timeout so it does + // not fire after the session is torn down (leaveVoice / cleanupAll both + // call this method, so one clearing point covers all cleanup paths). + if (this.tokenRefreshTimeoutTimer !== null) { + clearTimeout(this.tokenRefreshTimeoutTimer); + this.tokenRefreshTimeoutTimer = null; + } } private requestTokenRefresh(): void { - if (this.ws === null || this.room === null) { + if (this.ws === null || this._room === null) { log.debug("Skipping token refresh — no active session"); return; } @@ -396,9 +553,35 @@ export class LiveKitSession { // NOTE: startTokenRefreshTimer is called from handleVoiceTokenRefresh // (the server response handler), not here, to avoid scheduling two // competing timers per cycle. + + // BUG-146: Arm a 60-second response deadline. If the server never replies, + // the token stalls silently. On timeout we log a warning and reschedule the + // next refresh attempt rather than disconnecting — the current live session + // is unaffected (LiveKit keeps active connections alive beyond token expiry); + // the risk is only that a network blip during the stale window would fail to + // reconnect. Reconnecting for a refresh timeout is intentionally NOT done here + // because the WS connection itself may be degraded; a forced disconnect would + // make the UX worse than leaving the existing (still-valid) token in place. + if (this.tokenRefreshTimeoutTimer !== null) { + clearTimeout(this.tokenRefreshTimeoutTimer); + } + this.tokenRefreshTimeoutTimer = setTimeout(() => { + this.tokenRefreshTimeoutTimer = null; + log.warn( + "Voice token refresh timed out — server did not respond within 60 s. " + + "Rescheduling refresh; existing token remains in use.", + ); + // Re-arm the next scheduled refresh so the client keeps trying. + this.startTokenRefreshTimer(); + }, 60_000); } handleVoiceTokenRefresh(token?: string): void { + // BUG-146: Cancel the response-deadline timer — the server replied in time. + if (this.tokenRefreshTimeoutTimer !== null) { + clearTimeout(this.tokenRefreshTimeoutTimer); + this.tokenRefreshTimeoutTimer = null; + } // KNOWN LIMITATION: The livekit-client SDK does not expose a method to // rotate the token on an active connection. We store the fresh token so // that reconnection (auto-reconnect or manual rejoin) uses it, but the @@ -409,8 +592,10 @@ export class LiveKitSession { // - The 23h refresh timer ensures a fresh token is always ready // *before* the original expires, so reconnects within the window work. // See also: Server/ws/livekit.go tokenTTL constant. - if (token) { - this.latestToken = token; + if (token && this._state.type === "connected") { + this.setState({ ...this._state, latestToken: token }); + } else if (token && this._state.type === "reconnecting") { + this.setState({ ...this._state, latestToken: token }); } this.startTokenRefreshTimer(); log.info("Voice token refreshed, timer restarted"); @@ -419,7 +604,8 @@ export class LiveKitSession { // --- Volume helpers --- private async restoreLocalVoiceState(mode: "join" | "reconnect"): Promise { - if (this.room === null) return; + const room = this._room; + if (room === null) return; const state = voiceStore.getState(); const muted = state.localMuted || state.localDeafened; @@ -427,7 +613,7 @@ export class LiveKitSession { const shouldEnableMicrophone = !muted; try { - await this.room.localParticipant.setMicrophoneEnabled(shouldEnableMicrophone); + await room.localParticipant.setMicrophoneEnabled(shouldEnableMicrophone); if (shouldEnableMicrophone) { log.info( mode === "join" @@ -496,29 +682,74 @@ export class LiveKitSession { } /** Shared connect-with-retry + post-connect setup used by both the primary - * handleVoiceToken path and the pending-join drain loop. Returns true if - * the room ended up connected and set up, false otherwise. */ + * handleVoiceToken path and the pending-join drain loop. + * Returns true if the room ended up connected and set up, + * false on error, or "superseded" if a newer join generation invalidated + * this attempt (caller should re-read pendingJoin immediately). */ private async connectAndSetup( token: string, url: string, channelId: number, directUrl?: string, - ): Promise { - if (this.room !== null) this.leaveVoice(false); - this.connecting = true; + ): Promise { + if (this._room !== null) this.leaveVoice(false); + // Increment the generation counter and embed it into the "connecting" state. + // Any newer call to connectAndSetup() will produce a larger generation, + // making myGeneration !== currentGeneration at each checkpoint. + const prevState = this._state; + const prevGeneration = prevState.type === "connecting" ? prevState.joinGeneration : 0; + const myGeneration = prevGeneration + 1; + this.setState({ type: "connecting", pendingJoin: null, joinGeneration: myGeneration }); let resolvedUrl = ""; + // Track the room being built in this attempt so we can disconnect it on + // supersession without touching the shared state (which may already have + // been claimed by a newer attempt). + let localRoom: Room | null = null; try { - this.room = this.createRoom(); - this.syncModuleRooms(); + localRoom = this.createRoom(); + this._audioPipeline.setRoom(localRoom); + this._audioElements.setRoom(localRoom); + this._deviceManager.setRoom(localRoom); + this._deviceManager.setAudioPipeline(this._audioPipeline); + this._deviceManager.setOnError(this.onErrorCallback); + this._deviceManager.setOnToast(this.onErrorCallback); resolvedUrl = await this.resolveLiveKitUrl(url, directUrl); + + // Checkpoint 1: after URL resolution (may be slow for TLS proxy init). + if (this._state.type !== "connecting" || this._state.joinGeneration !== myGeneration) { + log.info("connectAndSetup: superseded after URL resolution — aborting", { + channelId, + myGeneration, + currentGeneration: this._state.type === "connecting" ? this._state.joinGeneration : "n/a", + }); + return "superseded"; + } + const MAX_RETRIES = 3; const RETRY_DELAY_MS = 2000; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { // eslint-disable-next-line no-await-in-loop -- sequential retry: must attempt connect before checking result - await this.room.connect(resolvedUrl, token); - // Check if a newer join was queued during the async connect. - const queuedJoin = getPendingJoin(this); + await localRoom.connect(resolvedUrl, token); + + // Checkpoint 2: after room.connect() — the primary race window. + if (this._state.type !== "connecting" || this._state.joinGeneration !== myGeneration) { + log.info("connectAndSetup: superseded after room.connect() — aborting", { + channelId, + myGeneration, + currentGeneration: + this._state.type === "connecting" ? this._state.joinGeneration : "n/a", + }); + localRoom.removeAllListeners(); + localRoom + .disconnect() + .catch((err) => log.debug("Failed to disconnect superseded room", err)); + return "superseded"; + } + + // Belt-and-suspenders: also keep existing pending-join token check + // for logging clarity when a newer request arrived via pendingJoin. + const queuedJoin = this._state.type === "connecting" ? this._state.pendingJoin : null; if ( queuedJoin !== null && (queuedJoin.token !== token || @@ -530,15 +761,15 @@ export class LiveKitSession { channelId, queuedChannelId: queuedJoin.channelId, }); - if (this.room !== null) { - const room = this.room; - this.room = null; - this.syncModuleRooms(); - room.removeAllListeners(); - room - .disconnect() - .catch((err) => log.debug("Failed to disconnect room during cleanup", err)); - } + localRoom.removeAllListeners(); + localRoom + .disconnect() + .catch((err) => log.debug("Failed to disconnect room during cleanup", err)); + localRoom = null; + this._audioPipeline.setRoom(null); + this._audioElements.setRoom(null); + this._deviceManager.setRoom(null); + this._deviceManager.setAudioPipeline(null); break; } break; @@ -552,46 +783,96 @@ export class LiveKitSession { }); // eslint-disable-next-line no-await-in-loop -- intentional backoff delay between retry attempts await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); - if (this.room === null) throw connectErr; - this.room.removeAllListeners(); - this.room = this.createRoom(); - this.syncModuleRooms(); + // Generation check inside retry loop: a superseding join may arrive + // during the backoff delay. + if (this._state.type !== "connecting" || this._state.joinGeneration !== myGeneration) { + log.info("connectAndSetup: superseded during retry backoff — aborting", { + channelId, + attempt, + }); + return "superseded"; + } + if (localRoom === null) throw connectErr; + localRoom.removeAllListeners(); + localRoom = this.createRoom(); + this._audioPipeline.setRoom(localRoom); + this._audioElements.setRoom(localRoom); + this._deviceManager.setRoom(localRoom); + this._deviceManager.setAudioPipeline(this._audioPipeline); } else { throw connectErr; } } } // If the room was discarded (stale join superseded by pending), skip setup. - if (this.room !== null) { + if (localRoom !== null) { log.info("Connected to LiveKit room", { channelId, url: resolvedUrl }); - logIceConnectionInfo(this.room); - this.currentChannelId = channelId; - this.latestToken = token; - this.lastUrl = url; - this.lastDirectUrl = directUrl; + logIceConnectionInfo(localRoom); + // Atomic transition to "connected" — all connection fields set together. + this.setState({ + type: "connected", + room: localRoom, + channelId, + latestToken: token, + lastUrl: url, + lastDirectUrl: directUrl, + }); // Optimistic startAudio — may succeed if the join was triggered by a // recent user gesture. If not, the AudioPlaybackStatusChanged handler // will register a click-to-unlock fallback. - this.room.startAudio().catch(() => { + localRoom.startAudio().catch(() => { log.debug("Optimistic startAudio failed — waiting for user gesture"); }); await this.restoreLocalVoiceState("join"); + + // Checkpoint 3: after restoreLocalVoiceState (mic acquisition can be slow). + // Cast to SessionState to escape TS control-flow narrowing that incorrectly + // assumes _state is still "connecting" (it was set to "connected" above, but + // TS cannot see through the setState() opaque method call). + if (!this.isStateConnected(channelId)) { + log.info("connectAndSetup: superseded after restoreLocalVoiceState — aborting", { + channelId, + }); + this.leaveVoice(false); + return "superseded"; + } + const savedInput = loadPref("audioInputDevice", ""); if (savedInput) { try { - await this.room.switchActiveDevice("audioinput", savedInput); + await localRoom.switchActiveDevice("audioinput", savedInput); } catch (err) { log.warn("Saved input device unavailable, using default", err); } } + + // Checkpoint 4: after audioinput switchActiveDevice. + if (!this.isStateConnected(channelId)) { + log.info("connectAndSetup: superseded after audioinput switch — aborting", { + channelId, + }); + this.leaveVoice(false); + return "superseded"; + } + const savedOutput = loadPref("audioOutputDevice", ""); if (savedOutput) { try { - await this.room.switchActiveDevice("audiooutput", savedOutput); + await localRoom.switchActiveDevice("audiooutput", savedOutput); } catch (err) { log.warn("Saved output device unavailable, using default", err); } } + + // Checkpoint 5: after audiooutput switchActiveDevice. + if (!this.isStateConnected(channelId)) { + log.info("connectAndSetup: superseded after audiooutput switch — aborting", { + channelId, + }); + this.leaveVoice(false); + return "superseded"; + } + this._audioPipeline.setupAudioPipeline(); this.reapplyMuteGain(); this.startTokenRefreshTimer(); @@ -601,13 +882,18 @@ export class LiveKitSession { return false; } catch (err) { log.error("Failed to connect to LiveKit", { url: resolvedUrl, error: err }); - if (this.room !== null) { + if (localRoom !== null) { this.onErrorCallback?.("Failed to join voice — connection error"); } this.leaveVoice(false); return false; } finally { - this.connecting = false; + // Only clear "connecting" back to "idle" if we are still in the connecting + // state for this generation — never overwrite a "connected" state that was + // set by the success path above (guards against risk #4 in the analysis). + if (this._state.type === "connecting" && this._state.joinGeneration === myGeneration) { + this.setState({ type: "idle" }); + } } } @@ -617,25 +903,32 @@ export class LiveKitSession { channelId: number, directUrl?: string, ): Promise { - if ( - this.room !== null && - this.currentChannelId === channelId && - this.room.state === "connected" - ) { + const s = this._state; + if (s.type === "connected" && s.channelId === channelId && s.room.state === "connected") { this.handleVoiceTokenRefresh(token); return; } // Prevent concurrent connect attempts (rapid channel switching). - if (this.connecting) { - this.pendingJoin = { token, url, channelId, directUrl }; + if (this._connecting) { + // Update the pendingJoin on the existing "connecting" state immutably. + if (this._state.type === "connecting") { + this.setState({ + ...this._state, + pendingJoin: { token, url, channelId, directUrl }, + }); + } log.warn("handleVoiceToken: already connecting, queued latest join request", { channelId }); return; } await this.connectAndSetup(token, url, channelId, directUrl); // Drain pending joins iteratively to avoid unbounded recursion when // rapid channel switches queue multiple requests. - let pendingJoin = this.pendingJoin; - this.pendingJoin = null; + // A "superseded" result means connectAndSetup() already aborted early; + // we still drain pendingJoin so the latest request always wins. + let pendingJoin = this._state.type === "connecting" ? this._state.pendingJoin : null; + if (this._state.type === "connecting") { + this.setState({ ...this._state, pendingJoin: null }); + } while (pendingJoin !== null) { const { token: pToken, @@ -643,26 +936,32 @@ export class LiveKitSession { channelId: pChannelId, directUrl: pDirectUrl, } = pendingJoin; + const cur = this._state; if ( - this.room !== null && - this.currentChannelId === pChannelId && - this.room.state === "connected" + cur.type === "connected" && + cur.channelId === pChannelId && + cur.room.state === "connected" ) { this.handleVoiceTokenRefresh(pToken); } else { // eslint-disable-next-line no-await-in-loop -- sequential drain of pending joins to avoid unbounded recursion await this.connectAndSetup(pToken, pUrl, pChannelId, pDirectUrl); + // If this attempt was itself superseded (another join arrived during the + // await), the loop will naturally pick it up via the updated pendingJoin. + } + pendingJoin = this._state.type === "connecting" ? this._state.pendingJoin : null; + if (this._state.type === "connecting") { + this.setState({ ...this._state, pendingJoin: null }); } - pendingJoin = this.pendingJoin; - this.pendingJoin = null; } } /** Retry microphone permission after being in listen-only mode. */ async retryMicPermission(): Promise { - if (this.room === null) return; + const room = this._room; + if (room === null) return; try { - await this.room.localParticipant.setMicrophoneEnabled(true); + await room.localParticipant.setMicrophoneEnabled(true); setListenOnly(false); // BUG-103: Honor deafened state — keep mic muted if user is deafened. const { localDeafened } = voiceStore.getState(); @@ -685,18 +984,18 @@ export class LiveKitSession { } leaveVoice(sendWs = true): void { - // Cancel any pending auto-reconnect loop first - if (this.reconnectAc !== null) { - this.reconnectAc.abort(); - this.reconnectAc = null; + // Cancel any pending auto-reconnect loop first. + const ac = this._reconnectAc; + if (ac !== null) { + ac.abort(); } + this._pendingReconnectFields = null; this.clearTokenRefreshTimer(); this._audioPipeline.teardownAudioPipeline(); this._eventHandlers.removeAutoplayUnlock(); - this.pendingJoin = null; // Clean up manually published tracks. - stopManualCameraTrack(this._cameraState, this.room); - stopManualScreenTracks(this._screenState, this.room); + stopManualCameraTrack(this._cameraState, this._room); + stopManualScreenTracks(this._screenState, this._room); if (sendWs && this.ws !== null) { this.ws.send({ type: "voice_leave", payload: {} }); } @@ -704,17 +1003,16 @@ export class LiveKitSession { // TrackUnsubscribed, but may be missed during rapid reconnection). // Full cleanup: also clears screenshare mute state on intentional leave. this._audioElements.cleanupAllAudioElementsFull(); - if (this.room !== null) { - const r = this.room; - this.room = null; - this.syncModuleRooms(); - r.removeAllListeners(); - r.disconnect().catch((err) => log.warn("room.disconnect() error (non-fatal)", err)); + const room = this._room; + if (room !== null) { + room.removeAllListeners(); + room.disconnect().catch((err) => log.warn("room.disconnect() error (non-fatal)", err)); } - this.currentChannelId = null; - this.latestToken = null; - this.lastUrl = null; - this.lastDirectUrl = undefined; + // Transition to idle — atomically clears room, channelId, tokens, reconnectAc, + // pendingJoin, and the joinGeneration (idle has none). Any in-flight + // connectAndSetup() will detect the state type change at its next checkpoint. + this.setState({ type: "idle" }); + this.syncModuleRooms(); setLocalCamera(false); setLocalScreenshare(false); log.info("Left voice session"); @@ -722,6 +1020,8 @@ export class LiveKitSession { cleanupAll(): void { this.leaveVoice(false); + // leaveVoice() already transitions state to "idle". + // Clear non-connection fields (config / callbacks / infrastructure). this.onErrorCallback = null; this.onRemoteVideoCallback = null; this.onRemoteVideoRemovedCallback = null; @@ -749,16 +1049,17 @@ export class LiveKitSession { * the audio pipeline. Re-publish and rebuild when unmuting. This guarantees * the SFU has no audio track to forward to other participants. */ private async applyMicMuteState(muted: boolean): Promise { - if (this.room === null) return; + const room = this._room; + if (room === null) return; if (muted) { // Tear down pipeline first so it doesn't hold refs to the track this._audioPipeline.teardownAudioPipeline(); // Fully disable the mic — this unpublishes the track from the SFU - await this.room.localParticipant.setMicrophoneEnabled(false); + await room.localParticipant.setMicrophoneEnabled(false); log.debug("Mic fully unpublished (muted)"); } else { // Re-enable mic — this re-publishes the track to the SFU - await this.room.localParticipant.setMicrophoneEnabled(true); + await room.localParticipant.setMicrophoneEnabled(true); // Rebuild the audio pipeline on the fresh track this._audioPipeline.setupAudioPipeline(); log.debug("Mic re-published (unmuted)"); @@ -840,26 +1141,26 @@ export class LiveKitSession { } getLocalCameraStream(): MediaStream | null { - return doGetLocalCameraStream(this.room); + return doGetLocalCameraStream(this._room); } getLocalScreenshareStream(): MediaStream | null { - return doGetLocalScreenshareStream(this.room); + return doGetLocalScreenshareStream(this._room); } /** Get a remote participant's video MediaStream by userId and track type. Returns null if not available. */ getRemoteVideoStream(userId: number, type: "camera" | "screenshare"): MediaStream | null { - return doGetRemoteVideoStream(this.room, userId, type); + return doGetRemoteVideoStream(this._room, userId, type); } getRoom(): Room | null { - return this.room; + return this._room; } getSessionDebugInfo(): Record { return buildSessionDebugInfo({ - room: this.room, - currentChannelId: this.currentChannelId, + room: this._room, + currentChannelId: this._currentChannelId, outputVolumeMultiplier: this.outputVolumeMultiplier, audioPipeline: this._audioPipeline, audioElements: this._audioElements, diff --git a/Client/tauri-client/src/lib/store.ts b/Client/tauri-client/src/lib/store.ts index bee2de27..e156851c 100644 --- a/Client/tauri-client/src/lib/store.ts +++ b/Client/tauri-client/src/lib/store.ts @@ -106,8 +106,13 @@ export function createStore(initialState: T): Store { /** Re-entrancy guard: true while a subscriber notification is running. */ let updating = false; - /** Updaters queued by re-entrant setState calls during notification. */ - const pendingUpdates: Array<(prev: T) => T> = []; + /** + * Coalesced updater for re-entrant setState calls made during notification. + * Instead of an unbounded array, re-entrant calls are composed into a single + * function chain so queue depth never exceeds 1, regardless of burst size. + * Ordering is preserved: each updater sees the output of the previous one. + */ + let pendingUpdater: ((prev: T) => T) | null = null; function getState(): T { return state; @@ -115,8 +120,12 @@ export function createStore(initialState: T): Store { function setState(updater: (prev: T) => T): void { if (updating) { - // Re-entrant call from within a subscriber — queue for later. - pendingUpdates.push(updater); + // Re-entrant call from within a subscriber — coalesce into a single + // pending updater by composing with any already-queued function. + // This keeps queue depth at O(1) regardless of burst size while + // preserving update ordering (each updater sees previous output). + const existing = pendingUpdater; + pendingUpdater = existing === null ? updater : (prev: T) => updater(existing(prev)); return; } state = updater(state); @@ -129,9 +138,12 @@ export function createStore(initialState: T): Store { for (const listener of listeners) { listener(state); } - // Drain any updates queued by re-entrant setState during notification. - while (pendingUpdates.length > 0) { - const queued = pendingUpdates.shift()!; + // Drain any update coalesced by re-entrant setState during notification. + // Loop to handle further re-entrant calls that may occur within listeners + // invoked during the drain itself. + while (pendingUpdater !== null) { + const queued = pendingUpdater; + pendingUpdater = null; state = queued(state); for (const listener of listeners) { listener(state); diff --git a/Client/tauri-client/src/lib/ws.ts b/Client/tauri-client/src/lib/ws.ts index 7d6da0d5..324b0220 100644 --- a/Client/tauri-client/src/lib/ws.ts +++ b/Client/tauri-client/src/lib/ws.ts @@ -215,10 +215,12 @@ export function createWsClient() { return; } replayDedup.add(dedupKey); - // Evict oldest entries if set is too large if (replayDedup.size > MAX_DEDUP_SIZE) { - const first = replayDedup.values().next().value; - if (first !== undefined) replayDedup.delete(first); + const targetSize = Math.floor(MAX_DEDUP_SIZE * 0.8); + for (const key of replayDedup) { + if (replayDedup.size <= targetSize) break; + replayDedup.delete(key); + } } } @@ -419,8 +421,17 @@ export function createWsClient() { log.warn("Cannot send, WebSocket not open"); return; } - tauriInvoke("ws_send", { message: json }).catch((err) => { - log.error("ws_send failed", err); + tauriInvoke("ws_send", { message: json }).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("channel full")) { + // Outbound channel is saturated — log a prominent warning so callers + // can detect backpressure rather than silently losing messages. + log.warn("ws_send: outbound channel full, message dropped (backpressure)", { + messagePreview: json.slice(0, 120), + }); + } else { + log.error("ws_send failed", err); + } }); } diff --git a/README.md b/README.md index 7dedf279..a1b51b9e 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,27 @@ ![stability-experimental](https://img.shields.io/badge/stability-experimental-orange.svg?style=for-the-badge) - ![Go](https://img.shields.io/badge/go-%2300ADD8.svg?style=for-the-badge&logo=go&logoColor=white) ![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white) ![NPM](https://img.shields.io/badge/NPM-%23CB3837.svg?style=for-the-badge&logo=npm&logoColor=white) + # OwnCord -*The gaming chat platform you actually own.* +The gaming chat platform you actually own. -> **Early Alpha — Building in the Open** -> OwnCord is under active development and is not production-ready. Do not use it for sensitive communications. Security hardening is in progress. Contributions and [security reports](https://github.com/J3vb/OwnCord/issues) are welcome. +> **Early Alpha / Work in Progress** +> OwnCord is in active development and is not production-ready. Expect rough edges, rapid changes, and occasional breaking behavior. +> +> Do not use it for sensitive communications yet. -A self-hosted chat platform with real-time messaging, -voice/video, file sharing, and a web admin panel. Run your own -server on Windows or Linux and keep everything under your control -— zero cloud dependencies, works fully on LAN. +## Development Model + +OwnCord is built with an AI-first development workflow. +Most implementation is generated through autonomous AI tooling, with quality validated primarily through automated checks (CI, tests, linting) and real-world feedback during alpha. + +This approach enables fast iteration, but it also means behavior may change quickly between releases. + +OwnCord is a self-hosted chat stack with a Go server and a Tauri desktop client. +It includes real-time messaging, voice/video via LiveKit, file sharing, and a web admin panel.

OwnCord Client @@ -25,164 +32,73 @@ server on Windows or Linux and keep everything under your control Admin Panel

+## Current Project Status + +| Area | Status | +| ---- | ------ | +| Core chat flow | Working in alpha | +| Voice/video | Working in alpha | +| Admin panel | Working in alpha | +| Security hardening | In progress | + +## Platform Support (Current Releases) + +| Component | Windows x64 | Linux x64 | Linux ARM64 | +| --------- | ----------- | --------- | ----------- | +| Server binary | Yes | Yes | Not published yet | +| Desktop client | Yes | Yes | Yes | +| Docker server | N/A | Yes | N/A | + +## Start Here + +- New user quick path: [docs/quick-start.md](docs/quick-start.md) +- Linux Docker deployment: [docs/deployment.md](docs/deployment.md) +- Remote access without router config: [docs/tailscale.md](docs/tailscale.md) +- Manual router/network setup: [docs/port-forwarding.md](docs/port-forwarding.md) + ## Quick Start -**Option A — Binary (Windows / Linux)** +### Option A: Prebuilt binaries -1. Download from [GitHub Releases](https://github.com/J3vb/OwnCord/releases): - - **Windows**: `chatserver.exe` + `OwnCord_x.x.x_x64-setup.exe` - - **Linux x64**: `chatserver-linux-amd64.tar.gz` + `OwnCord_x.x.x_x86_64.AppImage` (or `_amd64.deb`) - - **Linux ARM64**: `chatserver-linux-amd64.tar.gz` + `OwnCord_x.x.x_aarch64.AppImage` (or `_arm64.deb`) -2. Run `chatserver.exe` / `./chatserver` — generates `config.yaml` and `data/` on first run -3. Open `https://localhost:8443/admin` to create the Owner account -4. Generate invite codes and share them with friends +1. Download assets from [GitHub Releases](https://github.com/J3vb/OwnCord/releases). +2. Run the server binary: + - Windows: `chatserver.exe` + - Linux: `./chatserver` +3. Open `https://localhost:8443/admin` and create your Owner account. +4. Generate invite codes in the admin panel and share them with friends. -**Option B — Docker (Linux)** +### Option B: Docker (Linux server) ```bash cd Server -cp .env.example .env && cp livekit.yaml.example livekit.yaml -# Edit both files (set API keys + your public IP in livekit.yaml) +cp .env.example .env +cp livekit.yaml.example livekit.yaml +# Edit both files before starting docker compose up -d ``` -See [Deployment Guide](docs/deployment.md) for full Docker setup. +See the full setup guide in [docs/deployment.md](docs/deployment.md). -> **Finding your IP:** `ipconfig` (Windows) or `ip a` (Linux). The server binds to `0.0.0.0:8443` — share your LAN IP (e.g. `192.168.1.2:8443`) with friends. +The client uses TOFU (Trust On First Use) for self-signed certificates: it prompts once, then pins the certificate for future connections. -The client uses TOFU (Trust On First Use) for self-signed certificates — it prompts to trust the server on first connection, then pins it for future sessions. +## What OwnCord Already Has -### Voice & Video Setup (Optional) +- Real-time channels and direct messages over WebSocket +- Voice/video channels via LiveKit +- Invite-only registration and role-based permissions +- Web admin panel with logs, backups, and update tooling +- File uploads and inline media rendering +- TOTP 2FA support and API rate limiting +- Desktop client auto-update with signature verification -**Binary deployment** — set in `config.yaml` and restart: -```yaml -voice: - livekit_api_key: "my-unique-key" - livekit_api_secret: "my-secret-min-32-characters-long!!" - livekit_binary: "C:/path/to/livekit-server.exe" # Windows - # livekit_binary: "/usr/local/bin/livekit-server" # Linux -``` -OwnCord auto-starts LiveKit as a companion process. - -**Docker deployment** — LiveKit runs as a separate container, configured via `.env` and `livekit.yaml`. See [LiveKit Setup](docs/livekit-setup.md#docker). - - -## Features - -### Chat - -- Real-time text messaging over WebSocket -- Message editing, deletion, and replies -- Emoji reactions with per-message counts -- Typing indicators -- Full-text message search (SQLite FTS5) -- Pinned messages per channel -- Rich link previews with Open Graph metadata -- YouTube embed support with cached titles -- GIF picker powered by Klipy with inline rendering and Klipy watermark -- Inline image previews with lightbox viewer - -### Voice & Video - -- Voice channels powered by LiveKit SFU -- Webcam video chat with Discord-style grid layout (fixed 16:9 aspect ratio) -- Sidebar stream preview (hover to see live video thumbnail) -- Mute, deafen, camera, and screenshare controls -- Push-to-talk with global hotkey (non-consuming, works while unfocused) -- Per-user volume control (right-click user in voice channel) -- RNNoise ML noise suppression -- Voice activity detection with speaker indicators (pulsing green glow) -- Connection quality indicator with expandable transport stats -- Voice call duration timer (MM:SS / HH:MM:SS elapsed) -- LiveKit server runs as a companion process alongside `chatserver.exe` - -### Direct Messages - -- One-on-one DM conversations with any server member -- DM preview section in sidebar with unread bubble indicators -- Auto-reopen DM channels on incoming message -- DM header shows `@ username` with live online status - -### Channels & Organization - -- Text and voice channels organized by categories -- Create, edit, delete, and reorder channels -- Unread message indicators -- Quick channel switcher (Ctrl+K) - -### File Sharing - -- Drag-and-drop and clipboard paste uploads -- Inline image previews with persistent caching (IndexedDB) -- File download with native save dialog -- Configurable max upload size - -### Users & Permissions - -- Invite-only registration with invite codes -- Role-based permissions with custom roles -- Member list with online/offline presence -- User profiles with status (online, idle, dnd, offline) - -### Administration - -- Web-based admin panel at `/admin` (IP-restricted to private networks by default) -- Dashboard with server stats and recent activity -- User management (ban, kick, role assignment) with modals -- Channel management (create, edit, delete) -- Settings management (server name, MOTD, limits, security) -- Live server log streaming via SSE with level filters, - search, auto-scroll, pause/resume, copy, and clear -- Audit log with search, action type filter, copy, and CSV export -- Database backup and restore with pre-restore safety backups -- Server update checker and one-click apply (GitHub Releases) -- Metrics endpoint with uptime, goroutines, heap, connected users -- Diagnostics endpoint for connectivity checks - -### Security - -- TLS encryption (self-signed, Let's Encrypt, or custom cert) -- Trust-on-first-use certificate pinning in the client -- Two-factor authentication (TOTP) with QR enrollment and backup codes -- Ed25519-signed client auto-updates -- Rate limiting on all endpoints -- CSRF protection and security headers -- Account deletion with password confirmation and data anonymization - -### Desktop Client - -- Native desktop app built with Tauri v2 — Windows x64, Linux x64, Linux ARM64 -- System tray integration -- Desktop notifications with taskbar flash and sound -- In-app auto-update with progress notification -- Credential storage via system keychain (Windows Credential Manager / Linux Secret Service / macOS Keychain) -- Auto-login with saved credentials (one-click connect) -- Custom emoji picker -- Compact mode for information-dense layouts -- Discord-style settings panel with blurred backdrop -- OC Neon Glow theme with custom theming system (JSON import/export) -- Accent color picker -- Quick-switch server overlay for multi-server users -- Structured logging with JSONL persistence (5-day rotation) - - -### Networking - -For friends outside your LAN, you need to forward these ports: - -| Port | Protocol | Purpose | -| ---- | -------- | ------- | -| `8443` | TCP | HTTPS, WebSocket, REST API | -| `7881` | TCP | LiveKit signaling (voice/video) | -| `50000-60000` | UDP | LiveKit WebRTC media (voice/video) | - -Alternatively, use Tailscale for zero-config networking -with no port forwarding. +See deeper feature and architecture docs in [docs/client-architecture.md](docs/client-architecture.md) and [docs/protocol.md](docs/protocol.md). ## Architecture -Two components: a **Go server** and a **Tauri v2 client** -(Rust + TypeScript). +Two main components: + +- Go server (REST API, WebSocket hub, SQLite, admin panel) +- Tauri v2 desktop client (Rust backend + TypeScript frontend) ```text +---------------------+ +---------------------+ @@ -204,179 +120,103 @@ Two components: a **Go server** and a **Tauri v2 client** +---------------------+ ``` -- **WebSocket** — chat messages, typing, presence, voice signaling -- **REST API** — message history, file uploads, channel management, auth -- **LiveKit** — voice and video via LiveKit SFU (companion process) - -## Project Structure - -```text -OwnCord/ -├── Server/ # Go server -│ ├── api/ # REST handlers + middleware -│ ├── ws/ # WebSocket hub + handlers -│ ├── db/ # SQLite queries + migrations -│ ├── auth/ # Authentication + rate limiting -│ ├── config/ # YAML config loading -│ ├── updater/ # GitHub Releases update checker -│ ├── admin/ # Web admin panel (static SPA) -│ ├── storage/ # File upload storage -│ ├── permissions/ # Role-based permission system -│ └── migrations/ # Database migration files -├── Client/ -│ └── tauri-client/ # Tauri v2 desktop client -│ ├── src-tauri/ # Rust backend (plugins, commands) -│ ├── src/ # TypeScript frontend -│ │ ├── lib/ # Core services (API, WS, LiveKit, updater) -│ │ ├── stores/ # Reactive state (auth, channels, messages, voice) -│ │ ├── components/ # UI components (28 modules) -│ │ ├── pages/ # Page layouts -│ │ └── styles/ # CSS -│ └── tests/ # Unit, integration, and E2E tests -└── docs/ # Project documentation (Obsidian vault) -``` - -## Building from Source +## Build and Test ### Prerequisites - Go 1.25+ -- Node.js 20+ and Rust (stable) — client only -- Windows x64, Linux x64, or Linux ARM64 +- Node.js 20+ +- Rust stable (client builds) -### Server +### Build from source -**Windows:** ```bash +# Server (Windows) cd Server go build -o chatserver.exe -ldflags "-s -w -X main.version=1.0.0" . -``` -**Linux:** -```bash +# Server (Linux) cd Server CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.0.0" . -``` -### Client - -```bash +# Client cd Client/tauri-client npm install npm run tauri build ``` -Output location: -- **Windows**: `src-tauri/target/release/bundle/nsis/` (NSIS installer) -- **Linux**: `src-tauri/target/release/bundle/appimage/` and `bundle/deb/` - -### Running Tests +### Core verification commands ```bash # Server -cd Server && go test ./... -cd Server && go test ./... -cover # with coverage +cd Server +go test ./... # Client cd Client/tauri-client -npm test # all tests (vitest) -npm run test:unit # unit tests only -npm run test:integration # integration tests -npm run test:e2e # Playwright E2E (mocked Tauri) -npm run test:e2e:native # Playwright E2E (real Tauri exe + CDP) -npm run test:coverage # coverage report - -# Type checking & linting -npm run typecheck # full typecheck -npm run lint # ESLint check -npm run lint:fix # ESLint auto-fix +npm run typecheck +npm run lint +npm test ``` +For the full command set, use [docs/contributing.md](docs/contributing.md). + ## Configuration -The server generates a `config.yaml` on first run. All runtime data -is stored in a `data/` directory alongside the executable: +On first run, the server generates `config.yaml` and a local `data/` directory: ```text data/ -├── owncord.db # SQLite database -├── certs/ # TLS certificates (auto-generated if self_signed) -├── uploads/ # User-uploaded files -└── backups/ # Database backups +├── chatserver.db +├── certs/ +├── uploads/ +└── backups/ ``` -Key settings: +Key options include TLS mode, upload limits, LiveKit settings, and admin CIDR restrictions. +See [docs/server-configuration.md](docs/server-configuration.md). -| Setting | Default | Description | -| ------- | ------- | ----------- | -| `server.port` | `8443` | HTTPS port | -| `server.name` | `OwnCord Server` | Display name | -| `tls.mode` | `self_signed` | TLS mode (self_signed, acme, manual, off) | -| `upload.max_size_mb` | `100` | Max upload size | -| `voice.livekit_url` | `ws://localhost:7880` | LiveKit server WebSocket URL | -| `voice.livekit_api_key` | — | LiveKit API key (required for voice) | -| `voice.livekit_api_secret` | — | LiveKit API secret (min 32 chars, required for voice) | -| `voice.livekit_binary` | — | Path to `livekit-server` binary (empty = don't auto-start) | -| `voice.quality` | `medium` | Voice quality (low, medium, high) | -| `server.admin_allowed_cidrs` | private nets | CIDRs allowed to access `/admin` | -| `github.token` | — | Token for update checks (optional, for higher rate limits) | +## Security and Vulnerability Reporting -## Auto-Updates +- For vulnerabilities, use GitHub Security Advisories (private disclosure flow). +- Do not open public issues for security bugs. +- Read full policy and hardening notes in [docs/security.md](docs/security.md). -The client checks for updates after connecting to the server. -Client updates are Ed25519-signed and verified before install. -Server auto-updates use a separate minisign/Ed25519 signing key, verify `chatserver.exe.sig`, and require a signed `server-update-manifest.json` that binds the binary hash to the release version before apply. +## Update Signing Notes (Maintainers) -For maintainers publishing signed releases from GitHub Actions, configure these repository secrets: +Client and server update signing keys are intentionally separate. -- `TAURI_SIGNING_PRIVATE_KEY` — client updater private key - (via `npx tauri signer generate`) -- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` — client updater key password -- `SERVER_UPDATE_SIGNING_PRIVATE_KEY` — server updater private key -- `SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD` — server updater key password +Required Actions secrets for release signing: -These are secret names only. Do not commit private key material or passphrases to the repository. +- `TAURI_SIGNING_PRIVATE_KEY` +- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` +- `SERVER_UPDATE_SIGNING_PRIVATE_KEY` +- `SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD` -When rotating the server updater key, also update [Server/updater/server_update_public_key.txt](Server/updater/server_update_public_key.txt). For live deployments that rely on server auto-update continuity, treat key rotation as a staged rollover rather than a one-step secret swap. +When rotating the server updater key, update [Server/updater/server_update_public_key.txt](Server/updater/server_update_public_key.txt) and use staged rollover for live fleets. -## Documentation +## Docs Index -- [Quick Start Guide](docs/quick-start.md) -- [Server Configuration](docs/server-configuration.md) -- [LiveKit Setup (Voice/Video)](docs/livekit-setup.md) -- [Deployment Guide](docs/deployment.md) -- [Port Forwarding](docs/port-forwarding.md) -- [Tailscale Guide](docs/tailscale.md) -- [REST API Reference](docs/api.md) -- [WebSocket Protocol](docs/protocol.md) -- [Database Schema](docs/schema.md) -- [Client Architecture](docs/client-architecture.md) -- [Contributing](docs/contributing.md) -- [Security Policy](docs/security.md) +- [docs/quick-start.md](docs/quick-start.md) +- [docs/deployment.md](docs/deployment.md) +- [docs/livekit-setup.md](docs/livekit-setup.md) +- [docs/port-forwarding.md](docs/port-forwarding.md) +- [docs/tailscale.md](docs/tailscale.md) +- [docs/api.md](docs/api.md) +- [docs/protocol.md](docs/protocol.md) +- [docs/schema.md](docs/schema.md) +- [docs/client-architecture.md](docs/client-architecture.md) +- [docs/contributing.md](docs/contributing.md) +- [docs/security.md](docs/security.md) ## Contributing -1. Fork the repo and create a feature branch from `dev` -2. Follow existing code style and conventions -3. Write tests for new functionality -4. Open a PR against `dev` with a clear description +1. Create a branch from `dev`. +2. Keep changes focused and tested. +3. Open a PR targeting `dev`. -See [Contributing Guide](docs/contributing.md) for details. - -## Tech Stack - -| Component | Technology | -| --------- | --------- | -| Server | Go, chi router, LiveKit server SDK | -| Database | SQLite (pure Go, embedded) | -| Client | Tauri v2 (Rust + TypeScript) | -| Voice/Video | LiveKit SFU (companion process or Docker) | -| Build | NSIS (Windows), AppImage + deb (Linux), GitHub Actions CI | +See [docs/contributing.md](docs/contributing.md) for the full process. ## License AGPL-3.0 - ---- - -*Built with [Claude Code](https://claude.ai/code) and [GitHub Copilot](https://github.com/features/copilot).* diff --git a/Server/api/constants.go b/Server/api/constants.go index 830fc1c3..1c8bea50 100644 --- a/Server/api/constants.go +++ b/Server/api/constants.go @@ -1,6 +1,10 @@ package api -import "time" +import ( + "time" + + "github.com/owncord/server/config" +) // ─── Rate limits ──────────────────────────────────────────────────────────── // @@ -123,7 +127,7 @@ const ( const ( // defaultMaxBodySize is the default request body size limit (1 MiB). - defaultMaxBodySize = 1 << 20 + defaultMaxBodySize = config.MaxMessageBytes // uploadMaxBodySize is the request body size limit for file uploads (100 MiB). uploadMaxBodySize = 100 << 20 diff --git a/Server/api/coverage_push_test.go b/Server/api/coverage_push_test.go index 07c34c7a..550acae0 100644 --- a/Server/api/coverage_push_test.go +++ b/Server/api/coverage_push_test.go @@ -5,6 +5,7 @@ package api_test import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -322,7 +323,7 @@ func TestRevokeSession_NegativeID(t *testing.T) { // ─── handleLiveKitHealth via exported test helper ─────────────────────────── func TestLiveKitHealth_OK(t *testing.T) { - handler := api.HandleLiveKitHealthForTest(func() (bool, error) { + handler := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) { return true, nil }) @@ -345,7 +346,7 @@ func TestLiveKitHealth_OK(t *testing.T) { } func TestLiveKitHealth_Degraded_WithError(t *testing.T) { - handler := api.HandleLiveKitHealthForTest(func() (bool, error) { + handler := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) { return false, errors.New("connection refused") }) @@ -368,7 +369,7 @@ func TestLiveKitHealth_Degraded_WithError(t *testing.T) { } func TestLiveKitHealth_Degraded_NilError(t *testing.T) { - handler := api.HandleLiveKitHealthForTest(func() (bool, error) { + handler := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) { return false, nil }) @@ -637,7 +638,7 @@ func TestSetPinned_Unauthorized(t *testing.T) { // writeJSON is at 75% — testing the success path covers the rest. func TestWriteJSON_BasicSuccess(t *testing.T) { - handler := api.HandleLiveKitHealthForTest(func() (bool, error) { + handler := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) { return true, nil }) diff --git a/Server/api/diagnostics_handler.go b/Server/api/diagnostics_handler.go index 414fb6f1..9a974178 100644 --- a/Server/api/diagnostics_handler.go +++ b/Server/api/diagnostics_handler.go @@ -46,7 +46,7 @@ func handleDiagnosticsConnectivity( clientAddr := clientIP(r) lkHealthy := false - if ok, _ := hub.LiveKitHealthCheck(); ok { //nolint:contextcheck // TODO: propagate context through this call path + if ok, _ := hub.LiveKitHealthCheck(r.Context()); ok { lkHealthy = true } diff --git a/Server/api/export_test.go b/Server/api/export_test.go index 076b94f5..90229552 100644 --- a/Server/api/export_test.go +++ b/Server/api/export_test.go @@ -1,15 +1,18 @@ package api -import "net/http" +import ( + "context" + "net/http" +) // HandleMetricsForTest exposes handleMetrics for use in external tests. var HandleMetricsForTest = handleMetrics // HandleLiveKitHealthForTest exposes handleLiveKitHealth for use in external tests. -func HandleLiveKitHealthForTest(healthCheck func() (bool, error)) http.HandlerFunc { +func HandleLiveKitHealthForTest(healthCheck func(context.Context) (bool, error)) http.HandlerFunc { // Inline the logic since handleLiveKitHealth requires a *ws.Hub. return func(w http.ResponseWriter, r *http.Request) { - ok, err := healthCheck() + ok, err := healthCheck(r.Context()) if ok { writeJSON(w, http.StatusOK, livekitHealthResponse{ Status: "ok", diff --git a/Server/api/metrics_handler.go b/Server/api/metrics_handler.go index e6019340..221c854c 100644 --- a/Server/api/metrics_handler.go +++ b/Server/api/metrics_handler.go @@ -1,6 +1,7 @@ package api import ( + "context" "net/http" "runtime" "time" @@ -16,13 +17,15 @@ type ServerMetrics struct { NumGC uint32 `json:"num_gc"` ConnectedUsers int `json:"connected_users"` VoiceSessions int `json:"voice_sessions"` + BroadcastDrops uint64 `json:"broadcast_drops"` LiveKitHealthy *bool `json:"livekit_healthy,omitempty"` } // handleMetrics returns an HTTP handler that reports runtime server metrics. // getConnectedUsers is a callback to retrieve the current WebSocket client count. +// getBroadcastDrops is a callback to retrieve the cumulative broadcast drop counter. // livekitHealthCheck is optional — if non-nil, it probes the LiveKit companion process. -func handleMetrics(getConnectedUsers func() int, getVoiceSessions func() int, livekitHealthCheck func() (bool, error)) http.HandlerFunc { +func handleMetrics(getConnectedUsers func() int, getVoiceSessions func() int, getBroadcastDrops func() uint64, livekitHealthCheck func(context.Context) (bool, error)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var m runtime.MemStats runtime.ReadMemStats(&m) @@ -37,10 +40,11 @@ func handleMetrics(getConnectedUsers func() int, getVoiceSessions func() int, li NumGC: m.NumGC, ConnectedUsers: getConnectedUsers(), VoiceSessions: getVoiceSessions(), + BroadcastDrops: getBroadcastDrops(), } if livekitHealthCheck != nil { - healthy, _ := livekitHealthCheck() + healthy, _ := livekitHealthCheck(r.Context()) metrics.LiveKitHealthy = &healthy } diff --git a/Server/api/metrics_handler_test.go b/Server/api/metrics_handler_test.go index da17e714..df8ff281 100644 --- a/Server/api/metrics_handler_test.go +++ b/Server/api/metrics_handler_test.go @@ -1,6 +1,7 @@ package api_test import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -17,7 +18,8 @@ func buildMetricsRouter(allowedCIDRs []string) http.Handler { Get("/api/v1/metrics", api.HandleMetricsForTest( func() int { return 5 }, func() int { return 2 }, - func() (bool, error) { return true, nil }, + func() uint64 { return 0 }, + func(_ context.Context) (bool, error) { return true, nil }, )) return r } @@ -42,7 +44,7 @@ func TestHandleMetrics_ReturnsExpectedFields(t *testing.T) { requiredFields := []string{ "uptime", "uptime_seconds", "goroutines", "heap_alloc_mb", "heap_sys_mb", "num_gc", - "connected_users", "voice_sessions", "livekit_healthy", + "connected_users", "voice_sessions", "broadcast_drops", "livekit_healthy", } for _, f := range requiredFields { if _, ok := resp[f]; !ok { @@ -93,6 +95,7 @@ func TestHandleMetrics_WithoutLiveKitHealthCheck(t *testing.T) { r.Get("/api/v1/metrics", api.HandleMetricsForTest( func() int { return 0 }, func() int { return 0 }, + func() uint64 { return 0 }, nil, // no livekit )) diff --git a/Server/api/middleware_test.go b/Server/api/middleware_test.go index 7f851cf5..5366e142 100644 --- a/Server/api/middleware_test.go +++ b/Server/api/middleware_test.go @@ -1,6 +1,7 @@ package api_test import ( + "context" "encoding/json" "fmt" "net/http" @@ -786,7 +787,7 @@ func TestSecurityHeadersWithTLS_NoHSTSWithoutTLS(t *testing.T) { // ─── handleLiveKitHealth tests ────────────────────────────────────────────── func TestLiveKitHealth_Healthy(t *testing.T) { - h := api.HandleLiveKitHealthForTest(func() (bool, error) { + h := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) { return true, nil }) @@ -809,7 +810,7 @@ func TestLiveKitHealth_Healthy(t *testing.T) { } func TestLiveKitHealth_Unhealthy(t *testing.T) { - h := api.HandleLiveKitHealthForTest(func() (bool, error) { + h := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) { return false, fmt.Errorf("connection refused") }) @@ -835,7 +836,7 @@ func TestLiveKitHealth_Unhealthy(t *testing.T) { } func TestLiveKitHealth_UnhealthyNoError(t *testing.T) { - h := api.HandleLiveKitHealthForTest(func() (bool, error) { + h := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) { return false, nil }) diff --git a/Server/api/router.go b/Server/api/router.go index 032675bb..a8da6e95 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -2,6 +2,7 @@ package api import ( + "context" "encoding/json" "log/slog" "net/http" @@ -190,7 +191,8 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri Get("/api/v1/metrics", handleMetrics( func() int { return hub.ClientCount() }, func() int { return hub.VoiceSessionCount() }, - func() (bool, error) { return hub.LiveKitHealthCheck() }, + func() uint64 { return hub.BroadcastDropCount() }, + func(ctx context.Context) (bool, error) { return hub.LiveKitHealthCheck(ctx) }, )) // Admin panel: static files + REST API (Phase 6). @@ -266,7 +268,7 @@ type livekitHealthResponse struct { func handleLiveKitHealth(hub *ws.Hub) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - ok, err := hub.LiveKitHealthCheck() //nolint:contextcheck // TODO: propagate context through this call path + ok, err := hub.LiveKitHealthCheck(r.Context()) if ok { writeJSON(w, http.StatusOK, livekitHealthResponse{ Status: "ok", diff --git a/Server/config/constants.go b/Server/config/constants.go new file mode 100644 index 00000000..f141a5e4 --- /dev/null +++ b/Server/config/constants.go @@ -0,0 +1,8 @@ +package config + +const ( + // MaxMessageBytes is the maximum size of a single inbound message or + // small HTTP response body that the server will read into memory (1 MiB). + // Used by the WebSocket read-limit and the updater's capped response reader. + MaxMessageBytes = 1 << 20 +) diff --git a/Server/main.go b/Server/main.go index aef12dd2..9458b728 100644 --- a/Server/main.go +++ b/Server/main.go @@ -330,6 +330,11 @@ func getOutboundIP() string { return "localhost" } defer conn.Close() //nolint:errcheck - addr := conn.LocalAddr().(*net.UDPAddr) + addr, ok := conn.LocalAddr().(*net.UDPAddr) + if !ok { + slog.Warn("getOutboundIP: unexpected LocalAddr type, falling back to localhost", + "type", fmt.Sprintf("%T", conn.LocalAddr())) + return "localhost" + } return addr.IP.String() } diff --git a/Server/updater/updater.go b/Server/updater/updater.go index 02278ec7..d158c2fc 100644 --- a/Server/updater/updater.go +++ b/Server/updater/updater.go @@ -25,14 +25,19 @@ import ( "aead.dev/minisign" + "github.com/owncord/server/config" "github.com/owncord/server/syncutil" "golang.org/x/mod/semver" ) const ( - defaultBaseURL = "https://api.github.com" - cacheTTL = 1 * time.Hour + defaultBaseURL = "https://api.github.com" + cacheTTL = 1 * time.Hour + + // maxFetchBytes caps the response body read for checksum/signature files. + // Prevents a malicious or corrupted release asset from exhausting memory. + maxFetchBytes = config.MaxMessageBytes errorCacheTTL = 5 * time.Minute checksumAsset = "checksums.sha256" signatureAsset = windowsServerBinary + ".sig" @@ -673,7 +678,7 @@ func (u *Updater) fetchBody(ctx context.Context, url string) ([]byte, error) { // Cap reads at 1 MiB — checksum and signature files are tiny text; // this prevents a malicious or corrupted release asset from exhausting memory. - return io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return io.ReadAll(io.LimitReader(resp.Body, maxFetchBytes)) } // FindClientAssets scans the cached release assets for the Tauri NSIS diff --git a/Server/ws/client.go b/Server/ws/client.go index 9e03af38..dfe307a9 100644 --- a/Server/ws/client.go +++ b/Server/ws/client.go @@ -9,7 +9,7 @@ import ( "github.com/owncord/server/syncutil" ) -const sendBufSize = 256 +const sendBufSize = 256 // per-client outbound send-channel capacity // SessionCheckInterval is the number of messages processed between periodic // session-expiry checks in readPump. Exported so tests can trigger the check diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 4ba95358..6712b4db 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -2,6 +2,7 @@ package ws import ( + "context" "fmt" "log/slog" "runtime" @@ -39,8 +40,9 @@ type Hub struct { registry *HandlerRegistry permChecker *permissions.Checker - seq uint64 // atomic monotonic sequence counter - replayBuf *EventRingBuffer // recent broadcast events for reconnection replay + seq uint64 // atomic monotonic sequence counter + replayBuf *EventRingBuffer // recent broadcast events for reconnection replay + broadcastDrops atomic.Uint64 // counts messages dropped due to full broadcast channel // Settings cache — avoids per-connection DB queries for server_name/motd. settingsMu syncutil.RWMutex @@ -63,7 +65,7 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter) *Hub { clients: make(map[int64]*Client), db: database, limiter: limiter, - broadcast: make(chan broadcastMsg, 256), + broadcast: make(chan broadcastMsg, 1024), register: make(chan *Client, 32), unregister: make(chan *Client, 32), stop: make(chan struct{}), @@ -122,11 +124,11 @@ func (h *Hub) SetLiveKit(lk *LiveKitClient) { // 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) { +func (h *Hub) LiveKitHealthCheck(ctx context.Context) (bool, error) { if h.livekit == nil { return false, fmt.Errorf("not configured") } - return h.livekit.HealthCheck() + return h.livekit.HealthCheck(ctx) } // SetLiveKitProcess sets the LiveKit process manager on the hub. @@ -353,6 +355,7 @@ func (h *Hub) BroadcastToChannel(channelID int64, msg []byte) { select { case h.broadcast <- broadcastMsg{channelID: channelID, msg: msg}: default: + h.broadcastDrops.Add(1) slog.Warn("hub: broadcast channel full, dropping message", "channel_id", channelID, "msg_len", len(msg)) } @@ -364,6 +367,7 @@ func (h *Hub) BroadcastToAll(msg []byte) { select { case h.broadcast <- broadcastMsg{channelID: 0, msg: msg}: default: + h.broadcastDrops.Add(1) slog.Warn("hub: broadcast channel full, dropping global message", "msg_len", len(msg)) } @@ -442,6 +446,12 @@ func (h *Hub) ClientCount() int { return len(h.clients) } +// BroadcastDropCount returns the cumulative number of messages dropped due to a +// full broadcast channel. Safe to call from any goroutine. +func (h *Hub) BroadcastDropCount() uint64 { + return h.broadcastDrops.Load() +} + // VoiceSessionCount returns the number of clients currently in a voice channel. func (h *Hub) VoiceSessionCount() int { h.mu.RLock() diff --git a/Server/ws/hub_test.go b/Server/ws/hub_test.go index 7eb169e4..73112eac 100644 --- a/Server/ws/hub_test.go +++ b/Server/ws/hub_test.go @@ -1,6 +1,7 @@ package ws_test import ( + "context" "encoding/json" "fmt" "sync" @@ -796,7 +797,7 @@ func TestHub_SweepRevokedSessions_EmptyTokenHashSkipped(t *testing.T) { func TestHub_LiveKitHealthCheck_NilReturnsError(t *testing.T) { hub, _ := newTestHub(t) - ok, err := hub.LiveKitHealthCheck() + ok, err := hub.LiveKitHealthCheck(context.Background()) if ok { t.Error("expected ok=false when LiveKit is nil") } diff --git a/Server/ws/livekit.go b/Server/ws/livekit.go index 3573cf21..aacafbb8 100644 --- a/Server/ws/livekit.go +++ b/Server/ws/livekit.go @@ -210,8 +210,8 @@ func (c *LiveKitClient) CountVideoTracks(channelID int64) (int, error) { // 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) +func (c *LiveKitClient) HealthCheck(ctx context.Context) (bool, error) { + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() _, err := c.roomSvc.ListRooms(ctx, &livekit.ListRoomsRequest{}) diff --git a/Server/ws/livekit_process.go b/Server/ws/livekit_process.go index ddefff8f..767b874c 100644 --- a/Server/ws/livekit_process.go +++ b/Server/ws/livekit_process.go @@ -245,10 +245,10 @@ func (p *LiveKitProcess) IsRunning() bool { // 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) { +func (p *LiveKitProcess) HealthCheck(ctx context.Context) (bool, error) { httpURL := wsToHTTP(p.cfg.LiveKitURL) - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, httpURL, nil) diff --git a/Server/ws/livekit_test.go b/Server/ws/livekit_test.go index 9b1cf748..f4fd591c 100644 --- a/Server/ws/livekit_test.go +++ b/Server/ws/livekit_test.go @@ -1,6 +1,7 @@ package ws_test import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -720,7 +721,7 @@ func TestHealthCheck_Success(t *testing.T) { proc := ws.NewLiveKitProcess(cfg, tlsCfg, t.TempDir()) - ok, err := proc.HealthCheck() + ok, err := proc.HealthCheck(context.Background()) if err != nil { t.Fatalf("HealthCheck: %v", err) } @@ -741,7 +742,7 @@ func TestHealthCheck_ServerDown(t *testing.T) { proc := ws.NewLiveKitProcess(cfg, tlsCfg, t.TempDir()) - ok, err := proc.HealthCheck() + ok, err := proc.HealthCheck(context.Background()) if err == nil { t.Fatal("expected error for unreachable server, got nil") } @@ -767,7 +768,7 @@ func TestHealthCheck_NonOKStatus(t *testing.T) { tlsCfg := &config.TLSConfig{} proc := ws.NewLiveKitProcess(cfg, tlsCfg, t.TempDir()) - ok, err := proc.HealthCheck() + ok, err := proc.HealthCheck(context.Background()) if err != nil { t.Fatalf("HealthCheck: %v", err) } diff --git a/Server/ws/serve.go b/Server/ws/serve.go index 2b5e731c..10c2033d 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -12,6 +12,7 @@ import ( "nhooyr.io/websocket" "github.com/owncord/server/auth" + "github.com/owncord/server/config" "github.com/owncord/server/db" "github.com/owncord/server/permissions" ) @@ -20,6 +21,10 @@ const ( authDeadline = 10 * time.Second writeTimeout = 10 * time.Second settingsCacheTTL = 30 * time.Second + + // wsReadLimitBytes is the maximum size of a single inbound WebSocket + // message. Must match the client-side upload cap. + wsReadLimitBytes = config.MaxMessageBytes ) // ServeWS upgrades an HTTP connection to WebSocket, performs in-band auth, @@ -37,7 +42,7 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun slog.Warn("ws upgrade failed", "err", err) return } - conn.SetReadLimit(1 << 20) // 1 MB — match client-side limit + conn.SetReadLimit(wsReadLimitBytes) // match client-side upload cap c, lastSeq, err := hub.upgradeAndAuth(conn, database, r) if err != nil { diff --git a/docs/deployment.md b/docs/deployment.md index ec5fb632..80810507 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -6,8 +6,10 @@ Production deployment guide for OwnCord server on Windows and Linux. - **Windows 10+** (x64) or **Linux** (x64) - **Go 1.25+** (only if building from source) -- **LiveKit Server** binary (for voice/video) -- see [LiveKit Setup](livekit-setup.md) -- Ports available: `8443` (default), `7880` (LiveKit), `80` (if using ACME/Let's Encrypt) +- **LiveKit Server** binary (only if enabling voice/video) -- see [LiveKit Setup](livekit-setup.md) +- Required port: `8443` (OwnCord HTTPS/WebSocket) +- Additional ports for voice/video: `7880/TCP`, `7881/TCP`, `50000-60000/UDP` +- Additional port for ACME TLS: `80/TCP` ## Building from Source diff --git a/docs/port-forwarding.md b/docs/port-forwarding.md index ca9b5bf4..eec9ecaa 100644 --- a/docs/port-forwarding.md +++ b/docs/port-forwarding.md @@ -1,32 +1,48 @@ # Port Forwarding Guide -How to make your OwnCord server accessible to friends outside your local network. +How to make your OwnCord server reachable from outside your LAN. -## Why +## Before You Start -Friends outside your LAN need a way to reach your server. Port forwarding tells your router to send incoming traffic on a specific port to your server machine. +If you want a simpler remote-access path, use [Tailscale](tailscale.md) and skip manual forwarding. -## Steps +## Required Ports -1. **Find your router's admin page** -- usually `192.168.1.1` or `192.168.0.1`. Check your gateway IP with `ipconfig` (Windows) or `ip route` (Linux). -2. **Find the port forwarding section** -- may be listed under "NAT", "Virtual Servers", or "Firewall" depending on your router. -3. **Add a rule for the server:** - - External port: `8443` - - Internal IP: your server machine's local IP - - Internal port: `8443` - - Protocol: TCP -4. **Add a rule for voice chat** (if using voice/video): - - External port: `3478` - - Internal IP: your server machine's local IP - - Internal port: `3478` - - Protocol: UDP -5. **Find your public IP** at a site like `whatismyip.com`. -6. **Share your public IP and port** with friends: `your.public.ip:8443` +### Always required -## Troubleshooting +| Port | Protocol | Purpose | +| ---- | -------- | ------- | +| `8443` | TCP | OwnCord HTTPS + WebSocket | -Windows Firewall may block incoming connections. `chatserver.exe` should prompt on first run to allow access. If not, manually add a firewall rule for port 8443 (TCP) and 3478 (UDP). +### Required only for voice/video -## Dynamic IP +| Port | Protocol | Purpose | +| ---- | -------- | ------- | +| `7880` | TCP | LiveKit signaling | +| `7881` | TCP | LiveKit TCP fallback | +| `50000-60000` | UDP | LiveKit media | -If your public IP changes frequently, consider a Dynamic DNS service (e.g., No-IP, DuckDNS) so friends can use a stable hostname instead of a raw IP address. +## Router Steps + +1. Open your router admin page (often `192.168.1.1` or `192.168.0.1`). +2. Find the port forwarding section (sometimes called NAT, virtual server, or firewall rules). +3. Set static/reserved LAN IP for your server machine. +4. Add forwarding rule for `8443/TCP` to that LAN IP. +5. If using voice/video, add `7880/TCP`, `7881/TCP`, and `50000-60000/UDP`. +6. Save and apply rules. + +## Connect Address to Share + +Share `https://:8443` (or your DNS name) with users. + +## Troubleshooting Checklist + +- Confirm the server is listening on `8443`. +- Confirm router rules point to the correct LAN IP. +- Confirm OS firewall allows forwarded ports. +- Confirm ISP is not blocking inbound ports. +- Test from a different network (mobile hotspot), not from the same LAN. + +## Dynamic Public IP + +If your public IP changes, use dynamic DNS so users connect with a stable hostname. diff --git a/docs/quick-start.md b/docs/quick-start.md index bf3a8d0b..1f1e8590 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -1,89 +1,91 @@ # Quick Start Guide -Get OwnCord up and running in minutes. +Get OwnCord running with the fewest possible steps. + +## Choose Your Setup Path + +| Goal | Best path | +| ---- | --------- | +| Fastest local/LAN setup | Prebuilt binaries | +| Linux server with easiest operations | Docker | +| Custom dev build | Build from source | + +## Platform Support (Current Releases) + +| Component | Windows x64 | Linux x64 | Linux ARM64 | +| --------- | ----------- | --------- | ----------- | +| Server binary | Yes | Yes | Not published yet | +| Desktop client | Yes | Yes | Yes | ## Prerequisites -| | Windows x64 | Linux x64 | Linux ARM64 | -|-|:-----------:|:---------:|:-----------:| -| Server | ✅ | ✅ | ✅ | -| Client | ✅ | ✅ | ✅ | +- Go 1.25+ (only if building server from source) +- Node.js 20+ and Rust (only if building client from source) +- Docker + Compose v2 (Docker path only) +- LiveKit (optional, required for voice/video) -- **Go 1.25+** (only if building the server from source) -- **Node.js 20+** + **Rust / Cargo** (only if building the client from source) -- **Docker + Compose v2** (alternative to building the server — Linux only) -- **LiveKit Server** (optional, for voice/video) -- see [LiveKit Setup](livekit-setup.md) +## Option A: Prebuilt binaries (recommended) -## Step 1: Download +1. Download from [GitHub Releases](https://github.com/J3vb/OwnCord/releases). +2. Start the server: + - Windows: `chatserver.exe` + - Linux: `./chatserver` +3. Open `https://localhost:8443/admin`. +4. Create the Owner account. +5. Create invite codes and share them. -### Option A — Pre-built binaries (recommended) - -Download from [GitHub Releases](https://github.com/J3vb/OwnCord/releases): - -| Platform | Server | Client | -|----------|--------|--------| -| Windows x64 | `chatserver.exe` | `OwnCord_x.x.x_x64-setup.exe` | -| Linux x64 | `chatserver-linux-amd64.tar.gz` | `OwnCord_x.x.x_x86_64.AppImage` or `_amd64.deb` | -| Linux ARM64 | _(included in server tar)_ | `OwnCord_x.x.x_aarch64.AppImage` or `_arm64.deb` | - -### Option B — Docker (Linux server only) +## Option B: Docker (Linux server) ```bash cd Server -cp .env.example .env # set LIVEKIT_API_KEY + LIVEKIT_API_SECRET -cp livekit.yaml.example livekit.yaml # set node_ip + matching keys +cp .env.example .env +cp livekit.yaml.example livekit.yaml +# Edit both files before start docker compose up -d ``` -See [Deployment Guide — Docker](deployment.md#docker-linux) for full details. +Then open `https://localhost:8443/admin` and create the Owner account. -### Option C — Build from source +Full Docker details: [Deployment Guide](deployment.md#docker-linux). + +## Option C: Build from source ```bash # Server (Windows) -cd Server && go build -o chatserver.exe -ldflags "-s -w -X main.version=1.0.0" . +cd Server +go build -o chatserver.exe -ldflags "-s -w -X main.version=1.0.0" . # Server (Linux) -cd Server && CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.0.0" . +cd Server +CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.0.0" . # Client -cd Client/tauri-client && npm install && npm run tauri build +cd Client/tauri-client +npm install +npm run tauri build ``` -## Step 2: Run the Server +## What Happens on First Server Start -**Windows/Linux binary:** Run `chatserver.exe` (Windows) or `./chatserver` (Linux). On first run: +- `config.yaml` is created with defaults. +- `data/` is created for DB, certs, uploads, and backups. +- A self-signed TLS certificate is generated. +- SQLite schema and migrations are applied. -1. `config.yaml` is created in the working directory with default settings -2. `data/` directory is created for the database, TLS certs, uploads, and backups -3. A self-signed TLS certificate is generated automatically -4. SQLite database is created and all migrations are applied -5. All user statuses are reset to offline (clean slate) +## Client Connection Notes -The server starts on `https://0.0.0.0:8443`. +- The default server address is `https://:8443`. +- The desktop client uses TOFU certificate pinning: + - First connection prompts for trust. + - Future connections require the same cert fingerprint. -See [Server Configuration](server-configuration.md) for the full config key reference and environment variable overrides. +## If Remote Users Cannot Connect -## Step 3: Admin Setup - -Open `https://localhost:8443/admin` in a browser. The first-run setup page will prompt you to create the Owner account (username + password). This user gets the Owner role with full server control. - -## Step 4: Create Invites - -In the admin panel, go to invite management and generate invite codes for your friends. - -## Step 5: Connect Clients - -Friends install OwnCord, enter your server address (IP or domain + port 8443), and redeem their invite code to register. - -The client uses TOFU (Trust On First Use) for self-signed certificates -- it will prompt to trust the server's certificate on first connection, then pin it for future sessions. - -## Networking - -If friends are outside your local network, see the [Port Forwarding Guide](port-forwarding.md) or use [Tailscale](tailscale.md) for zero-config networking. +1. Use [Tailscale](tailscale.md) for the simplest remote setup. +2. Or configure [Port Forwarding](port-forwarding.md). ## Next Steps -- [Server Configuration](server-configuration.md) -- customize ports, TLS, uploads, voice -- [Deployment Guide](deployment.md) -- production hardening, backups, monitoring, Windows service setup -- [LiveKit Setup](livekit-setup.md) -- enable voice and video chat +- [Server Configuration](server-configuration.md) +- [Deployment Guide](deployment.md) +- [LiveKit Setup](livekit-setup.md) diff --git a/docs/tailscale.md b/docs/tailscale.md index ed490956..76fc09af 100644 --- a/docs/tailscale.md +++ b/docs/tailscale.md @@ -1,23 +1,33 @@ -# Tailscale Guide (Zero-Config Alternative) +# Tailscale Guide (Zero-Config Remote Access) -Use Tailscale for secure, zero-config networking without port forwarding. +Use Tailscale when you want remote access without port forwarding. -## What is Tailscale +## Why Tailscale -Tailscale is a mesh VPN that creates encrypted tunnels between your devices using WireGuard. No port forwarding, no dynamic DNS, and it works behind CGNAT. Free for personal use. +Tailscale creates an encrypted private network between your devices using WireGuard. +It works behind CGNAT and strict home routers, so setup is usually faster than manual forwarding. ## Setup -1. **Install Tailscale** on the server machine and each client machine: https://tailscale.com/download -2. **Sign in** with the same Tailscale account (or share the machine using Tailscale's sharing feature) -3. **Find the server's Tailscale IP** -- shown in the Tailscale app, typically `100.x.y.z` -4. **Disable TLS in config** -- set `tls.mode` to `"off"` in `config.yaml` since Tailscale already encrypts all traffic with WireGuard -5. **Connect clients** using the Tailscale IP: `100.x.y.z:8443` +1. Install Tailscale on the server machine and client machines: https://tailscale.com/download +2. Sign in and confirm all devices are in the same tailnet (or shared access is granted). +3. Get the server Tailscale IP (usually `100.x.y.z`) from the Tailscale app. +4. Keep OwnCord on port `8443`. +5. Connect clients to `https://:8443`. + +## TLS Recommendation + +- Recommended: keep `tls.mode: self_signed` (default). +- Optional advanced setup: set `tls.mode: off` only if every client is strictly inside trusted Tailscale access and you accept plaintext inside the tailnet. + +## Voice/Video with Tailscale + +- Tailscale handles device-to-device reachability, but LiveKit still needs correct runtime config. +- Follow [livekit-setup.md](livekit-setup.md) for LiveKit key/secret and port behavior. ## Benefits -- No port forwarding needed -- Works behind CGNAT and strict firewalls -- Encrypted by default (WireGuard) -- Stable IPs that don't change -- Easy to add/remove friends via the Tailscale admin console +- No router port forwarding. +- Works behind CGNAT. +- Stable private IPs. +- Encrypted transport by default.