功能:增加终端USB设备监控POC

This commit is contained in:
songyiming committed 2026-11-03 17:48:01 +08:00
1 parent 1bed1948b5
commit a98d9ddeee
15 files changed
+868

No files matched your search

+1
View File
@@ -0,0 +1 @@
/target/
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "vdi-usb-monitor"
version = "0.1.0"
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "vdi-usb-monitor"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
[dependencies]
[profile.release]
strip = true
opt-level = "s"
+28
View File
@@ -0,0 +1,28 @@
# USB 设备透传 POC
将盒子上指定的 U 盘经 usbredir + SSH + QEMU 接入 Windows,枚举真实 VID/PID/序列号和
USBSTOR,使用 Windows 自带驱动。这是 Moonlight 显示会话之外的设备通道,不是网络文件盘。
当前只支持明确登记的一个纯 USB 存储设备、一个盒子和一个 VM;盒子不调用 Proxmox API。
- `src/main.rs`:Rust 标准库实现设备扫描、精确匹配、本地挂载/占用保护、导出及拔插恢复。
- `etc/vdi-usb-monitor.service`:盒子服务,读取 root 管理的单设备白名单。
- `../../tools/deploy-usb-device-poc.py`:管理员部署和受限 SSH 隧道。
- `../../tools/usb-device-qmp.py`:在 PVE 节点操作独立 QMP 热插拔端点。
- `../../tools/test-usb-device-poc.ps1`:Windows USB 身份和文件回读校验。
在 ARM64 Linux 上安装 `rustc cargo usbredirect`,运行:
```sh
cargo test --offline
cargo build --release --offline
target/release/vdi-usb-monitor list
sudo target/release/vdi-usb-monitor serve \
--vendor 0951 --product 1666 --serial YOUR_USB_SERIAL --listen 127.0.0.1:40001
```
`list` 输出 JSON。`serve` 必须指定非空序列号,只能监听 IPv4 回环地址;
拒绝键鼠、Hub、包含非存储接口的复合设备以及本地尚在使用的磁盘。
本地已挂载时不会强制卸载,应先关闭本地文件操作并正常卸载。
完整现场、部署、限制和回退见
[`docs/usb-device-poc-20261103.md`](../../docs/usb-device-poc-20261103.md)。
@@ -0,0 +1,22 @@
[Unit]
Description=VDI approved USB storage redirection (device POC)
After=network.target systemd-udevd.service
[Service]
Type=simple
EnvironmentFile=/etc/vdi-usb-monitor/device.env
ExecStart=/usr/local/libexec/vdi-usb-monitor serve --vendor ${VDI_USB_VENDOR} --product ${VDI_USB_PRODUCT} --serial ${VDI_USB_SERIAL} --listen 127.0.0.1:40001
Restart=on-failure
RestartSec=3
KillMode=control-group
TimeoutStopSec=10
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
UMask=0077
Nice=10
[Install]
WantedBy=multi-user.target
+233
View File
@@ -0,0 +1,233 @@
//! Device-level USB POC. Never calls the hypervisor API or exports HID devices.
use std::{collections::BTreeSet, env, fs, net::SocketAddr, path::{Path, PathBuf},
process::{self, Command}, thread, time::Duration};
#[derive(Debug, Clone)]
struct Device {
path: PathBuf,
bus_id: String,
vendor: String,
product: String,
serial: String,
name: String,
bus: u16,
address: u16,
classes: Vec<String>,
device_class: String,
}
fn read(path: &Path, name: &str) -> String {
fs::read_to_string(path.join(name)).unwrap_or_default().trim().to_string()
}
fn storage_only(device_class: &str, classes: &[String]) -> bool {
matches!(device_class, "00" | "08") && !classes.is_empty()
&& classes.iter().all(|class| class == "08")
}
fn scan(root: &Path) -> Result<Vec<Device>, String> {
let entries: Vec<_> = fs::read_dir(root).map_err(|e| e.to_string())?
.filter_map(Result::ok).collect();
let mut devices = Vec::new();
for entry in &entries {
let p = entry.path();
let vendor = read(&p, "idVendor");
if vendor.is_empty() { continue; }
let bus_id = entry.file_name().to_string_lossy().into_owned();
let prefix = format!("{}:", bus_id);
let mut classes: Vec<_> = entries.iter().filter(|e|
e.file_name().to_string_lossy().starts_with(&prefix))
.map(|e| read(&e.path(), "bInterfaceClass")).collect();
classes.sort();
devices.push(Device {
path: p.canonicalize().map_err(|e| e.to_string())?,
bus_id, vendor, product: read(&p, "idProduct"), serial: read(&p, "serial"),
name: read(&p, "product"), bus: read(&p, "busnum").parse().unwrap_or(0),
address: read(&p, "devnum").parse().unwrap_or(0),
classes, device_class: read(&p, "bDeviceClass"),
});
}
devices.sort_by(|a, b| a.bus_id.cmp(&b.bus_id));
Ok(devices)
}
fn mounted_devices(mountinfo: &str, numbers: &BTreeSet<String>) -> bool {
mountinfo.lines().any(|line| line.split_whitespace().nth(2)
.map(|n| numbers.contains(n)).unwrap_or(false))
}
fn check_local_use(device: &Device) -> Result<(), String> {
let mut numbers = BTreeSet::new();
for entry in fs::read_dir("/sys/class/block").map_err(|e| e.to_string())? {
let p = entry.map_err(|e| e.to_string())?.path();
if !p.canonicalize().map_err(|e| e.to_string())?.starts_with(&device.path) {
continue;
}
numbers.insert(read(&p, "dev"));
if fs::read_dir(p.join("holders")).map_err(|e| e.to_string())?.next().is_some() {
return Err("block device has holders (RAID/LVM/encryption); refusing export".into());
}
let swaps = fs::read_to_string("/proc/swaps").map_err(|e| e.to_string())?;
if swaps.lines().skip(1).filter_map(|l| l.split_whitespace().next())
.any(|s| Path::new(s).canonicalize().ok() ==
Path::new("/dev").join(p.file_name().unwrap()).canonicalize().ok()) {
return Err("USB device is used as swap".into());
}
}
let mounts = fs::read_to_string("/proc/self/mountinfo").map_err(|e| e.to_string())?;
if mounted_devices(&mounts, &numbers) {
return Err("USB filesystem is mounted locally; unmount safely before export".into());
}
Ok(())
}
fn json_string(s: &str) -> String {
let mut out = String::from("\"");
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"), '"' => out.push_str("\\\""),
c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"'); out
}
fn hex_id(s: &str) -> bool {
s.len() == 4 && s.bytes().all(|c| c.is_ascii_hexdigit())
}
#[derive(Debug)]
struct Policy { vendor: String, product: String, serial: String, listen: SocketAddr }
fn parse_policy(args: &[String]) -> Result<Policy, String> {
if args.len() != 8 { return Err("serve requires --vendor XXXX --product XXXX --serial SERIAL --listen 127.0.0.1:PORT".into()); }
let mut values = std::collections::BTreeMap::new();
for pair in args.chunks(2) {
if !["--vendor", "--product", "--serial", "--listen"].contains(&pair[0].as_str())
|| values.insert(pair[0].as_str(), pair[1].as_str()).is_some() {
return Err("unknown or duplicate policy option".into());
}
}
let vendor = values["--vendor"].to_ascii_lowercase();
let product = values["--product"].to_ascii_lowercase();
let serial = values["--serial"].to_string();
let listen: SocketAddr = values["--listen"].parse().map_err(|_| "invalid listen address")?;
if !hex_id(&vendor) || !hex_id(&product) || serial.is_empty()
|| serial.chars().any(char::is_control) || !listen.ip().is_loopback()
|| !listen.is_ipv4() || listen.port() == 0 {
return Err("exact VID/PID/serial and an IPv4 loopback listener are required".into());
}
Ok(Policy { vendor, product, serial, listen })
}
fn select<'a>(devices: &'a [Device], p: &Policy) -> Result<&'a Device, String> {
let found: Vec<_> = devices.iter().filter(|d|
d.vendor == p.vendor && d.product == p.product && d.serial == p.serial).collect();
if found.len() != 1 { return Err(format!("waiting for exactly one approved device; found {}", found.len())); }
let d = found[0];
if !storage_only(&d.device_class, &d.classes) || d.bus == 0 || d.address == 0 {
return Err("not a pure USB mass-storage device; refusing export".into());
}
Ok(d)
}
fn run() -> Result<(), String> {
let args: Vec<_> = env::args().skip(1).collect();
if args == ["list"] {
let devices = scan(Path::new("/sys/bus/usb/devices"))?;
println!("[{}]", devices.iter().map(|d| format!(
"{{\"bus_id\":{},\"vendor\":{},\"product\":{},\"serial\":{},\"name\":{},\"storage_only\":{}}}",
json_string(&d.bus_id), json_string(&d.vendor), json_string(&d.product),
json_string(&d.serial), json_string(&d.name), storage_only(&d.device_class, &d.classes)))
.collect::<Vec<_>>().join(","));
return Ok(());
}
if args.first().map(String::as_str) != Some("serve") {
return Err("usage: vdi-usb-monitor list | serve --vendor XXXX --product XXXX --serial SERIAL --listen 127.0.0.1:PORT".into());
}
let policy = parse_policy(&args[1..])?;
let mut last = String::new();
loop {
let result: Result<(), String> = scan(Path::new("/sys/bus/usb/devices")).and_then(|devices| {
let device = select(&devices, &policy)?;
check_local_use(device)?;
// Bus/address selects one physical instance, never an arbitrary VID/PID match.
let selector = format!("{}-{}", device.bus, device.address);
eprintln!("exporting approved storage {} at {}", device.bus_id, policy.listen);
let mut child = Command::new("/usr/bin/usbredirect")
.args(["--device", &selector, "--as", &policy.listen.to_string(), "--keepalive"])
.spawn().map_err(|e| e.to_string())?;
loop {
if let Some(status) = child.try_wait().map_err(|e| e.to_string())? {
return Err(format!("export session ended: {status}; waiting before retry"));
}
thread::sleep(Duration::from_secs(1));
let present = scan(Path::new("/sys/bus/usb/devices")).ok().map(|current| {
select(&current, &policy).map(|d| d.bus == device.bus
&& d.address == device.address && d.bus_id == device.bus_id).unwrap_or(false)
}).unwrap_or(false);
if !present {
let _ = child.kill();
let _ = child.wait();
return Err("approved USB device disconnected; waiting for reinsertion".into());
}
}
});
if let Err(message) = result {
if message != last { eprintln!("{message}"); last = message; }
}
thread::sleep(Duration::from_secs(2));
}
}
fn main() {
if let Err(e) = run() { eprintln!("{e}"); process::exit(1); }
}
#[cfg(test)]
mod tests {
use super::*;
fn policy(extra: &str) -> Vec<String> {
format!("--vendor 0951 --product 1666 --serial TEST --listen {extra}")
.split_whitespace().map(String::from).collect()
}
#[test]
fn refuses_hid_composites_and_hubs() {
assert!(storage_only("00", &["08".into()]));
assert!(!storage_only("00", &["08".into(), "03".into()]));
assert!(!storage_only("09", &["08".into()]));
assert!(!storage_only("00", &[]));
}
#[test]
fn export_listener_cannot_be_exposed_to_lan() {
assert!(parse_policy(&policy("127.0.0.1:40001")).is_ok());
for address in ["0.0.0.0:40001", "192.168.1.188:40001", "127.0.0.1:0"] {
assert!(parse_policy(&policy(address)).is_err());
}
}
#[test]
fn mounted_partition_blocks_parent_disk_export() {
let numbers = BTreeSet::from(["8:0".into(), "8:1".into()]);
assert!(mounted_devices("24 1 8:1 / /media/KINGSTON rw - vfat /dev/sda1 rw", &numbers));
assert!(!mounted_devices("24 1 179:2 / / rw - ext4 /dev/mmcblk1p2 rw", &numbers));
}
#[test]
fn wrong_serial_and_duplicate_devices_are_not_exported() {
let p = parse_policy(&policy("127.0.0.1:40001")).unwrap();
let mut d = Device {
path: PathBuf::from("/sys/test"), bus_id: "3-1".into(),
vendor: "0951".into(), product: "1666".into(), serial: "OTHER".into(),
name: "storage".into(), bus: 3, address: 5,
classes: vec!["08".into()], device_class: "00".into(),
};
assert!(select(&[d.clone()], &p).is_err());
d.serial = "TEST".into();
assert!(select(&[d.clone()], &p).is_ok());
assert!(select(&[d.clone(), d], &p).is_err());
}
#[test]
fn enumeration_json_escapes_control_characters() {
assert_eq!(json_string("a\n\"\\"), "\"a\\u000a\\\"\\\\\"");
}
}