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

This commit is contained in:
宋一鸣 2026-11-03 17:48:01 +08:00
parent 1bed1948b5
commit a98d9ddeee
15 changed files with 868 additions and 0 deletions

View File

@ -0,0 +1,28 @@
active
enabled
11月 03 17:27:23 rk3528pro-ddr4 systemd[1]: Started vdi-usb-monitor.service - VDI approved USB storage redirection (device POC).
11月 03 17:27:23 rk3528pro-ddr4 vdi-usb-monitor[56130]: USB filesystem is mounted locally; unmount safely before export
11月 03 17:27:25 rk3528pro-ddr4 vdi-usb-monitor[56130]: exporting approved storage 3-1 at 127.0.0.1:40001
11月 03 17:29:02 rk3528pro-ddr4 vdi-usb-monitor[56130]: export session ended: exit status: 0; waiting before retry
11月 03 17:29:04 rk3528pro-ddr4 vdi-usb-monitor[56130]: exporting approved storage 3-1 at 127.0.0.1:40001
11月 03 17:34:43 rk3528pro-ddr4 vdi-usb-monitor[56130]: approved USB device disconnected; waiting for reinsertion
11月 03 17:34:45 rk3528pro-ddr4 vdi-usb-monitor[56130]: waiting for exactly one approved device; found 0
11月 03 17:34:53 rk3528pro-ddr4 vdi-usb-monitor[56130]: exporting approved storage 3-1 at 127.0.0.1:40001
/: Bus 001.Port 001: Dev 001, Class=root_hub, Driver=xhci-hcd/1p, 480M
|__ Port 001: Dev 047, If 0, Class=Human Interface Device, Driver=usbhid, 1.5M
/: Bus 002.Port 001: Dev 001, Class=root_hub, Driver=xhci-hcd/1p, 5000M
/: Bus 003.Port 001: Dev 001, Class=root_hub, Driver=ehci-platform/1p, 480M
|__ Port 001: Dev 006, If 0, Class=Mass Storage, Driver=usbfs, 480M
/: Bus 004.Port 001: Dev 001, Class=root_hub, Driver=ohci-platform/1p, 12M
NAME TRAN FSTYPE LABEL MOUNTPOINTS
mmcblk1 mmc
├─mmcblk1p1 mmc ext4 armbi_boot /boot
└─mmcblk1p2 mmc ext4 armbi_root /var/log.hdd
/
mmcblk1boot0 mmc
mmcblk1boot1 mmc
zram0 [SWAP]
zram1 /var/log
zram2
active
46934 /home/armbian/vdi-build/rkmpp-experiment/build/moonlight/app/moonlight stream 192.168.1.70 Desktop --resolution 2560x1440 --fps 45 --bitrate 30000 --display-mode fullscreen --video-codec H.264 --video-decoder hardware --absolute-mouse --keep-awake --no-vsync --no-frame-pacing --audio-on-host

View File

@ -0,0 +1,16 @@
{
"vm": 100,
"device_present": true,
"channels": [
{
"frontend-open": true,
"filename": "tcp:127.0.0.1:45882 <-> 127.0.0.1:40001",
"label": "vdi_usb_box_100"
}
],
"usb": " Device 0.1, Port 1, Speed 480 Mb/s, Product QEMU USB Tablet, ID: tablet\r\n Device 1.1, Port 2, Speed 480 Mb/s, Product USB Redirection Device, ID: vdi_usb_redir_100\r\n",
"vm_status": {
"status": "running",
"running": true
}
}

View File

@ -0,0 +1,49 @@
{
"USB": [
{
"Model": "Kingston DataTraveler 3.0 USB Device",
"InterfaceType": "USB",
"PNPDeviceID": "USBSTOR\\DISK\u0026VEN_KINGSTON\u0026PROD_DATATRAVELER_3.0\u0026REV_PMAP\\408D5C1653D4E44179789251\u00260",
"Size": 62051512320,
"Index": 1
}
],
"Volume": [
{
"DriveLetter": "F",
"FileSystemLabel": "KINGSTON",
"FileSystem": "FAT32",
"DriveType": "Removable",
"Size": 61908123648,
"HealthStatus": "Healthy"
}
],
"PnP": [
{
"Status": "OK",
"Class": "DiskDrive",
"FriendlyName": "Kingston DataTraveler 3.0 USB Device",
"InstanceId": "USBSTOR\\DISK\u0026VEN_KINGSTON\u0026PROD_DATATRAVELER_3.0\u0026REV_PMAP\\408D5C1653D4E44179789251\u00260"
},
{
"Status": "OK",
"Class": "USB",
"FriendlyName": "USB Mass Storage Device",
"InstanceId": "USB\\VID_0951\u0026PID_1666\\408D5C1653D4E44179789251"
}
],
"GPU": [
{
"Name": "Virtual Display Driver",
"ConfigManagerErrorCode": 0
},
{
"Name": "NVIDIA GeForce GTX 965M",
"ConfigManagerErrorCode": 0
},
{
"Name": "Microsoft Basic Display Adapter",
"ConfigManagerErrorCode": 0
}
]
}

View File

@ -0,0 +1,21 @@
{
"Result": "PASS",
"Disk": [
{
"Model": "Kingston DataTraveler 3.0 USB Device",
"InterfaceType": "USB",
"PNPDeviceID": "USBSTOR\\DISK\u0026VEN_KINGSTON\u0026PROD_DATATRAVELER_3.0\u0026REV_PMAP\\408D5C1653D4E44179789251\u00260",
"Index": 1
}
],
"Volume": [
{
"DriveLetter": "F",
"DriveType": "Removable",
"FileSystemLabel": "KINGSTON",
"FileSystem": "FAT32",
"HealthStatus": "Healthy"
}
],
"SHA256": "8244F1189EFA02118BF0DC4CB68FC8924FE562AC610B68DEEFBE09EC85454D51"
}

View File

@ -0,0 +1,11 @@
{
"Result": "PASS",
"Model": "Kingston DataTraveler 3.0 USB Device",
"InterfaceType": "USB",
"PNPDeviceID": "USBSTOR\\DISK\u0026VEN_KINGSTON\u0026PROD_DATATRAVELER_3.0\u0026REV_PMAP\\408D5C1653D4E44179789251\u00260",
"DriveLetter": "F",
"TestDirectory": "F:\\VDI_USB_TEST_20261103-173342",
"LocalDirectory": "C:\\Windows\\TEMP\\VDI_USB_TEST_20261103-173342",
"Bytes": 1048576,
"SHA256": "8244F1189EFA02118BF0DC4CB68FC8924FE562AC610B68DEEFBE09EC85454D51"
}

View File

@ -0,0 +1,117 @@
# U 盘真实 USB 设备透传(2026-11-03)
用户明确选择 Windows 必须识别真实 USB,并配合插盘、换口和 Windows 安全弹出后实际拔插。
当前为一个盒子、一个登记设备、一个 VM 的 POC,不是多用户生产功能。
## 已验证的现场结果
| 项目 | 结果 |
| --- | --- |
| U 盘 | Kingston DataTraveler 3.0,0951:1666,USB 描述符序列号 408D5C1653D4E44179789251 |
| 盒子 | 192.168.1.188,物理路径 3-1,实际 USB2 / 480 Mb/s 链路 |
| Windows | PVE starhe / VM100 / 192.168.1.70,USB Mass Storage 状态 OK |
| 磁盘 | Kingston DataTraveler 3.0 USB Device,InterfaceType=USB,USBSTOR 实例含原序列号 |
| 卷 | KINGSTON(F:),FAT32,Removable,约 57.7 GiB,Healthy |
| 文件读写 | 1 MiB 随机文件:源文件、USB 文件、复制回 VM 的文件 SHA-256 一致 |
| 实物拔插 | 用户先在 Windows 安全弹出,17:34:43 断开、17:34:53 重新导出,Windows F: 自动恢复 |
| 拔插后数据 | 再次读取测试文件,SHA-256 不变 |
| 独占 | 盒子 usbfs 持有接口;透传时 Linux 无该 U 盘的本地块设备挂载 |
| 原有会话 | Moonlight PID46934 / 2K45 保留,显示守护 active,GTX965M 错误码 0 |
只新增 `F:\VDI_USB_TEST_20261103-173342\from-windows.bin` 和 `readme.txt`。
未格式化、改分区、覆盖或删除原文件。源和回读副本在 VM 的
`C:\Windows\TEMP\VDI_USB_TEST_20261103-173342`。
SHA-256:`8244F1189EFA02118BF0DC4CB68FC8924FE562AC610B68DEEFBE09EC85454D51`。
原始验收记录见 [`evidence/usb-device-20261103/`](evidence/usb-device-20261103/)。
自动检查:盒子 Rust 测试 5/5、管理员 QMP 测试 3/3,通过;Python 编译检查和
`git diff --check` 通过。现场还实测了“本地挂载时拒绝导出”,随后正常卸载才导出。
## 实现
```text
盒子物理 USB -> Rust vdi-usb-monitor -> usbredirect/libusb(回环40001)
-> PVE 发起的 SSH 加密隧道 -> QEMU usb-redir(现有 ehci-2.0)
-> Windows 自带 USB Mass Storage / USBSTOR -> F:
```
usbredir 独立于 SPICE;显示通道仍为 Moonlight/Sunshine。
没有安装 Windows 第三方驱动、开测试签名、替换控制器、重启机器或改 GPU 配置。
盒子不调用 PVE API,也不保存 PVE 管理凭据。
Rust 使用标准库,无第三方 crate;等待设备每 2 秒扫描,活动连接每秒检查身份与地址。
按 VID/PID/serial 匹配唯一设备,并使用当前 bus-address 打开精确实例。
设备消失、匹配多实例、含非存储接口、已本地挂载、存在块设备 holder 或被用作 swap 时拒绝。
udev 规则仅对登记设备设置 UDISKS_IGNORE=1,防止本地自动挂载与 Windows 并发写盘。
SSH 使用独立密钥、已有可信主机密钥、源 IP 和 permitopen 限制,禁止该密钥执行 shell。
USB 监听与隧道两端均为回环地址,未暴露 LAN USB 服务端口。
Rust 为终端模块;Python 是管理员部署/QMP 工具;PowerShell 是 Windows 验收工具。
## 部署和管理
盒子先安装 `rustc cargo usbredirect`,在 `/home/armbian/vdi-build/usb-monitor`
运行 `cargo test --offline`、`cargo build --release --offline`。没有整体升级系统。
管理员机器需要 `paramiko`、可信 SSH 主机密钥,以及 PVE SSH 公钥或
`VDI_PVE_SSH_PASSWORD` 环境变量。部署器不保存密码。
```sh
python tools/deploy-usb-device-poc.py --pve 192.168.1.51 --box armbian@192.168.1.188 \
--vm 100 --vendor 0951 --product 1666 --serial 408D5C1653D4E44179789251
```
确认设备身份、关闭本地文件操作并正常卸载对应分区,然后启动盒子服务:
`sudo systemctl start vdi-usb-monitor.service`。本次分区为 `/dev/sda1`,
使用了 `udisksctl unmount --block-device /dev/sda1`;其他环境不能盲目照抄设备名。
在 PVE root 执行:
```sh
python3 /usr/local/libexec/vdi-usb-device-qmp.py attach --vm 100 --vendor 0951 --product 1666
python3 /usr/local/libexec/vdi-usb-device-qmp.py status --vm 100
```
QMP 只管理 `vdi_usb_box_100` / `vdi_usb_redir_100`,不操作已有 tablet、GPU、网卡。
重复 attach 拒绝覆盖;device_add 失败回收仅本次新建 channel。
QEMU filter 内部分隔符为冒号,与 spice-client 的逗号不同。
| 位置 | 文件 |
| --- | --- |
| 盒子 | `/usr/local/libexec/vdi-usb-monitor` |
| 盒子白名单 | `/etc/vdi-usb-monitor/device.env` |
| 盒子挂载策略 | `/etc/udev/rules.d/99-vdi-usb-device-poc.rules` |
| 盒子服务 | `/etc/systemd/system/vdi-usb-monitor.service` |
| PVE 密钥 | `/etc/vdi-usb-poc/vm100/` |
| PVE 隧道 | `/etc/systemd/system/vdi-usb-tunnel-100.service` |
| 盒子原始备份 | `/home/armbian/vdi-terminal/backups/usb-device-20261103-172709/` |
两端服务已 active/enabled,未通过重启验证开机顺序。重新部署前须在 Windows 安全弹出,
停止盒子导出服务;部署器拒绝覆盖运行中的服务。
## 限制
- 仅登记的这一只 U 盘;其他设备需管理员登记。没有多终端隔离、会话授权/切换或 Qt 状态提示。
- 固定 IP、单端口、单 VM POC。QMP 设备仅存在于当前运行实例,VM/PVE 重启后须重新 attach;
没有修改原 VM args 或 GPU 启动设置。
- 验证了当前 FAT32 U 盘、1 MiB 读写和一次实物热插拔;未测大文件吞吐、NTFS/exFAT、
写入中断网/掉电恢复或长期老化。
- 最初接口报 USB 枚举 -71,换到 3-1 对应口才成功;并非软件修好了原口,也未验证 USB3 速度。
- 使用后正常安全弹出;程序不会重试用户文件写入,不能保证强拔/断网时的数据完整性。
## 回退
1. 关闭 U 盘文件,在 Windows 正常安全弹出。
2. PVE:`python3 /usr/local/libexec/vdi-usb-device-qmp.py detach --vm 100 --guest-ejected`。
3. 盒子:`sudo systemctl disable --now vdi-usb-monitor.service`;PVE:
`systemctl disable --now vdi-usb-tunnel-100.service`。
4. 仅移除/恢复本功能 udev 规则,`udevadm control --reload-rules`,再拔插让 Linux 本地重新识别。
5. 如移除密钥,只删 authorized_keys 中 `vdi-usb-poc-vm100` 对应公钥行;保留其他公钥,
不直接覆盖整份可能已经更新的 authorized_keys。
## 官方依据
- [usbredir 独立协议](https://www.spice-space.org/usbredir.html)
- [Ubuntu usbredirect](https://manpages.ubuntu.com/manpages/noble/man1/usbredirect.1.html)
- [QEMU USB 接入](https://www.qemu.org/docs/master/system/devices/usb.html)
- [QEMU filter 实现](https://github.com/qemu/qemu/blob/master/hw/usb/redirect.c)

1
terminal/usb-monitor/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target/

7
terminal/usb-monitor/Cargo.lock generated Normal file
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"

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"

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

View File

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

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\\\"\\\\\"");
}
}

View File

@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Install a one-device usbredir POC using a restricted SSH tunnel.
Requires paramiko on the admin machine and rustc/cargo/usbredirect on the box.
PVE authentication uses SSH keys or VDI_PVE_SSH_PASSWORD (never stored in files).
This prepares services only. Safely unmount the selected USB volume, start the
box service, then run usb-device-qmp.py attach on the PVE node.
"""
import argparse
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import time
def box_run(box, command, stdin=None):
result = subprocess.run(
["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", box, command],
input=stdin, capture_output=True, text=True, encoding="utf-8", timeout=120)
if result.returncode:
raise RuntimeError(result.stderr + result.stdout)
return result.stdout
def pve_run(client, command):
_, out, err = client.exec_command(command, timeout=60)
stdout, stderr = out.read().decode(), err.read().decode()
if out.channel.recv_exit_status():
raise RuntimeError(stderr + stdout)
return stdout
def install(args, password=None):
import paramiko
if not re.fullmatch(r"[0-9a-f]{4}", args.vendor) or not re.fullmatch(r"[0-9a-f]{4}", args.product):
raise ValueError("VID/PID must be four lowercase hex digits")
if not re.fullmatch(r"[A-Za-z0-9_.-]+", args.serial) or not args.serial:
raise ValueError("POC installer supports nonempty alphanumeric serials only")
if not re.fullmatch(r"[a-z_][a-z0-9_-]*@[A-Za-z0-9.-]+", args.box):
raise ValueError("invalid box SSH destination")
if args.vm < 100:
raise ValueError("invalid VM ID")
if box_run(args.box, "systemctl is-active vdi-usb-monitor.service || true").strip() == "active":
raise RuntimeError("Safely eject the guest USB and stop the box export service before redeployment")
repo = Path(__file__).resolve().parents[1]
box_host = args.box.split("@", 1)[1]
# Copy the existing trusted host keys; never trust an unauthenticated keyscan.
known = subprocess.run(["ssh-keygen", "-F", box_host], capture_output=True,
text=True, check=True).stdout
known = "\n".join(line for line in known.splitlines() if not line.startswith("#")) + "\n"
if not known.strip():
raise RuntimeError("Box host key must already be trusted by OpenSSH")
client = paramiko.SSHClient()
client.load_system_host_keys(str(Path.home() / ".ssh" / "known_hosts"))
client.set_missing_host_key_policy(paramiko.RejectPolicy())
client.connect(args.pve, username="root", password=password or os.getenv("VDI_PVE_SSH_PASSWORD"),
timeout=10, allow_agent=True, look_for_keys=True)
try:
stamp = time.strftime("%Y%m%d-%H%M%S")
host_dir = f"/etc/vdi-usb-poc/vm{args.vm}"
pve_run(client, f"install -d -m 700 {host_dir}; test -f {host_dir}/id_ed25519 || "
f"ssh-keygen -q -t ed25519 -N '' -C vdi-usb-poc-vm{args.vm} -f {host_dir}/id_ed25519")
pubkey = pve_run(client, f"cat {host_dir}/id_ed25519.pub").strip()
backup = f"/home/{args.box.split('@')[0]}/vdi-terminal/backups/usb-device-{stamp}"
# append a restricted tunnel-only key while preserving existing access.
auth_script = '''import pathlib,shutil,sys
p=pathlib.Path.home()/'.ssh'/'authorized_keys'
b=pathlib.Path(sys.argv[1]);b.mkdir(parents=True,exist_ok=True)
if p.exists():shutil.copy2(p,b/'authorized_keys')
p.parent.mkdir(mode=0o700,exist_ok=True)
text=p.read_text() if p.exists() else ''
line=sys.argv[2]
if line not in text.splitlines():p.write_text(text.rstrip()+'\\n'+line+'\\n')
p.chmod(0o600)
'''
auth_line = (f'from="{args.pve}",restrict,port-forwarding,permitopen="127.0.0.1:40001",'
f'command="/bin/false" {pubkey}')
box_run(args.box, f"python3 - {shlex.quote(backup)} {shlex.quote(auth_line)}", auth_script)
stage = box_run(args.box, "mktemp -d /tmp/vdi-usb-deploy.XXXXXX").strip()
subprocess.run(["scp", "-q", str(repo / "terminal/usb-monitor/etc/vdi-usb-monitor.service"),
f"{args.box}:{stage}/vdi-usb-monitor.service"], check=True)
env = f'VDI_USB_VENDOR={args.vendor}\nVDI_USB_PRODUCT={args.product}\nVDI_USB_SERIAL={args.serial}\n'
rule = (f'# VDI USB device POC: only the approved device\nSUBSYSTEM=="block", '
f'ATTRS{{idVendor}}=="{args.vendor}", ATTRS{{idProduct}}=="{args.product}", '
f'ATTRS{{serial}}=="{args.serial}", ENV{{UDISKS_IGNORE}}="1"\n')
box_install = '''import pathlib,shutil,subprocess,sys
stage,backup,env,rule=sys.argv[1:]
backup=pathlib.Path(backup)
files={
'/etc/systemd/system/vdi-usb-monitor.service':pathlib.Path(stage,'vdi-usb-monitor.service').read_bytes(),
'/etc/vdi-usb-monitor/device.env':env.encode(),
'/etc/udev/rules.d/99-vdi-usb-device-poc.rules':rule.encode(),
}
for name,data in files.items():
p=pathlib.Path(name)
if p.exists():shutil.copy2(p,backup/p.name)
p.parent.mkdir(parents=True,exist_ok=True)
p.write_bytes(data)
p.chmod(0o600 if p.suffix=='.env' else 0o644)
binary=pathlib.Path('/usr/local/libexec/vdi-usb-monitor')
if binary.exists():shutil.copy2(binary,backup/binary.name)
binary.parent.mkdir(parents=True,exist_ok=True)
shutil.copy2(pathlib.Path.home()/'vdi-build/usb-monitor/target/release/vdi-usb-monitor',binary)
binary.chmod(0o755)
subprocess.run(['udevadm','control','--reload-rules'],check=True)
subprocess.run(['systemctl','daemon-reload'],check=True)
subprocess.run(['systemctl','enable','vdi-usb-monitor.service'],check=True)
print('Prepared USB service; not started; existing mounted data untouched')
'''
# sudo changes HOME; use the explicitly selected build path instead.
box_install = box_install.replace("pathlib.Path.home()/'vdi-build", f"pathlib.Path('/home/{args.box.split('@')[0]}')/'vdi-build")
box_run(args.box, "sudo -n python3 - " + " ".join(map(shlex.quote, [stage, backup, env, rule])), box_install)
unit = f'''[Unit]
Description=VDI USB POC encrypted tunnel for VM {args.vm}
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/ssh -NT -i {host_dir}/id_ed25519 -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile={host_dir}/known_hosts -o ExitOnForwardFailure=yes -o ServerAliveInterval=10 -o ServerAliveCountMax=3 -L 127.0.0.1:40001:127.0.0.1:40001 {args.box}
Restart=on-failure
RestartSec=3
NoNewPrivileges=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
'''
sftp = client.open_sftp()
try:
for path, content in [(f"{host_dir}/known_hosts", known),
(f"/etc/systemd/system/vdi-usb-tunnel-{args.vm}.service", unit),
("/usr/local/libexec/vdi-usb-device-qmp.py", (repo / "tools/usb-device-qmp.py").read_text())]:
with sftp.open(path, "w") as f:
f.write(content)
sftp.chmod(path, 0o600 if "known_hosts" in path else 0o644)
finally:
sftp.close()
pve_run(client, f"systemctl daemon-reload && systemctl enable --now vdi-usb-tunnel-{args.vm}.service")
print(json.dumps({"box_backup": backup, "pve_directory": host_dir,
"state": "prepared; unmount USB safely before starting export"}, indent=2))
finally:
client.close()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--pve", required=True)
parser.add_argument("--box", required=True)
parser.add_argument("--vm", type=int, required=True)
parser.add_argument("--vendor", required=True)
parser.add_argument("--product", required=True)
parser.add_argument("--serial", required=True)
install(parser.parse_args())

View File

@ -0,0 +1,34 @@
param(
[Parameter(Mandatory=$true)][ValidatePattern('^[A-Z]$')][string]$DriveLetter,
[Parameter(Mandatory=$true)][ValidatePattern('^[A-Za-z0-9_.-]+$')][string]$UsbSerial,
[ValidateRange(1,64)][int]$SizeMiB=1
)
$ErrorActionPreference='Stop'
$disk=@(Get-CimInstance Win32_DiskDrive | Where-Object {
$_.InterfaceType -eq 'USB' -and $_.PNPDeviceID -like "USBSTOR\*\$UsbSerial&*"
})
if($disk.Count -ne 1){throw 'Expected exactly one approved USB disk'}
$partition=Get-Partition -DriveLetter $DriveLetter
if($partition.DiskNumber -ne $disk[0].Index){throw 'Drive letter does not belong to the approved USB disk'}
$stamp=Get-Date -Format 'yyyyMMdd-HHmmss'
$name="VDI_USB_TEST_$stamp"
$local=Join-Path $env:TEMP $name
$usb=Join-Path "${DriveLetter}:\" $name
if((Test-Path -LiteralPath $local) -or (Test-Path -LiteralPath $usb)){throw 'Test directory already exists'}
New-Item -ItemType Directory -Path $local,$usb | Out-Null
$bytes=New-Object byte[] ($SizeMiB*1MB)
$rng=[Security.Cryptography.RandomNumberGenerator]::Create()
try{$rng.GetBytes($bytes)}finally{$rng.Dispose()}
$source=Join-Path $local 'source.bin'
[IO.File]::WriteAllBytes($source,$bytes)
$target=Join-Path $usb 'from-windows.bin'
$stream=[IO.File]::Open($target,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
try{$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)}finally{$stream.Dispose()}
$copy=Join-Path $local 'readback.bin'
Copy-Item -LiteralPath $target -Destination $copy
$a=(Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash
$b=(Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash
$c=(Get-FileHash -LiteralPath $copy -Algorithm SHA256).Hash
if($a -ne $b -or $a -ne $c){throw 'USB read/write hash mismatch'}
[IO.File]::WriteAllText((Join-Path $usb 'readme.txt'),"VDI USB device POC test. Only this directory was created by the test.`r`n",[Text.Encoding]::UTF8)
[pscustomobject]@{Result='PASS';Model=$disk[0].Model;InterfaceType=$disk[0].InterfaceType;PNPDeviceID=$disk[0].PNPDeviceID;DriveLetter=$DriveLetter;TestDirectory=$usb;LocalDirectory=$local;Bytes=$bytes.Length;SHA256=$a} | ConvertTo-Json

135
tools/usb-device-qmp.py Normal file
View File

@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Run on the PVE node as an administrator; the terminal never calls PVE APIs.
Runtime-only USB redirection, carried over a separately authenticated SSH tunnel.
No VM config writes, drivers, controller replacement or VM restarts.
"""
import argparse
import json
import re
import socket
import time
class Qmp:
def __init__(self, vm):
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.settimeout(10)
self.sock.connect(f"/var/run/qemu-server/{vm}.qmp")
self.stream = self.sock.makefile("rwb", buffering=0)
greeting = json.loads(self.stream.readline())
if "QMP" not in greeting:
raise RuntimeError("Not a QMP socket")
self.sequence = 0
self.call("qmp_capabilities")
def call(self, command, arguments=None):
self.sequence += 1
ident = self.sequence
message = {"execute": command, "id": ident}
if arguments is not None:
message["arguments"] = arguments
self.stream.write(json.dumps(message).encode() + b"\n")
while True:
raw = self.stream.readline()
if not raw:
raise RuntimeError("QMP disconnected")
reply = json.loads(raw)
if reply.get("id") != ident:
continue
if "error" in reply:
raise RuntimeError(f"{command}: {reply['error']}")
return reply["return"]
def close(self):
self.stream.close()
self.sock.close()
def ids(vm):
return f"vdi_usb_box_{vm}", f"vdi_usb_redir_{vm}"
def peripherals(qmp):
return {x["name"] for x in qmp.call("qom-list", {"path": "/machine/peripheral"})}
def attach(qmp, vm, port, bus, vendor, product):
channel, device = ids(vm)
channels = {x["label"]: x for x in qmp.call("query-chardev")}
if channel in channels or device in peripherals(qmp):
raise RuntimeError("POC endpoint already exists; inspect status before changing it")
qmp.call("chardev-add", {
"id": channel,
"backend": {"type": "socket", "data": {
"addr": {"type": "inet", "data": {"host": "127.0.0.1", "port": str(port)}},
"server": False, "reconnect-ms": 1000,
}},
})
try:
qmp.call("device_add", {
"driver": "usb-redir", "id": device, "chardev": channel, "bus": bus,
# QEMU uses ':' within rules; spice-client uses ',' instead.
"filter": f"0x08:0x{vendor}:0x{product}:-1:1|-1:-1:-1:-1:0",
})
except Exception:
# Roll back only the channel created by this invocation.
qmp.call("chardev-remove", {"id": channel})
raise
def detach(qmp, vm):
channel, device = ids(vm)
if device in peripherals(qmp):
qmp.call("device_del", {"id": device})
deadline = time.monotonic() + 8
while device in peripherals(qmp):
if time.monotonic() >= deadline:
raise RuntimeError("USB removal pending; leave its channel intact and inspect guest")
time.sleep(0.2)
if any(x["label"] == channel for x in qmp.call("query-chardev")):
qmp.call("chardev-remove", {"id": channel})
def status(qmp, vm):
channel, device = ids(vm)
return {
"vm": vm,
"device_present": device in peripherals(qmp),
"channels": [x for x in qmp.call("query-chardev") if x["label"] == channel],
"usb": qmp.call("human-monitor-command", {"command-line": "info usb"}),
"vm_status": qmp.call("query-status"),
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["status", "attach", "detach"])
parser.add_argument("--vm", type=int, required=True)
parser.add_argument("--port", type=int, default=40001)
parser.add_argument("--bus", default="ehci-2.0")
parser.add_argument("--vendor")
parser.add_argument("--product")
parser.add_argument("--guest-ejected", action="store_true",
help="Confirm the guest has safely ejected the USB device before detach")
args = parser.parse_args()
if args.vm < 100 or not 1 <= args.port <= 65535:
parser.error("invalid VM ID or local tunnel port")
if args.action == "attach" and not all(
re.fullmatch(r"[0-9a-fA-F]{4}", x or "") for x in [args.vendor, args.product]):
parser.error("attach requires an exact four-digit --vendor and --product")
if args.action == "detach" and not args.guest_ejected:
parser.error("safely eject in the guest first, then pass --guest-ejected")
qmp = Qmp(args.vm)
try:
if args.action == "attach":
attach(qmp, args.vm, args.port, args.bus, args.vendor.lower(), args.product.lower())
elif args.action == "detach":
detach(qmp, args.vm)
print(json.dumps(status(qmp, args.vm), ensure_ascii=False, indent=2))
finally:
qmp.close()
if __name__ == "__main__":
main()