dd2e83b8a9
Add abstract `setup()`/`status()` classmethods to BottleBackend (same
per-host, no-instance shape as is_available) so host provisioning is
part of the backend contract, not a per-backend command. Replace the
`./cli.py firecracker {setup,status}` command with a generic
`./cli.py backend {setup,status} [--backend=NAME]` that resolves a
backend (flag / $BOT_BOTTLE_BACKEND / host default) and dispatches —
swapping backends is just a different --backend.
Implementations:
- firecracker: moved out of cli/ into backend/firecracker/setup.py
(network pool module/script + range-overlap check), unchanged output.
- docker: new backend/docker/setup.py — reports docker on PATH, daemon
reachability, and gVisor runsc; setup notes no privileged pool is
needed. Minimal placeholder; richer version tracked in #345.
- macos-container: new setup.py — container CLI + system-service checks.
Also retarget the launch-preflight / isolation-probe pointers to the new
command. New test_cli_backend covers dispatch + docker setup/status.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
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 backend setup --backend=firecracker")
|
|
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}")
|