feat(sandbox): VM library — pointer catalog + local build via /sbx vmlib
CI / rust client (hh) (macos-latest) (push) Waiting to run
CI / rust client (hh) (ubuntu-latest) (push) Waiting to run
CI / rust coverage (push) Waiting to run
CI / python server (3.10) (push) Waiting to run
CI / python server (3.11) (push) Waiting to run
CI / python server (3.12) (push) Waiting to run
CI / headless e2e smoke (push) Waiting to run
CI / dependency audit (push) Waiting to run
CI / secret scanning (push) Waiting to run
CI / rust client (hh) (macos-latest) (push) Waiting to run
CI / rust client (hh) (ubuntu-latest) (push) Waiting to run
CI / rust coverage (push) Waiting to run
CI / python server (3.10) (push) Waiting to run
CI / python server (3.11) (push) Waiting to run
CI / python server (3.12) (push) Waiting to run
CI / headless e2e smoke (push) Waiting to run
CI / dependency audit (push) Waiting to run
CI / secret scanning (push) Waiting to run
Add an installable-VM catalog that ships pointers only (no multi-GB images in the repo). When a VM is chosen it is BUILT LOCALLY on the caller's own machine — nothing is relayed to the room. - scripts/vbox-library.json: 7-entry manifest (Windows 11, macOS Sonoma, Kali, Parrot, Ubuntu 24.04, Fedora 41, Debian 12) with download pointers, ostype, cpu/mem/disk, and build kind. - scripts/vbox-library.sh: --list / --info / --plan / --install. Build kinds: iso (download/--iso + createvm + boot installer; EFI+TPM for Win11), cloudimg (delegate to vbox-new.sh, unattended), ova (import), manual (pointer-only, e.g. macOS per Apple licensing). Detect-first: --plan changes nothing, install refuses to clobber and rolls back half-built VMs, direct URLs fall back to the page + --iso <path>. - sbx.rs: vbox_library()/library_vm()/vbox_library_install() loaders + running_vms() for live-state markers. - app.rs: /sbx vmlib (catalog ✓installed/↓available), /sbx vmlib <id> (pointer/notes), /sbx vmlib <id> install [--iso path] (local build); /sbx vms now flags running VMs (▶). Registered in SBX_SUBCOMMANDS + usage. - ui.rs: help entry for the VM library. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
|||||||
|
{
|
||||||
|
"_comment": "hack-house VirtualBox VM library. Pointers ONLY — no images are bundled in the repo (they are multi-GB). When a VM is chosen, it is built locally on the caller's own machine by scripts/vbox-library.sh. 'kind' decides how: iso = download installer ISO + create VM + boot the installer; cloudimg = delegate to vbox-new.sh (cloud-init, fully unattended); ova = download + VBoxManage import; manual = pointer-only (licence/availability prevents auto-build). Direct 'url' values are best-effort and version-pinned: when one 404s the script falls back to the 'page' and lets you supply a local file with --iso. Update versions here over time.",
|
||||||
|
"version": 1,
|
||||||
|
"vms": [
|
||||||
|
{
|
||||||
|
"id": "windows-11",
|
||||||
|
"name": "Windows 11",
|
||||||
|
"os": "Windows 11 x64",
|
||||||
|
"size": "~25 GB installed",
|
||||||
|
"kind": "iso",
|
||||||
|
"ostype": "Windows11_64",
|
||||||
|
"url": "",
|
||||||
|
"page": "https://www.microsoft.com/software-download/windows11",
|
||||||
|
"cpus": 4,
|
||||||
|
"mem_mb": 8192,
|
||||||
|
"disk_gb": 64,
|
||||||
|
"efi": true,
|
||||||
|
"tpm": "2.0",
|
||||||
|
"notes": "Microsoft serves the ISO behind a session-gated page, so there is no stable direct link. Download it from the page, then build locally with: /sbx vmlib windows-11 install --iso <path-to-Win11.iso>. The VM is created with EFI firmware + a TPM 2.0 device + Secure Boot so Setup accepts it."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "macos-sonoma",
|
||||||
|
"name": "macOS Sonoma",
|
||||||
|
"os": "macOS 14 (Sonoma)",
|
||||||
|
"size": "~40 GB installed",
|
||||||
|
"kind": "manual",
|
||||||
|
"ostype": "MacOS_64",
|
||||||
|
"url": "",
|
||||||
|
"page": "https://github.com/kholia/OSX-KVM",
|
||||||
|
"cpus": 4,
|
||||||
|
"mem_mb": 8192,
|
||||||
|
"disk_gb": 80,
|
||||||
|
"notes": "Apple's licence permits macOS only on Apple-branded hardware, and the installer is not redistributable, so this cannot be auto-built. Follow the OSX-KVM project (or Docker-OSX / sosumi) on the page to assemble an image yourself, then import it with /sbx vbox <name>."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kali-linux",
|
||||||
|
"name": "Kali Linux",
|
||||||
|
"os": "Debian-based · pentest",
|
||||||
|
"size": "~4 GB",
|
||||||
|
"kind": "iso",
|
||||||
|
"ostype": "Debian_64",
|
||||||
|
"version": "2024.4",
|
||||||
|
"url": "https://cdimage.kali.org/kali-2024.4/kali-linux-2024.4-installer-amd64.iso",
|
||||||
|
"page": "https://www.kali.org/get-kali/#kali-installer-images",
|
||||||
|
"cpus": 2,
|
||||||
|
"mem_mb": 4096,
|
||||||
|
"disk_gb": 30,
|
||||||
|
"notes": "The flagship offensive-security distro. Default live creds kali/kali; the installer ISO sets your own during setup."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "parrot-security",
|
||||||
|
"name": "Parrot Security OS",
|
||||||
|
"os": "Debian-based · pentest",
|
||||||
|
"size": "~5 GB",
|
||||||
|
"kind": "iso",
|
||||||
|
"ostype": "Debian_64",
|
||||||
|
"version": "6.2",
|
||||||
|
"url": "https://deb.parrot.sh/parrot/iso/6.2/Parrot-security-6.2_amd64.iso",
|
||||||
|
"page": "https://parrotsec.org/download/",
|
||||||
|
"cpus": 2,
|
||||||
|
"mem_mb": 4096,
|
||||||
|
"disk_gb": 30,
|
||||||
|
"notes": "Privacy/pentest distro, a lighter-weight alternative to Kali."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ubuntu-2404",
|
||||||
|
"name": "Ubuntu 24.04 LTS",
|
||||||
|
"os": "Ubuntu Server x64",
|
||||||
|
"size": "~2 GB base",
|
||||||
|
"kind": "cloudimg",
|
||||||
|
"ostype": "Ubuntu_64",
|
||||||
|
"version": "24.04",
|
||||||
|
"url": "https://cloud-images.ubuntu.com/releases/24.04/release/",
|
||||||
|
"page": "https://ubuntu.com/download/server",
|
||||||
|
"cpus": 2,
|
||||||
|
"mem_mb": 2048,
|
||||||
|
"disk_gb": 15,
|
||||||
|
"notes": "Fully unattended build via vbox-new.sh: a cloud-init seed installs the hack-house dev toolchain and creates a sudo login user on first boot. The only entry that comes up ready-to-use with no manual installer."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fedora-workstation",
|
||||||
|
"name": "Fedora Workstation",
|
||||||
|
"os": "Fedora · RPM",
|
||||||
|
"size": "~3 GB",
|
||||||
|
"kind": "iso",
|
||||||
|
"ostype": "Fedora_64",
|
||||||
|
"version": "41",
|
||||||
|
"url": "https://download.fedoraproject.org/pub/fedora/linux/releases/41/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-41-1.4.iso",
|
||||||
|
"page": "https://fedoraproject.org/workstation/download",
|
||||||
|
"cpus": 2,
|
||||||
|
"mem_mb": 4096,
|
||||||
|
"disk_gb": 30,
|
||||||
|
"notes": "Cutting-edge GNOME workstation; live ISO, install to disk from the desktop."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "debian-12",
|
||||||
|
"name": "Debian 12 (Bookworm)",
|
||||||
|
"os": "Debian · stable",
|
||||||
|
"size": "~1 GB (netinst)",
|
||||||
|
"kind": "iso",
|
||||||
|
"ostype": "Debian_64",
|
||||||
|
"version": "12",
|
||||||
|
"url": "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-12.8.0-amd64-netinst.iso",
|
||||||
|
"page": "https://www.debian.org/distrib/",
|
||||||
|
"cpus": 2,
|
||||||
|
"mem_mb": 2048,
|
||||||
|
"disk_gb": 20,
|
||||||
|
"notes": "Rock-stable base server/desktop. Small netinst ISO pulls packages during install."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Executable
+267
@@ -0,0 +1,267 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# vbox-library.sh — browse + locally build VMs from the hack-house VM library.
|
||||||
|
#
|
||||||
|
# The repo ships ONLY pointers (scripts/vbox-library.json), never the multi-GB
|
||||||
|
# images themselves. When you choose a VM it is built on YOUR own machine here:
|
||||||
|
# * cloudimg → hand off to vbox-new.sh (fully unattended cloud-init build)
|
||||||
|
# * iso → download the installer ISO, create a VM, attach it, boot the
|
||||||
|
# installer (you finish setup in the VirtualBox window)
|
||||||
|
# * ova → download the appliance and `VBoxManage import` it
|
||||||
|
# * manual → pointer-only (licence/availability prevents an auto-build)
|
||||||
|
#
|
||||||
|
# Detect-first, never silent: --plan shows exactly what would be downloaded and
|
||||||
|
# created and changes nothing; an install refuses to clobber an existing VM and
|
||||||
|
# rolls a half-built one back. Direct URLs are best-effort + version-pinned: if
|
||||||
|
# one 404s, you get the official page and can supply a local file with --iso.
|
||||||
|
#
|
||||||
|
# usage:
|
||||||
|
# ./vbox-library.sh --list # catalog (installed vs available)
|
||||||
|
# ./vbox-library.sh --info <id> # one entry's details + how to get it
|
||||||
|
# ./vbox-library.sh --plan <id> # show the build plan; change nothing
|
||||||
|
# ./vbox-library.sh --install <id> [--iso <path>] [--no-boot] [--yes]
|
||||||
|
#
|
||||||
|
# Deps: jq (read the manifest), VBoxManage; curl|wget for downloads; for cloudimg
|
||||||
|
# entries the deps of vbox-new.sh (qemu-img + a seed-ISO builder).
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
MANIFEST="$SCRIPT_DIR/vbox-library.json"
|
||||||
|
VBOX_NEW="$SCRIPT_DIR/vbox-new.sh"
|
||||||
|
CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/hh/vbox-library"
|
||||||
|
|
||||||
|
ACTION=""
|
||||||
|
ID=""
|
||||||
|
ISO_OVERRIDE=""
|
||||||
|
DO_BOOT=1
|
||||||
|
ASSUME_YES=0
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--list) ACTION="list"; shift ;;
|
||||||
|
--info) ACTION="info"; ID="${2:-}"; shift 2 ;;
|
||||||
|
--plan|--dry-run) ACTION="plan"; ID="${2:-}"; shift 2 ;;
|
||||||
|
--install) ACTION="install"; ID="${2:-}"; shift 2 ;;
|
||||||
|
--iso) ISO_OVERRIDE="${2:-}"; shift 2 ;;
|
||||||
|
--iso=*) ISO_OVERRIDE="${1#*=}"; shift ;;
|
||||||
|
--no-boot) DO_BOOT=0; shift ;;
|
||||||
|
-y|--yes) ASSUME_YES=1; shift ;;
|
||||||
|
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
|
*) echo "✖ unknown arg: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
need() { command -v "$1" >/dev/null 2>&1; }
|
||||||
|
need jq || { echo "✖ jq is required to read the VM library manifest (apt install jq)" >&2; exit 1; }
|
||||||
|
[[ -f "$MANIFEST" ]] || { echo "✖ manifest not found: $MANIFEST" >&2; exit 1; }
|
||||||
|
|
||||||
|
# A single jq query against one VM object, by id. Echoes the raw value ("" if null).
|
||||||
|
q() { jq -r --arg id "$ID" --arg k "$1" '.vms[] | select(.id==$id) | .[$k] // ""' "$MANIFEST"; }
|
||||||
|
vm_exists_in_manifest() { jq -e --arg id "$ID" '.vms[] | select(.id==$id)' "$MANIFEST" >/dev/null 2>&1; }
|
||||||
|
# Is a VM with this display name already registered in VirtualBox?
|
||||||
|
vbox_has() { VBoxManage list vms 2>/dev/null | grep -qiF "\"$1\""; }
|
||||||
|
|
||||||
|
# ── --list: print the catalog, marking what's already built locally ──────────
|
||||||
|
if [[ "$ACTION" == "list" ]]; then
|
||||||
|
echo "hack-house VM library — pointers only; chosen VMs are built locally:" >&2
|
||||||
|
while IFS=$'\t' read -r id name os size kind; do
|
||||||
|
if vbox_has "$name"; then mark="✓ installed"; else mark="↓ available"; fi
|
||||||
|
printf ' %-12s %-22s %-22s %-12s [%s]\n' "$mark" "$id" "$name" "$os" "$kind" >&2
|
||||||
|
done < <(jq -r '.vms[] | [.id, .name, .os, .size, .kind] | @tsv' "$MANIFEST")
|
||||||
|
echo "details: ./vbox-library.sh --info <id> · build: ./vbox-library.sh --install <id>" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Everything past here needs a valid id.
|
||||||
|
[[ -n "$ID" ]] || { echo "✖ no VM id given — see ./vbox-library.sh --list" >&2; exit 2; }
|
||||||
|
vm_exists_in_manifest || { echo "✖ unknown VM id '$ID' — see ./vbox-library.sh --list" >&2; exit 2; }
|
||||||
|
|
||||||
|
NAME="$(q name)"; OS="$(q os)"; SIZE="$(q size)"; KIND="$(q kind)"
|
||||||
|
URL="$(q url)"; PAGE="$(q page)"; NOTES="$(q notes)"
|
||||||
|
OSTYPE="$(q ostype)"; VERSION="$(q version)"
|
||||||
|
CPUS="$(q cpus)"; MEM="$(q mem_mb)"; DISK="$(q disk_gb)"
|
||||||
|
EFI="$(q efi)"; TPM="$(q tpm)"
|
||||||
|
[[ -n "$CPUS" ]] || CPUS=2
|
||||||
|
[[ -n "$MEM" ]] || MEM=2048
|
||||||
|
[[ -n "$DISK" ]] || DISK=20
|
||||||
|
|
||||||
|
# ── --info: details + the honest "how to get it" pointer ─────────────────────
|
||||||
|
if [[ "$ACTION" == "info" ]]; then
|
||||||
|
echo "● $NAME ($ID)" >&2
|
||||||
|
echo " os : $OS" >&2
|
||||||
|
echo " size : $SIZE" >&2
|
||||||
|
echo " build : $KIND" >&2
|
||||||
|
[[ -n "$VERSION" ]] && echo " pinned: v$VERSION" >&2
|
||||||
|
if vbox_has "$NAME"; then
|
||||||
|
echo " state : ✓ already built locally — boot it with /sbx vbox \"$NAME\"" >&2
|
||||||
|
else
|
||||||
|
echo " state : ↓ not installed" >&2
|
||||||
|
fi
|
||||||
|
[[ -n "$URL" ]] && echo " url : $URL" >&2
|
||||||
|
[[ -n "$PAGE" ]] && echo " page : $PAGE" >&2
|
||||||
|
[[ -n "$NOTES" ]] && echo " notes : $NOTES" >&2
|
||||||
|
echo "build locally with: ./vbox-library.sh --install $ID" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Where a downloaded image lands.
|
||||||
|
EXT="iso"; [[ "$KIND" == "ova" ]] && EXT="ova"
|
||||||
|
DL_PATH="$CACHE_DIR/${ID}.${EXT}"
|
||||||
|
|
||||||
|
# ── --plan: describe the build, change nothing ───────────────────────────────
|
||||||
|
if [[ "$ACTION" == "plan" ]]; then
|
||||||
|
echo "plan for '$NAME' (no changes will be made):" >&2
|
||||||
|
case "$KIND" in
|
||||||
|
manual)
|
||||||
|
echo " ⚠ manual-only — cannot be auto-built (see notes)" >&2
|
||||||
|
echo " notes: $NOTES" >&2
|
||||||
|
echo " page : $PAGE" >&2
|
||||||
|
;;
|
||||||
|
cloudimg)
|
||||||
|
echo " hand off to vbox-new.sh → unattended cloud-init build" >&2
|
||||||
|
echo " VM : $NAME · ${CPUS} cpu · ${MEM}MiB · ${DISK}GiB · release ${VERSION:-24.04}" >&2
|
||||||
|
"$VBOX_NEW" --name "$NAME" --release "${VERSION:-24.04}" \
|
||||||
|
--cpus "$CPUS" --mem "$MEM" --disk "$DISK" --plan 2>&1 | sed 's/^/ /' >&2 || true
|
||||||
|
;;
|
||||||
|
ova)
|
||||||
|
echo " source : ${URL:-<none — supply --iso/appliance>}" >&2
|
||||||
|
echo " → cache $DL_PATH" >&2
|
||||||
|
echo " import : VBoxManage import (registers '$NAME')" >&2
|
||||||
|
;;
|
||||||
|
iso)
|
||||||
|
iso_src="${ISO_OVERRIDE:-${URL:-<none>}}"
|
||||||
|
echo " iso : $iso_src" >&2
|
||||||
|
[[ -z "$ISO_OVERRIDE" && -n "$URL" ]] && echo " → cache $DL_PATH" >&2
|
||||||
|
echo " VM : $NAME · ${CPUS} cpu · ${MEM}MiB · ${DISK}GiB" >&2
|
||||||
|
[[ "$EFI" == "true" ]] && echo " firmware: EFI" >&2
|
||||||
|
[[ -n "$TPM" ]] && echo " tpm : $TPM" >&2
|
||||||
|
echo " then : boot the installer in the VirtualBox window" >&2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ "$ACTION" == "install" ]] || { echo "✖ nothing to do — pass --list, --info <id>, --plan <id> or --install <id>" >&2; exit 2; }
|
||||||
|
|
||||||
|
# ── install ──────────────────────────────────────────────────────────────────
|
||||||
|
need VBoxManage || { echo "✖ VirtualBox (VBoxManage) is not installed — run ./ensure-vbox.sh first" >&2; exit 1; }
|
||||||
|
|
||||||
|
# manual-only entries never auto-build: surface the pointer and stop.
|
||||||
|
if [[ "$KIND" == "manual" ]]; then
|
||||||
|
echo "ⓘ '$NAME' can't be built automatically." >&2
|
||||||
|
echo " $NOTES" >&2
|
||||||
|
echo " follow: $PAGE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Refuse to clobber an already-registered VM of the same name.
|
||||||
|
if vbox_has "$NAME"; then
|
||||||
|
echo "✓ '$NAME' is already built — boot it with /sbx vbox \"$NAME\" (nothing to do)" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# cloudimg: the existing, tested unattended path.
|
||||||
|
if [[ "$KIND" == "cloudimg" ]]; then
|
||||||
|
echo "⛧ building '$NAME' via vbox-new.sh (cloud-init, unattended)…" >&2
|
||||||
|
args=(--name "$NAME" --release "${VERSION:-24.04}" --cpus "$CPUS" --mem "$MEM" --disk "$DISK")
|
||||||
|
[[ $ASSUME_YES -eq 1 ]] && args+=(--yes)
|
||||||
|
[[ $DO_BOOT -eq 0 ]] && args+=(--no-boot)
|
||||||
|
exec "$VBOX_NEW" "${args[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$CACHE_DIR"
|
||||||
|
|
||||||
|
# Download helper → $DL_PATH. On failure, point at the page + --iso and bail
|
||||||
|
# (never leave a partial file or a half-built VM behind).
|
||||||
|
fetch() {
|
||||||
|
local url="$1" dst="$2"
|
||||||
|
[[ -f "$dst" ]] && { echo "↻ using cached $(basename "$dst")" >&2; return 0; }
|
||||||
|
echo "↓ downloading $(basename "$dst") … (this is large; be patient)" >&2
|
||||||
|
if need curl; then
|
||||||
|
curl -fL --retry 3 -o "$dst.partial" "$url"
|
||||||
|
elif need wget; then
|
||||||
|
wget -O "$dst.partial" "$url"
|
||||||
|
else
|
||||||
|
echo "✖ neither curl nor wget available to download" >&2; return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve the source image: an explicit --iso wins; else download the pinned URL.
|
||||||
|
SRC=""
|
||||||
|
if [[ -n "$ISO_OVERRIDE" ]]; then
|
||||||
|
[[ -f "$ISO_OVERRIDE" ]] || { echo "✖ --iso path not found: $ISO_OVERRIDE" >&2; exit 1; }
|
||||||
|
SRC="$ISO_OVERRIDE"
|
||||||
|
elif [[ -n "$URL" ]]; then
|
||||||
|
if fetch "$URL" "$DL_PATH"; then
|
||||||
|
mv "$DL_PATH.partial" "$DL_PATH" 2>/dev/null || true
|
||||||
|
SRC="$DL_PATH"
|
||||||
|
else
|
||||||
|
rm -f "$DL_PATH.partial"
|
||||||
|
echo "✖ couldn't download '$NAME' from the pinned URL (it may have moved)." >&2
|
||||||
|
echo " get it from: $PAGE" >&2
|
||||||
|
echo " then build with: ./vbox-library.sh --install $ID --iso <downloaded-file>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "✖ no direct download for '$NAME' — download it yourself, then:" >&2
|
||||||
|
echo " get it from: $PAGE" >&2
|
||||||
|
echo " ./vbox-library.sh --install $ID --iso <downloaded-file>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Confirmation (skipped with --yes).
|
||||||
|
if [[ $ASSUME_YES -ne 1 ]]; then
|
||||||
|
printf 'Build VM "%s" now (%s, %s cpu / %sMiB / %sGiB)? [y/N] ' "$NAME" "$OS" "$CPUS" "$MEM" "$DISK" >&2
|
||||||
|
read -r reply
|
||||||
|
case "$reply" in y|Y|yes|YES) ;; *) echo "✖ aborted — nothing built" >&2; exit 1 ;; esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── ova: import the appliance and we're done ─────────────────────────────────
|
||||||
|
if [[ "$KIND" == "ova" ]]; then
|
||||||
|
echo "⛧ importing appliance '$NAME' …" >&2
|
||||||
|
if VBoxManage import "$SRC" >/dev/null; then
|
||||||
|
echo "✓ imported '$NAME' — boot it with /sbx vbox \"$NAME\"" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "✖ VBoxManage import failed" >&2; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── iso: create a VM, attach the installer, boot it ──────────────────────────
|
||||||
|
echo "⛧ creating VM '$NAME' (${OSTYPE:-Other_64}) …" >&2
|
||||||
|
VBoxManage createvm --name "$NAME" --ostype "${OSTYPE:-Other_64}" --register >/dev/null
|
||||||
|
|
||||||
|
# Roll the half-built VM back if any step below fails.
|
||||||
|
cleanup() { VBoxManage unregistervm "$NAME" --delete >/dev/null 2>&1 || true; }
|
||||||
|
trap 'echo "✖ build failed — rolling back VM" >&2; cleanup' ERR
|
||||||
|
set -e
|
||||||
|
|
||||||
|
MACHINE_FOLDER="$(VBoxManage list systemproperties | sed -n 's/^Default machine folder: *//p')"
|
||||||
|
VMDIR="$MACHINE_FOLDER/$NAME"
|
||||||
|
VDI="$VMDIR/$NAME.vdi"
|
||||||
|
|
||||||
|
VBoxManage modifyvm "$NAME" \
|
||||||
|
--cpus "$CPUS" --memory "$MEM" \
|
||||||
|
--nic1 nat --graphicscontroller vmsvga --vram 64 \
|
||||||
|
--ioapic on --audio-driver none --boot1 dvd --boot2 disk >/dev/null
|
||||||
|
# Windows 11 (and other modern guests) require EFI firmware + a TPM 2.0 device.
|
||||||
|
[[ "$EFI" == "true" ]] && VBoxManage modifyvm "$NAME" --firmware efi >/dev/null
|
||||||
|
[[ -n "$TPM" ]] && VBoxManage modifyvm "$NAME" --tpm-type "$TPM" >/dev/null
|
||||||
|
|
||||||
|
# Boot disk.
|
||||||
|
VBoxManage createmedium disk --filename "$VDI" --size "$((DISK * 1024))" --format VDI >/dev/null
|
||||||
|
VBoxManage storagectl "$NAME" --name SATA --add sata --controller IntelAhci --portcount 2 >/dev/null
|
||||||
|
VBoxManage storageattach "$NAME" --storagectl SATA --port 0 --device 0 --type hdd --medium "$VDI" >/dev/null
|
||||||
|
# Installer ISO on an IDE optical drive.
|
||||||
|
VBoxManage storagectl "$NAME" --name IDE --add ide >/dev/null
|
||||||
|
VBoxManage storageattach "$NAME" --storagectl IDE --port 0 --device 0 --type dvddrive --medium "$SRC" >/dev/null
|
||||||
|
|
||||||
|
trap - ERR # past the rollback point
|
||||||
|
|
||||||
|
echo "✓ VM '$NAME' created — the installer ISO is attached." >&2
|
||||||
|
echo " finish setup inside the VirtualBox window (it boots into the installer)." >&2
|
||||||
|
|
||||||
|
if [[ $DO_BOOT -eq 1 ]]; then
|
||||||
|
echo "⛧ booting '$NAME' (GUI) …" >&2
|
||||||
|
VBoxManage startvm "$NAME" --type gui >/dev/null
|
||||||
|
echo "✓ launched $NAME" >&2
|
||||||
|
else
|
||||||
|
echo "ⓘ not booting (--no-boot); start later with: /sbx vbox \"$NAME\"" >&2
|
||||||
|
fi
|
||||||
+110
-6
@@ -2291,15 +2291,23 @@ fn handle_command(
|
|||||||
"VirtualBox isn't installed — install it with `/sbx gui <vm> --install`, or run ./scripts/ensure-vbox.sh".to_string()
|
"VirtualBox isn't installed — install it with `/sbx gui <vm> --install`, or run ./scripts/ensure-vbox.sh".to_string()
|
||||||
})?;
|
})?;
|
||||||
let vms = sbx::list_vms().map_err(|e| e.to_string())?;
|
let vms = sbx::list_vms().map_err(|e| e.to_string())?;
|
||||||
Ok::<_, String>((ver, vms))
|
let running = sbx::running_vms();
|
||||||
|
Ok::<_, String>((ver, vms, running))
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
let _ = match res {
|
let _ = match res {
|
||||||
Ok(Ok((ver, v))) if !v.is_empty() => tx.send(Net::Sys(format!(
|
Ok(Ok((ver, v, running))) if !v.is_empty() => tx.send(Net::Sys(format!(
|
||||||
"VirtualBox {ver} detected · VMs: {}",
|
"VirtualBox {ver} detected · VMs: {}",
|
||||||
v.join(", ")
|
v.iter()
|
||||||
|
.map(|n| if running.iter().any(|r| r == n) {
|
||||||
|
format!("{n} ▶")
|
||||||
|
} else {
|
||||||
|
n.clone()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
))),
|
))),
|
||||||
Ok(Ok((ver, _))) => tx.send(Net::Sys(format!(
|
Ok(Ok((ver, _, _))) => tx.send(Net::Sys(format!(
|
||||||
"VirtualBox {ver} detected · no VMs registered"
|
"VirtualBox {ver} detected · no VMs registered"
|
||||||
))),
|
))),
|
||||||
Ok(Err(e)) => tx.send(Net::Err(e)),
|
Ok(Err(e)) => tx.send(Net::Err(e)),
|
||||||
@@ -2401,6 +2409,102 @@ fn handle_command(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some("vmlib") => {
|
||||||
|
// VM library: a catalog of pointers to installable VMs (no
|
||||||
|
// images are bundled). Bare `/sbx vmlib` lists the catalog
|
||||||
|
// marking which are already installed locally; `/sbx vmlib <id>`
|
||||||
|
// shows the pointer/notes; `/sbx vmlib <id> install [--iso path]`
|
||||||
|
// builds the chosen VM locally on the caller's own machine.
|
||||||
|
let args: Vec<&str> = p.collect();
|
||||||
|
let iso = args
|
||||||
|
.iter()
|
||||||
|
.position(|a| *a == "--iso")
|
||||||
|
.and_then(|i| args.get(i + 1).copied())
|
||||||
|
.map(str::to_string);
|
||||||
|
let mut pos = args.iter().copied().filter(|a| !a.starts_with('-'));
|
||||||
|
match pos.next() {
|
||||||
|
None => {
|
||||||
|
// Catalog listing
|
||||||
|
let tx = app_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let res =
|
||||||
|
tokio::task::spawn_blocking(sbx::vbox_library).await;
|
||||||
|
let _ = match res {
|
||||||
|
Ok(Ok(vms)) => {
|
||||||
|
let body = vms
|
||||||
|
.iter()
|
||||||
|
.map(|v| {
|
||||||
|
let mark = if v.installed { "✓" } else { "↓" };
|
||||||
|
format!("{mark} {} — {} ({})", v.id, v.name, v.os)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
tx.send(Net::Sys(format!(
|
||||||
|
"VM library (✓ installed · ↓ available) — `/sbx vmlib <id>` for details, `/sbx vmlib <id> install` to build locally:\n{body}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => tx.send(Net::Err(format!("vmlib: {e}"))),
|
||||||
|
Err(e) => tx.send(Net::Err(format!("vmlib task: {e}"))),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(id) => {
|
||||||
|
let id = id.to_string();
|
||||||
|
let action = pos.next();
|
||||||
|
if matches!(action, Some("install")) {
|
||||||
|
app.sys(format!(
|
||||||
|
"building VM '{id}' locally{}… (this downloads + provisions on your own machine; watch for a VirtualBox window)",
|
||||||
|
iso.as_deref().map(|p| format!(" from {p}")).unwrap_or_default()
|
||||||
|
));
|
||||||
|
let (tx, iso) = (app_tx.clone(), iso.clone());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let res = tokio::task::spawn_blocking(move || {
|
||||||
|
sbx::vbox_library_install(&id, iso)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let _ = match res {
|
||||||
|
Ok(Ok(desc)) => tx.send(Net::Sys(format!("⛧ {desc}"))),
|
||||||
|
Ok(Err(e)) => tx.send(Net::Err(format!("vmlib install failed: {e}"))),
|
||||||
|
Err(e) => tx.send(Net::Err(format!("vmlib install task: {e}"))),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Info / pointer for one entry
|
||||||
|
let tx = app_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let res = tokio::task::spawn_blocking(move || {
|
||||||
|
sbx::library_vm(&id).ok_or(id)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let _ = match res {
|
||||||
|
Ok(Ok(v)) => {
|
||||||
|
let state = if v.installed {
|
||||||
|
"already installed".to_string()
|
||||||
|
} else {
|
||||||
|
"available — `/sbx vmlib <id> install` to build locally".to_string()
|
||||||
|
};
|
||||||
|
let mut body = format!(
|
||||||
|
"{} ({}) · {} · {}\n{}",
|
||||||
|
v.name, v.id, v.os, v.size, state
|
||||||
|
);
|
||||||
|
if !v.notes.is_empty() {
|
||||||
|
body.push_str(&format!("\n{}", v.notes));
|
||||||
|
}
|
||||||
|
if !v.page.is_empty() {
|
||||||
|
body.push_str(&format!("\nsource: {}", v.page));
|
||||||
|
}
|
||||||
|
tx.send(Net::Sys(body))
|
||||||
|
}
|
||||||
|
Ok(Err(bad)) => tx.send(Net::Err(format!(
|
||||||
|
"no VM library entry '{bad}' — `/sbx vmlib` lists the catalog"
|
||||||
|
))),
|
||||||
|
Err(e) => tx.send(Net::Err(format!("vmlib task: {e}"))),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some("gui") => {
|
Some("gui") => {
|
||||||
// Convenience alias for `/sbx vbox gui <vm> [yes]` — opens a
|
// Convenience alias for `/sbx vbox gui <vm> [yes]` — opens a
|
||||||
// local VirtualBox VM's GUI on your own machine. Bare `/sbx gui`
|
// local VirtualBox VM's GUI on your own machine. Bare `/sbx gui`
|
||||||
@@ -2416,7 +2520,7 @@ fn handle_command(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
let usage = "usage: /sbx <type> <option> — /sbx docker|podman|multipass|local [image] · /sbx vbox [gui] <vm> [yes] (host opens directly; non-host appends yes) · /sbx vbox new [name] (fresh VM) · vms · stop · save [label] [--local] · load <label> · snaps · vmsave <vm> [label] [--local] · vmload <vm> [label] · vmsnaps <vm>";
|
let usage = "usage: /sbx <type> <option> — /sbx docker|podman|multipass|local [image] · /sbx vbox [gui] <vm> [yes] (host opens directly; non-host appends yes) · /sbx vbox new [name] (fresh VM) · vmlib [<id> [install [--iso path]]] (VM library — build locally) · vms · stop · save [label] [--local] · load <label> · snaps · vmsave <vm> [label] [--local] · vmload <vm> [label] · vmsnaps <vm>";
|
||||||
// Bare `/sbx` → usage. An unrecognised subcommand → suggest the
|
// Bare `/sbx` → usage. An unrecognised subcommand → suggest the
|
||||||
// closest documented one before the usage line.
|
// closest documented one before the usage line.
|
||||||
match other.and_then(|bad| closest(bad, SBX_SUBCOMMANDS).map(|s| (bad, s))) {
|
match other.and_then(|bad| closest(bad, SBX_SUBCOMMANDS).map(|s| (bad, s))) {
|
||||||
@@ -2686,7 +2790,7 @@ const KNOWN_COMMANDS: &[&str] = &[
|
|||||||
/// omitted so suggestions point at the documented form.
|
/// omitted so suggestions point at the documented form.
|
||||||
const SBX_SUBCOMMANDS: &[&str] = &[
|
const SBX_SUBCOMMANDS: &[&str] = &[
|
||||||
"docker", "podman", "multipass", "local", "vbox", "stop", "save", "load", "snaps", "vms",
|
"docker", "podman", "multipass", "local", "vbox", "stop", "save", "load", "snaps", "vms",
|
||||||
"vmsave", "vmload", "vmsnaps",
|
"vmsave", "vmload", "vmsnaps", "vmlib",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Classic Levenshtein edit distance (insert/delete/substitute, each cost 1).
|
/// Classic Levenshtein edit distance (insert/delete/substitute, each cost 1).
|
||||||
|
|||||||
+120
@@ -26,6 +26,11 @@ const SBX_BOOTSTRAP: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/sandbo
|
|||||||
const SBX_TOOLS_JSON: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/sandbox-tools.json");
|
const SBX_TOOLS_JSON: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/sandbox-tools.json");
|
||||||
/// Creates a fresh, cloud-init-provisioned Ubuntu VirtualBox VM (hh/scripts/).
|
/// Creates a fresh, cloud-init-provisioned Ubuntu VirtualBox VM (hh/scripts/).
|
||||||
const VBOX_NEW: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/vbox-new.sh");
|
const VBOX_NEW: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/vbox-new.sh");
|
||||||
|
/// Browse + locally-build VMs from the curated library (hh/scripts/).
|
||||||
|
const VBOX_LIBRARY_SH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/vbox-library.sh");
|
||||||
|
/// The library manifest: pointers (URLs/pages) to VMs, never the images. The
|
||||||
|
/// loader cross-references `list_vms` to mark which are already built locally.
|
||||||
|
const VBOX_LIBRARY_JSON: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/vbox-library.json");
|
||||||
|
|
||||||
/// Is the `docker` binary installed? (`docker --version` succeeds.) This is a
|
/// Is the `docker` binary installed? (`docker --version` succeeds.) This is a
|
||||||
/// weaker check than `docker_daemon_up`: the CLI can be present while the daemon
|
/// weaker check than `docker_daemon_up`: the CLI can be present while the daemon
|
||||||
@@ -379,6 +384,121 @@ pub fn vbox_new(name: &str, user: &str) -> Result<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Names of VMs that are currently running (`VBoxManage list runningvms`), so the
|
||||||
|
/// `/sbx vms` listing can flag which of the registered VMs are live.
|
||||||
|
pub fn running_vms() -> Vec<String> {
|
||||||
|
Command::new("VBoxManage")
|
||||||
|
.args(["list", "runningvms"])
|
||||||
|
.output()
|
||||||
|
.map(|o| {
|
||||||
|
String::from_utf8_lossy(&o.stdout)
|
||||||
|
.lines()
|
||||||
|
.filter_map(|l| {
|
||||||
|
let start = l.find('"')? + 1;
|
||||||
|
let end = l[start..].find('"')? + start;
|
||||||
|
Some(l[start..end].to_string())
|
||||||
|
})
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- VM library (curated pointers; built locally on demand) -----------------
|
||||||
|
//
|
||||||
|
// The repo ships scripts/vbox-library.json — POINTERS only (download URLs/pages),
|
||||||
|
// never the multi-GB images. Choosing a VM builds it on the caller's own machine
|
||||||
|
// via scripts/vbox-library.sh. Here we parse the manifest for listing/info; the
|
||||||
|
// heavy build is shelled out to the script (which already handles download +
|
||||||
|
// VBoxManage create/import + boot, with rollback).
|
||||||
|
|
||||||
|
/// One curated VM as declared in the manifest. Only the fields we surface in the
|
||||||
|
/// TUI are deserialized; the rest of the JSON (specs, firmware, etc.) is consumed
|
||||||
|
/// by the build script.
|
||||||
|
#[derive(Clone, Debug, serde::Deserialize)]
|
||||||
|
pub struct LibraryVm {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub os: String,
|
||||||
|
pub size: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub page: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub notes: String,
|
||||||
|
/// Filled in by `vbox_library()` (not in the JSON): is a VM of this name
|
||||||
|
/// already registered in VirtualBox on this machine?
|
||||||
|
#[serde(skip)]
|
||||||
|
pub installed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct LibraryManifest {
|
||||||
|
vms: Vec<LibraryVm>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the VM library manifest, marking each entry `installed` if a VM of that
|
||||||
|
/// display name is already registered locally (`list_vms`). The catalog is the
|
||||||
|
/// same for everyone; `installed` is per-machine, so this works identically for a
|
||||||
|
/// host or any room member — VirtualBox VMs are always local to the caller.
|
||||||
|
pub fn vbox_library() -> Result<Vec<LibraryVm>> {
|
||||||
|
let text = std::fs::read_to_string(VBOX_LIBRARY_JSON)
|
||||||
|
.with_context(|| format!("reading VM library manifest ({VBOX_LIBRARY_JSON})"))?;
|
||||||
|
let manifest: LibraryManifest =
|
||||||
|
serde_json::from_str(&text).context("parsing vbox-library.json")?;
|
||||||
|
let have = list_vms().unwrap_or_default();
|
||||||
|
Ok(manifest
|
||||||
|
.vms
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut v| {
|
||||||
|
v.installed = have.iter().any(|n| n == &v.name);
|
||||||
|
v
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a single library entry by its id (with `installed` resolved).
|
||||||
|
pub fn library_vm(id: &str) -> Option<LibraryVm> {
|
||||||
|
vbox_library().ok()?.into_iter().find(|v| v.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a library VM locally by driving scripts/vbox-library.sh `--install`.
|
||||||
|
/// Blocking and potentially slow (large download); run off the UI thread. No
|
||||||
|
/// sudo is needed — VBoxManage create/import run as the user. `iso` supplies a
|
||||||
|
/// locally-downloaded installer for entries with no auto-download (e.g. Windows).
|
||||||
|
/// Returns the script's final status line(s).
|
||||||
|
pub fn vbox_library_install(id: &str, iso: Option<String>) -> Result<String> {
|
||||||
|
let mut cmd = Command::new("bash");
|
||||||
|
cmd.arg(VBOX_LIBRARY_SH).args(["--install", id, "--yes"]);
|
||||||
|
if let Some(path) = iso.as_deref() {
|
||||||
|
cmd.args(["--iso", path]);
|
||||||
|
}
|
||||||
|
let out = cmd
|
||||||
|
.output()
|
||||||
|
.context("running vbox-library.sh (is bash available?)")?;
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
if !out.status.success() {
|
||||||
|
// The script narrates on stderr and ends with a ✖ reason / pointer.
|
||||||
|
let reason = stderr
|
||||||
|
.lines()
|
||||||
|
.rev()
|
||||||
|
.find(|l| !l.trim().is_empty())
|
||||||
|
.unwrap_or("build failed")
|
||||||
|
.trim();
|
||||||
|
anyhow::bail!("{reason}");
|
||||||
|
}
|
||||||
|
// Surface the final ✓/ⓘ status line.
|
||||||
|
let last = stderr
|
||||||
|
.lines()
|
||||||
|
.rev()
|
||||||
|
.find(|l| {
|
||||||
|
let t = l.trim_start();
|
||||||
|
t.starts_with('✓') || t.starts_with('ⓘ')
|
||||||
|
})
|
||||||
|
.unwrap_or("done")
|
||||||
|
.trim();
|
||||||
|
Ok(last.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// A running hypervisor that holds the CPU's VT-x/VMX root mode. While one is
|
/// A running hypervisor that holds the CPU's VT-x/VMX root mode. While one is
|
||||||
/// live, VirtualBox can't boot a *hardware* VM (it aborts with
|
/// live, VirtualBox can't boot a *hardware* VM (it aborts with
|
||||||
/// `VERR_VMX_IN_VMX_ROOT_MODE`). We only mark holders we know how to stop
|
/// `VERR_VMX_IN_VMX_ROOT_MODE`). We only mark holders we know how to stop
|
||||||
|
|||||||
@@ -293,6 +293,10 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
|||||||
"/sbx vbox new [name]",
|
"/sbx vbox new [name]",
|
||||||
"build a FRESH Ubuntu VM from a cloud image (cloud-init installs the dev toolchain)",
|
"build a FRESH Ubuntu VM from a cloud image (cloud-init installs the dev toolchain)",
|
||||||
),
|
),
|
||||||
|
kv(
|
||||||
|
"/sbx vmlib [<id> [install]]",
|
||||||
|
"VM library — catalog of installable VMs (Win11, macOS, Kali…); <id> install builds it LOCALLY on your machine (pointers only, no images bundled)",
|
||||||
|
),
|
||||||
kv(
|
kv(
|
||||||
"/sbx vbox [gui] <vm> [yes]",
|
"/sbx vbox [gui] <vm> [yes]",
|
||||||
"boot a VirtualBox VM's GUI on YOUR machine (non-host appends yes to install/import first)",
|
"boot a VirtualBox VM's GUI on YOUR machine (non-host appends yes to install/import first)",
|
||||||
|
|||||||
Reference in New Issue
Block a user