From 7fb4f2d4e2fa9a73b129d30bf93b8ac4d103fa7b Mon Sep 17 00:00:00 2001 From: Oveshen Govender Date: Sun, 6 Sep 2026 16:40:01 +0000 Subject: [PATCH] feat(infra): SSH-over-Cloudflare-Tunnel setup for cloud environments Anthropic-hosted cloud sessions only egress over HTTP/HTTPS through a security proxy, so direct SSH to the auction server is impossible. Add: - infra/ssh-tunnel/server-setup.sh: one-shot root script that installs cloudflared as a service, authorises a dedicated key, and enables key-only root login (optional password-auth disable). - infra/ssh-tunnel/cloud-env-setup.sh: environment setup script that installs cloudflared without writing any secret into the cache. - infra/ssh-tunnel/session-init.sh: builds ~/.ssh config from env vars (key, Access service token, pinned host key) each session. - .claude/settings.json: SessionStart hook running session-init.sh in cloud sessions only. - infra/ssh-tunnel/README.md: dashboard steps, allowlist, env vars. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RMUkAjWYUX1CFQAe76zgEJ --- .claude/settings.json | 14 ++++ infra/ssh-tunnel/README.md | 108 ++++++++++++++++++++++++++++ infra/ssh-tunnel/cloud-env-setup.sh | 20 ++++++ infra/ssh-tunnel/server-setup.sh | 74 +++++++++++++++++++ infra/ssh-tunnel/session-init.sh | 74 +++++++++++++++++++ 5 files changed, 290 insertions(+) create mode 100644 .claude/settings.json create mode 100644 infra/ssh-tunnel/README.md create mode 100755 infra/ssh-tunnel/cloud-env-setup.sh create mode 100755 infra/ssh-tunnel/server-setup.sh create mode 100755 infra/ssh-tunnel/session-init.sh diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..edba254 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "if [ -n \"$CLAUDE_CODE_REMOTE\" ]; then bash \"$CLAUDE_PROJECT_DIR/infra/ssh-tunnel/session-init.sh\"; fi" + } + ] + } + ] + } +} diff --git a/infra/ssh-tunnel/README.md b/infra/ssh-tunnel/README.md new file mode 100644 index 0000000..3645ddb --- /dev/null +++ b/infra/ssh-tunnel/README.md @@ -0,0 +1,108 @@ +# SSH from a cloud environment (Cloudflare Tunnel) + +Anthropic-hosted cloud environments only allow outbound HTTP/HTTPS through a +security proxy, so `ssh root@187.77.130.10` cannot work directly. This setup +carries SSH over an HTTPS WebSocket via a Cloudflare Tunnel instead: + +``` +cloud session ──ssh──▶ cloudflared access (HTTPS/WebSocket, via the proxy) + ──▶ Cloudflare edge ──▶ cloudflared on the server ──▶ localhost:22 +``` + +The server makes only an outbound connection to Cloudflare, so port 22 can be +firewalled from the internet afterwards. Cloudflare Access with a service token +gates who can reach the hostname. + +## Prerequisites + +- A domain on Cloudflare (free plan is enough) and Zero Trust enabled on the + account (free tier is enough). +- Root access to the server, used once to run `server-setup.sh`. + +## 1. Generate a dedicated key pair (your machine) + +```bash +ssh-keygen -t ed25519 -f claude-cloud -C claude-cloud -N '' +base64 -w0 claude-cloud > claude-cloud.b64 # goes into CLAUDE_SSH_PRIVATE_KEY_B64 +cat claude-cloud.pub # goes into CLAUDE_SSH_PUBKEY for the server +``` + +Never put the private key in chat or in the repo. + +## 2. Cloudflare dashboard + +1. **Zero Trust > Networks > Tunnels > Create a tunnel** (Cloudflared). Name it, + pick **Debian** as the connector, and copy the token from the install + command. Don't run that command; `server-setup.sh` does it. +2. On the tunnel's **Public Hostname** tab add one route: + subdomain `ssh`, your domain, type **SSH**, URL `localhost:22`. +3. **Zero Trust > Access > Service Auth > Service Tokens > Create**. Save the + Client ID and Client Secret. +4. **Zero Trust > Access > Applications > Add > Self-hosted**. Domain + `ssh.`. Add a policy with action **Service Auth**, include + rule **Service Token** = the token from step 3. + +## 3. Server (once) + +```bash +scp infra/ssh-tunnel/server-setup.sh root@187.77.130.10:/root/ +ssh root@187.77.130.10 \ + 'CLOUDFLARE_TUNNEL_TOKEN= \ + CLAUDE_SSH_PUBKEY="" \ + bash /root/server-setup.sh' +``` + +It installs `cloudflared` as a service, appends the public key to +`/root/authorized_keys`, allows key-only root login, and prints the server's +host key. Once key login works, re-run with `DISABLE_PASSWORD_AUTH=1` to turn +off password login. + +## 4. Cloud environment (claude.ai/code > environment settings) + +**Network access**: Custom, with "Also include default list" checked, and: + +``` +pkg.cloudflare.com +ssh. +``` + +**Environment variables**: + +``` +SSH_TUNNEL_HOSTNAME=ssh. +SSH_TUNNEL_USER=root +SSH_TUNNEL_HOST_KEY="ssh-ed25519 AAAA... (printed by server-setup.sh)" +CLAUDE_SSH_PRIVATE_KEY_B64= +TUNNEL_SERVICE_TOKEN_ID= +TUNNEL_SERVICE_TOKEN_SECRET= +``` + +Anyone who can use the environment can read these values, so keep the +environment personal, and rotate the key and service token if it is shared. + +**Setup script**: paste the contents of `cloud-env-setup.sh`. It only installs +`cloudflared`; secrets are never written into the cached snapshot. + +## 5. Per session + +`.claude/settings.json` runs `session-init.sh` at the start of every cloud +session (it no-ops locally and whenever the variables are missing). It writes +the key, a `ProxyCommand` wrapper, and an `auction-server` host entry. Then: + +```bash +ssh -o BatchMode=yes auction-server 'hostname; uptime' +``` + +## Troubleshooting + +- `websocket: bad handshake` / 403: the Access policy is not matching the + service token, or the hostname is not in the environment allowlist. +- TLS errors from `cloudflared`: the session proxy re-terminates TLS. Go reads + `SSL_CERT_FILE`, which the session sets; if it still fails, run + `cloudflared access ssh` with `--loglevel debug` and check the proxy status + at `$HTTPS_PROXY/__agentproxy/status`. +- `Permission denied (publickey)`: the public key in `authorized_keys` does not + match `CLAUDE_SSH_PRIVATE_KEY_B64`, or the base64 was line-wrapped + (use `base64 -w0`). +- Host key mismatch: `SSH_TUNNEL_HOST_KEY` is stale; re-copy it from the + server. diff --git a/infra/ssh-tunnel/cloud-env-setup.sh b/infra/ssh-tunnel/cloud-env-setup.sh new file mode 100755 index 0000000..ac91db0 --- /dev/null +++ b/infra/ssh-tunnel/cloud-env-setup.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Paste this into the cloud environment's "Setup script" field (claude.ai/code > environment settings). +# It runs once per environment cache and only installs tooling; nothing secret is written here, +# so the cached filesystem snapshot never contains a key. Per-session config comes from +# infra/ssh-tunnel/session-init.sh via the SessionStart hook in .claude/settings.json. +set -euo pipefail + +if command -v cloudflared >/dev/null 2>&1; then + echo "cloudflared already installed: $(cloudflared --version)" + exit 0 +fi + +sudo mkdir -p --mode=0755 /usr/share/keyrings +curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \ + | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null +echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared noble main" \ + | sudo tee /etc/apt/sources.list.d/cloudflared.list >/dev/null +sudo apt-get update -qq +sudo apt-get install -y -qq cloudflared +cloudflared --version diff --git a/infra/ssh-tunnel/server-setup.sh b/infra/ssh-tunnel/server-setup.sh new file mode 100755 index 0000000..9f5ee9f --- /dev/null +++ b/infra/ssh-tunnel/server-setup.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Run ONCE on the target server (as root) to expose SSH through a Cloudflare Tunnel +# and authorise the cloud environment's SSH key. +# +# scp infra/ssh-tunnel/server-setup.sh root@187.77.130.10:/root/ +# ssh root@187.77.130.10 'CLOUDFLARE_TUNNEL_TOKEN=... CLAUDE_SSH_PUBKEY="ssh-ed25519 AAAA... claude-cloud" bash /root/server-setup.sh' +# +# Required env: +# CLOUDFLARE_TUNNEL_TOKEN token from Zero Trust > Networks > Tunnels > (your tunnel) > Install connector +# CLAUDE_SSH_PUBKEY public half of the key pair generated for the cloud environment +# Optional: +# DISABLE_PASSWORD_AUTH=1 turn off SSH password login once key login is confirmed working +# +# Everything here is idempotent; re-running is safe. +set -euo pipefail + +: "${CLOUDFLARE_TUNNEL_TOKEN:?set CLOUDFLARE_TUNNEL_TOKEN}" +: "${CLAUDE_SSH_PUBKEY:?set CLAUDE_SSH_PUBKEY}" + +if [[ $(id -u) -ne 0 ]]; then + echo "run as root" >&2; exit 1 +fi + +echo "==> installing cloudflared" +if ! command -v cloudflared >/dev/null 2>&1; then + mkdir -p --mode=0755 /usr/share/keyrings + curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \ + -o /usr/share/keyrings/cloudflare-main.gpg + . /etc/os-release + echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared ${VERSION_CODENAME:-jammy} main" \ + > /etc/apt/sources.list.d/cloudflared.list + apt-get update -qq + apt-get install -y -qq cloudflared +fi +cloudflared --version + +echo "==> registering cloudflared as a service" +if systemctl is-active --quiet cloudflared; then + echo "cloudflared service already running; leaving it alone" +else + cloudflared service install "$CLOUDFLARE_TUNNEL_TOKEN" + systemctl enable --now cloudflared +fi +systemctl --no-pager --lines=5 status cloudflared || true + +echo "==> authorising cloud environment SSH key" +install -d -m 700 /root/.ssh +touch /root/.ssh/authorized_keys +chmod 600 /root/.ssh/authorized_keys +if ! grep -qxF "$CLAUDE_SSH_PUBKEY" /root/.ssh/authorized_keys; then + echo "$CLAUDE_SSH_PUBKEY" >> /root/.ssh/authorized_keys + echo "key added" +else + echo "key already present" +fi + +echo "==> sshd: allow key-based root login" +mkdir -p /etc/ssh/sshd_config.d +cat > /etc/ssh/sshd_config.d/90-claude-tunnel.conf <> /etc/ssh/sshd_config.d/90-claude-tunnel.conf + echo "password authentication disabled" +fi +sshd -t +systemctl reload ssh 2>/dev/null || systemctl reload sshd + +echo +echo "==> server host key (put this in SSH_TUNNEL_HOST_KEY on the cloud environment):" +awk '{print $1" "$2}' /etc/ssh/ssh_host_ed25519_key.pub +echo +echo "done. Now finish the dashboard steps in infra/ssh-tunnel/README.md." diff --git a/infra/ssh-tunnel/session-init.sh b/infra/ssh-tunnel/session-init.sh new file mode 100755 index 0000000..e249ef0 --- /dev/null +++ b/infra/ssh-tunnel/session-init.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Builds ~/.ssh config for the tunnelled server from environment variables. +# Runs at the start of every cloud session (SessionStart hook in .claude/settings.json). +# Safe to run by hand: bash infra/ssh-tunnel/session-init.sh +# +# Env (set on the cloud environment): +# SSH_TUNNEL_HOSTNAME public hostname mapped to ssh://localhost:22 in the tunnel, e.g. ssh.example.com +# SSH_TUNNEL_USER login user on the server (default root) +# CLAUDE_SSH_PRIVATE_KEY_B64 base64 of the private key: base64 -w0 claude-cloud +# TUNNEL_SERVICE_TOKEN_ID Cloudflare Access service token id +# TUNNEL_SERVICE_TOKEN_SECRET Cloudflare Access service token secret +# SSH_TUNNEL_HOST_KEY optional "ssh-ed25519 AAAA..." printed by server-setup.sh; pins the host key +set -euo pipefail + +if [[ -z "${SSH_TUNNEL_HOSTNAME:-}" ]]; then + echo "ssh-tunnel: SSH_TUNNEL_HOSTNAME not set; skipping" >&2 + exit 0 +fi +for v in CLAUDE_SSH_PRIVATE_KEY_B64 TUNNEL_SERVICE_TOKEN_ID TUNNEL_SERVICE_TOKEN_SECRET; do + if [[ -z "${!v:-}" ]]; then + echo "ssh-tunnel: $v not set; cannot configure" >&2 + exit 0 + fi +done +if ! command -v cloudflared >/dev/null 2>&1; then + echo "ssh-tunnel: cloudflared missing; run infra/ssh-tunnel/cloud-env-setup.sh as the environment setup script" >&2 + exit 0 +fi + +user="${SSH_TUNNEL_USER:-root}" +install -d -m 700 "$HOME/.ssh" + +umask 077 +printf '%s' "$CLAUDE_SSH_PRIVATE_KEY_B64" | base64 -d > "$HOME/.ssh/claude-cloud" +chmod 600 "$HOME/.ssh/claude-cloud" + +# ProxyCommand wrapper: the service token is read from the environment at connect time, +# so it is never written into ~/.ssh/config. +cat > "$HOME/.ssh/cf-ssh-proxy" <<'WRAP' +#!/usr/bin/env bash +exec cloudflared access ssh --hostname "$1" \ + --service-token-id "${TUNNEL_SERVICE_TOKEN_ID:?}" \ + --service-token-secret "${TUNNEL_SERVICE_TOKEN_SECRET:?}" +WRAP +chmod 700 "$HOME/.ssh/cf-ssh-proxy" + +if [[ -n "${SSH_TUNNEL_HOST_KEY:-}" ]]; then + grep -qF "$SSH_TUNNEL_HOST_KEY" "$HOME/.ssh/known_hosts" 2>/dev/null \ + || echo "$SSH_TUNNEL_HOSTNAME $SSH_TUNNEL_HOST_KEY" >> "$HOME/.ssh/known_hosts" + strict="yes" +else + strict="accept-new" +fi + +# Replace any previous managed block, keep the rest of the file. +cfg="$HOME/.ssh/config" +touch "$cfg" +sed -i '/^# >>> claude-ssh-tunnel/,/^# <<< claude-ssh-tunnel/d' "$cfg" +cat >> "$cfg" <>> claude-ssh-tunnel (managed by infra/ssh-tunnel/session-init.sh) +Host auction-server $SSH_TUNNEL_HOSTNAME + HostName $SSH_TUNNEL_HOSTNAME + User $user + IdentityFile ~/.ssh/claude-cloud + IdentitiesOnly yes + ProxyCommand ~/.ssh/cf-ssh-proxy %h + StrictHostKeyChecking $strict + ServerAliveInterval 30 + ConnectTimeout 20 +# <<< claude-ssh-tunnel +CFG +chmod 600 "$cfg" + +echo "ssh-tunnel: configured. Test with: ssh -o BatchMode=yes auction-server 'hostname; uptime'"