# boot VM 0 (default) — creates TAP fc0, guest gets 172.16.0.2
./start-vm.sh
# ssh in
ssh -i guest.id_rsa -o UserKnownHostsFile=.known_hosts root@172.16.0.2
# see what's running (or stale)
./list-vms.sh
# stop it
./stop-vm.shstop-vm.sh runs systemctl poweroff over SSH, waits for the guest to actually
halt, then reaps the firecracker process and tears down host networking — so
ext4 unmounts properly instead of replaying the journal on next boot.
Two non-obvious facts drive the design:
- No ctrl-alt-del. The API's
SendCtrlAltDelis inert here:start-vm.shboot args passi8042.noaux i8042.nomux i8042.nopnp i8042.dumbkbd, the controller probe fails (error -22), so the injected scancode reaches no driver (the call still returns204). It is intentionally not used. - A clean poweroff does NOT exit firecracker. x86 Firecracker has no
power device, so the kernel prints
reboot: Power off not available: System halted insteadand parks the VCPUs while the parent process keeps running. "Process exited" is therefore not the halt signal;stop-vm.shalways reaps.
Halt is confirmed by two independent signals, checked each poll:
- Serial log (authoritative). The boot log ends with that final
reboot:line — proofpoweroff.targetcompleted and filesystems were unmounted. The log isrm -f'd every boot, so a match can't be stale. - Fallback: guest answers no ping (a live-but-idle VM always answers ICMP; parked VCPUs kill virtio-net RX) and its CPU time stays frozen across two consecutive polls. Ping is the gate that stops an idle live guest from being misread as halted.
Consequences:
sshmust be present on the host, or you silently fall back to hard kills.- Every outcome prints how it was decided (
serial log confirms…vsguest unreachable + CPU frozen…); a path that ends in a kill without halt evidence says so on stderr. Read that line rather than assuming a clean stop. - Typical clean stop is ~12s (matches the image's systemd teardown); the wait budget is 45s to absorb slow NFS unmounts from shared workspaces.
Each VM id gets its own API socket, TAP device, and /30 subnet:
./start-vm.sh 0 # guest 172.16.0.2 (TAP fc0)
./start-vm.sh 1 # guest 172.16.0.6 (TAP fc1)
./start-vm.sh 2 # guest 172.16.0.10 (TAP fc2)
./list-vms.sh # status of all of them (ping, PID, vCPU/mem)
./list-vms.sh 0 2 # or check specific ids ("absent" = really stopped)The CI rootfs ships with a fcnet-setup.sh that derives the guest IP from the
interface MAC: 06:00:ac:10:00:GG → 172.16.0.GG. start-vm.sh sets the MAC
accordingly, so the guest auto-configures its address on boot with no DHCP
server.
All paths are env-overridable for testing or non-default layouts:
FC_DIR=/some/other/dir ./start-vm.sh
BIN_INSTALL_DIR=~/.local/bin ./update-firecracker.sh binary
FC_SOCKET_DIR=/run ./start-vm.sh # put API sockets in /run
API_SOCKET=/run/fc-vm0.sock ./start-vm.sh # fully override one socket's path
VM_ID=3 FC_DIR=/some/other/dir ./start-vm.sh # VM_ID env wins over the positional arg
KERNEL=/path/to/vmlinux ROOTFS=/path/to/rootfs.ext4 SSH_KEY=/path/to/key ./start-vm.sh
SHARE_DIR=~/project ./start-vm.sh # live-mount ~/project at /workspace in the guest
SHARE_DIR=~/project SHARE_MNT=/work ./start-vm.sh # ...or at a custom guest path
VCPU_COUNT=4 MEM_SIZE_MIB=8192 ./start-vm.sh # override the machine profile (default: 2 vCPU / 2048 MiB)
AGENT_PROFILE=fc-agents ./auth-login.sh # auth profile name (default fc-agents)
ANTHROPIC_CONFIG_DIR=... ./auth-login.sh # ant's config dir (default ~/.config/anthropic-fc,
# or an existing <repo>/anthropic-config)
# rootfs isolation (start-vm.sh) — each VM gets its own writable bytes
ROOTFS_MODE=overlay ./start-vm.sh # read-only shared base + per-VM overlay layer
ROOTFS_MODE=copy ./start-vm.sh # per-VM copy of the base (no initrd needed)
ROOTFS_MODE=shared ./start-vm.sh # old behavior: write the shared image (unsafe)
OVERLAY_SIZE_MIB=4096 ./start-vm.sh # size of this VM's writable layer (default 2048)
RESET_LAYER=1 ./start-vm.sh # discard this VM's writable bytes first
INITRD=/path/to/initrd-overlay.img ./start-vm.sh
PER_VM_KEY=0 ./start-vm.sh # reuse guest.id_rsa instead of this VM's own key
# network policy (start-vm.sh; applied to the whole fc-nat table)
GUEST_LAN_ACCESS=1 ./start-vm.sh # let guests reach RFC1918 (default: blocked)
GUEST_HOST_PORTS=2049,8080 ./start-vm.sh # host ports guests may reach (default: 2049)
GUEST_HOST_PORTS=2049,111 ./start-vm.sh # ...add rpcbind if an NFS mount ever needs it
GUEST_HOST_FILTER=0 ./start-vm.sh # disable guest->host filtering entirely
GUEST_EGRESS_ALLOW=api.anthropic.com,registry.npmjs.org ./start-vm.sh
# allowlist guest egress, drop the rest
# (default: unset = unrestricted)
GUEST_EGRESS_DNS=1.1.1.1 ./start-vm.sh # resolvers reachable while the allowlist is on
# (default 1.1.1.1,8.8.8.8)
# download integrity (update-firecracker.sh)
STRICT_PINS=1 ./update-firecracker.sh agent # any pin change is fatal, incl. the installer
ALLOW_UNVERIFIED=1 ./update-firecracker.sh binary # proceed if upstream ships no checksum
FORCE_POWEROFF=1 ./stop-vm.sh 3 # ssh-poweroff the guest IP even with no TAP presentNotes:
start-vm.sh,stop-vm.sh, andlist-vms.shall understandFC_SOCKET_DIRandVM_ID— pass the same values you started the VM with when stopping or listing it.- API sockets default to
$XDG_RUNTIME_DIR/firecracker(falling back to/tmp/firecracker-$UID), created0700, and firecracker is launched underumask 077. The socket is a full control channel — anything that can connect to it can attach arbitrary host files as guest drives and read guest memory — and its mode otherwise comes from whatever umask you happen to have.stop-vm.shandlist-vms.shstill find sockets left in/tmpby an olderstart-vm.sh, so an already-running VM is not orphaned by the change. - A fully-renamed
API_SOCKETis only seen by./list-vms.sh <id>(single-id mode), not by the socket scan. stop-vm.shwill not ssh-poweroff a guest address when its TAP is absent — that address may belong to something that is not ours.FORCE_POWEROFF=1overrides.VM_IDmust be an integer in0..63— each id consumes one /30 out of the172.16.0.0/24range.VCPU_COUNT(>= 1) andMEM_SIZE_MIB(>= 128) are validated and rejected if not integers; Firecracker has no memory or vCPU hot-plug, so a running VM keeps the profile it booted with (./list-vms.shshows it).ROOTFS_MODEdefaults toauto:overlaywheninitrd-overlay.imgexists, otherwisecopy. Either way a VM never shares writable bytes with another — booting off an image some other VM already has attached is refused, and inoverlaymode an unclean base is refused too, because a read-only mount cannot replay an ext4 journal (e2fsck -fyit on the host).SSH_KEYstill overrides the key, and inoverlay/copymode its public half ($SSH_KEY.pub) is installed in that VM instead of a generated one. Otherwise each VM getsguest-vm<id>.id_rsa, created on first boot;stop-vm.shandshare-dir.shresolve the same key.GUEST_EGRESS_ALLOWresolves hostnames on the host, when the ruleset is built. For CDN-fronted names the address set rotates, so it bounds where a guest can dial rather than guaranteeing an allowed name keeps resolving.- There is one
fc-nattable for all VMs, so the allowlist is host-wide and remembered in.fc-egress-policy:stop-vm.shrebuilds the same table, and without the remembered policy stopping one VM would silently restore open egress for every VM still running. It is forgotten when the last VM stops, or immediately if you pass an emptyGUEST_EGRESS_ALLOW=. Pass it again on a laterstart-vm.shto change it. - An allowlisted address inside RFC1918/CGNAT is dropped by the
no guest->LANrule before the allowlist is consulted;start-vm.shwarns rather than letting it look effective.GUEST_LAN_ACCESS=1if you mean it. ./list-vms.shhas aROOTFScolumn:overlay:<layer>means a read-only base plus that VM's own layer;rw:<image>means a writable root.
firecracker-scripts/
├── prereqs.sh
├── update-firecracker.sh
├── start-vm.sh
├── list-vms.sh
├── stop-vm.sh
├── share-dir.sh
├── auth-login.sh
├── lib-fcnet.sh # sourced by the four VM scripts (not executable on its own)
├── image-pins.lock # sha256 of artifacts that publish no checksum (committed)
├── README.md
├── docs/THREAT-MODEL.md
└── .gitignore
After ./update-firecracker.sh (plus agent), the repo directory also contains
(all gitignored):
vmlinux-<version> # guest kernel
ubuntu-<version>.ext4 # guest rootfs (agent step: 2 GiB, claude, nfs-common, apt state stripped)
ubuntu-<version>.squashfs.upstream
squashfs-root/ # extracted rootfs tree the ext4 is built from
guest.id_rsa / .pub # dedicated SSH keypair (gitignored)
anthropic-config/ # ant profile + credentials for the guest (gitignored, SECRET)
claude-sessions/ # Claude Code transcripts shared out of /root/.claude (gitignored, optional)
vmlinux-latest # -> vmlinux-<version>
ubuntu-latest.ext4 # -> ubuntu-<version>.ext4
ubuntu-latest.id_rsa # -> guest.id_rsa
fc-vm*.log # per-VM serial console logs (mode 0600)
.known_hosts # TOFU guest host keys; deleted on an images rebuild
.fw-forward-added # marker: we enabled firewalld intra-zone forwarding
.fc-egress-policy # the egress allowlist in force (survives stop-vm.sh; see
# GUEST_EGRESS_ALLOW). Removed when the last VM stops.
- The firecracker binary is installed to
/usr/local/bin/firecracker-<tag>-<arch>with a stablefirecrackersymlink pointing at it. - Firecracker's serial console goes to
fc-vm<ID>.log—tail -fit to watch boot. For interactive access, use SSH (the scripts launch firecracker detached with stdin from/dev/null, so the serial console is read-only by design). - Networking uses a hardcoded
172.16.0.0/24range. Each VM's/30subnet and TAP name are derived fromVM_IDinlib-fcnet.sh(GUEST_IP,HOST_IP,TAP, and the MAC are computed, not env-overridable). If the range collides with another network on your host, changeFC_SUBNETinlib-fcnet.shand the matching derivation instart-vm.sh. - The host firewall treats every VM as its own trust domain.
fc_nft_applyinlib-fcnet.shrebuilds the wholefc-nattable atomically on every start and stop (validated withnft -cfirst, so a bad ruleset is reported instead of half-applied), from the TAPs that exist at that moment. The policy: guests reach the internet masqueraded; guest-to-guest is dropped; guests may not reach RFC1918/CGNAT/link-local destinations, i.e. your LAN (GUEST_LAN_ACCESS=1opts out); guest-to-host is limited to NFS and ping (GUEST_HOST_PORTSadds ports,GUEST_HOST_FILTER=0disables it); and a packet arriving on a TAP with a source outside the VM range is dropped, so a guest cannot spoof past any of it. Services on the host itself are unaffected by the LAN block — those are input, not forward. The guest→host allowance is 2049 only, on the basis that NFSv4 needs no portmapper; if a mount ever fails against a host whosenfs-utilsdisagrees,GUEST_HOST_PORTS=2049,111is the escape hatch. - IPv6 is disabled on the TAP. The
fc-natrules areip-family only, so a link-local address on the host end of the TAP would be an unfiltered path from the guest to any host service bound to::— around the guest→host filtering entirely.start-vm.shsetsnet.ipv6.conf.<tap>.disable_ipv6=1before bringing the device up, and warns if a link-local address survives anyway. Nothing here uses IPv6: the /30, the NAT and the NFS mount are all IPv4. - Guest SSH host keys are verified. The scripts use a repo-local
.known_hostswithStrictHostKeyChecking=accept-new: an unseen key is recorded, a changed one is refused. Host keys are baked into the image, so they are stable across boots;update-firecracker.sh imagesdeletes.known_hostswhen it rebuilds, since the rebuild regenerates them. - Guest internet egress needs four things, all handled by
start-vm.sh(each was a real failure mode on Fedora): the host routes (net.ipv4.ip_forward=1, persisted to/etc/sysctl.d/99-fc-agents.conf), masquerade by source subnet leaving via the uplink (nft), the TAP bound to the same firewalld zone as the uplink interface + intra-zone forwarding (--add-forward) — firewalld rejects cross-zone forwarding even when our own nft chains accept — and a default route in the guest (the CIfcnet-setup.shships none; theagentstep patches it in). ./update-firecracker.sh agentoperates on the extractedsquashfs-root/tree and rebuilds the ext4 from it — any state accumulated in the previous ext4 (e.g. a newer claude pulled by the auto-updater) is discarded by design, keeping rebuilds reproducible. The apt/dpkg state stripped from the shipped image lives on insquashfs-root/(the strip runs on a hardlink staging copy), so re-runningagentstays a fast idempotent no-op for the apt part. Re-runagentafter everyimagesrebuild. The flip side still holds:agentnever removes anything the previous run put intosquashfs-root/— converting a tree built by an older script version (e.g. one with git) requires./update-firecracker.sh images --force(fresh extract; plainimagesskips if versions match) followed byagent.- The guest ext4 is persistent across guest reboots (e.g. claude's self-updates
survive), but not an
images/agentrebuild. It ships no working package manager: the image is built appliance-style — packages are installed in the build chroot only, viaAGENT_APT_PKGSinupdate-firecracker.sh. - The CI rootfs ships an empty
/etc/resolv.conf; theagentstep bakes working nameservers (1.1.1.1,8.8.8.8) into the image. Without it nothing resolves in the guest. - The
agentstep hardens the guest image. The upstream CI rootfs allows SSH password authentication and ships root with an empty password field in/etc/shadow, guarded only byPermitEmptyPasswords no; the build turns password auth off (sshd_config.d/10-fc-agents.conf) and locks the account. Pubkey login is unaffected by a locked password. It also masksrpcbind, whichnfs-commonpulls in and which otherwise listens on0.0.0.0:111— NFSv4 only ever talks to 2049. - Downloads are checksum-verified. The firecracker tarball is checked against
the
.sha256file upstream publishes, and a mismatch is fatal. The CI kernel/rootfs andclaude.ai/install.shpublish no checksums, so their hashes are recorded inimage-pins.lock(committed) on first fetch and checked afterwards — for the CI artifacts a later change is fatal, since a dated CI key is immutable; for the installer it is reported and re-pinned, since it legitimately changes.STRICT_PINS=1makes everything fatal,ALLOW_UNVERIFIED=1relaxes the first case. This does not protect a first fetch; see THREAT-MODEL.md.