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:
76
compose.yml
76
compose.yml
@@ -887,6 +887,81 @@ 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
|
||||
@@ -910,3 +985,4 @@ volumes:
|
||||
prometheus_data:
|
||||
tempo_data:
|
||||
grafana_data:
|
||||
proxy_certs:
|
||||
|
||||
@@ -2,6 +2,8 @@ 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"
|
||||
|
||||
5
infra/squid-reloader/Dockerfile
Normal file
5
infra/squid-reloader/Dockerfile
Normal 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"]
|
||||
22
infra/squid-reloader/watch.sh
Normal file
22
infra/squid-reloader/watch.sh
Normal 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
7
infra/squid/Dockerfile
Normal 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
1
infra/squid/passwd
Normal file
@@ -0,0 +1 @@
|
||||
claude:$6$GswjIxib0ir2tXiF$wa3CoB0aOd7GRwnXb57yt0izN8.aMiLVJ21hhZnc2xBtYO7BOUCFEMTK9qMio5t4vznONaHLFlJXB2Lq1Au531
|
||||
23
infra/squid/squid.conf
Normal file
23
infra/squid/squid.conf
Normal 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
|
||||
@@ -157,8 +157,9 @@ class TestNetworkAssignment:
|
||||
"""Every service should be assigned to at least one network."""
|
||||
|
||||
def test_all_services_have_networks(self):
|
||||
# Exceptions: ephemeral/profile-only services
|
||||
exempt = {"wire"}
|
||||
# Exceptions: ephemeral/profile-only services, and proxy-reloader
|
||||
# which shares proxy's PID namespace and only signals locally.
|
||||
exempt = {"wire", "proxy-reloader"}
|
||||
compose = _load_compose()
|
||||
missing = []
|
||||
for svc, cfg in compose.get("services", {}).items():
|
||||
|
||||
Reference in New Issue
Block a user