banger/interactive.sh
Thales Maciel 430f66d5dd Move helper NAT management into Go
Remove the last shell-owned NAT surface by extracting the iptables logic into a shared Go package and using it from both bangerd and a hidden helper bridge in the CLI.

Route customize.sh and interactive.sh through banger internal nat up/down so the remaining shell helpers reuse the same rule logic, resolve the local banger binary explicitly, and tear NAT back down during cleanup.

Drop nat.sh from the runtime bundle and docs now that NAT is Go-managed everywhere, and keep coverage aligned with the new shared package and helper command.

Validation: go test ./..., bash -n customize.sh interactive.sh verify.sh, make build, and a live ./verify.sh --nat run that installed host rules, reached outbound network access, and cleaned them up successfully.
2026-03-17 15:07:49 -03:00

323 lines
8.3 KiB
Bash
Executable file

#!/usr/bin/env bash
set -euo pipefail
log() {
printf '[interactive] %s\n' "$*"
}
usage() {
cat <<'EOF'
Usage: ./interactive.sh <base-rootfs> [--out <path>] [--size <size>]
Creates a writable copy of the base rootfs and boots a VM so you can
customize it manually over SSH. No automatic package/config changes
are applied.
EOF
}
parse_size() {
local raw="$1"
if [[ "$raw" =~ ^([0-9]+)([KMG])?$ ]]; then
local num="${BASH_REMATCH[1]}"
local unit="${BASH_REMATCH[2]}"
case "$unit" in
K) echo $((num * 1024)) ;;
M|"") echo $((num * 1024 * 1024)) ;;
G) echo $((num * 1024 * 1024 * 1024)) ;;
esac
return 0
fi
return 1
}
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEFAULT_RUNTIME_DIR="$DIR"
if [[ -d "$DIR/runtime" ]]; then
DEFAULT_RUNTIME_DIR="$DIR/runtime"
fi
RUNTIME_DIR="${BANGER_RUNTIME_DIR:-$DEFAULT_RUNTIME_DIR}"
if [[ ! -d "$RUNTIME_DIR" ]]; then
log "runtime bundle not found: $RUNTIME_DIR"
log "run 'make runtime-bundle' or set BANGER_RUNTIME_DIR"
exit 1
fi
source "$RUNTIME_DIR/dns.sh"
STATE="${BANGER_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/banger/interactive}"
VM_ROOT="$STATE/vms"
mkdir -p "$VM_ROOT"
BUNDLE_METADATA="$RUNTIME_DIR/bundle.json"
bundle_path() {
local key="$1"
local fallback="$2"
local rel=""
if [[ -f "$BUNDLE_METADATA" ]] && command -v jq >/dev/null 2>&1; then
rel="$(jq -r --arg key "$key" '.[$key] // empty' "$BUNDLE_METADATA" 2>/dev/null || true)"
fi
if [[ -n "$rel" && "$rel" != "null" ]]; then
printf '%s\n' "$RUNTIME_DIR/$rel"
return
fi
printf '%s\n' "$fallback"
}
FC_BIN="$RUNTIME_DIR/firecracker"
KERNEL="$(bundle_path default_kernel "$RUNTIME_DIR/wtf/root/boot/vmlinux-6.8.0-94-generic")"
INITRD="$(bundle_path default_initrd "$RUNTIME_DIR/wtf/root/boot/initrd.img-6.8.0-94-generic")"
SSH_KEY="$RUNTIME_DIR/id_ed25519"
BR_DEV="br-fc"
BR_IP="172.16.0.1"
CIDR="24"
DNS_SERVER="1.1.1.1"
resolve_banger_bin() {
if [[ -n "${BANGER_BIN:-}" ]]; then
printf '%s\n' "$BANGER_BIN"
return
fi
if [[ -x "$DIR/banger" ]]; then
printf '%s\n' "$DIR/banger"
return
fi
if command -v banger >/dev/null 2>&1; then
command -v banger
return
fi
log "banger binary not found; install/build banger or set BANGER_BIN"
exit 1
}
BANGER_BIN="$(resolve_banger_bin)"
NAT_ACTIVE=0
banger_nat() {
local action="$1"
"$BANGER_BIN" internal nat "$action" --guest-ip "$GUEST_IP" --tap "$TAP_DEV"
}
BASE_ROOTFS=""
OUT_ROOTFS=""
SIZE_SPEC=""
while [[ $# -gt 0 ]]; do
case "$1" in
--out)
OUT_ROOTFS="${2:-}"
shift 2
;;
--size)
SIZE_SPEC="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
if [[ -z "$BASE_ROOTFS" ]]; then
BASE_ROOTFS="$1"
shift
else
log "unknown option: $1"
usage
exit 1
fi
;;
esac
done
if [[ -z "$BASE_ROOTFS" ]]; then
usage
exit 1
fi
if [[ ! -f "$BASE_ROOTFS" ]]; then
log "base rootfs not found: $BASE_ROOTFS"
exit 1
fi
if [[ ! -f "$KERNEL" ]]; then
log "kernel not found: $KERNEL"
exit 1
fi
if [[ ! -f "$INITRD" ]]; then
log "initrd not found: $INITRD"
exit 1
fi
if [[ -z "$OUT_ROOTFS" ]]; then
base_dir="$(dirname "$BASE_ROOTFS")"
base_name="$(basename "$BASE_ROOTFS")"
OUT_ROOTFS="${base_dir}/rw-${base_name}"
fi
if [[ -e "$OUT_ROOTFS" ]]; then
log "output rootfs already exists: $OUT_ROOTFS"
exit 1
fi
log "copying base rootfs to $OUT_ROOTFS"
cp --reflink=auto "$BASE_ROOTFS" "$OUT_ROOTFS"
if [[ -n "$SIZE_SPEC" ]]; then
SIZE_BYTES="$(parse_size "$SIZE_SPEC")"
BASE_BYTES="$(stat -c%s "$BASE_ROOTFS")"
if [[ -z "$SIZE_BYTES" || "$SIZE_BYTES" -lt "$BASE_BYTES" ]]; then
log "size must be >= base image size"
exit 1
fi
log "resizing rootfs to $SIZE_SPEC"
truncate -s "$SIZE_BYTES" "$OUT_ROOTFS"
e2fsck -p -f "$OUT_ROOTFS" >/dev/null
resize2fs "$OUT_ROOTFS" >/dev/null
fi
VM_ID="$(head -c 32 /dev/urandom | xxd -p -c 256)"
VM_TAG="${VM_ID:0:8}"
VM_NAME="interactive-${VM_TAG}"
VM_DIR="$VM_ROOT/$VM_ID"
mkdir -p "$VM_DIR"
API_SOCK="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/banger/fc-$VM_TAG.sock"
LOG_FILE="$VM_DIR/firecracker.log"
TAP_DEV="tap-fc-$VM_TAG"
DNS_NAME=""
# Allocate guest IP
NEXT_IP_FILE="$STATE/next_ip"
NEXT_IP="$(cat "$NEXT_IP_FILE" 2>/dev/null || echo 2)"
GUEST_IP="172.16.0.$NEXT_IP"
echo "$((NEXT_IP + 1))" > "$NEXT_IP_FILE"
sudo -v
cleanup() {
sudo kill "${FC_PID:-}" 2>/dev/null || true
if [[ "$NAT_ACTIVE" -eq 1 ]]; then
banger_nat down >/dev/null 2>&1 || true
fi
sudo ip link del "$TAP_DEV" 2>/dev/null || true
rm -f "$API_SOCK"
banger_dns_remove_record_name "${DNS_NAME:-}"
rm -rf "$VM_DIR"
}
trap cleanup EXIT
sudo mkdir -p "$(dirname "$API_SOCK")"
sudo chown "$(id -u):$(id -g)" "$(dirname "$API_SOCK")"
# Host bridge
if ! ip link show "$BR_DEV" >/dev/null 2>&1; then
log "creating host bridge $BR_DEV ($BR_IP/$CIDR)"
sudo ip link add name "$BR_DEV" type bridge
sudo ip addr add "${BR_IP}/${CIDR}" dev "$BR_DEV"
sudo ip link set "$BR_DEV" up
else
sudo ip link set "$BR_DEV" up
fi
log "creating tap device $TAP_DEV"
TAP_USER="${SUDO_UID:-$(id -u)}"
TAP_GROUP="${SUDO_GID:-$(id -g)}"
sudo ip tuntap add dev "$TAP_DEV" mode tap user "$TAP_USER" group "$TAP_GROUP"
sudo ip link set "$TAP_DEV" master "$BR_DEV"
sudo ip link set "$TAP_DEV" up
sudo ip link set "$BR_DEV" up
log "starting firecracker process"
rm -f "$API_SOCK"
nohup sudo -E "$FC_BIN" --api-sock "$API_SOCK" >"$LOG_FILE" 2>&1 &
FC_PID="$!"
log "waiting for firecracker api socket"
for _ in $(seq 1 200); do
[[ -S "$API_SOCK" ]] && break
sleep 0.02
done
[[ -S "$API_SOCK" ]] || { log "firecracker api socket not ready"; exit 1; }
log "configuring machine"
sudo -E curl --unix-socket "$API_SOCK" -X PUT http://localhost/machine-config \
-H "Content-Type: application/json" \
-d '{
"vcpu_count": 2,
"mem_size_mib": 1024,
"smt": false
}' >/dev/null
KCMD="console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rw ip=${GUEST_IP}::${BR_IP}:255.255.255.0:${VM_NAME}:eth0:off:${DNS_SERVER} hostname=${VM_NAME} systemd.mask=home.mount systemd.mask=var.mount"
sudo -E curl --unix-socket "$API_SOCK" -X PUT http://localhost/boot-source \
-H "Content-Type: application/json" \
-d "{
\"kernel_image_path\": \"$KERNEL\",
\"boot_args\": \"$KCMD\",
\"initrd_path\": \"$INITRD\"
}" >/dev/null
sudo -E curl --unix-socket "$API_SOCK" -X PUT http://localhost/drives/rootfs \
-H "Content-Type: application/json" \
-d "{
\"drive_id\": \"rootfs\",
\"path_on_host\": \"$OUT_ROOTFS\",
\"is_root_device\": true,
\"is_read_only\": false
}" >/dev/null
sudo -E curl --unix-socket "$API_SOCK" -X PUT http://localhost/network-interfaces/eth0 \
-H "Content-Type: application/json" \
-d "{
\"iface_id\": \"eth0\",
\"host_dev_name\": \"$TAP_DEV\"
}" >/dev/null
sudo -E curl --unix-socket "$API_SOCK" -X PUT http://localhost/actions \
-H "Content-Type: application/json" \
-d '{ "action_type": "InstanceStart" }' >/dev/null
SUDO_CHILD_PID="$(pgrep -n -f "$API_SOCK" || true)"
if [[ -n "$SUDO_CHILD_PID" ]]; then
FC_PID="$SUDO_CHILD_PID"
fi
VM_CONFIG_JSON="$(sudo -E curl --unix-socket "$API_SOCK" -sS http://localhost/vm/config)"
CREATED_AT="$(date -Iseconds)"
DNS_NAME="$(banger_dns_name "$VM_NAME")"
banger_dns_write_record "$VM_NAME" "$GUEST_IP"
jq -n \
--arg id "$VM_ID" \
--arg name "$VM_NAME" \
--arg pid "$FC_PID" \
--arg created_at "$CREATED_AT" \
--arg guest_ip "$GUEST_IP" \
--arg tap "$TAP_DEV" \
--arg api_sock "$API_SOCK" \
--arg log "$LOG_FILE" \
--arg rootfs "$OUT_ROOTFS" \
--arg kernel "$KERNEL" \
--arg dns_name "$DNS_NAME" \
--argjson config "$VM_CONFIG_JSON" \
'{meta:{id:$id,name:$name,pid:$pid,created_at:$created_at,guest_ip:$guest_ip,tap:$tap,api_sock:$api_sock,log:$log,rootfs:$rootfs,kernel:$kernel,dns_name:$dns_name},config:$config}' \
> "$VM_DIR/vm.json"
log "enabling NAT for interactive session"
banger_nat up >/dev/null
NAT_ACTIVE=1
log "waiting for SSH"
log "guest ip: $GUEST_IP"
log "ssh: ssh -i \"$SSH_KEY\" root@${GUEST_IP}"
for _ in $(seq 1 60); do
if ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"root@${GUEST_IP}" "true" >/dev/null 2>&1; then
log "ssh ready"
break
fi
sleep 1
done
log "output rootfs: $OUT_ROOTFS"
log "press Ctrl+C to stop and clean up"
while kill -0 "$FC_PID" >/dev/null 2>&1; do
sleep 1
done