功能:增加终端USB设备监控POC
This commit is contained in:
1 parent
1bed1948b5
commit
a98d9ddeee
15 files changed
+868
No files matched your search
@@ -0,0 +1 @@
|
||||
/target/
|
||||
Generated
+7
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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(¤t, &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\\\"\\\\\"");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user