c276f7b0b1
Adds a Firecracker-based backend for Linux, providing mature KVM-based microVM isolation to replace smolmachines/libkrun (issue #342, closes the dead-end tracked in #332). Architecture: - Guest control over SSH (dropbear injected into the rootfs) on a point-to-point TAP link. `ssh -t` forwards SIGWINCH natively, so no resize bridge is needed. - Networking: a one-time, root-provisioned pool of user-owned TAP devices (no shared bridge → no docker0/virbr0/cni0 collisions) plus a dedicated `table inet bot_bottle_fc` nftables table (independent of Docker/ufw/firewalld rules). `./cli.py firecracker setup` prints the host-appropriate config (NixOS module or sudo script). - Rootfs: `docker export` → ext4 via `mke2fs -d` (rootless, no mount), cached by image digest; per-bottle SSH pubkey + IP passed via the kernel cmdline. - Sidecar: reuses the Docker bundle, published on the slot's host TAP IP. - Fail-closed isolation: TAP pool verified at preflight; the egress boundary is proven empirically post-boot (before the agent runs) by a canary probe — the VM must fail to reach the host directly, or launch is refused. Linux hosts with Firecracker + KVM now default to this backend; macOS stays on macos-container. Not yet validated end-to-end on live hardware (requires the one-time network pool). Unit tests + pyright pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8p32HJgPoS1hLPWubbftM
117 lines
4.0 KiB
Python
117 lines
4.0 KiB
Python
"""Empirical, post-boot isolation probe — the authoritative fail-closed
|
|
egress-boundary check.
|
|
|
|
The pre-boot nft check can't be trusted on every host (listing an
|
|
nftables table typically needs root; the launcher runs unprivileged).
|
|
So before the agent ever runs, we prove the boundary directly: open a
|
|
canary TCP listener on a host IP the VM must NOT be able to reach (the
|
|
host's primary non-TAP address), then have the guest try to connect. If
|
|
the isolation rules are in force the connection is dropped and times
|
|
out; if it succeeds, the VM can reach host services it shouldn't — a
|
|
sandbox escape — and we refuse to continue.
|
|
|
|
The listener is load-bearing: without it a "blocked" result could just
|
|
be connection-refused. With it, a reachable canary means a real leak
|
|
and a timeout means a real drop.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import socket
|
|
import subprocess
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
from ...log import die, info, warn
|
|
from . import util
|
|
|
|
|
|
def _host_primary_ip() -> str:
|
|
"""The source IP the host uses for off-box traffic (its LAN
|
|
address) — a canary the VM must not reach. Empty if the host has no
|
|
such route (isolated box)."""
|
|
result = subprocess.run(
|
|
["ip", "-o", "route", "get", "1.1.1.1"],
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
return ""
|
|
tokens = result.stdout.split()
|
|
# `... src <addr> ...` — the address the host would use as source.
|
|
if "src" in tokens:
|
|
idx = tokens.index("src")
|
|
if idx + 1 < len(tokens):
|
|
return tokens[idx + 1]
|
|
return ""
|
|
|
|
|
|
# Guest-side connect test: exit 0 = reached the canary (LEAK), 1 =
|
|
# blocked (good), 2 = no usable tool (inconclusive -> fail-closed).
|
|
_GUEST_PROBE = r"""
|
|
h="$1"; p="$2"
|
|
if command -v python3 >/dev/null 2>&1; then
|
|
python3 - "$h" "$p" <<'PY'
|
|
import socket, sys
|
|
s = socket.socket(); s.settimeout(3)
|
|
sys.exit(0 if s.connect_ex((sys.argv[1], int(sys.argv[2]))) == 0 else 1)
|
|
PY
|
|
elif command -v bash >/dev/null 2>&1; then
|
|
timeout 3 bash -c "exec 3<>/dev/tcp/$h/$p" >/dev/null 2>&1
|
|
elif command -v nc >/dev/null 2>&1; then
|
|
nc -w3 -z "$h" "$p" >/dev/null 2>&1
|
|
else
|
|
exit 2
|
|
fi
|
|
"""
|
|
|
|
|
|
def verify_isolation(private_key: Path, guest_ip: str) -> None:
|
|
"""Prove the VM cannot reach the host's primary IP. Dies
|
|
(fail-closed) on a confirmed leak or if the guest has no tool to
|
|
run the test. Warns (does not fail) when the host has no canary
|
|
address to test against."""
|
|
canary_ip = _host_primary_ip()
|
|
if not canary_ip:
|
|
warn("isolation probe skipped: host has no non-TAP route to test "
|
|
"against (isolated box).")
|
|
return
|
|
|
|
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
listener.bind((canary_ip, 0))
|
|
listener.listen(1)
|
|
listener.settimeout(6)
|
|
canary_port = listener.getsockname()[1]
|
|
|
|
accepted: list[bool] = []
|
|
|
|
def _accept() -> None:
|
|
try:
|
|
conn, _ = listener.accept()
|
|
accepted.append(True)
|
|
conn.close()
|
|
except OSError:
|
|
pass
|
|
|
|
thread = threading.Thread(target=_accept, daemon=True)
|
|
thread.start()
|
|
|
|
ssh = util.ssh_base_argv(private_key, guest_ip)
|
|
result = subprocess.run(
|
|
[*ssh, "--", "sh", "-s", "--", canary_ip, str(canary_port)],
|
|
input=_GUEST_PROBE, capture_output=True, text=True, check=False,
|
|
)
|
|
thread.join(timeout=7)
|
|
listener.close()
|
|
|
|
if result.returncode == 0 or accepted:
|
|
die(f"ISOLATION FAILURE: the VM reached the host canary "
|
|
f"{canary_ip}:{canary_port}. The egress boundary is not in "
|
|
f"force — refusing to run the agent (fail-closed). Verify the "
|
|
f"nft table with: ./cli.py firecracker setup")
|
|
if result.returncode == 2:
|
|
die("isolation probe inconclusive: the guest has no python3/bash/nc "
|
|
"to run the connectivity test. Refusing to continue "
|
|
"(fail-closed).")
|
|
info(f"isolation verified: VM cannot reach host {canary_ip}")
|