utils/vmbox/lib/common.sh
Krzysztof kuhy Rudnicki 6c49f42c2f vmbox: derive the peer IP from the NIC's MAC; document that VM-to-VM is broken
The peer-address service read a `vmbox.peer=` kernel argument that the
launcher never passed -- the reader was written and the writer forgotten -- so
eth1 came up DOWN with no address on every guest. It now derives the address
from the NIC's own MAC (the host allocates 52:54:00:be:ef:<index>), which
removes the coupling entirely: nothing has to be passed in, so the launcher
and the guest cannot disagree about which index a VM has.

That fixed the addressing but NOT the connectivity. `mcast=` cannot work on
this host at all -- `lo` carries no MULTICAST flag and there is no multicast
route, so qemu accepts the option and silently drops every frame, which is the
same "flags accepted, nothing happens" trap as POWERDOWN. A listen=/connect=
socket hub over loopback TCP was tried next: the hub port listens, both
guests bring eth1 up with the right addresses, and ARP still fails.

Documented as a known limitation rather than papered over. Sandboxes work
fully on their own; two guests cannot talk to each other yet.

Also corrects the destructive demo: a running Linux SURVIVES `rm -rf /usr`
because processes hold their binaries through open file handles, so sshd keeps
answering. The honest proof of destruction is that the machine cannot boot
again, so the demo now reboots it and asserts it does not come back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 21:23:37 +02:00

142 lines
6.1 KiB
Bash

#!/bin/bash
# ============================================================================
# vmbox: shared paths, logging and validation.
# Sourced by every other lib/ file and by bin/vm. Defines no side effects.
# ============================================================================
# Guard against double-sourcing: each lib sources common.sh unconditionally.
# shellcheck disable=SC2034 # every path/const below is consumed by the
# sibling lib/*.sh files that source this one, which shellcheck cannot see.
[[ -n "${VMBOX_COMMON_SOURCED:-}" ]] && return 0
readonly VMBOX_COMMON_SOURCED=1
# Repo root is the parent of lib/, resolved through symlinks so the CLI works
# when bin/vm is symlinked onto PATH by install.sh.
VMBOX_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
readonly VMBOX_LIB_DIR
# Go up one real directory rather than stripping a "/lib" suffix: the suffix
# form silently yields the wrong root when sourced via a relative path.
VMBOX_ROOT="$(cd "$VMBOX_LIB_DIR/.." && pwd -P)"
readonly VMBOX_ROOT
readonly VMBOX_GUEST_DIR="$VMBOX_ROOT/guest"
# State lives OUTSIDE the repo: these are multi-GB disk images and live
# sockets, and the repo has a pre-commit hook that blocks binaries.
readonly VMBOX_HOME="${VMBOX_HOME:-$HOME/.local/share/vmbox}"
readonly VMBOX_BASE_DIR="$VMBOX_HOME/base"
readonly VMBOX_VMS_DIR="$VMBOX_HOME/vms"
readonly VMBOX_BASE_IMG="$VMBOX_BASE_DIR/base.qcow2"
readonly VMBOX_BASE_SHA="$VMBOX_BASE_DIR/base.qcow2.sha256"
readonly VMBOX_SSH_KEY="$VMBOX_BASE_DIR/id_ed25519"
# Base SSH port. Each VM gets VMBOX_SSH_PORT_BASE + its index.
readonly VMBOX_SSH_PORT_BASE="${VMBOX_SSH_PORT_BASE:-2300}"
# VM-to-VM segment. Multicast (mcast=) is NOT usable here: this host's `lo`
# has no MULTICAST flag and no multicast route, so qemu accepts the option and
# then silently drops every frame. A socket hub over plain TCP on loopback
# needs no multicast, no bridge and no root. The lowest-indexed running VM
# listens; the others connect to it.
readonly VMBOX_HUB_PORT="${VMBOX_HUB_PORT:-12345}"
# Guest static subnet on the second NIC (the mcast segment has no DHCP).
readonly VMBOX_GUEST_SUBNET="${VMBOX_GUEST_SUBNET:-10.77.0}"
readonly VMBOX_GUEST_USER="${VMBOX_GUEST_USER:-arch}"
# Colours only when stdout is a terminal, so logs stay clean when piped.
if [[ -t 1 ]]; then
readonly C_RED=$'\033[0;31m' C_GRN=$'\033[0;32m' C_YLW=$'\033[0;33m'
readonly C_BLU=$'\033[0;34m' C_OFF=$'\033[0m'
else
readonly C_RED='' C_GRN='' C_YLW='' C_BLU='' C_OFF=''
fi
log() { printf '%s==>%s %s\n' "$C_BLU" "$C_OFF" "$*"; }
ok() { printf '%s ok %s %s\n' "$C_GRN" "$C_OFF" "$*"; }
warn() { printf '%s warn%s %s\n' "$C_YLW" "$C_OFF" "$*" >&2; }
die() { printf '%serror%s %s\n' "$C_RED" "$C_OFF" "$*" >&2; exit 1; }
# VM names become directory names and qemu -name values; keep them boring.
validate_vm_name() {
local name="${1:-}"
[[ -n "$name" ]] || die "vm name is required"
[[ "$name" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,31}$ ]] ||
die "invalid vm name '$name' (allowed: alphanumeric, _ and -, max 32 chars)"
printf '%s' "$name"
}
vm_dir() { printf '%s/%s' "$VMBOX_VMS_DIR" "$1"; }
vm_overlay() { printf '%s/%s/overlay.qcow2' "$VMBOX_VMS_DIR" "$1"; }
vm_meta() { printf '%s/%s/meta' "$VMBOX_VMS_DIR" "$1"; }
vm_qmp_sock() { printf '%s/%s/qmp.sock' "$VMBOX_VMS_DIR" "$1"; }
# Separate control channel: the recorder occupies the main QMP socket, and a
# QMP socket accepts a single client at a time.
vm_qmp_ctl() { printf '%s/%s/qmp.sock.ctl' "$VMBOX_VMS_DIR" "$1"; }
# Bidirectional serial console. Unlike ssh this survives the poweroff it is
# reporting on, and it works when sshd does not -- so it is the transport of
# record for shutdown tests, not a fallback.
vm_console() { printf '%s/%s/console.sock' "$VMBOX_VMS_DIR" "$1"; }
vm_events() { printf '%s/%s/events.jsonl' "$VMBOX_VMS_DIR" "$1"; }
vm_pidfile() { printf '%s/%s/vm.pid' "$VMBOX_VMS_DIR" "$1"; }
# Serial logs are per-boot: a relaunch must never overwrite the log a verdict
# is about to be read from. See vm_next_serial().
vm_serial() { printf '%s/%s/serial.%s.log' "$VMBOX_VMS_DIR" "$1" "$2"; }
vm_exists() { [[ -d "$(vm_dir "$1")" ]]; }
require_vm() {
vm_exists "$1" || die "no such vm: '$1' (create it with: vm new $1)"
}
# meta is a flat key=value file; values must not contain newlines.
meta_get() {
local name="$1" key="$2" line
line="$(grep -m1 "^${key}=" "$(vm_meta "$name")" 2>/dev/null)" || return 1
printf '%s' "${line#*=}"
}
meta_set() {
local name="$1" key="$2" value="$3" meta
meta="$(vm_meta "$name")"
[[ "$value" == *$'\n'* ]] && die "meta value for '$key' must be single-line"
touch "$meta"
# Rewrite in place: delete any existing key, then append the new value.
grep -v "^${key}=" "$meta" > "$meta.tmp" 2>/dev/null || true
printf '%s=%s\n' "$key" "$value" >> "$meta.tmp"
mv "$meta.tmp" "$meta"
}
# Highest existing serial.N.log + 1, so each boot writes its own file.
vm_next_serial() {
local name="$1" n=0 f
# -e guards the unmatched-glob case; without it an empty dir yields either
# a literal "serial.*.log" (bash) or a hard error (zsh).
for f in "$(vm_dir "$name")"/serial.*.log; do
[[ -e "$f" ]] || continue
local base="${f##*/serial.}"
base="${base%.log}"
[[ "$base" =~ ^[0-9]+$ ]] && (( base >= n )) && n=$(( base + 1 ))
done
printf '%d' "$n"
}
# A VM is running if its pidfile names a live qemu process. Stale pidfiles are
# normal: a guest that powers itself off leaves one behind.
vm_is_running() {
local pid
pid="$(cat "$(vm_pidfile "$1")" 2>/dev/null)" || return 1
[[ -n "$pid" ]] || return 1
[[ -r "/proc/$pid/cmdline" ]] || return 1
grep -qa vmbox-"$1" "/proc/$pid/cmdline" 2>/dev/null
}
require_host_deps() {
local missing=()
command -v qemu-system-x86_64 >/dev/null || missing+=(qemu-base)
command -v qemu-img >/dev/null || missing+=(qemu-img)
command -v xorrisofs >/dev/null || missing+=(libisoburn)
command -v python3 >/dev/null || missing+=(python)
(( ${#missing[@]} == 0 )) ||
die "missing host dependencies: ${missing[*]} -- run: $VMBOX_ROOT/install.sh"
}