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
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:
76
compose.yml
76
compose.yml
@@ -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:
|
||||
|
||||
@@ -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'"
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -1 +0,0 @@
|
||||
claude:$6$GswjIxib0ir2tXiF$wa3CoB0aOd7GRwnXb57yt0izN8.aMiLVJ21hhZnc2xBtYO7BOUCFEMTK9qMio5t4vznONaHLFlJXB2Lq1Au531
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user