feat(ssh-gate)!: remove ssh-gate sidecar and provisioner (PRD 0009)
Delete claude_bottle/ssh_gate.py, the DockerSSHGate sidecar, and the provision_ssh provisioner (~/.ssh/config + ssh-agent wiring). Unwire the gate from the abstract BottleBackend (provision orchestration drops the ssh step, _validate_ssh_entries goes away) and from the Docker backend (prepare/launch lose the `gate` kwarg, bottle_plan drops the gate_plan field, dry-run JSON drops the ssh_hosts / ssh_gate keys, y/N preflight drops the ssh-hosts block). cli/info now prints declared git remotes instead of ssh hosts. pipelock's docstring picks up the git-gate framing now that there's no PRD-0007 boundary to call out. BREAKING (dry-run JSON): the `ssh_hosts` and `ssh_gate` keys are gone from `start --dry-run --format=json`. Consumers should read `git_remotes` / `git_gate` instead.
This commit is contained in:
@@ -37,7 +37,7 @@ from pathlib import Path
|
||||
from typing import Any, Generic, Sequence, TypeVar
|
||||
|
||||
from ..log import die
|
||||
from ..manifest import GitEntry, Manifest, SshEntry
|
||||
from ..manifest import GitEntry, Manifest
|
||||
from ..util import expand_tilde
|
||||
from .util import host_skill_dir
|
||||
|
||||
@@ -162,7 +162,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
|
||||
def _validate(self, spec: BottleSpec) -> None:
|
||||
"""Cross-backend pre-launch checks. Confirms the agent exists,
|
||||
the named skills are present on the host, and every SSH
|
||||
the named skills are present on the host, and every git
|
||||
IdentityFile resolves. Subclasses with additional preconditions
|
||||
should override and call `super()._validate(spec)` first."""
|
||||
manifest = spec.manifest
|
||||
@@ -170,7 +170,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
agent = manifest.agents[spec.agent_name]
|
||||
bottle = manifest.bottle_for(spec.agent_name)
|
||||
self._validate_skills(agent.skills)
|
||||
self._validate_ssh_entries(bottle.ssh)
|
||||
self._validate_git_entries(bottle.git)
|
||||
|
||||
def _validate_skills(self, skills: Sequence[str]) -> None:
|
||||
@@ -185,15 +184,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
f"Create it under ~/.claude/skills/, then re-run."
|
||||
)
|
||||
|
||||
def _validate_ssh_entries(self, entries: Sequence[SshEntry]) -> None:
|
||||
"""Each entry's IdentityFile must exist on the host (after
|
||||
expanding leading ~). Shape is already enforced by Manifest
|
||||
validation; this only checks file presence."""
|
||||
for entry in entries:
|
||||
key = expand_tilde(entry.IdentityFile)
|
||||
if not os.path.isfile(key):
|
||||
die(f"ssh key file not found for host '{entry.Host}': {key}")
|
||||
|
||||
def _validate_git_entries(self, entries: Sequence[GitEntry]) -> None:
|
||||
"""Each entry's IdentityFile must exist on the host (after
|
||||
expanding leading ~) — the git-gate copies it in at start time
|
||||
@@ -215,24 +205,23 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
"""Build/run the bottle and yield a handle; tear down on exit."""
|
||||
|
||||
def provision(self, plan: PlanT, target: str) -> str | None:
|
||||
"""Copy host-side files (CA cert, prompt, skills, SSH keys,
|
||||
.git) into the running bottle. Called from `launch` after the
|
||||
container/machine is up. `target` identifies the running
|
||||
instance in backend-specific terms (Docker: resolved
|
||||
container name; fly: machine id). Returns the in-container
|
||||
prompt path if a prompt was provisioned, else None — the
|
||||
Bottle handle uses it to decide whether to add
|
||||
--append-system-prompt-file to claude's argv.
|
||||
"""Copy host-side files (CA cert, prompt, skills, .git) into
|
||||
the running bottle. Called from `launch` after the container
|
||||
/ machine is up. `target` identifies the running instance in
|
||||
backend-specific terms (Docker: resolved container name; fly:
|
||||
machine id). Returns the in-container prompt path if a prompt
|
||||
was provisioned, else None — the Bottle handle uses it to
|
||||
decide whether to add --append-system-prompt-file to claude's
|
||||
argv.
|
||||
|
||||
Default orchestration: ca → prompt → skills → ssh → git.
|
||||
CA install runs first so the agent's trust store is rebuilt
|
||||
before anything inside the agent makes a TLS call. Subclasses
|
||||
Default orchestration: ca → prompt → skills → git. CA install
|
||||
runs first so the agent's trust store is rebuilt before
|
||||
anything inside the agent makes a TLS call. Subclasses
|
||||
typically don't override this; they implement the sub-methods
|
||||
below."""
|
||||
self.provision_ca(plan, target)
|
||||
prompt_path = self.provision_prompt(plan, target)
|
||||
self.provision_skills(plan, target)
|
||||
self.provision_ssh(plan, target)
|
||||
self.provision_git(plan, target)
|
||||
return prompt_path
|
||||
|
||||
@@ -257,12 +246,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
"""Copy the agent's named skills from the host into the
|
||||
running bottle. No-op when the agent has no skills."""
|
||||
|
||||
@abstractmethod
|
||||
def provision_ssh(self, plan: PlanT, target: str) -> None:
|
||||
"""Set up SSH in the running bottle (config, agent, keys)
|
||||
so the bottle can reach the manifest's declared SSH hosts.
|
||||
No-op when the bottle has no SSH entries."""
|
||||
|
||||
@abstractmethod
|
||||
def provision_git(self, plan: PlanT, target: str) -> None:
|
||||
"""Copy the host's cwd `.git` directory into the running
|
||||
|
||||
@@ -29,8 +29,6 @@ from .provision import ca as _ca
|
||||
from .provision import git as _git
|
||||
from .provision import prompt as _prompt
|
||||
from .provision import skills as _skills
|
||||
from .provision import ssh as _ssh
|
||||
from .ssh_gate import DockerSSHGate
|
||||
|
||||
|
||||
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
|
||||
@@ -41,7 +39,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._proxy = DockerPipelockProxy()
|
||||
self._gate = DockerSSHGate()
|
||||
self._git_gate = DockerGitGate()
|
||||
|
||||
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
|
||||
@@ -49,7 +46,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
spec,
|
||||
stage_dir=stage_dir,
|
||||
proxy=self._proxy,
|
||||
gate=self._gate,
|
||||
git_gate=self._git_gate,
|
||||
)
|
||||
|
||||
@@ -58,7 +54,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
with _launch.launch(
|
||||
plan,
|
||||
proxy=self._proxy,
|
||||
gate=self._gate,
|
||||
git_gate=self._git_gate,
|
||||
provision=self.provision,
|
||||
) as bottle:
|
||||
@@ -73,9 +68,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
def provision_skills(self, plan: DockerBottlePlan, target: str) -> None:
|
||||
_skills.provision_skills(plan, target)
|
||||
|
||||
def provision_ssh(self, plan: DockerBottlePlan, target: str) -> None:
|
||||
_ssh.provision_ssh(plan, target)
|
||||
|
||||
def provision_git(self, plan: DockerBottlePlan, target: str) -> None:
|
||||
_git.provision_git(plan, target)
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ from ...git_gate import GitGatePlan
|
||||
from ...log import info
|
||||
from ...manifest import Agent, Bottle
|
||||
from ...pipelock import PipelockProxyPlan, pipelock_effective_allowlist
|
||||
from ...ssh_gate import SSHGatePlan
|
||||
from .. import BottlePlan
|
||||
|
||||
|
||||
@@ -27,7 +26,6 @@ class _PlanView:
|
||||
agent: Agent
|
||||
bottle: Bottle
|
||||
env_names: list[str]
|
||||
ssh_hosts: list[str]
|
||||
git_names: list[str]
|
||||
prompt_first_line: str
|
||||
|
||||
@@ -52,7 +50,6 @@ class DockerBottlePlan(BottlePlan):
|
||||
forwarded_env: dict[str, str] = field(repr=False)
|
||||
prompt_file: Path
|
||||
proxy_plan: PipelockProxyPlan
|
||||
gate_plan: SSHGatePlan
|
||||
git_gate_plan: GitGatePlan
|
||||
allowlist_summary: str
|
||||
use_runsc: bool
|
||||
@@ -69,7 +66,6 @@ class DockerBottlePlan(BottlePlan):
|
||||
agent=agent,
|
||||
bottle=bottle,
|
||||
env_names=env_names,
|
||||
ssh_hosts=[e.Host for e in bottle.ssh],
|
||||
git_names=[e.Name for e in bottle.git],
|
||||
prompt_first_line=agent.prompt.splitlines()[0] if agent.prompt else "",
|
||||
)
|
||||
@@ -94,16 +90,6 @@ class DockerBottlePlan(BottlePlan):
|
||||
info("skills : " + (" ".join(v.agent.skills) if v.agent.skills else "(none)"))
|
||||
info(f"docker runtime : {runtime_label}")
|
||||
info(f"bottle : {v.agent.bottle}")
|
||||
if v.ssh_hosts:
|
||||
info(f" ssh hosts : {', '.join(v.ssh_hosts)}")
|
||||
gate_lines = [
|
||||
f"{u.bottle_host_alias} -> {u.upstream_host}:{u.upstream_port} "
|
||||
f"(listen {u.listen_port})"
|
||||
for u in self.gate_plan.upstreams
|
||||
]
|
||||
info(f" ssh gate : {'; '.join(gate_lines)}")
|
||||
else:
|
||||
info(" ssh hosts : (none)")
|
||||
if v.git_names:
|
||||
info(f" git remotes : {', '.join(v.git_names)}")
|
||||
git_lines = [
|
||||
@@ -136,15 +122,6 @@ class DockerBottlePlan(BottlePlan):
|
||||
"runtime": "runsc" if self.use_runsc else "runc",
|
||||
"env_names": v.env_names,
|
||||
"skills": list(v.agent.skills),
|
||||
"ssh_hosts": v.ssh_hosts,
|
||||
"ssh_gate": [
|
||||
{
|
||||
"host": u.bottle_host_alias,
|
||||
"upstream": f"{u.upstream_host}:{u.upstream_port}",
|
||||
"listen_port": u.listen_port,
|
||||
}
|
||||
for u in self.gate_plan.upstreams
|
||||
],
|
||||
"git_remotes": v.git_names,
|
||||
"git_gate": [
|
||||
{
|
||||
|
||||
@@ -25,7 +25,6 @@ from .bottle_plan import DockerBottlePlan
|
||||
from .git_gate import DockerGitGate
|
||||
from .pipelock import DockerPipelockProxy, pipelock_proxy_url, pipelock_tls_init
|
||||
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||
from .ssh_gate import DockerSSHGate
|
||||
|
||||
|
||||
# Where the repo root lives, for `docker build` context. Computed once.
|
||||
@@ -37,7 +36,6 @@ def launch(
|
||||
plan: DockerBottlePlan,
|
||||
*,
|
||||
proxy: DockerPipelockProxy,
|
||||
gate: DockerSSHGate,
|
||||
git_gate: DockerGitGate,
|
||||
provision: Callable[[DockerBottlePlan, str], str | None],
|
||||
) -> Generator[DockerBottle, None, None]:
|
||||
@@ -89,21 +87,6 @@ def launch(
|
||||
pipelock_name = proxy.start(plan.proxy_plan)
|
||||
stack.callback(proxy.stop, pipelock_name)
|
||||
|
||||
# SSH egress gate (PRD 0007). One sidecar per agent, only
|
||||
# brought up when the bottle has ssh entries. Lives on the
|
||||
# same internal + egress networks pipelock straddles; the
|
||||
# agent dials it by container name (DNS works on --internal,
|
||||
# confirmed by the PRD 0007 spike).
|
||||
if plan.gate_plan.upstreams:
|
||||
gate_plan = dataclasses.replace(
|
||||
plan.gate_plan,
|
||||
internal_network=internal_network,
|
||||
egress_network=egress_network,
|
||||
)
|
||||
plan = dataclasses.replace(plan, gate_plan=gate_plan)
|
||||
gate_name = gate.start(plan.gate_plan)
|
||||
stack.callback(gate.stop, gate_name)
|
||||
|
||||
# Git gate (PRD 0008). One sidecar per agent, only brought up
|
||||
# when the bottle has git entries. Same internal + egress
|
||||
# network attachment as the other sidecars; agent dials it as
|
||||
|
||||
@@ -21,7 +21,6 @@ from . import util as docker_mod
|
||||
from .bottle_plan import DockerBottlePlan
|
||||
from .git_gate import DockerGitGate
|
||||
from .pipelock import DockerPipelockProxy
|
||||
from .ssh_gate import DockerSSHGate
|
||||
|
||||
|
||||
def resolve_plan(
|
||||
@@ -29,12 +28,11 @@ def resolve_plan(
|
||||
*,
|
||||
stage_dir: Path,
|
||||
proxy: DockerPipelockProxy,
|
||||
gate: DockerSSHGate,
|
||||
git_gate: DockerGitGate,
|
||||
) -> DockerBottlePlan:
|
||||
"""Resolve Docker-specific names and write scratch files. Trusts
|
||||
that the agent and its skills/SSH keys are present — validation
|
||||
already ran in the base class."""
|
||||
that the agent and its skills/git-gate keys are present —
|
||||
validation already ran in the base class."""
|
||||
docker_mod.require_docker()
|
||||
|
||||
manifest = spec.manifest
|
||||
@@ -82,7 +80,6 @@ def resolve_plan(
|
||||
prompt_file.chmod(0o600)
|
||||
|
||||
proxy_plan = proxy.prepare(bottle, slug, stage_dir)
|
||||
gate_plan = gate.prepare(bottle, slug, stage_dir)
|
||||
git_gate_plan = git_gate.prepare(bottle, slug, stage_dir)
|
||||
resolved = resolve_env(manifest, spec.agent_name)
|
||||
# Everything that should reach the bottle by-name (so its value
|
||||
@@ -111,7 +108,6 @@ def resolve_plan(
|
||||
forwarded_env=forwarded_env,
|
||||
prompt_file=prompt_file,
|
||||
proxy_plan=proxy_plan,
|
||||
gate_plan=gate_plan,
|
||||
git_gate_plan=git_gate_plan,
|
||||
allowlist_summary=allowlist_summary,
|
||||
use_runsc=use_runsc,
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
"""Set up SSH inside a running Docker bottle.
|
||||
|
||||
This is the most involved provisioner. The end state in the container:
|
||||
- ~/.ssh/config + ~/.ssh/known_hosts owned by node, mode 600
|
||||
- ssh-agent running as root with each key loaded; agent socket at
|
||||
/run/claude-bottle-agent.sock
|
||||
- socat forwarder (also root) bridging the agent socket to
|
||||
/run/claude-bottle-agent-public.sock (mode 666) so node can talk
|
||||
to the agent despite ssh-agent's SO_PEERCRED UID match
|
||||
- on-disk key files deleted after `ssh-add`; the bytes only live in
|
||||
the agent process's memory thereafter
|
||||
|
||||
See the `provision_ssh` docstring for the full isolation rationale."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from ....log import die, info
|
||||
from ....util import expand_tilde
|
||||
from .. import util as docker_mod
|
||||
from ..bottle_plan import DockerBottlePlan
|
||||
from ..ssh_gate import ssh_gate_host
|
||||
|
||||
|
||||
def provision_ssh(plan: DockerBottlePlan, target: str) -> None:
|
||||
"""Set up SSH in the container so node can authenticate using
|
||||
each entry's key without the key file being readable by node.
|
||||
No-op when the bottle has no SSH entries.
|
||||
|
||||
Isolation strategy:
|
||||
- Keys live at /root/.claude-bottle-keys/ (mode 700,
|
||||
root-owned). /root is mode 700 in node:22-slim, so node
|
||||
(uid 1000) can't even traverse in.
|
||||
- ssh-agent runs as root, listening on
|
||||
/run/claude-bottle-agent.sock. Each key is loaded with
|
||||
ssh-add, then deleted; the bytes now live only in the
|
||||
agent process's memory.
|
||||
- ssh-agent's SO_PEERCRED-based UID match rejects every
|
||||
connection whose peer euid is neither 0 nor the agent's.
|
||||
To bridge that, a root-owned socat forwarder listens on
|
||||
/run/claude-bottle-agent-public.sock (mode 666) and
|
||||
proxies bytes to the real agent socket.
|
||||
- node can't ptrace root-owned agent or socat, so
|
||||
/proc/<pid>/mem is off-limits and key bytes never leave
|
||||
root-owned memory.
|
||||
- ~/.ssh/config in node's home points each Host at the
|
||||
public socket via IdentityAgent.
|
||||
|
||||
Why an in-container agent (not bind-mounted from host):
|
||||
Docker Desktop on macOS does not forward Unix-domain socket
|
||||
connect() across the VM boundary — connect() returns
|
||||
ENOTSUP. Running ssh-agent inside the container sidesteps
|
||||
that entirely.
|
||||
|
||||
Limitation: keys must be passphrase-less. ssh-add prompts on
|
||||
/dev/tty for passphrases, but our docker exec has no TTY."""
|
||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||
if not bottle.ssh:
|
||||
return
|
||||
|
||||
container = target
|
||||
gate_target = ssh_gate_host(plan.slug)
|
||||
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||
container_ssh = f"{container_home}/.ssh"
|
||||
agent_socket = "/run/claude-bottle-agent.sock"
|
||||
public_socket = "/run/claude-bottle-agent-public.sock"
|
||||
keys_dir = "/root/.claude-bottle-keys"
|
||||
|
||||
# Per-entry listen ports come off the gate plan (PRD 0007).
|
||||
# Indexed by the bottle.ssh entry's Host alias so each ssh_config
|
||||
# block knows which port its forwarder lives on.
|
||||
upstreams_by_alias = {u.bottle_host_alias: u for u in plan.gate_plan.upstreams}
|
||||
if set(upstreams_by_alias) != {e.Host for e in bottle.ssh}:
|
||||
die(
|
||||
"ssh-gate upstream table is out of sync with bottle.ssh; "
|
||||
"this is an internal bug"
|
||||
)
|
||||
|
||||
# ~/.ssh for node (700, owned by node).
|
||||
docker_mod.docker_exec_root(container, ["mkdir", "-p", container_ssh])
|
||||
docker_mod.docker_exec_root(container, ["chown", "node:node", container_ssh])
|
||||
docker_mod.docker_exec_root(container, ["chmod", "700", container_ssh])
|
||||
|
||||
# /root/.claude-bottle-keys for root (700, root-owned).
|
||||
docker_mod.docker_exec_root(container, ["mkdir", "-p", keys_dir])
|
||||
docker_mod.docker_exec_root(container, ["chown", "root:root", keys_dir])
|
||||
docker_mod.docker_exec_root(container, ["chmod", "700", keys_dir])
|
||||
|
||||
config_file = plan.stage_dir / "ssh_config"
|
||||
known_hosts_file = plan.stage_dir / "ssh_known_hosts"
|
||||
config_file.write_text("")
|
||||
config_file.chmod(0o600)
|
||||
known_hosts_file.write_text("")
|
||||
known_hosts_file.chmod(0o600)
|
||||
|
||||
container_key_paths: list[str] = []
|
||||
for entry in bottle.ssh:
|
||||
name = entry.Host
|
||||
key = expand_tilde(entry.IdentityFile)
|
||||
hostname = entry.Hostname
|
||||
user = entry.User
|
||||
known_host_key = entry.KnownHostKey
|
||||
upstream = upstreams_by_alias[name]
|
||||
listen_port = upstream.listen_port
|
||||
|
||||
key_basename = os.path.basename(key)
|
||||
container_key_path = f"{keys_dir}/{key_basename}"
|
||||
|
||||
info(f"copying ssh key for '{name}' -> {container} (root-only staging)")
|
||||
subprocess.run(
|
||||
["docker", "cp", key, f"{container}:{container_key_path}"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
docker_mod.docker_exec_root(container, ["chown", "root:root", container_key_path])
|
||||
docker_mod.docker_exec_root(container, ["chmod", "600", container_key_path])
|
||||
|
||||
container_key_paths.append(container_key_path)
|
||||
|
||||
# Each Host block points at the gate container + its
|
||||
# per-entry listen port. HostKeyAlias makes ssh validate
|
||||
# the host key against `hostname` (the real upstream
|
||||
# name) instead of the gate container; CheckHostIP=no
|
||||
# skips the resolved-IP lookup, which would also point at
|
||||
# the gate.
|
||||
block = (
|
||||
f"Host {name}\n"
|
||||
f" HostName {gate_target}\n"
|
||||
f" User {user}\n"
|
||||
f" Port {listen_port}\n"
|
||||
f" IdentityAgent {public_socket}\n"
|
||||
f" HostKeyAlias {hostname}\n"
|
||||
f" CheckHostIP no\n"
|
||||
f"\n"
|
||||
)
|
||||
with config_file.open("a") as f:
|
||||
f.write(block)
|
||||
|
||||
if known_host_key:
|
||||
# HostKeyAlias makes ssh look up known_hosts under
|
||||
# `hostname` (the upstream's real name / IP literal),
|
||||
# not the gate container. One unambiguous entry per
|
||||
# ssh entry.
|
||||
with known_hosts_file.open("a") as f:
|
||||
f.write(f"{hostname} {known_host_key}\n")
|
||||
|
||||
# Boot the agent, load each key, delete the key files, then
|
||||
# start the root-owned socat forwarder. One docker exec so the
|
||||
# whole sequence is atomic.
|
||||
info(f"starting in-container ssh-agent at {agent_socket} (forwarded via {public_socket})")
|
||||
setup_lines = [
|
||||
"set -eu",
|
||||
f"ssh-agent -a {agent_socket} >/dev/null",
|
||||
]
|
||||
for kp in container_key_paths:
|
||||
setup_lines.append(f"SSH_AUTH_SOCK={agent_socket} ssh-add {kp}")
|
||||
setup_lines.append(f"rm -f {kp}")
|
||||
setup_lines.append(f"rmdir {keys_dir} 2>/dev/null || true")
|
||||
# Forwarder: socat (uid 0) connects to the agent on node's behalf.
|
||||
setup_lines.append(
|
||||
f"nohup socat UNIX-LISTEN:{public_socket},fork,reuseaddr,mode=666 "
|
||||
f"UNIX-CONNECT:{agent_socket} </dev/null >/dev/null 2>&1 &"
|
||||
)
|
||||
# Wait briefly for the forwarder to bind.
|
||||
setup_lines.extend([
|
||||
"i=0",
|
||||
"while [ $i -lt 20 ]; do",
|
||||
f" [ -S {public_socket} ] && break",
|
||||
" i=$((i + 1))",
|
||||
" sleep 0.1",
|
||||
"done",
|
||||
f"[ -S {public_socket} ] || {{ echo 'claude-bottle: socat forwarder failed to bind {public_socket}' >&2; exit 1; }}",
|
||||
])
|
||||
setup_script = "\n".join(setup_lines) + "\n"
|
||||
subprocess.run(
|
||||
["docker", "exec", "-u", "0", container, "sh", "-c", setup_script],
|
||||
check=True,
|
||||
)
|
||||
|
||||
info(f"writing {container_ssh}/config")
|
||||
subprocess.run(
|
||||
["docker", "cp", str(config_file), f"{container}:{container_ssh}/config"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
docker_mod.docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/config"])
|
||||
docker_mod.docker_exec_root(container, ["chmod", "600", f"{container_ssh}/config"])
|
||||
|
||||
if known_hosts_file.stat().st_size > 0:
|
||||
info(f"writing {container_ssh}/known_hosts")
|
||||
subprocess.run(
|
||||
["docker", "cp", str(known_hosts_file), f"{container}:{container_ssh}/known_hosts"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
docker_mod.docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/known_hosts"])
|
||||
docker_mod.docker_exec_root(container, ["chmod", "600", f"{container_ssh}/known_hosts"])
|
||||
@@ -1,159 +0,0 @@
|
||||
"""DockerSSHGate — the Docker-specific lifecycle for the per-agent
|
||||
SSH egress gate sidecar (PRD 0007). Inherits the platform-agnostic
|
||||
prepare step (upstream allocation + entrypoint render) from
|
||||
`SSHGate`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from ...log import die, info, warn
|
||||
from ...ssh_gate import SSHGate, SSHGatePlan
|
||||
|
||||
|
||||
# alpine/socat pinned by digest. The image is `alpine` + `socat`
|
||||
# pre-installed; PRD 0007 requires the gate image to be
|
||||
# self-sufficient at boot (no apk pulls) because the agent-facing
|
||||
# leg sits on the `--internal` network.
|
||||
SSH_GATE_IMAGE = os.environ.get(
|
||||
"CLAUDE_BOTTLE_SSH_GATE_IMAGE",
|
||||
"alpine/socat@sha256:a26f4bcee25ad4a4096ce91e596c0a2fffcbb51f7fd198dd87a5c86eae66f0e1",
|
||||
)
|
||||
|
||||
# In-container path the entrypoint script lands at after `docker cp`.
|
||||
# Root path keeps the cp simple — no intermediate directories to
|
||||
# create.
|
||||
SSH_GATE_ENTRYPOINT_IN_CONTAINER = "/ssh-gate-entrypoint.sh"
|
||||
|
||||
|
||||
def ssh_gate_container_name(slug: str) -> str:
|
||||
return f"claude-bottle-ssh-gate-{slug}"
|
||||
|
||||
|
||||
def ssh_gate_host(slug: str) -> str:
|
||||
"""The hostname the agent's ssh client should connect to. Same as
|
||||
the container name — Docker's embedded DNS resolves it on the
|
||||
`--internal` network (verified by the PRD 0007 DNS spike)."""
|
||||
return ssh_gate_container_name(slug)
|
||||
|
||||
|
||||
class DockerSSHGate(SSHGate):
|
||||
"""Brings the SSH gate sidecar up and down via Docker."""
|
||||
|
||||
def start(self, plan: SSHGatePlan) -> str:
|
||||
"""Boot the gate sidecar:
|
||||
1. `docker create` on the internal network with the
|
||||
canonical name, `--entrypoint /bin/sh`, and the
|
||||
in-container entrypoint path as the CMD.
|
||||
2. `docker cp` the entrypoint script in.
|
||||
3. Attach to the per-agent egress network so socat can dial
|
||||
upstream.
|
||||
4. `docker start`.
|
||||
Returns the container name (the target passed to `.stop`)."""
|
||||
if not plan.upstreams:
|
||||
die("DockerSSHGate.start called with no upstreams; caller should skip")
|
||||
if not plan.internal_network or not plan.egress_network:
|
||||
die(
|
||||
"DockerSSHGate.start: internal_network / egress_network must be "
|
||||
"populated on the plan before start"
|
||||
)
|
||||
if not plan.entrypoint_script.is_file():
|
||||
die(
|
||||
f"ssh-gate entrypoint script missing at {plan.entrypoint_script}; "
|
||||
f"SSHGate.prepare must run first"
|
||||
)
|
||||
|
||||
name = ssh_gate_container_name(plan.slug)
|
||||
info(f"starting ssh-gate sidecar {name} on network {plan.internal_network}")
|
||||
|
||||
create_args = [
|
||||
"docker", "create",
|
||||
"--name", name,
|
||||
"--network", plan.internal_network,
|
||||
"--entrypoint", "/bin/sh",
|
||||
SSH_GATE_IMAGE,
|
||||
SSH_GATE_ENTRYPOINT_IN_CONTAINER,
|
||||
]
|
||||
if subprocess.run(
|
||||
create_args,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode != 0:
|
||||
die(f"failed to create ssh-gate sidecar {name}")
|
||||
|
||||
cp_result = subprocess.run(
|
||||
[
|
||||
"docker", "cp",
|
||||
str(plan.entrypoint_script),
|
||||
f"{name}:{SSH_GATE_ENTRYPOINT_IN_CONTAINER}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if cp_result.returncode != 0:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
die(
|
||||
f"failed to copy ssh-gate entrypoint into {name}: "
|
||||
f"{cp_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
if subprocess.run(
|
||||
["docker", "network", "connect", plan.egress_network, name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode != 0:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
die(
|
||||
f"failed to attach ssh-gate sidecar {name} to egress network "
|
||||
f"{plan.egress_network}"
|
||||
)
|
||||
|
||||
if subprocess.run(
|
||||
["docker", "start", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode != 0:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
die(f"failed to start ssh-gate sidecar {name}")
|
||||
|
||||
return name
|
||||
|
||||
def stop(self, target: str) -> None:
|
||||
"""Idempotent: missing container is success. `target` is the
|
||||
container name returned by `.start`."""
|
||||
if subprocess.run(
|
||||
["docker", "inspect", target],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode == 0:
|
||||
if subprocess.run(
|
||||
["docker", "rm", "-f", target],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode != 0:
|
||||
warn(
|
||||
f"failed to remove ssh-gate sidecar {target}; "
|
||||
f"clean up with 'docker rm -f {target}'"
|
||||
)
|
||||
@@ -31,16 +31,15 @@ def cmd_info(argv: list[str]) -> int:
|
||||
f"first line: {prompt_first_line or '(empty)'}"
|
||||
)
|
||||
info(f"bottle : {agent.bottle}")
|
||||
if bottle.ssh:
|
||||
for e in bottle.ssh:
|
||||
if bottle.git:
|
||||
for e in bottle.git:
|
||||
info(
|
||||
f" ssh host : {e.Host} "
|
||||
f"(Hostname={e.Hostname}, User={e.User}, "
|
||||
f"Port={e.Port}, IdentityFile={e.IdentityFile})"
|
||||
f" git remote : {e.Name} -> {e.Upstream} "
|
||||
f"(IdentityFile={e.IdentityFile})"
|
||||
)
|
||||
if e.KnownHostKey:
|
||||
info(f" KnownHostKey: {e.KnownHostKey}")
|
||||
else:
|
||||
info(" ssh hosts : (none)")
|
||||
info(" git remotes : (none)")
|
||||
print()
|
||||
return 0
|
||||
|
||||
@@ -57,9 +57,9 @@ def pipelock_bottle_allowlist(bottle: Bottle) -> list[str]:
|
||||
|
||||
def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
|
||||
"""Deduplicated union of: baked-in defaults, bottle.egress.allowlist.
|
||||
Sorted for stability. Per PRD 0007, bottle.ssh entries do NOT
|
||||
contribute here — SSH traffic flows through the per-agent ssh-gate
|
||||
sidecar, not pipelock."""
|
||||
Sorted for stability. Git upstreams declared in `bottle.git` do NOT
|
||||
contribute here — git traffic flows through the per-agent git-gate
|
||||
sidecar (PRD 0008), not pipelock."""
|
||||
seen: dict[str, None] = {}
|
||||
for h in DEFAULT_ALLOWLIST:
|
||||
seen.setdefault(h, None)
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
"""Per-agent SSH egress gate (PRD 0007).
|
||||
|
||||
A second per-agent sidecar that does plain TCP forwarding from a set
|
||||
of static listen ports to the SSH hosts declared in `bottle.ssh`.
|
||||
The agent's ssh client points each `Host` block at the gate
|
||||
container + a per-entry listen port; pipelock stops seeing SSH
|
||||
traffic entirely.
|
||||
|
||||
This module defines the abstract gate (`SSHGate`) and the plan
|
||||
dataclass (`SSHGatePlan`) consumed by its `start`. The sidecar's
|
||||
start/stop lifecycle is backend-specific and lives on concrete
|
||||
subclasses (see `claude_bottle/backend/docker/ssh_gate.py`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .log import die
|
||||
from .manifest import Bottle
|
||||
|
||||
# Default port when an ssh entry has no `Port` field. Matches OpenSSH.
|
||||
_DEFAULT_SSH_PORT = 22
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SSHGateUpstream:
|
||||
"""One forwarder rule on the gate: listen locally on `listen_port`,
|
||||
forward each connection to `upstream_host:upstream_port`. The
|
||||
`bottle_host_alias` is the `Host` value from the manifest entry,
|
||||
kept for diagnostics + so the ssh provisioner can correlate
|
||||
upstreams with their alias.
|
||||
|
||||
`listen_port` mirrors the upstream port. That choice lets git
|
||||
URLs that bake the upstream port into the remote (e.g.
|
||||
`ssh://git@host:30009/repo.git`) work without rewriting: OpenSSH
|
||||
treats a URL-supplied port as overriding the config's `Port`
|
||||
directive, so the gate must be reachable on the same port the URL
|
||||
names. Two ssh entries that share an upstream port are a config
|
||||
error and rejected at prepare time."""
|
||||
|
||||
listen_port: int
|
||||
upstream_host: str
|
||||
upstream_port: str
|
||||
bottle_host_alias: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SSHGatePlan:
|
||||
"""Output of SSHGate.prepare; consumed by .start when the sidecar
|
||||
needs to be brought up.
|
||||
|
||||
`upstreams` + `slug` + `entrypoint_script` are filled in at
|
||||
prepare time (host-side, side-effect-free on docker). The network
|
||||
fields are populated by the backend's launch step via
|
||||
`dataclasses.replace` once those networks exist. Empty defaults
|
||||
are sentinels meaning "not yet set"; `.start` validates that
|
||||
they are populated."""
|
||||
|
||||
slug: str
|
||||
entrypoint_script: Path
|
||||
upstreams: tuple[SSHGateUpstream, ...]
|
||||
internal_network: str = ""
|
||||
egress_network: str = ""
|
||||
|
||||
|
||||
def ssh_gate_upstreams_for_bottle(bottle: Bottle) -> tuple[SSHGateUpstream, ...]:
|
||||
"""Build the gate's upstream table. Each ssh entry's listen port
|
||||
equals its upstream port so URL-supplied ports (which override
|
||||
`~/.ssh/config`'s `Port` directive) still reach the gate.
|
||||
|
||||
Dies on two entries sharing an upstream port — the gate is a
|
||||
single container with a flat port space, so each listener has to
|
||||
be unique."""
|
||||
seen_ports: dict[int, str] = {}
|
||||
upstreams: list[SSHGateUpstream] = []
|
||||
for e in bottle.ssh:
|
||||
port = int(e.Port) if e.Port else _DEFAULT_SSH_PORT
|
||||
if port in seen_ports:
|
||||
die(
|
||||
f"ssh entries '{seen_ports[port]}' and '{e.Host}' share upstream port "
|
||||
f"{port}; the per-agent ssh gate can only forward one upstream "
|
||||
f"per port. Change one of the upstream Ports in claude-bottle.json."
|
||||
)
|
||||
seen_ports[port] = e.Host
|
||||
upstreams.append(
|
||||
SSHGateUpstream(
|
||||
listen_port=port,
|
||||
upstream_host=e.Hostname,
|
||||
upstream_port=e.Port,
|
||||
bottle_host_alias=e.Host,
|
||||
)
|
||||
)
|
||||
return tuple(upstreams)
|
||||
|
||||
|
||||
def ssh_gate_render_entrypoint(upstreams: tuple[SSHGateUpstream, ...]) -> str:
|
||||
"""Render the gate's entrypoint script: one `socat TCP-LISTEN`
|
||||
per upstream, all backgrounded, then `wait`. Posix sh, no bash-isms
|
||||
(alpine's sh is busybox ash). If any one socat dies, the others
|
||||
keep running until the container is removed — matches the v1
|
||||
no-restart policy from the PRD."""
|
||||
lines = ["#!/bin/sh", "set -eu"]
|
||||
for u in upstreams:
|
||||
lines.append(
|
||||
f"socat TCP-LISTEN:{u.listen_port},reuseaddr,fork "
|
||||
f"TCP:{u.upstream_host}:{u.upstream_port} &"
|
||||
)
|
||||
lines.append("wait")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
class SSHGate(ABC):
|
||||
"""The per-agent SSH egress gate. Encapsulates the host-side
|
||||
prepare step (upstream allocation + entrypoint render); the
|
||||
sidecar's start/stop lifecycle is backend-specific and lives on
|
||||
concrete subclasses."""
|
||||
|
||||
def prepare(self, bottle: Bottle, slug: str, stage_dir: Path) -> SSHGatePlan:
|
||||
"""Compute the upstream table from `bottle.ssh` and write the
|
||||
entrypoint script (mode 600) under `stage_dir`. Pure host-side,
|
||||
no docker subprocess.
|
||||
|
||||
Returned plan is incomplete: the launch step must fill
|
||||
`internal_network` / `egress_network` via `dataclasses.replace`
|
||||
before passing the plan to `.start`."""
|
||||
upstreams = ssh_gate_upstreams_for_bottle(bottle)
|
||||
script = stage_dir / "ssh_gate_entrypoint.sh"
|
||||
script.write_text(ssh_gate_render_entrypoint(upstreams))
|
||||
script.chmod(0o600)
|
||||
return SSHGatePlan(slug=slug, entrypoint_script=script, upstreams=upstreams)
|
||||
|
||||
@abstractmethod
|
||||
def start(self, plan: SSHGatePlan) -> str:
|
||||
"""Bring up the gate sidecar according to `plan`. Returns the
|
||||
target string identifying the running instance — the same
|
||||
value to pass to `.stop`. Backend-specific."""
|
||||
|
||||
@abstractmethod
|
||||
def stop(self, target: str) -> None:
|
||||
"""Tear down the gate sidecar identified by `target` (the
|
||||
value `.start` returned). Idempotent: a missing target is
|
||||
success. Backend-specific."""
|
||||
Reference in New Issue
Block a user