#!/bin/bash
# alpha-virt — allow-listed root helper for LXD + QEMU + consoles + ISOs.
set -euo pipefail
LXC="systemd-run --quiet --pipe --wait --collect /snap/bin/lxc"
QEMU=/usr/bin/qemu-system-x86_64
QIMG=/usr/bin/qemu-img
QDIR=/var/lib/alpha-qemu
ISODIR="$QDIR/isos"
INCOMING="$QDIR/incoming"
NVDIR=/etc/alpha-novnc/tokens
SHOTS="$QDIR/shots"
LXDPOOL=alpha
MAXCPU=4
LXDMETA=/var/lib/alpha-lxd        # per-instance state the panel needs to survive a reboot
PWDROP=/var/www/alpha-private/lxc-pw   # www-data drops a 0600 password file here; we consume it
LOGINUSER=alpha                   # the unprivileged in-container account a guarded terminal lands on
LXDNS=/run/snapd/ns/lxd.mnt   # the snap's mount namespace — the storage pool only exists in here
# ⚠️ every nsenter below is wrapped in systemd-run: php-fpm runs with PrivateDevices=yes,
# so it has its own mount namespace and setns() into the snap's fails with EINVAL.
# Going through PID1 escapes that sandbox (same trick as $LXC and vm-disk-mount).
TTYD_THEME='{"background":"#0b0c10","foreground":"#dbe3ec","cursor":"#16c172","cursorAccent":"#0b0c10","selectionBackground":"#2b4b6f","black":"#11131a","red":"#ff6b6b","green":"#4ade80","yellow":"#fbbf24","blue":"#60a5fa","magenta":"#c084fc","cyan":"#22d3ee","white":"#dbe3ec","brightBlack":"#7d8694","brightRed":"#ff8a8a","brightGreen":"#86efac","brightYellow":"#fde047","brightBlue":"#93c5fd","brightMagenta":"#d8b4fe","brightCyan":"#67e8f9","brightWhite":"#ffffff"}'
mkdir -p "$ISODIR" "$INCOMING" "$NVDIR" "$SHOTS" "$LXDMETA"
install -d -m 750 -o www-data -g www-data "$PWDROP" 2>/dev/null || true
chown www-data:www-data "$INCOMING" 2>/dev/null || true; chmod 750 "$INCOMING" 2>/dev/null || true
chmod 755 "$SHOTS" 2>/dev/null || true
# The OWNER's domain (/etc/alpha-panel.conf), never a literal: on a customer's
# server, exposed containers and consoles live under the domain THEY bought.
[ -r /etc/alpha-panel.conf ] && . /etc/alpha-panel.conf
DOMAIN="${PRIMARY_DOMAIN:-$(hostname -d 2>/dev/null || true)}"; DOMAIN="${DOMAIN:-localhost}"
DOMAIN_RE=$(printf '%s' "$DOMAIN" | sed 's/\./\\./g')   # regex-escaped, for grep -E / awk
CERT_EMAIL="${ADMIN_EMAIL:-admin@$DOMAIN}"              # the address 90-tls registers certs with

# Capture the current VM framebuffer to $SHOTS/<name>.png via the HMP monitor. Best-effort.
snap(){
  local mon="$QDIR/$1.mon" ppm="$SHOTS/$1.ppm" png="$SHOTS/$1.png"
  [ -S "$mon" ] || return 1
  python3 - "$mon" "$ppm" <<'PY'
import socket, sys, time
mon, ppm = sys.argv[1], sys.argv[2]
try:
    s = socket.socket(socket.AF_UNIX); s.settimeout(5); s.connect(mon)
    try: s.recv(8192)
    except Exception: pass
    s.sendall(b"screendump " + ppm.encode() + b"\n"); time.sleep(0.6)
    try: s.recv(8192)
    except Exception: pass
    s.close()
except Exception: pass
PY
  [ -f "$ppm" ] || return 1
  convert "$ppm" -resize 640 "$png" 2>/dev/null; local r=$?; rm -f "$ppm"
  [ $r -eq 0 ] && chmod 644 "$png" 2>/dev/null; return $r
}

# Set the "saved" flag (0/1) in a VM's json, adding the field if absent.
set_saved(){
  local j="$QDIR/$1.json"
  if grep -q '"saved"' "$j"; then sed -i "s/\"saved\":[0-9]*/\"saved\":$2/" "$j"
  else sed -i "s/}$/,\"saved\":$2}/" "$j"; fi
}

vname(){ [[ "${1:-}" =~ ^[a-z0-9][a-z0-9-]{0,30}$ ]] || { echo "BAD_NAME"; exit 2; }; }
lxc_present(){ local n="$1" i; systemd-run --quiet --wait --collect --pipe -- /snap/bin/lxd waitready --timeout=15 >/dev/null 2>&1 || true; for i in 1 2 3; do systemd-run --quiet --wait --collect --pipe -- /snap/bin/lxc info "$n" >/dev/null 2>&1 && return 0; sleep 1; done; return 1; }
distro_slug(){ local os desc rel ver s; os=$(systemd-run --quiet --wait --collect --pipe -- /snap/bin/lxc config get "$1" image.os 2>/dev/null); desc=$(systemd-run --quiet --wait --collect --pipe -- /snap/bin/lxc config get "$1" image.description 2>/dev/null); rel=$(systemd-run --quiet --wait --collect --pipe -- /snap/bin/lxc config get "$1" image.release 2>/dev/null); ver=$(printf '%s' "$desc" | grep -oE '[0-9]+\.[0-9]+' | head -1); [ -n "$ver" ] || ver="$rel"; s=$(printf '%s-%s' "$os" "$ver" | tr 'A-Z' 'a-z' | tr -c 'a-z0-9' '-' | sed 's/-\{2,\}/-/g; s/^-//; s/-$//'); [ -n "$s" ] || s=linux; printf '%s' "$s"; }
# Drop a small manifest next to the per-instance console app so the console page
# can badge itself with the real distro (it has no access to the panel's libs).
console_meta(){
  local n="$1" d="$2" s="$3" desc os rel
  # values are stripped of quotes/backslashes rather than escaped — they only ever
  # feed a label, and this keeps the hand-rolled JSON impossible to break.
  desc=$(systemd-run --quiet --wait --collect --pipe -- /snap/bin/lxc config get "$n" image.description 2>/dev/null | tr -d '"\\' || true)
  os=$(systemd-run --quiet --wait --collect --pipe -- /snap/bin/lxc config get "$n" image.os 2>/dev/null | tr -d '"\\' || true)
  rel=$(systemd-run --quiet --wait --collect --pipe -- /snap/bin/lxc config get "$n" image.release 2>/dev/null | tr -d '"\\' || true)
  printf '{"instance":"%s","os_slug":"%s","os":"%s","release":"%s","description":"%s"}\n' \
    "$n" "$s" "$os" "$rel" "$desc" > "$d/meta.json"
  chmod 644 "$d/meta.json" 2>/dev/null || true
}
# Where a container's root filesystem lives on the pool. Only resolvable inside
# the snap's mount namespace; echoes the path, non-zero if it isn't there.
lxd_rootfs(){
  # the glob itself must be expanded INSIDE the namespace — from out here the
  # storage-pools tree isn't mounted, so the pattern matches nothing
  systemd-run --quiet --pipe --wait --collect nsenter --mount="$LXDNS" -- /bin/bash -c '
    n="$1"
    for base in /var/snap/lxd/common/lxd/storage-pools/*/containers; do
      [ -d "$base/$n/rootfs" ] && { printf "%s" "$base/$n/rootfs"; exit 0; }
    done
    exit 1' _ "$1"
}
# A btrfs pool only ENFORCES a container's disk limit once quotas are switched on —
# without this, `-d root,size=` is silently cosmetic. The pool lives in the snap's
# own mount namespace and needs the snap's own btrfs binary. Idempotent, best-effort.
ensure_pool_quota(){
  local pool="/var/snap/lxd/common/lxd/storage-pools/$LXDPOOL" bt=/snap/lxd/current/bin/btrfs ns=/run/snapd/ns/lxd.mnt
  { [ -e "$ns" ] && [ -x "$bt" ]; } || return 0
  if ! nsenter --mount="$ns" -- "$bt" qgroup show -re "$pool" >/dev/null 2>&1; then
    nsenter --mount="$ns" -- "$bt" quota enable "$pool" >/dev/null 2>&1 || return 0
  fi
  # ALWAYS wait for a rescan, not only right after enabling. While one is in
  # flight btrfs accounting is inconsistent and even a chmod on a brand-new
  # container fails with "disk quota exceeded" — which is exactly what the launch
  # does. Deletes start rescans too, so "quotas are already on" is no proof that
  # accounting has settled. Instant no-op when nothing is running.
  nsenter --mount="$ns" -- "$bt" quota rescan -w "$pool" >/dev/null 2>&1 || true
  # Drop qgroups left behind by deleted instances: they keep their old limits
  # forever and make every rescan heavier, widening the window that breaks a
  # launch (14 had piled up before this was added).
  nsenter --mount="$ns" -- "$bt" qgroup show -re "$pool" 2>/dev/null \
    | awk '$NF == "<stale>" { print $1 }' | head -200 | while read -r qg; do
        [ -n "$qg" ] && nsenter --mount="$ns" -- "$bt" qgroup destroy "$qg" "$pool" >/dev/null 2>&1 || true
      done
  return 0
}
# After an instance is removed: drop cached images no remaining instance was
# built from, then fstrim the pool so the loop file hands the freed blocks back
# to the host. Without this the btrfs-on-loop alpha.img only ever grows, so a
# delete never shows up in the panel's disk numbers (File Manager reads du of
# the sparse file, not free space inside the pool). Best-effort by design.
lxd_reclaim(){
  local used names f log=/var/lib/alpha-lxd/reclaim.log
  used=$($LXC list --format json 2>/dev/null | jq -r '.[].config["volatile.base_image"] // empty' | sort -u) || true
  names=$($LXC list --format csv -c n 2>/dev/null) || true
  { echo "== $(date -Is) reclaim"; echo "instances=[$names]"; echo "used=[$used]"; } >> "$log" 2>/dev/null || true
  if [ -z "$used" ] && [ -n "$names" ]; then
    # Instances exist but the in-use query returned nothing → the query failed.
    # Pruning now would delete every cached image (it did, once). Skip it.
    echo "abort: in-use query failed" >> "$log" 2>/dev/null || true
  else
    # image list -c f prints the SHORT fingerprint; base_image is the full hash —
    # compare full-to-full via the JSON output or nothing ever matches.
    # ⚠️ an image with an alpha-* alias is kept on purpose (alpha-cws-base: the code store's workspace image, built once
    # and unused until the first sale) — pruning it would make the next sale rebuild it for minutes
    for f in $($LXC image list --format json 2>/dev/null | jq -r '.[] | select(([.aliases[]?.name] | any(startswith("alpha-"))) | not) | .fingerprint' 2>/dev/null); do
      if printf '%s\n' "$used" | grep -qx "$f"; then
        echo "keep $f" >> "$log" 2>/dev/null || true
      else
        echo "prune $f" >> "$log" 2>/dev/null || true
        $LXC image delete "$f" >/dev/null 2>&1 || true
      fi
    done
  fi
  # btrfs frees a deleted subvolume in the BACKGROUND: trimming straight after the
  # delete punched nothing (pool still "used" 15.5 GiB, 6.7 GiB under a <stale>
  # qgroup). Wait for the cleaner, drop stale qgroups, THEN trim.
  pool_settle
  systemd-run --quiet --pipe --wait --collect -- nsenter --mount="$LXDNS" -- \
    fstrim "/var/snap/lxd/common/lxd/storage-pools/$LXDPOOL" >/dev/null 2>&1 || true
}
pool_settle(){
  local R="/var/snap/lxd/common/lxd/storage-pools/$LXDPOOL" B=/snap/lxd/current/bin/btrfs qg
  systemd-run --quiet --pipe --wait --collect -- nsenter --mount="$LXDNS" -- timeout 300 "$B" subvolume sync "$R" >/dev/null 2>&1 || true
  systemd-run --quiet --pipe --wait --collect -- nsenter --mount="$LXDNS" -- "$B" quota rescan -w "$R" >/dev/null 2>&1 || true
  for qg in $(systemd-run --quiet --pipe --wait --collect -- nsenter --mount="$LXDNS" -- "$B" qgroup show -re "$R" 2>/dev/null | awk '$NF == "<stale>" { print $1 }' | head -200); do
    systemd-run --quiet --pipe --wait --collect -- nsenter --mount="$LXDNS" -- "$B" qgroup destroy "$qg" "$R" >/dev/null 2>&1 || true
  done
  return 0
}
# ======================= nothing an instance owned may outlive it =======================
# 2026-10-02: a container deleted while the pool could not mount (btrfs blacklisted,
# 08-14 → 09-30) lost its LXD database row but kept its 6.7 GB subvolume, plus a
# whole-host console vhost + cert, console app dirs, a devices dir and panel-store
# rows — none of it visible anywhere. Three guards now:
#   1. delete/launch REFUSE while the pool is not mounted (pool_ready)
#   2. after any delete, or a failed launch, lxd_purge removes every trace of the name
#   3. `lxd-orphans --purge` (pool guard, every 10 min, 10-min grace) sweeps leftovers
#      from anything that bypassed the panel (plain `lxc delete`, crashes)
LXDROOT=/var/snap/lxd/common/lxd
LXCONS=/var/www/visualization/lx-console
PRIV=/var/www/alpha-private
# run a command inside the snap's mount namespace (where the pool is mounted)
ins(){ systemd-run --quiet --pipe --wait --collect nsenter --mount="$LXDNS" -- "$@"; }
pool_ready(){
  local st
  st=$($LXC storage list --format json 2>/dev/null | jq -r --arg p "$LXDPOOL" '.[] | select(.name == $p) | .status' 2>/dev/null) || true
  [ "$st" = "Created" ]
}
# Every instance LXD knows, in every project. Returns 1 instead of an empty list when
# the query fails — an empty "known" list would make EVERY instance look orphaned.
known_instances(){
  local j
  j=$($LXC list --all-projects --format json 2>/dev/null) || return 1
  printf '%s' "$j" | jq -e 'type == "array"' >/dev/null 2>&1 || return 1
  printf '%s' "$j" | jq -r '.[].name'
}
iname_ok(){ [[ "${1:-}" =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,62}$ ]]; }
# Remove a tree inside the pool: nested subvolumes first (docker's btrfs driver
# nests them inside a rootfs; btrfs-progs 6.6 has no recursive delete), then the
# subvolume itself, then any plain directory left. Only the four instance roots.
pool_rm_tree(){
  local rel="$1"
  case "$rel" in containers/?*|containers-snapshots/?*|virtual-machines/?*|virtual-machines-snapshots/?*) ;; *) return 2 ;; esac
  case "$rel" in *..*|*/) return 2 ;; esac
  ins /bin/bash -c '
    B=/snap/lxd/current/bin/btrfs; R="$1"; rel="$2"
    [ -e "$R/$rel" ] || exit 0
    "$B" subvolume list "$R" 2>/dev/null | sed -n "s/^.* path //p" \
      | awk -v r="$rel" "\$0 == r || index(\$0, r \"/\") == 1 { print length(\$0) \" \" \$0 }" \
      | sort -rn | cut -d" " -f2- | while IFS= read -r sv; do
          "$B" subvolume delete "$R/$sv" >/dev/null 2>&1 || true
        done
    rm -rf --one-file-system -- "$R/$rel"
    [ ! -e "$R/$rel" ]' _ "$LXDROOT/storage-pools/$LXDPOOL" "$rel"
}
# Drop rows for instances that no longer exist from the panel's own JSON stores
# (www-data owned; rewritten atomically, owner and mode kept, only when changed).
panel_state_prune(){
  local known="$1" live f tmp
  live=$(printf '%s\n' "$known" | jq -R 'select(length > 0)' | jq -s -c .)
  for f in "$PRIV/lxconsole-web.json" "$PRIV/lxd-web.json" "$PRIV/lxd-stats.json"; do
    [ -s "$f" ] || continue
    tmp=$(mktemp "$f.XXXXXX") || continue
    if jq --argjson live "$live" 'if type == "object" then with_entries(select(.key as $k | any($live[]; . == $k))) else . end' "$f" > "$tmp" 2>/dev/null \
       && ! cmp -s <(jq -S . "$f" 2>/dev/null) <(jq -S . "$tmp"); then
      chown --reference="$f" "$tmp"; chmod --reference="$f" "$tmp"; mv -f "$tmp" "$f"
      echo "pruned $(basename "$f")"
    else rm -f "$tmp"; fi
  done
  for f in "$PRIV"/lxc-policy/*.json; do
    [ -e "$f" ] || continue
    printf '%s\n' "$known" | grep -qxF "$(basename "$f" .json)" || { rm -f "$f"; echo "removed lxc-policy/$(basename "$f")"; }
  done
}
# Every trace of ONE instance name, which must no longer exist in LXD.
lxd_purge(){
  local n="$1" known conf fq certs="" changed=0 d
  iname_ok "$n" || return 2
  known=$(known_instances) || { echo "PURGE_ABORT $n: LXD instance list failed"; return 1; }
  printf '%s\n' "$known" | grep -qxF "$n" && { echo "PURGE_SKIP $n: still an instance"; return 1; }
  # terminal process + its port file + per-instance helper state
  systemctl stop "alpha-ttyd-$n" >/dev/null 2>&1 || true
  systemctl reset-failed "alpha-ttyd-$n" >/dev/null 2>&1 || true
  rm -f "/run/alpha-ttyd-$n.port" "$LXDMETA/$n.user"
  # vhosts the panel wrote for it: a whole host (web expose / console expose) or a
  # console block attached inside another site
  for conf in /etc/nginx/sites-available/sub-*; do
    [ -f "$conf" ] || continue
    if grep -qE "^# ALPHA-LX(D|CONSOLE) instance=$n\$" "$conf"; then
      for fq in $(sed -n 's/^[[:space:]]*server_name[[:space:]]\{1,\}\([^;]*\);.*/\1/p' "$conf" | tr ' ' '\n' | sort -u); do certs="$certs $fq"; done
      rm -f "$conf" "/etc/nginx/sites-enabled/$(basename "$conf")"; changed=1; echo "removed vhost $(basename "$conf")"
    elif grep -qF "# ALPHA-LXCONSOLE-$n-START" "$conf"; then
      cp -p "$conf" "$conf.purgebak"
      sed -i "/# ALPHA-LXCONSOLE-$n-START/,/# ALPHA-LXCONSOLE-$n-END/d" "$conf"; changed=1; echo "detached console from $(basename "$conf")"
    fi
  done
  if [ "$changed" = 1 ]; then
    if nginx -t >/dev/null 2>&1; then
      systemctl reload nginx >/dev/null 2>&1 || true
      rm -f /etc/nginx/sites-available/*.purgebak
    else
      for b in /etc/nginx/sites-available/*.purgebak; do [ -e "$b" ] && mv -f "$b" "${b%.purgebak}"; done
      echo "WARN $n: nginx -t failed after detaching — attached blocks restored"
    fi
  fi
  # certificates for those hosts, once no enabled site serves the name any more
  for fq in $certs; do
    [ -d "/etc/letsencrypt/live/$fq" ] || continue
    grep -RqsE "server_name[^;]*[[:space:]]$fq([[:space:];]|\$)" /etc/nginx/sites-enabled/ && continue
    certbot delete --cert-name "$fq" --non-interactive >/dev/null 2>&1 && echo "deleted cert $fq"
  done
  # the console app folder(s), then any distro folder left empty
  for d in "$LXCONS"/*/"$n"; do [ -d "$d" ] && rm -rf -- "$d" && echo "removed console dir ${d#$LXCONS/}"; done
  for d in "$LXCONS"/*/; do rmdir "$d" 2>/dev/null || true; done
  # LXD's own per-instance files (it removes these itself on a clean delete)
  for d in "$LXDROOT/devices/$n" "$LXDROOT/logs/$n" "$LXDROOT/security/seccomp/$n" "$LXDROOT/backups/instances/$n" "$LXDROOT/security/apparmor/profiles/lxd-$n"; do
    [ -e "$d" ] && rm -rf -- "$d" && echo "removed ${d#$LXDROOT/}"
  done
  for d in "$LXDROOT"/security/apparmor/profiles/lxd_forkproxy-*_"$n"; do [ -e "$d" ] && rm -f -- "$d"; done
  # the disk itself, if LXD left it behind
  for d in containers containers-snapshots virtual-machines virtual-machines-snapshots; do
    if ins test -e "$LXDROOT/storage-pools/$LXDPOOL/$d/$n"; then
      if pool_rm_tree "$d/$n"; then echo "removed pool $d/$n"; else echo "WARN $n: could not fully remove pool $d/$n"; fi
    fi
  done
  panel_state_prune "$known"
  return 0
}
# Names that have traces somewhere but no instance behind them.
lxd_orphan_names(){
  local known="$1" base p
  {
    for base in containers containers-snapshots virtual-machines virtual-machines-snapshots; do
      ins /bin/sh -c 'ls -1 "$1" 2>/dev/null' _ "$LXDROOT/storage-pools/$LXDPOOL/$base" || true
    done
    for p in "$LXDROOT"/devices/* "$LXDROOT"/backups/instances/* "$LXCONS"/*/*/; do [ -d "$p" ] && basename "$p"; done
    for p in "$LXDROOT"/logs/*/; do [ -d "$p" ] && basename "$p"; done
    for p in /run/alpha-ttyd-*.port; do [ -e "$p" ] && { p=${p#/run/alpha-ttyd-}; echo "${p%.port}"; }; done
    for p in "$LXDMETA"/*.user; do [ -e "$p" ] && basename "$p" .user; done
    grep -hoE '^# ALPHA-LX(D|CONSOLE) instance=[A-Za-z0-9-]+$' /etc/nginx/sites-available/sub-* 2>/dev/null | sed 's/.*instance=//' || true
    grep -hoE '# ALPHA-LXCONSOLE-[A-Za-z0-9-]+-START' /etc/nginx/sites-available/sub-* 2>/dev/null | sed 's/# ALPHA-LXCONSOLE-//; s/-START$//' || true
  } | sort -u | while IFS= read -r p; do
    iname_ok "$p" || continue
    printf '%s\n' "$known" | grep -qxF "$p" || echo "$p"
  done
}
vm_os_slug(){ local iso os ver s; iso=$(sed -n 's/.*"iso"[: ]*"\([^"]*\)".*/\1/p' "$QDIR/$1.json" 2>/dev/null | head -1); iso=$(printf '%s' "$iso" | sed 's/\.iso$//I' | tr 'A-Z' 'a-z'); os=$(printf '%s' "$iso" | grep -oE '^[a-z]+' | head -1); ver=$(printf '%s' "$iso" | grep -oE '[0-9]+\.[0-9]+' | head -1); s=$(printf '%s-%s' "$os" "$ver" | tr -c 'a-z0-9' '-' | sed 's/-\{2,\}/-/g; s/^-//; s/-$//'); [ -n "$s" ] || s=vm; printf '%s' "$s"; }
vimage(){ [[ "${1:-}" =~ ^[a-z0-9][a-z0-9:._/-]{0,40}$ ]] || { echo "BAD_IMAGE"; exit 2; }; }
vint(){ [[ "${1:-}" =~ ^[0-9]+$ ]] || { echo "BAD_INT"; exit 2; }; }
viso(){ [[ "${1:-}" =~ ^[A-Za-z0-9._-]+\.iso$ ]] || { echo "BAD_ISO"; exit 2; }; }
pid_alive(){ [ -f "$1" ] && kill -0 "$(cat "$1")" 2>/dev/null; }
next_vnc(){ local m=0 f d; for f in "$QDIR"/*.json; do [ -e "$f" ] || continue; d=$(sed -n "s/.*\"vnc\":\([0-9]*\).*/\1/p" "$f"); [ "${d:-0}" -gt "$m" ] && m=$d; done; echo $((m+1)); }

# Obtain a TLS cert for <fqdn> and make sure a browser will actually trust it.
# Let's Encrypt currently issues from two intermediates, YE1 and YE2; certs from
# the YE1 batch are being rejected by Firefox for insufficient Certificate
# Transparency, while YE2 is accepted. So: issue, and if we drew YE1, force-renew
# (up to 2x) to land YE2. A restart (not reload) is required for nginx workers to
# swap a re-issued cert at an unchanged path. Returns 0 if HTTPS is live.
issue_cert(){
  local fqdn="$1" tries=0 iss renewed=0
  systemctl reload nginx   # serve the new http vhost so HTTP-01 validation resolves here
  certbot --nginx -d "$fqdn" --non-interactive --agree-tos --no-eff-email -m "$CERT_EMAIL" --redirect >/dev/null 2>&1 || return 1
  while :; do
    iss=$(openssl x509 -in "/etc/letsencrypt/live/$fqdn/cert.pem" -noout -issuer 2>/dev/null || true)
    case "$iss" in *YE1*) : ;; *) break ;; esac
    [ "$tries" -ge 2 ] && break
    tries=$((tries+1)); renewed=1
    certbot renew --cert-name "$fqdn" --force-renewal >/dev/null 2>&1 || break
  done
  # a re-issued cert keeps the same path, so workers only pick it up on a full restart
  if [ "$renewed" -eq 1 ]; then systemctl restart nginx; else systemctl reload nginx; fi
  return 0
}

a="${1:-}"; shift || true
case "$a" in
  lxd-list)    $LXC list --format json ;;
  lxd-launch)
    vname "${1:-}"; vimage "${2:-}"; vint "${3:-}"; vint "${4:-}"; vint "${5:-}"
    [ "$3" -ge 1 ] && [ "$3" -le 100 ] || { echo BAD_SIZE; exit 2; }
    [ "$4" -ge 128 ] && [ "$4" -le 16384 ] || { echo BAD_MEM; exit 2; }
    [ "$5" -ge 1 ] && [ "$5" -le "$MAXCPU" ] || { echo BAD_CPU; exit 2; }
    pool_ready || { echo "Storage pool '$LXDPOOL' is not mounted right now - try again in a minute."; exit 3; }
    # the cleanup below deletes whatever carries this name, so it must be ours
    lxc_present "$1" && { echo "An instance called '$1' already exists."; exit 2; }
    ensure_pool_quota
    t0=$(date +%s)
    # ⚠️ `out=$(cmd); rc=$?` under set -e exits on the FAILING assignment, so the
    # retry below never ran and the error text was swallowed. `|| rc=$?` keeps both.
    rc=0; out=$($LXC launch "$2" "$1" -s "$LXDPOOL" -c limits.cpu="$5" -c limits.memory="${4}MiB" -d root,size="${3}GiB" </dev/null 2>&1) || rc=$?
      # "disk quota exceeded" here means btrfs accounting was mid-rescan, NOT a
      # full disk (the pool is usually near-empty when it happens). Settle the
      # quotas, clear the half-created instance, try once more.
      if [ $rc -ne 0 ] && printf '%s' "$out" | grep -qi 'quota exceeded'; then
        $LXC delete -f "$1" >/dev/null 2>&1 || true
        ensure_pool_quota
        sleep 2
        rc=0; out=$($LXC launch "$2" "$1" -s "$LXDPOOL" -c limits.cpu="$5" -c limits.memory="${4}MiB" -d root,size="${3}GiB" </dev/null 2>&1) || rc=$?
      fi
      printf '%s\n' "$out"
      if [ $rc -ne 0 ]; then
        # a failed create leaves nothing: the half-made instance, its disk, and an
        # image downloaded only for it. Belt and braces: only an instance LXD says
        # was created during THIS launch is ever deleted here.
        ca=$($LXC list --format json 2>/dev/null | jq -r --arg n "$1" '.[] | select(.name == $n) | .created_at' 2>/dev/null) || true
        if [ -n "$ca" ]; then
          ce=$(date -d "$ca" +%s 2>/dev/null) || ce=0
          [ "$ce" -ge $(( t0 - 5 )) ] && { $LXC delete -f "$1" >/dev/null 2>&1 || true; }
        fi
        lxd_purge "$1" >/dev/null 2>&1 || true
        lxd_reclaim || true
        exit $rc
      fi
      ;;
  lxd-limits)
    vname "${1:-}"; vint "${2:-}"; vint "${3:-}"; vint "${4:-}"
    [ "$2" -ge 1 ] && [ "$2" -le "$MAXCPU" ] || { echo BAD_CPU; exit 2; }
    [ "$3" -ge 128 ] && [ "$3" -le 16384 ] || { echo BAD_MEM; exit 2; }
    [ "$4" -ge 1 ] && [ "$4" -le 100 ] || { echo BAD_SIZE; exit 2; }
    ensure_pool_quota
    $LXC config set "$1" limits.cpu "$2"
    $LXC config set "$1" limits.memory "${3}MiB"
    if $LXC config device set "$1" root size "${4}GiB" 2>/dev/null || $LXC config device override "$1" root size="${4}GiB" 2>/dev/null; then dz=ok; else dz=skip; fi
    echo "OK disk=$dz" ;;
  lxd-start)   vname "${1:-}"; $LXC start "$1" ;;
  lxd-stop)    vname "${1:-}"; $LXC stop -f "$1" ;;
  lxd-restart) vname "${1:-}"; $LXC restart -f "$1" ;;
  lxd-delete)
    vname "${1:-}"
    # Deleting while the pool is unmounted drops LXD's record but strands the disk
    # (how vscode-lxd's 6.7 GB was orphaned). Refuse instead.
    pool_ready || { echo "Storage pool '$LXDPOOL' is not mounted right now - deleting would leave the disk behind. Try again in a minute."; exit 3; }
    if lxc_present "$1"; then $LXC delete -f "$1"; fi
    lxd_purge "$1" >/dev/null 2>&1 || true
    lxd_reclaim
    ;;
  lxd-orphans)
    # [--purge] [--grace SEC] — list names with leftovers but no instance; with
    # --purge remove them. --grace: purge only names first seen >= SEC ago (the
    # pool guard uses 600 so nothing mid-create is ever touched).
    purge=0; grace=0
    while [ $# -gt 0 ]; do case "$1" in --purge) purge=1 ;; --grace) vint "${2:-}"; grace="$2"; shift ;; *) echo BAD_ARG; exit 2 ;; esac; shift; done
    pool_ready || { echo "POOL_NOT_READY"; exit 3; }
    known=$(known_instances) || { echo "LIST_FAILED"; exit 3; }
    exec 9>"$LXDMETA/.purge.lock"; flock -w 120 9 || { echo "BUSY"; exit 3; }
    orphans=$(lxd_orphan_names "$known") || true   # set -e + pipefail: a grep finding nothing must not abort
    seen="$LXDMETA/orphans.seen"; now=$(date +%s); newseen=""
    for n in $orphans; do
      first=$(awk -v n="$n" '$1 == n { print $2 }' "$seen" 2>/dev/null | head -1) || true; [ -n "$first" ] || first=$now
      newseen="$newseen$n $first"$'\n'
      age=$(( now - first ))
      if [ "$purge" = 1 ] && [ "$age" -ge "$grace" ]; then
        echo "PURGE $n (orphaned ${age}s)"; lxd_purge "$n" 2>&1 | sed 's/^/  /' || true
      else
        echo "ORPHAN $n (seen ${age}s)"
      fi
    done
    printf '%s' "$newseen" > "$seen"
    if [ "$purge" = 1 ]; then
      panel_state_prune "$known" | sed 's/^/  /' || true
      [ -n "$orphans" ] && lxd_reclaim
    fi
    echo "OK"
    ;;
  lxd-console)
    # <name> [maxsec] — maxsec caps how long the terminal process may live, so the
    # panel's "max session" setting is enforced by systemd, not just by a cookie.
    vname "${1:-}"; pf="/run/alpha-ttyd-$1.port"
    maxsec="${2:-3600}"; vint "$maxsec"
    [ "$maxsec" -ge 300 ] && [ "$maxsec" -le 86400 ] || maxsec=3600
    # Who the terminal logs in as: root when the panel is the only control surface,
    # or the unprivileged LOGINUSER once a terminal password is set (so that
    # installing/updating goes through sudo and has to be authorised).
    shu=$(cat "$LXDMETA/$1.user" 2>/dev/null || true); [ -n "$shu" ] || shu=root
    systemctl stop "alpha-ttyd-$1" 2>/dev/null || true; systemctl reset-failed "alpha-ttyd-$1" 2>/dev/null || true; sleep 0.3
    port=""; for p in $(seq 7700 7799); do ss -ltn 2>/dev/null | grep -q ":$p " || { port=$p; break; }; done
    [ -n "$port" ] || { echo NO_PORT; exit 2; }
    systemd-run --quiet --unit="alpha-ttyd-$1" --property=RuntimeMaxSec="$maxsec" --collect \
      /usr/bin/ttyd -p "$port" -i 127.0.0.1 -W -b "/lxconsole/$port" \
      -t disableLeaveAlert=true -t "titleFixed=$1" -t fontSize=14 -t lineHeight=1.25 \
      -t "fontFamily=JetBrains Mono, Cascadia Code, SF Mono, Menlo, Consolas, monospace" \
      -t "theme=$TTYD_THEME" \
      /snap/bin/lxc exec "$1" -- su - "$shu" >/dev/null 2>&1
    echo "$port" > "$pf"; sleep 1; echo "$port" ;;
  lxd-banner)
    # <name> — greet the shell with the allocation the panel actually set, since
    # lscpu reads host sysfs and will always name the physical CPU underneath.
    vname "${1:-}"
    cpu=$($LXC config get "$1" limits.cpu 2>/dev/null || true); [ -n "$cpu" ] || cpu="?"
    mem=$($LXC config get "$1" limits.memory 2>/dev/null || true); [ -n "$mem" ] || mem="?"
    dsk=$($LXC config device get "$1" root size 2>/dev/null || true); [ -n "$dsk" ] || dsk="?"
    desc=$($LXC config get "$1" image.description 2>/dev/null | tr -d "\\\\'\"" || true)
    for i in 1 2 3 4 5 6 7 8 9 10; do $LXC exec "$1" -- true >/dev/null 2>&1 && break; sleep 2; done
    tmpf=$(mktemp)
    {
      printf '#!/bin/sh\n'
      printf '# Written by the Alpha panel — the resources this container was given.\n'
      printf 'printf "\\n  \\033[1m%%s\\033[0m  ·  %%s\\n" "%s" "%s"\n' "$1" "${desc:-LXD container}"
      printf 'printf "  \\033[38;5;245mAllocated:\\033[0m %%s vCPU  ·  %%s RAM  ·  %%s disk\\n" "%s" "%s" "%s"\n' "$cpu" "$mem" "$dsk"
      printf 'printf "  \\033[38;5;245mHost-enforced — free/nproc report these limits; lscpu still names the physical CPU.\\033[0m\\n\\n"\n'
    } > "$tmpf"
    $LXC file push "$tmpf" "$1/etc/profile.d/00-alpha-alloc.sh" --mode 0755 >/dev/null 2>&1 || { rm -f "$tmpf"; echo PUSH_FAIL; exit 2; }
    rm -f "$tmpf"
    # Command audit. Logged from inside via the prompt hook rather than by taping
    # the tty on the host: this records commands only, so a password typed at a
    # sudo prompt is never captured — a host-side keystroke log would catch it.
    tmpf=$(mktemp)
    cat > "$tmpf" <<'AUDIT'
# Written by the Alpha panel — records interactive commands for the panel's
# session view. Commands only; never prompt input such as sudo passwords.
if [ -n "${BASH_VERSION:-}" ]; then
  case $- in *i*)
    __alpha_audit() {
      local h; h=$(history 1 2>/dev/null) || return 0
      [ "$h" = "${__ALPHA_LAST:-}" ] && return 0
      __ALPHA_LAST="$h"
      h=${h#"${h%%[![:space:]]*}"}; h=${h#* }
      h=${h#"${h%%[![:space:]]*}"}
      [ -n "$h" ] && printf '%s\t%s\t%s\n' "$(date +%s)" "${USER:-?}" "$h" >> /var/log/alpha-cmd.log 2>/dev/null
      return 0
    }
    case "${PROMPT_COMMAND:-}" in
      *__alpha_audit*) : ;;
      *) PROMPT_COMMAND="__alpha_audit${PROMPT_COMMAND:+; $PROMPT_COMMAND}" ;;
    esac
  ;; esac
fi
AUDIT
    $LXC file push "$tmpf" "$1/etc/profile.d/01-alpha-audit.sh" --mode 0755 >/dev/null 2>&1 || true
    rm -f "$tmpf"
    $LXC exec "$1" -- sh -c 'touch /var/log/alpha-cmd.log && chmod 0622 /var/log/alpha-cmd.log' >/dev/null 2>&1 || true
    echo OK ;;
  lxd-cmdlog)
    # <name> [n] — the last n audited commands (tab-separated: epoch, user, command)
    vname "${1:-}"; n="${2:-60}"; vint "$n"; [ "$n" -ge 1 ] && [ "$n" -le 500 ] || n=60
    $LXC exec "$1" -- sh -c "tail -n $n /var/log/alpha-cmd.log 2>/dev/null" 2>/dev/null || true ;;
  lxd-fs-ls)
    # <name> [relpath] — list a container's filesystem READ-ONLY.
    # An LXD container's rootfs is a plain directory on the pool, but the pool is
    # mounted inside the SNAP's mount namespace, so it is invisible from here and
    # every read has to run through nsenter. Works for stopped instances too — no
    # lxc exec involved, so nothing in the guest is started or modified.
    vname "${1:-}"; rel="${2:-/}"
    root=$(lxd_rootfs "$1") || { echo NO_ROOTFS; exit 2; }
    out=$(systemd-run --quiet --pipe --wait --collect nsenter --mount="$LXDNS" -- /bin/bash -c '
      root="$1"; rel="$2"
      target=$(realpath -m "$root/$rel" 2>/dev/null)
      case "$target" in "$root"|"$root"/*) : ;; *) echo BAD_PATH; exit 2 ;; esac
      [ -d "$target" ] || { echo NOT_DIR; exit 2; }
      echo "__OK__"
      find "$target" -maxdepth 1 -mindepth 1 -printf "%y\t%s\t%TF %TH:%TM\t%f\n" 2>/dev/null | sort -t "	" -k1,1r -k4,4f
    ' _ "$root" "$rel" 2>/dev/null) || { echo "$out"; exit 2; }
    printf '%s\n' "$out" ;;
  lxd-fs-copyout)
    # <name> <relpath> <dest> — copy one regular file out for download (read-only).
    vname "${1:-}"; rel="${2:-}"; dest="${3:-}"
    [ -n "$rel" ] && [ -n "$dest" ] || { echo BAD_ARGS; exit 2; }
    case "$dest" in /run/alpha-lxdfs/*) : ;; *) echo BAD_DEST; exit 2 ;; esac
    root=$(lxd_rootfs "$1") || { echo NO_ROOTFS; exit 2; }
    mkdir -p "$(dirname "$dest")"
    # the guest file is read INSIDE the namespace and the bytes are redirected out
    # here, because /run in there is not the /run php-fpm can see
    systemd-run --quiet --pipe --wait --collect nsenter --mount="$LXDNS" -- /bin/bash -c '
      root="$1"; rel="$2"
      target=$(realpath -m "$root/$rel" 2>/dev/null)
      case "$target" in "$root"/*) : ;; *) exit 3 ;; esac
      [ -f "$target" ] || exit 4
      exec cat -- "$target"
    ' _ "$root" "$rel" > "$dest" 2>/dev/null || { rc=$?; rm -f "$dest"; [ "$rc" = 3 ] && echo BAD_PATH || { [ "$rc" = 4 ] && echo NOT_FILE || echo COPY_FAIL; }; exit 2; }
    chmod 0640 "$dest" 2>/dev/null || true
    chown www-data:www-data "$dest" 2>/dev/null || true
    echo "OK $dest" ;;
  lxd-shelluser)
    vname "${1:-}"; shu=$(cat "$LXDMETA/$1.user" 2>/dev/null || true); printf '%s' "${shu:-root}"; echo ;;
  lxd-secure)
    # <name> <admin|guarded> [pwfile] — choose the terminal's login identity.
    #   admin   = land on root, no password anywhere; every control is the panel's.
    #   guarded = land on LOGINUSER, whose password (read from a 0600 drop file that
    #             www-data wrote, never from argv) is what sudo asks for on install/update.
    vname "${1:-}"; mode="${2:-}"
    case "$mode" in admin|guarded) : ;; *) echo BAD_MODE; exit 2 ;; esac
    lxc_present "$1" || { echo NO_INSTANCE; exit 2; }
    if [ "$mode" = admin ]; then
      printf 'root' > "$LXDMETA/$1.user"
      $LXC exec "$1" -- sh -c "id $LOGINUSER >/dev/null 2>&1 && passwd -l $LOGINUSER >/dev/null 2>&1 || true" >/dev/null 2>&1 || true
      echo OK; exit 0
    fi
    pwf="${3:-}"
    [ -n "$pwf" ] || { echo NO_PW; exit 2; }
    case "$pwf" in "$PWDROP"/*) : ;; *) echo BAD_PWFILE; exit 2 ;; esac   # only ever read from the drop dir
    [ -f "$pwf" ] || { echo NO_PW; exit 2; }
    pw=$(cat "$pwf"); rm -f "$pwf"
    [ "${#pw}" -ge 8 ] || { echo WEAK_PW; exit 2; }
    # a freshly launched container needs a moment before exec/network work
    for i in 1 2 3 4 5 6 7 8 9 10; do $LXC exec "$1" -- true >/dev/null 2>&1 && break; sleep 2; done
    $LXC exec "$1" -- sh -c "
      set +e
      if ! id $LOGINUSER >/dev/null 2>&1; then
        if command -v useradd >/dev/null 2>&1; then
          useradd -m -s /bin/bash $LOGINUSER 2>/dev/null || useradd -m $LOGINUSER
        elif command -v adduser >/dev/null 2>&1; then
          adduser -D $LOGINUSER
        fi
      fi
      if ! command -v sudo >/dev/null 2>&1; then
        if command -v apt-get >/dev/null 2>&1; then DEBIAN_FRONTEND=noninteractive apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo
        elif command -v apk >/dev/null 2>&1; then apk add --no-cache sudo
        elif command -v dnf >/dev/null 2>&1; then dnf install -y -q sudo
        elif command -v yum >/dev/null 2>&1; then yum install -y -q sudo
        fi
      fi
      for g in sudo wheel; do
        getent group \$g >/dev/null 2>&1 || continue
        usermod -aG \$g $LOGINUSER 2>/dev/null || addgroup $LOGINUSER \$g 2>/dev/null
      done
      rm -f /etc/sudoers.d/90-alpha-nopasswd   # never let sudo skip the password we just set
      exit 0" >/dev/null 2>&1 || true
    # push the credential in as a file rather than piping it through systemd-run
    tmpf=$(mktemp); printf '%s:%s\n' "$LOGINUSER" "$pw" > "$tmpf"; chmod 600 "$tmpf"
    $LXC file push "$tmpf" "$1/root/.alpha-pw" --mode 0600 >/dev/null 2>&1 || { rm -f "$tmpf"; echo PUSH_FAIL; exit 2; }
    rm -f "$tmpf"
    if ! $LXC exec "$1" -- sh -c 'chpasswd < /root/.alpha-pw; rc=$?; rm -f /root/.alpha-pw; exit $rc' >/dev/null 2>&1; then
      $LXC exec "$1" -- rm -f /root/.alpha-pw >/dev/null 2>&1 || true
      echo PW_FAIL; exit 2
    fi
    printf '%s' "$LOGINUSER" > "$LXDMETA/$1.user"
    if $LXC exec "$1" -- sh -c 'command -v sudo >/dev/null 2>&1' >/dev/null 2>&1; then echo OK; else echo OK_NOSUDO; fi ;;
  lxd-console-stop)
    vname "${1:-}"; systemctl stop "alpha-ttyd-$1" 2>/dev/null || true; rm -f "/run/alpha-ttyd-$1.port"; echo OK ;;
  lxd-conlog)
    vname "${1:-}"
    $LXC console "$1" --show-log 2>/dev/null | sed "s/\x1b\[[0-9;?]*[a-zA-Z]//g; s/\x1b[()][AB0]//g" | tr -cd "\11\12\15\40-\176" | grep -v "^$" | tail -n 14 ;;

  lxd-expose)
    # publish a container's web port at <sub>.<domain> via an LXD proxy device + nginx vhost + TLS
    vname "${1:-}"; sub="${2:-}"; cport="${3:-80}"
    [[ "$sub" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo BAD_SUB; exit 2; }
    case "$sub" in www|mail) echo BAD_SUB; exit 2 ;; esac
    { [[ "$cport" =~ ^[0-9]+$ ]] && [ "$cport" -ge 1 ] && [ "$cport" -le 65535 ]; } || { echo BAD_PORT; exit 2; }
    lxc_present "$1" || { echo NO_INSTANCE; exit 2; }
    fqdn="$sub.$DOMAIN"; conf="/etc/nginx/sites-available/sub-$sub"
    if [ -f "$conf" ] && ! grep -q "# ALPHA-LXD" "$conf"; then echo SUB_TAKEN; exit 2; fi
    hp=""; for p in $(seq 20000 20090); do ss -ltn 2>/dev/null | grep -q ":$p " || { hp=$p; break; }; done
    [ -n "$hp" ] || { echo NO_PORT; exit 2; }
    /snap/bin/lxc config device remove "$1" alpha-web >/dev/null 2>&1 || true
    /snap/bin/lxc config device add "$1" alpha-web proxy listen=tcp:127.0.0.1:$hp connect=tcp:127.0.0.1:$cport >/dev/null 2>&1 || { echo PROXY_FAIL; exit 2; }
    cat > "$conf" <<NGINX
# ALPHA-LXD instance=$1 hostport=$hp cport=$cport
server {
    listen 80; listen [::]:80;
    server_name $fqdn;
    location / {
        proxy_pass http://127.0.0.1:$hp;
        proxy_http_version 1.1;
        proxy_set_header Upgrade \$http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;
    }
}
NGINX
    ln -sf "$conf" "/etc/nginx/sites-enabled/sub-$sub"
    if ! nginx -t >/dev/null 2>&1; then rm -f "$conf" "/etc/nginx/sites-enabled/sub-$sub"; /snap/bin/lxc config device remove "$1" alpha-web >/dev/null 2>&1 || true; echo NGINX_FAIL; exit 2; fi
    systemctl reload nginx
    if certbot --nginx -d "$fqdn" --non-interactive --agree-tos --no-eff-email -m "$CERT_EMAIL" --redirect >/dev/null 2>&1; then
      systemctl reload nginx; echo "OK_HTTPS $hp"
    else
      echo "OK_HTTP $hp"
    fi ;;
  lxd-unexpose)
    vname "${1:-}"; sub="${2:-}"
    [[ "$sub" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo BAD_SUB; exit 2; }
    rm -f "/etc/nginx/sites-available/sub-$sub" "/etc/nginx/sites-enabled/sub-$sub"
    /snap/bin/lxc config device remove "$1" alpha-web >/dev/null 2>&1 || true
    { nginx -t >/dev/null 2>&1 && systemctl reload nginx; } || true
    echo OK ;;

  lxd-console-expose)
    # serve a container's ttyd terminal at <sub>.<domain> (one-time-token -> signed-cookie gate + /lxconsole proxy) + TLS
    vname "${1:-}"; sub="${2:-}"
    [[ "$sub" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo BAD_SUB; exit 2; }
    case "$sub" in www|mail|ns1|ns2|smtp|imap|pop|webmail|autodiscover|autoconfig|localhost) echo RESERVED; exit 2 ;; esac
    lxc_present "$1" || { echo NO_INSTANCE; exit 2; }
    [ -f /var/www/visualization/lx-console/index.php ] && [ -f /var/www/visualization/lx-console/gate.php ] || { echo NO_APP; exit 2; }
    fqdn="$sub.$DOMAIN"; conf="/etc/nginx/sites-available/sub-$sub"
    if [ -f "$conf" ] && ! grep -q "# ALPHA-LXCONSOLE" "$conf"; then echo SUB_TAKEN; exit 2; fi
    # shared secret + one-time-token dir (php-fpm = www-data reads/writes both)
    install -d -m 750 -o www-data -g www-data /var/www/alpha-private/lxc-tok
    if [ ! -f /var/www/alpha-private/lxc-console.secret ]; then
      ( umask 077; openssl rand -hex 32 > /var/www/alpha-private/lxc-console.secret )
      chown www-data:www-data /var/www/alpha-private/lxc-console.secret; chmod 640 /var/www/alpha-private/lxc-console.secret
    fi
    distro=$(distro_slug "$1"); appdir="/var/www/visualization/lx-console/$distro/$1"
    install -d -m 755 "$appdir"
    ln -sfn /var/www/visualization/lx-console/index.php "$appdir/index.php"
    ln -sfn /var/www/visualization/lx-console/gate.php "$appdir/gate.php"
    chown -h www-data:www-data "$appdir" "$appdir/index.php" "$appdir/gate.php" 2>/dev/null || true
    console_meta "$1" "$appdir" "$distro"
    cat > "$conf" <<NGINX
# ALPHA-LXCONSOLE instance=$1
server {
    listen 80; listen [::]:80;
    server_name $fqdn;
    root $appdir;
    add_header X-Robots-Tag "noindex, nofollow" always;
    location = /gate {
        internal;
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $appdir/gate.php;
        fastcgi_param SCRIPT_NAME /gate.php;
    }
    location ~ ^/lxconsole/(?<tport>77[0-9][0-9])(/|\$) {
        auth_request /gate;
        error_page 401 403 = @nope;
        proxy_pass http://127.0.0.1:\$tport;
        proxy_http_version 1.1;
        proxy_set_header Upgrade \$http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host \$host;
        proxy_read_timeout 3600s;
    }
    location @nope { return 403; }
    location = / {
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $appdir/index.php;
        fastcgi_param SCRIPT_NAME /index.php;
    }
    location / { return 404; }
}
NGINX
    ln -sf "$conf" "/etc/nginx/sites-enabled/sub-$sub"
    if ! nginx -t >/dev/null 2>&1; then rm -f "$conf" "/etc/nginx/sites-enabled/sub-$sub"; echo NGINX_FAIL; exit 2; fi
    if issue_cert "$fqdn"; then echo "OK_HTTPS"; else echo "OK_HTTP"; fi ;;
  lxd-console-unexpose)
    vname "${1:-}"; sub="${2:-}"
    [[ "$sub" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo BAD_SUB; exit 2; }
    rm -f "/etc/nginx/sites-available/sub-$sub" "/etc/nginx/sites-enabled/sub-$sub"
    for d in /var/www/visualization/lx-console/*/"$1"; do [ -d "$d" ] && rm -rf "$d"; done; rmdir /var/www/visualization/lx-console/*/ 2>/dev/null || true
    { nginx -t >/dev/null 2>&1 && systemctl reload nginx; } || true
    echo OK ;;
  lxd-console-attach)
    # mount a container's ttyd terminal inside an existing site: <parentsub>.<domain>/<path>/
    vname "${1:-}"; psub="${2:-}"; cpath="${3:-}"
    [[ "$psub" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo BAD_SUB; exit 2; }
    [[ "$cpath" =~ ^[a-z0-9][a-z0-9-]{0,40}$ ]] || { echo BAD_PATH; exit 2; }
    pconf="/etc/nginx/sites-available/sub-$psub"
    [ -f "$pconf" ] || { echo NO_PARENT; exit 2; }
    lxc_present "$1" || { echo NO_INSTANCE; exit 2; }
    { [ -f /var/www/visualization/lx-console/index.php ] && [ -f /var/www/visualization/lx-console/gate.php ]; } || { echo NO_APP; exit 2; }
    install -d -m 750 -o www-data -g www-data /var/www/alpha-private/lxc-tok
    [ -f /var/www/alpha-private/lxc-console.secret ] || { ( umask 077; openssl rand -hex 32 > /var/www/alpha-private/lxc-console.secret ); chown www-data:www-data /var/www/alpha-private/lxc-console.secret; chmod 640 /var/www/alpha-private/lxc-console.secret; }
    distro=$(distro_slug "$1"); appdir="/var/www/visualization/lx-console/$distro/$1"
    install -d -m 755 "$appdir"; ln -sfn /var/www/visualization/lx-console/index.php "$appdir/index.php"; ln -sfn /var/www/visualization/lx-console/gate.php "$appdir/gate.php"; chown -h www-data:www-data "$appdir" "$appdir/index.php" "$appdir/gate.php" 2>/dev/null || true
    console_meta "$1" "$appdir" "$distro"
    isafe=$(printf '%s' "$1" | tr -c 'a-z0-9' '_')
    bak="$pconf.consbak"; cp "$pconf" "$bak"
    sed -i "/# ALPHA-LXCONSOLE-$1-START/,/# ALPHA-LXCONSOLE-$1-END/d" "$pconf"
    bf=$(mktemp)
    cat > "$bf" <<BLK
    # ALPHA-LXCONSOLE-$1-START
    location = /$cpath { return 301 /$cpath/; }
    location = /$cpath/gate { internal; include fastcgi_params; fastcgi_pass unix:/run/php/php8.3-fpm.sock; fastcgi_param SCRIPT_FILENAME $appdir/gate.php; fastcgi_param SCRIPT_NAME /$cpath/gate.php; }
    location ~ ^/$cpath/lxconsole/(?<tp_$isafe>77[0-9][0-9])(?<rest_$isafe>/.*)?\$ { auth_request /$cpath/gate; error_page 401 403 = @nope_$isafe; proxy_pass http://127.0.0.1:\$tp_$isafe/lxconsole/\$tp_$isafe\$rest_$isafe; proxy_http_version 1.1; proxy_set_header Upgrade \$http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host \$host; proxy_read_timeout 3600s; }
    location @nope_$isafe { return 403; }
    location = /$cpath/ { include fastcgi_params; fastcgi_pass unix:/run/php/php8.3-fpm.sock; fastcgi_param SCRIPT_FILENAME $appdir/index.php; fastcgi_param SCRIPT_NAME /$cpath/index.php; }
    # ALPHA-LXCONSOLE-$1-END
BLK
    DRE="$DOMAIN_RE" awk -v bf="$bf" 'BEGIN{while((getline l < bf)>0) blk=blk l "\n"; sub(/\n$/,"",blk)} !d && $0 ~ ("server_name "psub"\\." ENVIRON["DRE"] ";") {print; print blk; d=1; next} {print}' psub="$psub" "$pconf" > "$pconf.tmp" && mv "$pconf.tmp" "$pconf"
    rm -f "$bf"
    if nginx -t >/dev/null 2>&1; then systemctl reload nginx; rm -f "$bak"; echo OK; else cp "$bak" "$pconf"; rm -f "$bak"; { nginx -t >/dev/null 2>&1 && systemctl reload nginx; }; echo NGINX_FAIL; exit 2; fi ;;
  lxd-console-detach)
    vname "${1:-}"; psub="${2:-}"
    [[ "$psub" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo BAD_SUB; exit 2; }
    pconf="/etc/nginx/sites-available/sub-$psub"
    [ -f "$pconf" ] || { echo OK; exit 0; }
    cp "$pconf" "$pconf.consbak"
    sed -i "/# ALPHA-LXCONSOLE-$1-START/,/# ALPHA-LXCONSOLE-$1-END/d" "$pconf"
    for d in /var/www/visualization/lx-console/*/"$1"; do [ -d "$d" ] && rm -rf "$d"; done; rmdir /var/www/visualization/lx-console/*/ 2>/dev/null || true
    if nginx -t >/dev/null 2>&1; then systemctl reload nginx; rm -f "$pconf.consbak"; echo OK; else cp "$pconf.consbak" "$pconf"; rm -f "$pconf.consbak"; echo NGINX_FAIL; exit 2; fi ;;

  sub-check)
    # is <sub>.<domain> free to use?  echoes FREE | TAKEN | MINE | RESERVED | BAD_SUB
    sub="${1:-}"
    [[ "$sub" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo BAD_SUB; exit 0; }
    case "$sub" in www|mail|ns1|ns2|smtp|imap|pop|webmail|autodiscover|autoconfig|localhost) echo RESERVED; exit 0 ;; esac
    conf="/etc/nginx/sites-available/sub-$sub"
    if grep -rlE "server_name[^;]*[[:space:].]${sub}\.${DOMAIN_RE}([[:space:];]|$)" /etc/nginx/sites-available/ >/dev/null 2>&1; then
      if [ -f "$conf" ] && grep -q "# ALPHA-VMCONSOLE" "$conf"; then echo MINE; else echo TAKEN; fi
    else echo FREE; fi ;;

  qemu-console-expose)
    # serve a VM's noVNC console at <sub>.<domain> (token-gated shell + /novnc + /websockify proxy) + TLS
    vname "${1:-}"; sub="${2:-}"
    [[ "$sub" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo BAD_SUB; exit 2; }
    case "$sub" in www|mail|ns1|ns2|smtp|imap|pop|webmail|autodiscover|autoconfig|localhost) echo RESERVED; exit 2 ;; esac
    [ -f "$QDIR/$1.json" ] || { echo NO_VM; exit 2; }
    fqdn="$sub.$DOMAIN"; conf="/etc/nginx/sites-available/sub-$sub"
    if [ -f "$conf" ] && ! grep -q "# ALPHA-VMCONSOLE" "$conf"; then echo SUB_TAKEN; exit 2; fi
    # shared static console shell (token-driven; no PHP, no panel exposure)
    install -d -m 755 /var/www/visualization/vm-console
    if [ ! -f /var/www/visualization/vm-console/index.html ]; then
cat > /var/www/visualization/vm-console/index.html <<'HTML'
<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>VM Console</title>
<style>
  html,body{margin:0;height:100%;background:#0b0c10;font-family:system-ui,-apple-system,Segoe UI,sans-serif;color:#e5e7eb}
  .bar{height:44px;display:flex;align-items:center;gap:12px;padding:0 14px;background:#15161c;border-bottom:1px solid #262833}
  .bar b{font-size:13px}.bar .muted{color:#9aa0a9;font-size:12px}
  iframe{display:block;width:100%;height:calc(100% - 44px);border:0;background:#000}
  .msg{display:grid;place-items:center;height:calc(100% - 44px);padding:24px;text-align:center}
  .box{max-width:440px}.box h2{font-size:18px;margin:0 0 10px}.box p{color:#9aa0a9;font-size:13.5px;line-height:1.6;margin:0}
</style></head>
<body>
  <div class="bar"><b id="t">🖥 VM Console</b><span class="muted">VNC over WebSocket · one-time secured link</span></div>
  <div id="host"></div>
  <script>
  (function(){
    var q=new URLSearchParams(location.search), tok=(q.get('token')||'').trim(), vm=(q.get('vm')||'').trim();
    if(vm) document.getElementById('t').textContent='🖥 '+vm;
    var host=document.getElementById('host');
    if(!/^[a-f0-9]{16,64}$/i.test(tok)){
      host.className='msg';
      host.innerHTML='<div class="box"><h2>Console link expired or missing</h2><p>Open this console from your Alpha panel — Virtualization, then the VM’s <b>Open console</b> button. Links are valid briefly, for security.</p></div>';
      return;
    }
    var f=document.createElement('iframe');
    var base=location.pathname.replace(/\/+$/,'');var baseRel=base?base.slice(1)+'/':'';f.src=base+'/novnc/vnc.html?path='+encodeURIComponent(baseRel+'websockify?token='+tok)+'&autoconnect=true&resize=scale&reconnect=true&show_dot=true';
    f.setAttribute('allow','clipboard-read; clipboard-write');
    host.appendChild(f);
  })();
  </script>
</body></html>
HTML
fi
    vmos=$(vm_os_slug "$1"); appdir="/var/www/visualization/vm-console/$vmos/$1"
    install -d -m 755 "$appdir"
    ln -sfn /var/www/visualization/vm-console/index.html "$appdir/index.html"
    rm -f "$appdir/signin.json"   # hidden until the panel marks this console public
    chown -h www-data:www-data "$appdir" "$appdir/index.html" 2>/dev/null || true
    cat > "$conf" <<NGINX
# ALPHA-VMCONSOLE instance=$1
server {
    listen 80; listen [::]:80;
    server_name $fqdn;
    root $appdir;
    add_header X-Robots-Tag "noindex, nofollow" always;
    location ^~ /novnc/ { alias /usr/share/novnc/; }
    location = /websockify {
        proxy_pass http://127.0.0.1:6080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade \$http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host \$host;
        proxy_read_timeout 3600s;
    }
    location / { try_files \$uri /index.html; }
}
NGINX
    ln -sf "$conf" "/etc/nginx/sites-enabled/sub-$sub"
    if ! nginx -t >/dev/null 2>&1; then rm -f "$conf" "/etc/nginx/sites-enabled/sub-$sub"; echo NGINX_FAIL; exit 2; fi
    if issue_cert "$fqdn"; then echo "OK_HTTPS"; else echo "OK_HTTP"; fi ;;
  qemu-console-signin)
    # <name> public|admin — publish (or withdraw) the panel's sign-in button in a
    # VM console's directory. Present = a bare visit offers the button; absent =
    # the page has nothing to fetch and stays a dead end.
    vname "${1:-}"; mode="${2:-admin}"
    src=/var/www/visualization/vm-console/signin.json
    found=0
    for d in /var/www/visualization/vm-console/*/"$1"; do
      [ -d "$d" ] || continue
      found=1
      if [ "$mode" = public ] && [ -f "$src" ]; then
        ln -sfn "$src" "$d/signin.json"; chown -h www-data:www-data "$d/signin.json" 2>/dev/null || true
      else
        rm -f "$d/signin.json"
      fi
    done
    [ "$found" = 1 ] || { echo NO_CONSOLE; exit 2; }
    echo OK ;;
  qemu-console-unexpose)
    vname "${1:-}"; sub="${2:-}"
    [[ "$sub" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo BAD_SUB; exit 2; }
    rm -f "/etc/nginx/sites-available/sub-$sub" "/etc/nginx/sites-enabled/sub-$sub"
    for d in /var/www/visualization/vm-console/*/"$1"; do [ -d "$d" ] && rm -rf "$d"; done; rmdir /var/www/visualization/vm-console/*/ 2>/dev/null || true
    { nginx -t >/dev/null 2>&1 && systemctl reload nginx; } || true
    echo OK ;;
  qemu-console-attach)
    # mount a VM's noVNC screen inside an existing site: <parentsub>.<domain>/<path>/
    vname "${1:-}"; psub="${2:-}"; cpath="${3:-}"
    [[ "$psub" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo BAD_SUB; exit 2; }
    [[ "$cpath" =~ ^[a-z0-9][a-z0-9-]{0,40}$ ]] || { echo BAD_PATH; exit 2; }
    pconf="/etc/nginx/sites-available/sub-$psub"
    [ -f "$pconf" ] || { echo NO_PARENT; exit 2; }
    [ -f "$QDIR/$1.json" ] || { echo NO_VM; exit 2; }
    [ -f /var/www/visualization/vm-console/index.html ] || { echo NO_APP; exit 2; }
    vmos=$(vm_os_slug "$1"); appdir="/var/www/visualization/vm-console/$vmos/$1"
    install -d -m 755 "$appdir"; ln -sfn /var/www/visualization/vm-console/index.html "$appdir/index.html"
    rm -f "$appdir/signin.json"   # hidden until the panel marks this console public; chown -h www-data:www-data "$appdir" "$appdir/index.html" 2>/dev/null || true
    bak="$pconf.consbak"; cp "$pconf" "$bak"
    sed -i "/# ALPHA-VMCONSOLE-$1-START/,/# ALPHA-VMCONSOLE-$1-END/d" "$pconf"
    bf=$(mktemp)
    cat > "$bf" <<BLK
    # ALPHA-VMCONSOLE-$1-START
    location ^~ /vm/$cpath/novnc/ { alias /usr/share/novnc/; }
    location = /vm/$cpath/websockify { proxy_pass http://127.0.0.1:6080; proxy_http_version 1.1; proxy_set_header Upgrade \$http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host \$host; proxy_read_timeout 3600s; }
    location = /vm/$cpath { alias $appdir/index.html; default_type text/html; add_header X-Robots-Tag "noindex, nofollow" always; }
    location = /vm/$cpath/signin.json { alias $appdir/signin.json; default_type application/json; }
    location = /vm/$cpath/ { return 301 /vm/$cpath; }
    # ALPHA-VMCONSOLE-$1-END
BLK
    DRE="$DOMAIN_RE" awk -v bf="$bf" 'BEGIN{while((getline l < bf)>0) blk=blk l "\n"; sub(/\n$/,"",blk)} !d && $0 ~ ("server_name "psub"\\." ENVIRON["DRE"] ";") {print; print blk; d=1; next} {print}' psub="$psub" "$pconf" > "$pconf.tmp" && mv "$pconf.tmp" "$pconf"
    rm -f "$bf"
    if nginx -t >/dev/null 2>&1; then systemctl reload nginx; rm -f "$bak"; echo OK; else cp "$bak" "$pconf"; rm -f "$bak"; { nginx -t >/dev/null 2>&1 && systemctl reload nginx; }; echo NGINX_FAIL; exit 2; fi ;;
  qemu-console-detach)
    vname "${1:-}"; psub="${2:-}"
    [[ "$psub" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo BAD_SUB; exit 2; }
    pconf="/etc/nginx/sites-available/sub-$psub"
    [ -f "$pconf" ] || { echo OK; exit 0; }
    cp "$pconf" "$pconf.consbak"
    sed -i "/# ALPHA-VMCONSOLE-$1-START/,/# ALPHA-VMCONSOLE-$1-END/d" "$pconf"
    for d in /var/www/visualization/vm-console/*/"$1"; do [ -d "$d" ] && rm -rf "$d"; done; rmdir /var/www/visualization/vm-console/*/ 2>/dev/null || true
    if nginx -t >/dev/null 2>&1; then systemctl reload nginx; rm -f "$pconf.consbak"; echo OK; else cp "$pconf.consbak" "$pconf"; rm -f "$pconf.consbak"; echo NGINX_FAIL; exit 2; fi ;;

  qemu-list)
    echo -n "["; first=1
    for f in "$QDIR"/*.json; do [ -e "$f" ] || continue
      n=$(basename "$f" .json); st="stopped"; pid_alive "$QDIR/$n.pid" && st="running"
      [ $first -eq 1 ] || echo -n ","; first=0
      printf "{\"name\":\"%s\",\"state\":\"%s\",%s}" "$n" "$st" "$(sed "s/^{//;s/}$//" "$f")"
    done; echo "]" ;;
  qemu-isos)
    for f in "$ISODIR"/*; do
      [ -f "$f" ] || continue
      b=$(basename "$f"); sz=$(stat -c%s "$f" 2>/dev/null || echo 0)
      case "$b" in
        *.meta) continue ;;
        *.part)
          nm="${b%.part}"; meta="$ISODIR/$nm.meta"; total=0; start=0
          [ -f "$meta" ] && { total=$(cut -f1 "$meta" 2>/dev/null); start=$(cut -f2 "$meta" 2>/dev/null); }
          now=$(date +%s); el=$((now-${start:-0})); [ "$el" -lt 1 ] && el=1
          spd=$((sz/el)); pct=0; [ "${total:-0}" -gt 0 ] && pct=$((sz*100/total))
          printf "%s\t%s\tdownloading\t%s\t%s\t%s\n" "$nm" "$sz" "${total:-0}" "$pct" "$spd" ;;
        *)
          printf "%s\t%s\tready\n" "$b" "$sz" ;;
      esac
    done
    true ;;
  qemu-pull-alpine)
    cd "$ISODIR"; curl -fsSL --max-time 120 -o alpine-virt-3.20.3-x86_64.iso \
      https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-virt-3.20.3-x86_64.iso && echo OK ;;
  qemu-iso-fetch)
    viso "${1:-}"
    [[ "${2:-}" =~ ^https?://[A-Za-z0-9._~:/?=\&%+-]+$ ]] || { echo BAD_URL; exit 2; }
    rm -f "$ISODIR/$1.part" "$ISODIR/$1.meta"
    total=$(curl -fsSLI --max-time 20 "$2" 2>/dev/null | tr -d "\r" | awk "tolower(\$1)==\"content-length:\"{v=\$2} END{print v+0}")
    printf "%s\t%s\n" "${total:-0}" "$(date +%s)" > "$ISODIR/$1.meta"
    systemd-run --quiet --collect --unit="alpha-isofetch-${1%.iso}" \
      /bin/bash -c 'curl -fsSL --max-time 1800 -o "$0.part" "$1" && mv "$0.part" "$0" && rm -f "$0.meta" || { rm -f "$0.part" "$0.meta"; }' "$ISODIR/$1" "$2" 2>/dev/null
    echo OK ;;
  qemu-iso-delete) viso "${1:-}"; rm -f "$ISODIR/$1" "$ISODIR/$1.part" "$ISODIR/$1.meta"; echo OK ;;
  qemu-eject)
    vname "${1:-}"; [ -f "$QDIR/$1.json" ] || { echo NO_VM; exit 2; }
    sed -i "s/\"iso\":\"[^\"]*\"/\"iso\":\"\"/" "$QDIR/$1.json"
    mon="$QDIR/$1.mon"
    if pid_alive "$QDIR/$1.pid" && [ -S "$mon" ]; then
      python3 - "$mon" <<'PY'
import socket, sys, time
try:
    s = socket.socket(socket.AF_UNIX); s.settimeout(5); s.connect(sys.argv[1])
    try: s.recv(8192)
    except Exception: pass
    s.sendall(b"eject -f ide1-cd0\n"); time.sleep(0.3)
    try: s.recv(8192)
    except Exception: pass
    s.close()
except Exception: pass
PY
    fi
    echo OK ;;
  qemu-iso-import) viso "${1:-}"; [ -f "$INCOMING/$1" ] || { echo NO_FILE; exit 2; }; mv "$INCOMING/$1" "$ISODIR/$1"; chmod 644 "$ISODIR/$1"; echo OK ;;
  vm-disk-mount)
    # Attach a STOPPED VM's qcow2 READ-ONLY via qemu-nbd and mount its largest Linux fs so the
    # panel can browse the guest's files. php-fpm runs under PrivateDevices=yes (no /dev/nbd*),
    # so qemu-nbd + the device inspection + mount MUST run in PID1's namespace via systemd-run.
    # PrivateMounts=no means the resulting mount is visible to php-fpm, so ls/copyout read it
    # directly. qemu-nbd lives in its own Type=forking unit so it survives past this call.
    vname "${1:-}"
    [ -f "$QDIR/$1.qcow2" ] || { echo NO_VM; exit 2; }
    if [ -f "$QDIR/$1.pid" ] && kill -0 "$(cat "$QDIR/$1.pid" 2>/dev/null)" 2>/dev/null; then echo RUNNING; exit 2; fi
    mnt="/run/alpha-vmdisk/$1"
    mountpoint -q "$mnt" 2>/dev/null && { echo "OK $mnt"; exit 0; }
    systemd-run --quiet --pipe --wait --collect modprobe nbd max_part=8 >/dev/null 2>&1 || true
    dev=""; for n in $(seq 0 15); do [ -e "/sys/block/nbd$n/pid" ] || { dev="/dev/nbd$n"; break; }; done
    [ -n "$dev" ] || { echo NO_NBD; exit 2; }
    unit="alpha-vmnbd-$1"
    systemctl reset-failed "$unit" 2>/dev/null || true
    systemd-run --quiet --collect --unit="$unit" -p Type=forking /usr/bin/qemu-nbd --read-only --connect="$dev" "$QDIR/$1.qcow2" >/dev/null 2>&1 || { echo NBD_FAIL; exit 2; }
    out=$(systemd-run --quiet --pipe --wait --collect /bin/bash -c '
      dev="$1"; mnt="$2"
      partprobe "$dev" 2>/dev/null || true; udevadm settle 2>/dev/null || true; sleep 1
      best=""; bestsz=0
      for part in "$dev" "$dev"p*; do
        [ -b "$part" ] || continue
        ty=$(blkid -o value -s TYPE "$part" 2>/dev/null)
        case "$ty" in ext2|ext3|ext4|xfs|btrfs|vfat|ntfs) : ;; *) continue ;; esac
        sz=$(blockdev --getsize64 "$part" 2>/dev/null || echo 0)
        [ "$sz" -gt "$bestsz" ] && { best="$part"; bestsz="$sz"; }
      done
      [ -n "$best" ] || { echo NO_FS; exit 3; }
      mkdir -p "$mnt"
      mount -o ro,noexec,nosuid,nodev "$best" "$mnt" 2>/dev/null || { echo MOUNT_FAIL; exit 4; }
      echo MOUNTED' _ "$dev" "$mnt" 2>/dev/null) || true
    case "$out" in
      *MOUNTED*) : ;;
      *NO_FS*)   systemctl stop "$unit" 2>/dev/null || true; echo NO_FS; exit 2 ;;
      *)         systemctl stop "$unit" 2>/dev/null || true; echo MOUNT_FAIL; exit 2 ;;
    esac
    mkdir -p /run/alpha-vmdisk; echo "$dev" > "/run/alpha-vmdisk/.$1.nbd"; echo "$unit" > "/run/alpha-vmdisk/.$1.unit"
    systemctl stop "alpha-vmdisk-$1.timer" 2>/dev/null || true
    systemd-run --quiet --on-active=1200 --unit="alpha-vmdisk-$1" /usr/local/sbin/alpha-virt vm-disk-umount "$1" >/dev/null 2>&1 || true
    echo "OK $mnt" ;;
  vm-disk-umount)
    vname "${1:-}"; mnt="/run/alpha-vmdisk/$1"
    systemd-run --quiet --pipe --wait --collect /bin/bash -c 'umount "$1" 2>/dev/null || umount -l "$1" 2>/dev/null || true' _ "$mnt" >/dev/null 2>&1 || true
    unit=$(cat "/run/alpha-vmdisk/.$1.unit" 2>/dev/null || true)
    systemctl stop "${unit:-alpha-vmnbd-$1}" 2>/dev/null || true
    rm -f "/run/alpha-vmdisk/.$1.nbd" "/run/alpha-vmdisk/.$1.unit"; rmdir "$mnt" 2>/dev/null || true
    systemctl stop "alpha-vmdisk-$1.timer" 2>/dev/null || true
    echo OK ;;
  vm-disk-status)
    vname "${1:-}"; mnt="/run/alpha-vmdisk/$1"
    mountpoint -q "$mnt" 2>/dev/null && echo "MOUNTED $mnt" || echo UNMOUNTED ;;
  vm-disk-ls)
    vname "${1:-}"; rel="${2:-/}"; mnt="/run/alpha-vmdisk/$1"
    mountpoint -q "$mnt" 2>/dev/null || { echo NOT_MOUNTED; exit 2; }
    target=$(realpath -m "$mnt/$rel" 2>/dev/null)
    case "$target" in "$mnt"|"$mnt"/*) : ;; *) echo BAD_PATH; exit 2 ;; esac
    [ -d "$target" ] || { echo NOT_DIR; exit 2; }
    echo "__OK__"
    find "$target" -maxdepth 1 -mindepth 1 -printf "%y\t%s\t%TF %TH:%TM\t%f\n" 2>/dev/null | sort -t "	" -k1,1r -k4,4f ;;
  vm-disk-copyout)
    vname "${1:-}"; rel="${2:-}"; mnt="/run/alpha-vmdisk/$1"
    mountpoint -q "$mnt" 2>/dev/null || { echo NOT_MOUNTED; exit 2; }
    target=$(realpath -m "$mnt/$rel" 2>/dev/null)
    case "$target" in "$mnt"/*) : ;; *) echo BAD_PATH; exit 2 ;; esac
    [ -f "$target" ] || { echo NOT_FILE; exit 2; }
    sz=$(stat -c %s "$target" 2>/dev/null || echo 0)
    [ "$sz" -le 209715200 ] || { echo TOO_BIG; exit 2; }
    dldir="/run/alpha-vmdisk/.dl"; mkdir -p "$dldir"; chown root:www-data "$dldir"; chmod 750 "$dldir"
    tmp="$dldir/$(head -c8 /dev/urandom | od -An -tx1 | tr -d " \n")"
    cp --no-preserve=mode,ownership "$target" "$tmp" 2>/dev/null && chown root:www-data "$tmp" && chmod 640 "$tmp" || { echo COPY_FAIL; exit 2; }
    echo "OK $tmp" ;;
  qemu-diskalloc)
    vname "${1:-}"; [ -f "$QDIR/$1.qcow2" ] || { echo 0; exit 0; }
    du -B1 "$QDIR/$1.qcow2" 2>/dev/null | cut -f1 || echo 0 ;;
  qemu-create)
    vname "${1:-}"; vint "${2:-}"; [ "$2" -ge 1 ] && [ "$2" -le 50 ] || { echo BAD_SIZE; exit 2; }
    mem="${3:-1024}"; vint "$mem"; [ "$mem" -ge 256 ] && [ "$mem" -le 8192 ] || { echo BAD_MEM; exit 2; }
    cpus="${4:-1}"; vint "$cpus"; [ "$cpus" -ge 1 ] && [ "$cpus" -le "$MAXCPU" ] || { echo BAD_CPU; exit 2; }
    [ -f "$QDIR/$1.json" ] && { echo EXISTS; exit 2; }
    createiso="${5:-}"
    case "$createiso" in *[!A-Za-z0-9._-]*) createiso="" ;; esac
    [ -n "$createiso" ] && [ ! -f "$ISODIR/$createiso" ] && createiso=""
    $QIMG create -f qcow2 "$QDIR/$1.qcow2" "${2}G" >/dev/null
    printf "{\"disk_gb\":%s,\"mem\":%s,\"cpus\":%s,\"vnc\":%s,\"iso\":\"%s\",\"saved\":0}" "$2" "$mem" "$cpus" "$(next_vnc)" "$createiso" > "$QDIR/$1.json"
    echo OK ;;
  qemu-edit)
    vname "${1:-}"; vint "${2:-}"; [ "$2" -ge 1 ] && [ "$2" -le 50 ] || { echo BAD_SIZE; exit 2; }
    mem="${3:-1024}"; vint "$mem"; [ "$mem" -ge 256 ] && [ "$mem" -le 8192 ] || { echo BAD_MEM; exit 2; }
    cpus="${4:-1}"; vint "$cpus"; [ "$cpus" -ge 1 ] && [ "$cpus" -le "$MAXCPU" ] || { echo BAD_CPU; exit 2; }
    [ -f "$QDIR/$1.json" ] || { echo NO_VM; exit 2; }
    pid_alive "$QDIR/$1.pid" && { echo RUNNING; exit 2; }
    cur=$(sed -n "s/.*\"disk_gb\":\([0-9]*\).*/\1/p" "$QDIR/$1.json")
    if [ "$2" -gt "${cur:-0}" ]; then $QIMG resize "$QDIR/$1.qcow2" "${2}G" >/dev/null 2>&1 || { echo RESIZE_FAIL; exit 2; }
    elif [ "$2" -lt "${cur:-0}" ]; then echo NO_SHRINK; exit 2; fi
    vnc=$(sed -n "s/.*\"vnc\":\([0-9]*\).*/\1/p" "$QDIR/$1.json")
    iso=$(sed -n "s/.*\"iso\":\"\([^\"]*\)\".*/\1/p" "$QDIR/$1.json")
    printf "{\"disk_gb\":%s,\"mem\":%s,\"cpus\":%s,\"vnc\":%s,\"iso\":\"%s\",\"saved\":0}" "$2" "$mem" "$cpus" "$vnc" "$iso" > "$QDIR/$1.json"
    echo OK ;;
  qemu-start)
    vname "${1:-}"; [ -f "$QDIR/$1.json" ] || { echo NO_VM; exit 2; }
    pid_alive "$QDIR/$1.pid" && { echo ALREADY_RUNNING; exit 0; }
    vnc=$(sed -n "s/.*\"vnc\":\([0-9]*\).*/\1/p" "$QDIR/$1.json")
    mem=$(sed -n "s/.*\"mem\":\([0-9]*\).*/\1/p" "$QDIR/$1.json"); [ -n "$mem" ] || mem=1024
    cpus=$(sed -n "s/.*\"cpus\":\([0-9]*\).*/\1/p" "$QDIR/$1.json"); [ -n "$cpus" ] || cpus=1
    # ISO: explicit 2nd arg overrides (empty = eject); otherwise reuse the stored one.
    if [ $# -ge 2 ]; then newiso="${2:-}"; else newiso=$(sed -n "s/.*\"iso\":\"\([^\"]*\)\".*/\1/p" "$QDIR/$1.json"); fi
    [ -n "$newiso" ] && [ ! -f "$ISODIR/$newiso" ] && newiso=""   # stale/missing ISO -> just boot disk
    isoarg=""
    if [ -n "$newiso" ]; then viso "$newiso"; isoarg="-cdrom $ISODIR/$newiso -boot order=cd,menu=on"; fi   # disk first, then CD
    sed -i "s/\"iso\":\"[^\"]*\"/\"iso\":\"$newiso\"/" "$QDIR/$1.json"   # remember the choice
    # resume a saved session if one exists
    saved=$(sed -n "s/.*\"saved\":\([0-9]*\).*/\1/p" "$QDIR/$1.json"); [ -z "$saved" ] && saved=0
    loadarg=""; [ "$saved" = "1" ] && loadarg="-loadvm sv"
    rm -f "$QDIR/$1.mon"
    # acceleration: real KVM if the host ever exposes it, else fastest software path (multi-threaded TCG)
    if [ -e /dev/kvm ]; then ACCEL="-enable-kvm -cpu host"; else ACCEL="-accel tcg,thread=multi,tb-size=1024"; fi
    $QEMU -name "$1" $ACCEL -m "$mem" -smp "$cpus" \
      -drive file="$QDIR/$1.qcow2",if=virtio,format=qcow2,cache=writeback,discard=unmap,aio=threads \
      -netdev user,id=n0 -device virtio-net-pci,netdev=n0 $isoarg $loadarg \
      -vnc 127.0.0.1:"$vnc" -monitor unix:"$QDIR/$1.mon",server,nowait -display none -daemonize -pidfile "$QDIR/$1.pid"
    echo "OK vnc=$((5900+vnc))" ;;
  qemu-suspend)
    vname "${1:-}"; [ -f "$QDIR/$1.json" ] || { echo NO_VM; exit 2; }
    pid_alive "$QDIR/$1.pid" || { echo NOT_RUNNING; exit 2; }
    mon="$QDIR/$1.mon"; [ -S "$mon" ] || { echo NO_MON; exit 2; }
    snap "$1" || true
    if python3 - "$mon" <<'PY'
import socket, sys, time
mon = sys.argv[1]
s = socket.socket(socket.AF_UNIX); s.settimeout(5); s.connect(mon)
def drain():
    s.settimeout(1.0)
    try:
        while s.recv(8192): pass
    except Exception: pass
drain()
s.sendall(b"stop\n"); time.sleep(0.2); drain()
s.sendall(b"savevm sv\n")
s.settimeout(180); buf = b""
try:
    while b"(qemu)" not in buf:
        d = s.recv(8192)
        if not d: break
        buf += d
except Exception: pass
ok = (b"Error" not in buf) and (b"rror" not in buf)
s.sendall(b"quit\n" if ok else b"cont\n"); time.sleep(0.3); s.close()
sys.exit(0 if ok else 1)
PY
    then
      for i in $(seq 1 40); do pid_alive "$QDIR/$1.pid" || break; sleep 0.3; done
      set_saved "$1" 1; rm -f "$QDIR/$1.pid" "$QDIR/$1.mon"; echo OK
    else
      echo SAVE_FAIL; exit 2
    fi ;;
  qemu-screenshot)
    vname "${1:-}"; pid_alive "$QDIR/$1.pid" || { echo NOT_RUNNING; exit 2; }
    if snap "$1"; then echo OK; else echo NO_SHOT; exit 2; fi ;;
  qemu-stop)
    vname "${1:-}"; if pid_alive "$QDIR/$1.pid"; then snap "$1" || true; kill "$(cat "$QDIR/$1.pid")" 2>/dev/null || true; sleep 1; fi; rm -f "$QDIR/$1.pid" "$QDIR/$1.mon"; [ -f "$QDIR/$1.json" ] && set_saved "$1" 0; echo OK ;;
  qemu-delete)
    vname "${1:-}"; if pid_alive "$QDIR/$1.pid"; then kill "$(cat "$QDIR/$1.pid")" 2>/dev/null || true; fi
    rm -f "$QDIR/$1.qcow2" "$QDIR/$1.json" "$QDIR/$1.pid" "$QDIR/$1.mon" "$SHOTS/$1.png" "$SHOTS/$1.ppm"; echo OK ;;
  novnc-token)
    vname "${1:-}"; [ -f "$QDIR/$1.json" ] || { echo NO_VM; exit 2; }
    pid_alive "$QDIR/$1.pid" || { echo NOT_RUNNING; exit 2; }
    vnc=$(sed -n "s/.*\"vnc\":\([0-9]*\).*/\1/p" "$QDIR/$1.json"); port=$((5900+vnc))
    find "$NVDIR" -type f -mmin +60 -delete 2>/dev/null || true
    tok=$(openssl rand -hex 16); echo "$tok: 127.0.0.1:$port" > "$NVDIR/$tok"; echo "$tok" ;;

  *) echo "UNKNOWN_ACTION"; exit 2 ;;
esac
