diff --git a/hh/scripts/vbox-library.json b/hh/scripts/vbox-library.json new file mode 100644 index 0000000..475c3e4 --- /dev/null +++ b/hh/scripts/vbox-library.json @@ -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 . 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 ." + }, + { + "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." + } + ] +} diff --git a/hh/scripts/vbox-library.sh b/hh/scripts/vbox-library.sh new file mode 100755 index 0000000..e09aafa --- /dev/null +++ b/hh/scripts/vbox-library.sh @@ -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 # one entry's details + how to get it +# ./vbox-library.sh --plan # show the build plan; change nothing +# ./vbox-library.sh --install [--iso ] [--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 · build: ./vbox-library.sh --install " >&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:-}" >&2 + echo " → cache $DL_PATH" >&2 + echo " import : VBoxManage import (registers '$NAME')" >&2 + ;; + iso) + iso_src="${ISO_OVERRIDE:-${URL:-}}" + 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 , --plan or --install " >&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 " >&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 " >&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 diff --git a/hh/src/app.rs b/hh/src/app.rs index dbb0930..68c66d6 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -2291,15 +2291,23 @@ fn handle_command( "VirtualBox isn't installed — install it with `/sbx gui --install`, or run ./scripts/ensure-vbox.sh".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; 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: {}", - v.join(", ") + v.iter() + .map(|n| if running.iter().any(|r| r == n) { + format!("{n} ▶") + } else { + n.clone() + }) + .collect::>() + .join(", ") ))), - Ok(Ok((ver, _))) => tx.send(Net::Sys(format!( + Ok(Ok((ver, _, _))) => tx.send(Net::Sys(format!( "VirtualBox {ver} detected · no VMs registered" ))), 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 ` + // shows the pointer/notes; `/sbx vmlib 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::>() + .join("\n"); + tx.send(Net::Sys(format!( + "VM library (✓ installed · ↓ available) — `/sbx vmlib ` for details, `/sbx vmlib 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 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") => { // Convenience alias for `/sbx vbox gui [yes]` — opens a // local VirtualBox VM's GUI on your own machine. Bare `/sbx gui` @@ -2416,7 +2520,7 @@ fn handle_command( } } other => { - let usage = "usage: /sbx