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