banger/customize.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

444 lines
12 KiB
Bash
Executable file

#!/usr/bin/env bash
set -euo pipefail
log() {
printf '[customize] %s\n' "$*"
}
usage() {
cat <<'EOF'
Usage: ./customize.sh <base-rootfs> [--out <path>] [--size <size>] [--kernel <path>] [--initrd <path>] [--docker] [--modules <dir>]
Creates a copy of rootfs.ext4, optionally resizes it, boots a VM using the
copy as a writable rootfs, then applies base configuration and packages.
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
}
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEFAULT_RUNTIME_DIR="$SCRIPT_DIR"
if [[ -d "$SCRIPT_DIR/runtime" ]]; then
DEFAULT_RUNTIME_DIR="$SCRIPT_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"
source "$RUNTIME_DIR/packages.sh"
STATE="${BANGER_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/banger/image-build}"
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"
}
BASE_ROOTFS="$RUNTIME_DIR/rootfs.ext4"
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 "$SCRIPT_DIR/banger" ]]; then
printf '%s\n' "$SCRIPT_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=""
INSTALL_DOCKER=0
MODULES_DIR="$(bundle_path default_modules_dir "$RUNTIME_DIR/wtf/root/lib/modules/6.8.0-94-generic")"
PACKAGES_FILE="$(banger_packages_file)"
while [[ $# -gt 0 ]]; do
case "$1" in
--out)
OUT_ROOTFS="${2:-}"
shift 2
;;
--size)
SIZE_SPEC="${2:-}"
shift 2
;;
--kernel)
KERNEL="${2:-}"
shift 2
;;
--initrd)
INITRD="${2:-}"
shift 2
;;
--docker)
INSTALL_DOCKER=1
shift
;;
--modules)
MODULES_DIR="${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 [[ -z "$OUT_ROOTFS" ]]; then
base_dir="$(dirname "$BASE_ROOTFS")"
base_name="$(basename "$BASE_ROOTFS")"
OUT_ROOTFS="${base_dir}/docker-${base_name}"
fi
if [[ ! -f "$KERNEL" ]]; then
log "kernel not found: $KERNEL"
exit 1
fi
if [[ -n "$INITRD" && ! -f "$INITRD" ]]; then
log "initrd not found: $INITRD"
exit 1
fi
if [[ -n "$MODULES_DIR" && ! -d "$MODULES_DIR" ]]; then
log "modules dir not found: $MODULES_DIR"
exit 1
fi
if [[ -e "$OUT_ROOTFS" ]]; then
log "output rootfs already exists: $OUT_ROOTFS"
exit 1
fi
if ! command -v resize2fs >/dev/null 2>&1; then
log "resize2fs required"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
log "jq required"
exit 1
fi
if ! command -v sha256sum >/dev/null 2>&1; then
log "sha256sum required to record package manifest metadata"
exit 1
fi
if [[ ! -f "$PACKAGES_FILE" ]]; then
log "package manifest not found: $PACKAGES_FILE"
exit 1
fi
APT_PACKAGES=()
if ! banger_packages_read_array APT_PACKAGES "$PACKAGES_FILE"; then
log "package manifest is empty: $PACKAGES_FILE"
exit 1
fi
if ! PACKAGES_HASH="$(printf '%s\n' "${APT_PACKAGES[@]}" | banger_packages_hash_stream)"; then
log "failed to hash package manifest: $PACKAGES_FILE"
exit 1
fi
printf -v APT_PACKAGES_ESCAPED '%q ' "${APT_PACKAGES[@]}"
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="customize-${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"
INITRD_JSON=""
if [[ -n "$INITRD" ]]; then
INITRD_JSON=", \"initrd_path\": \"$INITRD\""
fi
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_JSON}
}" >/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 customization"
banger_nat up >/dev/null
NAT_ACTIVE=1
log "waiting for SSH"
SSH_READY=0
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
SSH_READY=1
break
fi
sleep 1
done
if [[ "$SSH_READY" -ne 1 ]]; then
log "ssh did not become ready on $GUEST_IP"
exit 1
fi
log "configuring guest"
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"root@${GUEST_IP}" bash -lc "set -e
printf 'nameserver %s\n' \"$DNS_SERVER\" > /etc/resolv.conf
echo \"$VM_NAME\" > /etc/hostname
printf '127.0.0.1 localhost\n127.0.1.1 %s\n' \"$VM_NAME\" > /etc/hosts
touch /etc/fstab
sed -i '\|^/dev/vdb[[:space:]]\+/home[[:space:]]|d; \|^/dev/vdc[[:space:]]\+/var[[:space:]]|d' /etc/fstab
if ! grep -q '^tmpfs /run ' /etc/fstab; then
echo 'tmpfs /run tmpfs defaults,nodev,nosuid,mode=0755 0 0' >> /etc/fstab
fi
if ! grep -q '^tmpfs /tmp ' /etc/fstab; then
echo 'tmpfs /tmp tmpfs defaults,nodev,nosuid,mode=1777 0 0' >> /etc/fstab
fi
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get -y upgrade
DEBIAN_FRONTEND=noninteractive apt-get -y install ${APT_PACKAGES_ESCAPED}
if [[ \"$INSTALL_DOCKER\" == \"1\" ]]; then
DEBIAN_FRONTEND=noninteractive apt-get -y remove containerd || true
if ! DEBIAN_FRONTEND=noninteractive apt-get -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin; then
DEBIAN_FRONTEND=noninteractive apt-get -y install docker.io
fi
if command -v systemctl >/dev/null 2>&1; then
systemctl enable --now docker || true
fi
fi
git config --system init.defaultBranch main
"
if [[ -n "$MODULES_DIR" ]]; then
MODULES_BASE="$(basename "$MODULES_DIR")"
log "copying kernel modules ($MODULES_BASE) into guest"
tar -C "$(dirname "$MODULES_DIR")" -cf - "$MODULES_BASE" | \
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"root@${GUEST_IP}" bash -lc "set -e
mkdir -p /lib/modules
tar -C /lib/modules -xf -
depmod -a \"$MODULES_BASE\"
mkdir -p /etc/modules-load.d
printf 'nf_tables\nnft_chain_nat\nveth\nbr_netfilter\noverlay\n' > /etc/modules-load.d/docker-netfilter.conf
mkdir -p /etc/sysctl.d
cat > /etc/sysctl.d/99-docker.conf <<'SYSCTL'
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
SYSCTL
sysctl --system >/dev/null 2>&1 || true
sync
"
fi
log "shutting down guest"
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"root@${GUEST_IP}" bash -lc "sync" || true
sudo -E curl --unix-socket "$API_SOCK" -X PUT http://localhost/actions \
-H "Content-Type: application/json" \
-d '{ "action_type": "SendCtrlAltDel" }' >/dev/null || true
for _ in $(seq 1 200); do
if ! ps -p "$FC_PID" >/dev/null 2>&1; then
break
fi
sleep 0.05
done
banger_write_rootfs_manifest_metadata "$OUT_ROOTFS" "$PACKAGES_HASH"
log "done"