From 8582e608afed64f9419321222cdb5599bda77971 Mon Sep 17 00:00:00 2001 From: didericis Date: Fri, 8 May 2026 01:39:08 -0400 Subject: [PATCH] fix(ssh): tunnel ssh through pipelock so agents on --internal can reach git remotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent container is on an --internal Docker network with no default route — only the pipelock sidecar is reachable. HTTPS_PROXY routes HTTP through pipelock, but raw TCP (e.g. SSH on port 30009) had no egress path, so `git fetch` against any bottle.ssh entry failed with "Network is unreachable". Fix: tunnel SSH through pipelock's HTTP CONNECT proxy. - lib/ssh.sh injects `ProxyCommand socat - PROXY::%h:%p,proxyport=` into each Host block in the in-container ~/.ssh/config. socat is already in the image (apt-installed for the ssh-agent forwarder). - lib/pipelock.sh auto-adds each bottle.ssh[].Hostname to the effective allowlist so pipelock permits the CONNECT. - cli.sh threads the pipelock host:port into ssh_setup. Note: works for SSH hosts pipelock's SSRF layer doesn't block. CGNAT (100.64.0.0/10) and other non-RFC1918 ranges should pass; if a future host gets blocked, expose pipelock's trusted_domains as a follow-up. Assisted-by: Claude Code --- cli.sh | 4 +++- lib/pipelock.sh | 37 +++++++++++++++++++++++++++++++++---- lib/ssh.sh | 21 ++++++++++++++++++--- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/cli.sh b/cli.sh index d0aa31a..78c7611 100755 --- a/cli.sh +++ b/cli.sh @@ -650,7 +650,9 @@ cmd_start() { # Set up SSH keys and config. if [ "${#SSH_ENTRIES[@]}" -gt 0 ]; then - ssh_setup "$CONTAINER" "$STAGE_DIR" "${SSH_ENTRIES[@]}" + local PIPELOCK_PROXY_HOST_PORT + PIPELOCK_PROXY_HOST_PORT="$(pipelock_proxy_host_port "$SLUG")" + ssh_setup "$CONTAINER" "$STAGE_DIR" "$PIPELOCK_PROXY_HOST_PORT" "${SSH_ENTRIES[@]}" fi # When --cwd is on, ship the host repo's .git directory in via `docker cp` diff --git a/lib/pipelock.sh b/lib/pipelock.sh index 0284dbf..0a64f10 100644 --- a/lib/pipelock.sh +++ b/lib/pipelock.sh @@ -108,6 +108,17 @@ pipelock_proxy_url() { printf 'http://%s:%s' "$name" "$CLAUDE_BOTTLE_PIPELOCK_PORT" } +# pipelock_proxy_host_port — prints : (no scheme), +# suitable for socat's PROXY: directive in an SSH ProxyCommand. The +# agent's --internal network has no default route, so SSH (and any other +# raw TCP) must tunnel via pipelock's HTTP CONNECT. +pipelock_proxy_host_port() { + local slug="${1:?pipelock_proxy_host_port: missing slug}" + local name + name="$(pipelock_container_name "$slug")" + printf '%s:%s' "$name" "$CLAUDE_BOTTLE_PIPELOCK_PORT" +} + # --- Allowlist resolution -------------------------------------------------- # pipelock_bottle_allowlist @@ -139,12 +150,29 @@ pipelock_bottle_allowlist() { ' "$manifest_file" } +# pipelock_bottle_ssh_hostnames +# +# Prints one hostname per line for each entry in bottles[].ssh[].Hostname. +# These need to reach pipelock's allowlist so the agent can tunnel SSH +# through pipelock via HTTP CONNECT (see ssh_setup's ProxyCommand +# wiring). Empty output if the bottle has no ssh entries. +pipelock_bottle_ssh_hostnames() { + local manifest_file="${1:?pipelock_bottle_ssh_hostnames: missing manifest file}" + local bottle_name="${2:?pipelock_bottle_ssh_hostnames: missing bottle name}" + + jq -r --arg b "$bottle_name" ' + .bottles[$b].ssh // [] | .[] | .Hostname // empty + ' "$manifest_file" +} + # pipelock_effective_allowlist # -# Prints the deduplicated union of the baked-in default allowlist and -# the bottle's declared allowlist, one hostname per line, sorted for -# stability. This is the single source of truth callers should use for -# both YAML generation and the preflight summary. +# Prints the deduplicated union of: the baked-in default allowlist, the +# bottle's declared egress.allowlist, and any bottle.ssh[].Hostname +# entries (so SSH tunneling through pipelock is permitted by the same +# allowlist check that gates HTTP CONNECT). One hostname per line, +# sorted for stability. This is the single source of truth callers +# should use for both YAML generation and the preflight summary. pipelock_effective_allowlist() { local manifest_file="${1:?pipelock_effective_allowlist: missing manifest file}" local bottle_name="${2:?pipelock_effective_allowlist: missing bottle name}" @@ -152,6 +180,7 @@ pipelock_effective_allowlist() { { printf '%s\n' "$CLAUDE_BOTTLE_PIPELOCK_DEFAULT_ALLOWLIST" pipelock_bottle_allowlist "$manifest_file" "$bottle_name" + pipelock_bottle_ssh_hostnames "$manifest_file" "$bottle_name" } | awk 'NF && !seen[$0]++' | LC_ALL=C sort } diff --git a/lib/ssh.sh b/lib/ssh.sh index 5ac4fa2..81bd9be 100644 --- a/lib/ssh.sh +++ b/lib/ssh.sh @@ -89,7 +89,12 @@ ssh_validate_entries() { ssh_setup() { local container="${1:?ssh_setup: missing container}" local stage_dir="${2:?ssh_setup: missing stage dir}" - shift 2 + # proxy_host_port is the pipelock sidecar as : (no scheme). + # Used as socat's PROXY: argument so the agent can reach SSH hosts + # over the agent's --internal network — the only egress route is the + # pipelock CONNECT proxy. Required. + local proxy_host_port="${3:?ssh_setup: missing proxy_host_port}" + shift 3 local container_home="${CLAUDE_BOTTLE_CONTAINER_HOME:-/home/node}" local container_ssh="${container_home}/.ssh" @@ -140,8 +145,18 @@ ssh_setup() { # 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" + # + # ProxyCommand tunnels the SSH connection through pipelock via HTTP + # CONNECT. The agent container has no default route (--internal + # network); pipelock is the only path to anywhere. socat's PROXY: + # mode does CONNECT host:port to the proxy. %h / %p expand to this + # block's HostName / Port. The SSH host must also appear in + # pipelock's allowlist — pipelock_effective_allowlist auto-includes + # bottle.ssh[].Hostname entries so this just works for declared + # hosts. + printf 'Host %s\n HostName %s\n User %s\n Port %s\n IdentityAgent %s\n ProxyCommand socat - PROXY:%s:%%h:%%p,proxyport=%s\n\n' \ + "$name" "$hostname" "$user" "$port" "$public_socket" \ + "${proxy_host_port%:*}" "${proxy_host_port##*:}" >> "$config_file" if [ -n "$known_host_key" ]; then # Write under both the Host alias and the Hostname so SSH finds the key