Files
OwnCord/Client/tauri-client/src-tauri/src/ws_proxy.rs
T
J3vb c3a8aa477c fix: resolve 20 code review bugs across Rust, TypeScript, and Go
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
2026-04-03 23:18:06 +02:00

500 lines
17 KiB
Rust

// WebSocket proxy — routes WSS through Rust to bypass self-signed cert rejection.
// JS sends/receives messages via Tauri events instead of native WebSocket.
//
// Implements TOFU (Trust On First Use) certificate pinning:
// - On first connect to a host, the cert SHA-256 fingerprint is stored.
// - On subsequent connects, the fingerprint is compared with the stored value.
// - If the fingerprint changes, the connection is rejected (potential MitM).
use futures_util::{SinkExt, StreamExt};
use log::{debug, error, info, warn};
use ring::digest::{digest, SHA256};
use serde_json::Value;
use std::sync::Arc;
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);
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: Arc<Mutex<Option<mpsc::Sender<String>>>>,
}
impl WsState {
pub fn new() -> Self {
Self {
tx: Arc::new(Mutex::new(None)),
}
}
}
/// Shared fingerprint captured during TLS handshake.
type CapturedFingerprint = Arc<std::sync::Mutex<Option<String>>>;
/// TOFU certificate verifier that captures the server cert fingerprint
/// during the TLS handshake. Still accepts self-signed certs (required
/// for self-hosted servers), but records the fingerprint for comparison
/// with the stored value after the connection is established.
#[derive(Debug)]
struct TofuVerifier {
captured: CapturedFingerprint,
}
impl TofuVerifier {
fn new() -> (Self, CapturedFingerprint) {
let fp = Arc::new(std::sync::Mutex::new(None));
(Self { captured: fp.clone() }, fp)
}
}
impl rustls::client::danger::ServerCertVerifier for TofuVerifier {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
// Compute SHA-256 fingerprint of the DER-encoded leaf certificate.
let hash = digest(&SHA256, end_entity.as_ref());
let hex = hash
.as_ref()
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":");
if let Ok(mut guard) = self.captured.lock() {
*guard = Some(hex);
}
// Accept the cert — TOFU check happens after the handshake completes.
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::RSA_PKCS1_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::ED448,
]
}
}
/// Extract the host (with port) from a wss:// URL.
fn extract_host(url: &str) -> String {
url.strip_prefix("wss://")
.unwrap_or(url)
.split('/')
.next()
.unwrap_or(url)
.to_string()
}
/// Perform TOFU fingerprint check against the Tauri cert store.
/// Returns Ok(()) if trusted, Err(message) if fingerprint mismatch.
fn tofu_check<R: Runtime>(
app: &AppHandle<R>,
host: &str,
fingerprint: &str,
) -> Result<String, String> {
let store = app
.store(CERTS_STORE)
.map_err(|e| format!("failed to open certs store: {e}"))?;
let stored = store.get(host).and_then(|v| {
if let Value::String(s) = v {
Some(s)
} else {
None
}
});
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())
}
Some(ref stored_fp) if stored_fp == fingerprint => {
Ok("trusted".to_string())
}
Some(stored_fp) => {
Err(format!(
"Certificate fingerprint changed for {host}.\n\
Stored: {stored_fp}\n\
Current: {fingerprint}\n\
This may indicate a man-in-the-middle attack or a server certificate rotation.\n\
Use accept_cert_fingerprint to trust the new certificate."
))
}
}
}
/// Single call site for ws-state events — keeps tauri-typegen from generating duplicates.
fn emit_ws_state<R: Runtime>(app: &AppHandle<R>, state: &str) {
let _ = app.emit("ws-state", state);
}
/// Single call site for cert-tofu events — keeps tauri-typegen from generating duplicates.
fn emit_cert_tofu<R: Runtime>(app: &AppHandle<R>, payload: serde_json::Value) {
let _ = app.emit("cert-tofu", payload);
}
/// Connect to a WSS server. Spawns a background task that:
/// - Emits `ws-message` events for incoming server messages
/// - Emits `ws-state` events for connection state changes
/// - Emits `cert-tofu` events for TOFU fingerprint status
/// - Reads from an mpsc channel for outgoing messages
#[tauri::command]
pub async fn ws_connect<R: Runtime>(
app: AppHandle<R>,
state: tauri::State<'_, WsState>,
url: String,
) -> Result<(), String> {
info!("[ws_proxy] connecting to {}", url);
// Drop any existing connection
{
let mut tx_lock = state.tx.lock().await;
if tx_lock.is_some() {
debug!("[ws_proxy] dropping existing connection");
}
*tx_lock = None;
}
// Only allow secure WebSocket connections
if !url.starts_with("wss://") {
warn!("[ws_proxy] rejected non-wss URL: {}", url);
return Err("Only wss:// connections are permitted".into());
}
emit_ws_state(&app, "connecting");
// Create TOFU verifier that captures the cert fingerprint during handshake.
let (verifier, captured_fp) = TofuVerifier::new();
let tls_config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(verifier))
.with_no_client_auth();
let connector =
tokio_tungstenite::Connector::Rustls(Arc::new(tls_config));
let connect_future = tokio_tungstenite::connect_async_tls_with_config(
&url,
None,
false,
Some(connector),
);
let (ws_stream, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_future)
.await
.map_err(|_| {
error!("[ws_proxy] connect timed out after {}s to {}", CONNECT_TIMEOUT.as_secs(), url);
format!("ws connect timed out after {}s", CONNECT_TIMEOUT.as_secs())
})?
.map_err(|e| {
error!("[ws_proxy] connect failed to {}: {}", url, e);
format!("ws connect failed: {e}")
})?;
debug!("[ws_proxy] WebSocket handshake complete");
// ── TOFU check ───────────────────────────────────────────────────────
let host = extract_host(&url);
let fingerprint = captured_fp
.lock()
.map_err(|e| format!("failed to read captured fingerprint: {e}"))?
.clone()
.unwrap_or_default();
if fingerprint.is_empty() {
return Err("TLS handshake completed but no certificate fingerprint was captured".into());
}
match tofu_check(&app, &host, &fingerprint) {
Ok(status) => {
info!("[ws_proxy] TOFU check passed for {}: {}", host, status);
emit_cert_tofu(&app, serde_json::json!({
"host": host,
"fingerprint": fingerprint,
"status": status,
}));
}
Err(mismatch_msg) => {
warn!("[ws_proxy] TOFU check FAILED for {} — certificate fingerprint mismatch", host);
debug!("[ws_proxy] TOFU detail: {}", mismatch_msg);
emit_cert_tofu(&app, serde_json::json!({
"host": host,
"fingerprint": fingerprint,
"status": "mismatch",
"message": mismatch_msg,
}));
// Reject the connection — do not proceed.
return Err(mismatch_msg);
}
}
// ── End TOFU check ───────────────────────────────────────────────────
info!("[ws_proxy] connected to {}", host);
emit_ws_state(&app, "open");
let (mut sink, mut stream) = ws_stream.split();
// Channel for JS → server messages (bounded for backpressure)
let (tx, mut rx) = mpsc::channel::<String>(256);
{
let mut tx_lock = state.tx.lock().await;
*tx_lock = Some(tx);
}
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);
// 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 {
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
}
}
});
// 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");
}
}
// 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");
});
Ok(())
}
/// Send a text message through the proxy WebSocket.
#[tauri::command]
pub async fn ws_send(
state: tauri::State<'_, WsState>,
message: String,
) -> Result<(), String> {
let tx_lock = state.tx.lock().await;
if let Some(tx) = tx_lock.as_ref() {
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())
}
}
/// Disconnect the proxy WebSocket.
#[tauri::command]
pub async fn ws_disconnect(state: tauri::State<'_, WsState>) -> Result<(), String> {
let mut tx_lock = state.tx.lock().await;
*tx_lock = None; // dropping the sender closes the channel → write task ends
Ok(())
}
/// Accept a changed certificate fingerprint for a host.
/// Call this after the user acknowledges a cert-mismatch warning.
#[tauri::command]
pub fn accept_cert_fingerprint<R: Runtime>(
app: AppHandle<R>,
host: String,
fingerprint: String,
) -> Result<(), String> {
if host.is_empty() || fingerprint.is_empty() {
return Err("host and fingerprint must not be empty".into());
}
// Validate SHA-256 colon-hex format: XX:XX:XX:... (32 pairs = 95 chars)
let valid = fingerprint.len() == 95
&& fingerprint.bytes().enumerate().all(|(i, b)| {
if (i + 1) % 3 == 0 {
b == b':'
} else {
b.is_ascii_hexdigit()
}
});
if !valid {
return Err("fingerprint must be SHA-256 colon-hex format (e.g. aa:bb:cc:...)".into());
}
let store = app
.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));
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(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_host_basic_wss_url() {
assert_eq!(extract_host("wss://example.com/chat"), "example.com");
}
#[test]
fn extract_host_with_port() {
assert_eq!(extract_host("wss://example.com:8443/chat"), "example.com:8443");
}
#[test]
fn extract_host_no_path() {
assert_eq!(extract_host("wss://example.com"), "example.com");
}
#[test]
fn extract_host_no_scheme() {
assert_eq!(extract_host("example.com/path"), "example.com");
}
#[test]
fn extract_host_empty() {
assert_eq!(extract_host(""), "");
}
#[test]
fn extract_host_with_port_and_deep_path() {
assert_eq!(extract_host("wss://myhost:9443/api/v1/ws"), "myhost:9443");
}
}