Merge pull request 'PRD 0007: SSH egress gate' (#10) from ssh-egress-gate into main
This commit was merged in pull request #10.
This commit is contained in:
@@ -29,6 +29,7 @@ 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 .provision import ssh as _ssh
|
||||||
|
from .ssh_gate import DockerSSHGate
|
||||||
|
|
||||||
|
|
||||||
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
|
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
|
||||||
@@ -39,13 +40,18 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._proxy = DockerPipelockProxy()
|
self._proxy = DockerPipelockProxy()
|
||||||
|
self._gate = DockerSSHGate()
|
||||||
|
|
||||||
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
|
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
|
||||||
return _prepare.resolve_plan(spec, stage_dir=stage_dir, proxy=self._proxy)
|
return _prepare.resolve_plan(
|
||||||
|
spec, stage_dir=stage_dir, proxy=self._proxy, gate=self._gate
|
||||||
|
)
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]:
|
def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]:
|
||||||
with _launch.launch(plan, proxy=self._proxy, provision=self.provision) as bottle:
|
with _launch.launch(
|
||||||
|
plan, proxy=self._proxy, gate=self._gate, provision=self.provision
|
||||||
|
) as bottle:
|
||||||
yield bottle
|
yield bottle
|
||||||
|
|
||||||
def provision_ca(self, plan: DockerBottlePlan, target: str) -> None:
|
def provision_ca(self, plan: DockerBottlePlan, target: str) -> None:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from pathlib import Path
|
|||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ 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
|
||||||
allowlist_summary: str
|
allowlist_summary: str
|
||||||
use_runsc: bool
|
use_runsc: bool
|
||||||
|
|
||||||
@@ -90,6 +92,12 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
info(f"bottle : {v.agent.bottle}")
|
info(f"bottle : {v.agent.bottle}")
|
||||||
if v.ssh_hosts:
|
if v.ssh_hosts:
|
||||||
info(f" ssh hosts : {', '.join(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:
|
else:
|
||||||
info(" ssh hosts : (none)")
|
info(" ssh hosts : (none)")
|
||||||
info(f" egress : {self.allowlist_summary}")
|
info(f" egress : {self.allowlist_summary}")
|
||||||
@@ -115,6 +123,14 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
"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_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
|
||||||
|
],
|
||||||
"egress": {
|
"egress": {
|
||||||
"host_count": len(hosts),
|
"host_count": len(hosts),
|
||||||
"hosts": hosts,
|
"hosts": hosts,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from .bottle import DockerBottle
|
|||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
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.
|
||||||
@@ -35,6 +36,7 @@ def launch(
|
|||||||
plan: DockerBottlePlan,
|
plan: DockerBottlePlan,
|
||||||
*,
|
*,
|
||||||
proxy: DockerPipelockProxy,
|
proxy: DockerPipelockProxy,
|
||||||
|
gate: DockerSSHGate,
|
||||||
provision: Callable[[DockerBottlePlan, str], str | None],
|
provision: Callable[[DockerBottlePlan, str], str | None],
|
||||||
) -> Generator[DockerBottle, None, None]:
|
) -> Generator[DockerBottle, None, None]:
|
||||||
"""Build, launch, and provision a Docker bottle. Teardown on exit.
|
"""Build, launch, and provision a Docker bottle. Teardown on exit.
|
||||||
@@ -85,6 +87,21 @@ 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)
|
||||||
|
|
||||||
container = _run_agent_container(plan, internal_network)
|
container = _run_agent_container(plan, internal_network)
|
||||||
stack.callback(docker_mod.force_remove_container, container)
|
stack.callback(docker_mod.force_remove_container, container)
|
||||||
|
|
||||||
|
|||||||
@@ -37,10 +37,6 @@ def pipelock_proxy_url(slug: str) -> str:
|
|||||||
return f"http://{pipelock_container_name(slug)}:{PIPELOCK_PORT}"
|
return f"http://{pipelock_container_name(slug)}:{PIPELOCK_PORT}"
|
||||||
|
|
||||||
|
|
||||||
def pipelock_proxy_host_port(slug: str) -> str:
|
|
||||||
return f"{pipelock_container_name(slug)}:{PIPELOCK_PORT}"
|
|
||||||
|
|
||||||
|
|
||||||
def pipelock_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
def pipelock_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
||||||
"""Generate a fresh per-bottle CA via a one-shot pipelock container.
|
"""Generate a fresh per-bottle CA via a one-shot pipelock container.
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from .. import BottleSpec
|
|||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
from .pipelock import DockerPipelockProxy
|
from .pipelock import DockerPipelockProxy
|
||||||
|
from .ssh_gate import DockerSSHGate
|
||||||
|
|
||||||
|
|
||||||
def resolve_plan(
|
def resolve_plan(
|
||||||
@@ -27,6 +28,7 @@ def resolve_plan(
|
|||||||
*,
|
*,
|
||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
proxy: DockerPipelockProxy,
|
proxy: DockerPipelockProxy,
|
||||||
|
gate: DockerSSHGate,
|
||||||
) -> 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/SSH keys are present — validation
|
||||||
@@ -78,6 +80,7 @@ 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)
|
||||||
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
|
||||||
# never lands on argv or in env_file) goes into one dict. The
|
# never lands on argv or in env_file) goes into one dict. The
|
||||||
@@ -105,6 +108,7 @@ 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,
|
||||||
allowlist_summary=allowlist_summary,
|
allowlist_summary=allowlist_summary,
|
||||||
use_runsc=use_runsc,
|
use_runsc=use_runsc,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from ....log import info
|
from ....log import die, info
|
||||||
from ....util import expand_tilde
|
from ....util import expand_tilde
|
||||||
from .. import util as docker_mod
|
from .. import util as docker_mod
|
||||||
from ..bottle_plan import DockerBottlePlan
|
from ..bottle_plan import DockerBottlePlan
|
||||||
from ..pipelock import pipelock_proxy_host_port
|
from ..ssh_gate import ssh_gate_host
|
||||||
|
|
||||||
|
|
||||||
def provision_ssh(plan: DockerBottlePlan, target: str) -> None:
|
def provision_ssh(plan: DockerBottlePlan, target: str) -> None:
|
||||||
@@ -61,13 +61,23 @@ def provision_ssh(plan: DockerBottlePlan, target: str) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
container = target
|
container = target
|
||||||
proxy_host_port = pipelock_proxy_host_port(plan.slug)
|
gate_target = ssh_gate_host(plan.slug)
|
||||||
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||||
container_ssh = f"{container_home}/.ssh"
|
container_ssh = f"{container_home}/.ssh"
|
||||||
agent_socket = "/run/claude-bottle-agent.sock"
|
agent_socket = "/run/claude-bottle-agent.sock"
|
||||||
public_socket = "/run/claude-bottle-agent-public.sock"
|
public_socket = "/run/claude-bottle-agent-public.sock"
|
||||||
keys_dir = "/root/.claude-bottle-keys"
|
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).
|
# ~/.ssh for node (700, owned by node).
|
||||||
docker_mod.docker_exec_root(container, ["mkdir", "-p", container_ssh])
|
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, ["chown", "node:node", container_ssh])
|
||||||
@@ -85,16 +95,15 @@ def provision_ssh(plan: DockerBottlePlan, target: str) -> None:
|
|||||||
known_hosts_file.write_text("")
|
known_hosts_file.write_text("")
|
||||||
known_hosts_file.chmod(0o600)
|
known_hosts_file.chmod(0o600)
|
||||||
|
|
||||||
proxy_host, _, proxy_port = proxy_host_port.partition(":")
|
|
||||||
|
|
||||||
container_key_paths: list[str] = []
|
container_key_paths: list[str] = []
|
||||||
for entry in bottle.ssh:
|
for entry in bottle.ssh:
|
||||||
name = entry.Host
|
name = entry.Host
|
||||||
key = expand_tilde(entry.IdentityFile)
|
key = expand_tilde(entry.IdentityFile)
|
||||||
hostname = entry.Hostname
|
hostname = entry.Hostname
|
||||||
user = entry.User
|
user = entry.User
|
||||||
port = entry.Port
|
|
||||||
known_host_key = entry.KnownHostKey
|
known_host_key = entry.KnownHostKey
|
||||||
|
upstream = upstreams_by_alias[name]
|
||||||
|
listen_port = upstream.listen_port
|
||||||
|
|
||||||
key_basename = os.path.basename(key)
|
key_basename = os.path.basename(key)
|
||||||
container_key_path = f"{keys_dir}/{key_basename}"
|
container_key_path = f"{keys_dir}/{key_basename}"
|
||||||
@@ -110,35 +119,32 @@ def provision_ssh(plan: DockerBottlePlan, target: str) -> None:
|
|||||||
|
|
||||||
container_key_paths.append(container_key_path)
|
container_key_paths.append(container_key_path)
|
||||||
|
|
||||||
# ProxyCommand tunnels SSH through pipelock via HTTP
|
# Each Host block points at the gate container + its
|
||||||
# CONNECT. %h / %p expand to this block's HostName /
|
# per-entry listen port. HostKeyAlias makes ssh validate
|
||||||
# Port. socat's PROXY: mode does CONNECT host:port to
|
# the host key against `hostname` (the real upstream
|
||||||
# the proxy.
|
# name) instead of the gate container; CheckHostIP=no
|
||||||
|
# skips the resolved-IP lookup, which would also point at
|
||||||
|
# the gate.
|
||||||
block = (
|
block = (
|
||||||
f"Host {name}\n"
|
f"Host {name}\n"
|
||||||
f" HostName {hostname}\n"
|
f" HostName {gate_target}\n"
|
||||||
f" User {user}\n"
|
f" User {user}\n"
|
||||||
f" Port {port}\n"
|
f" Port {listen_port}\n"
|
||||||
f" IdentityAgent {public_socket}\n"
|
f" IdentityAgent {public_socket}\n"
|
||||||
f" ProxyCommand socat - PROXY:{proxy_host}:%h:%p,proxyport={proxy_port}\n"
|
f" HostKeyAlias {hostname}\n"
|
||||||
|
f" CheckHostIP no\n"
|
||||||
f"\n"
|
f"\n"
|
||||||
)
|
)
|
||||||
with config_file.open("a") as f:
|
with config_file.open("a") as f:
|
||||||
f.write(block)
|
f.write(block)
|
||||||
|
|
||||||
if known_host_key:
|
if known_host_key:
|
||||||
entries_to_write: list[str] = []
|
# HostKeyAlias makes ssh look up known_hosts under
|
||||||
if port == "22":
|
# `hostname` (the upstream's real name / IP literal),
|
||||||
entries_to_write.append(f"{name} {known_host_key}\n")
|
# not the gate container. One unambiguous entry per
|
||||||
if hostname != name:
|
# ssh entry.
|
||||||
entries_to_write.append(f"{hostname} {known_host_key}\n")
|
|
||||||
else:
|
|
||||||
entries_to_write.append(f"[{name}]:{port} {known_host_key}\n")
|
|
||||||
if hostname != name:
|
|
||||||
entries_to_write.append(f"[{hostname}]:{port} {known_host_key}\n")
|
|
||||||
with known_hosts_file.open("a") as f:
|
with known_hosts_file.open("a") as f:
|
||||||
for e in entries_to_write:
|
f.write(f"{hostname} {known_host_key}\n")
|
||||||
f.write(e)
|
|
||||||
|
|
||||||
# Boot the agent, load each key, delete the key files, then
|
# Boot the agent, load each key, delete the key files, then
|
||||||
# start the root-owned socat forwarder. One docker exec so the
|
# start the root-owned socat forwarder. One docker exec so the
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""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}'"
|
||||||
|
)
|
||||||
@@ -18,7 +18,6 @@ from pathlib import Path
|
|||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from .manifest import Bottle
|
from .manifest import Bottle
|
||||||
from .util import is_ipv4_literal
|
|
||||||
|
|
||||||
# Baked-in default allowlist for hosts Claude Code itself needs.
|
# Baked-in default allowlist for hosts Claude Code itself needs.
|
||||||
DEFAULT_ALLOWLIST: tuple[str, ...] = (
|
DEFAULT_ALLOWLIST: tuple[str, ...] = (
|
||||||
@@ -40,30 +39,17 @@ def pipelock_bottle_allowlist(bottle: Bottle) -> list[str]:
|
|||||||
return list(bottle.egress.allowlist)
|
return list(bottle.egress.allowlist)
|
||||||
|
|
||||||
|
|
||||||
def pipelock_bottle_ssh_hostnames(bottle: Bottle) -> list[str]:
|
|
||||||
return [e.Hostname for e in bottle.ssh if e.Hostname]
|
|
||||||
|
|
||||||
|
|
||||||
def pipelock_bottle_ssh_trusted_domains(bottle: Bottle) -> list[str]:
|
|
||||||
return [h for h in pipelock_bottle_ssh_hostnames(bottle) if not is_ipv4_literal(h)]
|
|
||||||
|
|
||||||
|
|
||||||
def pipelock_bottle_ssh_ip_cidrs(bottle: Bottle) -> list[str]:
|
|
||||||
return [f"{h}/32" for h in pipelock_bottle_ssh_hostnames(bottle) if is_ipv4_literal(h)]
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
||||||
bottle.ssh[].Hostname. Sorted for stability."""
|
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."""
|
||||||
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)
|
||||||
for h in pipelock_bottle_allowlist(bottle):
|
for h in pipelock_bottle_allowlist(bottle):
|
||||||
if h:
|
if h:
|
||||||
seen.setdefault(h, None)
|
seen.setdefault(h, None)
|
||||||
for h in pipelock_bottle_ssh_hostnames(bottle):
|
|
||||||
if h:
|
|
||||||
seen.setdefault(h, None)
|
|
||||||
return sorted(seen.keys())
|
return sorted(seen.keys())
|
||||||
|
|
||||||
|
|
||||||
@@ -116,12 +102,6 @@ def pipelock_build_config(
|
|||||||
"api_allowlist": pipelock_effective_allowlist(bottle),
|
"api_allowlist": pipelock_effective_allowlist(bottle),
|
||||||
"forward_proxy": {"enabled": True},
|
"forward_proxy": {"enabled": True},
|
||||||
}
|
}
|
||||||
trusted = pipelock_bottle_ssh_trusted_domains(bottle)
|
|
||||||
if trusted:
|
|
||||||
cfg["trusted_domains"] = trusted
|
|
||||||
ip_cidrs = pipelock_bottle_ssh_ip_cidrs(bottle)
|
|
||||||
if ip_cidrs:
|
|
||||||
cfg["ssrf"] = {"ip_allowlist": ip_cidrs}
|
|
||||||
cfg["dlp"] = {"include_defaults": True, "scan_env": True}
|
cfg["dlp"] = {"include_defaults": True, "scan_env": True}
|
||||||
# Body-scan enforcement is a separate pipelock section (each DLP
|
# Body-scan enforcement is a separate pipelock section (each DLP
|
||||||
# "surface" — body, MCP, response — has its own action). Pipelock's
|
# "surface" — body, MCP, response — has its own action). Pipelock's
|
||||||
@@ -163,18 +143,6 @@ def pipelock_render_yaml(cfg: dict[str, object]) -> str:
|
|||||||
fp = cast(dict[str, object], cfg["forward_proxy"])
|
fp = cast(dict[str, object], cfg["forward_proxy"])
|
||||||
lines.append(f" enabled: {_bool(fp['enabled'])}")
|
lines.append(f" enabled: {_bool(fp['enabled'])}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
if "trusted_domains" in cfg:
|
|
||||||
lines.append("trusted_domains:")
|
|
||||||
for td in cast(list[str], cfg["trusted_domains"]):
|
|
||||||
lines.append(f' - "{td}"')
|
|
||||||
lines.append("")
|
|
||||||
if "ssrf" in cfg:
|
|
||||||
lines.append("ssrf:")
|
|
||||||
ssrf = cast(dict[str, object], cfg["ssrf"])
|
|
||||||
lines.append(" ip_allowlist:")
|
|
||||||
for cidr in cast(list[str], ssrf["ip_allowlist"]):
|
|
||||||
lines.append(f' - "{cidr}"')
|
|
||||||
lines.append("")
|
|
||||||
lines.append("dlp:")
|
lines.append("dlp:")
|
||||||
dlp = cast(dict[str, object], cfg["dlp"])
|
dlp = cast(dict[str, object], cfg["dlp"])
|
||||||
lines.append(f" include_defaults: {_bool(dlp['include_defaults'])}")
|
lines.append(f" include_defaults: {_bool(dlp['include_defaults'])}")
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""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."""
|
||||||
@@ -6,7 +6,6 @@ level deeper, under their backend package."""
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
def expand_tilde(path: str) -> str:
|
def expand_tilde(path: str) -> str:
|
||||||
@@ -17,15 +16,3 @@ def expand_tilde(path: str) -> str:
|
|||||||
home = os.environ.get("HOME", "")
|
home = os.environ.get("HOME", "")
|
||||||
return home + path[1:]
|
return home + path[1:]
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
_IPV4_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$")
|
|
||||||
|
|
||||||
|
|
||||||
def is_ipv4_literal(s: str) -> bool:
|
|
||||||
"""True iff `s` looks like a dotted-quad IPv4 literal. Does not
|
|
||||||
validate octet ranges; consumers that care about that should run
|
|
||||||
a stricter check. Empty input returns False."""
|
|
||||||
if not s:
|
|
||||||
return False
|
|
||||||
return bool(_IPV4_RE.match(s))
|
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# PRD 0007: SSH egress gate
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** didericis
|
||||||
|
- **Created:** 2026-05-12
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Per-agent TCP-forwarder sidecar built from `bottle.ssh` entries; SSH stops
|
||||||
|
going through pipelock; pipelock keeps full TLS interception with no
|
||||||
|
SSH carve-outs.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`git fetch` over SSH from inside an implementer-agent bottle is broken
|
||||||
|
on `main`. The error surfaced after PRD 0006 enabled pipelock's
|
||||||
|
native `tls_interception`:
|
||||||
|
|
||||||
|
```
|
||||||
|
kex_exchange_identification: Connection closed by remote host
|
||||||
|
Connection closed by UNKNOWN port 65535
|
||||||
|
fatal: Could not read from remote repository.
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent's ssh client tunnels through pipelock via a `ProxyCommand
|
||||||
|
socat - PROXY:pipelock:%h:%p` and pipelock now bumps that CONNECT
|
||||||
|
tunnel. SSH sends its banner instead of a TLS ClientHello; pipelock's
|
||||||
|
SNI gate rejects it; the tunnel closes mid-kex. Every bottle with an
|
||||||
|
`ssh` entry hits this — including the implementer agent used by the
|
||||||
|
free-agent workflow, which can't pull or push.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
Integration test: spin up a bottle with an SSH entry, exec `git
|
||||||
|
fetch` against a real-ish SSH host from inside the agent, observe
|
||||||
|
exit 0. This is the same signal that's broken today; flipping it
|
||||||
|
back to green is the test.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Pluggable forwarder backend. One TCP forwarder image is baked in;
|
||||||
|
abstracting over haproxy / nginx-stream / etc. is deferred.
|
||||||
|
- SSH-protocol awareness. The gate stays at L4. No SSH-version
|
||||||
|
sniffing, no kex inspection, no per-key gating beyond what ssh
|
||||||
|
itself enforces inside the agent.
|
||||||
|
- Replacing pipelock for anything else. HTTPS / HTTP traffic
|
||||||
|
continues to flow through pipelock unchanged. This PRD adds a
|
||||||
|
sidecar; it doesn't displace one.
|
||||||
|
- Connection rate limits or quotas. No per-host or per-agent rate
|
||||||
|
limiting on the gate; future PRD if it ever matters.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- **Gate sidecar lifecycle.** `DockerSSHGate` class with
|
||||||
|
`prepare` / `start` / `stop`, mirroring `DockerPipelockProxy`'s
|
||||||
|
shape and network attachment story.
|
||||||
|
- **ssh provisioner rewrite.** `provision/ssh.py` drops the socat
|
||||||
|
`ProxyCommand`; `~/.ssh/config` points each `Host` at the gate
|
||||||
|
container and the per-host listen port.
|
||||||
|
- **Pipelock carve-out removal.** Strip
|
||||||
|
`pipelock_bottle_ssh_trusted_domains`,
|
||||||
|
`pipelock_bottle_ssh_ip_cidrs`, and the related code paths in
|
||||||
|
`pipelock_build_config` + tests. After this PRD, pipelock has no
|
||||||
|
knowledge of `bottle.ssh`.
|
||||||
|
- **Plan rendering / dry-run.** `bottle_plan.py` and the y/N
|
||||||
|
preflight surface the new gate sidecar (name, listen ports,
|
||||||
|
upstream targets).
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- SSH key generation / rotation. Bottle keys are still
|
||||||
|
user-supplied via `IdentityFile`; the gate doesn't manage key
|
||||||
|
material.
|
||||||
|
- Per-host audit logging. The gate is dumb TCP forwarding; no
|
||||||
|
in-band visibility into SSH session content. (Connection-level
|
||||||
|
logs from socat are a nice-to-have, not a goal.)
|
||||||
|
- Non-Docker backends. Implementation lands for Docker only; the
|
||||||
|
`BottleBackend` abstraction can grow the hook but other backends
|
||||||
|
are deferred.
|
||||||
|
- Manifest schema changes. `bottle.ssh` stays exactly as it is
|
||||||
|
today; this PRD is internals-only.
|
||||||
|
|
||||||
|
## Proposed Design
|
||||||
|
|
||||||
|
### New services / components
|
||||||
|
|
||||||
|
Mirror the pipelock layout:
|
||||||
|
|
||||||
|
- **`claude_bottle/ssh_gate.py`** (new): abstract `SSHGate` +
|
||||||
|
`SSHGatePlan` dataclass. `prepare` is host-side / side-effect-free
|
||||||
|
on docker; renders the forwarder config under `stage_dir`.
|
||||||
|
- **`claude_bottle/backend/docker/ssh_gate.py`** (new):
|
||||||
|
`DockerSSHGate` concrete subclass — `start` does `docker create`
|
||||||
|
on the internal network, copies the config in, attaches the
|
||||||
|
egress network, `docker start`. `stop` is idempotent `docker rm
|
||||||
|
-f`. Container name: `claude-bottle-ssh-gate-<slug>`.
|
||||||
|
|
||||||
|
Forwarder image: `alpine/socat`, pinned by digest. Must be
|
||||||
|
self-sufficient at boot (no apk/apt pulls on first run) because
|
||||||
|
the gate's agent-facing leg sits on the `--internal` network and
|
||||||
|
has no internet at startup. One socat process per ssh entry,
|
||||||
|
multiplexed inside the same gate container via an entrypoint
|
||||||
|
script that backgrounds N socat invocations:
|
||||||
|
|
||||||
|
```
|
||||||
|
socat TCP-LISTEN:<port_i>,reuseaddr,fork TCP:<Hostname_i>:<Port_i>
|
||||||
|
```
|
||||||
|
|
||||||
|
Listen ports mirror the upstream port (entry `Port`, default 22).
|
||||||
|
That choice is load-bearing: OpenSSH treats a URL-supplied port
|
||||||
|
(e.g. `ssh://git@host:30009/repo.git`) as overriding the config's
|
||||||
|
`Port` directive, so the gate has to be reachable on the same port
|
||||||
|
the URL names — otherwise git fetch hits "connection refused" on
|
||||||
|
the URL's port even though the config block points elsewhere. Two
|
||||||
|
ssh entries sharing an upstream port are a config error and
|
||||||
|
rejected at prepare time. One container, N listeners, N upstreams.
|
||||||
|
|
||||||
|
### Existing code touched
|
||||||
|
|
||||||
|
- **`claude_bottle/backend/docker/provision/ssh.py`**: drop the
|
||||||
|
`ProxyCommand socat - PROXY:...` plumbing and the
|
||||||
|
`pipelock_proxy_host_port` import. The rendered `~/.ssh/config`
|
||||||
|
block per entry becomes:
|
||||||
|
```
|
||||||
|
Host <name>
|
||||||
|
HostName <gate-container>
|
||||||
|
User <user>
|
||||||
|
Port <listen-port>
|
||||||
|
IdentityAgent <public-socket>
|
||||||
|
```
|
||||||
|
`known_hosts` entries are keyed off `<name>` and the new
|
||||||
|
`[<gate-container>]:<listen-port>` form so OpenSSH's strict
|
||||||
|
host-key checking still matches.
|
||||||
|
- **`claude_bottle/pipelock.py`**: delete
|
||||||
|
`pipelock_bottle_ssh_hostnames`, `pipelock_bottle_ssh_trusted_domains`,
|
||||||
|
`pipelock_bottle_ssh_ip_cidrs`, and the calls into them from
|
||||||
|
`pipelock_effective_allowlist` and `pipelock_build_config`. The
|
||||||
|
effective allowlist becomes baked-defaults ∪ `bottle.egress.allowlist`.
|
||||||
|
- **`claude_bottle/backend/docker/backend.py`**: instantiate
|
||||||
|
`DockerSSHGate` alongside `DockerPipelockProxy`; thread its
|
||||||
|
`prepare` / `start` / `stop` through `resolve_plan` / `launch`.
|
||||||
|
- **`claude_bottle/backend/docker/launch.py`**: add gate start /
|
||||||
|
stop to the `ExitStack` in the right order — gate must be up
|
||||||
|
before `provision_ssh` runs so the agent can dial it on first
|
||||||
|
boot.
|
||||||
|
- **`claude_bottle/backend/docker/bottle_plan.py`**: new
|
||||||
|
`SSHGatePlan` field on `DockerBottlePlan`; preflight rendering
|
||||||
|
surfaces the gate sidecar (name, per-entry listen ports,
|
||||||
|
upstream `Hostname:Port` targets).
|
||||||
|
- **Tests**: update `tests/fixtures.py` callers; rewrite
|
||||||
|
`tests/unit/test_pipelock_yaml.py::TestBuildConfig::test_ssh_shape`
|
||||||
|
to assert pipelock no longer reflects ssh entries; add unit
|
||||||
|
tests for `SSHGate.prepare` + render shape; add an integration
|
||||||
|
test in `tests/integration/` for the `git fetch` round-trip.
|
||||||
|
|
||||||
|
### Data model changes
|
||||||
|
|
||||||
|
None. `bottle.ssh` schema is unchanged; one new internal plan
|
||||||
|
dataclass (`SSHGatePlan`) under `claude_bottle/ssh_gate.py`.
|
||||||
|
|
||||||
|
### External dependencies
|
||||||
|
|
||||||
|
- `alpine/socat` image, pinned by digest (declared next to the
|
||||||
|
`PIPELOCK_IMAGE` constant). No new Python packages.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Network topology: does the gate need its own per-agent egress
|
||||||
|
bridge, or can it share pipelock's egress network? Sharing is
|
||||||
|
simpler; per-gate isolates failure modes. Decide during
|
||||||
|
implementation; default to "share pipelock's egress network"
|
||||||
|
unless a concrete reason emerges.
|
||||||
|
- Socat container restart policy: a single socat that crashes
|
||||||
|
takes one upstream offline; do we want a wrapper that restarts
|
||||||
|
individual listeners, or just rely on `docker restart`? Default
|
||||||
|
to no-restart for v1 (matches pipelock).
|
||||||
|
- Connection-level audit log: socat's `-v` mode logs every
|
||||||
|
connect/close. Worth piping into the bottle's stderr stream, or
|
||||||
|
is that noise? Default off, reconsider if debugging gets hard.
|
||||||
|
- ~~Docker DNS for the `<gate-container>` hostname inside the
|
||||||
|
agent: works via Docker's embedded resolver on user-defined
|
||||||
|
networks. Verify on the `--internal` network specifically before
|
||||||
|
implementation.~~ **Resolved.** Spike confirmed: a container on
|
||||||
|
a `--internal` user-defined network resolves another
|
||||||
|
container's name via the embedded resolver at 127.0.0.11 and
|
||||||
|
reaches it over TCP, while egress to the public internet
|
||||||
|
remains blocked. The PRD's design assumption holds.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- PRD 0001: per-agent egress proxy via pipelock — the parent
|
||||||
|
topology this PRD slots into.
|
||||||
|
- PRD 0006: pipelock native TLS interception — the change that
|
||||||
|
surfaced this regression by making pipelock incompatible with
|
||||||
|
SSH-over-CONNECT.
|
||||||
|
- `claude_bottle/backend/docker/provision/ssh.py` — current SSH
|
||||||
|
provisioning that this PRD rewrites.
|
||||||
|
- `claude_bottle/pipelock.py` — current pipelock config builder
|
||||||
|
that gains the `bottle.ssh`-derived fields this PRD removes.
|
||||||
@@ -80,6 +80,7 @@ class TestDryRunPlan(unittest.TestCase):
|
|||||||
"runsc isn't available on the CI runner")
|
"runsc isn't available on the CI runner")
|
||||||
self.assertEqual([], plan["skills"])
|
self.assertEqual([], plan["skills"])
|
||||||
self.assertEqual([], plan["ssh_hosts"])
|
self.assertEqual([], plan["ssh_hosts"])
|
||||||
|
self.assertEqual([], plan["ssh_gate"])
|
||||||
self.assertEqual(False, plan["remote_control"])
|
self.assertEqual(False, plan["remote_control"])
|
||||||
self.assertEqual(0, plan["prompt"]["length"])
|
self.assertEqual(0, plan["prompt"]["length"])
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Integration: the cleanup primitives the start-flow trap depends on
|
"""Integration: the cleanup primitives the start-flow trap depends on
|
||||||
are idempotent. The original orphan-network bug was a trap-ordering
|
are idempotent. The original orphan-network bug was a trap-ordering
|
||||||
issue; the fix moved the install earlier. The trap is only safe if
|
issue; the fix moved the install earlier. The trap is only safe if
|
||||||
network_remove and PipelockProxy.stop are no-ops against missing
|
network_remove, PipelockProxy.stop, and SSHGate.stop are no-ops
|
||||||
resources."""
|
against missing resources."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -17,6 +17,10 @@ from claude_bottle.backend.docker.pipelock import (
|
|||||||
DockerPipelockProxy,
|
DockerPipelockProxy,
|
||||||
pipelock_container_name,
|
pipelock_container_name,
|
||||||
)
|
)
|
||||||
|
from claude_bottle.backend.docker.ssh_gate import (
|
||||||
|
DockerSSHGate,
|
||||||
|
ssh_gate_container_name,
|
||||||
|
)
|
||||||
from tests._docker import skip_unless_docker
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
|
|
||||||
@@ -75,6 +79,13 @@ class TestOrphanCleanup(unittest.TestCase):
|
|||||||
# Should not raise.
|
# Should not raise.
|
||||||
DockerPipelockProxy().stop(pipelock_container_name(f"missing-{self.slug}"))
|
DockerPipelockProxy().stop(pipelock_container_name(f"missing-{self.slug}"))
|
||||||
|
|
||||||
|
def test_ssh_gate_stop_missing_sidecar(self):
|
||||||
|
# Same trap-safety requirement for the gate (PRD 0007). The
|
||||||
|
# launch ExitStack calls gate.stop on every error path; if
|
||||||
|
# the container was never created (early failure), stop must
|
||||||
|
# still no-op.
|
||||||
|
DockerSSHGate().stop(ssh_gate_container_name(f"missing-{self.slug}"))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,20 +1,12 @@
|
|||||||
"""Unit: pipelock_effective_allowlist — the union of baked-in defaults,
|
"""Unit: pipelock_effective_allowlist — the union of baked-in defaults
|
||||||
bottle.egress.allowlist, and bottle.ssh[].Hostname. Plus a small check
|
and bottle.egress.allowlist. Per PRD 0007, bottle.ssh entries do NOT
|
||||||
that IPv4 hostnames pick up the /32 suffix when classified as CIDRs.
|
contribute (SSH traffic goes through the per-agent ssh-gate, not
|
||||||
|
pipelock)."""
|
||||||
The lower-level one-line helpers (pipelock_bottle_allowlist,
|
|
||||||
pipelock_bottle_ssh_hostnames, pipelock_bottle_ssh_trusted_domains)
|
|
||||||
are exercised end-to-end by test_union_and_dedup, so they don't get
|
|
||||||
their own tests."""
|
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from claude_bottle.manifest import Manifest
|
from claude_bottle.manifest import Manifest
|
||||||
from claude_bottle.pipelock import (
|
from claude_bottle.pipelock import pipelock_effective_allowlist
|
||||||
pipelock_bottle_ssh_ip_cidrs,
|
|
||||||
pipelock_effective_allowlist,
|
|
||||||
)
|
|
||||||
from tests.fixtures import fixture_with_ssh
|
|
||||||
|
|
||||||
|
|
||||||
class TestEffectiveAllowlist(unittest.TestCase):
|
class TestEffectiveAllowlist(unittest.TestCase):
|
||||||
@@ -36,20 +28,14 @@ class TestEffectiveAllowlist(unittest.TestCase):
|
|||||||
eff = pipelock_effective_allowlist(manifest.bottles["dev"])
|
eff = pipelock_effective_allowlist(manifest.bottles["dev"])
|
||||||
self.assertIn("api.anthropic.com", eff, "baked default present")
|
self.assertIn("api.anthropic.com", eff, "baked default present")
|
||||||
self.assertIn("registry.npmjs.org", eff, "egress.allowlist present")
|
self.assertIn("registry.npmjs.org", eff, "egress.allowlist present")
|
||||||
self.assertIn("100.78.141.42", eff, "ssh ipv4 hostname present")
|
# PRD 0007: ssh hostnames must not contribute to pipelock's
|
||||||
self.assertIn("github.com", eff, "ssh hostname present")
|
# allowlist anymore — they're routed through the ssh-gate
|
||||||
|
# sidecar, which is on its own egress path.
|
||||||
|
self.assertNotIn("100.78.141.42", eff)
|
||||||
|
self.assertNotIn("github.com", eff)
|
||||||
self.assertEqual(len(eff), len(set(eff)), "deduplicated")
|
self.assertEqual(len(eff), len(set(eff)), "deduplicated")
|
||||||
self.assertEqual(eff, sorted(eff), "sorted")
|
self.assertEqual(eff, sorted(eff), "sorted")
|
||||||
|
|
||||||
|
|
||||||
class TestSSHIPCidrs(unittest.TestCase):
|
|
||||||
def test_ipv4_hostname_gets_32_suffix(self):
|
|
||||||
cidrs = pipelock_bottle_ssh_ip_cidrs(fixture_with_ssh().bottles["dev"])
|
|
||||||
self.assertIn("100.78.141.42/32", cidrs)
|
|
||||||
# Hostname-typed entries don't end up here.
|
|
||||||
self.assertNotIn("github.com", cidrs)
|
|
||||||
self.assertNotIn("github.com/32", cidrs)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
"""Unit: is_ipv4_literal — the classifier that decides whether
|
|
||||||
bottle.ssh[].Hostname goes into pipelock's ssrf.ip_allowlist (IPv4
|
|
||||||
literal) or trusted_domains (hostname)."""
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from claude_bottle.util import is_ipv4_literal
|
|
||||||
|
|
||||||
|
|
||||||
class TestIPv4Classify(unittest.TestCase):
|
|
||||||
def test_positive(self):
|
|
||||||
for ip in ("127.0.0.1", "10.0.0.5", "100.78.141.42", "0.0.0.0", "255.255.255.255"):
|
|
||||||
with self.subTest(ip=ip):
|
|
||||||
self.assertTrue(is_ipv4_literal(ip), ip)
|
|
||||||
|
|
||||||
def test_negative(self):
|
|
||||||
for hn in (
|
|
||||||
"github.com",
|
|
||||||
"gitea.dideric.is",
|
|
||||||
"100.78.141",
|
|
||||||
"100.78.141.42.5",
|
|
||||||
"::1",
|
|
||||||
"fe80::1",
|
|
||||||
"localhost",
|
|
||||||
"",
|
|
||||||
"1.2.3.4.example.com",
|
|
||||||
):
|
|
||||||
with self.subTest(hn=hn):
|
|
||||||
self.assertFalse(is_ipv4_literal(hn), hn)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -34,23 +34,25 @@ class TestBuildConfig(unittest.TestCase):
|
|||||||
# Baked defaults always present.
|
# Baked defaults always present.
|
||||||
self.assertIn("api.anthropic.com", cast(list[str], cfg["api_allowlist"]))
|
self.assertIn("api.anthropic.com", cast(list[str], cfg["api_allowlist"]))
|
||||||
self.assertIn("raw.githubusercontent.com", cast(list[str], cfg["api_allowlist"]))
|
self.assertIn("raw.githubusercontent.com", cast(list[str], cfg["api_allowlist"]))
|
||||||
# No SSH entries → no trusted_domains, no ssrf.
|
# PRD 0007: pipelock has no SSH carve-outs at all — neither
|
||||||
|
# trusted_domains nor ssrf are ever emitted from bottle data
|
||||||
|
# in v1.
|
||||||
self.assertNotIn("trusted_domains", cfg)
|
self.assertNotIn("trusted_domains", cfg)
|
||||||
self.assertNotIn("ssrf", cfg)
|
self.assertNotIn("ssrf", cfg)
|
||||||
# Without CA paths, the tls_interception block is omitted —
|
# Without CA paths, the tls_interception block is omitted —
|
||||||
# pipelock falls back to its built-in default of `enabled: false`.
|
# pipelock falls back to its built-in default of `enabled: false`.
|
||||||
self.assertNotIn("tls_interception", cfg)
|
self.assertNotIn("tls_interception", cfg)
|
||||||
|
|
||||||
def test_ssh_shape(self):
|
def test_ssh_entries_do_not_leak_into_pipelock(self):
|
||||||
|
# PRD 0007: bottle.ssh routes through the ssh-gate sidecar,
|
||||||
|
# so pipelock's config must not reflect those hostnames or
|
||||||
|
# IPs in any of its blocks.
|
||||||
cfg = pipelock_build_config(fixture_with_ssh().bottles["dev"])
|
cfg = pipelock_build_config(fixture_with_ssh().bottles["dev"])
|
||||||
self.assertIn("github.com", cast(list[str], cfg["trusted_domains"]))
|
allow = cast(list[str], cfg["api_allowlist"])
|
||||||
self.assertNotIn("100.78.141.42", cast(list[str], cfg["trusted_domains"]))
|
self.assertNotIn("github.com", allow)
|
||||||
self.assertIn(
|
self.assertNotIn("100.78.141.42", allow)
|
||||||
"100.78.141.42/32",
|
self.assertNotIn("trusted_domains", cfg)
|
||||||
cast(dict[str, Any], cfg["ssrf"])["ip_allowlist"],
|
self.assertNotIn("ssrf", cfg)
|
||||||
)
|
|
||||||
# Strict mode: IPv4 host is also in the api_allowlist union.
|
|
||||||
self.assertIn("100.78.141.42", cast(list[str], cfg["api_allowlist"]))
|
|
||||||
|
|
||||||
def test_tls_interception_block_emitted_when_paths_supplied(self):
|
def test_tls_interception_block_emitted_when_paths_supplied(self):
|
||||||
# PRD 0006: paths flow in via DockerPipelockProxy's in-container
|
# PRD 0006: paths flow in via DockerPipelockProxy's in-container
|
||||||
@@ -95,12 +97,13 @@ class TestRenderAndWrite(unittest.TestCase):
|
|||||||
for required in (
|
for required in (
|
||||||
"api_allowlist:",
|
"api_allowlist:",
|
||||||
"forward_proxy:",
|
"forward_proxy:",
|
||||||
"trusted_domains:",
|
|
||||||
"ssrf:",
|
|
||||||
"dlp:",
|
"dlp:",
|
||||||
"request_body_scanning:",
|
"request_body_scanning:",
|
||||||
):
|
):
|
||||||
self.assertIn(required, text)
|
self.assertIn(required, text)
|
||||||
|
# PRD 0007: no ssh carve-outs in the rendered yaml.
|
||||||
|
self.assertNotIn("trusted_domains:", text)
|
||||||
|
self.assertNotIn("ssrf:", text)
|
||||||
|
|
||||||
def test_prepare_writes_file_at_mode_600(self):
|
def test_prepare_writes_file_at_mode_600(self):
|
||||||
plan = DockerPipelockProxy().prepare(
|
plan = DockerPipelockProxy().prepare(
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Unit: SSHGate prepare shape + entrypoint render."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from claude_bottle.manifest import Manifest
|
||||||
|
from claude_bottle.ssh_gate import (
|
||||||
|
SSHGate,
|
||||||
|
SSHGatePlan,
|
||||||
|
SSHGateUpstream,
|
||||||
|
ssh_gate_render_entrypoint,
|
||||||
|
ssh_gate_upstreams_for_bottle,
|
||||||
|
)
|
||||||
|
from tests.fixtures import fixture_minimal, fixture_with_ssh
|
||||||
|
|
||||||
|
|
||||||
|
class _StubGate(SSHGate):
|
||||||
|
"""Concrete subclass for testing the abstract `prepare`. The
|
||||||
|
backend-specific start/stop aren't exercised here."""
|
||||||
|
|
||||||
|
def start(self, plan: SSHGatePlan) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def stop(self, target: str) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpstreamAssignment(unittest.TestCase):
|
||||||
|
def test_listen_port_matches_upstream_port(self):
|
||||||
|
# Critical: URLs like ssh://git@host:30009/... override the
|
||||||
|
# config Port directive, so the gate must listen on the same
|
||||||
|
# port the URL names.
|
||||||
|
bottle = fixture_with_ssh().bottles["dev"]
|
||||||
|
upstreams = ssh_gate_upstreams_for_bottle(bottle)
|
||||||
|
self.assertEqual(2, len(upstreams))
|
||||||
|
# Fixture: tailscale-gitea -> 100.78.141.42:30009, github -> github.com:22.
|
||||||
|
self.assertEqual(30009, upstreams[0].listen_port)
|
||||||
|
self.assertEqual(22, upstreams[1].listen_port)
|
||||||
|
|
||||||
|
def test_upstream_fields_mirror_ssh_entry(self):
|
||||||
|
bottle = fixture_with_ssh().bottles["dev"]
|
||||||
|
first = ssh_gate_upstreams_for_bottle(bottle)[0]
|
||||||
|
self.assertEqual("tailscale-gitea", first.bottle_host_alias)
|
||||||
|
self.assertEqual("100.78.141.42", first.upstream_host)
|
||||||
|
self.assertEqual("30009", first.upstream_port)
|
||||||
|
|
||||||
|
def test_empty_bottle_yields_empty_upstreams(self):
|
||||||
|
bottle = fixture_minimal().bottles["dev"]
|
||||||
|
self.assertEqual((), ssh_gate_upstreams_for_bottle(bottle))
|
||||||
|
|
||||||
|
def test_duplicate_upstream_port_is_rejected(self):
|
||||||
|
# Two entries on the same upstream port can't both have a
|
||||||
|
# listener — the gate is one container with a flat port
|
||||||
|
# space. Surface as a clear config error.
|
||||||
|
manifest = Manifest.from_json_obj({
|
||||||
|
"bottles": {
|
||||||
|
"dev": {
|
||||||
|
"ssh": [
|
||||||
|
{"Host": "a", "IdentityFile": "/dev/null",
|
||||||
|
"Hostname": "host-a.example", "User": "git", "Port": 22},
|
||||||
|
{"Host": "b", "IdentityFile": "/dev/null",
|
||||||
|
"Hostname": "host-b.example", "User": "git", "Port": 22},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
})
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
ssh_gate_upstreams_for_bottle(manifest.bottles["dev"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestEntrypointRender(unittest.TestCase):
|
||||||
|
def test_one_socat_line_per_upstream(self):
|
||||||
|
upstreams = (
|
||||||
|
SSHGateUpstream(30009, "gitea.example", "30009", "gitea"),
|
||||||
|
SSHGateUpstream(22, "github.com", "22", "gh"),
|
||||||
|
)
|
||||||
|
script = ssh_gate_render_entrypoint(upstreams)
|
||||||
|
self.assertIn("#!/bin/sh", script)
|
||||||
|
self.assertIn(
|
||||||
|
"socat TCP-LISTEN:30009,reuseaddr,fork TCP:gitea.example:30009 &", script
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"socat TCP-LISTEN:22,reuseaddr,fork TCP:github.com:22 &", script
|
||||||
|
)
|
||||||
|
# wait blocks the entrypoint so PID 1 stays alive while sockets
|
||||||
|
# are open.
|
||||||
|
self.assertTrue(script.rstrip().endswith("wait"))
|
||||||
|
|
||||||
|
def test_empty_upstreams_still_has_wait(self):
|
||||||
|
# Defensive: a no-upstream gate is a no-op, but render must
|
||||||
|
# still produce a valid shell script.
|
||||||
|
script = ssh_gate_render_entrypoint(())
|
||||||
|
self.assertIn("#!/bin/sh", script)
|
||||||
|
self.assertIn("wait", script)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrepare(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.stage = Path(tempfile.mkdtemp())
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(self.stage, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_prepare_writes_entrypoint_mode_600(self):
|
||||||
|
plan = _StubGate().prepare(
|
||||||
|
fixture_with_ssh().bottles["dev"], "demo", self.stage
|
||||||
|
)
|
||||||
|
self.assertEqual(self.stage / "ssh_gate_entrypoint.sh", plan.entrypoint_script)
|
||||||
|
self.assertEqual(0o600, os.stat(plan.entrypoint_script).st_mode & 0o777)
|
||||||
|
|
||||||
|
def test_prepare_plan_carries_upstreams_and_slug(self):
|
||||||
|
plan = _StubGate().prepare(
|
||||||
|
fixture_with_ssh().bottles["dev"], "demo", self.stage
|
||||||
|
)
|
||||||
|
self.assertEqual("demo", plan.slug)
|
||||||
|
self.assertEqual(2, len(plan.upstreams))
|
||||||
|
self.assertEqual("", plan.internal_network)
|
||||||
|
self.assertEqual("", plan.egress_network)
|
||||||
|
|
||||||
|
def test_prepare_with_no_ssh_writes_minimal_script(self):
|
||||||
|
plan = _StubGate().prepare(
|
||||||
|
fixture_minimal().bottles["dev"], "demo", self.stage
|
||||||
|
)
|
||||||
|
self.assertEqual((), plan.upstreams)
|
||||||
|
content = plan.entrypoint_script.read_text()
|
||||||
|
self.assertNotIn("socat", content)
|
||||||
|
self.assertIn("wait", content)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user