diff --git a/containers/agent/setup-iptables.sh b/containers/agent/setup-iptables.sh index 0f7e05b14..41a563d00 100644 --- a/containers/agent/setup-iptables.sh +++ b/containers/agent/setup-iptables.sh @@ -138,12 +138,8 @@ fi echo "[iptables] Allow traffic to Squid proxy (${SQUID_IP}:${SQUID_PORT})..." iptables -t nat -A OUTPUT -d "$SQUID_IP" -j RETURN -# Allow traffic to API proxy sidecar (when enabled) -# AWF_API_PROXY_IP is set by docker-manager.ts when --enable-api-proxy is used -if [ -n "$AWF_API_PROXY_IP" ]; then - echo "[iptables] Allow traffic to API proxy sidecar (${AWF_API_PROXY_IP})..." - iptables -t nat -A OUTPUT -d "$AWF_API_PROXY_IP" -j RETURN -fi +# Note: API auth proxy traffic to Squid IP on ports 10000-10002 is already allowed +# by the rule above (iptables -t nat -A OUTPUT -d "$SQUID_IP" -j RETURN) # Bypass Squid for host.docker.internal when host access is enabled. # MCP gateway traffic to host.docker.internal gets DNAT'd to Squid, @@ -281,10 +277,8 @@ iptables -A OUTPUT -p tcp -d 127.0.0.11 --dport 53 -j ACCEPT # Allow traffic to Squid proxy (after NAT redirection) iptables -A OUTPUT -p tcp -d "$SQUID_IP" -j ACCEPT -# Allow traffic to API proxy sidecar (when enabled) -if [ -n "$AWF_API_PROXY_IP" ]; then - iptables -A OUTPUT -p tcp -d "$AWF_API_PROXY_IP" -j ACCEPT -fi +# Note: API auth proxy traffic to Squid IP on ports 10000-10002 is already allowed +# by the rule above (iptables -A OUTPUT -p tcp -d "$SQUID_IP" -j ACCEPT) # Drop all other TCP traffic (default deny policy) # This ensures that only explicitly allowed ports can be accessed diff --git a/containers/api-proxy/Dockerfile b/containers/api-proxy/Dockerfile deleted file mode 100644 index 505ec49cc..000000000 --- a/containers/api-proxy/Dockerfile +++ /dev/null @@ -1,34 +0,0 @@ -# Node.js API proxy for credential management -# Routes through Squid to respect domain whitelisting -FROM node:22-alpine - -# Install curl for healthchecks -RUN apk add --no-cache curl - -# Create app directory -WORKDIR /app - -# Copy package files -COPY package*.json ./ - -# Install dependencies from lockfile (deterministic) -RUN npm ci --omit=dev - -# Copy application files -COPY server.js ./ - -# Create non-root user -RUN addgroup -S apiproxy && adduser -S apiproxy -G apiproxy - -# Switch to non-root user -USER apiproxy - -# Expose ports -# 10000 - OpenAI API proxy (also serves as health check endpoint) -# 10001 - Anthropic API proxy -# 10002 - GitHub Copilot API proxy -EXPOSE 10000 10001 10002 - -# Redirect stdout/stderr to log file for persistence -# Use shell form to enable redirection and tee for both file and console -CMD node server.js 2>&1 | tee -a /var/log/api-proxy/api-proxy.log diff --git a/containers/api-proxy/README.md b/containers/api-proxy/README.md deleted file mode 100644 index b6b8805aa..000000000 --- a/containers/api-proxy/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# AWF API Proxy Sidecar - -Node.js-based API proxy that keeps LLM API credentials isolated from the agent container while routing all traffic through Squid to respect domain whitelisting. - -## Architecture - -``` -Agent Container (172.30.0.20) - ↓ HTTP request to api-proxy:10000 -API Proxy Sidecar (172.30.0.30) - ↓ Injects Authorization header - ↓ Routes via HTTP_PROXY (172.30.0.10:3128) -Squid Proxy (172.30.0.10) - ↓ Domain whitelist enforcement - ↓ TLS connection -api.openai.com or api.anthropic.com -``` - -## Features - -- **Credential Isolation**: API keys held only in sidecar, never exposed to agent -- **Squid Routing**: All traffic routes through Squid via HTTP_PROXY/HTTPS_PROXY -- **Domain Whitelisting**: Squid enforces ACL filtering on all egress traffic -- **Header Injection**: Automatically adds Authorization and x-api-key headers -- **Health Checks**: /health endpoint on both ports - -## Ports - -- **10000**: OpenAI API proxy (api.openai.com) -- **10001**: Anthropic API proxy (api.anthropic.com) - -## Environment Variables - -Required (at least one): -- `OPENAI_API_KEY` - OpenAI API key for authentication -- `ANTHROPIC_API_KEY` - Anthropic API key for authentication - -Set by AWF: -- `HTTP_PROXY` - Squid proxy URL (http://172.30.0.10:3128) -- `HTTPS_PROXY` - Squid proxy URL (http://172.30.0.10:3128) - -## Security - -- Runs as non-root user (apiproxy) -- All capabilities dropped (cap_drop: ALL) -- Memory limits (512MB) -- Process limits (100 PIDs) -- no-new-privileges security option - -## Building - -```bash -cd containers/api-proxy -docker build -t awf-api-proxy . -``` - -## Testing - -```bash -# Start proxy with test key -docker run -p 10000:10000 \ - -e OPENAI_API_KEY=sk-test123 \ - -e HTTP_PROXY=http://squid:3128 \ - -e HTTPS_PROXY=http://squid:3128 \ - awf-api-proxy - -# Test health endpoint -curl http://localhost:10000/health -``` - -## Implementation Details - -- Built on Node.js 22 Alpine Linux -- Uses Express for HTTP server -- Uses http-proxy-middleware for proxying -- Naturally respects HTTP_PROXY/HTTPS_PROXY environment variables -- Simpler and more maintainable than Envoy configuration diff --git a/containers/squid/Dockerfile b/containers/squid/Dockerfile index c5a695eed..4a92f07e3 100644 --- a/containers/squid/Dockerfile +++ b/containers/squid/Dockerfile @@ -1,9 +1,9 @@ FROM ubuntu/squid:latest -# Install additional tools for debugging, healthcheck, and SSL Bump +# Install additional tools for debugging, healthcheck, SSL Bump, and Node.js (for auth proxy) # Retry logic handles transient 404s when Ubuntu archive supersedes package versions mid-build RUN set -eux; \ - PKGS="curl dnsutils net-tools netcat-openbsd openssl squid-openssl"; \ + PKGS="curl dnsutils net-tools netcat-openbsd openssl squid-openssl nodejs npm"; \ apt-get update && \ apt-get install -y --only-upgrade gpgv && \ ( apt-get install -y --no-install-recommends $PKGS || \ @@ -11,17 +11,26 @@ RUN set -eux; \ apt-get install -y --no-install-recommends $PKGS) ) && \ rm -rf /var/lib/apt/lists/* -# Create log directory and SSL database directory -RUN mkdir -p /var/log/squid && \ - chown -R proxy:proxy /var/log/squid +# Create log directories +RUN mkdir -p /var/log/squid /var/log/api-proxy && \ + chown -R proxy:proxy /var/log/squid /var/log/api-proxy + +# Copy API proxy files and install dependencies +WORKDIR /app/api-proxy +COPY package*.json ./ +RUN npm ci --omit=dev +COPY server.js ./ + +# Reset workdir +WORKDIR / # Copy entrypoint script COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh # Expose Squid port (3128 for HTTP, 3129 for HTTPS with SSL Bump) -EXPOSE 3128 -EXPOSE 3129 +# and API proxy ports (10000-10002 for LLM provider proxies) +EXPOSE 3128 3129 10000 10001 10002 # Use entrypoint to fix permissions before starting Squid ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/containers/squid/entrypoint.sh b/containers/squid/entrypoint.sh index d6d2fa598..33fd24884 100644 --- a/containers/squid/entrypoint.sh +++ b/containers/squid/entrypoint.sh @@ -19,5 +19,51 @@ if [ -d "/var/spool/squid_ssl_db" ]; then echo "[squid-entrypoint] SSL certificate database ready" fi -# Start Squid -exec squid -N -d 1 +# Start Node.js auth proxy if API keys are configured +# Security mitigation 3a: Run Node.js as non-root 'proxy' user +if [ -n "$OPENAI_API_KEY" ] || [ -n "$ANTHROPIC_API_KEY" ] || [ -n "$COPILOT_GITHUB_TOKEN" ]; then + echo "[squid-entrypoint] Starting API auth proxy..." + + # Fix permissions on api-proxy log directory + chown -R proxy:proxy /var/log/api-proxy + chmod -R 755 /var/log/api-proxy + + # Route through localhost Squid (not external IP) + export HTTP_PROXY="http://localhost:3128" + export HTTPS_PROXY="http://localhost:3128" + + # Security mitigation 3a: Drop to non-root 'proxy' user before starting Node.js + su -s /bin/sh proxy -c "HTTP_PROXY='$HTTP_PROXY' HTTPS_PROXY='$HTTPS_PROXY' \ + OPENAI_API_KEY='${OPENAI_API_KEY:-}' \ + ANTHROPIC_API_KEY='${ANTHROPIC_API_KEY:-}' \ + COPILOT_GITHUB_TOKEN='${COPILOT_GITHUB_TOKEN:-}' \ + node /app/api-proxy/server.js" & + API_PROXY_PID=$! + echo "[squid-entrypoint] API auth proxy started as non-root (PID: $API_PROXY_PID)" +fi + +# Security mitigation 3c: Don't use 'exec squid' - manage both processes properly +# Start Squid in background (not foreground with exec) +squid -N -d 1 & +SQUID_PID=$! +echo "[squid-entrypoint] Squid started (PID: $SQUID_PID)" + +# Graceful shutdown handler for both processes +cleanup() { + echo "[squid-entrypoint] Shutting down..." + kill $SQUID_PID 2>/dev/null || true + if [ -n "$API_PROXY_PID" ]; then + kill $API_PROXY_PID 2>/dev/null || true + fi + wait +} +trap cleanup TERM INT + +# Wait for either process to exit +wait -n +EXIT_CODE=$? +echo "[squid-entrypoint] A process exited with code $EXIT_CODE, shutting down..." + +# Clean up remaining processes +cleanup +exit $EXIT_CODE diff --git a/containers/api-proxy/package-lock.json b/containers/squid/package-lock.json similarity index 100% rename from containers/api-proxy/package-lock.json rename to containers/squid/package-lock.json diff --git a/containers/api-proxy/package.json b/containers/squid/package.json similarity index 100% rename from containers/api-proxy/package.json rename to containers/squid/package.json diff --git a/containers/api-proxy/server.js b/containers/squid/server.js similarity index 100% rename from containers/api-proxy/server.js rename to containers/squid/server.js diff --git a/docs/api-proxy-sidecar.md b/docs/api-proxy-sidecar.md index dc9b5c81e..f817a32a6 100644 --- a/docs/api-proxy-sidecar.md +++ b/docs/api-proxy-sidecar.md @@ -1,9 +1,11 @@ --- -title: API Proxy Sidecar -description: Secure LLM API credential management using an isolated proxy sidecar container. +title: API Proxy (Unified Architecture) +description: Secure LLM API credential management using a unified proxy container. --- -The AWF firewall supports an optional Node.js-based API proxy sidecar that securely holds LLM API credentials and automatically injects authentication headers while routing all traffic through Squid to respect domain whitelisting. +The AWF firewall supports an optional Node.js-based API auth proxy that securely holds LLM API credentials and automatically injects authentication headers while routing all traffic through Squid to respect domain whitelisting. + +The auth proxy runs **inside the Squid container** (unified architecture), eliminating the need for a separate sidecar container. :::note For a deep dive into how AWF handles authentication tokens and credential isolation, see the [Authentication Architecture](./authentication-architecture.md) guide. @@ -11,12 +13,13 @@ For a deep dive into how AWF handles authentication tokens and credential isolat ## Overview -When enabled, the API proxy sidecar: +When enabled, the unified API proxy: - **Isolates credentials**: API keys are never exposed to the agent container - **Auto-authentication**: Automatically injects Bearer tokens and API keys -- **Dual provider support**: Supports both OpenAI (Codex) and Anthropic (Claude) APIs +- **Multi-provider support**: Supports OpenAI (Codex), Anthropic (Claude), and GitHub Copilot APIs - **Transparent proxying**: Agent code uses standard SDK environment variables -- **Squid routing**: All traffic routes through Squid to respect domain whitelisting +- **Squid routing**: Auth proxy routes through local Squid for domain whitelisting +- **Reduced complexity**: Single container instead of separate sidecar ## Architecture @@ -24,31 +27,35 @@ When enabled, the API proxy sidecar: ┌─────────────────────────────────────────────────┐ │ AWF Network (172.30.0.0/24) │ │ │ -│ ┌──────────────┐ ┌─────────────────┐ │ -│ │ Squid │◄──────│ Node.js Proxy │ │ -│ │ 172.30.0.10 │ │ 172.30.0.30 │ │ -│ └──────┬───────┘ └─────────────────┘ │ -│ │ ▲ │ -│ │ ┌──────────────────────────────┐ │ -│ │ │ Agent Container │ │ -│ │ │ 172.30.0.20 │ │ -│ │ │ OPENAI_BASE_URL= │ │ -│ │ │ http://172.30.0.30:10000/v1│────┘ -│ │ │ ANTHROPIC_BASE_URL= │ -│ │ │ http://172.30.0.30:10001 │ -│ │ └──────────────────────────────┘ -│ │ -└─────────┼─────────────────────────────────────┘ - │ (Domain whitelist enforced) - ↓ +│ ┌──────────────────────────────────────────┐ │ +│ │ Unified Squid Container │ │ +│ │ 172.30.0.10 │ │ +│ │ ┌─────────────┐ ┌──────────────────┐ │ │ +│ │ │ Squid Proxy │◄─│ Node.js Auth │ │ │ +│ │ │ :3128 │ │ Proxy :10000-2 │ │ │ +│ │ └──────┬───────┘ └────────▲────────┘ │ │ +│ └─────────┼───────────────────┼───────────┘ │ +│ │ │ │ +│ ┌─────────┼────────────────────┼──────────┐ │ +│ │ │ Agent Container │ │ │ +│ │ │ 172.30.0.20 │ │ │ +│ │ OPENAI_BASE_URL= │ │ │ +│ │ http://172.30.0.10:10000/v1─┘ │ │ +│ │ ANTHROPIC_BASE_URL= │ │ +│ │ http://172.30.0.10:10001 │ │ +│ └──────────────────────────────────────────┘ │ +│ │ │ +└────────────┼────────────────────────────────────┘ + │ (Domain whitelist enforced) + ↓ api.openai.com or api.anthropic.com ``` **Traffic flow:** -1. Agent makes a request to `172.30.0.30:10000` (OpenAI) or `172.30.0.30:10001` (Anthropic) -2. API proxy strips any client-supplied auth headers and injects the real credentials -3. API proxy routes the request through Squid via `HTTP_PROXY`/`HTTPS_PROXY` -4. Squid enforces the domain whitelist (only allowed domains pass) +1. Agent makes a request to `172.30.0.10:10000` (OpenAI) or `172.30.0.10:10001` (Anthropic) +2. Auth proxy strips any client-supplied auth headers and injects the real credentials +3. Auth proxy routes the request through localhost Squid via `HTTP_PROXY`/`HTTPS_PROXY` +4. Squid enforces the domain whitelist (L7 filtering) 5. Request reaches `api.openai.com` or `api.anthropic.com` ## Usage @@ -60,7 +67,7 @@ When enabled, the API proxy sidecar: export OPENAI_API_KEY="sk-..." export ANTHROPIC_API_KEY="sk-ant-..." -# Enable API proxy sidecar +# Enable API proxy sudo awf --enable-api-proxy \ --allow-domains api.openai.com,api.anthropic.com \ -- your-command @@ -76,7 +83,7 @@ sudo awf --enable-api-proxy \ -- npx @openai/codex -p "write a hello world function" ``` -The agent container automatically uses `http://172.30.0.30:10000/v1` as the OpenAI base URL. +The agent container automatically uses `http://172.30.0.10:10000/v1` as the OpenAI base URL. ### Claude Code example @@ -88,7 +95,7 @@ sudo awf --enable-api-proxy \ -- claude-code "write a hello world function" ``` -The agent container automatically uses `http://172.30.0.30:10001` as the Anthropic base URL. +The agent container automatically uses `http://172.30.0.10:10001` as the Anthropic base URL. ### Both providers @@ -103,31 +110,22 @@ sudo awf --enable-api-proxy \ ## Environment variables -AWF manages environment variables differently across the three containers (squid, api-proxy, agent) to ensure secure credential isolation. - -### Squid container - -The Squid proxy container runs with minimal environment variables: - -| Variable | Value | Description | -|----------|-------|-------------| -| `HTTP_PROXY` | Not set | Squid is the proxy, not a client | -| `HTTPS_PROXY` | Not set | Squid is the proxy, not a client | +AWF manages environment variables across the Squid container and agent container to ensure secure credential isolation. -### API proxy container +### Squid container (with auth proxy) -The API proxy sidecar receives **real credentials** and routing configuration: +The unified Squid container receives **real credentials** when `--enable-api-proxy` is used: | Variable | Value | When set | Description | |----------|-------|----------|-------------| | `OPENAI_API_KEY` | Real API key | `--enable-api-proxy` and env set | OpenAI API key (injected into requests) | | `ANTHROPIC_API_KEY` | Real API key | `--enable-api-proxy` and env set | Anthropic API key (injected into requests) | | `COPILOT_GITHUB_TOKEN` | Real token | `--enable-api-proxy` and env set | GitHub Copilot token (injected into requests) | -| `HTTP_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid for domain filtering | -| `HTTPS_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid for domain filtering | +| `HTTP_PROXY` | `http://localhost:3128` | Auth proxy enabled | Routes auth proxy traffic through Squid | +| `HTTPS_PROXY` | `http://localhost:3128` | Auth proxy enabled | Routes auth proxy traffic through Squid | -:::danger[Real credentials in api-proxy] -The api-proxy container holds **real, unredacted credentials**. These are used to authenticate requests to LLM providers. This container is isolated from the agent and has all capabilities dropped for security. +:::danger[Real credentials in Squid container] +The Squid container holds **real, unredacted credentials** when `--enable-api-proxy` is enabled. The Node.js auth proxy runs as the non-root `proxy` user with `no-new-privileges` security option for defense in depth. ::: ### Agent container @@ -136,19 +134,18 @@ The agent container receives **redacted placeholders** and proxy URLs: | Variable | Value | When set | Description | |----------|-------|----------|-------------| -| `OPENAI_BASE_URL` | `http://172.30.0.30:10000/v1` | `OPENAI_API_KEY` provided to host | Redirects OpenAI SDK to proxy | -| `ANTHROPIC_BASE_URL` | `http://172.30.0.30:10001` | `ANTHROPIC_API_KEY` provided to host | Redirects Anthropic SDK to proxy | +| `OPENAI_BASE_URL` | `http://172.30.0.10:10000/v1` | `OPENAI_API_KEY` provided to host | Redirects OpenAI SDK to proxy | +| `ANTHROPIC_BASE_URL` | `http://172.30.0.10:10001` | `ANTHROPIC_API_KEY` provided to host | Redirects Anthropic SDK to proxy | | `ANTHROPIC_AUTH_TOKEN` | `placeholder-token-for-credential-isolation` | `ANTHROPIC_API_KEY` provided to host | Placeholder token (real auth via BASE_URL) | | `CLAUDE_CODE_API_KEY_HELPER` | `/usr/local/bin/get-claude-key.sh` | `ANTHROPIC_API_KEY` provided to host | Helper script for Claude Code CLI | -| `COPILOT_API_URL` | `http://172.30.0.30:10002` | `COPILOT_GITHUB_TOKEN` provided to host | Redirects Copilot CLI to proxy | +| `COPILOT_API_URL` | `http://172.30.0.10:10002` | `COPILOT_GITHUB_TOKEN` provided to host | Redirects Copilot CLI to proxy | | `COPILOT_TOKEN` | `placeholder-token-for-credential-isolation` | `COPILOT_GITHUB_TOKEN` provided to host | Placeholder token (real auth via API_URL) | | `COPILOT_GITHUB_TOKEN` | `placeholder-token-for-credential-isolation` | `COPILOT_GITHUB_TOKEN` provided to host | Placeholder token protected by one-shot-token | -| `OPENAI_API_KEY` | Not set | `--enable-api-proxy` | Excluded from agent (held in api-proxy) | -| `ANTHROPIC_API_KEY` | Not set | `--enable-api-proxy` | Excluded from agent (held in api-proxy) | +| `OPENAI_API_KEY` | Not set | `--enable-api-proxy` | Excluded from agent (held in Squid container) | +| `ANTHROPIC_API_KEY` | Not set | `--enable-api-proxy` | Excluded from agent (held in Squid container) | | `HTTP_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid proxy | | `HTTPS_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid proxy | -| `NO_PROXY` | `localhost,127.0.0.1,172.30.0.30` | `--enable-api-proxy` | Bypass proxy for localhost and api-proxy | -| `AWF_API_PROXY_IP` | `172.30.0.30` | `--enable-api-proxy` | Used by iptables setup script | +| `NO_PROXY` | `localhost,127.0.0.1,172.30.0.10` | `--enable-api-proxy` | Bypass proxy for localhost and Squid IP | | `AWF_ONE_SHOT_TOKENS` | `COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,...` | Always | Tokens protected by one-shot-token library | :::tip[Placeholder tokens] @@ -176,51 +173,54 @@ You don't need to change any agent code. The SDKs automatically read `*_BASE_URL ### Credential isolation -API keys are held in the sidecar container, not the agent: +API keys are held in the Squid container, not the agent: - Agent code cannot read API keys from environment variables - A compromised agent cannot exfiltrate credentials - Keys are not exposed in the agent container's stdout/stderr logs +- Node.js auth proxy runs as non-root `proxy` user :::danger[Protect host credentials] -API keys are stored in the sidecar container's environment and in the Docker Compose configuration on disk. Protect the host filesystem and configuration accordingly. Only non-sensitive key prefixes are logged for debugging. +API keys are stored in the Squid container's environment and in the Docker Compose configuration on disk. Protect the host filesystem and configuration accordingly. Only non-sensitive key prefixes are logged for debugging. ::: ### Network isolation The proxy enforces domain-level egress control: -- The agent can only reach the API proxy IP (`172.30.0.30`) for API calls -- The sidecar routes all traffic through Squid proxy +- The agent can only reach the Squid IP (`172.30.0.10`) for API calls +- The auth proxy routes all traffic through Squid internally - Squid enforces the domain whitelist (L7 filtering) - iptables rules prevent the agent from bypassing the proxy -### Resource limits +### Container hardening -The sidecar has strict resource constraints: -- 512 MB memory limit -- 100 process limit -- All capabilities dropped +The unified Squid container has strict security constraints: +- 1 GB memory limit (Squid + Node.js) +- 200 process limit - `no-new-privileges` security option +- Unnecessary capabilities dropped +- Node.js auth proxy runs as non-root `proxy` user ## How it works ### 1. Container startup When you pass `--enable-api-proxy`: -1. AWF starts a Node.js API proxy at `172.30.0.30` -2. API keys are passed to the sidecar via environment variables -3. `HTTP_PROXY`/`HTTPS_PROXY` in the sidecar are configured to route through Squid -4. The agent container waits for the sidecar health check to pass +1. AWF configures the Squid container with API keys in its environment +2. The Squid entrypoint starts the Node.js auth proxy as non-root `proxy` user +3. The Squid entrypoint starts Squid in background +4. Docker healthcheck verifies both Squid (port 3128) and auth proxy (port 10000) +5. The agent container waits for the combined health check to pass ### 2. Request flow ``` Agent Code - ↓ (HTTP request to 172.30.0.30:10000/v1) -Node.js API Proxy + ↓ (HTTP request to 172.30.0.10:10000/v1) +Node.js Auth Proxy (inside Squid container) ↓ (strips client auth headers) ↓ (injects Authorization: Bearer $OPENAI_API_KEY) - ↓ (routes via HTTPS_PROXY to Squid) -Squid Proxy + ↓ (routes via localhost:3128 to Squid) +Squid Proxy (same container) ↓ (enforces domain whitelist) ↓ (TLS connection to api.openai.com) OpenAI API @@ -264,20 +264,22 @@ sudo awf --enable-api-proxy [OPTIONS] -- COMMAND ### Container configuration -The sidecar container: -- **Image**: `ghcr.io/github/gh-aw-firewall/api-proxy:latest` -- **Base**: `node:22-alpine` -- **Network**: `awf-net` at `172.30.0.30` -- **Ports**: 10000 (OpenAI), 10001 (Anthropic), 10002 (GitHub Copilot) -- **Proxy**: Routes via Squid at `http://172.30.0.10:3128` +The unified Squid container (with auth proxy): +- **Image**: `ghcr.io/github/gh-aw-firewall/squid:latest` +- **Base**: `ubuntu/squid:latest` with Node.js +- **Network**: `awf-net` at `172.30.0.10` +- **Ports**: 3128 (Squid), 10000 (OpenAI), 10001 (Anthropic), 10002 (GitHub Copilot) +- **Auth proxy routes via**: localhost Squid at `http://localhost:3128` ### Health check -Docker healthcheck on the `/health` endpoint (port 10000): +Docker healthcheck verifies both services: +- Squid: `nc -z localhost 3128` +- Auth proxy: `curl -sf http://localhost:10000/health` - **Interval**: 5s - **Timeout**: 3s - **Retries**: 5 -- **Start period**: 5s +- **Start period**: 10s ## Troubleshooting @@ -296,18 +298,18 @@ export OPENAI_API_KEY="sk-..." export ANTHROPIC_API_KEY="sk-ant-..." ``` -### Sidecar health check failing +### Health check failing -Check if the API proxy container started: +Check if the Squid container started: ```bash -docker ps | grep awf-api-proxy +docker ps | grep awf-squid ``` -View API proxy logs: +View Squid container logs (includes auth proxy output): ```bash -docker logs awf-api-proxy +docker logs awf-squid ``` ### API requests timing out @@ -328,7 +330,7 @@ docker exec awf-squid cat /var/log/squid/access.log | grep DENIED ## Limitations -- Only supports OpenAI and Anthropic APIs +- Only supports OpenAI, Anthropic, and GitHub Copilot APIs - Keys must be set as environment variables (not file-based) - No support for Azure OpenAI endpoints - No request/response logging (by design, for security) diff --git a/scripts/ci/cleanup.sh b/scripts/ci/cleanup.sh index ae59652e6..48a1d25c9 100755 --- a/scripts/ci/cleanup.sh +++ b/scripts/ci/cleanup.sh @@ -11,8 +11,9 @@ echo "Cleaning up awf resources" echo "===========================================" # First, explicitly remove containers by name (handles orphaned containers) +# Note: awf-api-proxy is included for backward compatibility with older versions echo "Removing awf containers by name..." -docker rm -f awf-squid awf-agent 2>/dev/null || true +docker rm -f awf-squid awf-agent awf-api-proxy 2>/dev/null || true # Cleanup diagnostic test containers echo "Stopping docker compose services..." diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index e61bc6230..3a16fdb20 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -1,8 +1,8 @@ import { WrapperConfig } from './types'; export interface WorkflowDependencies { - ensureFirewallNetwork: () => Promise<{ squidIp: string; agentIp: string; proxyIp: string; subnet: string }>; - setupHostIptables: (squidIp: string, port: number, dnsServers: string[], apiProxyIp?: string) => Promise; + ensureFirewallNetwork: () => Promise<{ squidIp: string; agentIp: string; subnet: string }>; + setupHostIptables: (squidIp: string, port: number, dnsServers: string[], apiProxyEnabled?: boolean) => Promise; writeConfigs: (config: WrapperConfig) => Promise; startContainers: (workDir: string, allowedDomains: string[], proxyLogsDir?: string, skipPull?: boolean) => Promise; runAgentCommand: ( @@ -43,10 +43,10 @@ export async function runMainWorkflow( logger.info('Setting up host-level firewall network and iptables rules...'); const networkConfig = await dependencies.ensureFirewallNetwork(); const dnsServers = config.dnsServers || ['8.8.8.8', '8.8.4.4']; - // When API proxy is enabled, allow agent→sidecar traffic at the host level. - // The sidecar itself routes through Squid, so domain whitelisting is still enforced. - const apiProxyIp = config.enableApiProxy ? networkConfig.proxyIp : undefined; - await dependencies.setupHostIptables(networkConfig.squidIp, 3128, dnsServers, apiProxyIp); + // When API proxy is enabled, allow agent→auth proxy traffic at the host level. + // The auth proxy is embedded in the Squid container and routes through Squid internally. + const apiProxyEnabled = config.enableApiProxy || false; + await dependencies.setupHostIptables(networkConfig.squidIp, 3128, dnsServers, apiProxyEnabled); onHostIptablesSetup?.(); // Step 1: Write configuration files diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index d9ce8fe0e..2817d12f9 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -1435,205 +1435,161 @@ describe('docker-manager', () => { }); }); - describe('API proxy sidecar', () => { - const mockNetworkConfigWithProxy = { - ...mockNetworkConfig, - proxyIp: '172.30.0.30', - }; - - it('should not include api-proxy service when enableApiProxy is false', () => { - const result = generateDockerCompose(mockConfig, mockNetworkConfigWithProxy); + describe('Unified API proxy (embedded in Squid container)', () => { + it('should not include separate api-proxy service when enableApiProxy is false', () => { + const result = generateDockerCompose(mockConfig, mockNetworkConfig); expect(result.services['api-proxy']).toBeUndefined(); }); - it('should not include api-proxy service when enableApiProxy is true but no proxyIp', () => { + it('should not include separate api-proxy service even when enableApiProxy is true', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; const result = generateDockerCompose(configWithProxy, mockNetworkConfig); + // No separate api-proxy service - auth proxy is embedded in Squid expect(result.services['api-proxy']).toBeUndefined(); + expect(Object.keys(result.services)).toEqual(['squid-proxy', 'agent']); }); - it('should include api-proxy service when enableApiProxy is true with OpenAI key', () => { + it('should pass API keys to Squid container when enableApiProxy is true with OpenAI key', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-openai-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - expect(result.services['api-proxy']).toBeDefined(); - const proxy = result.services['api-proxy']; - expect(proxy.container_name).toBe('awf-api-proxy'); - expect((proxy.networks as any)['awf-net'].ipv4_address).toBe('172.30.0.30'); + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); + const squid = result.services['squid-proxy']; + const env = squid.environment as Record; + expect(env.OPENAI_API_KEY).toBe('sk-test-openai-key'); }); - it('should include api-proxy service when enableApiProxy is true with Anthropic key', () => { + it('should pass API keys to Squid container when enableApiProxy is true with Anthropic key', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - expect(result.services['api-proxy']).toBeDefined(); - const proxy = result.services['api-proxy']; - expect(proxy.container_name).toBe('awf-api-proxy'); + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); + const squid = result.services['squid-proxy']; + const env = squid.environment as Record; + expect(env.ANTHROPIC_API_KEY).toBe('sk-ant-test-key'); }); - it('should include api-proxy service with both keys', () => { + it('should pass both keys to Squid container', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-openai-key', anthropicApiKey: 'sk-ant-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - expect(result.services['api-proxy']).toBeDefined(); - const proxy = result.services['api-proxy']; - const env = proxy.environment as Record; + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); + const squid = result.services['squid-proxy']; + const env = squid.environment as Record; expect(env.OPENAI_API_KEY).toBe('sk-test-openai-key'); expect(env.ANTHROPIC_API_KEY).toBe('sk-ant-test-key'); }); - it('should only pass OpenAI key when only OpenAI key is provided', () => { + it('should only pass OpenAI key to Squid when only OpenAI key is provided', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-openai-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - const env = proxy.environment as Record; + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); + const squid = result.services['squid-proxy']; + const env = squid.environment as Record; expect(env.OPENAI_API_KEY).toBe('sk-test-openai-key'); expect(env.ANTHROPIC_API_KEY).toBeUndefined(); }); - it('should only pass Anthropic key when only Anthropic key is provided', () => { + it('should only pass Anthropic key to Squid when only Anthropic key is provided', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - const env = proxy.environment as Record; + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); + const squid = result.services['squid-proxy']; + const env = squid.environment as Record; expect(env.ANTHROPIC_API_KEY).toBe('sk-ant-test-key'); expect(env.OPENAI_API_KEY).toBeUndefined(); }); - it('should use GHCR image by default', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', buildLocal: false }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - expect(proxy.image).toBe('ghcr.io/github/gh-aw-firewall/api-proxy:latest'); - expect(proxy.build).toBeUndefined(); - }); - - it('should build locally when buildLocal is true', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', buildLocal: true }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - expect(proxy.build).toBeDefined(); - expect((proxy.build as any).context).toContain('containers/api-proxy'); - expect(proxy.image).toBeUndefined(); - }); - - it('should use custom registry and tag', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', buildLocal: false, imageRegistry: 'my-registry.com', imageTag: 'v1.0.0' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - expect(proxy.image).toBe('my-registry.com/api-proxy:v1.0.0'); - }); - - it('should configure healthcheck for api-proxy', () => { + it('should configure combined healthcheck for Squid and auth proxy', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - expect(proxy.healthcheck).toBeDefined(); - expect((proxy.healthcheck as any).test).toEqual(['CMD', 'curl', '-f', 'http://localhost:10000/health']); - }); - - it('should drop all capabilities', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - expect(proxy.cap_drop).toEqual(['ALL']); - expect(proxy.security_opt).toContain('no-new-privileges:true'); + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); + const squid = result.services['squid-proxy']; + expect(squid.healthcheck).toBeDefined(); + // Combined healthcheck: Squid (nc) AND auth proxy (curl /health) + expect((squid.healthcheck as any).test).toEqual(['CMD-SHELL', 'nc -z localhost 3128 && curl -sf http://localhost:10000/health']); }); - it('should set resource limits', () => { + it('should have security hardening on Squid container', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - expect(proxy.mem_limit).toBe('512m'); - expect(proxy.memswap_limit).toBe('512m'); - expect(proxy.pids_limit).toBe(100); - expect(proxy.cpu_shares).toBe(512); + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); + const squid = result.services['squid-proxy']; + expect(squid.security_opt).toContain('no-new-privileges:true'); + expect(squid.mem_limit).toBe('1g'); + expect(squid.memswap_limit).toBe('1g'); + expect(squid.pids_limit).toBe(200); + expect(squid.cpu_shares).toBe(1024); }); - it('should update agent depends_on to wait for api-proxy', () => { + it('should not add agent depends_on for api-proxy (only squid-proxy)', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); const agent = result.services.agent; const dependsOn = agent.depends_on as { [key: string]: { condition: string } }; - expect(dependsOn['api-proxy']).toBeDefined(); - expect(dependsOn['api-proxy'].condition).toBe('service_healthy'); + expect(dependsOn['squid-proxy']).toBeDefined(); + expect(dependsOn['api-proxy']).toBeUndefined(); }); - it('should set OPENAI_BASE_URL in agent when OpenAI key is provided', () => { + it('should set OPENAI_BASE_URL in agent pointing to squidIp when OpenAI key is provided', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); const agent = result.services.agent; const env = agent.environment as Record; - expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000/v1'); - }); - - it('should configure HTTP_PROXY and HTTPS_PROXY in api-proxy to route through Squid', () => { - const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); - const proxy = result.services['api-proxy']; - const env = proxy.environment as Record; - expect(env.HTTP_PROXY).toBe('http://172.30.0.10:3128'); - expect(env.HTTPS_PROXY).toBe('http://172.30.0.10:3128'); + expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.10:10000/v1'); }); - it('should set ANTHROPIC_BASE_URL in agent when Anthropic key is provided', () => { + it('should set ANTHROPIC_BASE_URL in agent pointing to squidIp when Anthropic key is provided', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); const agent = result.services.agent; const env = agent.environment as Record; - expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.30:10001'); + expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.10:10001'); expect(env.ANTHROPIC_AUTH_TOKEN).toBe('placeholder-token-for-credential-isolation'); expect(env.CLAUDE_CODE_API_KEY_HELPER).toBe('/usr/local/bin/get-claude-key.sh'); }); it('should set both ANTHROPIC_BASE_URL and OPENAI_BASE_URL when both keys are provided', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-openai-key', anthropicApiKey: 'sk-ant-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); const agent = result.services.agent; const env = agent.environment as Record; - expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000/v1'); - expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.30:10001'); + expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.10:10000/v1'); + expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.10:10001'); expect(env.ANTHROPIC_AUTH_TOKEN).toBe('placeholder-token-for-credential-isolation'); expect(env.CLAUDE_CODE_API_KEY_HELPER).toBe('/usr/local/bin/get-claude-key.sh'); }); it('should not set OPENAI_BASE_URL in agent when only Anthropic key is provided', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); const agent = result.services.agent; const env = agent.environment as Record; expect(env.OPENAI_BASE_URL).toBeUndefined(); - expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.30:10001'); + expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.10:10001'); expect(env.ANTHROPIC_AUTH_TOKEN).toBe('placeholder-token-for-credential-isolation'); expect(env.CLAUDE_CODE_API_KEY_HELPER).toBe('/usr/local/bin/get-claude-key.sh'); }); it('should set OPENAI_BASE_URL and not set ANTHROPIC_BASE_URL when only OpenAI key is provided', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); const agent = result.services.agent; const env = agent.environment as Record; expect(env.ANTHROPIC_BASE_URL).toBeUndefined(); - expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000/v1'); + expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.10:10000/v1'); }); - it('should set AWF_API_PROXY_IP in agent environment', () => { + it('should not set AWF_API_PROXY_IP in agent environment (no separate sidecar)', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); const agent = result.services.agent; const env = agent.environment as Record; - expect(env.AWF_API_PROXY_IP).toBe('172.30.0.30'); + expect(env.AWF_API_PROXY_IP).toBeUndefined(); }); - it('should set NO_PROXY to include api-proxy IP', () => { + it('should set NO_PROXY to include squid IP', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); const agent = result.services.agent; const env = agent.environment as Record; - expect(env.NO_PROXY).toContain('172.30.0.30'); - expect(env.no_proxy).toContain('172.30.0.30'); + expect(env.NO_PROXY).toContain('172.30.0.10'); + expect(env.no_proxy).toContain('172.30.0.10'); }); it('should set CLAUDE_CODE_API_KEY_HELPER when Anthropic key is provided', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); const agent = result.services.agent; const env = agent.environment as Record; expect(env.CLAUDE_CODE_API_KEY_HELPER).toBe('/usr/local/bin/get-claude-key.sh'); @@ -1641,25 +1597,24 @@ describe('docker-manager', () => { it('should not set CLAUDE_CODE_API_KEY_HELPER when only OpenAI key is provided', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); const agent = result.services.agent; const env = agent.environment as Record; expect(env.CLAUDE_CODE_API_KEY_HELPER).toBeUndefined(); }); it('should not leak ANTHROPIC_API_KEY to agent when api-proxy is enabled', () => { - // Simulate the key being in process.env (as it would be in real usage) const origKey = process.env.ANTHROPIC_API_KEY; process.env.ANTHROPIC_API_KEY = 'sk-ant-secret-key'; try { const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-secret-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); const agent = result.services.agent; const env = agent.environment as Record; - // Agent should NOT have the raw API key — only the sidecar gets it + // Agent should NOT have the raw API key — only the Squid container gets it expect(env.ANTHROPIC_API_KEY).toBeUndefined(); - // Agent should have the BASE_URL to reach the sidecar instead - expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.30:10001'); + // Agent should have the BASE_URL to reach the unified proxy instead + expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.10:10001'); // Agent should have placeholder token for Claude Code compatibility expect(env.ANTHROPIC_AUTH_TOKEN).toBe('placeholder-token-for-credential-isolation'); } finally { @@ -1672,18 +1627,17 @@ describe('docker-manager', () => { }); it('should not leak OPENAI_API_KEY to agent when api-proxy is enabled', () => { - // Simulate the key being in process.env (as it would be in real usage) const origKey = process.env.OPENAI_API_KEY; process.env.OPENAI_API_KEY = 'sk-secret-key'; try { const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-secret-key' }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); const agent = result.services.agent; const env = agent.environment as Record; - // Agent should NOT have the raw API key — only the sidecar gets it + // Agent should NOT have the raw API key — only the Squid container gets it expect(env.OPENAI_API_KEY).toBeUndefined(); - // Agent should have OPENAI_BASE_URL to proxy through sidecar - expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000/v1'); + // Agent should have OPENAI_BASE_URL to proxy through unified proxy + expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.10:10000/v1'); } finally { if (origKey !== undefined) { process.env.OPENAI_API_KEY = origKey; @@ -1694,19 +1648,17 @@ describe('docker-manager', () => { }); it('should not leak CODEX_API_KEY to agent when api-proxy is enabled with envAll', () => { - // Simulate the key being in process.env AND envAll enabled - // CODEX_API_KEY is now excluded when api-proxy is enabled for credential isolation const origKey = process.env.CODEX_API_KEY; process.env.CODEX_API_KEY = 'sk-codex-secret'; try { const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test', envAll: true }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); const agent = result.services.agent; const env = agent.environment as Record; // CODEX_API_KEY should NOT be passed to agent when api-proxy is enabled expect(env.CODEX_API_KEY).toBeUndefined(); // OPENAI_BASE_URL should be set when api-proxy is enabled with openaiApiKey - expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000/v1'); + expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.10:10000/v1'); } finally { if (origKey !== undefined) { process.env.CODEX_API_KEY = origKey; @@ -1717,18 +1669,17 @@ describe('docker-manager', () => { }); it('should not leak OPENAI_API_KEY to agent when api-proxy is enabled with envAll', () => { - // Simulate envAll scenario (smoke-codex uses --env-all) const origKey = process.env.OPENAI_API_KEY; process.env.OPENAI_API_KEY = 'sk-openai-secret'; try { const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-openai-secret', envAll: true }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); const agent = result.services.agent; const env = agent.environment as Record; // Even with envAll, agent should NOT have OPENAI_API_KEY when api-proxy is enabled expect(env.OPENAI_API_KEY).toBeUndefined(); - // Agent should have OPENAI_BASE_URL to proxy through sidecar - expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000/v1'); + // Agent should have OPENAI_BASE_URL to proxy through unified proxy + expect(env.OPENAI_BASE_URL).toBe('http://172.30.0.10:10000/v1'); } finally { if (origKey !== undefined) { process.env.OPENAI_API_KEY = origKey; @@ -1743,12 +1694,12 @@ describe('docker-manager', () => { process.env.ANTHROPIC_API_KEY = 'sk-ant-secret'; try { const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-secret', envAll: true }; - const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const result = generateDockerCompose(configWithProxy, mockNetworkConfig); const agent = result.services.agent; const env = agent.environment as Record; // Even with envAll, agent should NOT have ANTHROPIC_API_KEY when api-proxy is enabled expect(env.ANTHROPIC_API_KEY).toBeUndefined(); - expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.30:10001'); + expect(env.ANTHROPIC_BASE_URL).toBe('http://172.30.0.10:10001'); // But should have placeholder token for Claude Code compatibility expect(env.ANTHROPIC_AUTH_TOKEN).toBe('placeholder-token-for-credential-isolation'); } finally { @@ -2041,7 +1992,7 @@ describe('docker-manager', () => { expect(mockExecaFn).toHaveBeenCalledWith( 'docker', - ['rm', '-f', 'awf-squid', 'awf-agent'], + ['rm', '-f', 'awf-squid', 'awf-agent', 'awf-api-proxy'], { reject: false } ); }); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 6f6e926c5..cf4a2c8af 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -290,8 +290,37 @@ export function generateDockerCompose( 'AUDIT_WRITE', // No audit log writing 'SETFCAP', // No setting file capabilities ], + // Security hardening: prevent privilege escalation and set resource limits + security_opt: ['no-new-privileges:true'], + mem_limit: '1g', // Squid (~512m) + Node.js auth proxy (~512m) + memswap_limit: '1g', // No swap + pids_limit: 200, // Squid + Node.js processes + cpu_shares: 1024, // Default CPU share }; + // When API proxy is enabled, add API keys and auth proxy config to the Squid container + // The auth proxy Node.js server runs alongside Squid in the same container + if (config.enableApiProxy) { + // Pass API keys as environment variables to the unified Squid container + squidService.environment = { + ...(config.openaiApiKey && { OPENAI_API_KEY: config.openaiApiKey }), + ...(config.anthropicApiKey && { ANTHROPIC_API_KEY: config.anthropicApiKey }), + ...(config.copilotGithubToken && { COPILOT_GITHUB_TOKEN: config.copilotGithubToken }), + }; + + // Mount API proxy log directory + squidVolumes.push(`${apiProxyLogsPath}:/var/log/api-proxy:rw`); + + // Update healthcheck to verify both Squid AND the auth proxy are running + squidService.healthcheck = { + test: ['CMD-SHELL', `nc -z localhost 3128 && curl -sf http://localhost:${API_PROXY_HEALTH_PORT}/health`], + interval: '5s', + timeout: '3s', + retries: 5, + start_period: '10s', + }; + } + // Only enable host.docker.internal when explicitly requested via --enable-host-access // This allows containers to reach services on the host machine (e.g., MCP gateways) // Security note: When combined with allowing host.docker.internal domain, @@ -327,7 +356,7 @@ export function generateDockerCompose( ]); // When api-proxy is enabled, exclude API keys from agent environment - // (they are held securely in the api-proxy sidecar instead) + // (they are held securely in the unified proxy container instead) if (config.enableApiProxy) { EXCLUDED_ENV_VARS.add('OPENAI_API_KEY'); EXCLUDED_ENV_VARS.add('OPENAI_KEY'); @@ -375,13 +404,14 @@ export function generateDockerCompose( environment.no_proxy = environment.NO_PROXY; } - // When API proxy is enabled, bypass HTTP_PROXY for the api-proxy IP - // so the agent can reach the sidecar directly without going through Squid - if (config.enableApiProxy && networkConfig.proxyIp) { + // When API proxy is enabled, bypass HTTP_PROXY for the Squid IP + // so the agent can reach the auth proxy ports (10000-10002) directly + // The auth proxy is now embedded in the Squid container at the same IP + if (config.enableApiProxy) { if (environment.NO_PROXY) { - environment.NO_PROXY += `,${networkConfig.proxyIp}`; + environment.NO_PROXY += `,${networkConfig.squidIp}`; } else { - environment.NO_PROXY = `localhost,127.0.0.1,${networkConfig.proxyIp}`; + environment.NO_PROXY = `localhost,127.0.0.1,${networkConfig.squidIp}`; } environment.no_proxy = environment.NO_PROXY; } @@ -432,7 +462,7 @@ export function generateDockerCompose( if (process.env.GH_TOKEN) environment.GH_TOKEN = process.env.GH_TOKEN; if (process.env.GITHUB_PERSONAL_ACCESS_TOKEN) environment.GITHUB_PERSONAL_ACCESS_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN; // API keys for LLM providers — skip when api-proxy is enabled - // (the sidecar holds the keys; the agent uses *_BASE_URL instead) + // (the unified proxy holds the keys; the agent uses *_BASE_URL instead) if (process.env.OPENAI_API_KEY && !config.enableApiProxy) environment.OPENAI_API_KEY = process.env.OPENAI_API_KEY; if (process.env.CODEX_API_KEY && !config.enableApiProxy) environment.CODEX_API_KEY = process.env.CODEX_API_KEY; if (process.env.ANTHROPIC_API_KEY && !config.enableApiProxy) environment.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; @@ -952,85 +982,25 @@ export function generateDockerCompose( agentService.image = agentImage; } - // API Proxy sidecar service (Node.js) - optionally deployed + // Build services map (2 containers: squid-proxy + agent) const services: Record = { 'squid-proxy': squidService, 'agent': agentService, }; - // Add Node.js API proxy sidecar if enabled - if (config.enableApiProxy && networkConfig.proxyIp) { - const proxyService: any = { - container_name: 'awf-api-proxy', - networks: { - 'awf-net': { - ipv4_address: networkConfig.proxyIp, - }, - }, - volumes: [ - // Mount log directory for api-proxy logs - `${apiProxyLogsPath}:/var/log/api-proxy:rw`, - ], - environment: { - // Pass API keys securely to sidecar (not visible to agent) - ...(config.openaiApiKey && { OPENAI_API_KEY: config.openaiApiKey }), - ...(config.anthropicApiKey && { ANTHROPIC_API_KEY: config.anthropicApiKey }), - ...(config.copilotGithubToken && { COPILOT_GITHUB_TOKEN: config.copilotGithubToken }), - // Route through Squid to respect domain whitelisting - HTTP_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`, - HTTPS_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`, - }, - healthcheck: { - test: ['CMD', 'curl', '-f', `http://localhost:${API_PROXY_HEALTH_PORT}/health`], - interval: '5s', - timeout: '3s', - retries: 5, - start_period: '5s', - }, - // Security hardening: Drop all capabilities - cap_drop: ['ALL'], - security_opt: [ - 'no-new-privileges:true', - ], - // Resource limits to prevent DoS attacks - mem_limit: '512m', - memswap_limit: '512m', - pids_limit: 100, - cpu_shares: 512, - }; - - // Use GHCR image or build locally - if (useGHCR) { - proxyService.image = `${registry}/api-proxy:${tag}`; - } else { - proxyService.build = { - context: path.join(projectRoot, 'containers/api-proxy'), - dockerfile: 'Dockerfile', - }; - } - - services['api-proxy'] = proxyService; - - // Update agent dependencies to wait for api-proxy - agentService.depends_on['api-proxy'] = { - condition: 'service_healthy', - }; - - // Set environment variables in agent to use the proxy - // AWF_API_PROXY_IP is used by setup-iptables.sh to allow agent→api-proxy traffic - // Use IP address instead of hostname for BASE_URLs since Docker DNS may not resolve - // container names in chroot mode - environment.AWF_API_PROXY_IP = networkConfig.proxyIp; + // Set agent environment variables for API proxy (auth proxy embedded in Squid container) + // Use squidIp instead of separate proxyIp since auth proxy runs inside Squid container + if (config.enableApiProxy) { if (config.openaiApiKey) { - environment.OPENAI_BASE_URL = `http://${networkConfig.proxyIp}:${API_PROXY_PORTS.OPENAI}/v1`; - logger.debug(`OpenAI API will be proxied through sidecar at http://${networkConfig.proxyIp}:${API_PROXY_PORTS.OPENAI}/v1`); + environment.OPENAI_BASE_URL = `http://${networkConfig.squidIp}:${API_PROXY_PORTS.OPENAI}/v1`; + logger.debug(`OpenAI API will be proxied through unified proxy at http://${networkConfig.squidIp}:${API_PROXY_PORTS.OPENAI}/v1`); } if (config.anthropicApiKey) { - environment.ANTHROPIC_BASE_URL = `http://${networkConfig.proxyIp}:${API_PROXY_PORTS.ANTHROPIC}`; - logger.debug(`Anthropic API will be proxied through sidecar at http://${networkConfig.proxyIp}:${API_PROXY_PORTS.ANTHROPIC}`); + environment.ANTHROPIC_BASE_URL = `http://${networkConfig.squidIp}:${API_PROXY_PORTS.ANTHROPIC}`; + logger.debug(`Anthropic API will be proxied through unified proxy at http://${networkConfig.squidIp}:${API_PROXY_PORTS.ANTHROPIC}`); // Set placeholder token for Claude Code CLI compatibility - // Real authentication happens via ANTHROPIC_BASE_URL pointing to api-proxy + // Real authentication happens via ANTHROPIC_BASE_URL pointing to unified proxy environment.ANTHROPIC_AUTH_TOKEN = 'placeholder-token-for-credential-isolation'; logger.debug('ANTHROPIC_AUTH_TOKEN set to placeholder value for credential isolation'); @@ -1040,11 +1010,11 @@ export function generateDockerCompose( logger.debug('Claude Code API key helper configured: /usr/local/bin/get-claude-key.sh'); } if (config.copilotGithubToken) { - environment.COPILOT_API_URL = `http://${networkConfig.proxyIp}:${API_PROXY_PORTS.COPILOT}`; - logger.debug(`GitHub Copilot API will be proxied through sidecar at http://${networkConfig.proxyIp}:${API_PROXY_PORTS.COPILOT}`); + environment.COPILOT_API_URL = `http://${networkConfig.squidIp}:${API_PROXY_PORTS.COPILOT}`; + logger.debug(`GitHub Copilot API will be proxied through unified proxy at http://${networkConfig.squidIp}:${API_PROXY_PORTS.COPILOT}`); // Set placeholder token for GitHub Copilot CLI compatibility - // Real authentication happens via COPILOT_API_URL pointing to api-proxy + // Real authentication happens via COPILOT_API_URL pointing to unified proxy environment.COPILOT_TOKEN = 'placeholder-token-for-credential-isolation'; logger.debug('COPILOT_TOKEN set to placeholder value for credential isolation'); @@ -1052,8 +1022,8 @@ export function generateDockerCompose( // to prevent override by host environment variable } - logger.info('API proxy sidecar enabled - API keys will be held securely in sidecar container'); - logger.info('API proxy will route through Squid to respect domain whitelisting'); + logger.info('API proxy enabled - API keys held securely in unified proxy container'); + logger.info('Auth proxy routes through Squid to respect domain whitelisting'); } return { @@ -1103,9 +1073,9 @@ export async function writeConfigs(config: WrapperConfig): Promise { logger.debug(`Squid logs directory created at: ${squidLogsDir}`); // Create api-proxy logs directory for persistence + // The auth proxy now runs inside the Squid container but still writes logs separately // If proxyLogsDir is specified, write to sibling directory (timeout-safe) // Otherwise, write to workDir/api-proxy-logs (will be moved to /tmp after cleanup) - // Note: API proxy runs as user 'apiproxy' (non-root) const apiProxyLogsDir = config.proxyLogsDir ? path.join(path.dirname(config.proxyLogsDir), 'api-proxy-logs') : path.join(config.workDir, 'api-proxy-logs'); @@ -1169,13 +1139,13 @@ export async function writeConfigs(config: WrapperConfig): Promise { } // Use fixed network configuration (network is created by host-iptables.ts) + // Note: proxyIp (172.30.0.30) is no longer used - auth proxy is now embedded in the Squid container const networkConfig = { subnet: '172.30.0.0/24', squidIp: '172.30.0.10', agentIp: '172.30.0.20', - proxyIp: '172.30.0.30', // Envoy API proxy sidecar }; - logger.debug(`Using network config: ${networkConfig.subnet} (squid: ${networkConfig.squidIp}, agent: ${networkConfig.agentIp}, api-proxy: ${networkConfig.proxyIp})`); + logger.debug(`Using network config: ${networkConfig.subnet} (squid: ${networkConfig.squidIp}, agent: ${networkConfig.agentIp})`); // Copy seccomp profile to work directory for container security @@ -1327,9 +1297,10 @@ export async function startContainers(workDir: string, allowedDomains: string[], // Force remove any existing containers with these names to avoid conflicts // This handles orphaned containers from failed/interrupted previous runs + // Note: awf-api-proxy is included for backward compatibility with older versions logger.debug('Removing any existing containers with conflicting names...'); try { - await execa('docker', ['rm', '-f', 'awf-squid', 'awf-agent'], { + await execa('docker', ['rm', '-f', 'awf-squid', 'awf-agent', 'awf-api-proxy'], { reject: false, }); } catch { diff --git a/src/host-iptables.test.ts b/src/host-iptables.test.ts index 3985423a4..4d03f620e 100644 --- a/src/host-iptables.test.ts +++ b/src/host-iptables.test.ts @@ -36,7 +36,6 @@ describe('host-iptables', () => { subnet: '172.30.0.0/24', squidIp: '172.30.0.10', agentIp: '172.30.0.20', - proxyIp: '172.30.0.30', }); // Should only check if network exists, not create it @@ -61,7 +60,6 @@ describe('host-iptables', () => { subnet: '172.30.0.0/24', squidIp: '172.30.0.10', agentIp: '172.30.0.20', - proxyIp: '172.30.0.30', }); expect(mockedExeca).toHaveBeenCalledWith('docker', ['network', 'inspect', 'awf-net']); diff --git a/src/host-iptables.ts b/src/host-iptables.ts index 5f130b734..dfd1d1560 100644 --- a/src/host-iptables.ts +++ b/src/host-iptables.ts @@ -60,7 +60,6 @@ export async function ensureFirewallNetwork(): Promise<{ subnet: string; squidIp: string; agentIp: string; - proxyIp: string; }> { logger.debug(`Ensuring firewall network '${NETWORK_NAME}' exists...`); @@ -93,7 +92,6 @@ export async function ensureFirewallNetwork(): Promise<{ subnet: NETWORK_SUBNET, squidIp: '172.30.0.10', agentIp: '172.30.0.20', - proxyIp: '172.30.0.30', }; } @@ -161,7 +159,7 @@ async function setupIpv6Chain(bridgeName: string): Promise { * @param squidPort - Port number of the Squid proxy * @param dnsServers - Array of trusted DNS server IP addresses (DNS traffic is ONLY allowed to these servers) */ -export async function setupHostIptables(squidIp: string, squidPort: number, dnsServers: string[], apiProxyIp?: string): Promise { +export async function setupHostIptables(squidIp: string, squidPort: number, dnsServers: string[], apiProxyEnabled?: boolean): Promise { logger.info('Setting up host-level iptables rules...'); // Get the bridge interface name @@ -442,16 +440,16 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS '-j', 'ACCEPT', ]); - // 5b. Allow traffic to API proxy sidecar (when enabled) - // Allow all API proxy ports (OpenAI, Anthropic, GitHub Copilot). - // The sidecar itself routes through Squid, so domain whitelisting is still enforced. - if (apiProxyIp) { + // 5b. Allow traffic to API auth proxy ports (when enabled) + // The auth proxy is embedded in the Squid container, so allow ports 10000-10002 to Squid IP. + // The auth proxy routes through Squid internally, so domain whitelisting is still enforced. + if (apiProxyEnabled) { const minPort = Math.min(API_PROXY_PORTS.OPENAI, API_PROXY_PORTS.ANTHROPIC, API_PROXY_PORTS.COPILOT); const maxPort = Math.max(API_PROXY_PORTS.OPENAI, API_PROXY_PORTS.ANTHROPIC, API_PROXY_PORTS.COPILOT); - logger.debug(`Allowing traffic to API proxy sidecar at ${apiProxyIp}:${minPort}-${maxPort}`); + logger.debug(`Allowing traffic to API auth proxy at ${squidIp}:${minPort}-${maxPort}`); await execa('iptables', [ '-t', 'filter', '-A', CHAIN_NAME, - '-p', 'tcp', '-d', apiProxyIp, '--dport', `${minPort}:${maxPort}`, + '-p', 'tcp', '-d', squidIp, '--dport', `${minPort}:${maxPort}`, '-j', 'ACCEPT', ]); } diff --git a/tests/integration/api-proxy.test.ts b/tests/integration/api-proxy.test.ts index a231a7f53..4593b52e1 100644 --- a/tests/integration/api-proxy.test.ts +++ b/tests/integration/api-proxy.test.ts @@ -1,8 +1,8 @@ /** - * API Proxy Sidecar Integration Tests + * API Proxy Integration Tests (Unified Architecture) * - * Tests that the --enable-api-proxy flag correctly starts the API proxy sidecar - * and routes requests through Squid. + * Tests that the --enable-api-proxy flag correctly starts the auth proxy + * inside the unified Squid container and routes requests through Squid. */ /// @@ -11,10 +11,10 @@ import { describe, test, expect, beforeAll, afterAll } from '@jest/globals'; import { createRunner, AwfRunner } from '../fixtures/awf-runner'; import { cleanup } from '../fixtures/cleanup'; -// The API proxy sidecar is at this fixed IP on the awf-net network -const API_PROXY_IP = '172.30.0.30'; +// The auth proxy now runs inside the Squid container at this IP +const API_PROXY_IP = '172.30.0.10'; -describe('API Proxy Sidecar', () => { +describe('API Proxy (Unified Architecture)', () => { let runner: AwfRunner; beforeAll(async () => { @@ -26,7 +26,7 @@ describe('API Proxy Sidecar', () => { await cleanup(false); }); - test('should start api-proxy sidecar with Anthropic key and pass healthcheck', async () => { + test('should start auth proxy with Anthropic key and pass healthcheck', async () => { const result = await runner.runWithSudo( `curl -s http://${API_PROXY_IP}:10001/health`, { @@ -46,7 +46,7 @@ describe('API Proxy Sidecar', () => { expect(result.stdout).toContain('anthropic-proxy'); }, 180000); - test('should start api-proxy sidecar with OpenAI key and pass healthcheck', async () => { + test('should start auth proxy with OpenAI key and pass healthcheck', async () => { const result = await runner.runWithSudo( `curl -s http://${API_PROXY_IP}:10000/health`, { @@ -173,7 +173,7 @@ describe('API Proxy Sidecar', () => { expect(result.stdout).toContain('anthropic-proxy'); }, 180000); - test('should start api-proxy sidecar with Copilot key and pass healthcheck', async () => { + test('should start auth proxy with Copilot key and pass healthcheck', async () => { const result = await runner.runWithSudo( `curl -s http://${API_PROXY_IP}:10002/health`, {