#!/usr/bin/env bash # 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 (Claude) # can use the keys for SSH operations 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 macOS↔Linux # VM boundary — connect() returns ENOTSUP. Running ssh-agent inside the # container sidesteps that entirely and keeps the same isolation guarantee. # # How the isolation works: # - Keys are docker cp'd to /root/.claude-bottle-keys/ (mode 700, root-owned). # /root itself is mode 700 in the node:22-slim base image, so node (uid # 1000) cannot even traverse into it. # - ssh-agent runs as root, listening on /run/claude-bottle-agent.sock. Each # key is loaded with ssh-add, then the key file is deleted. The bytes # now live only in the agent process's memory. # - The agent socket stays root-only. OpenSSH's ssh-agent enforces a # SO_PEERCRED-based UID match: it rejects every connection whose peer # euid is neither 0 nor the agent's own uid. chmod'ing the socket open # does *not* defeat this — the kernel-level check still rejects node. # - 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. From the agent's view, socat (uid 0) is the peer # and passes the UID check. From node's view, the public socket is the # accessible endpoint. # - node cannot ptrace the root-owned agent or socat (no CAP_SYS_PTRACE in # a default container), so /proc//mem is off-limits and the key # bytes never leave root-owned memory. # - ~/.ssh/config in node's home points each Host at the public socket via # IdentityAgent, so SSH always reaches the forwarder regardless of # SSH_AUTH_SOCK. # # Limitation: keys must be passphrase-less. ssh-add prompts on /dev/tty for # passphrases, but our docker exec has no TTY. Adding SSH_ASKPASS support is # possible but not implemented in v1. # # Each ssh entry is a JSON object (jq -c) with keys: # Host SSH Host alias # IdentityFile absolute path to the private key file on the host # Hostname the actual hostname or IP # User SSH username # Port SSH port (number) # KnownHostKey (optional) host public key — written to known_hosts under # both the Host alias and the Hostname so the lookup works # whether SSH connects via the alias or the raw IP/host. # # Idempotent: safe to source multiple times. if [ -n "${CLAUDE_BOTTLE_LIB_SSH_SOURCED:-}" ]; then return 0 fi CLAUDE_BOTTLE_LIB_SSH_SOURCED=1 _iso_lib_ssh_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=./log.sh . "${_iso_lib_ssh_dir}/log.sh" # ssh_validate_entries ... — checks that each entry has the # required fields and that its IdentityFile exists on the host. Dies on the # first problem. ssh_validate_entries() { local entry name key for entry in "$@"; do name="$(printf '%s' "$entry" | jq -r '.Host // empty')" key="$(printf '%s' "$entry" | jq -r '.IdentityFile // empty')" [ -n "$name" ] || die "ssh entry missing required field 'Host': ${entry}" [ -n "$key" ] || die "ssh entry '${name}' missing required field 'IdentityFile'" # Expand a leading ~ so callers can use ~/... paths. key="${key/#\~/$HOME}" [ -f "$key" ] || die "ssh key file not found for host '${name}': ${key}" done } # ssh_setup ... — sets up SSH in the # container so node (Claude) can authenticate using each entry's key without # the key file being readable by node. # # Lifecycle: # 1. Create ~/.ssh (700) for node and /root/.claude-bottle-keys (700) for root. # 2. docker cp each key into /root/.claude-bottle-keys/, chown root, chmod 600. # 3. Boot ssh-agent at /run/claude-bottle-agent.sock (root-only), ssh-add each # key, delete the key file, rmdir the keys staging dir. # 4. Boot a root-owned socat forwarder on /run/claude-bottle-agent-public.sock # (mode 666) proxying to the agent socket. Bridges the UID-match check # that would otherwise reject node's connections (see file header). # 5. Install ~/.ssh/config (IdentityAgent → public socket) and # ~/.ssh/known_hosts under node's home. ssh_setup() { local container="${1:?ssh_setup: missing container}" local stage_dir="${2:?ssh_setup: missing stage dir}" shift 2 local container_home="${CLAUDE_BOTTLE_CONTAINER_HOME:-/home/node}" local container_ssh="${container_home}/.ssh" local agent_socket="/run/claude-bottle-agent.sock" local public_socket="/run/claude-bottle-agent-public.sock" local keys_dir="/root/.claude-bottle-keys" # ~/.ssh for node (700, owned by node). docker exec -u 0 "$container" mkdir -p "$container_ssh" >/dev/null docker exec -u 0 "$container" chown node:node "$container_ssh" >/dev/null docker exec -u 0 "$container" chmod 700 "$container_ssh" >/dev/null # /root/.claude-bottle-keys for root (700, root-owned). /root is already 700 # in node:22-slim, so node can't traverse here either way; setting both # layers keeps the intent explicit. docker exec -u 0 "$container" mkdir -p "$keys_dir" >/dev/null docker exec -u 0 "$container" chown root:root "$keys_dir" >/dev/null docker exec -u 0 "$container" chmod 700 "$keys_dir" >/dev/null local config_file="${stage_dir}/ssh_config" local known_hosts_file="${stage_dir}/ssh_known_hosts" : > "$config_file" chmod 600 "$config_file" : > "$known_hosts_file" chmod 600 "$known_hosts_file" local entry name key hostname user port known_host_key key_basename container_key_path local container_key_paths=() for entry in "$@"; do name="$(printf '%s' "$entry" | jq -r '.Host')" key="$(printf '%s' "$entry" | jq -r '.IdentityFile')" hostname="$(printf '%s' "$entry" | jq -r '.Hostname')" user="$(printf '%s' "$entry" | jq -r '.User')" port="$(printf '%s' "$entry" | jq -r '.Port')" known_host_key="$(printf '%s' "$entry" | jq -r '.KnownHostKey // empty')" key="${key/#\~/$HOME}" key_basename="$(basename "$key")" container_key_path="${keys_dir}/${key_basename}" info "copying ssh key for '${name}' -> ${container} (root-only staging)" docker cp "$key" "${container}:${container_key_path}" >/dev/null docker exec -u 0 "$container" chown root:root "$container_key_path" >/dev/null docker exec -u 0 "$container" chmod 600 "$container_key_path" >/dev/null container_key_paths+=("$container_key_path") # No IdentityFile — IdentityAgent points SSH at the public (forwarded) # socket. Pointing at the real agent socket directly would be rejected # by ssh-agent's UID-match check (see file header). printf 'Host %s\n HostName %s\n User %s\n Port %s\n IdentityAgent %s\n\n' \ "$name" "$hostname" "$user" "$port" "$public_socket" >> "$config_file" if [ -n "$known_host_key" ]; then # Write under both the Host alias and the Hostname so SSH finds the key # whether the connection uses the alias (`ssh `) or a raw IP/host # (e.g. git remote URLs that bypass the alias). Skip the duplicate when # they're already the same string. if [ "$port" = "22" ]; then printf '%s %s\n' "$name" "$known_host_key" >> "$known_hosts_file" [ "$hostname" != "$name" ] && printf '%s %s\n' "$hostname" "$known_host_key" >> "$known_hosts_file" else printf '[%s]:%s %s\n' "$name" "$port" "$known_host_key" >> "$known_hosts_file" [ "$hostname" != "$name" ] && printf '[%s]:%s %s\n' "$hostname" "$port" "$known_host_key" >> "$known_hosts_file" fi fi done # Boot the agent, load each key, delete the key files, then start the # root-owned socat forwarder that exposes a node-accessible socket. One # docker exec so the whole sequence is atomic — if any step fails (e.g. # passphrase-protected key), set -e dies before we leave behind a # half-initialized agent. info "starting in-container ssh-agent at ${agent_socket} (forwarded via ${public_socket})" local setup_script="set -eu ssh-agent -a ${agent_socket} >/dev/null " local kp for kp in "${container_key_paths[@]}"; do setup_script+="SSH_AUTH_SOCK=${agent_socket} ssh-add ${kp} rm -f ${kp} " done setup_script+="rmdir ${keys_dir} 2>/dev/null || true # Start the forwarder. Detach from the calling shell so it survives this # docker exec returning. socat (running as root) connects to the agent on # node's behalf; the agent's UID-match check sees uid 0 and accepts. nohup socat UNIX-LISTEN:${public_socket},fork,reuseaddr,mode=666 UNIX-CONNECT:${agent_socket} /dev/null 2>&1 & # Wait briefly for the forwarder to bind. Without this, an SSH client that # fires immediately after this script returns can race the listener and hit # ENOENT/ECONNREFUSED on the public socket. i=0 while [ \$i -lt 20 ]; do [ -S ${public_socket} ] && break i=\$((i + 1)) sleep 0.1 done [ -S ${public_socket} ] || { echo 'claude-bottle: socat forwarder failed to bind ${public_socket}' >&2; exit 1; } " docker exec -u 0 "$container" sh -c "$setup_script" info "writing ${container_ssh}/config" docker cp "$config_file" "${container}:${container_ssh}/config" >/dev/null docker exec -u 0 "$container" chown node:node "${container_ssh}/config" >/dev/null docker exec -u 0 "$container" chmod 600 "${container_ssh}/config" >/dev/null if [ -s "$known_hosts_file" ]; then info "writing ${container_ssh}/known_hosts" docker cp "$known_hosts_file" "${container}:${container_ssh}/known_hosts" >/dev/null docker exec -u 0 "$container" chown node:node "${container_ssh}/known_hosts" >/dev/null docker exec -u 0 "$container" chmod 600 "${container_ssh}/known_hosts" >/dev/null fi }