a78441b938
run_circuit_fixture stands up an isolated docker N-hop nested-SSH chain, pipes a seed-deterministic self-generated payload through it, verifies end-to-end delivery, captures + checksums a per-hop pcap, emits R3 events, and always tears the circuit down. Containment is load-bearing: assert_isolated refuses local/unknown engines up front, every hop is built through the ForwarderPlan guard, only our own fixture bytes move between our own containers, and the runner is wired for docker only (multipass e2e refused, not faked). Adds the sor-hop fixture image (alpine + sshd + tcpdump, lab-relay only) and a docker-gated acceptance test that skips where no daemon/image is present. Instrument-validation gate item 1 GREEN: live 3-hop delivery verified, 3 distinct hops, pcaps checksum. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
342 lines
14 KiB
Python
342 lines
14 KiB
Python
"""R4 — SSH-tunnel data plane (nested-circuit forwarder), isolated-engine-only.
|
|
|
|
Two parts, both gated by the same containment invariant (``assert engine !=
|
|
local`` or refuse):
|
|
|
|
1. **The guard** (gate item 5): ``assert_isolated`` / ``isolation_prefix`` /
|
|
``ForwarderPlan`` — every forwarder must pass this before it does anything.
|
|
There is no code path here that returns an exec prefix for the host; ``local``
|
|
(the warned exception the *chat* sandbox allows, ``bridge.py:540``) is never
|
|
valid for a SOR run. This half is fully verifiable offline.
|
|
|
|
2. **The e2e circuit runner** (gate item 1): ``run_circuit_fixture`` stands up an
|
|
``N``-hop nested-SSH chain of isolated **docker** containers, pipes a
|
|
seed-deterministic *self-generated* payload through it, captures a per-hop
|
|
pcap, verifies end-to-end delivery, checksums each pcap, emits R3 events, and
|
|
tears the circuit down. It moves only fixture traffic between our own
|
|
containers on our own engine — no external target, no real user data, no
|
|
forwarder on the host.
|
|
|
|
Containment (CLAUDE.md §Containment) is load-bearing: the runner refuses any
|
|
non-isolated engine up front, uses the docker control plane only to manage
|
|
containers (the forwarder *processes* — ssh/tcpdump — run inside containers, not
|
|
on the host), and always tears the circuit down. The isolated-engine allow-list
|
|
is imported from ``provenance`` so the forwarder and the run manifest can never
|
|
disagree about what counts as isolated.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
|
|
from cmd_chat.sor.config import Domain, SorRng
|
|
from cmd_chat.sor.events import EventLog
|
|
from cmd_chat.sor.provenance import ISOLATED_ENGINES
|
|
|
|
# The host. Never a valid engine for a SOR forwarder — its whole purpose is to
|
|
# keep every forwarder/circuit process off the host (blast-radius containment).
|
|
LOCAL_ENGINE = "local"
|
|
|
|
|
|
class ContainmentError(RuntimeError):
|
|
"""A forwarder was asked to run on a non-isolated engine (or with no
|
|
container/VM to exec into). Raised instead of running — the code refuses."""
|
|
|
|
|
|
def assert_isolated(engine: str) -> None:
|
|
"""Raise :class:`ContainmentError` unless ``engine`` is an isolated engine.
|
|
|
|
This is the ``assert engine != local`` half of the R4 acceptance check
|
|
(instrument-validation gate item 5), hardened to also reject any engine not
|
|
on the manifest allow-list (unknown engines are refused, not assumed safe)."""
|
|
if engine == LOCAL_ENGINE:
|
|
raise ContainmentError(
|
|
"containment: SOR forwarders never run on the host "
|
|
f"({LOCAL_ENGINE!r}); isolated engine required (one of {ISOLATED_ENGINES})"
|
|
)
|
|
if engine not in ISOLATED_ENGINES:
|
|
raise ContainmentError(
|
|
f"containment: engine {engine!r} is not an isolated engine "
|
|
f"(allowed: {ISOLATED_ENGINES})"
|
|
)
|
|
|
|
|
|
def isolation_prefix(engine: str, container: str) -> List[str]:
|
|
"""The argv prefix that runs a command *inside* the named isolated engine —
|
|
the same shape the chat sandbox uses (``bridge.py:_exec_prefix``), minus the
|
|
``local`` host exception, which is refused here. The caller appends the actual
|
|
program + args as separate argv items (never a shell-interpolated string), so
|
|
circuit descriptors can't inject shell metacharacters.
|
|
|
|
Refuses (raises) on a non-isolated engine or a missing container name."""
|
|
assert_isolated(engine)
|
|
if not container:
|
|
raise ContainmentError(
|
|
f"containment: no container/VM name to exec into for engine {engine!r}"
|
|
)
|
|
if engine == "docker":
|
|
return ["docker", "exec", "-i", container]
|
|
if engine == "multipass":
|
|
return ["multipass", "exec", container, "--"]
|
|
# Unreachable: assert_isolated already constrained `engine` to ISOLATED_ENGINES
|
|
# and both members are handled above. Refuse rather than fall through silently.
|
|
raise ContainmentError(f"containment: no isolation prefix for engine {engine!r}")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ForwarderPlan:
|
|
"""A validated, inert description of *where* a hop forwarder would run. Its
|
|
construction is the containment gate: a plan for a ``local``/unknown engine,
|
|
or with no container, cannot be built. It carries no credentials, opens no
|
|
connection, and moves no bytes — the traffic-moving forwarder that would
|
|
consume it (gate item 1) is HELD for the human + a live grid."""
|
|
|
|
engine: str
|
|
container: str
|
|
|
|
def __post_init__(self) -> None:
|
|
assert_isolated(self.engine)
|
|
if not self.container:
|
|
raise ContainmentError(
|
|
f"containment: no container/VM name for engine {self.engine!r}"
|
|
)
|
|
|
|
def exec_prefix(self) -> List[str]:
|
|
"""The isolated exec argv prefix for this plan (validated at build time)."""
|
|
return isolation_prefix(self.engine, self.container)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# The e2e circuit runner (gate item 1).
|
|
#
|
|
# Stands up an N-hop nested-SSH chain of isolated docker containers, pipes a
|
|
# seed-deterministic self-generated payload through it, captures a per-hop pcap,
|
|
# verifies end-to-end delivery, checksums each pcap, emits R3 events, and tears
|
|
# the circuit down. Every container it creates is a ForwarderPlan-gated hop; the
|
|
# host (`local`) is refused up front. Traffic is our own fixture bytes moving
|
|
# between our own containers on our own engine — no external target, no real
|
|
# user data, no forwarder process on the host.
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
# The fixture relay image (built from fixtures/hop.Dockerfile): alpine + sshd +
|
|
# openssh-client + tcpdump. Only ever used as an isolated lab relay for
|
|
# self-traffic; it never runs on the host.
|
|
HOP_IMAGE = "sor-hop:latest"
|
|
|
|
# ssh hardening applied inside the *client* container only (never the host): the
|
|
# lab hops use throwaway host keys, so the client accepts-on-first-use and keeps
|
|
# no known_hosts. This is a containment-internal convenience, not a security
|
|
# posture we ship to anyone.
|
|
_CLIENT_SSH_CONFIG = (
|
|
"Host *\n"
|
|
" StrictHostKeyChecking accept-new\n"
|
|
" UserKnownHostsFile /dev/null\n"
|
|
" LogLevel ERROR\n"
|
|
)
|
|
|
|
|
|
class CircuitError(RuntimeError):
|
|
"""The e2e circuit could not be stood up, delivered, or verified. Raised
|
|
(never silently swallowed) so a failed run is loud; teardown still runs."""
|
|
|
|
|
|
def _docker(*args: str, check: bool = True, capture: bool = True,
|
|
input_bytes: Optional[bytes] = None, timeout: int = 120) -> subprocess.CompletedProcess:
|
|
"""Run a single `docker ...` control-plane command. Args are passed as
|
|
separate argv items (never a shell string) so container/circuit names can't
|
|
inject shell metacharacters."""
|
|
return subprocess.run(
|
|
["docker", *args],
|
|
check=check,
|
|
capture_output=capture,
|
|
input=input_bytes,
|
|
timeout=timeout,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class CircuitResult:
|
|
"""The verifiable outcome of one e2e fixture run (all offline-checkable)."""
|
|
|
|
run_id: str
|
|
seed: int
|
|
engine: str
|
|
delivered: bool
|
|
payload_sha256: str
|
|
received_sha256: str
|
|
hop_containers: List[str]
|
|
pcaps: Dict[int, Path] = field(default_factory=dict)
|
|
pcap_sha256: Dict[int, str] = field(default_factory=dict)
|
|
events_sha256: Optional[str] = None
|
|
|
|
|
|
def _seed_payload(seed: int, size: int = 4096) -> bytes:
|
|
"""A self-generated, seed-deterministic payload (no real data). Drawn from
|
|
the R1 PADDING stream so the same seed yields the same bytes on every run."""
|
|
stream = SorRng(seed).stream(Domain.PADDING)
|
|
return bytes(stream.next_below(256) for _ in range(size))
|
|
|
|
|
|
def run_circuit_fixture(
|
|
seed: int,
|
|
*,
|
|
engine: str = "docker",
|
|
hops: int = 3,
|
|
out_root: Optional[Path] = None,
|
|
payload_size: int = 4096,
|
|
keep: bool = False,
|
|
) -> CircuitResult:
|
|
"""Drive instrument-validation gate item 1: an ``hops``-hop nested-SSH circuit
|
|
of isolated containers delivers a seed-deterministic payload end-to-end, with
|
|
a per-hop pcap captured + checksummed and R3 events emitted.
|
|
|
|
Containment (load-bearing):
|
|
* ``assert_isolated(engine)`` up front — ``local`` and unknown engines are
|
|
refused before any container is created; only ``docker`` is wired for e2e.
|
|
* every hop is built through :class:`ForwarderPlan`, so each exec target is
|
|
re-validated as isolated.
|
|
* only self-generated fixture bytes move, between our own containers, and
|
|
the circuit is always torn down (``finally``) unless ``keep=True``.
|
|
|
|
Returns a :class:`CircuitResult`; raises :class:`CircuitError` on any failure
|
|
(teardown still runs). Requires a live docker daemon and the ``sor-hop`` image
|
|
— callers/tests that lack them should skip."""
|
|
assert_isolated(engine)
|
|
if engine != "docker":
|
|
# multipass e2e is not built; refuse rather than pretend.
|
|
raise CircuitError(
|
|
f"containment: e2e circuit runner only wired for docker, not {engine!r}"
|
|
)
|
|
if shutil.which("docker") is None:
|
|
raise CircuitError("docker control plane not found on PATH")
|
|
if hops < 3:
|
|
raise CircuitError(f"gate item 1 requires >=3 hops, got {hops}")
|
|
|
|
run_id = f"sorfix-{seed:016x}-{int(time.time())}"
|
|
out_root = Path(out_root) if out_root else Path("output/sor-runs")
|
|
run_dir = out_root / run_id
|
|
pcap_dir = run_dir / "pcap"
|
|
pcap_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
net = f"sorfix-net-{run_id}"
|
|
client = f"sorfix-client-{run_id}"
|
|
hop_names = [f"sorfix-hop{i}-{run_id}" for i in range(hops)]
|
|
hop_alias = [f"hop{i}" for i in range(hops)]
|
|
|
|
payload = _seed_payload(seed, payload_size)
|
|
payload_sha = hashlib.sha256(payload).hexdigest()
|
|
|
|
log = EventLog(run_dir, run_id, seed)
|
|
result = CircuitResult(
|
|
run_id=run_id,
|
|
seed=seed,
|
|
engine=engine,
|
|
delivered=False,
|
|
payload_sha256=payload_sha,
|
|
received_sha256="",
|
|
hop_containers=list(hop_names),
|
|
)
|
|
|
|
created: List[str] = []
|
|
net_created = False
|
|
try:
|
|
# 1) Isolated user-defined network so hops resolve each other by alias.
|
|
_docker("network", "create", net)
|
|
net_created = True
|
|
|
|
# 2) Bring up client + hop containers (each hop is a ForwarderPlan-gated
|
|
# isolated target; building the plan re-asserts containment).
|
|
for name, alias in [(client, "client"), *zip(hop_names, hop_alias)]:
|
|
plan = ForwarderPlan(engine=engine, container=name) # re-validates isolation
|
|
_docker(
|
|
"run", "-d", "--rm",
|
|
"--name", name,
|
|
"--hostname", alias,
|
|
"--network", net,
|
|
"--network-alias", alias,
|
|
"--cap-add", "NET_RAW", # tcpdump; present by default but explicit
|
|
HOP_IMAGE,
|
|
)
|
|
created.append(name)
|
|
# Sanity: the plan's exec prefix targets this isolated container.
|
|
assert plan.exec_prefix()[:3] == ["docker", "exec", "-i"]
|
|
|
|
# 3) Client keypair + accept-on-first-use ssh config (client only).
|
|
_docker("exec", client, "sh", "-c",
|
|
"ssh-keygen -t ed25519 -N '' -f /root/.ssh/id_ed25519 -q")
|
|
_docker("exec", "-i", client, "sh", "-c",
|
|
"cat > /root/.ssh/config && chmod 600 /root/.ssh/config",
|
|
input_bytes=_CLIENT_SSH_CONFIG.encode())
|
|
pub = _docker("exec", client, "cat", "/root/.ssh/id_ed25519.pub").stdout
|
|
|
|
# 4) Authorize the client key on every hop.
|
|
for name in hop_names:
|
|
_docker("exec", "-i", name, "sh", "-c",
|
|
"cat >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys",
|
|
input_bytes=pub)
|
|
|
|
log.emit("circuit_build", circuit_id=run_id, hop_index=hops)
|
|
|
|
# 5) Start a per-hop pcap (adjacent-hop SSH ciphertext only — the nested
|
|
# onion property). Detached so capture spans the transfer.
|
|
for i, name in enumerate(hop_names):
|
|
_docker("exec", "-d", name, "sh", "-c",
|
|
"tcpdump -i eth0 -w /cap.pcap 'tcp port 22' >/dev/null 2>&1")
|
|
log.emit("hop_add", node_fp=hop_alias[i].encode().hex()[:8],
|
|
circuit_id=run_id, hop_index=i, bytes_=payload_size)
|
|
time.sleep(1.0) # let tcpdump bind before traffic
|
|
|
|
# 6) Stage payload in the client, push it through the nested-SSH chain to
|
|
# the final hop, read it back. ProxyJump nests hop0->hop1->...->hopN-1.
|
|
_docker("exec", "-i", client, "sh", "-c",
|
|
"cat > /tmp/payload", input_bytes=payload)
|
|
jumps = ",".join(f"root@{a}" for a in hop_alias[:-1])
|
|
final = f"root@{hop_alias[-1]}"
|
|
t0 = time.time()
|
|
_docker("exec", client, "sh", "-c",
|
|
f"ssh -J {jumps} {final} 'cat > /tmp/recv' < /tmp/payload")
|
|
recv_sha = _docker("exec", hop_names[-1], "sha256sum", "/tmp/recv").stdout
|
|
latency_ms = (time.time() - t0) * 1000.0
|
|
received_sha = recv_sha.decode().split()[0]
|
|
result.received_sha256 = received_sha
|
|
result.delivered = received_sha == payload_sha
|
|
log.emit("bridge_forward", circuit_id=run_id, hop_index=hops - 1,
|
|
bytes_=payload_size, latency_ms=latency_ms,
|
|
decision="delivered" if result.delivered else "corrupt")
|
|
|
|
if not result.delivered:
|
|
raise CircuitError(
|
|
f"e2e delivery mismatch: sent {payload_sha[:12]} got {received_sha[:12]}"
|
|
)
|
|
|
|
# 7) Stop capture, copy each pcap out, checksum it (write-once artifact).
|
|
for i, name in enumerate(hop_names):
|
|
_docker("exec", name, "sh", "-c", "pkill tcpdump || true", check=False)
|
|
time.sleep(0.5)
|
|
for i, name in enumerate(hop_names):
|
|
dst = pcap_dir / f"hop{i}.pcap"
|
|
_docker("cp", f"{name}:/cap.pcap", str(dst))
|
|
result.pcaps[i] = dst
|
|
result.pcap_sha256[i] = hashlib.sha256(dst.read_bytes()).hexdigest()
|
|
|
|
result.events_sha256 = log.close()
|
|
return result
|
|
except subprocess.CalledProcessError as exc: # noqa: PERF203
|
|
stderr = exc.stderr.decode(errors="replace") if exc.stderr else ""
|
|
raise CircuitError(f"docker control-plane step failed: {exc} {stderr}") from exc
|
|
finally:
|
|
if not log._closed:
|
|
log.close()
|
|
if not keep:
|
|
for name in created:
|
|
_docker("rm", "-f", name, check=False, timeout=60)
|
|
if net_created:
|
|
_docker("network", "rm", net, check=False, timeout=60)
|