From eb3b029071c8074310044a14baed01d0a3dd5769 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Thu, 14 May 2026 12:08:53 -0300 Subject: [PATCH] media: harden uvc mjpeg transport --- Cargo.lock | 6 +- client/Cargo.toml | 2 +- client/src/launcher/clipboard.rs | 26 ++++- common/Cargo.toml | 2 +- docs/operational-env.md | 7 +- scripts/daemon/lesavka-core.sh | 43 ++++++++- scripts/install/client.sh | 19 +++- scripts/install/server.sh | 19 +++- server/Cargo.toml | 2 +- server/src/bin/lesavka-uvc.real.inc | 95 ++++++++++++++++--- .../src/bin/lesavka_uvc/control_payloads.rs | 3 +- server/src/bin/lesavka_uvc/coverage_model.rs | 14 +++ .../src/bin/lesavka_uvc/coverage_startup.rs | 90 ++++++++++++++++-- .../daemon/server_core_script_contract.rs | 5 + .../server/uvc/server_uvc_binary_contract.rs | 69 +++++++++++++- .../install/client_install_script_contract.rs | 8 ++ .../install/server_install_script_contract.rs | 13 +++ 17 files changed, 384 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1dbf945..9f76250 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.22.30" +version = "0.22.31" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.22.30" +version = "0.22.31" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.22.30" +version = "0.22.31" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index d5a1012..bd842ff 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.22.30" +version = "0.22.31" edition = "2024" [dependencies] diff --git a/client/src/launcher/clipboard.rs b/client/src/launcher/clipboard.rs index 0da3d28..17d5cbf 100644 --- a/client/src/launcher/clipboard.rs +++ b/client/src/launcher/clipboard.rs @@ -55,6 +55,7 @@ fn send_clipboard_via_hid(server_addr: &str, text: &str) -> Result<()> { let delay = clipboard_hid_delay(); let report_count = reports.len(); let timeout = clipboard_transport_timeout(); + let ack_timeout = clipboard_hid_ack_timeout(report_count, delay); let rt = RuntimeBuilder::new_current_thread().enable_all().build()?; rt.block_on(async { let channel = tokio::time::timeout(timeout, relay_transport::connect(server_addr)) @@ -73,7 +74,7 @@ fn send_clipboard_via_hid(server_addr: &str, text: &str) -> Result<()> { .await .map_err(|_| anyhow!("timed out opening keyboard fallback stream after {:?}", timeout))??; let mut echoed = 0usize; - let deadline = tokio::time::Instant::now() + timeout; + let deadline = tokio::time::Instant::now() + ack_timeout; while echoed < report_count { let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { @@ -132,6 +133,16 @@ fn clipboard_hid_delay() -> Duration { Duration::from_millis(delay_ms) } +fn clipboard_hid_ack_timeout(report_count: usize, delay: Duration) -> Duration { + let base = clipboard_transport_timeout(); + let per_report_ms = delay.as_millis().saturating_mul(report_count as u128); + let total_ms = base + .as_millis() + .saturating_add(per_report_ms) + .saturating_add(1_000); + Duration::from_millis(total_ms.min(u64::MAX as u128) as u64) +} + fn clipboard_transport_timeout() -> Duration { let timeout_ms = std::env::var("LESAVKA_CLIPBOARD_TIMEOUT_MS") .ok() @@ -142,7 +153,10 @@ fn clipboard_transport_timeout() -> Duration { #[cfg(test)] mod tests { - use super::{build_hid_paste_reports, clipboard_hid_delay, clipboard_transport_timeout}; + use super::{ + build_hid_paste_reports, clipboard_hid_ack_timeout, clipboard_hid_delay, + clipboard_transport_timeout, + }; use std::time::Duration; #[test] @@ -170,4 +184,12 @@ mod tests { fn clipboard_transport_timeout_has_stable_default() { assert_eq!(clipboard_transport_timeout(), Duration::from_millis(6_000)); } + + #[test] + fn clipboard_hid_ack_timeout_scales_with_report_count() { + assert_eq!( + clipboard_hid_ack_timeout(1_962, Duration::from_millis(18)), + Duration::from_millis(42_316) + ); + } } diff --git a/common/Cargo.toml b/common/Cargo.toml index 8d90601..fb2fb02 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.22.30" +version = "0.22.31" edition = "2024" build = "build.rs" diff --git a/docs/operational-env.md b/docs/operational-env.md index efb39da..32f7e08 100644 --- a/docs/operational-env.md +++ b/docs/operational-env.md @@ -212,7 +212,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_OUTPUT_FRESHNESS_MAX_DRIFT_MS` | manual direct UVC/UAC probe freshness gate; maximum allowed freshness drift across paired probe events, defaults to `100` | | `LESAVKA_PASTE_DELAY_MS` | input routing/clipboard override | | `LESAVKA_PASTE_KEY` | input routing/clipboard override | -| `LESAVKA_PASTE_KEY_FILE` | input routing/clipboard override | +| `LESAVKA_PASTE_KEY_FILE` | encrypted paste shared-key file; server installer defaults to `/etc/lesavka/paste-key` and includes `paste-key` in the client enrollment bundle, while the client installer defaults to `~/.config/lesavka/paste-key` | | `LESAVKA_PASTE_MAX` | input routing/clipboard override | | `LESAVKA_PASTE_RPC` | input routing/clipboard override | | `LESAVKA_PERFORMANCE_GATE_PUSHGATEWAY_JOB` | CI metrics destination override for latency/performance checks | @@ -313,7 +313,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_UVC_APP_MAX_BYTES` | server UVC appsrc memory guard; defaults to `4194304` queued bytes | | `LESAVKA_UVC_APP_MAX_TIME_NS` | server UVC appsrc memory guard; defaults to `200000000` ns of queued media | | `LESAVKA_UVC_BLOCKING` | server hardware/device override | -| `LESAVKA_UVC_BULK` | server hardware/device override | +| `LESAVKA_UVC_BULK` | UVC transfer-mode override; defaults to `1` so patched kernels prefer reliable bulk transfer over lossy isochronous MJPEG. Set `0` to force classic isochronous descriptors | | `LESAVKA_UVC_BUFFER_COUNT` | UVC helper freshness override; number of queued gadget output buffers, defaults to `2` for live-call freshness | | `LESAVKA_UVC_BY_PATH_ROOT` | server hardware/device override | | `LESAVKA_UVC_CODEC` | server hardware/device override | @@ -331,7 +331,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_UVC_FRAME_META_PATH` | UVC helper diagnostic override; explicit path for the optional MJPEG spool metadata sidecar | | `LESAVKA_UVC_FRAME_MAX_AGE_MS` | UVC helper freshness override; stale spooled MJPEG frames older than this are not replayed, defaults to `1000`; `0` disables TTL | | `LESAVKA_UVC_FRAME_MAX_BYTES` | UVC helper MJPEG frame-size guard; explicit maximum accepted frame bytes. Unset or `0` uses the live-call byte budget so oversized frames freeze instead of tearing on the host | -| `LESAVKA_UVC_FRAME_SIZE` | server hardware/device override | +| `LESAVKA_UVC_FRAME_SIZE` | UVC advertised maximum MJPEG frame bytes; defaults to the live-call byte budget instead of raw uncompressed frame size so the gadget/host do not schedule oversized phantom frames | | `LESAVKA_UVC_FRAME_SIZE_GUARD` | UVC helper MJPEG frame-size guard toggle; defaults to `1`; set `0` only for diagnostics when oversized MJPEG frames must be allowed through | | `LESAVKA_UVC_HEIGHT` | server hardware/device override | | `LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS` | server HEVC decode-to-MJPEG freshness override; appsink pull wait for decoded MJPEG handoff before publishing newest frame to the UVC helper, defaults to `20` and is capped at `50` | @@ -346,6 +346,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC` | UVC helper MJPEG budget guard; derives a per-frame byte cap from target FPS when `LESAVKA_UVC_FRAME_MAX_BYTES` is unset | | `LESAVKA_UVC_SKIP_UDEV` | server hardware/device override | | `LESAVKA_UVC_STATS_INTERVAL_MS` | UVC helper telemetry interval for queued/reloaded/rejected MJPEG frame counters; defaults to `5000`, `0` disables | +| `LESAVKA_UVC_STATS_PATH` | UVC helper JSON stats snapshot path for queued/reloaded/rejected MJPEG frame counters; defaults to `/run/lesavka-uvc-video-stats.json`, set `0` or empty to disable file snapshots | | `LESAVKA_UVC_STREAMING_INTERVAL` | server hardware/device override | | `LESAVKA_UVC_STREAM_INTF` | server hardware/device override | | `LESAVKA_UVC_WIDTH` | server hardware/device override | diff --git a/scripts/daemon/lesavka-core.sh b/scripts/daemon/lesavka-core.sh index 8e450d9..dd16980 100755 --- a/scripts/daemon/lesavka-core.sh +++ b/scripts/daemon/lesavka-core.sh @@ -173,7 +173,18 @@ UVC_WIDTH=${LESAVKA_UVC_WIDTH:-1280} UVC_HEIGHT=${LESAVKA_UVC_HEIGHT:-720} UVC_FPS=${LESAVKA_UVC_FPS:-30} UVC_DISABLE_IRQ=${LESAVKA_UVC_DISABLE_IRQ:-} -UVC_BULK=${LESAVKA_UVC_BULK:-} +flag_enabled() { + case "${1,,}" in + 1|true|yes|on) return 0 ;; + 0|false|no|off|"") return 1 ;; + *) return 0 ;; + esac +} +if flag_enabled "${LESAVKA_UVC_BULK:-1}"; then + UVC_BULK=1 +else + UVC_BULK= +fi UVC_CODEC=${LESAVKA_UVC_CODEC:-mjpeg} case "${UVC_CODEC,,}" in mjpeg|mjpg|jpeg) @@ -306,6 +317,34 @@ fi UVC_FRAME_SIZE=${LESAVKA_UVC_FRAME_SIZE:-$((UVC_WIDTH * UVC_HEIGHT * 2))} UVC_INTERVAL_30=${LESAVKA_UVC_INTERVAL_30:-333333} UVC_INTERVAL_20=${LESAVKA_UVC_INTERVAL_20:-500000} +UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-10000000} + +uvc_mjpeg_frame_size_for_fps() { + local fps=$1 + if ((fps < 1)); then + fps=1 + fi + local per_frame=$((UVC_MJPEG_BUDGET_BYTES_PER_SEC / fps)) + if ((per_frame < 65536)); then + per_frame=65536 + elif ((per_frame > 8388608)); then + per_frame=8388608 + fi + echo "$per_frame" +} + +uvc_fps_for_interval() { + local interval=$1 + if ((interval <= 0)); then + echo "$UVC_FPS" + else + echo $((10000000 / interval)) + fi +} + +if [[ -z ${LESAVKA_UVC_FRAME_SIZE:-} ]]; then + UVC_FRAME_SIZE="$(uvc_mjpeg_frame_size_for_fps "$UVC_FPS")" +fi uvc_selected_frame_index() { case "${UVC_WIDTH}x${UVC_HEIGHT}" in @@ -320,7 +359,7 @@ uvc_frame_size_for() { if [[ $width == "$UVC_WIDTH" && $height == "$UVC_HEIGHT" && -n ${LESAVKA_UVC_FRAME_SIZE:-} ]]; then echo "$LESAVKA_UVC_FRAME_SIZE" else - echo $((width * height * 2)) + uvc_mjpeg_frame_size_for_fps "$(uvc_fps_for_interval "$(uvc_default_interval_for "$width" "$height")")" fi } diff --git a/scripts/install/client.sh b/scripts/install/client.sh index cf4941e..6731006 100755 --- a/scripts/install/client.sh +++ b/scripts/install/client.sh @@ -13,6 +13,7 @@ INSTALL_SOURCE=${LESAVKA_INSTALL_SOURCE:-auto} export TMPDIR=${TMPDIR:-/var/tmp} USER_HOME=$(getent passwd "$ORIG_USER" | cut -d: -f6) CLIENT_PKI_DIR=${LESAVKA_CLIENT_PKI_DIR:-$USER_HOME/.config/lesavka/pki} +CLIENT_PASTE_KEY_FILE=${LESAVKA_PASTE_KEY_FILE:-$USER_HOME/.config/lesavka/paste-key} CLIENT_PKI_AUTO_FETCH=${LESAVKA_CLIENT_PKI_AUTO_FETCH:-1} CLIENT_PKI_SSH_SOURCE=${LESAVKA_CLIENT_PKI_SSH_SOURCE:-theia:/etc/lesavka/lesavka-client-pki.tar.gz} CLIENT_CAPTURE_DIR=${LESAVKA_CLIENT_CAPTURE_DIR:-$USER_HOME/Pictures/lesavka} @@ -424,8 +425,9 @@ install_client_pki_bundle() { local bundle=${LESAVKA_CLIENT_PKI_BUNDLE:-} local fetched_bundle=0 if [[ -z $bundle ]]; then - if [[ -s "$CLIENT_PKI_DIR/ca.crt" && -s "$CLIENT_PKI_DIR/client.crt" && -s "$CLIENT_PKI_DIR/client.key" ]]; then + if [[ -s "$CLIENT_PKI_DIR/ca.crt" && -s "$CLIENT_PKI_DIR/client.crt" && -s "$CLIENT_PKI_DIR/client.key" && -s "$CLIENT_PASTE_KEY_FILE" ]]; then echo " ↪ TLS client identity already present: $CLIENT_PKI_DIR" + echo " ↪ paste shared key already present: $CLIENT_PASTE_KEY_FILE" return 0 fi @@ -433,6 +435,11 @@ install_client_pki_bundle() { fetched_bundle=1 echo " ↪ fetched TLS client enrollment bundle from $CLIENT_PKI_SSH_SOURCE" else + if [[ -s "$CLIENT_PKI_DIR/ca.crt" && -s "$CLIENT_PKI_DIR/client.crt" && -s "$CLIENT_PKI_DIR/client.key" ]]; then + echo "⚠️ TLS client identity is present but encrypted paste key is missing: $CLIENT_PASTE_KEY_FILE" + echo " Rerun after the server installer refreshes $CLIENT_PKI_SSH_SOURCE, or provide LESAVKA_CLIENT_PKI_BUNDLE." + return 0 + fi echo "⚠️ no TLS client identity installed." echo " Rerun with LESAVKA_CLIENT_PKI_BUNDLE=/path/to/lesavka-client-pki.tar.gz," echo " or make $CLIENT_PKI_SSH_SOURCE readable over SSH and rerun the installer." @@ -456,11 +463,20 @@ install_client_pki_bundle() { sudo install -m 0644 -o "$ORIG_USER" -g "$ORIG_USER" "$tmp/ca.crt" "$CLIENT_PKI_DIR/ca.crt" sudo install -m 0644 -o "$ORIG_USER" -g "$ORIG_USER" "$tmp/client.crt" "$CLIENT_PKI_DIR/client.crt" sudo install -m 0600 -o "$ORIG_USER" -g "$ORIG_USER" "$tmp/client.key" "$CLIENT_PKI_DIR/client.key" + if [[ -s "$tmp/paste-key" ]]; then + sudo install -d -m 0700 -o "$ORIG_USER" -g "$ORIG_USER" "$(dirname "$CLIENT_PASTE_KEY_FILE")" + sudo install -m 0600 -o "$ORIG_USER" -g "$ORIG_USER" "$tmp/paste-key" "$CLIENT_PASTE_KEY_FILE" + else + echo "⚠️ TLS client bundle $bundle does not include paste-key; encrypted paste RPC will use HID fallback." + fi sudo rm -rf "$tmp" if [[ $fetched_bundle == 1 ]]; then rm -f "$bundle" fi echo " ↪ installed TLS client identity: $CLIENT_PKI_DIR" + if [[ -s "$CLIENT_PASTE_KEY_FILE" ]]; then + echo " ↪ installed encrypted paste key: $CLIENT_PASTE_KEY_FILE" + fi } mkdir -p "$TMPDIR" @@ -593,6 +609,7 @@ echo " User PATH alias: $USER_HOME/.local/bin/lesavka-client" echo " Desktop entry: /usr/share/applications/lesavka.desktop" echo " Build source: $SRC/target/release/lesavka-client" echo " TLS identity: $CLIENT_PKI_DIR" +echo " Paste key: $CLIENT_PASTE_KEY_FILE" echo " Captures: $CLIENT_CAPTURE_DIR" warn_running_stale_client_processes echo "✅ Installed version: lesavka-client ${INSTALLED_VERSION:-unknown}${INSTALLED_SHA:+ ($INSTALLED_SHA)}" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index 1571ddc..a64a130 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -84,6 +84,7 @@ INSTALL_UVC_FRAME_META_LOG_PATH=${LESAVKA_INSTALL_UVC_FRAME_META_LOG_PATH:-${LES INSTALL_SERVER_BIND_ADDR=${LESAVKA_INSTALL_SERVER_BIND_ADDR:-0.0.0.0:50051} LESAVKA_TLS_DIR=${LESAVKA_TLS_DIR:-/etc/lesavka/pki} LESAVKA_CLIENT_BUNDLE=${LESAVKA_CLIENT_BUNDLE:-/etc/lesavka/lesavka-client-pki.tar.gz} +LESAVKA_PASTE_KEY_FILE=${LESAVKA_PASTE_KEY_FILE:-/etc/lesavka/paste-key} DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=0 DEFAULT_MJPEG_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=135090 DEFAULT_MJPEG_UPSTREAM_AUDIO_PLAYOUT_MODE_OFFSETS_US=1280x720@20=0,1280x720@30=0,1920x1080@20=0,1920x1080@30=0 @@ -355,9 +356,11 @@ LESAVKA_UVC_CODEC=${INSTALL_UVC_CODEC} LESAVKA_UVC_BLOCKING=$(uvc_env_value LESAVKA_UVC_BLOCKING 1) LESAVKA_UVC_CONTROL_READ_ONLY=$(uvc_env_value LESAVKA_UVC_CONTROL_READ_ONLY 0) LESAVKA_UVC_MAXBURST=$(uvc_env_value LESAVKA_UVC_MAXBURST 0) +LESAVKA_UVC_BULK=$(uvc_env_value LESAVKA_UVC_BULK 1) LESAVKA_UVC_FRAME_SIZE_GUARD=$(uvc_env_value LESAVKA_UVC_FRAME_SIZE_GUARD 1) LESAVKA_UVC_FRAME_MAX_BYTES=$(uvc_env_value LESAVKA_UVC_FRAME_MAX_BYTES 0) LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC=$(uvc_env_value LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC 10000000) +LESAVKA_UVC_STATS_PATH=$(uvc_env_value LESAVKA_UVC_STATS_PATH /run/lesavka-uvc-video-stats.json) EOF } @@ -479,12 +482,25 @@ ensure_server_tls_pki() { sudo chmod 0644 "$LESAVKA_TLS_DIR/"*.crt rm -f "$ext_file" "$client_ext_file" + local paste_key_dir paste_tmp + paste_key_dir=$(dirname "$LESAVKA_PASTE_KEY_FILE") + sudo install -d -m 0750 "$paste_key_dir" + if ! sudo test -s "$LESAVKA_PASTE_KEY_FILE"; then + echo " ↪ generating encrypted paste shared key" + paste_tmp=$(mktemp) + openssl rand -hex 32 >"$paste_tmp" + sudo install -m 0600 -o root -g root "$paste_tmp" "$LESAVKA_PASTE_KEY_FILE" + rm -f "$paste_tmp" + fi + sudo chmod 0600 "$LESAVKA_PASTE_KEY_FILE" + local bundle_tmp bundle_tmp=$(mktemp -d) sudo cp "$LESAVKA_TLS_DIR/ca.crt" "$bundle_tmp/ca.crt" sudo cp "$LESAVKA_TLS_DIR/client.crt" "$bundle_tmp/client.crt" sudo cp "$LESAVKA_TLS_DIR/client.key" "$bundle_tmp/client.key" - sudo tar -C "$bundle_tmp" -czf "$LESAVKA_CLIENT_BUNDLE" ca.crt client.crt client.key + sudo cp "$LESAVKA_PASTE_KEY_FILE" "$bundle_tmp/paste-key" + sudo tar -C "$bundle_tmp" -czf "$LESAVKA_CLIENT_BUNDLE" ca.crt client.crt client.key paste-key sudo chown "$ORIG_USER":"$ORIG_USER" "$LESAVKA_CLIENT_BUNDLE" sudo chmod 0600 "$LESAVKA_CLIENT_BUNDLE" sudo rm -rf "$bundle_tmp" @@ -1596,6 +1612,7 @@ SERVER_ENV_TMP=$(mktemp) printf 'LESAVKA_TLS_CERT=%s\n' "${LESAVKA_TLS_CERT:-$LESAVKA_TLS_DIR/server.crt}" printf 'LESAVKA_TLS_KEY=%s\n' "${LESAVKA_TLS_KEY:-$LESAVKA_TLS_DIR/server.key}" printf 'LESAVKA_TLS_CLIENT_CA=%s\n' "${LESAVKA_TLS_CLIENT_CA:-$LESAVKA_TLS_DIR/ca.crt}" + printf 'LESAVKA_PASTE_KEY_FILE=%s\n' "$LESAVKA_PASTE_KEY_FILE" } >"$SERVER_ENV_TMP" UVC_ENV_TMP=$(mktemp) diff --git a/server/Cargo.toml b/server/Cargo.toml index 6b5d3be..77d90fa 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.22.30" +version = "0.22.31" edition = "2024" autobins = false diff --git a/server/src/bin/lesavka-uvc.real.inc b/server/src/bin/lesavka-uvc.real.inc index a571218..aa632bc 100644 --- a/server/src/bin/lesavka-uvc.real.inc +++ b/server/src/bin/lesavka-uvc.real.inc @@ -53,6 +53,7 @@ const DEFAULT_UVC_IDLE_PUMP_MS: u64 = 2; const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000; const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 10_000_000; const DEFAULT_UVC_STATS_INTERVAL_MS: u64 = 5_000; +const DEFAULT_UVC_STATS_PATH: &str = "/run/lesavka-uvc-video-stats.json"; #[repr(C)] struct V4l2EventSubscription { @@ -521,6 +522,12 @@ impl UvcVideoStream { self.stats.latest_bytes, self.frame_payload_limit() ); + if let Some(path) = uvc_stats_path() { + let _ = write_atomic_text( + &path, + &uvc_stats_snapshot_json(&self.stats, self.frame_payload_limit()), + ); + } } } @@ -565,6 +572,20 @@ fn frame_spool_path() -> std::path::PathBuf { .unwrap_or_else(|_| std::path::PathBuf::from("/run/lesavka-uvc-frame.mjpg")) } +fn uvc_stats_path() -> Option { + match env::var("LESAVKA_UVC_STATS_PATH") { + Ok(value) => { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed == "0" { + None + } else { + Some(std::path::PathBuf::from(trimmed)) + } + } + Err(_) => Some(std::path::PathBuf::from(DEFAULT_UVC_STATS_PATH)), + } +} + fn looks_like_mjpeg_frame(frame: &[u8]) -> bool { frame.len() > MINIMAL_MJPEG_FRAME.len() && frame.starts_with(&[0xff, 0xd8]) @@ -605,7 +626,11 @@ fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize { } fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize { - let fps = cfg.fps.max(1); + derived_uvc_frame_max_bytes_for_fps(cfg.fps) +} + +fn derived_uvc_frame_max_bytes_for_fps(fps: u32) -> usize { + let fps = fps.max(1); let budget_per_sec = env_u32( "LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC, @@ -615,17 +640,66 @@ fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize { per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize } -fn uvc_frame_size_guard_enabled() -> bool { - env::var("LESAVKA_UVC_FRAME_SIZE_GUARD") +fn env_flag_enabled(name: &str, default: bool) -> bool { + env::var(name) .ok() .map(|value| { let trimmed = value.trim(); - !(trimmed.eq_ignore_ascii_case("0") + if trimmed.eq_ignore_ascii_case("0") || trimmed.eq_ignore_ascii_case("false") || trimmed.eq_ignore_ascii_case("no") - || trimmed.eq_ignore_ascii_case("off")) + || trimmed.eq_ignore_ascii_case("off") + { + false + } else if trimmed.eq_ignore_ascii_case("1") + || trimmed.eq_ignore_ascii_case("true") + || trimmed.eq_ignore_ascii_case("yes") + || trimmed.eq_ignore_ascii_case("on") + { + true + } else { + default + } }) - .unwrap_or(true) + .unwrap_or(default) +} + +fn uvc_frame_size_guard_enabled() -> bool { + env_flag_enabled("LESAVKA_UVC_FRAME_SIZE_GUARD", true) +} + +fn uvc_bulk_transfer_enabled() -> bool { + env_flag_enabled("LESAVKA_UVC_BULK", true) +} + +fn uvc_frame_size_for_active_mode(width: u32, height: u32, fps: u32) -> u32 { + env_u32_opt("LESAVKA_UVC_FRAME_SIZE") + .unwrap_or_else(|| derived_uvc_frame_max_bytes_for_fps(fps).min(u32::MAX as usize) as u32) + .max((width.saturating_mul(height) / 32).max(64 * 1024)) +} + +fn write_atomic_text(path: &std::path::Path, text: &str) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = path.with_extension(format!("tmp.{}", std::process::id())); + std::fs::write(&tmp, text)?; + std::fs::rename(&tmp, path)?; + Ok(()) +} + +fn uvc_stats_snapshot_json(stats: &UvcVideoStats, frame_cap: usize) -> String { + format!( + "{{\"queued\":{},\"reloaded\":{},\"stale_replay\":{},\"rejected_oversize\":{},\"rejected_invalid\":{},\"fallback_idle\":{},\"latest_bytes\":{},\"frame_cap\":{}}}\n", + stats.queued, + stats.reloaded, + stats.replayed_stale, + stats.rejected_oversize, + stats.rejected_invalid, + stats.fallback_idle, + stats.latest_bytes, + frame_cap + ) } fn uvc_stats_interval() -> Option { @@ -906,8 +980,8 @@ impl UvcConfig { } } let mut max_packet = env_u32("LESAVKA_UVC_MAXPACKET", 1024); - let frame_size = env_u32("LESAVKA_UVC_FRAME_SIZE", width * height * 2); - let bulk = env::var("LESAVKA_UVC_BULK").is_ok(); + let frame_size = uvc_frame_size_for_active_mode(width, height, fps); + let bulk = uvc_bulk_transfer_enabled(); if let Some(cap) = compute_payload_cap(bulk) { if max_packet > cap.limit { eprintln!( @@ -952,7 +1026,7 @@ impl UvcConfig { ); } } - if env::var("LESAVKA_UVC_BULK").is_ok() { + if bulk { max_packet = max_packet.min(512); } else { max_packet = max_packet.min(1024); @@ -1441,8 +1515,7 @@ fn uvc_frame_index_for_request(requested: u8, cfg: &UvcConfig) -> u8 { fn uvc_frame_size_for_index(frame_index: u8, fallback: u32) -> u32 { match frame_index { - 1 => 1920 * 1080 * 2, - 2 => 1280 * 720 * 2, + 1 | 2 => fallback, _ => fallback, } } diff --git a/server/src/bin/lesavka_uvc/control_payloads.rs b/server/src/bin/lesavka_uvc/control_payloads.rs index 3d4343f..f9596cb 100644 --- a/server/src/bin/lesavka_uvc/control_payloads.rs +++ b/server/src/bin/lesavka_uvc/control_payloads.rs @@ -52,8 +52,7 @@ fn uvc_frame_index_for_request(requested: u8, cfg: &UvcConfig) -> u8 { fn uvc_frame_size_for_index(frame_index: u8, fallback: u32) -> u32 { match frame_index { - 1 => 1920 * 1080 * 2, - 2 => 1280 * 720 * 2, + 1 | 2 => fallback, _ => fallback, } } diff --git a/server/src/bin/lesavka_uvc/coverage_model.rs b/server/src/bin/lesavka_uvc/coverage_model.rs index 1aed643..61c99d7 100644 --- a/server/src/bin/lesavka_uvc/coverage_model.rs +++ b/server/src/bin/lesavka_uvc/coverage_model.rs @@ -58,6 +58,8 @@ const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 10_000_000; #[cfg(coverage)] const DEFAULT_UVC_STATS_INTERVAL_MS: u64 = 5_000; #[cfg(coverage)] +const DEFAULT_UVC_STATS_PATH: &str = "/run/lesavka-uvc-video-stats.json"; +#[cfg(coverage)] const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024; #[cfg(coverage)] const MINIMAL_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9]; @@ -160,6 +162,18 @@ struct UvcVideoStream { frame_max_bytes: usize, } +#[cfg(coverage)] +#[derive(Default)] +struct UvcVideoStats { + queued: u64, + reloaded: u64, + replayed_stale: u64, + rejected_oversize: u64, + rejected_invalid: u64, + fallback_idle: u64, + latest_bytes: usize, +} + #[cfg(coverage)] impl UvcVideoStream { fn new(_fd: i32) -> Self { diff --git a/server/src/bin/lesavka_uvc/coverage_startup.rs b/server/src/bin/lesavka_uvc/coverage_startup.rs index e6df018..3462cae 100644 --- a/server/src/bin/lesavka_uvc/coverage_startup.rs +++ b/server/src/bin/lesavka_uvc/coverage_startup.rs @@ -38,9 +38,9 @@ impl UvcConfig { let width = env_u32("LESAVKA_UVC_WIDTH", 1280); let height = env_u32("LESAVKA_UVC_HEIGHT", 720); let fps = env_u32("LESAVKA_UVC_FPS", 30).max(1); - let frame_size = env_u32("LESAVKA_UVC_FRAME_SIZE", width * height * 2); + let frame_size = uvc_frame_size_for_active_mode(width, height, fps); let interval = env_u32("LESAVKA_UVC_INTERVAL", 0); - let bulk = env::var("LESAVKA_UVC_BULK").is_ok(); + let bulk = uvc_bulk_transfer_enabled(); let mut max_packet = env_u32("LESAVKA_UVC_MAXPACKET", 1024); if let Some(limit) = compute_payload_cap(bulk).map(|cap| cap.limit) { @@ -175,7 +175,12 @@ fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize { #[cfg(coverage)] fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize { - let fps = cfg.fps.max(1); + derived_uvc_frame_max_bytes_for_fps(cfg.fps) +} + +#[cfg(coverage)] +fn derived_uvc_frame_max_bytes_for_fps(fps: u32) -> usize { + let fps = fps.max(1); let budget_per_sec = env_u32( "LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC, @@ -186,17 +191,86 @@ fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize { } #[cfg(coverage)] -fn uvc_frame_size_guard_enabled() -> bool { - env::var("LESAVKA_UVC_FRAME_SIZE_GUARD") +fn env_flag_enabled(name: &str, default: bool) -> bool { + env::var(name) .ok() .map(|value| { let trimmed = value.trim(); - !(trimmed.eq_ignore_ascii_case("0") + if trimmed.eq_ignore_ascii_case("0") || trimmed.eq_ignore_ascii_case("false") || trimmed.eq_ignore_ascii_case("no") - || trimmed.eq_ignore_ascii_case("off")) + || trimmed.eq_ignore_ascii_case("off") + { + false + } else if trimmed.eq_ignore_ascii_case("1") + || trimmed.eq_ignore_ascii_case("true") + || trimmed.eq_ignore_ascii_case("yes") + || trimmed.eq_ignore_ascii_case("on") + { + true + } else { + default + } }) - .unwrap_or(true) + .unwrap_or(default) +} + +#[cfg(coverage)] +fn uvc_frame_size_guard_enabled() -> bool { + env_flag_enabled("LESAVKA_UVC_FRAME_SIZE_GUARD", true) +} + +#[cfg(coverage)] +fn uvc_bulk_transfer_enabled() -> bool { + env_flag_enabled("LESAVKA_UVC_BULK", true) +} + +#[cfg(coverage)] +fn uvc_frame_size_for_active_mode(width: u32, height: u32, fps: u32) -> u32 { + env_u32_opt("LESAVKA_UVC_FRAME_SIZE") + .unwrap_or_else(|| derived_uvc_frame_max_bytes_for_fps(fps).min(u32::MAX as usize) as u32) + .max((width.saturating_mul(height) / 32).max(64 * 1024)) +} + +#[cfg(coverage)] +fn uvc_stats_path() -> Option { + match std::env::var("LESAVKA_UVC_STATS_PATH") { + Ok(value) => { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed == "0" { + None + } else { + Some(std::path::PathBuf::from(trimmed)) + } + } + Err(_) => Some(std::path::PathBuf::from(DEFAULT_UVC_STATS_PATH)), + } +} + +#[cfg(coverage)] +fn write_atomic_text(path: &std::path::Path, text: &str) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = path.with_extension(format!("tmp.{}", std::process::id())); + std::fs::write(&tmp, text)?; + std::fs::rename(&tmp, path)?; + Ok(()) +} + +#[cfg(coverage)] +fn uvc_stats_snapshot_json(stats: &UvcVideoStats, frame_cap: usize) -> String { + format!( + "{{\"queued\":{},\"reloaded\":{},\"stale_replay\":{},\"rejected_oversize\":{},\"rejected_invalid\":{},\"fallback_idle\":{},\"latest_bytes\":{},\"frame_cap\":{}}}\n", + stats.queued, + stats.reloaded, + stats.replayed_stale, + stats.rejected_oversize, + stats.rejected_invalid, + stats.fallback_idle, + stats.latest_bytes, + frame_cap + ) } #[cfg(coverage)] diff --git a/tests/contract/scripts/daemon/server_core_script_contract.rs b/tests/contract/scripts/daemon/server_core_script_contract.rs index 7cdebef..1fa463e 100644 --- a/tests/contract/scripts/daemon/server_core_script_contract.rs +++ b/tests/contract/scripts/daemon/server_core_script_contract.rs @@ -96,8 +96,13 @@ fn core_script_keeps_uvc_output_on_supported_mjpeg_descriptor() { "UVC_CODEC=${LESAVKA_UVC_CODEC:-mjpeg}", "UVC codec '$UVC_CODEC' is not supported by the MJPEG UVC helper; using mjpeg", "UVC_CODEC=mjpeg", + "flag_enabled \"${LESAVKA_UVC_BULK:-1}\"", + "uvc_mjpeg_frame_size_for_fps()", + "UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-10000000}", + "UVC_FRAME_SIZE=\"$(uvc_mjpeg_frame_size_for_fps \"$UVC_FPS\")\"", "write_mjpeg_frame_descriptor 1080p 1920 1080", "write_mjpeg_frame_descriptor 720p 1280 720", + "echo \"$(uvc_frame_size_for \"$width\" \"$height\")\" >\"$frame/dwMaxVideoFrameBufferSize\"", "UVC_INTERVAL_30=${LESAVKA_UVC_INTERVAL_30:-333333}", "UVC_INTERVAL_20=${LESAVKA_UVC_INTERVAL_20:-500000}", ] { diff --git a/tests/contract/server/uvc/server_uvc_binary_contract.rs b/tests/contract/server/uvc/server_uvc_binary_contract.rs index 9d9b340..2db3462 100644 --- a/tests/contract/server/uvc/server_uvc_binary_contract.rs +++ b/tests/contract/server/uvc/server_uvc_binary_contract.rs @@ -18,7 +18,7 @@ mod uvc_binary { use serial_test::serial; use std::fs; - use temp_env::with_var; + use temp_env::{with_var, with_vars}; use tempfile::NamedTempFile; fn sample_cfg() -> UvcConfig { @@ -71,7 +71,7 @@ mod uvc_binary { with_var("LESAVKA_UVC_INTERVAL", Some("200000"), || { with_var("LESAVKA_UVC_MAXPAYLOAD_LIMIT", Some("1500"), || { with_var("LESAVKA_UVC_MAXPACKET", Some("1200"), || { - with_var("LESAVKA_UVC_BULK", None::<&str>, || { + with_var("LESAVKA_UVC_BULK", Some("0"), || { let cfg = UvcConfig::from_env(); assert_eq!(cfg.interval, 200_000); assert_eq!(cfg.max_packet, 1024); @@ -134,7 +134,7 @@ mod uvc_binary { assert_eq!(out[2], 1); assert_eq!(out[3], 1); assert_eq!(read_le32(&out, 4), 333_333); - assert_eq!(read_le32(&out, 18), 1920 * 1080 * 2); + assert_eq!(read_le32(&out, 18), state.cfg.frame_size); assert_eq!(read_le32(&out, 22), state.cfg.max_packet); } @@ -312,6 +312,69 @@ mod uvc_binary { }); } + #[test] + #[serial] + fn uvc_defaults_to_reliable_bulk_and_budgeted_mjpeg_frame_size() { + with_vars( + [ + ("LESAVKA_UVC_WIDTH", Some("1280")), + ("LESAVKA_UVC_HEIGHT", Some("720")), + ("LESAVKA_UVC_FPS", Some("30")), + ("LESAVKA_UVC_INTERVAL", None::<&str>), + ("LESAVKA_UVC_FRAME_SIZE", None::<&str>), + ("LESAVKA_UVC_BULK", None::<&str>), + ("LESAVKA_UVC_MAXPACKET", Some("1024")), + ("LESAVKA_UVC_MAXPAYLOAD_LIMIT", None::<&str>), + ("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("9000000")), + ], + || { + let cfg = UvcConfig::from_env(); + assert_eq!(cfg.interval, 10_000_000 / 30); + assert_eq!(cfg.frame_size, 300_000); + assert_eq!(cfg.max_packet, 512); + assert!(uvc_bulk_transfer_enabled()); + }, + ); + + with_var("LESAVKA_UVC_BULK", Some("0"), || { + assert!(!uvc_bulk_transfer_enabled()); + }); + } + + #[test] + #[serial] + fn uvc_stats_snapshot_can_be_disabled_or_written() { + let stats = UvcVideoStats { + queued: 7, + reloaded: 6, + replayed_stale: 2, + rejected_oversize: 1, + rejected_invalid: 3, + fallback_idle: 4, + latest_bytes: 77_036, + last_report: None, + }; + let json = uvc_stats_snapshot_json(&stats, 333_333); + assert!(json.contains("\"queued\":7")); + assert!(json.contains("\"latest_bytes\":77036")); + assert!(json.contains("\"frame_cap\":333333")); + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("uvc").join("stats.json"); + write_atomic_text(&path, &json).expect("write stats"); + assert_eq!(fs::read_to_string(&path).expect("read stats"), json); + + with_var("LESAVKA_UVC_STATS_PATH", Some("0"), || { + assert!(uvc_stats_path().is_none()); + }); + with_var("LESAVKA_UVC_STATS_PATH", None::<&str>, || { + assert_eq!( + uvc_stats_path().expect("default stats path"), + std::path::PathBuf::from(DEFAULT_UVC_STATS_PATH) + ); + }); + } + #[test] #[serial] fn uvc_control_open_mode_defaults_read_only_with_escape_hatch() { diff --git a/tests/installer/scripts/install/client_install_script_contract.rs b/tests/installer/scripts/install/client_install_script_contract.rs index 80661f9..f533383 100644 --- a/tests/installer/scripts/install/client_install_script_contract.rs +++ b/tests/installer/scripts/install/client_install_script_contract.rs @@ -18,14 +18,18 @@ fn client_install_accepts_server_generated_tls_bundle() { "ca.crt", "client.crt", "client.key", + "paste-key", "install_client_pki_bundle", "fetch_client_pki_bundle", "LESAVKA_CLIENT_PKI_SSH_SOURCE", + "CLIENT_PASTE_KEY_FILE", "LESAVKA_CLIENT_CAPTURE_DIR", "theia:/etc/lesavka/lesavka-client-pki.tar.gz", "Pictures/lesavka", "HTTPS/mTLS relay connections will not work until this bundle is installed", + "encrypted paste key:", "TLS identity:", + "Paste key:", "Captures:", "$USER_HOME/.local/bin/lesavka-client", "User PATH alias:", @@ -39,6 +43,10 @@ fn client_install_accepts_server_generated_tls_bundle() { CLIENT_INSTALL.contains(".config/lesavka/pki"), "client cert bundle should land in the same path the desktop app auto-loads" ); + assert!( + CLIENT_INSTALL.contains(".config/lesavka/paste-key"), + "client installer should land the server-issued paste key where runtime paste auto-loads it" + ); assert!( CLIENT_INSTALL.contains("0600"), "client private key should be installed with private permissions" diff --git a/tests/installer/scripts/install/server_install_script_contract.rs b/tests/installer/scripts/install/server_install_script_contract.rs index 35c11e7..d31a0c7 100644 --- a/tests/installer/scripts/install/server_install_script_contract.rs +++ b/tests/installer/scripts/install/server_install_script_contract.rs @@ -50,6 +50,7 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { "LESAVKA_TLS_CERT=%s", "LESAVKA_TLS_KEY=%s", "LESAVKA_TLS_CLIENT_CA=%s", + "LESAVKA_PASTE_KEY_FILE=%s", ] { assert!( SERVER_INSTALL.contains(expected), @@ -181,13 +182,25 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { "installer should persist HEVC-specific calibration maps" ); assert!(SERVER_INSTALL.contains("${LESAVKA_INSTALL_SERVER_BIND_ADDR:-0.0.0.0:50051}")); + assert!( + SERVER_INSTALL + .contains("LESAVKA_PASTE_KEY_FILE=${LESAVKA_PASTE_KEY_FILE:-/etc/lesavka/paste-key}") + ); + assert!(SERVER_INSTALL.contains("openssl rand -hex 32")); + assert!(SERVER_INSTALL.contains("paste-key")); + assert!(SERVER_INSTALL.contains("ca.crt client.crt client.key paste-key")); assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_MAXPACKET 1024")); assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_INTERVAL 333333")); assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_WIDTH 1280")); assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_HEIGHT 720")); assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_CONTROL_READ_ONLY 0")); + assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_BULK 1")); assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_FRAME_SIZE_GUARD 1")); assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_FRAME_MAX_BYTES 0")); + assert!( + SERVER_INSTALL + .contains("uvc_env_value LESAVKA_UVC_STATS_PATH /run/lesavka-uvc-video-stats.json") + ); assert!( !SERVER_INSTALL.contains("LESAVKA_UVC_CODEC=${LESAVKA_UVC_CODEC:-mjpeg}"), "install script should not let ambient LESAVKA_UVC_CODEC leak into persisted defaults"