Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
}
108 changes: 108 additions & 0 deletions infra/ssh-tunnel/README.md
Original file line number Diff line number Diff line change
@@ -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.<your-domain>`. 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=<token from step 2.1> \
CLAUDE_SSH_PUBKEY="<contents of claude-cloud.pub>" \
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.<your-domain>
```

**Environment variables**:

```
SSH_TUNNEL_HOSTNAME=ssh.<your-domain>
SSH_TUNNEL_USER=root
SSH_TUNNEL_HOST_KEY="ssh-ed25519 AAAA... (printed by server-setup.sh)"
CLAUDE_SSH_PRIVATE_KEY_B64=<contents of claude-cloud.b64>
TUNNEL_SERVICE_TOKEN_ID=<client id>
TUNNEL_SERVICE_TOKEN_SECRET=<client 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.
20 changes: 20 additions & 0 deletions infra/ssh-tunnel/cloud-env-setup.sh
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions infra/ssh-tunnel/server-setup.sh
Original file line number Diff line number Diff line change
@@ -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 <<CONF
PermitRootLogin prohibit-password
PubkeyAuthentication yes
CONF
if [[ "${DISABLE_PASSWORD_AUTH:-0}" == "1" ]]; then
echo "PasswordAuthentication no" >> /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."
74 changes: 74 additions & 0 deletions infra/ssh-tunnel/session-init.sh
Original file line number Diff line number Diff line change
@@ -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" <<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'"
Loading