media: harden uvc mjpeg transport

This commit is contained in:
Brad Stein 2026-05-14 12:08:53 -03:00
parent 22dd45aa39
commit eb3b029071
17 changed files with 384 additions and 39 deletions

6
Cargo.lock generated
View File

@ -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",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.22.30"
version = "0.22.31"
edition = "2024"
[dependencies]

View File

@ -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)
);
}
}

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.22.30"
version = "0.22.31"
edition = "2024"
build = "build.rs"

View File

@ -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 |

View File

@ -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
}

View File

@ -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)}"

View File

@ -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)

View File

@ -10,7 +10,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.22.30"
version = "0.22.31"
edition = "2024"
autobins = false

View File

@ -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<std::path::PathBuf> {
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<Duration> {
@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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 {

View File

@ -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<std::path::PathBuf> {
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)]

View File

@ -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}",
] {

View File

@ -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() {

View File

@ -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"

View File

@ -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"