feat(smolmachines): per-bottle loopback alias scopes TSI to single /32
PR #74's Docker-Desktop fix routed the agent through `127.0.0.1:<random>` loopback forwards, but TSI filters by IP only — so the allowlist `127.0.0.1/32` let the agent VM reach **any** host service on macOS loopback (postgres, dev servers, other bottles' published ports, mDNSResponder, ...). Real downgrade vs the docker backend's `--internal` network. Resolution: per-bottle loopback alias. - New `loopback_alias` module manages a pool of `127.0.0.16` .. `127.0.0.31` on `lo0`. macOS only routes `127.0.0.1` by default; the extras need `sudo ifconfig lo0 alias`. `ensure_pool()` lazily adds the missing entries via one sudo prompt on first launch per reboot — aliases persist on `lo0` until reboot, so subsequent launches skip the prompt entirely. - `allocate(slug)` picks the lowest-numbered unused alias by inspecting running bundle containers' port-binding HostIps. No on-disk reservation — docker is the source of truth. - Bundle bringup binds published ports to the allocated alias (`docker run -p <alias>::<port>`) instead of `127.0.0.1`. - TSI allowlist becomes the alias's /32 — narrows reachability to this bottle's bundle only. - Linux native daemons share the host's network namespace; `127.0.0.0/8` works without aliases, so the module no-ops on non-Darwin and returns `127.0.0.1` from `allocate`. Tracking issue closed: gitea/issues/75. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -200,19 +200,14 @@ sidecar bundle still in Docker. Selected via
|
||||
The integration tests run against whichever backend the env var
|
||||
selects and skip cleanly when its prerequisites are missing.
|
||||
|
||||
**Known limitation, v1:** smolvm's TSI uses macOS networking, and
|
||||
Docker Desktop's container IPs aren't reachable from macOS, so the
|
||||
smolmachines bottle dials the sidecar bundle through host loopback
|
||||
port-forwards (`127.0.0.1:<random>`). TSI filters by IP only, so the
|
||||
allowlist is `127.0.0.1/32` — meaning the agent VM can reach **any
|
||||
service bound to macOS's loopback**, not just the bundle's published
|
||||
ports. Practical implication: while a smolmachines bottle is running,
|
||||
host-local dev services (postgres on 5432, dev servers, etc.) are
|
||||
reachable from inside the agent even if you intended them to be
|
||||
host-private. The docker backend keeps the bottle on a `--internal`
|
||||
docker network and doesn't have this issue. A future revision will
|
||||
narrow this via a per-bottle loopback alias + host-side proxy (see
|
||||
PRD 0023's "loopback scoping" section).
|
||||
**One-time sudo on first launch (macOS):** smolmachines needs
|
||||
per-bottle loopback aliases (`127.0.0.16` .. `127.0.0.31`) on `lo0`
|
||||
so each bottle's TSI allowlist scopes to its own /32. The first
|
||||
`./cli.py start` after each reboot prompts for sudo to add the pool
|
||||
via `ifconfig lo0 alias`. Aliases persist until reboot; subsequent
|
||||
launches don't prompt. Without this, every bottle would share
|
||||
`127.0.0.1` and be able to reach unrelated host services on the
|
||||
loopback.
|
||||
|
||||
## Manifest
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ from ..docker.pipelock import (
|
||||
PIPELOCK_PORT as _PIPELOCK_PORT_STR,
|
||||
pipelock_tls_init,
|
||||
)
|
||||
from . import loopback_alias as _loopback
|
||||
from . import sidecar_bundle as _bundle
|
||||
from . import smolvm as _smolvm
|
||||
from .bottle import SmolmachinesBottle
|
||||
@@ -76,7 +77,16 @@ def launch(
|
||||
via the ExitStack."""
|
||||
stack = ExitStack()
|
||||
try:
|
||||
# 1. Per-bottle docker bridge.
|
||||
# 1. Reserve a loopback alias for this bottle. macOS only
|
||||
# routes 127.0.0.1 by default; the per-bottle alias is
|
||||
# what bundles the docker port-publishes and TSI allowlist
|
||||
# against, so this bottle can't reach other bottles' (or
|
||||
# other host services') ports on the loopback. Lazy
|
||||
# sudo-driven on first use per boot. No-op on Linux.
|
||||
_loopback.ensure_pool()
|
||||
loopback_ip = _loopback.allocate(plan.slug)
|
||||
|
||||
# 2. Per-bottle docker bridge.
|
||||
network = _bundle.bundle_network_name(plan.slug)
|
||||
_bundle.create_bundle_network(network, plan.bundle_subnet, plan.bundle_gateway)
|
||||
stack.callback(_bundle.remove_bundle_network, network)
|
||||
@@ -112,21 +122,22 @@ def launch(
|
||||
)
|
||||
|
||||
# 3. Build the BundleLaunchSpec from the (now-resolved)
|
||||
# inner Plans: daemon subset, env, bind-mounts. The spec's
|
||||
# ports_to_publish list expands depending on which daemons
|
||||
# the agent needs to reach from the smolvm guest.
|
||||
bundle_spec = _bundle_launch_spec(plan, network)
|
||||
# inner Plans: daemon subset, env, bind-mounts, and the
|
||||
# loopback alias to bind published ports against. The
|
||||
# spec's ports_to_publish list expands depending on which
|
||||
# daemons the agent needs to reach from the smolvm guest.
|
||||
bundle_spec = _bundle_launch_spec(plan, network, loopback_ip)
|
||||
token_env = _resolve_token_env(plan, os.environ)
|
||||
_bundle.start_bundle(bundle_spec, env={**os.environ, **token_env})
|
||||
stack.callback(_bundle.stop_bundle, plan.slug)
|
||||
|
||||
# 4. Discover the host-side ports docker assigned for the
|
||||
# bundle's published container ports, and bind the
|
||||
# agent's URLs to `127.0.0.1:<host port>`. Docker container
|
||||
# IPs (192.168.x.x in the daemon's bridge) aren't
|
||||
# reachable from the smolvm guest on macOS — TSI uses
|
||||
# macOS networking, and macOS sees the daemon's bridge
|
||||
# via the published-port loopback forward only.
|
||||
# agent's URLs to `<loopback_ip>:<host port>`. Docker
|
||||
# container IPs (192.168.x.x in the daemon's bridge)
|
||||
# aren't reachable from the smolvm guest on macOS — TSI
|
||||
# uses macOS networking, and macOS sees the daemon's
|
||||
# bridge via the published-port loopback forward only.
|
||||
#
|
||||
# Proxy hop order matches the docker backend: when the
|
||||
# bottle declares egress routes, the agent's first hop is
|
||||
@@ -140,21 +151,21 @@ def launch(
|
||||
else:
|
||||
agent_facing_port = _PIPELOCK_PORT
|
||||
agent_facing_host_port = _bundle.bundle_host_port(
|
||||
plan.slug, agent_facing_port,
|
||||
plan.slug, agent_facing_port, host_ip=loopback_ip,
|
||||
)
|
||||
agent_proxy_url = f"http://127.0.0.1:{agent_facing_host_port}"
|
||||
agent_proxy_url = f"http://{loopback_ip}:{agent_facing_host_port}"
|
||||
agent_git_gate_host = ""
|
||||
if plan.git_gate_plan.upstreams:
|
||||
git_gate_host_port = _bundle.bundle_host_port(
|
||||
plan.slug, _GIT_GATE_PORT,
|
||||
plan.slug, _GIT_GATE_PORT, host_ip=loopback_ip,
|
||||
)
|
||||
agent_git_gate_host = f"127.0.0.1:{git_gate_host_port}"
|
||||
agent_git_gate_host = f"{loopback_ip}:{git_gate_host_port}"
|
||||
agent_supervise_url = ""
|
||||
if plan.supervise_plan is not None:
|
||||
supervise_host_port = _bundle.bundle_host_port(
|
||||
plan.slug, _SUPERVISE_PORT,
|
||||
plan.slug, _SUPERVISE_PORT, host_ip=loopback_ip,
|
||||
)
|
||||
agent_supervise_url = f"http://127.0.0.1:{supervise_host_port}/"
|
||||
agent_supervise_url = f"http://{loopback_ip}:{supervise_host_port}/"
|
||||
|
||||
# Stamp the URLs onto the plan + guest_env. provision_git
|
||||
# and provision_supervise read the plan fields; the agent
|
||||
@@ -178,15 +189,16 @@ def launch(
|
||||
|
||||
# 5. smolvm VM. --from carries the pre-packed .smolmachine
|
||||
# artifact (built by prepare); --allow-cidr + -e carry the
|
||||
# per-bottle TSI allowlist + env. The allowlist is
|
||||
# `127.0.0.1/32` because every bundle daemon the agent
|
||||
# reaches is fronted by a host loopback port-forward.
|
||||
# Smolfile isn't usable here — smolvm 0.8.0 makes `--from`
|
||||
# and `--smolfile` mutually exclusive.
|
||||
# per-bottle TSI allowlist + env. The allowlist is the
|
||||
# per-bottle loopback alias — narrowing it to one /32 keeps
|
||||
# the agent from reaching other host loopback services or
|
||||
# other bottles' published ports. Smolfile isn't usable
|
||||
# here — smolvm 0.8.0 makes `--from` and `--smolfile`
|
||||
# mutually exclusive.
|
||||
_smolvm.machine_create(
|
||||
plan.machine_name,
|
||||
from_path=plan.agent_from_path,
|
||||
allow_cidrs=["127.0.0.1/32"],
|
||||
allow_cidrs=[f"{loopback_ip}/32"],
|
||||
env=plan.guest_env,
|
||||
)
|
||||
stack.callback(_smolvm.machine_delete, plan.machine_name)
|
||||
@@ -240,7 +252,7 @@ def launch(
|
||||
|
||||
|
||||
def _bundle_launch_spec(
|
||||
plan: SmolmachinesBottlePlan, network: str
|
||||
plan: SmolmachinesBottlePlan, network: str, loopback_ip: str,
|
||||
) -> _bundle.BundleLaunchSpec:
|
||||
"""Build a BundleLaunchSpec from the resolved inner Plans.
|
||||
|
||||
@@ -345,6 +357,7 @@ def _bundle_launch_spec(
|
||||
environment=tuple(env),
|
||||
volumes=tuple(volumes),
|
||||
ports_to_publish=tuple(ports_to_publish),
|
||||
publish_host_ip=loopback_ip,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Per-bottle loopback alias allocation (PRD 0023, follow-up to
|
||||
the Docker-Desktop fix in PR #74).
|
||||
|
||||
After the pivot to host-loopback port-forwards, the smolmachines
|
||||
TSI allowlist was `127.0.0.1/32` — which meant the agent VM could
|
||||
reach **any** service bound to macOS's loopback, not just the
|
||||
bundle's published ports. That's a real downgrade from the
|
||||
docker backend's `--internal` network isolation.
|
||||
|
||||
This module narrows the allowlist by allocating each bottle a
|
||||
unique loopback alias (`127.0.0.16` .. `127.0.0.31` by default).
|
||||
The bundle's port-forwards bind to that alias, TSI's allowlist is
|
||||
the alias /32, and other host loopback services stay invisible to
|
||||
the bottle.
|
||||
|
||||
macOS only configures `127.0.0.1` on `lo0` by default; the
|
||||
additional aliases require `sudo ifconfig lo0 alias`. We lazily
|
||||
sudo-add the missing pool on first use per boot — the aliases
|
||||
persist on `lo0` until reboot, so subsequent launches don't
|
||||
prompt.
|
||||
|
||||
Linux native daemons share the host's network namespace; the
|
||||
whole `127.0.0.0/8` is reachable by default and aliases are
|
||||
unnecessary. The pool logic detects native-Linux and skips sudo
|
||||
entirely.
|
||||
|
||||
Allocation is coordinated by inspecting running bundle
|
||||
containers' published host IPs — each bottle's bundle owns the
|
||||
alias appearing in its port bindings. The lowest-numbered free
|
||||
alias gets handed to a new bottle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Iterable
|
||||
|
||||
from ...log import die, info
|
||||
|
||||
|
||||
# Sixteen aliases by default. Tunable for hosts that want more
|
||||
# concurrent bottles (each bottle reserves one alias for its
|
||||
# bundle bringup). The range is chosen to avoid the reserved
|
||||
# 127.0.0.1/2/3 ports (1 is the default, 2 is sometimes used by
|
||||
# CUPS, 3 by other macOS services) and stay well clear of
|
||||
# 127.0.0.53 (systemd-resolved) and 127.0.0.54 (libvirt).
|
||||
_POOL_START = 16
|
||||
_POOL_END = 31 # inclusive
|
||||
|
||||
|
||||
# Loopback aliases pool: 127.0.0.<start>..127.0.0.<end>.
|
||||
def _pool_addresses() -> list[str]:
|
||||
return [f"127.0.0.{i}" for i in range(_POOL_START, _POOL_END + 1)]
|
||||
|
||||
|
||||
def _is_macos() -> bool:
|
||||
return platform.system() == "Darwin"
|
||||
|
||||
|
||||
def ensure_pool() -> None:
|
||||
"""Make sure each address in the pool is up on `lo0`. Lazily
|
||||
runs `sudo ifconfig lo0 alias <ip>/32 up` for missing entries
|
||||
(sudo prompts once, then the aliases persist on lo0 until
|
||||
reboot). No-op on non-macOS hosts."""
|
||||
if not _is_macos():
|
||||
return
|
||||
missing = [ip for ip in _pool_addresses() if not _alias_present(ip)]
|
||||
if not missing:
|
||||
return
|
||||
info(
|
||||
f"smolmachines needs {len(missing)} loopback alias(es) on lo0 "
|
||||
f"({', '.join(missing[:3])}{', ...' if len(missing) > 3 else ''}) "
|
||||
f"to scope per-bottle TSI allowlists. sudo will prompt once; "
|
||||
f"aliases persist until reboot."
|
||||
)
|
||||
for ip in missing:
|
||||
result = subprocess.run(
|
||||
["sudo", "-p", "claude-bottle (loopback alias): ",
|
||||
"ifconfig", "lo0", "alias", f"{ip}/32", "up"],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"sudo ifconfig lo0 alias {ip} failed (exit "
|
||||
f"{result.returncode}). Re-run with sudo available, "
|
||||
f"or add manually: sudo ifconfig lo0 alias {ip}/32 up"
|
||||
)
|
||||
|
||||
|
||||
def allocate(slug: str) -> str:
|
||||
"""Pick the lowest-numbered alias from the pool not already
|
||||
in use by a running smolmachines bundle. Bails when the pool
|
||||
is exhausted — the caller should report the limit to the
|
||||
operator. `slug` is logged for traceability; not otherwise
|
||||
used (no on-disk reservation, allocation is purely
|
||||
docker-state-driven).
|
||||
|
||||
On non-macOS the whole `127.0.0.0/8` is loopback by default;
|
||||
`127.0.0.1` is fine to share and we skip the alias dance.
|
||||
This still returns a deterministic address so launch.py's
|
||||
callers don't have to branch on platform."""
|
||||
if not _is_macos():
|
||||
return "127.0.0.1"
|
||||
in_use = _aliases_in_use()
|
||||
for ip in _pool_addresses():
|
||||
if ip not in in_use:
|
||||
return ip
|
||||
die(
|
||||
f"smolmachines loopback alias pool exhausted "
|
||||
f"({_POOL_END - _POOL_START + 1} aliases, all in use). "
|
||||
f"Stop a running bottle (`smolvm machine ls --json`) or "
|
||||
f"raise _POOL_END in loopback_alias.py."
|
||||
)
|
||||
return "" # unreachable; die() never returns
|
||||
|
||||
|
||||
def _alias_present(ip: str) -> bool:
|
||||
"""True iff `ifconfig lo0` shows `<ip>` as an inet address.
|
||||
Exact-match — `127.0.0.1` shouldn't match `127.0.0.16`."""
|
||||
result = subprocess.run(
|
||||
["/sbin/ifconfig", "lo0"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
pattern = re.compile(rf"\binet {re.escape(ip)}\b")
|
||||
return bool(pattern.search(result.stdout or ""))
|
||||
|
||||
|
||||
def _aliases_in_use() -> set[str]:
|
||||
"""Aliases already bound by another smolmachines bundle's
|
||||
published-port mappings. We inspect every container whose
|
||||
name matches the smolmachines bundle prefix and pull the
|
||||
`HostIp` out of its port bindings."""
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "--format", "{{.Names}}",
|
||||
"--filter", "name=claude-bottle-sidecars-"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return set()
|
||||
names = [n.strip() for n in (result.stdout or "").splitlines() if n.strip()]
|
||||
in_use: set[str] = set()
|
||||
for name in names:
|
||||
in_use.update(_host_ips_for_container(name))
|
||||
return in_use
|
||||
|
||||
|
||||
def _host_ips_for_container(name: str) -> Iterable[str]:
|
||||
"""Yield the `HostIp` values across all port bindings on
|
||||
container `name`. A bundle binds three or four ports and
|
||||
they all share the same HostIp, so callers can take any."""
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", name,
|
||||
"--format", "{{json .HostConfig.PortBindings}}"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return ()
|
||||
try:
|
||||
bindings = json.loads(result.stdout or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return ()
|
||||
seen: set[str] = set()
|
||||
for _port, mappings in (bindings or {}).items():
|
||||
for m in mappings or []:
|
||||
host_ip = m.get("HostIp") or ""
|
||||
if host_ip:
|
||||
seen.add(host_ip)
|
||||
return seen
|
||||
|
||||
|
||||
__all__ = ["allocate", "ensure_pool"]
|
||||
@@ -70,13 +70,19 @@ class BundleLaunchSpec:
|
||||
environment: Sequence[str] = field(default_factory=tuple)
|
||||
# (host_path, container_path, read_only) bind mounts.
|
||||
volumes: Sequence[tuple[str, str, bool]] = field(default_factory=tuple)
|
||||
# Container ports to publish on the host's 127.0.0.1, random
|
||||
# Container ports to publish on `publish_host_ip`, random
|
||||
# host-side port per entry. The smolvm guest's TSI talks via
|
||||
# macOS networking, so docker container IPs (192.168.x.x in
|
||||
# the daemon's bridge) aren't directly reachable from the
|
||||
# guest — host-loopback port-forwards are. Egress's port
|
||||
# is bundle-internal and never published.
|
||||
ports_to_publish: Sequence[int] = field(default_factory=tuple)
|
||||
# Loopback IP to bind published ports against. Per-bottle
|
||||
# loopback aliases (`127.0.0.16` etc., added via sudo
|
||||
# ifconfig lo0 alias) narrow the TSI allowlist so a bottle
|
||||
# can't reach other bottles' (or other host services') ports
|
||||
# via 127.0.0.1.
|
||||
publish_host_ip: str = "127.0.0.1"
|
||||
|
||||
|
||||
def create_bundle_network(network_name: str, subnet: str, gateway: str) -> None:
|
||||
@@ -145,8 +151,10 @@ def start_bundle(spec: BundleLaunchSpec, *,
|
||||
# Loopback-only host port-forwards — the smolvm guest's TSI
|
||||
# uses macOS networking, and macOS loopback is the only host
|
||||
# surface that round-trips into Docker Desktop's daemon VM.
|
||||
# Binds to the per-bottle alias so TSI's IP-only allowlist
|
||||
# narrows reachability to this bottle's bundle only.
|
||||
for port in spec.ports_to_publish:
|
||||
argv += ["-p", f"127.0.0.1::{port}"]
|
||||
argv += ["-p", f"{spec.publish_host_ip}::{port}"]
|
||||
argv.append(spec.image)
|
||||
result = subprocess.run(
|
||||
argv, capture_output=True, text=True,
|
||||
@@ -159,13 +167,15 @@ def start_bundle(spec: BundleLaunchSpec, *,
|
||||
)
|
||||
|
||||
|
||||
def bundle_host_port(slug: str, container_port: int) -> int:
|
||||
def bundle_host_port(
|
||||
slug: str, container_port: int, *, host_ip: str = "127.0.0.1",
|
||||
) -> int:
|
||||
"""`docker port <bundle> <container_port>/tcp` → the random
|
||||
host-side port docker assigned. Called after `start_bundle`
|
||||
on each container port listed in `BundleLaunchSpec
|
||||
.ports_to_publish` so the launch step can build the agent's
|
||||
HTTPS_PROXY / GIT_GATE / SUPERVISE URLs in
|
||||
`127.0.0.1:<host port>` form."""
|
||||
host-side port docker assigned for the binding on `host_ip`.
|
||||
Called after `start_bundle` on each container port listed in
|
||||
`BundleLaunchSpec.ports_to_publish` so the launch step can
|
||||
build the agent's HTTPS_PROXY / GIT_GATE / SUPERVISE URLs in
|
||||
`<host_ip>:<host port>` form."""
|
||||
container = bundle_container_name(slug)
|
||||
result = subprocess.run(
|
||||
["docker", "port", container, f"{container_port}/tcp"],
|
||||
@@ -176,13 +186,21 @@ def bundle_host_port(slug: str, container_port: int) -> int:
|
||||
f"docker port {container} {container_port}/tcp failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
# `127.0.0.1:54321\n` — rpartition on last colon gives the port.
|
||||
line = (result.stdout or "").splitlines()[0].strip()
|
||||
# Each line looks like `127.0.0.16:54321` — one per address
|
||||
# family / host IP. Match on the expected host_ip prefix so
|
||||
# bottles bound to per-bottle aliases pick the right line.
|
||||
for raw in (result.stdout or "").splitlines():
|
||||
line = raw.strip()
|
||||
if line.startswith(f"{host_ip}:"):
|
||||
_, _, port_str = line.rpartition(":")
|
||||
try:
|
||||
return int(port_str)
|
||||
except ValueError:
|
||||
die(f"unexpected `docker port` output: {line!r}")
|
||||
die(
|
||||
f"no port mapping on {host_ip} for {container} "
|
||||
f"{container_port}/tcp; got: {(result.stdout or '').strip()!r}"
|
||||
)
|
||||
return -1 # unreachable; die() never returns
|
||||
|
||||
|
||||
|
||||
@@ -600,22 +600,18 @@ PRD 0024's bundle image is a prerequisite — this PRD assumes
|
||||
the plan is to filter on a deterministic name prefix
|
||||
`claude-bottle-<slug>` + cross-reference with on-disk metadata
|
||||
under `state/<slug>/`.
|
||||
8. **Loopback scoping (Docker Desktop pivot).** The original
|
||||
design pinned the bundle at a docker bridge IP and set TSI's
|
||||
allowlist to `<bundle-ip>/32`. On Docker Desktop / macOS the
|
||||
daemon runs inside its own Linux VM, so bridge IPs aren't
|
||||
reachable from macOS networking — TSI's syscall impersonation
|
||||
can't reach them. Resolution: publish each agent-facing bundle
|
||||
port on host loopback (`-p 127.0.0.1::<port>`) and set TSI to
|
||||
`127.0.0.1/32`. **This widens the TSI allowlist to anything
|
||||
bound to macOS's loopback** — postgres, dev servers, other
|
||||
bottles' published ports, mDNSResponder, etc. The agent can't
|
||||
reach them by intent, but TSI can't filter by port. Follow-up
|
||||
to scope back: bind each bottle's bundle ports on a per-bottle
|
||||
loopback alias (e.g. `127.0.0.2` for bottle A, `127.0.0.3` for
|
||||
B) added via `ifconfig lo0 alias`, set TSI to that single /32.
|
||||
Needs sudo for alias setup; a small daemon-or-script we ship
|
||||
alongside the launcher could handle it.
|
||||
8. **~~Loopback scoping (Docker Desktop pivot).~~ Resolved.**
|
||||
Each bottle now allocates a per-bottle loopback alias from a
|
||||
pool of `127.0.0.16` .. `127.0.0.31`, binds the bundle's
|
||||
port-forwards to that alias, and sets TSI's allowlist to the
|
||||
alias's /32. So a smolmachines bottle can only reach its own
|
||||
bundle's published ports — not other bottles' ports, and not
|
||||
unrelated host services on `127.0.0.1`. macOS loopback
|
||||
aliases need `sudo ifconfig lo0 alias`; the launcher lazily
|
||||
adds missing pool entries on first launch per reboot (sudo
|
||||
prompts once, aliases persist until reboot). Linux native
|
||||
daemons share the host's network namespace and skip the
|
||||
alias dance.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Unit: per-bottle loopback alias pool (follow-up to the
|
||||
Docker-Desktop fix in PR #74).
|
||||
|
||||
`ensure_pool` lazily sudo-adds missing aliases on macOS; no-ops
|
||||
on Linux. `allocate` picks the lowest-numbered unused alias by
|
||||
inspecting running bundle containers' port bindings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from claude_bottle.backend.smolmachines import loopback_alias
|
||||
|
||||
|
||||
def _ok(stdout: str = "") -> subprocess.CompletedProcess:
|
||||
return subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout=stdout, stderr="",
|
||||
)
|
||||
|
||||
|
||||
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess:
|
||||
return subprocess.CompletedProcess(
|
||||
args=[], returncode=1, stdout="", stderr=stderr,
|
||||
)
|
||||
|
||||
|
||||
# `ifconfig lo0` on macOS with the default lo0 config: just
|
||||
# 127.0.0.1. We craft fixtures around this shape.
|
||||
_LO0_DEFAULT = (
|
||||
"lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384\n"
|
||||
"\tinet 127.0.0.1 netmask 0xff000000\n"
|
||||
"\tinet6 ::1 prefixlen 128\n"
|
||||
)
|
||||
|
||||
_LO0_PARTIAL = (
|
||||
_LO0_DEFAULT
|
||||
+ "\tinet 127.0.0.16 netmask 0xffffffff\n"
|
||||
+ "\tinet 127.0.0.17 netmask 0xffffffff\n"
|
||||
)
|
||||
|
||||
|
||||
def _lo0_full() -> str:
|
||||
"""All 16 pool addresses already aliased."""
|
||||
aliases = "".join(
|
||||
f"\tinet 127.0.0.{i} netmask 0xffffffff\n"
|
||||
for i in range(16, 32)
|
||||
)
|
||||
return _LO0_DEFAULT + aliases
|
||||
|
||||
|
||||
class TestEnsurePool(unittest.TestCase):
|
||||
def test_noop_on_linux(self):
|
||||
# `_is_macos` returns False on Linux; ensure_pool should
|
||||
# never shell out to sudo.
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=False), \
|
||||
patch.object(loopback_alias.subprocess, "run") as run:
|
||||
loopback_alias.ensure_pool()
|
||||
run.assert_not_called()
|
||||
|
||||
def test_all_present_skips_sudo(self):
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(
|
||||
loopback_alias.subprocess, "run",
|
||||
return_value=_ok(stdout=_lo0_full()),
|
||||
) as run:
|
||||
loopback_alias.ensure_pool()
|
||||
# Just the ifconfig probe per pool address; no sudo at all.
|
||||
for call in run.call_args_list:
|
||||
self.assertNotIn("sudo", call.args[0])
|
||||
|
||||
def test_missing_aliases_dispatch_sudo(self):
|
||||
# lo0 only has 16+17 already; sudo runs for 18..31 (14 missing).
|
||||
runs: list[list[str]] = []
|
||||
|
||||
def fake_run(argv, *a, **kw):
|
||||
runs.append(argv)
|
||||
if argv[:2] == ["/sbin/ifconfig", "lo0"]:
|
||||
return _ok(stdout=_LO0_PARTIAL)
|
||||
return _ok()
|
||||
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(loopback_alias.subprocess, "run", side_effect=fake_run):
|
||||
loopback_alias.ensure_pool()
|
||||
|
||||
sudo_calls = [r for r in runs if r and r[0] == "sudo"]
|
||||
self.assertEqual(14, len(sudo_calls))
|
||||
sudo_ips = {call[call.index("alias") + 1].split("/")[0] for call in sudo_calls}
|
||||
self.assertEqual(
|
||||
{f"127.0.0.{i}" for i in range(18, 32)},
|
||||
sudo_ips,
|
||||
)
|
||||
|
||||
def test_sudo_failure_dies(self):
|
||||
def fake_run(argv, *a, **kw):
|
||||
if argv[:2] == ["/sbin/ifconfig", "lo0"]:
|
||||
return _ok(stdout=_LO0_DEFAULT)
|
||||
if argv[:1] == ["sudo"]:
|
||||
return _fail()
|
||||
return _ok()
|
||||
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(loopback_alias.subprocess, "run", side_effect=fake_run), \
|
||||
patch.object(loopback_alias, "die", side_effect=SystemExit("die")):
|
||||
with self.assertRaises(SystemExit):
|
||||
loopback_alias.ensure_pool()
|
||||
|
||||
|
||||
class TestAllocate(unittest.TestCase):
|
||||
def test_returns_loopback_on_linux(self):
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=False):
|
||||
self.assertEqual("127.0.0.1", loopback_alias.allocate("demo"))
|
||||
|
||||
def test_picks_lowest_unused_on_macos(self):
|
||||
# No bundles running -> first pool entry.
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(loopback_alias, "_aliases_in_use", return_value=set()):
|
||||
self.assertEqual("127.0.0.16", loopback_alias.allocate("demo-1"))
|
||||
|
||||
def test_skips_in_use_aliases(self):
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(
|
||||
loopback_alias, "_aliases_in_use",
|
||||
return_value={"127.0.0.16", "127.0.0.17", "127.0.0.19"},
|
||||
):
|
||||
# First unused = 127.0.0.18.
|
||||
self.assertEqual("127.0.0.18", loopback_alias.allocate("demo-3"))
|
||||
|
||||
def test_dies_when_pool_exhausted(self):
|
||||
all_in_use = {f"127.0.0.{i}" for i in range(16, 32)}
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(
|
||||
loopback_alias, "_aliases_in_use",
|
||||
return_value=all_in_use,
|
||||
), patch.object(
|
||||
loopback_alias, "die", side_effect=SystemExit("die"),
|
||||
):
|
||||
with self.assertRaises(SystemExit):
|
||||
loopback_alias.allocate("demo-overflow")
|
||||
|
||||
|
||||
class TestAliasInUseDetection(unittest.TestCase):
|
||||
"""`_aliases_in_use` inspects every running bundle and pulls
|
||||
each container's port-binding `HostIp` out. The detection has
|
||||
to survive: no running bundles, multiple bundles, docker
|
||||
inspect failures."""
|
||||
|
||||
def test_no_bundles_returns_empty(self):
|
||||
with patch.object(
|
||||
loopback_alias.subprocess, "run",
|
||||
return_value=_ok(stdout=""),
|
||||
):
|
||||
self.assertEqual(set(), loopback_alias._aliases_in_use())
|
||||
|
||||
def test_walks_bundles_and_pulls_host_ips(self):
|
||||
# First call: docker ps -> two bundle names.
|
||||
# Then docker inspect each, returning a port-bindings JSON
|
||||
# blob with a HostIp on the per-bottle alias.
|
||||
ps_out = "claude-bottle-sidecars-a\nclaude-bottle-sidecars-b\n"
|
||||
inspect_a = (
|
||||
'{"8888/tcp":[{"HostIp":"127.0.0.16","HostPort":"54000"}]}'
|
||||
)
|
||||
inspect_b = (
|
||||
'{"9099/tcp":[{"HostIp":"127.0.0.17","HostPort":"54001"}]}'
|
||||
)
|
||||
|
||||
seq = [
|
||||
_ok(stdout=ps_out),
|
||||
_ok(stdout=inspect_a),
|
||||
_ok(stdout=inspect_b),
|
||||
]
|
||||
with patch.object(
|
||||
loopback_alias.subprocess, "run", side_effect=seq,
|
||||
):
|
||||
self.assertEqual(
|
||||
{"127.0.0.16", "127.0.0.17"},
|
||||
loopback_alias._aliases_in_use(),
|
||||
)
|
||||
|
||||
def test_inspect_failures_are_skipped(self):
|
||||
ps_out = "claude-bottle-sidecars-c\n"
|
||||
with patch.object(
|
||||
loopback_alias.subprocess, "run",
|
||||
side_effect=[_ok(stdout=ps_out), _fail("inspect failed")],
|
||||
):
|
||||
self.assertEqual(set(), loopback_alias._aliases_in_use())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user