feat(ssh-gate): retarget ssh provisioner at the new gate

PRD 0007: stop tunneling ssh through pipelock. Each Host block in
the agent's ~/.ssh/config now points at the gate container + the
per-entry listen port; HostKeyAlias preserves host-key validation
against the real upstream name, and CheckHostIP=no skips the
resolved-IP path (which would otherwise hit the gate's IP).
known_hosts collapses to a single entry per upstream keyed on the
alias.

The pipelock_proxy_host_port import is gone from this module; the
function itself becomes dead code and gets removed alongside the
broader pipelock SSH carve-outs in the next commit.
This commit is contained in:
2026-05-12 16:05:22 -04:00
parent 2533f8a00b
commit ce948db0b7
+30 -24
View File
@@ -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