From a98d9ddeee5668474765109693a4e477112cb33f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=8B=E4=B8=80=E9=B8=A3?= <1000000008@qq.com> Date: Tue, 3 Nov 2026 17:48:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E7=BB=88=E7=AB=AFUSB=E8=AE=BE=E5=A4=87=E7=9B=91=E6=8E=A7POC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usb-device-20261103/final-box-state.txt | 28 +++ .../usb-device-20261103/final-pve-state.json | 16 ++ .../final-windows-state.json | 49 ++++ .../windows-hotplug-test.json | 21 ++ .../usb-device-20261103/windows-rw-test.json | 11 + docs/usb-device-poc-20261103.md | 117 +++++++++ terminal/usb-monitor/.gitignore | 1 + terminal/usb-monitor/Cargo.lock | 7 + terminal/usb-monitor/Cargo.toml | 11 + terminal/usb-monitor/README.md | 28 +++ .../usb-monitor/etc/vdi-usb-monitor.service | 22 ++ terminal/usb-monitor/src/main.rs | 233 ++++++++++++++++++ tools/deploy-usb-device-poc.py | 155 ++++++++++++ tools/test-usb-device-poc.ps1 | 34 +++ tools/usb-device-qmp.py | 135 ++++++++++ 15 files changed, 868 insertions(+) create mode 100644 docs/evidence/usb-device-20261103/final-box-state.txt create mode 100644 docs/evidence/usb-device-20261103/final-pve-state.json create mode 100644 docs/evidence/usb-device-20261103/final-windows-state.json create mode 100644 docs/evidence/usb-device-20261103/windows-hotplug-test.json create mode 100644 docs/evidence/usb-device-20261103/windows-rw-test.json create mode 100644 docs/usb-device-poc-20261103.md create mode 100644 terminal/usb-monitor/.gitignore create mode 100644 terminal/usb-monitor/Cargo.lock create mode 100644 terminal/usb-monitor/Cargo.toml create mode 100644 terminal/usb-monitor/README.md create mode 100644 terminal/usb-monitor/etc/vdi-usb-monitor.service create mode 100644 terminal/usb-monitor/src/main.rs create mode 100644 tools/deploy-usb-device-poc.py create mode 100644 tools/test-usb-device-poc.ps1 create mode 100644 tools/usb-device-qmp.py diff --git a/docs/evidence/usb-device-20261103/final-box-state.txt b/docs/evidence/usb-device-20261103/final-box-state.txt new file mode 100644 index 0000000..eb17b30 --- /dev/null +++ b/docs/evidence/usb-device-20261103/final-box-state.txt @@ -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 diff --git a/docs/evidence/usb-device-20261103/final-pve-state.json b/docs/evidence/usb-device-20261103/final-pve-state.json new file mode 100644 index 0000000..ad1a02d --- /dev/null +++ b/docs/evidence/usb-device-20261103/final-pve-state.json @@ -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 + } +} diff --git a/docs/evidence/usb-device-20261103/final-windows-state.json b/docs/evidence/usb-device-20261103/final-windows-state.json new file mode 100644 index 0000000..eb0a5f2 --- /dev/null +++ b/docs/evidence/usb-device-20261103/final-windows-state.json @@ -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 + } + ] +} diff --git a/docs/evidence/usb-device-20261103/windows-hotplug-test.json b/docs/evidence/usb-device-20261103/windows-hotplug-test.json new file mode 100644 index 0000000..6bd7e16 --- /dev/null +++ b/docs/evidence/usb-device-20261103/windows-hotplug-test.json @@ -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" +} diff --git a/docs/evidence/usb-device-20261103/windows-rw-test.json b/docs/evidence/usb-device-20261103/windows-rw-test.json new file mode 100644 index 0000000..c5cbd3c --- /dev/null +++ b/docs/evidence/usb-device-20261103/windows-rw-test.json @@ -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" +} diff --git a/docs/usb-device-poc-20261103.md b/docs/usb-device-poc-20261103.md new file mode 100644 index 0000000..2ae6168 --- /dev/null +++ b/docs/usb-device-poc-20261103.md @@ -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) diff --git a/terminal/usb-monitor/.gitignore b/terminal/usb-monitor/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/terminal/usb-monitor/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/terminal/usb-monitor/Cargo.lock b/terminal/usb-monitor/Cargo.lock new file mode 100644 index 0000000..083e541 --- /dev/null +++ b/terminal/usb-monitor/Cargo.lock @@ -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" diff --git a/terminal/usb-monitor/Cargo.toml b/terminal/usb-monitor/Cargo.toml new file mode 100644 index 0000000..abc72fe --- /dev/null +++ b/terminal/usb-monitor/Cargo.toml @@ -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" diff --git a/terminal/usb-monitor/README.md b/terminal/usb-monitor/README.md new file mode 100644 index 0000000..406226e --- /dev/null +++ b/terminal/usb-monitor/README.md @@ -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)。 diff --git a/terminal/usb-monitor/etc/vdi-usb-monitor.service b/terminal/usb-monitor/etc/vdi-usb-monitor.service new file mode 100644 index 0000000..467c353 --- /dev/null +++ b/terminal/usb-monitor/etc/vdi-usb-monitor.service @@ -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 diff --git a/terminal/usb-monitor/src/main.rs b/terminal/usb-monitor/src/main.rs new file mode 100644 index 0000000..3a87f7b --- /dev/null +++ b/terminal/usb-monitor/src/main.rs @@ -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, + 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, 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) -> 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 { + 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::>().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 { + 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\\\"\\\\\""); + } +} diff --git a/tools/deploy-usb-device-poc.py b/tools/deploy-usb-device-poc.py new file mode 100644 index 0000000..5a000d2 --- /dev/null +++ b/tools/deploy-usb-device-poc.py @@ -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()) diff --git a/tools/test-usb-device-poc.ps1 b/tools/test-usb-device-poc.ps1 new file mode 100644 index 0000000..4da0655 --- /dev/null +++ b/tools/test-usb-device-poc.ps1 @@ -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 diff --git a/tools/usb-device-qmp.py b/tools/usb-device-qmp.py new file mode 100644 index 0000000..75986fb --- /dev/null +++ b/tools/usb-device-qmp.py @@ -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()