20f411b22e
Ships the smolmachines backend's prepare side: subpackage layout,
`_BACKENDS` registration under "smolmachines", preflight check
for `smolvm` + `gvproxy` on PATH, and the two config-file
renderers (Smolfile TOML + gvproxy YAML). Launch raises
NotImplementedError until chunk 2.
New module layout (mirrors backend/docker/):
claude_bottle/backend/smolmachines/
__init__.py re-exports SmolmachinesBottleBackend
backend.py SmolmachinesBottleBackend façade
bottle.py SmolmachinesBottle stub (NotImpl until ch2)
bottle_plan.py SmolmachinesBottlePlan + .print()
bottle_cleanup_plan.py SmolmachinesBottleCleanupPlan stub
prepare.py resolve_plan: writes both config files
smolfile.py TOML renderer (stdlib, no tomli_w dep)
gvproxy_config.py YAML renderer (same shape as pipelock_yaml)
util.py preflight + per-slug subnet + loopback port
The renderers are pure functions. `resolve_plan` runs the
preflight, allocates one host-side loopback port per active
sidecar (pipelock always; git-gate / supervise conditional),
derives a per-slug gvproxy subnet (hash-mod-254, skipping the
docker-default 17), and writes:
- <stage>/gvproxy.yaml: subnet + DNS rule resolving only
`proxy.internal` + port_forwards (one per active sidecar).
- <stage>/smolfile.toml: guest command/env + virtio-net device
backed by gvproxy's unixgram socket. No TSI flags — see
PRD 0023 "Why gvproxy, not TSI".
The agent's HTTPS_PROXY etc. point at `proxy.internal:<gateway-
port>` so the guest dials through gvproxy. gvproxy resolves only
`proxy.internal` → the gateway IP, and forwards exactly the
listed ports to the host-side sidecar bundle (PRD 0024); every
other destination — host LAN, host loopback, public internet
directly — is unreachable by construction.
29 new unit tests covering renderer correctness, subnet
derivation stability + collision-avoidance, loopback port
allocation, and preflight error paths. Full unit suite: 532
passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
102 lines
3.4 KiB
Python
102 lines
3.4 KiB
Python
"""gvproxy YAML config renderer (PRD 0023).
|
|
|
|
The gvproxy config defines:
|
|
|
|
- The per-bottle subnet + gateway IP — derived from the slug so
|
|
parallel bottles don't collide on 192.168.X.0/24.
|
|
- A DNS rule that resolves only `proxy.internal` to the gateway.
|
|
Every other hostname returns NXDOMAIN. This is the load-bearing
|
|
rule for PRD 0022's DNS-exfil attack: the guest can't `dig`
|
|
arbitrary names.
|
|
- `port_forwards` — one entry per sidecar daemon the bottle uses.
|
|
Only what's listed here is reachable from the guest. The
|
|
host-side sidecar bundle listens on the resolved `host_port`s;
|
|
gvproxy port-forwards `gateway_port` (what the guest dials) →
|
|
host `host_port`.
|
|
|
|
This is the file the PRD 0023 design names as the network
|
|
primitive. TSI is explicitly NOT used — see PRD 0023 "Why gvproxy,
|
|
not TSI".
|
|
|
|
The renderer is pure; disk writes happen in prepare.py."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PortForward:
|
|
"""One gvproxy port forward. The guest dials `gateway_port`; the
|
|
host receives the connection at `127.0.0.1:host_port`."""
|
|
gateway_port: int
|
|
host_port: int
|
|
|
|
|
|
def gvproxy_config_build(
|
|
*,
|
|
subnet: str,
|
|
gateway: str,
|
|
port_forwards: tuple[PortForward, ...],
|
|
) -> dict[str, Any]:
|
|
"""Build the gvproxy YAML config dict. The shape matches the
|
|
recipe in `agent-vm-isolation.md` § "Full Setup": top-level
|
|
`subnet`, `gateway`, `dns:` with the `proxy.internal` carve-out,
|
|
and `port_forwards`."""
|
|
return {
|
|
"subnet": subnet,
|
|
"gateway": gateway,
|
|
"dns": [
|
|
{
|
|
"zone": ".",
|
|
"records": [
|
|
{"name": "proxy.internal", "ip": gateway},
|
|
],
|
|
},
|
|
],
|
|
"port_forwards": [
|
|
{
|
|
"gateway_port": p.gateway_port,
|
|
"host": "127.0.0.1",
|
|
"host_port": p.host_port,
|
|
}
|
|
for p in port_forwards
|
|
],
|
|
}
|
|
|
|
|
|
def gvproxy_config_render(cfg: dict[str, Any]) -> str:
|
|
"""Render the gvproxy config as a small YAML subset (the same
|
|
shape `pipelock_render_yaml` uses). Stdlib has no YAML writer
|
|
and the config shape is narrow — strings + ints + lists of dicts
|
|
— so rendering by hand is straightforward and keeps the project
|
|
stdlib-only."""
|
|
lines: list[str] = []
|
|
lines.append(f'subnet: "{cfg["subnet"]}"')
|
|
lines.append(f'gateway: "{cfg["gateway"]}"')
|
|
lines.append("dns:")
|
|
for zone in cfg["dns"]:
|
|
lines.append(f' - zone: "{zone["zone"]}"')
|
|
lines.append(" records:")
|
|
for record in zone["records"]:
|
|
lines.append(f' - name: "{record["name"]}"')
|
|
lines.append(f' ip: "{record["ip"]}"')
|
|
if cfg["port_forwards"]:
|
|
lines.append("port_forwards:")
|
|
for pf in cfg["port_forwards"]:
|
|
lines.append(f' - gateway_port: {pf["gateway_port"]}')
|
|
lines.append(f' host: "{pf["host"]}"')
|
|
lines.append(f' host_port: {pf["host_port"]}')
|
|
else:
|
|
lines.append("port_forwards: []")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def gvproxy_config_write(cfg: dict[str, Any], path: Path) -> Path:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(gvproxy_config_render(cfg))
|
|
path.chmod(0o600)
|
|
return path
|