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.
323 lines
8.3 KiB
Bash
Executable file
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
|