feat(proxy): allowlisted forward HTTPS proxy for Anthropic via Squid + lego

TLS-terminated Squid on :3128 with basic auth, restricted by ACL to
.anthropic.com / .claude.ai / .claude.com. CONNECT-only; no ssl_bump.

Stack:
- proxy:           squid-openssl, reads cert from proxy_certs volume
- proxy-reloader:  inotify-watches the cert dir, HUPs squid on rotation
                   (joins proxy's PID namespace so pkill finds PID 1)
- lego:            ACME DNS-01 via Cloudflare for proxy.fhirworx.io,
                   issues into proxy_certs, renews when <30d remain
- cloudflared:     new TCP ingress route proxy.fhirworx.io -> proxy:3128

Tests:
- proxy-reloader has no network by design (signal-only, PID-shared with
  proxy), so add it to the network-assignment exempt set alongside wire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kert
2026-05-17 20:23:10 -04:00
parent 8dd3f7570a
commit fbf621c0ec
8 changed files with 139 additions and 2 deletions

View File

@@ -887,6 +887,81 @@ services:
- no-new-privileges:true - no-new-privileges:true
restart: unless-stopped 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: cloudflared:
image: cloudflare/cloudflared:latest image: cloudflare/cloudflared:latest
container_name: cloudflared container_name: cloudflared
@@ -910,3 +985,4 @@ volumes:
prometheus_data: prometheus_data:
tempo_data: tempo_data:
grafana_data: grafana_data:
proxy_certs:

View File

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

View File

@@ -0,0 +1,5 @@
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

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

7
infra/squid/Dockerfile Normal file
View File

@@ -0,0 +1,7 @@
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"]

1
infra/squid/passwd Normal file
View File

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

23
infra/squid/squid.conf Normal file
View File

@@ -0,0 +1,23 @@
# 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

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