feat(scripts): WSL client bootstrap for routing Claude Code through proxy
Some checks failed
CI / lint (push) Successful in 29s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / notebooks (push) Failing after 19s
Infra CI / zotero (push) Successful in 15s
Infra CI / docs (push) Successful in 1m28s
Infra CI / api (push) Successful in 33s
Infra CI / mc (push) Successful in 14s
Deploy / report (push) Successful in 14s
CI / test (push) Failing after 13m32s
Renovate / renovate (push) Successful in 15s
Package Supply Chain / pkg-supply-chain (push) Failing after 45s

setup-proxy-client.sh: one-shot, idempotent setup for a WSL (or any
Debian/Ubuntu) machine to route Claude Code through proxy.fhirworx.io.

- Installs cloudflared from the official apt repo
- Pins proxy.fhirworx.io -> 127.0.0.1 in /etc/hosts (and patches
  /etc/wsl.conf so the pin survives reboots on WSL)
- Drops a systemd --user unit (cf-proxy.service) running
  `cloudflared access tcp --hostname proxy.fhirworx.io --url 127.0.0.1:18443`
- Installs Claude Code if missing
- Merges HTTPS_PROXY + NO_PROXY into ~/.claude/settings.json (mode 0600
  since the URL embeds the proxy password)
- Smoke-tests by hitting api.anthropic.com through the proxy and
  asserting HTTP 401 (proves auth + allow-list both work)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kert
2026-05-17 20:23:40 -04:00
parent fbf621c0ec
commit 61d3000138

161
dev/scripts/setup-proxy-client.sh Executable file
View File

@@ -0,0 +1,161 @@
#!/usr/bin/env bash
# Set up a WSL (or any Debian/Ubuntu) machine to route Claude Code through
# proxy.fhirworx.io via cloudflared TCP access. Idempotent — safe to re-run.
#
# Usage:
# PROXY_PASSWORD=... ./setup-proxy-client.sh
# or
# ./setup-proxy-client.sh # will prompt for the password
set -euo pipefail
HOSTNAME_PROXY=proxy.fhirworx.io
LOCAL_PORT=18443
SERVICE_NAME=cf-proxy
# ── 0. Pre-flight ─────────────────────────────────────────────────────────────
log() { printf '\033[1;34m[setup]\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31m[setup] ERROR:\033[0m %s\n' "$*" >&2; exit 1; }
[[ $EUID -ne 0 ]] || fail "run as your normal user — sudo is invoked per-step"
command -v systemctl >/dev/null || fail "systemd not found. Enable it in /etc/wsl.conf ([boot] systemd=true) then 'wsl --shutdown'."
command -v sudo >/dev/null || fail "sudo missing"
command -v curl >/dev/null || fail "curl missing"
command -v python3 >/dev/null || fail "python3 missing"
if [[ -z "${PROXY_PASSWORD:-}" ]]; then
read -rsp "proxy password for user 'claude': " PROXY_PASSWORD; echo
fi
[[ -n "$PROXY_PASSWORD" ]] || fail "PROXY_PASSWORD is empty"
# ── 1. cloudflared ────────────────────────────────────────────────────────────
if ! command -v cloudflared >/dev/null; then
log "installing cloudflared"
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
CODENAME=$(. /etc/os-release; echo "${VERSION_CODENAME:-bookworm}")
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $CODENAME main" \
| sudo tee /etc/apt/sources.list.d/cloudflared.list >/dev/null
sudo apt-get update -qq
sudo apt-get install -y cloudflared
else
log "cloudflared already installed ($(cloudflared --version 2>&1 | head -1))"
fi
# ── 2. /etc/hosts pin + WSL persistence ───────────────────────────────────────
log "pinning $HOSTNAME_PROXY → 127.0.0.1 in /etc/hosts"
sudo sed -i "/\b$HOSTNAME_PROXY\b/d" /etc/hosts
echo "127.0.0.1 $HOSTNAME_PROXY" | sudo tee -a /etc/hosts >/dev/null
if grep -qi microsoft /proc/version 2>/dev/null; then
if ! grep -q '^\s*generateHosts\s*=\s*false' /etc/wsl.conf 2>/dev/null; then
log "patching /etc/wsl.conf so /etc/hosts isn't regenerated on next boot"
sudo tee -a /etc/wsl.conf >/dev/null <<'EOF'
[network]
generateHosts = false
EOF
WSL_REBOOT_NEEDED=1
fi
fi
getent hosts "$HOSTNAME_PROXY" | grep -q '^127\.0\.0\.1' \
|| fail "$HOSTNAME_PROXY does not resolve to 127.0.0.1 — check /etc/hosts and /etc/nsswitch.conf"
# ── 3. systemd user service for the cloudflared shim ──────────────────────────
log "writing systemd user unit ~/.config/systemd/user/$SERVICE_NAME.service"
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/$SERVICE_NAME.service <<EOF
[Unit]
Description=cloudflared access tcp shim for $HOSTNAME_PROXY
After=network-online.target
[Service]
ExecStart=/usr/bin/cloudflared access tcp --hostname $HOSTNAME_PROXY --url 127.0.0.1:$LOCAL_PORT
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user enable --now "$SERVICE_NAME.service"
sudo loginctl enable-linger "$USER" # keep user manager alive across logouts
# wait briefly for the listener
for _ in $(seq 1 20); do
ss -tln 2>/dev/null | grep -q ":$LOCAL_PORT\b" && break
sleep 0.5
done
ss -tln 2>/dev/null | grep -q ":$LOCAL_PORT\b" \
|| fail "cloudflared shim didn't open :$LOCAL_PORT — check 'journalctl --user -u $SERVICE_NAME -n 30'"
# ── 4. Claude Code ────────────────────────────────────────────────────────────
if ! command -v claude >/dev/null; then
log "installing Claude Code"
curl -fsSL https://claude.ai/install.sh | bash
# the installer drops it under ~/.local/bin or similar; rehash for this shell
hash -r
fi
command -v claude >/dev/null || log "WARNING: claude not on PATH — may need a new shell"
# ── 5. settings.json (merge, don't clobber) ───────────────────────────────────
log "merging proxy env into ~/.claude/settings.json"
mkdir -p ~/.claude
SETTINGS=~/.claude/settings.json
[[ -f "$SETTINGS" ]] || echo '{}' > "$SETTINGS"
PROXY_URL="https://claude:${PROXY_PASSWORD}@${HOSTNAME_PROXY}:${LOCAL_PORT}"
python3 - "$SETTINGS" "$PROXY_URL" <<'PY'
import json, sys, os
path, proxy_url = sys.argv[1], sys.argv[2]
with open(path) as f:
cfg = json.load(f)
env = cfg.get("env", {}) or {}
env["HTTPS_PROXY"] = proxy_url
env["NO_PROXY"] = "localhost,127.0.0.1"
cfg["env"] = env
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(cfg, f, indent=2)
os.replace(tmp, path)
os.chmod(path, 0o600) # contains the proxy password
PY
log "wrote $SETTINGS (mode 0600)"
# ── 6. smoke test ─────────────────────────────────────────────────────────────
log "smoke-testing the full path (expect HTTP 401 from Anthropic)"
CODE=$(curl -sS -o /dev/null -w '%{http_code}' -m 30 \
-x "$PROXY_URL" \
https://api.anthropic.com/v1/messages \
-H "x-api-key: dummy" -H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{"model":"claude-opus-4-7","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}' \
|| echo "curl-failed")
case "$CODE" in
401) log "✓ smoke test passed (got 401 from api.anthropic.com)" ;;
407) fail "got 407 — proxy auth failed. Check PROXY_PASSWORD." ;;
403) fail "got 403 — Cloudflare Access is gating the tunnel. Run: cloudflared access login $HOSTNAME_PROXY" ;;
"curl-failed"|000) fail "curl couldn't reach the proxy. Check 'systemctl --user status $SERVICE_NAME'." ;;
*) fail "unexpected HTTP $CODE — check 'docker compose logs proxy' on the stack host." ;;
esac
if [[ -n "${WSL_REBOOT_NEEDED:-}" ]]; then
cat <<'EOF'
────────────────────────────────────────────────────────────────────
One-time follow-up: /etc/wsl.conf was changed so /etc/hosts won't be
regenerated on next WSL boot. To make that effective, from PowerShell
on the Windows side, run once:
wsl --shutdown
Then reopen WSL. (Current session continues to work as-is.)
────────────────────────────────────────────────────────────────────
EOF
fi
log "done. Try: claude -p 'ping'"