diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 8b9b5f7..26f39e5 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -14,9 +14,50 @@ env: IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/anyllm-proxy jobs: + test: + name: Test container + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + # Build locally (no push). Primes the GHA cache for the build matrix jobs. + - name: Build test image + uses: docker/build-push-action@v6 + with: + context: . + load: true + tags: anyllm-proxy:test + cache-from: type=gha,scope=test-linux/amd64 + cache-to: type=gha,scope=test-linux/amd64,mode=max + + - name: Smoke test (standalone container) + run: | + docker run -d --name proxy-test \ + -e PROXY_OPEN_RELAY=true \ + -p 3000:3000 \ + anyllm-proxy:test + timeout 30 sh -c 'until curl -sf http://localhost:3000/health; do sleep 1; done' + curl -sf http://localhost:3000/health | grep -q '"status":"ok"' + docker stop proxy-test && docker rm proxy-test + + - name: Smoke test (docker compose) + run: | + printf 'PROXY_OPEN_RELAY=true\nWEBUI=1\n' > .env + docker compose up -d + timeout 30 sh -c 'until curl -sf http://localhost:3000/health; do sleep 1; done' + curl -sf http://localhost:3000/health | grep -q '"status":"ok"' + curl -sf http://localhost:3001/admin/health | grep -q '"status":"ok"' + docker compose down -v + build: name: Build (${{ matrix.platform }}) runs-on: ${{ matrix.runner }} + needs: test + if: startsWith(github.ref, 'refs/tags/v') permissions: contents: read strategy: @@ -34,7 +75,6 @@ jobs: - uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub - if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -49,7 +89,7 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha,prefix=sha-,format=short - type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=raw,value=latest,enable=true - name: Build and push by digest id: build @@ -57,21 +97,19 @@ jobs: with: context: . platforms: ${{ matrix.platform }} - push: ${{ github.event_name != 'pull_request' }} - outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} + push: true + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true cache-from: type=gha,scope=${{ matrix.platform }} cache-to: type=gha,scope=${{ matrix.platform }},mode=max labels: ${{ steps.meta.outputs.labels }} - name: Export digest - if: github.event_name != 'pull_request' run: | mkdir -p /tmp/digests digest="${{ steps.build.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - name: Upload digest artifact - if: github.event_name != 'pull_request' uses: actions/upload-artifact@v4 with: name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} @@ -83,7 +121,7 @@ jobs: name: Merge multi-arch manifest runs-on: ubuntu-latest needs: build - if: github.event_name != 'pull_request' + if: startsWith(github.ref, 'refs/tags/v') permissions: contents: read steps: @@ -114,7 +152,7 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha,prefix=sha-,format=short - type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=raw,value=latest,enable=true - name: Create and push multi-arch manifest working-directory: /tmp/digests diff --git a/.gitignore b/.gitignore index 746101f..f1ec594 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ Thumbs.db .env .env.* !.env.example +!.env.example.test # Debug *.pdb diff --git a/CLAUDE.md b/CLAUDE.md index 98f2c1c..875e108 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ All implementation phases are complete. - Audit log: admin config mutations recorded in SQLite `audit_log` table - Spend alerts: webhook notifications at 80% / 95% / 100% of key budget - Model allowlist: per-key policy with exact match and `prefix/*` wildcard -- Admin UI: login form (sessionStorage), virtual keys tab, models tab, request detail view, cost column, feed pause + filter +- Admin UI: requires `--webui` or `--admin` CLI flag to start (or `WEBUI=1`/`ADMIN=1` env via docker-entrypoint.sh); login form (sessionStorage), virtual keys tab, models tab, request detail view, cost column, feed pause + filter - Security hardening: plaintext HTTP startup warning, 1MB admin body limit, CSP header, model name validation - Security fixes (2026-03-30 audit): `AWS_ACCESS_KEY_ID`/`GOOGLE_ACCESS_TOKEN` redacted in env endpoint; admin rate limiter uses sliding window; all audit entries include `source_ip`; OIDC discovery and webhook callbacks use SSRF-safe HTTP client and validate URLs against private IP ranges; CSRF public-route decision documented; non-Unix token file warning already present - Model mapping and lossy-translation warnings @@ -102,6 +102,11 @@ OPENAI_API_KEY=sk-... cargo run -p anyllm_proxy - `OPENAI_BASE_URL`: OpenAI base URL (default: `https://api.openai.com`) - `OPENAI_API_FORMAT`: OpenAI API format: `chat` (default, Chat Completions) or `responses` (Responses API). Only relevant when BACKEND=openai. - `LISTEN_PORT`: Server port (default: `3000`) +- `ADMIN_PORT`: Admin server port (default: `3001`; must differ from `LISTEN_PORT`) +- `ADMIN_BIND`: Admin server bind address (default: `127.0.0.1`; set `0.0.0.0` in Docker) +- `ADMIN_DB_PATH`: SQLite database path (default: `admin.db` in CWD) +- `ADMIN_TOKEN_PATH`: Path for auto-generated admin token file (default: `.admin_token` in CWD) +- `DISABLE_ADMIN`: Set to `1` to force-disable admin UI even when `--webui` flag is passed - `BIG_MODEL`: Backend model for sonnet/opus requests (default: `gpt-4o` for OpenAI, `gemini-2.5-pro` for Vertex/Gemini) - `SMALL_MODEL`: Backend model for haiku requests (default: `gpt-4o-mini` for OpenAI, `gemini-2.5-flash` for Vertex/Gemini) - `RUST_LOG`: Tracing filter (e.g., `info`, `anyllm_proxy=debug`) diff --git a/Dockerfile b/Dockerfile index 2ef4e29..5378628 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 # ── Stage 1: install cargo-chef ────────────────────────────────────────────── -FROM rust:1.85-alpine AS chef +FROM rust:1-alpine AS chef RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static RUN cargo install cargo-chef --locked WORKDIR /app @@ -21,6 +21,7 @@ COPY --from=planner /app/recipe.json recipe.json RUN cargo chef cook --release --recipe-path recipe.json -p anyllm_proxy COPY Cargo.toml Cargo.lock ./ COPY crates crates +COPY assets assets RUN cargo build --release -p anyllm_proxy # ── Stage 4: minimal Alpine runtime ────────────────────────────────────────── diff --git a/crates/client/src/http.rs b/crates/client/src/http.rs index 921167a..d8b28ed 100644 --- a/crates/client/src/http.rs +++ b/crates/client/src/http.rs @@ -91,7 +91,13 @@ pub fn build_http_client(config: &HttpClientConfig) -> Client { #[cfg(feature = "ssrf-protection")] if config.ssrf_protection { - builder = builder.dns_resolver(std::sync::Arc::new(SsrfSafeDnsResolver)); + // Disable redirects in addition to DNS filtering. The DNS resolver only + // intercepts hostname lookups; a redirect to a bare IP (e.g., + // http://169.254.169.254/) bypasses DNS entirely, so the SSRF-safe + // resolver would never be called and the redirect would be followed. + builder = builder + .dns_resolver(std::sync::Arc::new(SsrfSafeDnsResolver)) + .redirect(reqwest::redirect::Policy::none()); } builder.build().expect("failed to build HTTP client") diff --git a/crates/proxy/admin-ui/index.html b/crates/proxy/admin-ui/index.html index 4d1eb5f..cf8e4b0 100644 --- a/crates/proxy/admin-ui/index.html +++ b/crates/proxy/admin-ui/index.html @@ -5,126 +5,167 @@ Proxy Admin
Streams Started
0
-
Completed
0
-
Failed
0
-
Client Disconnects
0
+
Completed
0
+
Failed
0
+
Client Disconnects
0
Operator View
@@ -271,7 +313,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
Environment (requires restart to change)
-
Loading...
+
Loading...
@@ -285,40 +327,40 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
Virtual API Keys
-