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 typing import Any, Generic, Sequence, TypeVar
|
||||||
|
|
||||||
from ..log import die
|
from ..log import die
|
||||||
from ..manifest import GitEntry, Manifest, SshEntry
|
from ..manifest import GitEntry, Manifest
|
||||||
from ..util import expand_tilde
|
from ..util import expand_tilde
|
||||||
from .util import host_skill_dir
|
from .util import host_skill_dir
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
|
|
||||||
def _validate(self, spec: BottleSpec) -> None:
|
def _validate(self, spec: BottleSpec) -> None:
|
||||||
"""Cross-backend pre-launch checks. Confirms the agent exists,
|
"""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
|
IdentityFile resolves. Subclasses with additional preconditions
|
||||||
should override and call `super()._validate(spec)` first."""
|
should override and call `super()._validate(spec)` first."""
|
||||||
manifest = spec.manifest
|
manifest = spec.manifest
|
||||||
@@ -170,7 +170,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
agent = manifest.agents[spec.agent_name]
|
agent = manifest.agents[spec.agent_name]
|
||||||
bottle = manifest.bottle_for(spec.agent_name)
|
bottle = manifest.bottle_for(spec.agent_name)
|
||||||
self._validate_skills(agent.skills)
|
self._validate_skills(agent.skills)
|
||||||
self._validate_ssh_entries(bottle.ssh)
|
|
||||||
self._validate_git_entries(bottle.git)
|
self._validate_git_entries(bottle.git)
|
||||||
|
|
||||||
def _validate_skills(self, skills: Sequence[str]) -> None:
|
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."
|
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:
|
def _validate_git_entries(self, entries: Sequence[GitEntry]) -> None:
|
||||||
"""Each entry's IdentityFile must exist on the host (after
|
"""Each entry's IdentityFile must exist on the host (after
|
||||||
expanding leading ~) — the git-gate copies it in at start time
|
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."""
|
"""Build/run the bottle and yield a handle; tear down on exit."""
|
||||||
|
|
||||||
def provision(self, plan: PlanT, target: str) -> str | None:
|
def provision(self, plan: PlanT, target: str) -> str | None:
|
||||||
"""Copy host-side files (CA cert, prompt, skills, SSH keys,
|
"""Copy host-side files (CA cert, prompt, skills, .git) into
|
||||||
.git) into the running bottle. Called from `launch` after the
|
the running bottle. Called from `launch` after the container
|
||||||
container/machine is up. `target` identifies the running
|
/ machine is up. `target` identifies the running instance in
|
||||||
instance in backend-specific terms (Docker: resolved
|
backend-specific terms (Docker: resolved container name; fly:
|
||||||
container name; fly: machine id). Returns the in-container
|
machine id). Returns the in-container prompt path if a prompt
|
||||||
prompt path if a prompt was provisioned, else None — the
|
was provisioned, else None — the Bottle handle uses it to
|
||||||
Bottle handle uses it to decide whether to add
|
decide whether to add --append-system-prompt-file to claude's
|
||||||
--append-system-prompt-file to claude's argv.
|
argv.
|
||||||
|
|
||||||
Default orchestration: ca → prompt → skills → ssh → git.
|
Default orchestration: ca → prompt → skills → git. CA install
|
||||||
CA install runs first so the agent's trust store is rebuilt
|
runs first so the agent's trust store is rebuilt before
|
||||||
before anything inside the agent makes a TLS call. Subclasses
|
anything inside the agent makes a TLS call. Subclasses
|
||||||
typically don't override this; they implement the sub-methods
|
typically don't override this; they implement the sub-methods
|
||||||
below."""
|
below."""
|
||||||
self.provision_ca(plan, target)
|
self.provision_ca(plan, target)
|
||||||
prompt_path = self.provision_prompt(plan, target)
|
prompt_path = self.provision_prompt(plan, target)
|
||||||
self.provision_skills(plan, target)
|
self.provision_skills(plan, target)
|
||||||
self.provision_ssh(plan, target)
|
|
||||||
self.provision_git(plan, target)
|
self.provision_git(plan, target)
|
||||||
return prompt_path
|
return prompt_path
|
||||||
|
|
||||||
@@ -257,12 +246,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
"""Copy the agent's named skills from the host into the
|
"""Copy the agent's named skills from the host into the
|
||||||
running bottle. No-op when the agent has no skills."""
|
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
|
@abstractmethod
|
||||||
def provision_git(self, plan: PlanT, target: str) -> None:
|
def provision_git(self, plan: PlanT, target: str) -> None:
|
||||||
"""Copy the host's cwd `.git` directory into the running
|
"""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 git as _git
|
||||||
from .provision import prompt as _prompt
|
from .provision import prompt as _prompt
|
||||||
from .provision import skills as _skills
|
from .provision import skills as _skills
|
||||||
from .provision import ssh as _ssh
|
|
||||||
from .ssh_gate import DockerSSHGate
|
|
||||||
|
|
||||||
|
|
||||||
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
|
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
|
||||||
@@ -41,7 +39,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._proxy = DockerPipelockProxy()
|
self._proxy = DockerPipelockProxy()
|
||||||
self._gate = DockerSSHGate()
|
|
||||||
self._git_gate = DockerGitGate()
|
self._git_gate = DockerGitGate()
|
||||||
|
|
||||||
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
|
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
|
||||||
@@ -49,7 +46,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
spec,
|
spec,
|
||||||
stage_dir=stage_dir,
|
stage_dir=stage_dir,
|
||||||
proxy=self._proxy,
|
proxy=self._proxy,
|
||||||
gate=self._gate,
|
|
||||||
git_gate=self._git_gate,
|
git_gate=self._git_gate,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,7 +54,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
with _launch.launch(
|
with _launch.launch(
|
||||||
plan,
|
plan,
|
||||||
proxy=self._proxy,
|
proxy=self._proxy,
|
||||||
gate=self._gate,
|
|
||||||
git_gate=self._git_gate,
|
git_gate=self._git_gate,
|
||||||
provision=self.provision,
|
provision=self.provision,
|
||||||
) as bottle:
|
) as bottle:
|
||||||
@@ -73,9 +68,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
def provision_skills(self, plan: DockerBottlePlan, target: str) -> None:
|
def provision_skills(self, plan: DockerBottlePlan, target: str) -> None:
|
||||||
_skills.provision_skills(plan, target)
|
_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:
|
def provision_git(self, plan: DockerBottlePlan, target: str) -> None:
|
||||||
_git.provision_git(plan, target)
|
_git.provision_git(plan, target)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from ...git_gate import GitGatePlan
|
|||||||
from ...log import info
|
from ...log import info
|
||||||
from ...manifest import Agent, Bottle
|
from ...manifest import Agent, Bottle
|
||||||
from ...pipelock import PipelockProxyPlan, pipelock_effective_allowlist
|
from ...pipelock import PipelockProxyPlan, pipelock_effective_allowlist
|
||||||
from ...ssh_gate import SSHGatePlan
|
|
||||||
from .. import BottlePlan
|
from .. import BottlePlan
|
||||||
|
|
||||||
|
|
||||||
@@ -27,7 +26,6 @@ class _PlanView:
|
|||||||
agent: Agent
|
agent: Agent
|
||||||
bottle: Bottle
|
bottle: Bottle
|
||||||
env_names: list[str]
|
env_names: list[str]
|
||||||
ssh_hosts: list[str]
|
|
||||||
git_names: list[str]
|
git_names: list[str]
|
||||||
prompt_first_line: str
|
prompt_first_line: str
|
||||||
|
|
||||||
@@ -52,7 +50,6 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
forwarded_env: dict[str, str] = field(repr=False)
|
forwarded_env: dict[str, str] = field(repr=False)
|
||||||
prompt_file: Path
|
prompt_file: Path
|
||||||
proxy_plan: PipelockProxyPlan
|
proxy_plan: PipelockProxyPlan
|
||||||
gate_plan: SSHGatePlan
|
|
||||||
git_gate_plan: GitGatePlan
|
git_gate_plan: GitGatePlan
|
||||||
allowlist_summary: str
|
allowlist_summary: str
|
||||||
use_runsc: bool
|
use_runsc: bool
|
||||||
@@ -69,7 +66,6 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
agent=agent,
|
agent=agent,
|
||||||
bottle=bottle,
|
bottle=bottle,
|
||||||
env_names=env_names,
|
env_names=env_names,
|
||||||
ssh_hosts=[e.Host for e in bottle.ssh],
|
|
||||||
git_names=[e.Name for e in bottle.git],
|
git_names=[e.Name for e in bottle.git],
|
||||||
prompt_first_line=agent.prompt.splitlines()[0] if agent.prompt else "",
|
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("skills : " + (" ".join(v.agent.skills) if v.agent.skills else "(none)"))
|
||||||
info(f"docker runtime : {runtime_label}")
|
info(f"docker runtime : {runtime_label}")
|
||||||
info(f"bottle : {v.agent.bottle}")
|
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:
|
if v.git_names:
|
||||||
info(f" git remotes : {', '.join(v.git_names)}")
|
info(f" git remotes : {', '.join(v.git_names)}")
|
||||||
git_lines = [
|
git_lines = [
|
||||||
@@ -136,15 +122,6 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
"runtime": "runsc" if self.use_runsc else "runc",
|
"runtime": "runsc" if self.use_runsc else "runc",
|
||||||
"env_names": v.env_names,
|
"env_names": v.env_names,
|
||||||
"skills": list(v.agent.skills),
|
"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_remotes": v.git_names,
|
||||||
"git_gate": [
|
"git_gate": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ from .bottle_plan import DockerBottlePlan
|
|||||||
from .git_gate import DockerGitGate
|
from .git_gate import DockerGitGate
|
||||||
from .pipelock import DockerPipelockProxy, pipelock_proxy_url, pipelock_tls_init
|
from .pipelock import DockerPipelockProxy, pipelock_proxy_url, pipelock_tls_init
|
||||||
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
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.
|
# Where the repo root lives, for `docker build` context. Computed once.
|
||||||
@@ -37,7 +36,6 @@ def launch(
|
|||||||
plan: DockerBottlePlan,
|
plan: DockerBottlePlan,
|
||||||
*,
|
*,
|
||||||
proxy: DockerPipelockProxy,
|
proxy: DockerPipelockProxy,
|
||||||
gate: DockerSSHGate,
|
|
||||||
git_gate: DockerGitGate,
|
git_gate: DockerGitGate,
|
||||||
provision: Callable[[DockerBottlePlan, str], str | None],
|
provision: Callable[[DockerBottlePlan, str], str | None],
|
||||||
) -> Generator[DockerBottle, None, None]:
|
) -> Generator[DockerBottle, None, None]:
|
||||||
@@ -89,21 +87,6 @@ def launch(
|
|||||||
pipelock_name = proxy.start(plan.proxy_plan)
|
pipelock_name = proxy.start(plan.proxy_plan)
|
||||||
stack.callback(proxy.stop, pipelock_name)
|
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
|
# Git gate (PRD 0008). One sidecar per agent, only brought up
|
||||||
# when the bottle has git entries. Same internal + egress
|
# when the bottle has git entries. Same internal + egress
|
||||||
# network attachment as the other sidecars; agent dials it as
|
# 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 .bottle_plan import DockerBottlePlan
|
||||||
from .git_gate import DockerGitGate
|
from .git_gate import DockerGitGate
|
||||||
from .pipelock import DockerPipelockProxy
|
from .pipelock import DockerPipelockProxy
|
||||||
from .ssh_gate import DockerSSHGate
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_plan(
|
def resolve_plan(
|
||||||
@@ -29,12 +28,11 @@ def resolve_plan(
|
|||||||
*,
|
*,
|
||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
proxy: DockerPipelockProxy,
|
proxy: DockerPipelockProxy,
|
||||||
gate: DockerSSHGate,
|
|
||||||
git_gate: DockerGitGate,
|
git_gate: DockerGitGate,
|
||||||
) -> DockerBottlePlan:
|
) -> DockerBottlePlan:
|
||||||
"""Resolve Docker-specific names and write scratch files. Trusts
|
"""Resolve Docker-specific names and write scratch files. Trusts
|
||||||
that the agent and its skills/SSH keys are present — validation
|
that the agent and its skills/git-gate keys are present —
|
||||||
already ran in the base class."""
|
validation already ran in the base class."""
|
||||||
docker_mod.require_docker()
|
docker_mod.require_docker()
|
||||||
|
|
||||||
manifest = spec.manifest
|
manifest = spec.manifest
|
||||||
@@ -82,7 +80,6 @@ def resolve_plan(
|
|||||||
prompt_file.chmod(0o600)
|
prompt_file.chmod(0o600)
|
||||||
|
|
||||||
proxy_plan = proxy.prepare(bottle, slug, stage_dir)
|
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)
|
git_gate_plan = git_gate.prepare(bottle, slug, stage_dir)
|
||||||
resolved = resolve_env(manifest, spec.agent_name)
|
resolved = resolve_env(manifest, spec.agent_name)
|
||||||
# Everything that should reach the bottle by-name (so its value
|
# Everything that should reach the bottle by-name (so its value
|
||||||
@@ -111,7 +108,6 @@ def resolve_plan(
|
|||||||
forwarded_env=forwarded_env,
|
forwarded_env=forwarded_env,
|
||||||
prompt_file=prompt_file,
|
prompt_file=prompt_file,
|
||||||
proxy_plan=proxy_plan,
|
proxy_plan=proxy_plan,
|
||||||
gate_plan=gate_plan,
|
|
||||||
git_gate_plan=git_gate_plan,
|
git_gate_plan=git_gate_plan,
|
||||||
allowlist_summary=allowlist_summary,
|
allowlist_summary=allowlist_summary,
|
||||||
use_runsc=use_runsc,
|
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)'}"
|
f"first line: {prompt_first_line or '(empty)'}"
|
||||||
)
|
)
|
||||||
info(f"bottle : {agent.bottle}")
|
info(f"bottle : {agent.bottle}")
|
||||||
if bottle.ssh:
|
if bottle.git:
|
||||||
for e in bottle.ssh:
|
for e in bottle.git:
|
||||||
info(
|
info(
|
||||||
f" ssh host : {e.Host} "
|
f" git remote : {e.Name} -> {e.Upstream} "
|
||||||
f"(Hostname={e.Hostname}, User={e.User}, "
|
f"(IdentityFile={e.IdentityFile})"
|
||||||
f"Port={e.Port}, IdentityFile={e.IdentityFile})"
|
|
||||||
)
|
)
|
||||||
if e.KnownHostKey:
|
if e.KnownHostKey:
|
||||||
info(f" KnownHostKey: {e.KnownHostKey}")
|
info(f" KnownHostKey: {e.KnownHostKey}")
|
||||||
else:
|
else:
|
||||||
info(" ssh hosts : (none)")
|
info(" git remotes : (none)")
|
||||||
print()
|
print()
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ def pipelock_bottle_allowlist(bottle: Bottle) -> list[str]:
|
|||||||
|
|
||||||
def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
|
def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
|
||||||
"""Deduplicated union of: baked-in defaults, bottle.egress.allowlist.
|
"""Deduplicated union of: baked-in defaults, bottle.egress.allowlist.
|
||||||
Sorted for stability. Per PRD 0007, bottle.ssh entries do NOT
|
Sorted for stability. Git upstreams declared in `bottle.git` do NOT
|
||||||
contribute here — SSH traffic flows through the per-agent ssh-gate
|
contribute here — git traffic flows through the per-agent git-gate
|
||||||
sidecar, not pipelock."""
|
sidecar (PRD 0008), not pipelock."""
|
||||||
seen: dict[str, None] = {}
|
seen: dict[str, None] = {}
|
||||||
for h in DEFAULT_ALLOWLIST:
|
for h in DEFAULT_ALLOWLIST:
|
||||||
seen.setdefault(h, None)
|
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