revert: remove forward proxy and Claude Code routing through it
Some checks failed
CI / lint (push) Successful in 42s
Deploy / notebooks (push) Has been skipped
Deploy / api (push) Has been skipped
CI / test (push) Successful in 14m27s
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / mc (push) Has been skipped
Package Supply Chain / pkg-supply-chain (push) Failing after 1m4s
Deploy / report (push) Successful in 12s

Undoes the entire proxy.fhirworx.io stack:
  - fbf621c — squid + lego + cloudflared ingress (server side)
  - 61d3000 — WSL client bootstrap script
  - 6b02b95 — proxy/ docker compose (client container)
  - 2bdbdc1 — sandbox topology fix
  - a94d3d4 — daemon-mode claude container

Routing Claude Code through a self-hosted proxy was the goal; the
WSL/docker client path proved fragile (TLS/proxy interactions, clock
drift, bind-mount assumptions) and not worth keeping. Dropping the
whole concept rather than carrying broken scaffolding.

Kept: 794edf8 (traefik trustedIPs) — unrelated to the proxy work.
This commit is contained in:
kert
2026-05-20 11:57:23 -04:00
parent a94d3d4f96
commit bc3e833e92
12 changed files with 2 additions and 406 deletions

View File

@@ -887,81 +887,6 @@ services:
- no-new-privileges:true
restart: unless-stopped
proxy:
build: ./infra/squid
container_name: proxy
networks:
- gateway
volumes:
- ./infra/squid/squid.conf:/etc/squid/squid.conf:ro
- ./infra/squid/passwd:/etc/squid/passwd:ro
- proxy_certs:/etc/squid/certs:ro
depends_on:
lego:
condition: service_healthy
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
# Watches the cert volume; HUPs squid when lego writes a new cert.
# Shares squid's PID namespace so `pkill` can find PID 1.
proxy-reloader:
build: ./infra/squid-reloader
container_name: proxy-reloader
pid: "service:proxy"
volumes:
- proxy_certs:/etc/squid/certs:ro
depends_on:
- proxy
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
# ACME (Let's Encrypt) for proxy.fhirworx.io via Cloudflare DNS-01.
# On first boot, issues the cert; thereafter loops daily and renews
# when <30 days remain. Cert files land in the proxy_certs volume,
# which squid mounts read-only.
lego:
image: goacme/lego:latest
container_name: lego
networks:
- gateway
dns:
- 1.1.1.1
- 1.0.0.1
environment:
- CLOUDFLARE_DNS_API_TOKEN=${CF_API_TOKEN}
- LEGO_EMAIL=${LEGO_EMAIL}
entrypoint:
- sh
- -c
- |
set -e
DOMAIN=proxy.fhirworx.io
while true; do
/lego run --email="$$LEGO_EMAIL" --domains="$$DOMAIN" \
--dns=cloudflare --dns.propagation.wait=60s \
--path=/data --accept-tos --renew-days=30 || true
sleep 86400
done
volumes:
- proxy_certs:/data
healthcheck:
test: ["CMD", "test", "-f", "/data/certificates/proxy.fhirworx.io.crt"]
interval: 10s
timeout: 5s
retries: 60
start_period: 5s
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
@@ -985,4 +910,3 @@ volumes:
prometheus_data:
tempo_data:
grafana_data:
proxy_certs:

View File

@@ -1,161 +0,0 @@
#!/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'"

View File

@@ -2,8 +2,6 @@ tunnel: 1389035e-d3ba-4a4f-969d-a369c07ee057
credentials-file: /home/nonroot/.cloudflared/1389035e-d3ba-4a4f-969d-a369c07ee057.json
ingress:
- hostname: proxy.fhirworx.io
service: tcp://proxy:3128
- hostname: "*.fhirworx.io"
service: http://traefik:80
- hostname: "fhirworx.io"

View File

@@ -1,5 +0,0 @@
FROM alpine:3.20
RUN apk add --no-cache inotify-tools
COPY watch.sh /usr/local/bin/watch.sh
RUN chmod +x /usr/local/bin/watch.sh
CMD ["/usr/local/bin/watch.sh"]

View File

@@ -1,22 +0,0 @@
#!/bin/sh
# Watch the cert directory for atomic replaces (lego renames temp → final)
# and HUP squid so it re-reads the cert. Joined-PID-namespace with proxy
# (compose `pid: service:proxy`) means `pkill -x squid` finds PID 1.
set -eu
CERT_DIR=/etc/squid/certs/certificates
CERT_FILE=proxy.fhirworx.io.crt
echo "watching $CERT_DIR for changes to $CERT_FILE"
inotifywait -m -e close_write,moved_to,create "$CERT_DIR" | \
while read -r _ _ filename; do
if [ "$filename" = "$CERT_FILE" ]; then
if pkill -HUP -x squid; then
echo "$(date -Is) reloaded squid (HUP)"
else
echo "$(date -Is) no squid process found to signal"
fi
fi
done

View File

@@ -1,7 +0,0 @@
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
squid-openssl apache2-utils ca-certificates \
&& rm -rf /var/lib/apt/lists/*
EXPOSE 3128
CMD ["squid", "-N", "-f", "/etc/squid/squid.conf"]

View File

@@ -1 +0,0 @@
claude:$6$GswjIxib0ir2tXiF$wa3CoB0aOd7GRwnXb57yt0izN8.aMiLVJ21hhZnc2xBtYO7BOUCFEMTK9qMio5t4vznONaHLFlJXB2Lq1Au531

View File

@@ -1,23 +0,0 @@
# Forward HTTPS proxy. Listens TLS-terminated on :3128, accepts CONNECT
# tunnels to Anthropic destinations only, requires basic auth. Inner
# TLS to api.anthropic.com is untouched (no ssl_bump).
https_port 3128 tls-cert=/etc/squid/certs/certificates/proxy.fhirworx.io.crt tls-key=/etc/squid/certs/certificates/proxy.fhirworx.io.key
acl anthropic_dsts dstdomain .anthropic.com .claude.ai .claude.com
acl SSL_ports port 443
acl CONNECT method CONNECT
auth_param basic program /usr/lib/squid/basic_ncsa_auth /etc/squid/passwd
auth_param basic realm fhirworx-proxy
acl authenticated proxy_auth REQUIRED
http_access deny CONNECT !SSL_ports
http_access allow authenticated anthropic_dsts
http_access deny all
forwarded_for delete
via off
cache deny all
access_log daemon:/var/log/squid/access.log squid
cache_log /var/log/squid/cache.log

View File

@@ -1,7 +0,0 @@
# Password for the squid 'claude' user (see infra/squid/passwd on the host).
PROXY_PASSWORD=
# Cloudflare Access service token for proxy.fhirworx.io.
# Mint a new pair under Zero Trust → Access → Service Auth.
CF_ACCESS_CLIENT_ID=
CF_ACCESS_CLIENT_SECRET=

View File

@@ -1,16 +0,0 @@
# Containerized Claude Code client — proxied via cf-proxy (sibling service).
# Mirrors what dev/scripts/setup-proxy-client.sh does on a WSL host, minus the
# systemd/hosts hackery (compose handles networking + extra_hosts).
FROM node:20-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl git jq openssh-client \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g @anthropic-ai/claude-code
# Daemon-style: container stays alive so users `docker compose exec` claude
# inside it. For one-shot invocations: `docker compose run --rm
# --entrypoint claude claude [-p 'prompt']`.
ENTRYPOINT ["sleep", "infinity"]

View File

@@ -1,83 +0,0 @@
# Run Claude Code in a sandboxed container whose only outside path is the
# allowlisted forward proxy at proxy.fhirworx.io.
#
# Network topology (the point of this compose):
# proxy_net internal bridge, 192.168.10.0/24. No NAT, no internet egress.
# claude lives only here, so it physically cannot reach anything
# outside the host except by going through cf-proxy.
# egress_net regular bridge. Only cf-proxy is dual-homed: it sits on
# proxy_net to be reachable by claude, and on egress_net so
# cloudflared can dial the Cloudflare edge.
#
# Services:
# cf-proxy cloudflared access TCP shim. Terminates the Cloudflare tunnel
# at 192.168.10.2:18443. The only door out for claude.
# claude Claude Code CLI. HTTPS_PROXY points at proxy.fhirworx.io:18443,
# which extra_hosts pins to 192.168.10.2 so the TLS SNI/cert
# match without poisoning cf-proxy's own DNS view.
#
# Usage:
# cp .env.example .env && $EDITOR .env # PROXY_PASSWORD and
# CF_ACCESS_CLIENT_ID/SECRET
# docker compose up -d claude # bring up the sandbox
# docker compose exec claude claude # interactive session
# docker compose exec claude claude -p 'ping' # one-shot inside the sandbox
# docker compose run --rm --entrypoint claude claude [-p 'ping']
# # ephemeral one-shot
#
# Notes:
# - claude-home is a named volume so login state persists across runs.
# - Bind mounts use identical host/container paths so Claude never sees a
# translated path: /home/care/acoharmony, /opt/s3/data/workspace,
# /opt/s3/data/notebooks. Claude only has filesystem access to these.
services:
cf-proxy:
image: cloudflare/cloudflared:latest
container_name: cc-cf-proxy
command: access tcp --hostname proxy.fhirworx.io --url 0.0.0.0:18443
environment:
TUNNEL_SERVICE_TOKEN_ID: ${CF_ACCESS_CLIENT_ID}
TUNNEL_SERVICE_TOKEN_SECRET: ${CF_ACCESS_CLIENT_SECRET}
networks:
proxy_net:
ipv4_address: 192.168.10.2
egress_net: {}
restart: unless-stopped
claude:
image: git.fhirworx.io/kert/claude:latest
container_name: cc-claude
depends_on:
- cf-proxy
# Long-running so users docker exec into it. claude itself is launched
# per-session via `docker compose exec claude claude`.
entrypoint: ["sleep", "infinity"]
restart: unless-stopped
networks:
- proxy_net
extra_hosts:
- "proxy.fhirworx.io:192.168.10.2"
environment:
HTTPS_PROXY: "https://claude:${PROXY_PASSWORD}@proxy.fhirworx.io:18443"
HTTP_PROXY: "https://claude:${PROXY_PASSWORD}@proxy.fhirworx.io:18443"
NO_PROXY: "localhost,127.0.0.1"
working_dir: /home/care/acoharmony
volumes:
- claude-home:/root/.claude
- /home/care/acoharmony:/home/care/acoharmony
- /opt/s3/data/workspace:/opt/s3/data/workspace
- /opt/s3/data/notebooks:/opt/s3/data/notebooks
networks:
proxy_net:
driver: bridge
internal: true
ipam:
config:
- subnet: 192.168.10.0/24
egress_net:
driver: bridge
volumes:
claude-home:

View File

@@ -157,9 +157,8 @@ class TestNetworkAssignment:
"""Every service should be assigned to at least one network."""
def test_all_services_have_networks(self):
# Exceptions: ephemeral/profile-only services, and proxy-reloader
# which shares proxy's PID namespace and only signals locally.
exempt = {"wire", "proxy-reloader"}
# Exceptions: ephemeral/profile-only services
exempt = {"wire"}
compose = _load_compose()
missing = []
for svc, cfg in compose.get("services", {}).items():