diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 145eb9d..96f091d 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -15,12 +15,12 @@ import subprocess import sys from contextlib import contextmanager from pathlib import Path -from typing import Iterator +from typing import Iterator, Sequence from ... import pipelock -from ... import ssh as ssh_mod from ...env_resolve import env_resolve from ...log import die, info +from ...manifest import SshEntry from .. import BottleBackend, BottleCleanupPlan, BottlePlan, BottleSpec from . import network as network_mod from . import util as docker_mod @@ -88,7 +88,7 @@ class DockerBottleBackend(BottleBackend): if agent.skills: self.validate_skills(list(agent.skills)) if bottle.ssh: - ssh_mod.ssh_validate_entries(bottle.ssh) + self.validate_ssh_entries(bottle.ssh) env_file = stage_dir / "agent.env" args_file = stage_dir / "docker-args" @@ -353,16 +353,198 @@ class DockerBottleBackend(BottleBackend): check=True, ) + def validate_ssh_entries(self, entries: Sequence[SshEntry]) -> None: + """Each entry's IdentityFile must exist on the host (after + expanding leading ~). Host and IdentityFile shape are already + enforced by Manifest validation. Called from `prepare` before + the y/N so the user doesn't get prompted for a plan with a + missing key.""" + for entry in entries: + key = self._expand_tilde(entry.IdentityFile) + if not os.path.isfile(key): + die(f"ssh key file not found for host '{entry.Host}': {key}") + def provision_ssh(self, plan: BottlePlan, target: str) -> None: - """If the bottle has SSH entries, set up the in-container - ssh-agent and config so node can authenticate without ever - seeing the key bytes. No-op when the bottle has no SSH.""" + """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//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.""" assert isinstance(plan, DockerBottlePlan) bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) if not bottle.ssh: return + + container = target proxy_host_port = pipelock.pipelock_proxy_host_port(plan.slug) - ssh_mod.ssh_setup(target, plan.stage_dir, proxy_host_port, bottle.ssh) + 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" + + # ~/.ssh for node (700, owned by node). + self._docker_exec_root(container, ["mkdir", "-p", container_ssh]) + self._docker_exec_root(container, ["chown", "node:node", container_ssh]) + self._docker_exec_root(container, ["chmod", "700", container_ssh]) + + # /root/.claude-bottle-keys for root (700, root-owned). + self._docker_exec_root(container, ["mkdir", "-p", keys_dir]) + self._docker_exec_root(container, ["chown", "root:root", keys_dir]) + self._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) + + proxy_host, _, proxy_port = proxy_host_port.partition(":") + + container_key_paths: list[str] = [] + for entry in bottle.ssh: + name = entry.Host + key = self._expand_tilde(entry.IdentityFile) + hostname = entry.Hostname + user = entry.User + port = entry.Port + known_host_key = entry.KnownHostKey + + 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, + ) + self._docker_exec_root(container, ["chown", "root:root", container_key_path]) + self._docker_exec_root(container, ["chmod", "600", container_key_path]) + + container_key_paths.append(container_key_path) + + # ProxyCommand tunnels SSH through pipelock via HTTP + # CONNECT. %h / %p expand to this block's HostName / + # Port. socat's PROXY: mode does CONNECT host:port to + # the proxy. + block = ( + f"Host {name}\n" + f" HostName {hostname}\n" + f" User {user}\n" + f" Port {port}\n" + f" IdentityAgent {public_socket}\n" + f" ProxyCommand socat - PROXY:{proxy_host}:%h:%p,proxyport={proxy_port}\n" + f"\n" + ) + with config_file.open("a") as f: + f.write(block) + + if known_host_key: + entries_to_write: list[str] = [] + if port == "22": + entries_to_write.append(f"{name} {known_host_key}\n") + if hostname != name: + 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: + for e in entries_to_write: + f.write(e) + + # 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 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, + ) + self._docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/config"]) + self._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, + ) + self._docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/known_hosts"]) + self._docker_exec_root(container, ["chmod", "600", f"{container_ssh}/known_hosts"]) + + def _docker_exec_root(self, container: str, argv: list[str]) -> None: + subprocess.run( + ["docker", "exec", "-u", "0", container, *argv], + stdout=subprocess.DEVNULL, + check=True, + ) + + def _expand_tilde(self, path: str) -> str: + if path.startswith("~"): + home = os.environ.get("HOME", "") + return home + path[1:] + return path def provision_git(self, plan: BottlePlan, target: str) -> None: """If --cwd was set and the host cwd has a .git directory, copy diff --git a/claude_bottle/ssh.py b/claude_bottle/ssh.py deleted file mode 100644 index 634958f..0000000 --- a/claude_bottle/ssh.py +++ /dev/null @@ -1,204 +0,0 @@ -"""SSH helpers. Validates ssh entries from claude-bottle.json, then sets -up SSH inside the container via a root-owned ssh-agent so the `node` -user can use the keys for SSH but cannot read the key bytes. - -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. - -Isolation: - - 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//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. - -Limitation: keys must be passphrase-less. ssh-add prompts on /dev/tty -for passphrases, but our docker exec has no TTY. - -Each ssh entry has keys: Host, IdentityFile, Hostname, User, Port -(required); KnownHostKey (optional). -""" - -from __future__ import annotations - -import os -import subprocess -from pathlib import Path -from typing import Sequence - -from .log import die, info -from .manifest import SshEntry - - -def ssh_validate_entries(entries: Sequence[SshEntry]) -> None: - """The IdentityFile must exist on the host (after expanding leading ~). - Host and IdentityFile shape are already enforced by Manifest validation.""" - 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 ssh_setup( - container: str, - stage_dir: Path, - proxy_host_port: str, - entries: Sequence[SshEntry], -) -> None: - """Set up SSH in the container so node can authenticate using each - entry's key without the key file being readable by node.""" - 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" - - # ~/.ssh for node (700, owned by node). - _docker_exec_root(container, ["mkdir", "-p", container_ssh]) - _docker_exec_root(container, ["chown", "node:node", container_ssh]) - _docker_exec_root(container, ["chmod", "700", container_ssh]) - - # /root/.claude-bottle-keys for root (700, root-owned). - _docker_exec_root(container, ["mkdir", "-p", keys_dir]) - _docker_exec_root(container, ["chown", "root:root", keys_dir]) - _docker_exec_root(container, ["chmod", "700", keys_dir]) - - config_file = stage_dir / "ssh_config" - known_hosts_file = stage_dir / "ssh_known_hosts" - config_file.write_text("") - config_file.chmod(0o600) - known_hosts_file.write_text("") - known_hosts_file.chmod(0o600) - - proxy_host, _, proxy_port = proxy_host_port.partition(":") - - container_key_paths: list[str] = [] - for entry in entries: - name = entry.Host - key = _expand_tilde(entry.IdentityFile) - hostname = entry.Hostname - user = entry.User - port = entry.Port - known_host_key = entry.KnownHostKey - - 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_exec_root(container, ["chown", "root:root", container_key_path]) - _docker_exec_root(container, ["chmod", "600", container_key_path]) - - container_key_paths.append(container_key_path) - - # ProxyCommand tunnels SSH through pipelock via HTTP CONNECT. - # %h / %p expand to this block's HostName / Port. socat's - # PROXY: mode does CONNECT host:port to the proxy. - block = ( - f"Host {name}\n" - f" HostName {hostname}\n" - f" User {user}\n" - f" Port {port}\n" - f" IdentityAgent {public_socket}\n" - f" ProxyCommand socat - PROXY:{proxy_host}:%h:%p,proxyport={proxy_port}\n" - f"\n" - ) - with config_file.open("a") as f: - f.write(block) - - if known_host_key: - entries_to_write: list[str] = [] - if port == "22": - entries_to_write.append(f"{name} {known_host_key}\n") - if hostname != name: - 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: - for e in entries_to_write: - f.write(e) - - # 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 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_exec_root(container, ["chown", "node:node", f"{container_ssh}/config"]) - _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_exec_root(container, ["chown", "node:node", f"{container_ssh}/known_hosts"]) - _docker_exec_root(container, ["chmod", "600", f"{container_ssh}/known_hosts"]) - - -def _docker_exec_root(container: str, argv: list[str]) -> None: - subprocess.run( - ["docker", "exec", "-u", "0", container, *argv], - stdout=subprocess.DEVNULL, - check=True, - ) - - -def _expand_tilde(path: str) -> str: - if path.startswith("~"): - home = os.environ.get("HOME", "") - return home + path[1:] - return path