#!/usr/bin/env bash #============================================================================== # pve-vm.sh —— Proxmox VE 虚拟机管理脚本(基于 cloud-init) # # 功能概览 # · 下载主流云镜像一键制作模板(Debian / Ubuntu / Rocky / Alma / CentOS) # · 从模板克隆创建、批量创建(静态 IP 自动递增) # · 为已有 VM 生成独立 cloud-init 片段 + 随机密码(兼容原 init-vm.sh) # · 启动 / 关机 / 重启 / 强停 / 挂起 / 恢复(支持多个 VMID) # · 修改 CPU / 内存 / 名称 / 标签,系统盘扩容(在线扩分区),添加数据盘 # · 修改网桥 / VLAN / IPv4 / IPv6 / DNS # · 通过 guest agent 重置密码、注入 SSH 公钥、执行命令、查询 IP / 系统信息 # · 快照、vzdump 备份、恢复、迁移、删除(自动清理 cloud-init 片段) # · 凭据记录、操作日志、环境自检、配置文件、交互式菜单 # · Windows:从 ISO 创建(UEFI/TPM/virtio 驱动盘)、Cloudbase-Init 模板克隆, # passwd / exec / resize 自动识别 Windows # # 用法:直接运行进入交互菜单;`./pve-vm.sh help` 查看命令行用法 # 配置:/etc/pve-vm.conf(`./pve-vm.sh config init` 生成),可用环境变量 PVE_VM_CONFIG 指定 #============================================================================== set -Eeuo pipefail VERSION="2.2.0" SELF=$(readlink -f "${BASH_SOURCE[0]}") SCRIPT_NAME=$(basename "$SELF") NODE=$(hostname) #------------------------------------------------------------------------------ # 默认配置(可在配置文件中覆盖) #------------------------------------------------------------------------------ CONFIG_FILE="${PVE_VM_CONFIG:-/etc/pve-vm.conf}" SNIPPET_STORAGE="snippets" # 存放 cloud-init 片段的存储 ID(需启用 Snippets 内容类型) SNIPPET_DIR="/cloud-init-snippets/snippets" # pvesm 无法解析路径时的回退目录 VM_STORAGE="local-lvm" # 虚拟机磁盘默认存储 BACKUP_STORAGE="local" # 备份默认存储 IMAGE_DIR="/var/lib/vz/template/cloud-images" # 云镜像下载目录 DEFAULT_TEMPLATE="" # 默认模板 VMID(如 9000) DEFAULT_BRIDGE="vmbr0" DEFAULT_CORES=2 DEFAULT_MEMORY=2048 DEFAULT_DISK="20G" DEFAULT_DNS="1.1.1.1 8.8.8.8" DEFAULT_SEARCHDOMAIN="" DEFAULT_SSH_KEY_FILE="" # 如 /root/.ssh/id_ed25519.pub,留空则不注入 DEFAULT_CLONE_MODE="full" # full / linked DEFAULT_USER="root" TIMEZONE="America/Los_Angeles" PACKAGES="git vim wget curl sudo qemu-guest-agent" PACKAGE_UPGRADE="true" EXTRA_RUNCMD=() # 额外首启命令,如 ("apt-get install -y htop") PASSWORD_LENGTH=16 AGENT_WAIT_TIMEOUT=600 # 等待 guest agent 秒数(首启安装软件较慢) EXEC_TIMEOUT=60 SAVE_CREDENTIALS=1 # 1=把生成的密码记录到 CRED_FILE CRED_FILE="/root/.pve-vm-credentials" LOG_FILE="/var/log/pve-vm.log" # Windows ISO_STORAGE="local" # ISO 存储(下载 virtio-win 驱动盘用) DEFAULT_WIN_USER="Administrator" # 需与模板内 Cloudbase-Init 配置的 username 一致 WIN_CORES=4 WIN_MEMORY=4096 WIN_DISK=64 # GB,Win11 安装要求至少 64G VIRTIO_WIN_URL="https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso" # 脚本自更新(self-update) UPDATE_URL="https://update.555333.best" # shellcheck source=/dev/null [[ -f $CONFIG_FILE ]] && source "$CONFIG_FILE" ASSUME_YES=0 CLEANUP_HINT="" #------------------------------------------------------------------------------ # 输出 / 日志 #------------------------------------------------------------------------------ if [[ -t 1 && -z ${NO_COLOR:-} ]]; then RED=$'\e[31m' GRN=$'\e[32m' YEL=$'\e[33m' CYN=$'\e[36m' BLD=$'\e[1m' RST=$'\e[0m' else RED="" GRN="" YEL="" CYN="" BLD="" RST="" fi log() { [[ -n $LOG_FILE ]] || return 0; printf '%s %s\n' "$(date '+%F %T')" "$*" >>"$LOG_FILE" 2>/dev/null || true; } info() { printf '%s[INFO]%s %s\n' "$GRN" "$RST" "$*"; log "INFO $*"; } warn() { printf '%s[WARN]%s %s\n' "$YEL" "$RST" "$*" >&2; log "WARN $*"; } err() { printf '%s[ERR ]%s %s\n' "$RED" "$RST" "$*" >&2; log "ERROR $*"; } die() { err "$*"; exit 1; } title() { printf '\n%s== %s ==%s\n' "$BLD$CYN" "$*" "$RST"; } on_err() { err "命令失败 (行 $1): $2" if [[ -n $CLEANUP_HINT ]]; then warn "可执行以下命令清理未完成的虚拟机: $CLEANUP_HINT"; fi } trap 'on_err "$LINENO" "$BASH_COMMAND"' ERR #------------------------------------------------------------------------------ # 交互工具 #------------------------------------------------------------------------------ # ask <变量名> <提示> [默认值]:变量已有值则跳过;-y 或非终端时直接取默认值 ask() { local __v=$1 __p=$2 __d=${3:-} __a="" [[ -n ${!__v:-} ]] && return 0 if (( ASSUME_YES )) || [[ ! -t 0 ]]; then printf -v "$__v" '%s' "$__d"; return 0; fi read -r -p "$__p${__d:+ [$__d]}: " __a || true printf -v "$__v" '%s' "${__a:-$__d}" } # ask_secret <变量名> <提示>:静默输入并二次确认,留空表示不设置 ask_secret() { local __v=$1 __p=$2 __a="" __b="" [[ -n ${!__v:-} ]] && return 0 if (( ASSUME_YES )) || [[ ! -t 0 ]]; then return 0; fi read -r -s -p "$__p: " __a || true; echo if [[ -n $__a ]]; then read -r -s -p "再次输入确认: " __b || true; echo [[ $__a == "$__b" ]] || die "两次输入不一致" fi printf -v "$__v" '%s' "$__a" } confirm() { (( ASSUME_YES )) && return 0 local a="" read -r -p "$1 [y/N]: " a || true [[ $a =~ ^[Yy]([Ee][Ss])?$ ]] } # 可选项输入 none / - 视为清空 norm_empty() { if [[ $1 == none || $1 == - ]]; then echo ""; else echo "$1"; fi; } #------------------------------------------------------------------------------ # 校验 #------------------------------------------------------------------------------ is_int() { [[ ${1:-} =~ ^[0-9]+$ ]]; } valid_vmid() { is_int "${1:-}" && (( 10#$1 >= 100 && 10#$1 <= 999999999 )); } valid_name() { [[ $1 =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]]; } valid_size() { [[ $1 =~ ^\+?[0-9]+(\.[0-9]+)?[KMGT]$ ]]; } valid_snap() { [[ $1 =~ ^[A-Za-z][A-Za-z0-9_-]+$ ]]; } ip4_octets_ok() { local IFS=. o; for o in $1; do (( 10#$o <= 255 )) || return 1; done; } valid_ip4() { [[ $1 =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] && ip4_octets_ok "$1"; } valid_cidr4() { [[ $1 =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[12][0-9]|3[0-2])$ ]] && ip4_octets_ok "${1%/*}"; } valid_ip6cfg(){ [[ $1 == auto || $1 == dhcp || $1 =~ ^[0-9A-Fa-f:]+/[0-9]{1,3}$ ]]; } guess_gw() { local ip=${1%/*}; echo "${ip%.*}.1"; } # ip_inc 192.168.1.10/24 3 -> 192.168.1.13/24 ip_inc() { local ip=${1%/*} pre=${1#*/} n=$2 a b c d v IFS=. read -r a b c d <<<"$ip" v=$(( (10#$a << 24 | 10#$b << 16 | 10#$c << 8 | 10#$d) + n )) echo "$(( v >> 24 & 255 )).$(( v >> 16 & 255 )).$(( v >> 8 & 255 )).$(( v & 255 ))/$pre" } #------------------------------------------------------------------------------ # PVE 辅助 #------------------------------------------------------------------------------ need_env() { [[ $EUID -eq 0 ]] || die "请使用 root 运行" local c for c in qm pvesm pvesh python3 openssl; do command -v "$c" >/dev/null || die "缺少命令 $c,请在 Proxmox VE 节点上运行" done } vm_cfg() { qm config "$1" 2>/dev/null; } cfg_get() { sed -n "s/^$2: //p" <<<"$1"; } cfg_def() { local v; v=$(cfg_get "$1" "$2"); echo "${v:-$3}"; } vm_exists() { qm config "$1" &>/dev/null; } vm_status() { qm status "$1" 2>/dev/null | awk '{print $2}'; } vm_name() { cfg_get "$(vm_cfg "$1")" name; } is_template(){ grep -q '^template: 1' <<<"$(vm_cfg "$1")"; } id_free() { pvesh get /cluster/nextid --vmid "$1" &>/dev/null; } next_id() { pvesh get /cluster/nextid; } agent_ok() { qm guest cmd "$1" ping &>/dev/null; } is_windows() { [[ $(cfg_get "$(vm_cfg "$1")" ostype) =~ ^(win|w2k|wxp|wvista) ]]; } default_user() { if is_windows "$1"; then echo "$DEFAULT_WIN_USER"; else echo "$DEFAULT_USER"; fi; } pick() { if (( $1 )); then echo "$2"; else echo "$3"; fi; } win_pw_ok() { [[ ${#1} -ge 8 && $1 =~ [A-Z] && $1 =~ [a-z] && $1 =~ [0-9] ]]; } # pve_ge 8 1 -> PVE 版本 >= 8.1 pve_ge() { local v maj min v=$(pveversion 2>/dev/null | sed -n 's#^pve-manager/\([0-9]*\)\.\([0-9]*\).*#\1.\2#p') [[ -n $v ]] || return 0 maj=${v%.*}; min=${v#*.} (( maj > $1 || (maj == $1 && min >= $2) )) } require_vm() { valid_vmid "${1:-}" || die "无效的 VMID: ${1:-<空>}" vm_exists "$1" || die "VM $1 不存在(或不在本节点 $NODE 上)" } # kv_get "virtio=XX,bridge=vmbr0,tag=10" bridge -> vmbr0 kv_get() { local IFS=, kv for kv in $1; do if [[ ${kv%%=*} == "$2" ]]; then echo "${kv#*=}"; return 0; fi done return 0 } # 找出启动盘(排除光驱和 cloud-init 盘) boot_disk() { local cfg order d cfg=$(vm_cfg "$1") order=$(sed -n 's/^boot: .*order=\([^,]*\).*/\1/p' <<<"$cfg") local IFS=';' for d in $order; do if grep -q "^$d: " <<<"$cfg" && ! grep -Eq "^$d: .*(media=cdrom|cloudinit)" <<<"$cfg"; then echo "$d"; return 0 fi done for d in scsi0 virtio0 sata0 ide0; do if grep -q "^$d: " <<<"$cfg"; then echo "$d"; return 0; fi done return 1 } # 生成 net0 字符串,保留原有网卡型号/MAC/防火墙设置 build_net0() { local id=$1 bridge=$2 vlan=$3 cur first s cur=$(cfg_get "$(vm_cfg "$id")" net0) first=${cur%%,*} if [[ $first == *=* ]]; then s=$first; else s="virtio"; fi s+=",bridge=$bridge" if [[ $cur == *firewall=1* ]]; then s+=",firewall=1"; fi if [[ -n $vlan ]]; then s+=",tag=$vlan"; fi echo "$s" } build_ipconfig() { local ip=$1 gw=${2:-} ip6=${3:-} gw6=${4:-} s if [[ $ip == dhcp ]]; then s="ip=dhcp"; else s="ip=$ip"; [[ -n $gw ]] && s+=",gw=$gw"; fi if [[ -n $ip6 ]]; then s+=",ip6=$ip6"; [[ -n $gw6 ]] && s+=",gw6=$gw6"; fi echo "$s" } ensure_cloudinit_drive() { local id=$1 st=${2:-$VM_STORAGE} cfg slot cfg=$(vm_cfg "$id") grep -Eq '^(ide|sata|scsi)[0-9]+: .*cloudinit' <<<"$cfg" && return 0 for slot in ide2 ide3 ide1 ide0 sata5 sata4; do if ! grep -q "^$slot: " <<<"$cfg"; then qm set "$id" --"$slot" "$st:cloudinit" >/dev/null info "已添加 cloud-init 驱动器 $slot ($st)" return 0 fi done die "找不到空闲槽位添加 cloud-init 驱动器" } resize_disk() { local id=$1 disk=$2 size=$3 out valid_size "$size" || die "无效磁盘大小: $size(示例: 20G 或 +10G)" if out=$(qm resize "$id" "$disk" "$size" 2>&1); then info "磁盘 $disk 已调整为 $size" else warn "磁盘 $disk 调整到 $size 失败(PVE 不支持缩小磁盘): $out" fi } #------------------------------------------------------------------------------ # guest agent #------------------------------------------------------------------------------ _agent_result() { python3 - "$1" <<'PY' import json, sys try: d = json.loads(sys.argv[1]) except Exception: print(sys.argv[1]); sys.exit(1) if d.get("out-data"): sys.stdout.write(d["out-data"]) if d.get("err-data"): sys.stderr.write(d["err-data"]) if not d.get("exited", 1): sys.stderr.write("[超时] 命令仍在虚拟机内运行\n") sys.exit(124) sys.exit(int(d.get("exitcode", 0))) PY } agent_exec() { local id=$1 out; shift out=$(qm guest exec "$id" --timeout "$EXEC_TIMEOUT" -- "$@") || return 1 _agent_result "$out" } # 从标准输入向虚拟机内命令传递数据 agent_exec_stdin() { local id=$1 out; shift out=$(qm guest exec "$id" --pass-stdin 1 --timeout "$EXEC_TIMEOUT" -- "$@") || return 1 _agent_result "$out" } # 在 Windows 虚拟机内执行 PowerShell(输出转为 UTF-8,避免中文乱码) win_ps() { local id=$1; shift agent_exec "$id" powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass \ -Command "[Console]::OutputEncoding=[Text.Encoding]::UTF8; $*" } # 输出: 接口ipv4|ipv6地址/前缀 get_ips() { local json json=$(qm guest cmd "$1" network-get-interfaces 2>/dev/null) || return 1 python3 - "$json" <<'PY' import json, sys try: data = json.loads(sys.argv[1]) except Exception: sys.exit(1) skip = ("docker", "veth", "br-", "virbr", "cni", "flannel", "cali", "kube", "Loopback", "isatap", "Teredo") for itf in data: name = itf.get("name", "") if name == "lo" or name.startswith(skip): continue for a in itf.get("ip-addresses") or []: ip = a.get("ip-address", "") if ip.lower().startswith("fe80") or ip.startswith("169.254.") or ip in ("127.0.0.1", "::1"): continue print(f'{name}\t{a.get("ip-address-type")}\t{ip}/{a.get("prefix")}') PY } primary_ipv4() { { get_ips "$1" 2>/dev/null || true; } | awk -F'\t' '$2=="ipv4" && !f {split($3,a,"/"); print a[1]; f=1}' } wait_agent() { local id=$1 t=${2:-$AGENT_WAIT_TIMEOUT} s=0 info "等待 guest agent 就绪(首次启动需安装软件包,最长 ${t}s,Ctrl+C 可跳过)..." while (( s < t )); do if agent_ok "$id"; then printf '\n' >&2; info "guest agent 已就绪 (${s}s)"; return 0; fi if [[ $(vm_status "$id") != running ]]; then printf '\n' >&2; warn "虚拟机未在运行"; return 1; fi printf '\r 已等待 %ds ...' "$s" >&2 sleep 5; s=$((s + 5)) done printf '\n' >&2 warn "等待 guest agent 超时" return 1 } wait_ipv4() { local id=$1 i ip for i in $(seq 1 30); do ip=$(primary_ipv4 "$id") if [[ -n $ip ]]; then echo "$ip"; return 0; fi sleep 3 done return 0 } #------------------------------------------------------------------------------ # 密码 / 凭据 / cloud-init 片段 #------------------------------------------------------------------------------ # 保证同时含大写、小写、数字(满足 Windows 密码复杂度要求) gen_password() { local p while :; do p="" while (( ${#p} < PASSWORD_LENGTH )); do p+=$(openssl rand -base64 48 | tr -dc 'A-Za-z0-9') done p=${p:0:PASSWORD_LENGTH} if [[ $p =~ [A-Z] && $p =~ [a-z] && $p =~ [0-9] ]]; then break; fi done echo "$p" } hash_password() { openssl passwd -6 -stdin <<<"$1"; } snippet_volid() { echo "${SNIPPET_STORAGE}:snippets/${1}.yaml"; } snippet_path() { pvesm path "$(snippet_volid "$1")" 2>/dev/null || echo "$SNIPPET_DIR/$1.yaml"; } snippet_storage_ok() { grep -qx "$SNIPPET_STORAGE" <<<"$(pvesm status --content snippets 2>/dev/null | awk 'NR>1{print $1}')" } # 读取 SSH 公钥:参数可以是文件路径或公钥内容 load_keys() { local s=${1:-} if [[ -z $s || $s == none ]]; then return 0; fi if [[ -f $s ]]; then grep -E '^(ssh-|ecdsa-|sk-)' "$s" || die "文件中没有有效公钥: $s" elif [[ $s =~ ^(ssh-|ecdsa-|sk-) ]]; then printf '%s\n' "$s" else die "无效的 SSH 公钥或文件: $s" fi } write_userdata() { local path=$1 user=$2 hash=$3 host=$4 keys=$5 k p c mkdir -p "$(dirname "$path")" { echo "#cloud-config" echo "# generated by $SCRIPT_NAME at $(date '+%F %T')" echo "hostname: $host" echo "manage_etc_hosts: true" if [[ -n $TIMEZONE ]]; then echo "timezone: $TIMEZONE"; fi if [[ $user == root ]]; then echo "user: root" echo "disable_root: false" else echo "user:" echo " name: $user" echo " shell: /bin/bash" echo " lock_passwd: false" echo " sudo: \"ALL=(ALL) NOPASSWD:ALL\"" fi echo "password: '$hash'" echo "chpasswd:" echo " expire: false" echo "ssh_pwauth: true" if [[ -n $keys ]]; then echo "ssh_authorized_keys:" while IFS= read -r k; do if [[ -n $k ]]; then echo " - '${k//\'/\'\'}'"; fi done <<<"$keys" fi echo "package_update: true" echo "package_upgrade: $PACKAGE_UPGRADE" if [[ -n $PACKAGES ]]; then echo "packages:" for p in $PACKAGES; do echo " - $p"; done fi # sshd_config.d 中的 00- 前缀文件优先级最高,可覆盖镜像自带的禁止密码登录配置 echo "write_files:" echo " - path: /etc/ssh/sshd_config.d/00-pve-vm.conf" echo " permissions: '0644'" echo " content: |" echo " PasswordAuthentication yes" if [[ $user == root ]]; then echo " PermitRootLogin yes"; fi echo "runcmd:" if [[ $user == root ]]; then cat <<'EOF' - sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin yes/g' /etc/ssh/sshd_config EOF fi cat <<'EOF' - sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication yes/g' /etc/ssh/sshd_config - systemctl restart ssh 2>/dev/null || systemctl restart sshd - systemctl enable --now qemu-guest-agent || true EOF for c in "${EXTRA_RUNCMD[@]}"; do c=${c//\\/\\\\}; c=${c//\"/\\\"} printf ' - "%s"\n' "$c" done echo 'final_message: "cloud-init finished after $UPTIME seconds"' } >"$path" chmod 600 "$path" } # apply_cloudinit <用户> <密码> <主机名> <公钥> [cloudinit盘存储] apply_cloudinit() { local id=$1 user=$2 pass=$3 host=$4 keys=$5 st=${6:-$VM_STORAGE} hash path snippet_storage_ok || die "存储 '$SNIPPET_STORAGE' 不存在或未启用 Snippets 内容类型(数据中心 -> 存储 -> 编辑 -> 内容 勾选“片段”)" hash=$(hash_password "$pass") path=$(snippet_path "$id") write_userdata "$path" "$user" "$hash" "$host" "$keys" ensure_cloudinit_drive "$id" "$st" qm set "$id" --cicustom "user=$(snippet_volid "$id")" >/dev/null qm cloudinit update "$id" >/dev/null 2>&1 || true info "cloud-init 配置已写入: $path" } # Windows:使用 PVE 原生 cloud-init(configdrive2),由系统内的 Cloudbase-Init 读取 # apply_cloudinit_win <用户> <密码> [cloudinit盘存储] apply_cloudinit_win() { local id=$1 user=$2 pass=$3 st=${4:-$VM_STORAGE} pve_ge 8 1 || warn "PVE 8.1 以下不支持向 Windows 注入密码,请升级 PVE" win_pw_ok "$pass" || warn "密码可能不满足 Windows 复杂度要求(至少 8 位,含大小写字母和数字),Cloudbase-Init 可能设置失败" ensure_cloudinit_drive "$id" "$st" local args=(--citype configdrive2 --ciuser "$user" --cipassword "$pass") if grep -q '^cicustom:' <<<"$(vm_cfg "$id")"; then args+=(--delete cicustom); fi qm set "$id" "${args[@]}" >/dev/null qm cloudinit update "$id" >/dev/null 2>&1 || true info "已写入 Windows cloud-init 配置(configdrive2,需模板内已安装 Cloudbase-Init)" } save_cred() { (( SAVE_CREDENTIALS )) || return 0 ( umask 077; printf '%s\t%s\t%s\t%s\t%s\n' "$(date '+%F %T')" "$1" "$2" "$3" "$4" >>"$CRED_FILE" ) } cred_user() { local u="" if [[ -f $CRED_FILE ]]; then u=$(awk -F'\t' -v id="$1" '$2==id{u=$4} END{print u}' "$CRED_FILE") fi echo "${u:-$(default_user "$1")}" } print_creds() { local id=$1 name=$2 user=$3 pass=$4 ip=${5:-} printf '\n%s' "$GRN$BLD" echo "================================================" printf ' VMID : %s\n 名称 : %s\n 用户 : %s\n 密码 : %s\n' "$id" "$name" "$user" "$pass" if [[ -n $ip ]]; then if is_windows "$id"; then printf ' IP : %s\n 登录 : 远程桌面 mstsc /v:%s\n' "$ip" "$ip" else printf ' IP : %s\n 登录 : ssh %s@%s\n' "$ip" "$user" "$ip"; fi fi echo "================================================" printf '%s' "$RST" if (( SAVE_CREDENTIALS )); then echo "凭据已记录到 $CRED_FILE"; fi } #============================================================================== # 命令:查看 #============================================================================== cmd_list() { local show_ip=0 only_tpl=0 a json rows for a in "$@"; do case $a in --ip) show_ip=1 ;; --templates) only_tpl=1 ;; *) die "未知参数: $a" ;; esac done json=$(pvesh get /cluster/resources --type vm --output-format json) rows=$(python3 - "$json" "$only_tpl" <<'PY' import json, sys vms = [v for v in json.loads(sys.argv[1]) if v.get("type") == "qemu"] if sys.argv[2] == "1": vms = [v for v in vms if v.get("template")] vms.sort(key=lambda v: int(v["vmid"])) def gb(b): return f"{b / 1024**3:.0f}G" if b else "-" for v in vms: up = int(v.get("uptime") or 0) ups = f"{up // 86400}d{up % 86400 // 3600}h{up % 3600 // 60}m" if up else "-" st = "template" if v.get("template") else v.get("status", "?") print("\t".join(str(x) for x in [ v["vmid"], v.get("name", "-"), st, v.get("node", "-"), v.get("maxcpu", "-"), int((v.get("maxmem") or 0) / 1024**2), gb(v.get("maxdisk") or 0), ups, v.get("tags") or "-"])) PY ) printf '%s%-6s %-22s %-9s %-12s %-4s %-7s %-6s %-10s' "$BLD" VMID NAME STATUS NODE CPU MEM_MB DISK UPTIME if (( show_ip )); then printf ' %-16s' IPv4; fi printf ' %s%s\n' TAGS "$RST" local vmid name st node cpu mem disk up tags color ipcol while IFS=$'\t' read -r vmid name st node cpu mem disk up tags; do [[ -z $vmid ]] && continue case $st in running) color=$GRN ;; stopped) color=$RED ;; template) color=$CYN ;; *) color=$YEL ;; esac printf '%-6s %-22s %s%-9s%s %-12s %-4s %-7s %-6s %-10s' "$vmid" "${name:0:22}" "$color" "$st" "$RST" "$node" "$cpu" "$mem" "$disk" "$up" if (( show_ip )); then ipcol="-" if [[ $st == running && $node == "$NODE" ]]; then ipcol=$(primary_ipv4 "$vmid"); ipcol=${ipcol:--}; fi printf ' %-16s' "$ipcol" fi printf ' %s\n' "$tags" done <<<"$rows" } cmd_info() { local id=${1:-} cfg st k v line json ask id "VMID" "" require_vm "$id" cfg=$(vm_cfg "$id"); st=$(vm_status "$id") title "虚拟机 $id - $(cfg_get "$cfg" name)" printf ' %-10s %s\n' "状态" "$st$(is_template "$id" && echo ' (模板)')" printf ' %-10s %s x %s 核, 类型 %s\n' "CPU" "$(cfg_def "$cfg" sockets 1)" "$(cfg_def "$cfg" cores 1)" "$(cfg_def "$cfg" cpu 默认)" printf ' %-10s %s MB (balloon: %s)\n' "内存" "$(cfg_def "$cfg" memory 512)" "$(cfg_def "$cfg" balloon 默认)" printf ' %-10s %s\n' "开机自启" "$(cfg_def "$cfg" onboot 0)" printf ' %-10s %s\n' "标签" "$(cfg_def "$cfg" tags -)" printf ' %-10s %s\n' "系统类型" "$(cfg_def "$cfg" ostype -)" printf ' %-10s %s\n' "cicustom" "$(cfg_def "$cfg" cicustom -)" printf ' %-10s %s / 用户 %s\n' "cloud-init" "$(cfg_def "$cfg" citype 默认)" "$(cfg_def "$cfg" ciuser -)" printf ' %-10s %s\n' "DNS" "$(cfg_def "$cfg" nameserver -)" echo " 磁盘:" grep -E '^(scsi|virtio|sata|ide|efidisk|tpmstate)[0-9]+: ' <<<"$cfg" | sed 's/^/ /' || true echo " 网络:" grep -E '^(net|ipconfig)[0-9]+: ' <<<"$cfg" | sed 's/^/ /' || true if [[ $st == running ]]; then json=$(pvesh get "/nodes/$NODE/qemu/$id/status/current" --output-format json 2>/dev/null || echo '{}') python3 - "$json" <<'PY' import json, sys d = json.loads(sys.argv[1]) h = lambda b: f"{(b or 0) / 1024**3:.2f}G" up = int(d.get("uptime") or 0) print(f" 运行时间 {up // 86400}天 {up % 86400 // 3600}时 {up % 3600 // 60}分") print(f" CPU 使用 {(d.get('cpu') or 0) * 100:.1f}% ({d.get('cpus')} vCPU)") print(f" 内存使用 {h(d.get('mem'))} / {h(d.get('maxmem'))}") PY if agent_ok "$id"; then json=$(qm guest cmd "$id" get-osinfo 2>/dev/null || echo '{}') python3 - "$json" <<'PY' import json, sys d = json.loads(sys.argv[1]) print(f" 操作系统 {d.get('pretty-name', '-')} (内核 {d.get('kernel-release', '-')})") PY echo " IP 地址:" get_ips "$id" | awk -F'\t' '{printf " %-12s %-5s %s\n", $1, $2, $3}' || true else warn "guest agent 不可用(未安装或尚未启动)" fi fi echo " 快照:" qm listsnapshot "$id" 2>/dev/null | sed 's/^/ /' || true if [[ -f $(snippet_path "$id") ]]; then echo " cloud-init 片段: $(snippet_path "$id")"; fi if [[ -f $CRED_FILE ]] && grep -q $'\t'"$id"$'\t' "$CRED_FILE"; then echo " 已记录凭据: $SCRIPT_NAME creds $id"; fi } cmd_ip() { local id=${1:-} ask id "VMID" "" require_vm "$id" [[ $(vm_status "$id") == running ]] || die "VM $id 未运行" agent_ok "$id" || die "guest agent 不可用" get_ips "$id" | awk -F'\t' '{printf "%-12s %-5s %s\n", $1, $2, $3}' } cmd_creds() { local id=${1:-} t v n u p [[ -f $CRED_FILE ]] || die "暂无凭据记录 ($CRED_FILE)" printf '%s%-20s %-8s %-20s %-10s %s%s\n' "$BLD" 时间 VMID 名称 用户 密码 "$RST" while IFS=$'\t' read -r t v n u p; do if [[ -z $id || $v == "$id" ]]; then printf '%-20s %-8s %-20s %-10s %s\n' "$t" "$v" "$n" "$u" "$p"; fi done <"$CRED_FILE" } #============================================================================== # 命令:创建 #============================================================================== optarg() { [[ $# -ge 2 && -n $2 ]] || die "选项 $1 需要一个参数"; } cmd_create() { local id="" name="" tpl="" mode="" storage="" cores="" memory="" disk="" bridge="" vlan="" \ ip="" gw="" ip6="" gw6="" dns="" sshkey="" password="" user="" tags="" onboot="" start="" wait="" while (($#)); do case $1 in --id) optarg "$@"; id=$2; shift 2 ;; --name) optarg "$@"; name=$2; shift 2 ;; --template) optarg "$@"; tpl=$2; shift 2 ;; --mode) optarg "$@"; mode=$2; shift 2 ;; --storage) optarg "$@"; storage=$2; shift 2 ;; --cores) optarg "$@"; cores=$2; shift 2 ;; --memory) optarg "$@"; memory=$2; shift 2 ;; --disk) optarg "$@"; disk=$2; shift 2 ;; --bridge) optarg "$@"; bridge=$2; shift 2 ;; --vlan) optarg "$@"; vlan=$2; shift 2 ;; --ip) optarg "$@"; ip=$2; shift 2 ;; --gw) optarg "$@"; gw=$2; shift 2 ;; --ip6) optarg "$@"; ip6=$2; shift 2 ;; --gw6) optarg "$@"; gw6=$2; shift 2 ;; --dns) optarg "$@"; dns=$2; shift 2 ;; --sshkey) optarg "$@"; sshkey=$2; shift 2 ;; --password) optarg "$@"; password=$2; shift 2 ;; --user) optarg "$@"; user=$2; shift 2 ;; --tags) optarg "$@"; tags=$2; shift 2 ;; --onboot) optarg "$@"; onboot=$2; shift 2 ;; --start) optarg "$@"; start=$2; shift 2 ;; --wait) optarg "$@"; wait=$2; shift 2 ;; -h|--help) usage; return 0 ;; *) die "未知参数: $1" ;; esac done if [[ -z $tpl && -z $DEFAULT_TEMPLATE ]] && (( ! ASSUME_YES )); then title "可用模板"; cmd_list --templates; fi ask tpl "模板 VMID" "$DEFAULT_TEMPLATE" require_vm "$tpl" local win=0 if is_windows "$tpl"; then win=1 info "检测到 Windows 模板,将通过 Cloudbase-Init(configdrive2)设置密码/主机名/IP" fi ask id "新虚拟机 VMID" "$(next_id)" valid_vmid "$id" || die "无效 VMID: $id" id_free "$id" || die "VMID $id 已被占用" ask name "主机名" "$(pick "$win" "win-$id" "vm-$id")" valid_name "$name" || die "主机名只能包含字母、数字、- 和 .: $name" if (( win )) && (( ${#name} > 15 )); then warn "Windows 计算机名最多 15 个字符,超出部分会被截断"; fi ask user "登录用户" "$(default_user "$tpl")" ask cores "CPU 核心数" "$(pick "$win" "$WIN_CORES" "$DEFAULT_CORES")" ask memory "内存 (MB)" "$(pick "$win" "$WIN_MEMORY" "$DEFAULT_MEMORY")" ask disk "系统盘大小(如 20G / +10G,none 不调整)" "$(pick "$win" none "$DEFAULT_DISK")" if is_template "$tpl"; then ask mode "克隆方式 full/linked" "$DEFAULT_CLONE_MODE"; else mode=full; fi if [[ $mode == full ]]; then ask storage "目标存储" "$VM_STORAGE"; fi ask bridge "网桥" "$DEFAULT_BRIDGE" ask vlan "VLAN ID(none 不设置)" "none" ask ip "IPv4(dhcp 或 CIDR,如 192.168.1.10/24)" "dhcp" if [[ $ip != dhcp ]]; then ask gw "IPv4 网关" "$(guess_gw "$ip")"; fi ask ip6 "IPv6(none / auto / dhcp / CIDR)" "none" ask dns "DNS(空格分隔,none 继承模板)" "$DEFAULT_DNS" if (( ! win )); then ask sshkey "SSH 公钥文件或内容(none 跳过)" "${DEFAULT_SSH_KEY_FILE:-none}"; fi ask_secret password "$user 密码(留空随机生成)" ask tags "标签(分号分隔,none 不设置)" "none" ask onboot "开机自启 0/1" "0" ask start "创建后立即启动 0/1" "1" if [[ $start == 1 ]]; then ask wait "等待启动并获取 IP 0/1" "1"; fi disk=$(norm_empty "$disk"); vlan=$(norm_empty "$vlan"); ip6=$(norm_empty "$ip6") dns=$(norm_empty "$dns"); tags=$(norm_empty "$tags"); sshkey=$(norm_empty "$sshkey") is_int "$cores" && (( cores > 0 )) || die "无效 CPU 核心数: $cores" is_int "$memory" && (( memory >= 256 )) || die "无效内存: $memory" [[ -z $disk ]] || valid_size "$disk" || die "无效磁盘大小: $disk" [[ $mode == full || $mode == linked ]] || die "克隆方式只能是 full 或 linked" [[ -z $vlan ]] || { is_int "$vlan" && (( vlan >= 1 && vlan <= 4094 )); } || die "无效 VLAN: $vlan" if [[ $ip != dhcp ]]; then valid_cidr4 "$ip" || die "无效 IPv4 CIDR: $ip" [[ -z $gw ]] || valid_ip4 "$gw" || die "无效网关: $gw" fi [[ -z $ip6 ]] || valid_ip6cfg "$ip6" || die "无效 IPv6 配置: $ip6" if (( win )); then [[ $user =~ ^[A-Za-z0-9._-]{1,20}$ ]] || die "无效 Windows 用户名: $user" else [[ $user =~ ^[a-z_][a-z0-9_-]*$ ]] || die "无效用户名: $user" fi ip link show "$bridge" &>/dev/null || die "网桥 $bridge 不存在" local keys gen=0 keys=$(load_keys "$sshkey") if [[ -z $password ]]; then password=$(gen_password); gen=1; fi title "创建确认" cat < $id ($name) [$mode] ..." local cargs=(--name "$name") if [[ $mode == full ]]; then cargs+=(--full 1) if [[ -n $storage ]]; then cargs+=(--storage "$storage"); fi else cargs+=(--full 0) fi qm clone "$tpl" "$id" "${cargs[@]}" CLEANUP_HINT="$SCRIPT_NAME -y delete $id" local sargs=(--cores "$cores" --memory "$memory" --onboot "$onboot" --net0 "$(build_net0 "$id" "$bridge" "$vlan")" --ipconfig0 "$(build_ipconfig "$ip" "$gw" "$ip6" "$gw6")") grep -q '^agent:' <<<"$(vm_cfg "$id")" || sargs+=(--agent enabled=1) [[ -n $dns ]] && sargs+=(--nameserver "$dns") [[ -n $DEFAULT_SEARCHDOMAIN ]] && sargs+=(--searchdomain "$DEFAULT_SEARCHDOMAIN") [[ -n $tags ]] && sargs+=(--tags "$tags") qm set "$id" "${sargs[@]}" >/dev/null info "已设置 CPU/内存/网络" if [[ -n $disk ]]; then resize_disk "$id" "$(boot_disk "$id")" "$disk"; fi if (( win )); then apply_cloudinit_win "$id" "$user" "$password" "${storage:-$VM_STORAGE}" else apply_cloudinit "$id" "$user" "$password" "$name" "$keys" "${storage:-$VM_STORAGE}" fi save_cred "$id" "$name" "$user" "$password" CLEANUP_HINT="" log "CREATE vmid=$id name=$name tpl=$tpl ip=$ip" local ipaddr="" if [[ $start == 1 ]]; then qm start "$id" info "VM $id 已启动" if [[ $wait == 1 ]] && wait_agent "$id"; then ipaddr=$(wait_ipv4 "$id"); fi fi if [[ -z $ipaddr && $ip != dhcp ]]; then ipaddr=${ip%/*}; fi print_creds "$id" "$name" "$user" "$password" "$ipaddr" } cmd_batch() { local count="" prefix="" start_id="" ip_start="" tpl="" rest=() while (($#)); do case $1 in --count) optarg "$@"; count=$2; shift 2 ;; --prefix) optarg "$@"; prefix=$2; shift 2 ;; --start-id) optarg "$@"; start_id=$2; shift 2 ;; --ip-start) optarg "$@"; ip_start=$2; shift 2 ;; --template) optarg "$@"; tpl=$2; shift 2 ;; --id|--name|--ip) die "批量模式请使用 --start-id / --prefix / --ip-start" ;; --*) optarg "$@"; rest+=("$1" "$2"); shift 2 ;; *) die "未知参数: $1" ;; esac done if [[ -z $tpl && -z $DEFAULT_TEMPLATE ]] && (( ! ASSUME_YES )); then title "可用模板"; cmd_list --templates; fi ask tpl "模板 VMID" "$DEFAULT_TEMPLATE" require_vm "$tpl" ask count "创建数量" "2" is_int "$count" && (( count >= 1 && count <= 100 )) || die "数量需在 1-100 之间" ask prefix "名称前缀(最终名称: 前缀-VMID)" "vm" ask start_id "起始 VMID" "$(next_id)" valid_vmid "$start_id" || die "无效 VMID: $start_id" ask ip_start "起始 IPv4(dhcp 或 CIDR,后续自动 +1)" "dhcp" [[ $ip_start == dhcp ]] || valid_cidr4 "$ip_start" || die "无效 IPv4: $ip_start" local ids=() ips=() id=$start_id i for (( i = 0; i < count; i++ )); do while ! id_free "$id"; do id=$((id + 1)); done ids+=("$id") if [[ $ip_start == dhcp ]]; then ips+=(dhcp); else ips+=("$(ip_inc "$ip_start" "$i")"); fi id=$((id + 1)) done title "批量创建计划" for (( i = 0; i < count; i++ )); do printf ' %-8s %-24s %s\n' "${ids[i]}" "$prefix-${ids[i]}" "${ips[i]}"; done [[ ${#rest[@]} -gt 0 ]] && echo " 其他参数: ${rest[*]}" confirm "确认批量创建 $count 台?" || die "已取消" local ok=() fail=() for (( i = 0; i < count; i++ )); do title "[$((i + 1))/$count] 创建 ${ids[i]}" if bash "$SELF" -y create --template "$tpl" --id "${ids[i]}" --name "$prefix-${ids[i]}" \ --ip "${ips[i]}" --wait 0 "${rest[@]}"; then ok+=("${ids[i]}") else fail+=("${ids[i]}") fi done title "批量创建结果" info "成功: ${ok[*]:-无}" [[ ${#fail[@]} -eq 0 ]] || warn "失败: ${fail[*]}" if (( SAVE_CREDENTIALS )) && [[ ${#ok[@]} -gt 0 ]]; then local v for v in "${ok[@]}"; do cmd_creds "$v" | tail -n 1; done fi } # 为已有虚拟机生成独立 cloud-init 片段与密码(原 init-vm.sh 功能) cmd_init() { local id="" user="" password="" sshkey="" host="" while (($#)); do case $1 in --user) optarg "$@"; user=$2; shift 2 ;; --password) optarg "$@"; password=$2; shift 2 ;; --sshkey) optarg "$@"; sshkey=$2; shift 2 ;; --hostname) optarg "$@"; host=$2; shift 2 ;; -*) die "未知参数: $1" ;; *) id=$1; shift ;; esac done ask id "VMID" "" require_vm "$id" is_template "$id" && die "VM $id 是模板,请先克隆" if is_windows "$id"; then ask user "登录用户(需与 Cloudbase-Init 配置的 username 一致)" "$DEFAULT_WIN_USER" ask_secret password "$user 密码(留空随机生成)" [[ -n $password ]] || password=$(gen_password) apply_cloudinit_win "$id" "$user" "$password" if ! grep -q '^ipconfig0:' <<<"$(vm_cfg "$id")"; then qm set "$id" --ipconfig0 ip=dhcp >/dev/null info "未配置 ipconfig0,已设置为 DHCP" fi save_cred "$id" "$(vm_name "$id")" "$user" "$password" log "INIT(win) vmid=$id" print_creds "$id" "$(vm_name "$id")" "$user" "$password" warn "Cloudbase-Init 在下次启动时应用(主机名取 VM 名称);运行中的系统可用 $SCRIPT_NAME passwd $id 立即改密码" return 0 fi ask user "登录用户" "$DEFAULT_USER" ask host "主机名" "$(vm_name "$id")" ask sshkey "SSH 公钥文件或内容(none 跳过)" "${DEFAULT_SSH_KEY_FILE:-none}" ask_secret password "$user 密码(留空随机生成)" sshkey=$(norm_empty "$sshkey") local keys keys=$(load_keys "$sshkey") [[ -n $password ]] || password=$(gen_password) if [[ -f $(snippet_path "$id") ]]; then confirm "已存在 $(snippet_path "$id"),覆盖?" || die "已取消" fi apply_cloudinit "$id" "$user" "$password" "$host" "$keys" if ! grep -q '^ipconfig0:' <<<"$(vm_cfg "$id")"; then qm set "$id" --ipconfig0 ip=dhcp >/dev/null info "未配置 ipconfig0,已设置为 DHCP" fi save_cred "$id" "$host" "$user" "$password" log "INIT vmid=$id" print_creds "$id" "$host" "$user" "$password" if [[ $(vm_status "$id") == running ]]; then warn "VM 正在运行:cloud-init 只在首次启动(或实例变化)时应用配置。" warn "已运行的系统请用: $SCRIPT_NAME passwd $id 通过 guest agent 直接改密码" fi } cmd_clone() { local src="" dst="" name="" full="" storage="" while (($#)); do case $1 in --full) optarg "$@"; full=$2; shift 2 ;; --storage) optarg "$@"; storage=$2; shift 2 ;; -*) die "未知参数: $1" ;; *) if [[ -z $src ]]; then src=$1; elif [[ -z $dst ]]; then dst=$1; else name=$1; fi; shift ;; esac done ask src "源 VMID" "" require_vm "$src" ask dst "新 VMID" "$(next_id)" valid_vmid "$dst" && id_free "$dst" || die "VMID $dst 无效或已占用" ask name "新名称" "$(vm_name "$src")-clone" valid_name "$name" || die "无效名称: $name" if is_template "$src"; then ask full "完整克隆 1 / 链接克隆 0" "1"; else full=1; fi [[ $full == 1 ]] && ask storage "目标存储(留空同源)" "" local args=(--name "$name" --full "$full") [[ -n $storage ]] && args+=(--storage "$storage") info "克隆 $src -> $dst ..." qm clone "$src" "$dst" "${args[@]}" info "克隆完成: $dst ($name)" if grep -q '^cicustom:' <<<"$(vm_cfg "$dst")"; then warn "新 VM 仍引用源 VM 的 cloud-init 片段" if confirm "为 $dst 生成独立的 cloud-init 配置和密码?"; then cmd_init "$dst"; fi fi } #============================================================================== # 命令:电源 / 删除 #============================================================================== read_ids() { # read_ids <数组名> 参数... local __n=$1 __s=""; shift if [[ $# -gt 0 ]]; then eval "$__n=(\"\$@\")"; return 0; fi ask __s "VMID(多个用空格分隔)" "" [[ -n $__s ]] || die "未指定 VMID" read -r -a "$__n" <<<"$__s" } cmd_power() { local act=$1 id ids=(); shift read_ids ids "$@" for id in "${ids[@]}"; do require_vm "$id" case $act in start) qm start "$id" ;; shutdown) info "正在关机 $id(超时 120s 后强制停止)..."; qm shutdown "$id" --timeout 120 --forceStop 1 ;; reboot) qm reboot "$id" ;; stop) confirm "强制停止 $id ($(vm_name "$id"))? 相当于拔电源" || continue; qm stop "$id" ;; reset) confirm "硬重置 $id ($(vm_name "$id"))?" || continue; qm reset "$id" ;; suspend) qm suspend "$id" ;; resume) qm resume "$id" ;; esac info "$act $id 完成" log "POWER $act vmid=$id" done } cmd_delete() { local force=0 id ids=() args=() a for a in "$@"; do if [[ $a == --force ]]; then force=1; else args+=("$a"); fi; done read_ids ids "${args[@]}" for id in "${ids[@]}"; do require_vm "$id" local cfg name path refs cfg=$(vm_cfg "$id"); name=$(cfg_get "$cfg" name) if grep -q '^protection: 1' <<<"$cfg"; then if (( force )); then qm set "$id" --protection 0 >/dev/null else warn "VM $id 已开启删除保护,跳过(使用 --force 强制)"; continue; fi fi printf '%s即将永久删除 VM %s (%s) 及其所有磁盘、快照!%s\n' "$RED$BLD" "$id" "$name" "$RST" confirm "确认删除 $id?" || { info "跳过 $id"; continue; } if [[ $(vm_status "$id") != stopped ]]; then info "停止 $id ..."; qm stop "$id" || true; fi qm destroy "$id" --purge 1 --destroy-unreferenced-disks 1 path=$(snippet_path "$id") if [[ -f $path ]]; then refs=$(grep -l "snippets/$id.yaml" /etc/pve/nodes/*/qemu-server/*.conf 2>/dev/null || true) if [[ -n $refs ]]; then warn "片段 $path 仍被其他 VM 引用,保留: $(echo $refs)" else rm -f "$path"; info "已删除片段 $path"; fi fi info "VM $id 已删除" log "DELETE vmid=$id name=$name" done } #============================================================================== # 命令:配置修改 #============================================================================== cmd_set() { local id="" args=() while (($#)); do case $1 in --cores|--sockets|--memory|--balloon|--name|--onboot|--tags|--cpu|--protection) optarg "$@"; args+=("$1" "$2"); shift 2 ;; --desc) optarg "$@"; args+=(--description "$2"); shift 2 ;; -*) die "未知参数: $1" ;; *) id=$1; shift ;; esac done ask id "VMID" "" require_vm "$id" if [[ ${#args[@]} -eq 0 ]]; then local cfg cores memory name onboot cfg=$(vm_cfg "$id") ask cores "CPU 核心数" "$(cfg_def "$cfg" cores 1)" ask memory "内存 (MB)" "$(cfg_def "$cfg" memory 512)" ask name "名称" "$(cfg_get "$cfg" name)" ask onboot "开机自启 0/1" "$(cfg_def "$cfg" onboot 0)" args=(--cores "$cores" --memory "$memory" --name "$name" --onboot "$onboot") fi qm set "$id" "${args[@]}" >/dev/null info "VM $id 配置已更新: ${args[*]}" log "SET vmid=$id ${args[*]}" local pending pending=$(qm pending "$id" 2>/dev/null | grep -E '^(new|del) ' || true) if [[ -n $pending ]]; then warn "以下修改需重启(关机再开机)后生效:" echo "$pending" | sed 's/^/ /' fi } cmd_resize() { local id=${1:-} size=${2:-} disk=${3:-} ask id "VMID" "" require_vm "$id" [[ -n $disk ]] || disk=$(boot_disk "$id") || die "找不到系统盘" ask size "$disk 新大小(如 50G 或 +10G)" "" resize_disk "$id" "$disk" "$size" log "RESIZE vmid=$id disk=$disk size=$size" if [[ $(vm_status "$id") == running ]] && agent_ok "$id" && is_windows "$id"; then info "尝试在 Windows 内扩展 C 盘..." local ps read -r -d '' ps <<'PS' || true Update-HostStorageCache $max = (Get-PartitionSupportedSize -DriveLetter C).SizeMax $cur = (Get-Partition -DriveLetter C).Size if ($max -gt ($cur + 1MB)) { Resize-Partition -DriveLetter C -Size $max; 'C: extended' } else { 'C: nothing to extend (a recovery partition right after C: may be blocking it)' } Get-Volume -DriveLetter C | Format-Table DriveLetter, @{n='SizeGB';e={[math]::Round($_.Size/1GB,1)}}, @{n='FreeGB';e={[math]::Round($_.SizeRemaining/1GB,1)}} -AutoSize | Out-String PS if win_ps "$id" "${ps//$'\n'/; }"; then info "执行完成" else warn "自动扩展失败,请在 Windows「磁盘管理」中手动扩展 C 盘" fi elif [[ $(vm_status "$id") == running ]] && agent_ok "$id"; then info "尝试在虚拟机内扩展分区和文件系统..." if agent_exec "$id" sh -c 'cloud-init single --name growpart >/dev/null 2>&1; cloud-init single --name resizefs >/dev/null 2>&1; df -h /'; then info "在线扩容完成" else warn "自动扩展失败,请登录系统手动执行 growpart / resize2fs / xfs_growfs,或直接重启" fi else if is_windows "$id"; then info "开机后在「磁盘管理」中扩展 C 盘(装了 Cloudbase-Init 的话启动时会自动扩展)" else info "下次启动时 cloud-init 会自动扩展根分区"; fi fi } cmd_adddisk() { local id="" size="" storage="" i slot while (($#)); do case $1 in --storage) optarg "$@"; storage=$2; shift 2 ;; -*) die "未知参数: $1" ;; *) if [[ -z $id ]]; then id=$1; else size=$1; fi; shift ;; esac done ask id "VMID" "" require_vm "$id" ask size "数据盘大小(GB,整数)" "50" size=${size%[Gg]} is_int "$size" && (( size > 0 )) || die "无效大小: $size" ask storage "存储" "$VM_STORAGE" local cfg; cfg=$(vm_cfg "$id") for i in $(seq 1 30); do if ! grep -q "^scsi$i: " <<<"$cfg"; then slot=scsi$i; break; fi done [[ -n ${slot:-} ]] || die "没有可用的 scsi 槽位" qm set "$id" --"$slot" "$storage:$size,discard=on,ssd=1" >/dev/null info "已添加数据盘 $slot: ${size}G ($storage),在系统内需自行分区格式化挂载" log "ADDDISK vmid=$id $slot ${size}G" } cmd_net() { local id="" bridge="" vlan="" ip="" gw="" ip6="" gw6="" dns="" changed=0 while (($#)); do case $1 in --bridge) optarg "$@"; bridge=$2; shift 2 ;; --vlan) optarg "$@"; vlan=$2; shift 2 ;; --ip) optarg "$@"; ip=$2; shift 2 ;; --gw) optarg "$@"; gw=$2; shift 2 ;; --ip6) optarg "$@"; ip6=$2; shift 2 ;; --gw6) optarg "$@"; gw6=$2; shift 2 ;; --dns) optarg "$@"; dns=$2; shift 2 ;; -*) die "未知参数: $1" ;; *) id=$1; shift ;; esac done ask id "VMID" "" require_vm "$id" local cfg net0 ipc cfg=$(vm_cfg "$id"); net0=$(cfg_get "$cfg" net0); ipc=$(cfg_get "$cfg" ipconfig0) echo "当前 net0 : ${net0:-无}" echo "当前 ipconfig0: ${ipc:-无}" echo "当前 DNS : $(cfg_def "$cfg" nameserver -)" local cur_vlan cur_ip cur_gw cur_ip6 cur_gw6 cur_vlan=$(kv_get "$net0" tag); cur_ip=$(kv_get "$ipc" ip); cur_gw=$(kv_get "$ipc" gw) cur_ip6=$(kv_get "$ipc" ip6); cur_gw6=$(kv_get "$ipc" gw6) ask bridge "网桥" "$(kv_get "$net0" bridge)" ask vlan "VLAN(none 清除)" "${cur_vlan:-none}" ask ip "IPv4(dhcp 或 CIDR)" "${cur_ip:-dhcp}" if [[ $ip != dhcp ]]; then ask gw "IPv4 网关" "${cur_gw:-$(guess_gw "$ip")}"; fi ask ip6 "IPv6(none / auto / dhcp / CIDR)" "${cur_ip6:-none}" if [[ $ip6 =~ / ]]; then ask gw6 "IPv6 网关(none 不设置)" "${cur_gw6:-none}"; fi ask dns "DNS(空格分隔,none 不修改)" "$(cfg_def "$cfg" nameserver none)" vlan=$(norm_empty "$vlan"); ip6=$(norm_empty "$ip6"); gw6=$(norm_empty "$gw6"); dns=$(norm_empty "$dns") ip link show "$bridge" &>/dev/null || die "网桥 $bridge 不存在" [[ -z $vlan ]] || { is_int "$vlan" && (( vlan >= 1 && vlan <= 4094 )); } || die "无效 VLAN: $vlan" if [[ $ip != dhcp ]]; then valid_cidr4 "$ip" || die "无效 IPv4: $ip"; [[ -z $gw ]] || valid_ip4 "$gw" || die "无效网关: $gw"; fi [[ -z $ip6 ]] || valid_ip6cfg "$ip6" || die "无效 IPv6: $ip6" local new_net new_ipc new_net=$(build_net0 "$id" "$bridge" "$vlan") new_ipc=$(build_ipconfig "$ip" "$gw" "$ip6" "$gw6") echo "新 net0 : $new_net" echo "新 ipconfig0: $new_ipc" confirm "确认修改?" || die "已取消" if [[ $new_net != "$net0" ]]; then qm set "$id" --net0 "$new_net" >/dev/null; info "net0 已更新(网桥/VLAN 立即生效)"; fi if [[ $new_ipc != "$ipc" ]]; then qm set "$id" --ipconfig0 "$new_ipc" >/dev/null; changed=1; fi if [[ -n $dns && $dns != "$(cfg_get "$cfg" nameserver)" ]]; then qm set "$id" --nameserver "$dns" >/dev/null; changed=1; fi log "NET vmid=$id net0=$new_net ipconfig0=$new_ipc dns=$dns" if (( changed )); then qm cloudinit update "$id" >/dev/null 2>&1 || true info "IP/DNS 已写入 cloud-init" if [[ $(vm_status "$id") == running ]]; then warn "IP 配置需重启后由 cloud-init 应用" if confirm "现在重启 $id?"; then qm reboot "$id"; info "已发送重启"; fi fi fi } cmd_passwd() { local id="" user="" password="" while (($#)); do case $1 in --user) optarg "$@"; user=$2; shift 2 ;; --password) optarg "$@"; password=$2; shift 2 ;; -*) die "未知参数: $1" ;; *) if [[ -z $id ]]; then id=$1; else password=$1; fi; shift ;; esac done ask id "VMID" "" require_vm "$id" ask user "用户" "$(cred_user "$id")" ask_secret password "$user 新密码(留空随机生成)" [[ -n $password ]] || password=$(gen_password) local applied=0 path win=0 if is_windows "$id"; then win=1 win_pw_ok "$password" || warn "密码可能不满足 Windows 复杂度要求(至少 8 位,含大小写字母和数字)" fi if [[ $(vm_status "$id") == running ]] && agent_ok "$id"; then if (( win )); then if pvesh create "/nodes/$NODE/qemu/$id/agent/set-user-password" \ --username "$user" --password "$password" >/dev/null; then applied=1; fi elif printf '%s:%s\n' "$user" "$password" | agent_exec_stdin "$id" chpasswd; then applied=1 fi if (( applied )); then info "已通过 guest agent 修改 $user 的密码(立即生效)" else warn "通过 guest agent 修改失败"; fi fi if (( win )); then if grep -Eq '^(citype|ciuser|cipassword):' <<<"$(vm_cfg "$id")"; then qm set "$id" --cipassword "$password" >/dev/null qm cloudinit update "$id" >/dev/null 2>&1 || true info "已同步更新 cloud-init 密码" fi else path=$(snippet_path "$id") if [[ -f $path ]]; then sed -i "s|^password: .*|password: '$(hash_password "$password")'|" "$path" qm cloudinit update "$id" >/dev/null 2>&1 || true info "已同步更新 cloud-init 片段" fi fi if (( ! applied )); then warn "VM 未运行或 guest agent 不可用:新密码只写入了 cloud-init,仅在 cloud-init 重新初始化时生效" warn "建议开机且 agent 就绪后再执行一次: $SCRIPT_NAME passwd $id" fi save_cred "$id" "$(vm_name "$id")" "$user" "$password" log "PASSWD vmid=$id user=$user applied=$applied" print_creds "$id" "$(vm_name "$id")" "$user" "$password" "$(primary_ipv4 "$id")" } cmd_sshkey() { local id="" key="" user="" while (($#)); do case $1 in --user) optarg "$@"; user=$2; shift 2 ;; -*) die "未知参数: $1" ;; *) if [[ -z $id ]]; then id=$1; else key=$1; fi; shift ;; esac done ask id "VMID" "" require_vm "$id" is_windows "$id" && die "Windows 虚拟机不支持此命令" ask key "公钥文件路径或公钥内容" "${DEFAULT_SSH_KEY_FILE:-/root/.ssh/authorized_keys}" ask user "目标用户" "$(cred_user "$id")" local keys; keys=$(load_keys "$key") [[ $(vm_status "$id") == running ]] && agent_ok "$id" || die "需要 VM 运行且 guest agent 可用" # shellcheck disable=SC2016 printf '%s\n' "$keys" | agent_exec_stdin "$id" sh -c ' set -e; u="$1"; h=$(getent passwd "$u" | cut -d: -f6); [ -n "$h" ] || { echo "用户 $u 不存在" >&2; exit 1; } mkdir -p "$h/.ssh"; touch "$h/.ssh/authorized_keys" while IFS= read -r k; do grep -qxF "$k" "$h/.ssh/authorized_keys" || echo "$k" >> "$h/.ssh/authorized_keys"; done chmod 700 "$h/.ssh"; chmod 600 "$h/.ssh/authorized_keys"; chown -R "$u": "$h/.ssh"' sh "$user" info "已为 $user 添加 $(wc -l <<<"$keys") 个公钥" log "SSHKEY vmid=$id user=$user" } #============================================================================== # 命令:guest agent / 登录 #============================================================================== cmd_exec() { local id="" shell="" cmd="" while (($#)); do case $1 in --cmd) shell=cmd; shift ;; --ps|--powershell) shell=ps; shift ;; *) if [[ -z $id ]]; then id=$1; shift; else cmd="$*"; break; fi ;; esac done ask id "VMID" "" require_vm "$id" ask cmd "要执行的命令" "" [[ -n $cmd ]] || die "命令为空" agent_ok "$id" || die "guest agent 不可用" log "EXEC vmid=$id cmd=$cmd" local rc=0 if ! is_windows "$id"; then agent_exec "$id" sh -c "$cmd" || rc=$? elif [[ $shell == cmd ]]; then agent_exec "$id" cmd.exe /c "chcp 65001 >nul & $cmd" || rc=$? else win_ps "$id" "$cmd" || rc=$? fi exit "$rc" } cmd_ssh() { local id=${1:-} user=${2:-} ip ask id "VMID" "" require_vm "$id" [[ -n $user ]] || user=$(cred_user "$id") ip=$(primary_ipv4 "$id") if [[ -z $ip ]]; then ip=$(kv_get "$(cfg_get "$(vm_cfg "$id")" ipconfig0)" ip); ip=${ip%/*} [[ $ip != dhcp && -n $ip ]] || die "无法获取 VM $id 的 IP(guest agent 不可用且为 DHCP)" fi if is_windows "$id"; then info "Windows 请用远程桌面连接: mstsc /v:$ip 用户: $user" confirm "仍要尝试 SSH(需系统内已启用 OpenSSH Server)?" || return 0 fi info "ssh $user@$ip" exec ssh -o StrictHostKeyChecking=accept-new "$user@$ip" } cmd_console() { local id=${1:-} ask id "VMID" "" require_vm "$id" grep -q '^serial0:' <<<"$(vm_cfg "$id")" || die "VM $id 没有串口 serial0,请使用网页 noVNC 控制台(或 qm set $id --serial0 socket 后重启)" info "进入串口控制台,按 Ctrl+O 退出" exec qm terminal "$id" } cmd_wait() { local id=${1:-} ask id "VMID" "" require_vm "$id" wait_agent "$id" || die "guest agent 未就绪" get_ips "$id" | awk -F'\t' '{printf "%-12s %-5s %s\n", $1, $2, $3}' || true } #============================================================================== # 命令:快照 / 备份 / 恢复 / 迁移 #============================================================================== cmd_snap() { local action=${1:-} id=${2:-} name=${3:-} desc="" vmstate=0 [[ $# -gt 3 ]] && shift 3 || set -- while (($#)); do case $1 in --desc) optarg "$@"; desc=$2; shift 2 ;; --vmstate) vmstate=1; shift ;; *) die "未知参数: $1" ;; esac done ask action "操作 list/create/rollback/delete" "list" ask id "VMID" "" require_vm "$id" case $action in list) qm listsnapshot "$id" ;; create) ask name "快照名称" "snap-$(date +%Y%m%d-%H%M%S)" valid_snap "$name" || die "快照名需字母开头,仅含字母数字 _ -" local args=(); [[ -n $desc ]] && args+=(--description "$desc") (( vmstate )) && args+=(--vmstate 1) qm snapshot "$id" "$name" "${args[@]}" info "快照 $name 已创建"; log "SNAP create vmid=$id $name" ;; rollback) qm listsnapshot "$id" ask name "回滚到快照" "" local was; was=$(vm_status "$id") confirm "回滚 $id 到 $name? 当前状态将丢失" || die "已取消" qm rollback "$id" "$name" info "已回滚到 $name"; log "SNAP rollback vmid=$id $name" if [[ $was == running && $(vm_status "$id") != running ]] && confirm "VM 回滚前在运行,现在启动?"; then qm start "$id"; fi ;; delete) qm listsnapshot "$id" ask name "删除快照" "" confirm "删除快照 $name?" || die "已取消" qm delsnapshot "$id" "$name" info "快照 $name 已删除"; log "SNAP delete vmid=$id $name" ;; *) die "未知快照操作: $action" ;; esac } cmd_backup() { local ids=() storage="" mode="" compress="zstd" while (($#)); do case $1 in --storage) optarg "$@"; storage=$2; shift 2 ;; --mode) optarg "$@"; mode=$2; shift 2 ;; --compress) optarg "$@"; compress=$2; shift 2 ;; -*) die "未知参数: $1" ;; *) ids+=("$1"); shift ;; esac done read_ids ids "${ids[@]}" local id; for id in "${ids[@]}"; do require_vm "$id"; done ask storage "备份存储" "$BACKUP_STORAGE" ask mode "备份模式 snapshot/suspend/stop" "snapshot" info "开始备份: ${ids[*]} -> $storage ($mode, $compress)" vzdump "${ids[@]}" --storage "$storage" --mode "$mode" --compress "$compress" --notes-template '{{guestname}}' info "备份完成"; log "BACKUP ${ids[*]} storage=$storage" } cmd_backups() { local id=${1:-} storage=${2:-$BACKUP_STORAGE} if [[ -n $id ]]; then pvesm list "$storage" --content backup --vmid "$id" else pvesm list "$storage" --content backup; fi } cmd_restore() { local archive="" id="" storage="" force=0 unique=0 while (($#)); do case $1 in --storage) optarg "$@"; storage=$2; shift 2 ;; --force) force=1; shift ;; --unique) unique=1; shift ;; -*) die "未知参数: $1" ;; *) if [[ -z $archive ]]; then archive=$1; else id=$1; fi; shift ;; esac done if [[ -z $archive ]] && (( ! ASSUME_YES )); then cmd_backups; fi ask archive "备份卷 ID 或文件路径(如 local:backup/vzdump-qemu-...zst)" "" [[ -n $archive ]] || die "未指定备份" ask id "恢复到 VMID" "$(next_id)" valid_vmid "$id" || die "无效 VMID" ask storage "目标存储" "$VM_STORAGE" local args=(--storage "$storage") if vm_exists "$id"; then (( force )) || confirm "VM $id 已存在,覆盖?(原 VM 将被替换)" || die "已取消" args+=(--force 1) fi (( unique )) && args+=(--unique 1) qmrestore "$archive" "$id" "${args[@]}" info "已恢复到 VM $id"; log "RESTORE $archive -> $id" } cmd_migrate() { local id="" target="" online=0 localdisk=0 while (($#)); do case $1 in --online) online=1; shift ;; --with-local-disks) localdisk=1; shift ;; -*) die "未知参数: $1" ;; *) if [[ -z $id ]]; then id=$1; else target=$1; fi; shift ;; esac done ask id "VMID" "" require_vm "$id" echo "集群节点: $(pvesh get /nodes --output-format json | python3 -c 'import json,sys;print(" ".join(n["node"] for n in json.load(sys.stdin)))')" ask target "目标节点" "" [[ -n $target && $target != "$NODE" ]] || die "无效目标节点" local args=() if [[ $(vm_status "$id") == running ]]; then online=1; fi (( online )) && args+=(--online 1) (( localdisk )) && args+=(--with-local-disks 1) if grep -q '^cicustom:' <<<"$(vm_cfg "$id")"; then warn "VM 使用 cloud-init 片段,若 '$SNIPPET_STORAGE' 不是共享存储,请先把 $(snippet_path "$id") 复制到目标节点同路径" fi confirm "迁移 $id 到 $target?" || die "已取消" qm migrate "$id" "$target" "${args[@]}" info "迁移完成"; log "MIGRATE $id -> $target" } #============================================================================== # 命令:模板 #============================================================================== IMAGES=( "debian12|Debian 12 Bookworm|https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2" "debian13|Debian 13 Trixie|https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2" "ubuntu2204|Ubuntu 22.04 LTS|https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img" "ubuntu2404|Ubuntu 24.04 LTS|https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img" "rocky9|Rocky Linux 9|https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2" "alma9|AlmaLinux 9|https://repo.almalinux.org/almalinux/9/cloud/x86_64/images/AlmaLinux-9-GenericCloud-latest.x86_64.qcow2" "centos9|CentOS Stream 9|https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2" ) download() { local url=$1 out=$2 info "下载 $url" if command -v wget >/dev/null; then wget -c -q --show-progress -O "$out.part" "$url" else curl -fL --retry 3 -C - -o "$out.part" "$url" fi mv -f "$out.part" "$out" } cmd_template_create() { local key="" url="" id="" name="" storage="" bridge="" customize="" i entry while (($#)); do case $1 in --image) optarg "$@"; key=$2; shift 2 ;; --url) optarg "$@"; url=$2; shift 2 ;; --id) optarg "$@"; id=$2; shift 2 ;; --name) optarg "$@"; name=$2; shift 2 ;; --storage) optarg "$@"; storage=$2; shift 2 ;; --bridge) optarg "$@"; bridge=$2; shift 2 ;; --customize) optarg "$@"; customize=$2; shift 2 ;; *) die "未知参数: $1" ;; esac done if [[ -z $key && -z $url ]]; then title "选择云镜像" for i in "${!IMAGES[@]}"; do IFS='|' read -r k d _ <<<"${IMAGES[i]}" printf ' %d) %-12s %s\n' "$((i + 1))" "$k" "$d" done echo " c) 自定义 URL" local choice="" ask choice "请选择" "1" if [[ $choice == c ]]; then ask url "镜像 URL" ""; key="custom" else is_int "$choice" && (( choice >= 1 && choice <= ${#IMAGES[@]} )) || die "无效选择" key=${IMAGES[choice - 1]%%|*} fi fi if [[ -z $url ]]; then for entry in "${IMAGES[@]}"; do if [[ ${entry%%|*} == "$key" ]]; then url=${entry##*|}; fi done [[ -n $url ]] || die "未知镜像: $key(可选: $(printf '%s ' "${IMAGES[@]%%|*}"))" fi local def_id=9000 while ! id_free "$def_id"; do def_id=$((def_id + 1)); done ask id "模板 VMID" "$def_id" valid_vmid "$id" && id_free "$id" || die "VMID $id 无效或已占用" ask name "模板名称" "tpl-$key" valid_name "$name" || die "无效名称: $name" ask storage "磁盘存储" "$VM_STORAGE" ask bridge "网桥" "$DEFAULT_BRIDGE" mkdir -p "$IMAGE_DIR" local file="$IMAGE_DIR/$(basename "${url%%\?*}")" if [[ -f $file ]]; then if confirm "镜像已存在 $file,重新下载最新版?"; then rm -f "$file"; download "$url" "$file"; fi else download "$url" "$file" fi local img=$file tmp="" if command -v virt-customize >/dev/null; then ask customize "使用 virt-customize 预装 qemu-guest-agent 0/1" "1" if [[ $customize == 1 ]]; then tmp="$IMAGE_DIR/.build-$id.img" cp -f "$file" "$tmp" info "预装 qemu-guest-agent(耗时较长)..." if virt-customize -a "$tmp" --install qemu-guest-agent --truncate /etc/machine-id; then img=$tmp else warn "virt-customize 失败,使用原始镜像"; rm -f "$tmp"; tmp=""; fi fi else info "提示: apt install libguestfs-tools 后可预装 guest agent,首启更快" fi info "创建模板 VM $id ..." CLEANUP_HINT="$SCRIPT_NAME -y delete $id" qm create "$id" --name "$name" --ostype l26 --cpu host --cores 2 --memory 2048 \ --net0 "virtio,bridge=$bridge" --scsihw virtio-scsi-single \ --scsi0 "$storage:0,import-from=$img,discard=on,ssd=1,iothread=1" \ --ide2 "$storage:cloudinit" --boot order=scsi0 \ --serial0 socket --vga serial0 --agent enabled=1,fstrim_cloned_disks=1 \ --ipconfig0 ip=dhcp --tags template [[ -n $tmp ]] && rm -f "$tmp" qm template "$id" CLEANUP_HINT="" log "TEMPLATE create id=$id url=$url" info "模板 $id ($name) 创建完成" info "可在 $CONFIG_FILE 中设置 DEFAULT_TEMPLATE=$id,之后: $SCRIPT_NAME create" } cmd_template_convert() { local id=${1:-} ask id "VMID" "" require_vm "$id" is_template "$id" && die "VM $id 已经是模板" warn "转换为模板后不可再启动,且不可逆(只能克隆使用)" confirm "确认将 $id ($(vm_name "$id")) 转为模板?" || die "已取消" if [[ $(vm_status "$id") != stopped ]]; then qm shutdown "$id" --timeout 120 --forceStop 1; fi qm template "$id" info "VM $id 已转换为模板"; log "TEMPLATE convert id=$id" } #============================================================================== # 命令:Windows #============================================================================== # 键|名称|PVE ostype|virtio 驱动目录|默认是否加 TPM WIN_VERSIONS=( "win10|Windows 10|win10|w10|0" "win11|Windows 11|win11|w11|1" "2016|Windows Server 2016|win10|2k16|0" "2019|Windows Server 2019|win10|2k19|0" "2022|Windows Server 2022|win11|2k22|1" "2025|Windows Server 2025|win11|2k25|1" ) # 列出所有存储里的 ISO 卷 ID list_isos() { local st for st in $(pvesm status --content iso 2>/dev/null | awk 'NR>1{print $1}' || true); do pvesm list "$st" --content iso 2>/dev/null | awk 'NR>1{print $1}' || true done } # pick_iso <变量名> <提示> [默认值]:可输入序号或卷 ID pick_iso() { local __v=$1 __p=$2 __d=${3:-} __a="" __i __isos=() [[ -n ${!__v:-} ]] && return 0 if (( ASSUME_YES )) || [[ ! -t 0 ]]; then printf -v "$__v" '%s' "$__d"; return 0; fi mapfile -t __isos < <(list_isos) if [[ ${#__isos[@]} -gt 0 ]]; then echo "可用 ISO:" for __i in "${!__isos[@]}"; do printf ' %2d) %s\n' "$((__i + 1))" "${__isos[__i]}"; done else warn "没有找到任何 ISO,请先上传到 ISO 存储(如 /var/lib/vz/template/iso/)" fi read -r -p "$__p(序号或卷 ID)${__d:+ [$__d]}: " __a || true __a=${__a:-$__d} if is_int "$__a" && (( __a >= 1 && __a <= ${#__isos[@]} )); then __a=${__isos[__a - 1]}; fi printf -v "$__v" '%s' "$__a" } cmd_create_win() { local id="" name="" ver="" iso="" virtio="" cores="" memory="" disk="" storage="" bridge="" vlan="" \ tpm="" onboot="" start="" while (($#)); do case $1 in --id) optarg "$@"; id=$2; shift 2 ;; --name) optarg "$@"; name=$2; shift 2 ;; --version) optarg "$@"; ver=$2; shift 2 ;; --iso) optarg "$@"; iso=$2; shift 2 ;; --virtio) optarg "$@"; virtio=$2; shift 2 ;; --cores) optarg "$@"; cores=$2; shift 2 ;; --memory) optarg "$@"; memory=$2; shift 2 ;; --disk) optarg "$@"; disk=$2; shift 2 ;; --storage) optarg "$@"; storage=$2; shift 2 ;; --bridge) optarg "$@"; bridge=$2; shift 2 ;; --vlan) optarg "$@"; vlan=$2; shift 2 ;; --tpm) optarg "$@"; tpm=$2; shift 2 ;; --onboot) optarg "$@"; onboot=$2; shift 2 ;; --start) optarg "$@"; start=$2; shift 2 ;; -h|--help) usage; return 0 ;; *) die "未知参数: $1" ;; esac done local i k d o v t entry choice="" if [[ -z $ver ]]; then if (( ! ASSUME_YES )) && [[ -t 0 ]]; then title "选择 Windows 版本" for i in "${!WIN_VERSIONS[@]}"; do IFS='|' read -r k d _ <<<"${WIN_VERSIONS[i]}" printf ' %d) %s\n' "$((i + 1))" "$d" done fi ask choice "请选择" "2" if is_int "$choice" && (( choice >= 1 && choice <= ${#WIN_VERSIONS[@]} )); then ver=${WIN_VERSIONS[choice - 1]%%|*} else ver=$choice fi fi local desc="" ostype="" drv="" need_tpm=0 for entry in "${WIN_VERSIONS[@]}"; do IFS='|' read -r k d o v t <<<"$entry" if [[ $k == "$ver" ]]; then desc=$d ostype=$o drv=$v need_tpm=$t; fi done [[ -n $ostype ]] || die "未知版本: $ver(可选: win10 win11 2016 2019 2022 2025)" ask id "VMID" "$(next_id)" valid_vmid "$id" && id_free "$id" || die "VMID $id 无效或已占用" ask name "名称(Windows 计算机名最多 15 个字符)" "win-$id" valid_name "$name" || die "无效名称: $name" ask cores "CPU 核心数" "$WIN_CORES" ask memory "内存 (MB)" "$WIN_MEMORY" ask disk "系统盘大小 (GB)" "$WIN_DISK" disk=${disk%[Gg]} ask storage "磁盘存储" "$VM_STORAGE" ask bridge "网桥" "$DEFAULT_BRIDGE" ask vlan "VLAN ID(none 不设置)" "none" ask tpm "添加 TPM 2.0 0/1" "$need_tpm" local def_iso def_virtio vpath def_iso=$(list_isos | grep -iv virtio | grep -i -m1 -E 'win|server' || true) pick_iso iso "Windows 安装 ISO" "$def_iso" [[ -n $iso ]] || die "未指定 Windows ISO,请先上传到 ISO 存储" pvesm path "$iso" &>/dev/null || die "ISO 不存在: $iso" def_virtio=$(list_isos | grep -i -m1 virtio || true) if [[ -z $def_virtio && -z $virtio ]] && confirm "没有找到 virtio-win 驱动 ISO,现在下载到 $ISO_STORAGE?"; then vpath=$(pvesm path "$ISO_STORAGE:iso/virtio-win.iso") mkdir -p "$(dirname "$vpath")" download "$VIRTIO_WIN_URL" "$vpath" def_virtio="$ISO_STORAGE:iso/virtio-win.iso" fi pick_iso virtio "virtio 驱动 ISO(none 不挂载)" "${def_virtio:-none}" virtio=$(norm_empty "$virtio") [[ -z $virtio ]] || pvesm path "$virtio" &>/dev/null || die "ISO 不存在: $virtio" ask onboot "开机自启 0/1" "0" ask start "创建后立即启动 0/1" "1" vlan=$(norm_empty "$vlan") is_int "$cores" && (( cores > 0 )) || die "无效 CPU 核心数: $cores" is_int "$memory" && (( memory >= 1024 )) || die "无效内存: $memory(至少 1024)" is_int "$disk" && (( disk >= 20 )) || die "无效磁盘大小: $disk(至少 20G)" [[ -z $vlan ]] || { is_int "$vlan" && (( vlan >= 1 && vlan <= 4094 )); } || die "无效 VLAN: $vlan" ip link show "$bridge" &>/dev/null || die "网桥 $bridge 不存在" if [[ $ver == win11 ]] && (( disk < 64 || memory < 4096 )); then warn "Windows 11 安装要求至少 64G 磁盘、4G 内存,否则安装程序会拒绝"; fi if (( ${#name} > 15 )); then warn "Windows 计算机名最多 15 个字符"; fi title "创建确认" cat < VM $id -> 控制台,出现 \"Press any key to boot from CD or DVD\" 时立刻按任意键" echo " (错过了会进 UEFI 界面,执行 $SCRIPT_NAME reset $id 后重新按)" printf ' 2. 选择安装位置时看不到磁盘:点「加载驱动程序」-> 浏览 virtio 光盘 -> vioscsi\\%s\\amd64\n' "$drv" printf ' (可同时加载网卡驱动 NetKVM\\%s\\amd64;不加载则安装过程断网)\n' "$drv" if [[ $ver == win11 ]]; then echo " 3. Win11 要求联网/微软账户:在联网界面按 Shift+F10,输入 start ms-cxh:localonly 创建本地账户" echo " (旧版本 Win11 用 OOBE\\BYPASSNRO)" else echo " 3. 按提示设置管理员密码" fi echo " 4. 进入系统后,运行 virtio 光盘里的 virtio-win-guest-tools.exe(全部驱动 + guest agent)" echo " 5. 打开 设置 -> 系统 -> 远程桌面,启用远程桌面" echo " 6. 装完后移除光盘、改为硬盘启动: $SCRIPT_NAME eject $id" echo " 7. 想做成可一键克隆的模板: $SCRIPT_NAME win-guide" if [[ $start == 1 ]]; then qm start "$id" info "VM $id 已启动,请立即打开控制台按任意键从光盘启动" fi } # 移除安装光盘(保留 cloud-init 盘),改为从系统盘启动 cmd_eject() { local id=${1:-} disk slot n=0 ask id "VMID" "" require_vm "$id" disk=$(boot_disk "$id") || die "找不到系统盘" qm set "$id" --boot "order=$disk" >/dev/null info "启动顺序已改为 $disk" for slot in $(awk -F: '/^(ide|sata|scsi)[0-9]+: .*media=cdrom/ && !/cloudinit/ {print $1}' <<<"$(vm_cfg "$id")"); do qm set "$id" --delete "$slot" >/dev/null info "已移除光驱 $slot" n=$((n + 1)) done if (( n == 0 )); then info "没有需要移除的光驱"; fi if [[ $(vm_status "$id") == running ]]; then warn "VM 运行中,部分修改需关机再开机后生效(qm pending $id 查看)" fi log "EJECT vmid=$id" } cmd_win_guide() { sed "s/@SELF@/$SCRIPT_NAME/g" <<'EOF' Windows 模板制作(Cloudbase-Init + sysprep),做好后可像 Linux 一样一键克隆 要求 PVE 8.1 及以上 1. 用 @SELF@ create-win 装好系统,安装 virtio-win-guest-tools,打好补丁、 启用远程桌面、装好常用软件(模板里的东西所有克隆都会有) 2. 下载安装 Cloudbase-Init(64 位 Stable 版): https://cloudbase.it/cloudbase-init/#download 安装时: - Username: 填 Administrator(Server 版); Win10/11 建议填一个新用户名如 admin,Cloudbase-Init 会自动创建并加入管理员组 - 勾选 "Use metadata password" - 最后一页先【不要】点 Finish,先做第 3 步 3. 用记事本打开下面两个文件: C:\Program Files\Cloudbase Solutions\Cloudbase-Init\conf\cloudbase-init.conf C:\Program Files\Cloudbase Solutions\Cloudbase-Init\conf\cloudbase-init-unattend.conf 确保 [DEFAULT] 下有这些行(没有就加,有就改): metadata_services=cloudbaseinit.metadata.services.configdrive.ConfigDriveService inject_user_password=true first_logon_behaviour=no 如果文件里有 plugins= 行,确认包含: SetHostNamePlugin、CreateUserPlugin、SetUserPasswordPlugin、 NetworkConfigPlugin、ExtendVolumesPlugin 4. 回到安装程序最后一页,勾选 "Run Sysprep" 和 "Shutdown when Sysprep terminates", 点 Finish,等待 VM 自动关机(不要再开机,否则要重新 sysprep) 5. 在 PVE 上: @SELF@ eject @SELF@ template-convert 6. 以后创建: @SELF@ create --template <模板ID> --id 130 --name win-130 --user Administrator \ --ip 192.168.1.130/24 --gw 192.168.1.1 (--user 要和第 2 步填的用户名一致) 会自动设置密码、计算机名、IP/DNS,并扩展 C 盘;首次启动需要几分钟 已有的 Windows VM(装了 Cloudbase-Init)也可以用 @SELF@ init 重新设置 运行中的 Windows 改密码: @SELF@ passwd (通过 guest agent,立即生效) EOF } #============================================================================== # 命令:环境检查 / 配置 #============================================================================== cmd_check() { local ok="${GRN}✔${RST}" bad="${RED}✘${RST}" c title "环境检查" echo " PVE 版本: $(pveversion 2>/dev/null || echo 未知) 节点: $NODE" echo " 配置文件: $CONFIG_FILE $( [[ -f $CONFIG_FILE ]] && echo '(已加载)' || echo '(不存在,使用内置默认)')" for c in qm pvesm pvesh python3 openssl; do if command -v "$c" >/dev/null; then echo " $ok $c"; else echo " $bad $c(必需)"; fi done for c in wget curl virt-customize; do if command -v "$c" >/dev/null; then echo " $ok $c"; else echo " - $c 未安装(可选)"; fi done if snippet_storage_ok; then echo " $ok 片段存储 $SNIPPET_STORAGE -> $(dirname "$(snippet_path 0)")" else echo " $bad 片段存储 '$SNIPPET_STORAGE' 不存在或未启用 Snippets"; fi if grep -qx "$VM_STORAGE" <<<"$(pvesm status 2>/dev/null | awk 'NR>1{print $1}')"; then echo " $ok 磁盘存储 $VM_STORAGE" else echo " $bad 磁盘存储 $VM_STORAGE 不存在"; fi if grep -qx "$BACKUP_STORAGE" <<<"$(pvesm status --content backup 2>/dev/null | awk 'NR>1{print $1}')"; then echo " $ok 备份存储 $BACKUP_STORAGE" else echo " $bad 备份存储 $BACKUP_STORAGE 不存在或未启用 backup 内容"; fi if ip link show "$DEFAULT_BRIDGE" &>/dev/null; then echo " $ok 网桥 $DEFAULT_BRIDGE"; else echo " $bad 网桥 $DEFAULT_BRIDGE 不存在"; fi if [[ -n $DEFAULT_TEMPLATE ]]; then if vm_exists "$DEFAULT_TEMPLATE" && is_template "$DEFAULT_TEMPLATE"; then echo " $ok 默认模板 $DEFAULT_TEMPLATE ($(vm_name "$DEFAULT_TEMPLATE"))" else echo " $bad 默认模板 $DEFAULT_TEMPLATE 不存在或不是模板"; fi else echo " - 未设置默认模板 DEFAULT_TEMPLATE" fi if grep -qx "$ISO_STORAGE" <<<"$(pvesm status --content iso 2>/dev/null | awk 'NR>1{print $1}')"; then echo " $ok ISO 存储 $ISO_STORAGE($(list_isos | wc -l) 个 ISO)" else echo " $bad ISO 存储 $ISO_STORAGE 不存在或未启用 ISO 内容(create-win 需要)"; fi if pve_ge 8 1; then echo " $ok PVE >= 8.1,支持 Windows cloud-init(Cloudbase-Init)" else echo " $bad PVE < 8.1,Windows 模板无法自动设置密码"; fi } cmd_config() { local action=${1:-show} case $action in init) if [[ -f $CONFIG_FILE ]]; then confirm "$CONFIG_FILE 已存在,覆盖?" || die "已取消"; fi cat >"$CONFIG_FILE" < 版本号 A 大于 B ver_gt() { [[ $1 != "$2" && $(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1) == "$1" ]]; } cmd_self_update() { local check=0 force=0 resp line ver="" url="" sha="" notes="" tmp while (($#)); do case $1 in --check) check=1 ;; -f|--force) force=1 ;; *) die "用法: $SCRIPT_NAME self-update [--check] [--force]" ;; esac shift done command -v curl >/dev/null || die "需要 curl" resp=$(curl -fsS --max-time 20 "${UPDATE_URL%/}/api/pve-vm/latest?format=txt¤t=$VERSION") \ || die "无法从 $UPDATE_URL 获取版本信息" while IFS= read -r line; do case ${line%%=*} in version) ver=${line#*=} ;; url) url=${line#*=} ;; sha256) sha=${line#*=} ;; notes) notes=${line#*=}; notes=${notes//\\n/$'\n'} ;; esac done <<<"$resp" [[ -n $ver && -n $url && -n $sha ]] || die "更新服务器返回格式无效" echo "当前版本: $VERSION 最新版本: $ver" if ! ver_gt "$ver" "$VERSION" && (( ! force )); then info "已是最新版本"; return 0; fi if [[ -n $notes ]]; then printf '更新内容:\n%s\n' "$notes" | sed '2,$s/^/ /'; fi if (( check )); then echo "执行 $SCRIPT_NAME self-update 进行升级"; return 0; fi [[ -w $SELF && -w $(dirname "$SELF") ]] || die "没有写入 $SELF 的权限(请用 root 运行)" confirm "升级到 $ver?" || die "已取消" tmp=$(mktemp "$SELF.new.XXXXXX") if ! curl -fsSL --max-time 300 -o "$tmp" "$url"; then rm -f "$tmp"; die "下载失败: $url"; fi if [[ $(sha256sum "$tmp" | awk '{print $1}') != "$sha" ]]; then rm -f "$tmp"; die "SHA256 校验失败,已放弃更新"; fi if ! bash -n "$tmp"; then rm -f "$tmp"; die "新版本语法检查失败,已放弃更新"; fi chmod --reference="$SELF" "$tmp" cp -p "$SELF" "$SELF.bak" mv -f "$tmp" "$SELF" # 新 inode 原子替换,不影响正在运行的旧进程 info "已升级到 $ver(旧版本备份为 $SELF.bak)" } #============================================================================== # 帮助 / 菜单 #============================================================================== usage() { cat < [参数] (无参数进入交互菜单) -y, --yes 自动确认,未指定的参数使用默认值(非交互) ${BLD}查看${RST} list [--ip] [--templates] 列出虚拟机(--ip 通过 agent 显示 IP) info 详细信息(配置/资源/系统/IP/快照) ip 查询 IP creds [vmid] 查看记录的账号密码 ${BLD}创建${RST} create [选项] 从模板克隆创建(缺少的参数会交互询问) --template ID --id ID --name NAME --user root --cores 2 --memory 2048 --disk 20G --mode full|linked --storage local-lvm --bridge vmbr0 --vlan 10 --ip dhcp|192.168.1.10/24 --gw 192.168.1.1 --ip6 auto|CIDR --gw6 X --dns "1.1.1.1" --sshkey 文件或公钥 --password X --tags "a;b" --onboot 0|1 --start 0|1 --wait 0|1 batch [选项] 批量创建: --count 3 --prefix web --start-id 200 --ip-start 192.168.1.50/24 (其余选项同 create) init [--user --password --sshkey --hostname] 为已有 VM 生成 cloud-init 片段和随机密码 clone [dst] [name] [--full 1|0 --storage S] ${BLD}电源${RST} start|shutdown|reboot|stop|reset|suspend|resume delete [--force] 删除 VM、磁盘及其 cloud-init 片段 ${BLD}配置${RST} set [--cores --sockets --memory --balloon --name --onboot --tags --cpu --protection --desc] resize <50G|+10G> [disk] 扩容(运行中会通过 agent 在线扩分区) adddisk [--storage S] 添加数据盘 net [--bridge --vlan --ip --gw --ip6 --gw6 --dns] passwd [密码] [--user U] 重置密码(运行中经 agent 立即生效) sshkey <公钥文件|公钥> [--user U] ${BLD}guest agent / 登录${RST} exec [--cmd] "<命令>" 在 VM 内执行命令(Windows 默认 PowerShell,--cmd 用 cmd.exe) ssh [user] SSH 登录(Windows 显示远程桌面地址) console 串口控制台(Ctrl+O 退出) wait 等待 agent 就绪并显示 IP ${BLD}快照 / 备份${RST} snap list|create|rollback|delete [name] [--desc X] [--vmstate] backup [--storage S --mode snapshot|suspend|stop --compress zstd] backups [vmid] [storage] 列出备份 restore <备份> [vmid] [--storage S --force --unique] migrate [--online --with-local-disks] ${BLD}模板${RST} template-create [--image debian12|debian13|ubuntu2204|ubuntu2404|rocky9|alma9|centos9 --url URL --id 9000 --name N --storage S --bridge B --customize 0|1] template-convert VM 转模板 templates 列出模板 ${BLD}Windows${RST} create-win [选项] 从 ISO 创建 Windows(UEFI/安全启动/TPM/virtio 驱动盘自动配置) --version win10|win11|2016|2019|2022|2025 --id ID --name N --iso 卷ID --virtio 卷ID|none --cores 4 --memory 4096 --disk 64 --storage S --bridge B --vlan V --tpm 0|1 --onboot 0|1 --start 0|1 eject 移除安装光盘,改为从硬盘启动 win-guide Windows 模板(Cloudbase-Init + sysprep)制作步骤 * Windows 模板做好后直接用 create / batch 克隆,自动设置密码、计算机名、IP * init / passwd / exec / resize / info 会自动识别 Windows ${BLD}其他${RST} check 环境检查 config [init|show|edit] 配置文件 ($CONFIG_FILE) self-update [--check] [--force] 从 $UPDATE_URL 检查 / 升级本脚本 help | version 示例: $SCRIPT_NAME template-create --image debian12 --id 9000 $SCRIPT_NAME create --template 9000 --id 110 --name web1 --ip 10.0.0.110/24 --gw 10.0.0.1 $SCRIPT_NAME -y batch --template 9000 --count 3 --prefix k8s --start-id 120 --ip-start 10.0.0.120/24 $SCRIPT_NAME init 103 $SCRIPT_NAME passwd 103 $SCRIPT_NAME exec 103 "df -h" $SCRIPT_NAME create-win --version 2022 --id 130 --name win-130 $SCRIPT_NAME exec 130 "Get-Volume" EOF } run_sub() { bash "$SELF" "$@" || true; } pick_vm() { local __v=$1 __a="" read -r -p "VMID: " __a || return 1 valid_vmid "$__a" || { warn "无效 VMID"; return 1; } printf -v "$__v" '%s' "$__a" } menu() { local c v x y while true; do printf '\n%s' "$BLD$CYN" cat <