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
|
- 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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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."""
|
"""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():
|
||||||
|
|||||||
Reference in New Issue
Block a user