功能:增加终端USB设备监控POC
This commit is contained in:
parent
1bed1948b5
commit
a98d9ddeee
28
docs/evidence/usb-device-20261103/final-box-state.txt
Normal file
28
docs/evidence/usb-device-20261103/final-box-state.txt
Normal 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
|
||||||
16
docs/evidence/usb-device-20261103/final-pve-state.json
Normal file
16
docs/evidence/usb-device-20261103/final-pve-state.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
49
docs/evidence/usb-device-20261103/final-windows-state.json
Normal file
49
docs/evidence/usb-device-20261103/final-windows-state.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
21
docs/evidence/usb-device-20261103/windows-hotplug-test.json
Normal file
21
docs/evidence/usb-device-20261103/windows-hotplug-test.json
Normal 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"
|
||||||
|
}
|
||||||
11
docs/evidence/usb-device-20261103/windows-rw-test.json
Normal file
11
docs/evidence/usb-device-20261103/windows-rw-test.json
Normal 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"
|
||||||
|
}
|
||||||
117
docs/usb-device-poc-20261103.md
Normal file
117
docs/usb-device-poc-20261103.md
Normal 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
1
terminal/usb-monitor/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target/
|
||||||
7
terminal/usb-monitor/Cargo.lock
generated
Normal file
7
terminal/usb-monitor/Cargo.lock
generated
Normal 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
terminal/usb-monitor/Cargo.toml
Normal file
11
terminal/usb-monitor/Cargo.toml
Normal 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
terminal/usb-monitor/README.md
Normal file
28
terminal/usb-monitor/README.md
Normal 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)。
|
||||||
22
terminal/usb-monitor/etc/vdi-usb-monitor.service
Normal file
22
terminal/usb-monitor/etc/vdi-usb-monitor.service
Normal 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
|
||||||
233
terminal/usb-monitor/src/main.rs
Normal file
233
terminal/usb-monitor/src/main.rs
Normal 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(¤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\\\"\\\\\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
155
tools/deploy-usb-device-poc.py
Normal file
155
tools/deploy-usb-device-poc.py
Normal 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())
|
||||||
34
tools/test-usb-device-poc.ps1
Normal file
34
tools/test-usb-device-poc.ps1
Normal 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
135
tools/usb-device-qmp.py
Normal 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()
|
||||||
Loading…
Reference in New Issue
Block a user