#!/usr/bin/env bash set -euo pipefail # Proxmox cluster power helper # - Start or shutdown a set of QEMU VMs and/or LXC containers by ID, or all. # - Auth via API token or username/password (env vars or secret-tool). # # Requirements: curl, jq; optional: secret-tool (GNOME keyring) # # Environment variables (examples): # PVE_HOST=proxmox.example.com[:8006] # PVE_TOKEN_ID="user@pam!automation" # when using API token # PVE_TOKEN_SECRET="xxxxxxxx-xxxx-xxxx" # when using API token # PVE_USER="user" # when using password login # PVE_REALM="pam" # default pam # PVE_PASSWORD="..." # or provided via keyring # PVE_SCHEME="https" # default https # PVE_VERIFY_SSL="true|false" # default true # PVE_NODE_FILTER="" # optional: restrict to node name # # Examples: # scripts/proxmox-power.sh --op shutdown --all # scripts/proxmox-power.sh --op start --ids 100 101 --only-qemu # PVE_TOKEN_ID=me@pam!ci PVE_TOKEN_SECRET=... scripts/proxmox-power.sh --op shutdown --all SCHEME=${PVE_SCHEME:-https} HOST=${PVE_HOST:-} VERIFY_SSL=${PVE_VERIFY_SSL:-true} INSECURE_FLAG="" if [[ ${VERIFY_SSL} != "true" ]]; then INSECURE_FLAG="-k" fi usage() { cat < [ ...]] [options] Options: --host HOST Proxmox host (env PVE_HOST). Example: proxmox.example.com:8006 --op OP Operation: start or shutdown --all Apply to all VMs/containers in the cluster (honors filters) --ids LIST Space-separated list of VMIDs to operate on --only-qemu Only operate on QEMU VMs --only-lxc Only operate on LXC containers --include-stopped Include stopped guests when op=shutdown (no-op otherwise) --force If shutdown times out, force stop --timeout SEC Shutdown wait timeout (default 120) --concurrency N Parallel operations (default 4) --node NODE Restrict to a specific node name --dry-run Show actions without executing --insecure Do not verify SSL (same as PVE_VERIFY_SSL=false) -h, --help Show this help Auth (choose one): API Token: env PVE_TOKEN_ID and PVE_TOKEN_SECRET Password: env PVE_USER, PVE_PASSWORD (or from keyring), optional PVE_REALM (default pam) Keyring: If PVE_PASSWORD is empty and 'secret-tool' is available, the script tries: secret-tool lookup service proxmox user "+$PVE_USER+" realm "+${PVE_REALM:-pam}+" If PVE_TOKEN_SECRET is empty, it tries: secret-tool lookup service proxmox token_id "+$PVE_TOKEN_ID+" EOF } require_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "Error: required command '$1' not found" >&2; exit 1; } } get_keyring() { local value="" if command -v secret-tool >/dev/null 2>&1; then value=$(secret-tool lookup "$@" || true) fi printf '%s' "$value" } # Globals set by auth_init AUTH_HEADER="" COOKIE_HEADER="" CSRF_HEADER="" auth_init() { local base_url="$SCHEME://$HOST/api2/json" if [[ -n "${PVE_TOKEN_ID:-}" && -z "${PVE_TOKEN_SECRET:-}" ]]; then PVE_TOKEN_SECRET=$(get_keyring service proxmox token_id "${PVE_TOKEN_ID}") || true fi if [[ -n "${PVE_TOKEN_ID:-}" && -n "${PVE_TOKEN_SECRET:-}" ]]; then AUTH_HEADER=("-H" "Authorization: PVEAPIToken=${PVE_TOKEN_ID}=${PVE_TOKEN_SECRET}") return 0 fi local user="${PVE_USER:-}" local realm="${PVE_REALM:-pam}" local password="${PVE_PASSWORD:-}" if [[ -z "$user" ]]; then echo "Error: set PVE_TOKEN_ID/PVE_TOKEN_SECRET or PVE_USER[/PVE_PASSWORD]" >&2 exit 2 fi if [[ -z "$password" ]]; then password=$(get_keyring service proxmox user "$user" realm "$realm") || true fi if [[ -z "$password" ]]; then echo "Error: password not provided and not found in keyring for user '$user' realm '$realm'" >&2 exit 2 fi # Login to get ticket and CSRF token local resp resp=$(curl -sS $INSECURE_FLAG -X POST \("${AUTH_HEADER[*]}"\) \ -d "username=${user}@${realm}" \ -d "password=${password}" \ "$base_url/access/ticket") local ticket csrf ticket=$(echo "$resp" | jq -r '.data.ticket // empty') csrf=$(echo "$resp" | jq -r '.data.CSRFPreventionToken // empty') if [[ -z "$ticket" || -z "$csrf" ]]; then echo "Error: failed to obtain auth ticket (check credentials)" >&2 echo "$resp" | jq -r '.' >&2 || true exit 3 fi COOKIE_HEADER=("-H" "Cookie: PVEAuthCookie=${ticket}") CSRF_HEADER=("-H" "CSRFPreventionToken: ${csrf}") } api_get() { local path="$1"; shift local url="$SCHEME://$HOST/api2/json$path" curl -sS $INSECURE_FLAG "${AUTH_HEADER[@]}" "${COOKIE_HEADER[@]}" -X GET "$url" "$@" } api_post() { local path="$1"; shift local url="$SCHEME://$HOST/api2/json$path" curl -sS $INSECURE_FLAG "${AUTH_HEADER[@]}" "${COOKIE_HEADER[@]}" "${CSRF_HEADER[@]}" -X POST "$url" "$@" } # Parse CLI OP="" DO_ALL=false IDS=() ONLY_QEMU=false ONLY_LXC=false INCLUDE_STOPPED=false FORCE=false TIMEOUT=120 CONCURRENCY=4 NODE_FILTER="${PVE_NODE_FILTER:-}" DRY_RUN=false while [[ $# -gt 0 ]]; do case "$1" in --op) OP="$2"; shift 2;; --all) DO_ALL=true; shift;; --ids) shift; while [[ $# -gt 0 && ! "$1" =~ ^-- ]]; do IDS+=("$1"); shift; done ;; --only-qemu) ONLY_QEMU=true; shift;; --only-lxc) ONLY_LXC=true; shift;; --include-stopped) INCLUDE_STOPPED=true; shift;; --force) FORCE=true; shift;; --timeout) TIMEOUT="$2"; shift 2;; --concurrency) CONCURRENCY="$2"; shift 2;; --node) NODE_FILTER="$2"; shift 2;; --host) HOST="$2"; shift 2;; --dry-run) DRY_RUN=true; shift;; --insecure) VERIFY_SSL=false; INSECURE_FLAG="-k"; shift;; -h|--help) usage; exit 0;; *) echo "Unknown argument: $1" >&2; usage; exit 2;; esac done require_cmd curl require_cmd jq if [[ -z "$HOST" ]]; then echo "Error: --host or PVE_HOST is required" >&2 usage exit 2 fi case "$OP" in start|shutdown) :;; *) echo "Error: --op must be 'start' or 'shutdown'" >&2; usage; exit 2;; esac if ! $DO_ALL && [[ ${#IDS[@]} -eq 0 ]]; then echo "Error: specify --all or a list of --ids" >&2 exit 2 fi if $ONLY_QEMU && $ONLY_LXC; then echo "Error: cannot use --only-qemu and --only-lxc together" >&2 exit 2 fi auth_init # Collect targets resources=$(api_get "/cluster/resources?type=vm") filter_jq='[.data[] | {type, vmid: (.vmid|tostring), status, node}]' items=$(echo "$resources" | jq "$filter_jq") if [[ -n "$NODE_FILTER" ]]; then items=$(echo "$items" | jq --arg node "$NODE_FILTER" '[.[] | select(.node==$node)]') fi if $ONLY_QEMU; then items=$(echo "$items" | jq '[.[] | select(.type=="qemu")]') elif $ONLY_LXC; then items=$(echo "$items" | jq '[.[] | select(.type=="lxc")]') fi select_ids=() if $DO_ALL; then mapfile -t select_ids < <(echo "$items" | jq -r '.[].vmid') else select_ids=("${IDS[@]}") fi if [[ ${#select_ids[@]} -eq 0 ]]; then echo "No matching guests found." >&2 exit 0 fi # Build an associative map of vmid -> node,type,status declare -A VM_NODE VM_TYPE VM_STATUS while IFS=$'\t' read -r vid node type status; do VM_NODE[$vid]="$node" VM_TYPE[$vid]="$type" VM_STATUS[$vid]="$status" done < <( echo "$items" | jq -r '.[] | "\(.vmid)\t\(.node)\t\(.type)\t\(.status)"' ) work_list=() for vid in "${select_ids[@]}"; do if [[ -z "${VM_NODE[$vid]:-}" ]]; then echo "Skip vmid=$vid (not found by filters)" >&2 continue fi # Idempotence: skip if already desired state st="${VM_STATUS[$vid]}" case "$OP" in start) if [[ "$st" == "running" ]]; then echo "Already running: $vid (${VM_TYPE[$vid]} on ${VM_NODE[$vid]})" continue fi ;; shutdown) if [[ "$st" != "running" && $INCLUDE_STOPPED == false ]]; then echo "Already stopped: $vid (${VM_TYPE[$vid]} on ${VM_NODE[$vid]})" continue fi ;; esac work_list+=("$vid") done if [[ ${#work_list[@]} -eq 0 ]]; then echo "Nothing to do." exit 0 fi run_action() { local vid="$1" local node="${VM_NODE[$vid]}" local type="${VM_TYPE[$vid]}" local path_base="/nodes/${node}/${type}/${vid}/status" echo "[$OP] ${type}:${vid} on node ${node}" if $DRY_RUN; then return 0 fi case "$OP" in start) api_post "${path_base}/start" >/dev/null ;; shutdown) # Try graceful shutdown api_post "${path_base}/shutdown" -d "timeout=${TIMEOUT}" >/dev/null || true # Optionally force stop if still running after timeout # We poll once after timeout window to check status sleep 2 local st_json st_json=$(api_get "/nodes/${node}/${type}/${vid}/status/current") local cur cur=$(echo "$st_json" | jq -r '.data.status // .data.status.current // empty') if [[ "$cur" == "running" && $FORCE == true ]]; then echo "Forcing stop: ${type}:${vid}" api_post "${path_base}/stop" >/dev/null || true fi ;; esac } # Parallelize with xargs -P export -f run_action api_post api_get export SCHEME HOST INSECURE_FLAG AUTH_HEADER COOKIE_HEADER CSRF_HEADER TIMEOUT FORCE DRY_RUN declare -p VM_NODE VM_TYPE VM_STATUS >/dev/null 2>&1 || true printf '%s\n' "${work_list[@]}" | xargs -I{} -P "$CONCURRENCY" bash -c 'run_action "$@"' _ {} echo "Done: $OP ${#work_list[@]} item(s)."