Files
bot-bottle/claude_bottle/ssh.py
T
didericis 399ed93dc8 refactor: convert project from bash to Python
Replaces cli.sh + lib/*.sh with a claude_bottle/ Python package and a
cli.py entry point. No external dependencies — uses only Python's
stdlib (json, subprocess, getpass, tempfile, argparse, re, etc.).

- claude_bottle/{log,docker,manifest,env_resolve,network,pipelock,
  skills,ssh,cli}.py mirror the previous lib/*.sh modules.
- Tests converted to unittest under tests/test_*.py with a stdlib
  runner at tests/run_tests.py (unit | integration | path).
- .githooks/commit-msg ported to Python; same Conventional Commits rules.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:26:58 +00:00

210 lines
8.1 KiB
Python

"""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/<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.
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 Any
from .log import die, info
def ssh_validate_entries(entries: list[dict[str, Any]]) -> None:
"""Each entry must have Host + IdentityFile, and the IdentityFile
must exist on the host (after expanding leading ~)."""
for entry in entries:
name = entry.get("Host", "")
key = entry.get("IdentityFile", "")
if not name:
die(f"ssh entry missing required field 'Host': {entry}")
if not key:
die(f"ssh entry '{name}' missing required field 'IdentityFile'")
key = _expand_tilde(key)
if not os.path.isfile(key):
die(f"ssh key file not found for host '{name}': {key}")
def ssh_setup(
container: str,
stage_dir: Path,
proxy_host_port: str,
entries: list[dict[str, Any]],
) -> 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 = str(entry["Port"])
known_host_key = entry.get("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 >/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