命令说明
kvm list 列出所有虚拟机 + 开机自启列表
kvm info [虚拟机] 看静态配置 + 运行信息(缺省取 list 第一个)
kvm create [虚拟机] <目录|qcow2路径> [方案] 创建(定义)虚拟机,创建后为 off,不自动运行
kvm switch [虚拟机] [方案] [on|off|run] 切换 xml 方案(缺省方案=虚拟机名.xml;缺省 run)
kvm run|boot|start|up|on [虚拟机] 开机(已开机不报错)
kvm stop|down|off [虚拟机] 关机(ACPI 优雅)
kvm reboot|restart [虚拟机] 重启
kvm poweroff|destroy [虚拟机] 强制断电
kvm autostart [--disable] [虚拟机] 设/取消 开机自启
kvm rm [-f] [虚拟机] 卸载虚拟机实例(卸域 + 卸存储池;物理目录保留,默认二次确认)
kvm login|desktop [虚拟机] 进入图形桌面
kvm exec|console [虚拟机] 进入文本控制台
kvm ls [虚拟机] 列出客机桌面上的文件(缺省取 list 第一个)
kvm cp|copy|get|pull [虚拟机] [文件名] 客机桌面 → <虚拟机xml目录>/desktop/(通配符请加引号)
kvm paste|send|push [虚拟机] [文件名] <虚拟机xml目录>/desktop/ → 客机桌面(同上)
kvm usb [list] [虚拟机] 列出宿主 USB 设备 + 是否已挂给客机
kvm usb <设备> [虚拟机] 切换:已挂→拔回,未挂→挂上(设备=序号或 VID:PID)
kvm usb add|rm <设备> [虚拟机] 显式指定挂上 / 拔回
方案名: 空=默认(<虚拟机>.xml) | 例如 half / full → <虚拟机>-<方案>.xml
/etc/nixos/modules/system/service/qemu.nix
#!/usr/bin/env bash
# ============================================================
# kvm.sh —— 本机 KVM/libvirt 虚拟机管理命令
# 约定: 一台虚拟机 = /s500data/kvm/<虚拟机名>/ 一个子目录
# 方案文件: <虚拟机名>.xml(默认) 或 <虚拟机名>-<方案名>.xml
# 系统盘: <虚拟机名>.qcow2
# 建议: alias kvm=/s500data/kvm/kvm.sh 之后即可用 `kvm <命令>`
# ============================================================
set -uo pipefail
KVM_ROOT="/s500data/kvm"
CONN="qemu:///system"
V="virsh -c $CONN"
die(){ echo "错误: $*" >&2; exit 1; }
# ---------- 辅助 ----------
scheme_xml(){ # <vm> [scheme] -> 方案 xml 路径
if [ -z "${2:-}" ]; then echo "$KVM_ROOT/$1/$1.xml"; else echo "$KVM_ROOT/$1/$1-$2.xml"; fi
}
xml_name(){ sed -n 's:.*<name>\(.*\)</name>.*:\1:p' "$1" 2>/dev/null | head -1; }
list_vms(){ # 所有虚拟机(含 .qcow2 或 .xml 的子目录),按名排序
local d
for d in "$KVM_ROOT"/*/; do
[ -d "$d" ] || continue
if compgen -G "${d}*.qcow2" >/dev/null || compgen -G "${d}*.xml" >/dev/null; then
basename "$d"
fi
done | sort
}
dom_of_vm(){ # 该虚拟机当前已定义的域(按“磁盘路径归属”识别),无则空
local vm="$1" d
while read -r d; do
[ -n "$d" ] || continue
if $V dumpxml --inactive "$d" 2>/dev/null | grep -q "source file='$KVM_ROOT/$vm/"; then
echo "$d"; return 0
fi
done < <($V list --all --name 2>/dev/null | sed '/^$/d')
return 1
}
vm_for(){ # 目标虚拟机:给了就用;没给取 list 第一个
if [ -n "${1:-}" ]; then echo "$1"; return; fi
local f; f="$(list_vms | head -1)"
[ -n "$f" ] || die "没有可用虚拟机"
echo "$f"
}
avail_schemes(){ ls "$KVM_ROOT/$1/$1"*.xml 2>/dev/null | xargs -n1 basename 2>/dev/null | tr '\n' ' '; }
usage(){
cat <<'EOF'
kvm.sh —— 虚拟机管理
kvm list 列出所有虚拟机 + 开机自启列表
kvm info [虚拟机] 看静态配置 + 运行信息(缺省取 list 第一个)
kvm create [虚拟机] <目录|qcow2路径> [方案] 创建(定义)虚拟机,创建后为 off,不自动运行
kvm switch [虚拟机] [方案] [on|off|run] 切换 xml 方案(缺省方案=虚拟机名.xml;缺省 run)
kvm run|boot|start|up|on [虚拟机] 开机(已开机不报错)
kvm stop|down|off [虚拟机] 关机(ACPI 优雅)
kvm reboot|restart [虚拟机] 重启
kvm poweroff|destroy [虚拟机] 强制断电
kvm autostart [--disable] [虚拟机] 设/取消 开机自启
kvm rm [-f] [虚拟机] 卸载虚拟机实例(卸域 + 卸存储池;物理目录保留,默认二次确认)
kvm login|desktop [虚拟机] 进入图形桌面
kvm exec|console [虚拟机] 进入文本控制台
kvm ls [虚拟机] 列出客机桌面上的文件(缺省取 list 第一个)
kvm cp|copy|get|pull [虚拟机] [文件名] 客机桌面 → <虚拟机xml目录>/desktop/(通配符请加引号)
kvm paste|send|push [虚拟机] [文件名] <虚拟机xml目录>/desktop/ → 客机桌面(同上)
kvm usb [list] [虚拟机] 列出宿主 USB 设备 + 是否已挂给客机
kvm usb <设备> [虚拟机] 切换:已挂→拔回,未挂→挂上(设备=序号或 VID:PID)
kvm usb add|rm <设备> [虚拟机] 显式指定挂上 / 拔回
方案名: 空=默认(<虚拟机>.xml) | 例如 half / full → <虚拟机>-<方案>.xml
EOF
}
# ---------- 命令 ----------
cmd_list(){
printf "%-14s %-20s %-9s %-6s %s\n" "虚拟机" "当前方案/域" "状态" "自启" "磁盘"
local vm cur st as disk
for vm in $(list_vms); do
cur="$(dom_of_vm "$vm" || true)"
if [ -n "$cur" ]; then
st="$($V domstate "$cur" 2>/dev/null)"; [ "$st" = running ] && st="运行中" || st="已关机"
as="$($V dominfo "$cur" 2>/dev/null | awk '/^Autostart:/{print $2}')"
[ "$as" = enable ] && as="是" || as="否"
else
st="未定义"; as="-"; cur="—"
fi
disk="$(ls "$KVM_ROOT/$vm"/*.qcow2 2>/dev/null | xargs -n1 basename 2>/dev/null | tr '\n' ' ')"
printf "%-14s %-20s %-9s %-6s %s\n" "$vm" "$cur" "$st" "$as" "${disk:-—}"
done
echo
echo "【开机自启列表】"
$V list --autostart --all 2>/dev/null
}
cmd_info(){
local vm cur X
vm="$(vm_for "${1:-}")"
echo "==================== 虚拟机: $vm ===================="
echo "实例目录: $KVM_ROOT/$vm"
cur="$(dom_of_vm "$vm" || true)"
if [ -n "$cur" ]; then
echo "当前域 : $cur ($($V domstate "$cur" 2>/dev/null))"
X="$($V dumpxml --inactive "$cur" 2>/dev/null)"
else
echo "当前域 : (未定义)"
X="$(cat "$KVM_ROOT/$vm/$vm.xml" 2>/dev/null)"
fi
[ -n "$X" ] || die "无可用配置(既未定义域,也无默认 $vm.xml)"
echo "---- 静态配置 (KVM 设定) ----"
echo "$X" | sed -n 's/^[[:space:]]*//;s/^\(<name>\|<uuid>\|<vcpu[^>]*>\|<memory[^>]*>\|<currentMemory[^>]*>\|.*machine=[^ ]*\|<source file=.*\|<model type=.*\|<mac address=.*\|<graphics type=.*\|<source network=.*\)/\1/p'
echo "---- 运行信息 ----"
if [ -n "$cur" ] && [ "$($V domstate "$cur" 2>/dev/null)" = running ]; then
$V domstats "$cur" 2>/dev/null | grep -E "state.state|cpu.time|vcpu.current|vcpu.maximum|balloon.rss|block.1.(rd|wr)_bytes|net.0.(rx|tx).bytes" | sed 's/^/ /'
echo " -- 磁盘 --"; $V domblklist "$cur" 2>/dev/null | sed 's/^/ /'
echo " -- 网卡 --"; $V domiflist "$cur" 2>/dev/null | sed 's/^/ /'
echo " -- IP --"; $V domifaddr "$cur" --source lease 2>/dev/null | sed 's/^/ /'
else
echo " (未运行)"
fi
echo "---- 存储池 / 卷 ----"
$V pool-list --all 2>/dev/null | sed 's/^/ /'
$V vol-list "$vm" 2>/dev/null | sed 's/^/ /'
echo "---- 网络 ----"
$V net-list --all 2>/dev/null | sed 's/^/ /'
ip -4 addr show virbr0 2>/dev/null | awk '/inet /{print " virbr0 "$2}'
}
cmd_create(){
local a1="${1:-}" a2="${2:-}" a3="${3:-}" vm path sc dir disk xml cur
[ -n "$a1" ] || die "用法: kvm create [虚拟机] <目录|qcow2> [方案]"
if [[ "$a1" == */* || -e "$a1" ]]; then vm=""; path="$a1"; sc="$a2"; else vm="$a1"; path="$a2"; sc="$a3"; fi
[ -n "$path" ] || die "缺少路径"
if [ -d "$path" ]; then dir="${path%/}"; else dir="$(dirname "$path")"; fi
[ -n "$vm" ] || vm="$(basename "$dir")"
if [ -d "$path" ]; then disk="$dir/$vm.qcow2"; else disk="$path"; fi
xml="$(scheme_xml "$vm" "$sc")"
[ -d "$dir" ] || die "目录不存在: $dir"
[ -f "$disk" ] || die "磁盘不存在: $disk"
[ -f "$xml" ] || die "方案文件不存在: $xml (现有: $(avail_schemes "$vm"))"
if ! $V pool-info "$vm" >/dev/null 2>&1; then
$V pool-define-as "$vm" dir --target "$dir" >/dev/null 2>&1
$V pool-build "$vm" >/dev/null 2>&1; $V pool-start "$vm" >/dev/null 2>&1; $V pool-autostart "$vm" >/dev/null 2>&1
echo "已建存储池: $vm → $dir"
fi
cur="$(dom_of_vm "$vm" || true)"
if [ -n "$cur" ]; then
[ "$($V domstate "$cur" 2>/dev/null)" = running ] && die "虚拟机 $vm 正在运行($cur),请先 stop/poweroff 再 create"
$V undefine "$cur" --nvram >/dev/null 2>&1 || $V undefine "$cur" >/dev/null 2>&1
fi
$V define "$xml" >/dev/null || die "define 失败: $xml"
echo "已创建: $vm 方案=${sc:-默认} 域='$(xml_name "$xml")' 状态=off(create 不自动运行)"
}
cmd_switch(){
local vm="${1:-}" sc="${2:-}" mode="${3:-}"
case "$sc" in on|off|run|start) mode="$sc"; sc="";; esac
[ -n "$mode" ] || mode="run"
vm="$(vm_for "$vm")"
local dir="$KVM_ROOT/$vm" xml cur dn
xml="$(scheme_xml "$vm" "$sc")"
[ -f "$xml" ] || die "找不到方案文件: $xml (现有: $(avail_schemes "$vm"))"
dn="$(xml_name "$xml")"; [ -n "$dn" ] || die "方案文件缺 <name>: $xml"
cur="$(dom_of_vm "$vm" || true)"
if [ -n "$cur" ]; then
[ "$($V domstate "$cur" 2>/dev/null)" = running ] && $V destroy "$cur" >/dev/null 2>&1
{ $V undefine "$cur" --nvram >/dev/null 2>&1 || $V undefine "$cur" >/dev/null 2>&1; }
fi
$V define "$xml" >/dev/null || die "define 失败: $xml"
echo "已切换: $vm 方案=${sc:-(默认)} → 域 '$dn' (${xml##*/})"
if [ "$mode" = off ]; then echo "(off:切换后未启动)"; return 0; fi
$V start "$dn" >/dev/null && echo "已启动 '$dn'"
}
# ---------- 图形控制台(脱离终端) ----------
# 背景:virt-viewer 以前用 `exec` 前台运行 ⇒ 命令"卡"在控制台,Ctrl+C / 关终端会把窗口一起带走。
# 现在:新会话(setsid)+ 断开 stdio ⇒ 终端立刻归还;Ctrl+C / 关终端只影响本地,
# 窗口与虚拟机都继续运行。
spawn_viewer(){
local cur="$1" log
log="${XDG_RUNTIME_DIR:-/tmp}/virt-viewer-$cur.log"
command -v virt-viewer >/dev/null || die "缺 virt-viewer"
# 每次都新起一个图形控制台(新会话 ⇒ 脱离终端):
# 命令立即返回、不在终端刷输出;Ctrl+C / 关终端都不影响窗口与虚拟机。
# 不做"已在运行就跳过"的判断 —— 进程表里的僵尸/同名进程会造成误判而什么都不开(实测踩过)。
setsid virt-viewer -c "$CONN" "$cur" </dev/null >/dev/null 2>"$log" &
disown 2>/dev/null || true
sleep 1
if grep -qiE "cannot open display|Authorization required|Unable to connect|Connection refused|Failed to connect" "$log" 2>/dev/null; then
echo "警告: 图形控制台未打开(原因见 $log):" >&2; sed -n '1,3p' "$log" >&2
else
echo "已打开图形控制台(脱离终端): $cur —— Ctrl+C / 关终端都不影响它与虚拟机"
fi
}
cmd_run(){
local vm cur dx
vm="$(vm_for "${1:-}")"
cur="$(dom_of_vm "$vm" || true)"
if [ -z "$cur" ]; then
dx="$KVM_ROOT/$vm/$vm.xml"
[ -f "$dx" ] || die "虚拟机 $vm 未定义,且无默认方案 $vm.xml(请先 create)"
cur="$(xml_name "$dx")"; $V define "$dx" >/dev/null || die "define 失败: $dx"
echo "未定义 → 已按默认方案 $vm.xml 定义域 '$cur'"
fi
if [ "$($V domstate "$cur" 2>/dev/null)" = running ]; then echo "虚拟机 $vm('$cur')已在运行"; else
$V start "$cur" >/dev/null && echo "已启动 $vm('$cur')"
fi
spawn_viewer "$cur"
}
cmd_stop(){
local vm cur; vm="$(vm_for "${1:-}")"; cur="$(dom_of_vm "$vm" || true)"
[ -n "$cur" ] || die "虚拟机 $vm 未定义"
[ "$($V domstate "$cur" 2>/dev/null)" = running ] || { echo "虚拟机 $vm 未运行"; return 0; }
$V shutdown "$cur" >/dev/null && echo "已发送关机(ACPI): $vm('$cur')"
}
cmd_reboot(){
local vm cur; vm="$(vm_for "${1:-}")"; cur="$(dom_of_vm "$vm" || true)"
[ -n "$cur" ] || die "虚拟机 $vm 未定义"
[ "$($V domstate "$cur" 2>/dev/null)" = running ] || die "虚拟机 $vm 未运行,无法重启"
$V reboot "$cur" >/dev/null && echo "已重启: $vm('$cur')"
spawn_viewer "$cur"
}
cmd_poweroff(){
local vm cur; vm="$(vm_for "${1:-}")"; cur="$(dom_of_vm "$vm" || true)"
[ -n "$cur" ] || die "虚拟机 $vm 未定义"
[ "$($V domstate "$cur" 2>/dev/null)" = running ] || { echo "虚拟机 $vm 未运行"; return 0; }
$V destroy "$cur" >/dev/null && echo "已强制断电: $vm('$cur')"
}
cmd_rm(){
local force=0 vm dir cur disk sz ans
while [ $# -gt 0 ]; do
case "$1" in -f|--force|-y|--yes) force=1; shift ;; *) break ;; esac
done
vm="$(vm_for "${1:-}")"
dir="$KVM_ROOT/$vm"
cur="$(dom_of_vm "$vm" || true)"
if [ "$force" != 1 ]; then
echo "将卸载虚拟机实例 $vm (只卸 libvirt 注册;物理目录保留):"
echo " · 域定义${cur:+(当前 '$cur',在运行则先强制断电)}"
echo " · 存储池 $vm"
if [ -d "$dir" ]; then
disk="$(ls "$dir"/*.qcow2 2>/dev/null | xargs -n1 basename 2>/dev/null | tr '\n' ' ')"
sz="$(du -sh "$dir" 2>/dev/null | cut -f1)"
echo " · 物理目录保留: $dir(磁盘 ${disk:-—},实占 ${sz:-—})—— 不动,由你手动维护"
else
echo " · 物理目录不存在: $dir(仅清注册)"
fi
printf "确认?[y/N] "
read -r ans
case "$ans" in y|Y|yes|YES) ;; *) echo "已取消"; return 0 ;; esac
fi
if [ -n "$cur" ]; then
[ "$($V domstate "$cur" 2>/dev/null)" = running ] && $V destroy "$cur" >/dev/null 2>&1
$V undefine "$cur" --nvram >/dev/null 2>&1 || $V undefine "$cur" >/dev/null 2>&1
echo "已移除域: $cur"
else
echo "无已定义域(跳过)"
fi
if $V pool-info "$vm" >/dev/null 2>&1; then
$V pool-destroy "$vm" >/dev/null 2>&1
$V pool-undefine "$vm" >/dev/null 2>&1
echo "已移除存储池: $vm"
fi
echo "已卸载实例注册: $vm (物理目录保留: $dir)"
}
cmd_autostart(){
local disable=0 vm cur
if [ "${1:-}" = "--disable" ]; then disable=1; shift; fi
vm="$(vm_for "${1:-}")"; cur="$(dom_of_vm "$vm" || true)"
[ -n "$cur" ] || die "虚拟机 $vm 未定义"
if [ "$disable" = 1 ]; then $V autostart --disable "$cur" >/dev/null && echo "已取消开机自启: $vm('$cur')"
else $V autostart "$cur" >/dev/null && echo "已设为开机自启: $vm('$cur')"; fi
}
cmd_login(){
local vm cur; vm="$(vm_for "${1:-}")"; cur="$(dom_of_vm "$vm" || true)"
[ -n "$cur" ] || die "虚拟机 $vm 未定义"
[ "$($V domstate "$cur" 2>/dev/null)" = running ] || die "虚拟机 $vm 未运行,请先 run"
echo "打开桌面: $vm('$cur')..."
spawn_viewer "$cur"
}
cmd_console(){
local vm cur; vm="$(vm_for "${1:-}")"; cur="$(dom_of_vm "$vm" || true)"
[ -n "$cur" ] || die "虚拟机 $vm 未定义"
[ "$($V domstate "$cur" 2>/dev/null)" = running ] || die "虚拟机 $vm 未运行,请先 run"
echo "进入文本控制台: $vm('$cur') (退出: Ctrl+] )"
exec $V console "$cur"
}
# ---------- 文件互搬 / 客机桌面列表(经 qemu-guest-agent) ----------
# 客机侧固定是「当前用户桌面」;宿主侧固定是「虚拟机 xml 所在目录下的 desktop/」(不存在则自动创建)。
# 走 qemu-ga:不改 XML、不装软件、不重启(客机里 qemu-ga 随 virtio-win guest tools 已在跑)。
# 通配符 * 由两端各自展开;**文件名请加引号**(如 kvm cp '*.png'),否则会被本地 shell 提前展开。
# 性能:客机侧命令轮询 50ms 起指数退避(原先固定 sleep 2s/次);桌面探测合成单次调用。
ga_run(){ # <模式 ls|get|put> [参数1] [参数2]
local mode="$1" a1="${2:-}" a2="${3:-}" vm pat cur vmdir
# 参数1 若正好是已知虚拟机名 → 它作虚拟机、参数2 作文件名模式;否则参数1 作模式、虚拟机取第一个
if [ -n "$a1" ] && list_vms | grep -qxF -- "$a1"; then vm="$a1"; pat="${a2:-*}"; else vm="$(vm_for)"; pat="${a1:-*}"; fi
command -v python3 >/dev/null || die "缺 python3"
vmdir="$KVM_ROOT/$vm/desktop" # 本地落点 = 虚拟机 xml 所在目录下的 desktop/
mkdir -p "$vmdir" || die "无法创建目录: $vmdir"
# 找域 / 查运行状态一并交给 python(libvirt 绑定枚举,比 bash 反复调 virsh 快)
KVM_GA_CONN="$CONN" KVM_GA_VM="$vm" KVM_GA_ROOT="$KVM_ROOT" KVM_GA_MODE="$mode" \
KVM_GA_PAT="$pat" KVM_GA_VMDIR="$vmdir" KVM_GA_USER="${KVM_GA_USER:-}" \
KVM_GA_DESKTOP="${KVM_GA_DESKTOP:-}" \
python3 - <<'PYEOF'
# 经 qemu-guest-agent 与客机互搬文件 / 列客机桌面(由 kvm 的 ls / cp / paste 子命令调用)
# 入参走环境变量,避免把中文路径塞进命令行被 shell/locale 破坏
# 连接走 libvirt python 绑定(不是 virsh 子进程):绑定复用连接,单次 agent 命令 0.001s,
# virsh 每次新建进程 + 连 libvirtd 要 0.059s —— 一次 kvm ls 差 20~60 倍。
import os, sys, json, glob, time, base64, shlex
import libvirt, libvirt_qemu
CONN = os.environ["KVM_GA_CONN"] # libvirt 连接 URI(qemu:///system)
VM = os.environ["KVM_GA_VM"] # 虚拟机名(目录名)
ROOT = os.environ["KVM_GA_ROOT"] # 虚拟机存储根
MODE = os.environ["KVM_GA_MODE"] # ls | get | put
PAT = os.environ["KVM_GA_PAT"] # 文件名通配符
VMDIR = os.environ["KVM_GA_VMDIR"] # 宿主落点(虚拟机 xml 所在目录下的 desktop/)
USER = os.environ.get("KVM_GA_USER", "").strip() # 客机用户名,空=自动探测(仅 Windows 用)
DESKTOP = os.environ.get("KVM_GA_DESKTOP", "").strip() # 兜底:直接指定客机桌面绝对路径,任何 OS 都适用
_conn = libvirt.open(CONN) # 进程内复用
_dom = None # 域:首次用时按磁盘归属查找(见 dom())
CMD = r"C:\Windows\System32\cmd.exe" # Windows 客机
SH = "/bin/sh" # POSIX 客机(linux / darwin)
CHUNK_RD = 1048576 # guest-file-read 的量:数据经通道返回,不受命令行参数大小限制
CHUNK_WR = 65536 # guest-file-write 的量:base64 要塞进命令行参数,Linux 单参数上限 128KB,取 64KB 稳妥
SYSDIRS = {"all users", "default", "default user", "public"}
_OSID = None # 客机 OS 标识(mswindows / linux / darwin),本进程内只探一次
class AgentError(Exception):
"""qemu-agent 侧失败(文件被占用 / 路径不存在 / 通道不通…):可预期的业务失败,批量时逐条报告、不中断整批"""
def die(msg):
print("错误: " + msg, file=sys.stderr); sys.exit(1)
def dom():
"""取本虚拟机对应的域:所有域里谁的 XML 引用了 <ROOT>/<VM>/ 下的盘;顺带确认在运行。
放在 python 里做(绑定枚举域远快于 bash 里反复调 virsh dumpxml / domstate)。"""
global _dom
if _dom is None:
for d in _conn.listAllDomains():
if f"{ROOT}/{VM}/" in d.XMLDesc():
_dom = d; break
if _dom is None:
raise AgentError(f"虚拟机 {VM} 未定义(没有域引用 {ROOT}/{VM}/)")
if not _dom.isActive():
raise AgentError(f"虚拟机 {VM} 未运行(qemu-guest-agent 需要运行中的域)")
return _dom
def q(obj):
# ensure_ascii=True 必须:非 ASCII 路径要转义成 \uXXXX,原样发 UTF-8 字节会解析失败
try:
r = libvirt_qemu.qemuAgentCommand(dom(), json.dumps(obj, ensure_ascii=True), 10, 0)
except libvirt.libvirtError as e:
raise AgentError(str(e)[:200])
return json.loads(r)["return"]
def osid():
"""客机 OS 标识(guest-get-osinfo):mswindows / linux / darwin。本进程内只探一次。"""
global _OSID
if _OSID is None:
_OSID = q({"execute": "guest-get-osinfo"})["id"]
return _OSID
def exec_wait(path, args):
"""在客机里跑一条命令并等它结束,返回 (stdout_bytes, exitcode)。
轮询固定 20ms:dir / ls 这类命令毫秒级就结束,指数退避会让首轮白等。"""
pid = q({"execute": "guest-exec",
"arguments": {"path": path, "arg": args, "capture-output": True}})["pid"]
st = {}
for _ in range(250): # 上限 5s
time.sleep(0.02)
st = q({"execute": "guest-exec-status", "arguments": {"pid": pid}})
if st.get("exited"):
return base64.b64decode(st.get("out-data") or b""), st.get("exitcode")
raise AgentError("客机命令超时未结束: " + path + " " + " ".join(args)[:80])
def parse_dir_lines(lines):
"""把 Windows dir /a-d-h-s /-c 的输出行解析成 [(名字, 字节)];隐藏/系统文件不在其中"""
files = []
for ln in lines:
p = ln.split(None, 3) # 形如: 2026/10/17 21:57 132767 文件名
if len(p) < 4 or "/" not in p[0]: # 跳过表头 / "N 个文件" 汇总行
continue
try:
size = int(p[2])
except ValueError:
continue
files.append((p[3].strip(), size))
return files
def parse_find_lines(lines):
"""把 POSIX find -printf '%s\t%f\n' 的输出解析成 [(名字, 字节)]"""
files = []
for ln in lines:
if "\t" not in ln:
continue
sz, name = ln.split("\t", 1)
try:
files.append((name.strip(), int(sz)))
except ValueError:
continue
return files
def guest_join(desk, name):
"""按客机 OS 拼路径分隔符(Windows 反斜杠 / POSIX 正斜杠)"""
return desk + ("\\" if "\\" in desk else "/") + name
def win_desktop_and_list(pat):
"""Windows:一次 guest-exec 同时拿到「用户桌面」与「该桌面下匹配的文件列表」。
输出形如 @@KVMGA@@<用户目录> 紧跟该用户桌面的 dir 输出,可能多块(Public 等按系统目录过滤)。"""
if USER: # 指定了用户名就只发一次 dir
desk = rf"C:\Users\{USER}\Desktop"
out, _ = exec_wait(CMD, ["/c", "dir", "/a-d-h-s", "/-c", rf"{desk}\{pat}"])
return desk, parse_dir_lines(out.decode("gbk", "replace").splitlines())
out, _ = exec_wait(CMD, ["/c", "for", "/d", "%u", "in", r"(C:\Users\*)", "do", "@if",
"exist", r"%u\Desktop", "(echo", "@@KVMGA@@%u", "&", "dir",
"/a-d-h-s", "/-c", rf"%u\Desktop\{pat})"])
for blk in out.decode("gbk", "replace").split("@@KVMGA@@")[1:]:
lines = blk.splitlines()
if not lines:
continue
path = lines[0].strip() # 形如 <盘>:\Users\<名>
name = path.rsplit("\\", 1)[-1]
low = name.lower()
if not name or low in SYSDIRS or low.startswith("defaultuser"):
continue
return path + r"\Desktop", parse_dir_lines(lines[1:])
die("客机里找不到用户桌面(可用环境变量 KVM_GA_USER=<用户名> 或 KVM_GA_DESKTOP=<路径> 指定)")
def posix_desktop_and_list(pat):
"""linux / darwin:桌面按 XDG 取(xdg-user-dir DESKTOP),退化到 ~/Desktop、~/桌面;
find -printf 统一输出「字节<TAB>名」,与 Windows 侧解析结果同形。
※ 本机只有 Windows 客机,此分支尚无实测对象。"""
script = (
'd=$(xdg-user-dir DESKTOP 2>/dev/null); '
'[ -z "$d" ] && [ -d "$HOME/Desktop" ] && d="$HOME/Desktop"; '
'[ -z "$d" ] && [ -d "$HOME/\u684c\u9762" ] && d="$HOME/\u684c\u9762"; '
'[ -n "$d" ] && [ -d "$d" ] || exit 3; '
'echo "@@KVMGA@@$d"; '
'find "$d" -maxdepth 1 -type f -name ' + shlex.quote(pat) + ' -printf "%s\\t%f\\n" 2>/dev/null'
)
out, rc = exec_wait(SH, ["-c", script])
if rc == 3:
die("客机里找不到用户桌面(可用环境变量 KVM_GA_DESKTOP=<路径> 指定)")
blocks = out.decode("utf-8", "replace").split("@@KVMGA@@")[1:]
if not blocks:
die("客机里找不到用户桌面(可用环境变量 KVM_GA_DESKTOP=<路径> 指定)")
lines = blocks[0].splitlines()
return lines[0].strip(), parse_find_lines(lines[1:])
def desktop_and_list(pat):
"""统一入口:按客机 OS 定位用户桌面并列出匹配文件,返回 (桌面路径, [(名字, 字节)])。
KVM_GA_DESKTOP 直接指定时任何 OS 都走它。"""
if DESKTOP: # 直接指定:仍按客机 OS 选命令(Windows 客机没有 /bin/sh)
if osid() == "mswindows":
out, rc = exec_wait(CMD, ["/c", "dir", "/a-d-h-s", "/-c", DESKTOP + "\\" + pat])
if rc != 0 and not out.strip():
die("KVM_GA_DESKTOP 指定的路径不可用: " + DESKTOP)
return DESKTOP, parse_dir_lines(out.decode("gbk", "replace").splitlines())
out, rc = exec_wait(SH, ["-c", 'find ' + shlex.quote(DESKTOP) + ' -maxdepth 1 -type f -name '
+ shlex.quote(pat) + ' -printf "%s\\t%f\\n" 2>/dev/null'])
if rc not in (0, 1):
die("KVM_GA_DESKTOP 指定的路径不可用: " + DESKTOP)
return DESKTOP, parse_find_lines(out.decode("utf-8", "replace").splitlines())
if osid() == "mswindows":
return win_desktop_and_list(pat)
return posix_desktop_and_list(pat)
def get_one(gpath, hpath):
h = q({"execute": "guest-file-open",
"arguments": {"path": gpath, "mode": "r"}}) # 返回值本身就是 handle
n = 0
try:
with open(hpath, "wb") as f:
while True:
r = q({"execute": "guest-file-read", "arguments": {"handle": h, "count": CHUNK_RD}})
if r.get("eof"):
break
b = base64.b64decode(r["buf-b64"]); f.write(b); n += len(b)
finally:
q({"execute": "guest-file-close", "arguments": {"handle": h}}) # 失败路径也必须关,否则客机文件被锁
return n
def put_one(hpath, gpath):
h = q({"execute": "guest-file-open", "arguments": {"path": gpath, "mode": "w"}})
n = 0
try:
with open(hpath, "rb") as f:
while True:
c = f.read(CHUNK_WR)
if not c:
break
q({"execute": "guest-file-write",
"arguments": {"handle": h, "buf-b64": base64.b64encode(c).decode()}})
n += len(c)
finally:
q({"execute": "guest-file-close", "arguments": {"handle": h}})
return n
def human(n):
x = float(n)
for u in ("B", "KB", "MB", "GB"):
if x < 1024:
return f"{x:.0f}{u}" if u == "B" else f"{x:.1f}{u}"
x /= 1024
return f"{x:.1f}TB"
def main():
if MODE == "ls":
desk, files = desktop_and_list(PAT)
if not files:
die(f"无匹配 {PAT}({desk})")
print(f"客机桌面 {desk}")
for name, size in files:
print(f" {human(size):>9} {name}")
print(f"共 {len(files)} 个文件")
return 0
if MODE == "get":
desk, files = desktop_and_list(PAT)
if not files:
die(f"无匹配 {PAT}({desk})")
fails = []
for name, _ in files:
try:
n = get_one(guest_join(desk, name), os.path.join(VMDIR, name))
print(f" ↓ {name} {human(n)}")
except AgentError as e:
fails.append(name); print(f" ✗ {name}: {e}", file=sys.stderr)
print(f"已拷出 {len(files)-len(fails)}/{len(files)} 个文件 → {VMDIR}")
return 1 if fails else 0
if MODE == "put":
desk, _ = desktop_and_list("*") # 只需要桌面路径
srcs = [s for s in sorted(glob.glob(os.path.join(VMDIR, PAT))) if os.path.isfile(s)]
if not srcs:
die(f"无匹配 {PAT}({VMDIR})")
fails = []
for s in srcs:
name = os.path.basename(s)
try:
n = put_one(s, guest_join(desk, name))
print(f" ↑ {name} {human(n)}")
except AgentError as e:
fails.append(name); print(f" ✗ {name}: {e}", file=sys.stderr)
print(f"已拷入 {len(srcs)-len(fails)}/{len(srcs)} 个文件 → {desk}")
return 1 if fails else 0
die("未知模式 " + MODE)
if __name__ == "__main__":
try:
sys.exit(main())
except AgentError as e:
die(str(e))
PYEOF
}
cmd_ls(){ ga_run ls "${1:-}" "${2:-}"; }
cmd_copy(){ ga_run get "${1:-}" "${2:-}"; }
cmd_paste(){ ga_run put "${1:-}" "${2:-}"; }
# ---------- USB 直通(运行时热插拔,不写进 XML)----------
# 为什么不写进 XML:设备不在时域会起不来(USB hostdev 没有「可缺失」语义);
# 而键鼠这类设备本来就该由运行时决定给谁 —— 与 gpu 的「状态不进世代」同一条思路。
cmd_usb(){
# 参数形态(2026-10-18 精简):
# kvm usb → 列表
# kvm usb list → 列表
# kvm usb <设备> [虚拟机] → 切换:已挂就拔回、没挂就挂上
# kvm usb add <设备> [虚拟机] / kvm usb rm <设备> [虚拟机] → 显式指定
local a="${1:-list}" b="${2:-}" c="${3:-}" act="" spec="" vmarg="" vm dom
case "$a" in
list) act=list; vmarg="$b" ;;
add|rm) act="$a"; spec="$b"; vmarg="$c" ;;
*) act=toggle; spec="$a"; vmarg="$b" ;;
esac
vm="$(vm_for "$vmarg")"
# 同一块盘可能以不同「方案」定义成不同域名 ⇒ 一律解析成当前实际域名
dom="$(dom_of_vm "$vm" || true)"; dom="${dom:-$vm}"
python3 - "$act" "$spec" "$dom" "$V" <<'PY'
import subprocess, sys, glob, os, re, shlex
act, spec, vm, V = sys.argv[1], sys.argv[2], sys.argv[3], shlex.split(sys.argv[4])
def virsh(*args):
r = subprocess.run(V + list(args), capture_output=True, text=True)
return r.returncode, (r.stdout or "") + (r.stderr or "")
def devices():
out = []
for d in sorted(glob.glob('/sys/bus/usb/devices/*')):
def rd(f):
try:
return open(os.path.join(d, f)).read().strip()
except Exception:
return ""
vid, pid, prod = rd('idVendor'), rd('idProduct'), rd('product')
if not vid or not prod:
continue # 没有产品名的多半是根 hub / 接口节点
if rd('bDeviceClass') == '09':
continue # 跳过 hub
out.append({
'vid': vid.lower(), 'pid': pid.lower(), 'product': prod,
'manuf': rd('manufacturer'), 'port': os.path.basename(d),
'class': rd('bDeviceClass'),
})
return out
def attached():
rc, xml = virsh('dumpxml', vm)
res = set()
for blk in re.findall(r"<hostdev[^>]*type='usb'.*?</hostdev>", xml, re.S):
v = re.search(r"<vendor id='0x([0-9a-fA-F]+)'", blk)
p = re.search(r"<product id='0x([0-9a-fA-F]+)'", blk)
if v and p:
res.add(f"{v.group(1).lower()}:{p.group(1).lower()}")
return res
devs = devices()
if act == 'list':
att = attached()
print(f"{'#':<3} {'设备':<12} {'产品':<30} {'厂商':<22} {'端口':<12} 给客机")
for i, d in enumerate(devs):
key = f"{d['vid']}:{d['pid']}"
mark = "✓ 已挂" if key in att else ""
print(f"{i:<3} {key:<12} {d['product'][:29]:<30} {d['manuf'][:21]:<22} {d['port']:<12} {mark}")
print(f"\n已挂给 {vm} 的 USB 设备:{len(att)} 个" + (f"({', '.join(sorted(att))})" if att else ""))
print("切换:kvm usb <序号|VID:PID> 显式:kvm usb add|rm <序号|VID:PID>")
sys.exit(0)
# ---- add / rm ----
if not spec:
sys.exit("用法:kvm usb add|rm <序号|VID:PID> [虚拟机]")
target = None
if re.fullmatch(r'\d+', spec):
idx = int(spec)
if idx >= len(devs):
sys.exit(f"序号越界:只有 {len(devs)} 个设备(kvm usb list 看序号)")
target = devs[idx]
elif re.fullmatch(r'[0-9a-fA-F]{4}:[0-9a-fA-F]{4}', spec):
v, p = spec.lower().split(':')
target = next((d for d in devs if d['vid'] == v and d['pid'] == p), None)
else:
sys.exit(f"无法识别的设备写法:{spec}(用序号或 VID:PID)")
if not target:
sys.exit(f"宿主上找不到该设备:{spec}")
xml = (f"<hostdev mode='subsystem' type='usb' managed='yes'>\n"
f" <source><vendor id='0x{target['vid']}'/><product id='0x{target['pid']}'/></source>\n"
f"</hostdev>\n")
tmp = "/tmp/kvm-usb-" + target['vid'] + target['pid'] + ".xml"
open(tmp, 'w').write(xml)
if act == 'toggle':
act = 'rm' if f"{target['vid']}:{target['pid']}" in attached() else 'add'
op = 'attach-device' if act == 'add' else 'detach-device'
rc, out = virsh(op, vm, tmp, '--live')
name = f"{target['product']} ({target['vid']}:{target['pid']})"
if rc != 0:
sys.exit(f"✗ {op} 失败:{out.strip()}")
print(("✓ 已挂给客机:" if act == 'add' else "✓ 已拔回宿主:") + name)
if act == 'add':
print(" 提示:这是运行时直通,客机重启后失效(要长期生效再改 XML 或写进启动流程)")
PY
}
# ---------- 分发 ----------
cmd="${1:-}"; [ $# -gt 0 ] && shift
case "$cmd" in
list) cmd_list ;;
info) cmd_info "${1:-}" ;;
create) cmd_create "${1:-}" "${2:-}" "${3:-}" ;;
switch|sw) cmd_switch "${1:-}" "${2:-}" "${3:-}" ;;
run|boot|start|up|on) cmd_run "${1:-}" ;;
stop|down|off) cmd_stop "${1:-}" ;;
reboot|restart) cmd_reboot "${1:-}" ;;
poweroff|destroy|destory) cmd_poweroff "${1:-}" ;;
autostart) cmd_autostart "${1:-}" "${2:-}" ;;
login|desktop|view) cmd_login "${1:-}" ;;
ls) cmd_ls "${1:-}" ;;
cp|copy|get|pull) cmd_copy "${1:-}" "${2:-}" ;;
paste|send|push) cmd_paste "${1:-}" "${2:-}" ;;
exec|console) cmd_console "${1:-}" ;;
usb) cmd_usb "${1:-list}" "${2:-}" "${3:-}" ;;
rm|del|remove) cmd_rm "$@" ;;
""|-h|--help|help) usage ;;
*) echo "未知命令: $cmd"; echo; usage; exit 2 ;;
esac