From 0e6ff6e490a1a71134f51111c45b4af8bab63110 Mon Sep 17 00:00:00 2001 From: yuanyuanac <121149874+yuanyuanac@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:55:31 +0800 Subject: [PATCH] Harden multitenant gateway --- .github/workflows/build.yml | 28 ++- .gitignore | 2 + API.md | 50 ++--- API_EN.md | 50 ++--- Dockerfile | 7 +- README.md | 298 +++++++++++--------------- README_EN.md | 210 +++++------------- SECURITY.md | 82 +++++++ claude/client.go | 92 ++++---- claude/client_test.go | 47 ++++ cmd/keygen/main.go | 33 +++ config/config.go | 320 +++++++++++++++++++++++++--- config/config_test.go | 104 +++++++++ docker-compose.yml | 10 +- go.mod | 12 +- go.sum | 20 +- handlers/anthropic.go | 16 +- handlers/chat.go | 18 +- handlers/common.go | 128 +++++++++-- handlers/conversation_store.go | 119 ++++++++--- handlers/conversation_store_test.go | 115 ++++++++++ handlers/conversations.go | 24 ++- handlers/errors_test.go | 74 +++++++ handlers/responses.go | 16 +- main.go | 37 ++-- middleware/auth.go | 84 ++++---- middleware/auth_test.go | 96 +++++++++ middleware/rate_limit.go | 76 +++++++ middleware/rate_limit_test.go | 30 +++ middleware/recovery.go | 30 +++ middleware/request.go | 57 +++++ middleware/request_test.go | 91 ++++++++ tenants.example.json | 7 + 33 files changed, 1759 insertions(+), 624 deletions(-) create mode 100644 SECURITY.md create mode 100644 claude/client_test.go create mode 100644 cmd/keygen/main.go create mode 100644 config/config_test.go create mode 100644 handlers/conversation_store_test.go create mode 100644 handlers/errors_test.go create mode 100644 middleware/auth_test.go create mode 100644 middleware/rate_limit.go create mode 100644 middleware/rate_limit_test.go create mode 100644 middleware/recovery.go create mode 100644 middleware/request.go create mode 100644 middleware/request_test.go create mode 100644 tenants.example.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3a0e2bf..b817138 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,8 +12,7 @@ on: workflow_dispatch: permissions: - contents: write - packages: write + contents: read concurrency: group: build-${{ github.ref }} @@ -23,6 +22,22 @@ env: IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/claude2api jobs: + test: + name: Test and vet + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Test with race detector + run: go test -race ./... + - name: Vet + run: go vet ./... + build-binaries: name: Build ${{ matrix.goos }}/${{ matrix.goarch }} runs-on: ubuntu-latest @@ -63,7 +78,9 @@ jobs: CGO_ENABLED: '0' GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} - run: go build -trimpath -ldflags='-s -w' -o dist/${{ matrix.artifact }} . + run: | + mkdir -p dist + go build -trimpath -ldflags='-s -w' -o dist/${{ matrix.artifact }} . - name: Upload artifact uses: actions/upload-artifact@v4 @@ -77,6 +94,8 @@ jobs: needs: build-binaries if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Download artifacts uses: actions/download-artifact@v4 @@ -97,6 +116,9 @@ jobs: docker: name: Build and push Docker image runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 303197a..333d01b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ .env.* captured/ .claude/ +tenants*.json +!tenants.example.json # Binaries *.exe diff --git a/API.md b/API.md index 88881c6..791fe24 100644 --- a/API.md +++ b/API.md @@ -8,19 +8,13 @@ http://127.0.0.1:8080/v1 ``` -所有 `/v1` 下的接口都需要认证。可以使用以下任意一种方式: +所有 `/v1` 下的接口都使用独立代理 API Key 认证: ```http -Authorization: Bearer +Authorization: Bearer ``` -或: - -```http -X-Claude-Cookie: <完整 claude.ai 浏览器 Cookie> -``` - -如果服务端已经配置 `CLAUDE_SESSION_KEY` 或 `CLAUDE_COOKIE`,请求端可以省略对应 Header。 +Bearer 不再接受 Claude sessionKey。上游 sessionKey/Cookie 只能由服务端通过单租户环境变量或 `TENANTS_FILE` 配置;客户端提交浏览器凭据 Header 会被拒绝。 ## 错误格式 @@ -30,7 +24,8 @@ X-Claude-Cookie: <完整 claude.ai 浏览器 Cookie> { "error": { "message": "错误信息", - "type": "invalid_request_error" + "type": "invalid_request_error", + "request_id": "non-secret-request-id" } } ``` @@ -40,8 +35,10 @@ X-Claude-Cookie: <完整 claude.ai 浏览器 Cookie> | 状态码 | 说明 | | --- | --- | | `400` | 请求体无效或模型不支持。 | -| `401` | 缺少 sessionKey 或浏览器 Cookie。 | +| `401` | 缺少代理 API Key 或 key 无效。 | +| `429` | 当前租户超过速率限制。 | | `502` | 上游 claude.ai 请求失败。 | +| `503` | 网关认证或租户上游凭据未配置。 | ## 模型列表 @@ -51,7 +48,7 @@ X-Claude-Cookie: <完整 claude.ai 浏览器 Cookie> ```bash curl http://127.0.0.1:8080/v1/models \ - -H 'Authorization: Bearer ' + -H 'Authorization: Bearer ' ``` 响应示例: @@ -122,7 +119,7 @@ OpenAI Chat Completions 兼容接口。 ```bash curl http://127.0.0.1:8080/v1/chat/completions \ - -H 'Authorization: Bearer ' \ + -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "model": "claude-sonnet-5", @@ -161,7 +158,7 @@ curl http://127.0.0.1:8080/v1/chat/completions \ ```bash curl -N http://127.0.0.1:8080/v1/chat/completions \ - -H 'Authorization: Bearer ' \ + -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "model": "claude-sonnet-5", @@ -396,7 +393,7 @@ data: {"type":"response.completed","response":{"status":"completed"}} ```bash curl -X DELETE http://127.0.0.1:8080/v1/conversations/my-chat-001 \ - -H 'Authorization: Bearer ' + -H 'Authorization: Bearer ' ``` 响应: @@ -407,26 +404,9 @@ curl -X DELETE http://127.0.0.1:8080/v1/conversations/my-chat-001 \ 注意:当前会话映射保存在内存中,服务重启后会丢失。 -## 完整浏览器 Cookie 用法 - -如果 Bearer sessionKey 模式和浏览器行为不一致,或遇到浏览器正常但本地请求异常的情况,建议传完整 claude.ai 浏览器 Cookie: - -```bash -curl http://127.0.0.1:8080/v1/chat/completions \ - -H 'X-Claude-Cookie: sessionKey=...; sessionKeyLC=...; anthropic-device-id=...; lastActiveOrg=...; ...' \ - -H 'Content-Type: application/json' \ - -d '{ - "model": "claude-sonnet-5", - "messages": [{"role": "user", "content": "Reply with exactly: pong"}], - "stream": false - }' -``` - -没有 Bearer token 时,代理会尝试从 Cookie 中提取 `sessionKey`。 - -## sessionKey 模式下自动生成的环境 +## 服务端浏览器凭据 -当只传 `sessionKey` 时,代理服务端会自动生成前端可生成的浏览器环境,并补齐更接近浏览器的请求结构,例如: +客户端不能提交 Claude sessionKey 或完整 Cookie。服务端可以为每个租户配置 sessionKey;代理会在该租户的上游 client 生命周期内生成前端环境,例如: - `sessionKeyLC` - `anthropic-device-id` @@ -450,7 +430,7 @@ curl http://127.0.0.1:8080/v1/chat/completions \ `routingHint` 是 claude.ai 后端签发的路由提示,值通常是 `sk-ant-rh-...` 形式,内部结构类似带签名的 JWT。它一般在登录、会话刷新、账号路由初始化或组织信息加载时由 claude.ai 服务端下发;客户端最多只能原样保存和回传,不能自行生成有效值。 -如果确实需要这些值,请使用 `X-Claude-Cookie` 或 `CLAUDE_COOKIE` 传入真实浏览器完整 Cookie。 +如果确实需要这些值,请仅在服务端的私密 `TENANTS_FILE` 中为对应租户配置 `claude_cookie`。完整安全配置见 [SECURITY.md](SECURITY.md)。 ## 不支持的接口 diff --git a/API_EN.md b/API_EN.md index 3b77f4b..ba6a7f4 100644 --- a/API_EN.md +++ b/API_EN.md @@ -6,19 +6,13 @@ Base URL: http://127.0.0.1:8080/v1 ``` -All endpoints under `/v1` require authentication. Use either: +All endpoints under `/v1` require an independent proxy API key: ```http -Authorization: Bearer +Authorization: Bearer ``` -or: - -```http -X-Claude-Cookie: -``` - -If `CLAUDE_SESSION_KEY` or `CLAUDE_COOKIE` is configured on the server, callers may omit the matching request header. +The Bearer value is never a Claude session key. Upstream browser credentials are configured only on the server through single-tenant environment variables or `TENANTS_FILE`. Client browser-credential headers are rejected. ## Errors @@ -28,7 +22,8 @@ Errors use an OpenAI-like JSON shape: { "error": { "message": "error message", - "type": "invalid_request_error" + "type": "invalid_request_error", + "request_id": "non-secret-request-id" } } ``` @@ -38,8 +33,10 @@ Common status codes: | Status | Meaning | | --- | --- | | `400` | Invalid request body or unsupported model. | -| `401` | Missing session key or browser Cookie. | +| `401` | Missing or invalid proxy API key. | +| `429` | The authenticated tenant exceeded its rate limit. | | `502` | Upstream claude.ai request failed. | +| `503` | Gateway authentication or the tenant upstream credential is not configured. | ## Models @@ -49,7 +46,7 @@ Returns the supported model list. ```bash curl http://127.0.0.1:8080/v1/models \ - -H 'Authorization: Bearer ' + -H 'Authorization: Bearer ' ``` Response: @@ -120,7 +117,7 @@ Messages are flattened to a claude.ai prompt using `[System]`, `[Human]`, and `[ ```bash curl http://127.0.0.1:8080/v1/chat/completions \ - -H 'Authorization: Bearer ' \ + -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "model": "claude-sonnet-5", @@ -159,7 +156,7 @@ Response: ```bash curl -N http://127.0.0.1:8080/v1/chat/completions \ - -H 'Authorization: Bearer ' \ + -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "model": "claude-sonnet-5", @@ -237,7 +234,7 @@ Delete a persistent conversation: ```bash curl -X DELETE http://127.0.0.1:8080/v1/conversations/my-chat-001 \ - -H 'Authorization: Bearer ' + -H 'Authorization: Bearer ' ``` Response: @@ -248,26 +245,9 @@ Response: The conversation mapping is stored in memory and is lost after server restart. -## Full Browser Cookie Usage - -When the simple Bearer session key gets rate-limited or behaves differently from the browser, pass the full claude.ai browser Cookie: - -```bash -curl http://127.0.0.1:8080/v1/chat/completions \ - -H 'X-Claude-Cookie: sessionKey=...; sessionKeyLC=...; anthropic-device-id=...; lastActiveOrg=...; ...' \ - -H 'Content-Type: application/json' \ - -d '{ - "model": "claude-sonnet-5", - "messages": [{"role": "user", "content": "Reply with exactly: pong"}], - "stream": false - }' -``` - -The proxy extracts `sessionKey` from the Cookie when no Bearer token is present. - -## Generated Environment in sessionKey Mode +## Server-side browser credentials -When only a `sessionKey` is provided, the server generates browser-like values and request structure that are normally frontend-generated, such as: +Clients cannot submit Claude session keys or full Cookies. When a tenant uses a server-side session key, the service generates browser-like values and request structure within that tenant's upstream client lifecycle, such as: - `sessionKeyLC` - `anthropic-device-id` @@ -289,7 +269,7 @@ Signed server-side or Cloudflare-issued cookies are not forged and are not sent `routingHint` is issued by the claude.ai backend. It commonly appears as `sk-ant-rh-...` and its internal shape is similar to a signed JWT. It is usually created during login, session refresh, account routing initialization, or organization loading. Clients can only store and replay a real value; they cannot generate a valid one locally. -Use `X-Claude-Cookie` or `CLAUDE_COOKIE` with a real browser Cookie header if you need those values. +If these values are required, configure `claude_cookie` only in the tenant's protected server-side `TENANTS_FILE`. See [SECURITY.md](SECURITY.md). ## Unsupported Endpoints diff --git a/Dockerfile b/Dockerfile index b54a98e..9c04871 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,12 +12,15 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/claude2 FROM alpine:3.22 -RUN apk add --no-cache ca-certificates tzdata +RUN apk add --no-cache ca-certificates tzdata \ + && addgroup -S claude2api \ + && adduser -S -G claude2api -H -s /sbin/nologin claude2api WORKDIR /app COPY --from=builder /out/claude2api /app/claude2api ENV PORT=8080 \ + BIND_ADDR=0.0.0.0 \ CLAUDE_BASE_URL=https://claude.ai \ CLAUDE_TIMEZONE=Asia/Singapore \ CLAUDE_LOCALE=en-US \ @@ -25,4 +28,6 @@ ENV PORT=8080 \ EXPOSE 8080 +USER claude2api + ENTRYPOINT ["/app/claude2api"] diff --git a/README.md b/README.md index e07db30..b65c3c8 100644 --- a/README.md +++ b/README.md @@ -1,249 +1,189 @@ # Claude2api -> 中文文档 | [English README](README_EN.md) | [API 文档](API.md) | [English API](API_EN.md) +> 中文文档 | [English README](README_EN.md) | [API 文档](API.md) | [English API](API_EN.md) | [安全模型](SECURITY.md) -`Claude2api` 是一个基于 Go + Gin 的 claude.ai 网页 API 代理服务,可以把 claude.ai 网页会话包装成常见的 OpenAI / Anthropic 兼容接口。 +`Claude2api` 是一个 Go + Gin 本地网关,把 claude.ai 网页会话包装成 OpenAI、Anthropic 和 Responses 风格接口。它依赖逆向的消费者网页接口,不是 Anthropic 官方 API;生产业务优先使用官方 API 或受支持云渠道。 -项目会使用接近真实浏览器的 TLS / Header / Cookie 环境访问 `https://claude.ai`,并对外提供 JSON 与 SSE 流式响应。 -## API 文档 +## 接口 -详细接口说明见:[API.md](API.md)。 +- `GET /health` +- `GET /v1/models` +- `POST /v1/chat/completions` +- `POST /v1/messages` +- `POST /v1/responses` +- `DELETE /v1/conversations/:id` -## 功能特性 +支持 JSON、SSE 和按租户隔离的持久 `conversation_id`。 -- OpenAI Chat Completions 兼容接口:`POST /v1/chat/completions` -- Anthropic Messages 兼容接口:`POST /v1/messages` -- OpenAI Responses 兼容接口:`POST /v1/responses` -- 模型列表接口:`GET /v1/models` -- 支持非流式与 SSE 流式返回 -- 支持 `conversation_id` 持久会话模式,保持多轮对话连续性 -- 支持完整浏览器 Cookie 模式,更接近 claude.ai 浏览器请求环境 -- 支持 Bearer sessionKey 模式,便于本地简单调用 -- Bearer 模式下会自动生成可由前端生成的浏览器环境 Cookie/Header;签名或 Cloudflare 类 Cookie 不伪造、不传递 -- completion 请求会携带从真实浏览器请求逆向得到的 claude.ai web `tools` 字段 -- Referer 会按请求阶段动态设置为 `/new` 或 `/chat/` -- Datadog/RUM Cookie 与 trace headers 会按浏览器 SDK 的字段结构生成 -- 使用独立 `tlsclient` 模块封装 `github.com/bogdanfinn/tls-client` 的 Chrome 指纹、CookieJar 和浏览器基础 Header -- 支持 Docker / Docker Compose 部署 +## 安全边界 -## 支持的模型 +新版把下游认证和上游浏览器凭据完全分开: -`/v1/models` 只会返回以下模型,请求时也只允许使用这些模型: +- 客户端 Bearer 是独立的代理 API Key,不再是 Claude `sessionKey`。 +- 配置只保存下游 API Key 的 SHA-256 摘要。 +- Claude sessionKey/Cookie 只保存在服务端;客户端提交 `X-Claude-Cookie` 或 `X-Claude-Session-Key` 会被拒绝。 +- 每个租户拥有独立限流桶、会话命名空间和上游凭据绑定。 +- 多租户必须使用受 ACL 保护的 `TENANTS_FILE`,不允许隐式共享一个上游登录态。 +- 错误响应会脱敏并返回 `request_id`,不会转发上游正文。 +- 默认只监听 `127.0.0.1`,请求体上限 1 MiB。 -- `claude-fable-5` -- `claude-opus-4-8` -- `claude-haiku-4-5` -- `claude-opus-4-7` -- `claude-opus-4-6` -- `claude-opus-3` -- `claude-sonnet-4-6` -- `claude-sonnet-5` - -使用其他模型会返回 `invalid_request_error`。 +完整说明见 [SECURITY.md](SECURITY.md)。 -## 环境要求 +## 构建和测试 -- Go 1.26.4 或更高版本 -- 一个可用的 claude.ai 浏览器会话 -- 可选:Docker / Docker Compose +需要 Go 1.26.4+: -## 快速开始 - -### 1. 构建 +```bash +go test -race ./... +go vet ./... +go build -o claude2api . +``` Windows: -```bash +```powershell +go test -race ./... +go vet ./... go build -o claude2api.exe . ``` -Linux / macOS: +## 生成下游代理 API Key ```bash -go build -o claude2api . +go run ./cmd/keygen -tenant local ``` -### 2. 运行 +命令会显示一次明文 key,并输出 `PROXY_API_KEYS` 摘要项和 `TENANTS_FILE` 示例项。明文 key 只交给该租户并保存到秘密管理器,不要写进 Git、文档、截图或命令历史。 -使用 sessionKey: +## 单租户本机运行 -```bash -CLAUDE_SESSION_KEY='你的-sessionKey' PORT=8080 ./claude2api.exe +在私密终端中设置: + +```text +PROXY_API_KEYS=local= +CLAUDE_SESSION_KEY=<仅服务端保存的上游凭据> ``` -使用完整浏览器 Cookie: +然后启动: ```bash -CLAUDE_COOKIE='sessionKey=...; sessionKeyLC=...; anthropic-device-id=...; ...' PORT=8080 ./claude2api.exe +./claude2api ``` -服务地址: +PowerShell: + +```powershell +.\claude2api.exe +``` + +服务默认监听: ```text -http://127.0.0.1:8080/v1 +http://127.0.0.1:8080 ``` -### 3. 测试 +健康检查会同时报告进程存活和配置就绪状态: ```bash -curl http://127.0.0.1:8080/v1/chat/completions \ - -H 'Authorization: Bearer 你的-sessionKey' \ - -H 'Content-Type: application/json' \ - -d '{ - "model": "claude-sonnet-5", - "messages": [{"role": "user", "content": "Reply with exactly: pong"}], - "stream": false - }' +curl http://127.0.0.1:8080/health ``` -预期返回格式: - ```json -{ - "id": "chatcmpl-...", - "object": "chat.completion", - "model": "claude-sonnet-5", - "choices": [ - { - "index": 0, - "message": {"role": "assistant", "content": "pong"}, - "finish_reason": "stop" - } - ] -} +{"status":"ok","ready":true} ``` -## Docker 部署 +## 多租户运行 -GitHub Actions 会自动构建 Docker 镜像并推送到 GitHub Container Registry: +复制 `tenants.example.json` 到仓库外的私有路径,为每个租户填写不同的 API Key 摘要和不同的上游凭据,并限制文件 ACL。然后只设置: ```text -ghcr.io/aurora-develop/claude2api +TENANTS_FILE=/absolute/private/path/tenants.json ``` -拉取镜像: +示例结构: -```bash -docker pull ghcr.io/aurora-develop/claude2api:latest +```json +{ + "tenant-a": { + "api_key_sha256": "", + "claude_session_key": "", + "claude_cookie": "" + }, + "tenant-b": { + "api_key_sha256": "", + "claude_session_key": "", + "claude_cookie": "" + } +} ``` -运行远程镜像: +`TENANTS_FILE` 不能和 `PROXY_API_KEYS`、`CLAUDE_SESSION_KEY`、`CLAUDE_COOKIE` 同时使用。 -```bash -docker run --rm -p 8080:8080 \ - -e CLAUDE_SESSION_KEY='你的-sessionKey' \ - ghcr.io/aurora-develop/claude2api:latest -``` +## 调用 -### Docker build +客户端始终使用 keygen 生成的代理 key: ```bash -docker build -t claude2api . +curl http://127.0.0.1:8080/v1/chat/completions \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "claude-sonnet-5", + "messages": [{"role": "user", "content": "Reply with exactly: pong"}], + "stream": false + }' ``` -### Docker run - -使用 sessionKey: +不要向客户端分发 Claude sessionKey/Cookie。 -```bash -docker run --rm -p 8080:8080 \ - -e CLAUDE_SESSION_KEY='你的-sessionKey' \ - claude2api -``` +## Docker -使用完整 Cookie: +容器内需要监听 `0.0.0.0`,Dockerfile 和 Compose 已显式设置 `BIND_ADDR=0.0.0.0`。单租户可传入 key 摘要和上游凭据;多租户应把受控文件只读挂载到容器,再设置容器内 `TENANTS_FILE`。 ```bash -docker run --rm -p 8080:8080 \ - -e CLAUDE_COOKIE='sessionKey=...; sessionKeyLC=...; anthropic-device-id=...; ...' \ +docker build -t claude2api . +docker run --rm -p 127.0.0.1:8080:8080 \ + -e PROXY_API_KEYS='local=' \ + -e CLAUDE_SESSION_KEY='' \ claude2api ``` -### Docker Compose +不要使用 `-p 8080:8080` 直接暴露到全部网卡,除非前面已有 TLS、认证、防火墙和受控反向代理。 -```bash -CLAUDE_SESSION_KEY='你的-sessionKey' docker compose up --build -``` - -或者: - -```bash -CLAUDE_COOKIE='sessionKey=...; sessionKeyLC=...; anthropic-device-id=...; ...' docker compose up --build -``` - -## 配置项 +## 配置 | 环境变量 | 默认值 | 说明 | | --- | --- | --- | -| `PORT` | `8080` | 本地 HTTP 服务端口。 | -| `CLAUDE_BASE_URL` | `https://claude.ai` | claude.ai 上游地址。 | -| `CLAUDE_SESSION_KEY` | 空 | claude.ai 的 `sessionKey`。配置后请求端可以不传 Bearer。 | -| `CLAUDE_COOKIE` | 空 | 从浏览器复制的完整 claude.ai Cookie。推荐用于更接近浏览器环境。 | -| `CLAUDE_TIMEZONE` | `Asia/Singapore` | 发送给 claude.ai 的时区。 | -| `CLAUDE_LOCALE` | `en-US` | 发送给 claude.ai 的语言区域。 | -| `DEFAULT_MODEL` | `claude-sonnet-5` | 请求未指定模型时使用的默认模型。必须在支持模型列表内。 | - -## 认证方式 - -所有 `/v1/*` 接口都需要认证。 - -### 方式一:Bearer sessionKey - -```http -Authorization: Bearer -``` - -如果服务端已经配置 `CLAUDE_SESSION_KEY`,请求端可以不传这个 Header。 - -Bearer 模式下,用户只需要提供 `sessionKey`。服务端会自动生成这些前端可生成的环境值: +| `BIND_ADDR` | `127.0.0.1` | 监听地址;Docker 显式设为 `0.0.0.0` | +| `PORT` | `8080` | HTTP 端口 | +| `TENANTS_FILE` | 空 | 多租户私密 JSON 文件绝对路径 | +| `PROXY_API_KEYS` | 空 | 单租户 `tenant=sha256hex` 兼容配置 | +| `CLAUDE_SESSION_KEY` | 空 | 单租户服务端上游凭据 | +| `CLAUDE_COOKIE` | 空 | 单租户服务端完整上游 Cookie | +| `RATE_LIMIT_RPM` | `60` | 每租户每分钟令牌补充率 | +| `RATE_LIMIT_BURST` | `10` | 每租户突发容量 | +| `MAX_REQUEST_BYTES` | `1048576` | 请求体最大字节数 | +| `MAX_CONVERSATIONS_PER_TENANT` | `100` | 每租户持久会话上限 | +| `CONVERSATION_TTL` | `24h` | 空闲持久会话映射的生存时间(Go duration) | +| `CLAUDE_BASE_URL` | `https://claude.ai` | 上游地址 | +| `CLAUDE_TIMEZONE` | `Asia/Singapore` | 上游浏览器时区 | +| `CLAUDE_LOCALE` | `en-US` | 上游浏览器区域 | +| `DEFAULT_MODEL` | `claude-sonnet-5` | 默认模型 | + +## 支持模型 -- `sessionKeyLC` -- `anthropic-device-id` -- `activitySessionId` -- `ajs_anonymous_id` -- `__ssid` -- `_dd_s` -- `traceparent` / Datadog RUM 相关 Header -- 部分 UI / analytics Cookie - -以下不能伪造的服务端签名或 Cloudflare Cookie 不会自动生成,也不会在 Bearer 模式下传递: - -- `routingHint` -- `cf_clearance` -- `__cf_bm` -- `_cfuvid` - -### 方式二:完整浏览器 Cookie - -```http -X-Claude-Cookie: <从 claude.ai 浏览器请求中复制的完整 Cookie> -``` - -这种方式最接近浏览器行为。代理会复用 Cookie 中的: - -- `sessionKey` -- `sessionKeyLC` -- `anthropic-device-id` -- `lastActiveOrg` -- `routingHint` -- Cloudflare 相关 Cookie - -如果服务端已经配置 `CLAUDE_COOKIE`,请求端可以不传 `X-Claude-Cookie`。 - -## 致谢 - -感谢 [LINUX DO 社区](https://linux.do) —— 本项目在此发布,感谢社区用户的反馈与帮助。 - - -英文版: - -- [English README](README_EN.md) -- [English API](API_EN.md) +- `claude-fable-5` +- `claude-opus-4-8` +- `claude-haiku-4-5` +- `claude-opus-4-7` +- `claude-opus-4-6` +- `claude-opus-3` +- `claude-sonnet-4-6` +- `claude-sonnet-5` -## 注意事项 +## 限制 -- 每次 completion 请求都会创建一个临时 claude.ai 会话,请求结束后会尝试删除。 -- `usage` 中的 token 数是近似值,目前主要根据输出文本长度估算。 -- 如果 Bearer sessionKey 模式和浏览器行为不一致,建议使用完整 Cookie 模式。 -- 如果上游返回 `429`,说明 claude.ai 当前账号或会话触发了速率限制。 -- 请不要把自己的 `sessionKey`、完整 Cookie、抓包文件提交到公开仓库。 \ No newline at end of file +- 网页接口、模型名、Cookie 和请求结构可能随时变化。 +- 内存会话映射在进程重启后丢失。 +- `usage` 仍是按文本长度估算,不等同官方计费。 +- 这些安全控制只保护本地网关,不能消除消费者网页自动化的条款、授权、稳定性和许可风险。 diff --git a/README_EN.md b/README_EN.md index 675a417..4a456ea 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,194 +1,90 @@ -# claude2api +# Claude2api -`claude2api` is a Go/Gin proxy that exposes OpenAI-compatible and Anthropic-compatible HTTP endpoints backed by the claude.ai web API. +> [中文 README](README.md) | [API reference](API_EN.md) | [Security model](SECURITY.md) -It reverse-proxies requests to `https://claude.ai` using a browser-like TLS/client environment and returns standard JSON or Server-Sent Events (SSE) responses for common API clients. +Claude2api is a Go + Gin local gateway that wraps a claude.ai browser session in OpenAI-, Anthropic-, and Responses-style APIs. It uses reverse-engineered consumer web endpoints and is not the official Anthropic API. Prefer the official API or supported cloud channels for production. -## Features +## Endpoints -- OpenAI-compatible chat completions: `POST /v1/chat/completions` -- Anthropic Messages-compatible endpoint: `POST /v1/messages` -- OpenAI Responses-compatible endpoint: `POST /v1/responses` -- Model listing: `GET /v1/models` -- Streaming and non-streaming responses -- Persistent `conversation_id` mode for multi-turn conversation continuity -- Browser Cookie mode to match a real claude.ai browser session -- Bearer session key mode for simple local use -- Dedicated local `tlsclient` module wrapping the Chrome-profile `github.com/bogdanfinn/tls-client` client, CookieJar, and common browser headers -- Completion requests include the claude.ai web `tools` payload reverse-engineered from a real browser request -- Referer is set dynamically to `/new` or `/chat/` depending on the upstream request phase -- Datadog/RUM cookies and trace headers are generated following the browser SDK field structure -- In Bearer mode, the server generates frontend-like browser cookies/headers where possible; signed or Cloudflare cookies are not forged or sent +- `GET /health` +- `GET /v1/models` +- `POST /v1/chat/completions` +- `POST /v1/messages` +- `POST /v1/responses` +- `DELETE /v1/conversations/:id` -Image endpoints such as `/v1/images/generations`, `/v1/images/edits`, and `/v1/images/variations` are not supported. +## Secure authentication model -## Supported Models +- Client Bearer tokens are independent proxy API keys, never Claude session keys. +- Configuration stores only SHA-256 digests of downstream keys. +- Claude session keys and Cookies stay server-side. Client browser-credential headers are rejected. +- Tenant rate limits, conversation namespaces, and upstream credential bindings are isolated. +- Multi-tenant deployments require an ACL-protected `TENANTS_FILE`; tenants cannot silently share one upstream browser credential. +- Errors are redacted and include a non-secret request ID. +- The default bind address is `127.0.0.1` and the default request-body limit is 1 MiB. -Only these model IDs are accepted and returned by `/v1/models`: +See [SECURITY.md](SECURITY.md) for configuration and threat-boundary details. -- `claude-fable-5` -- `claude-opus-4-8` -- `claude-haiku-4-5` -- `claude-opus-4-7` -- `claude-opus-4-6` -- `claude-opus-3` -- `claude-sonnet-4-6` -- `claude-sonnet-5` - -Requests using any other model return an `invalid_request_error`. - -## Requirements - -- Go 1.26.4 or newer, matching `go.mod` -- A valid claude.ai browser session - -## Build - -```bash -go build -o claude2api.exe . -``` - -On non-Windows systems you can build without the `.exe` suffix: +## Build and test ```bash +go test -race ./... +go vet ./... go build -o claude2api . ``` -## Configuration - -The service is configured with environment variables. - -| Variable | Default | Description | -| --- | --- | --- | -| `PORT` | `8080` | Local HTTP server port. | -| `CLAUDE_BASE_URL` | `https://claude.ai` | Upstream claude.ai base URL. | -| `CLAUDE_SESSION_KEY` | empty | Optional claude.ai `sessionKey`. If set, API requests do not need a Bearer token. | -| `CLAUDE_COOKIE` | empty | Optional full browser Cookie header from claude.ai. Recommended when you want behavior closest to the browser. | -| `CLAUDE_TIMEZONE` | `Asia/Singapore` | Timezone sent to claude.ai completion requests. | -| `CLAUDE_LOCALE` | `en-US` | Locale sent to claude.ai completion requests. | -| `DEFAULT_MODEL` | `claude-sonnet-5` | Model used when a request omits `model`. Must be one of the supported models. | - -## Authentication - -Every `/v1/*` endpoint requires either a session key or a full browser Cookie. - -### Option 1: Bearer session key - -```http -Authorization: Bearer -``` - -You can also set `CLAUDE_SESSION_KEY` so callers do not need to pass the Bearer header on each request. - -### Option 2: Full browser Cookie - -```http -X-Claude-Cookie: -``` - -This mode is closest to browser behavior. The proxy reuses values such as `sessionKey`, `sessionKeyLC`, `anthropic-device-id`, `lastActiveOrg`, routing and Cloudflare cookies if they are present. - -You can also set `CLAUDE_COOKIE` to use the same browser Cookie for all requests. - -## Run - -Bearer session key mode: +## Generate a downstream key ```bash -CLAUDE_SESSION_KEY='your-session-key' PORT=8080 ./claude2api.exe +go run ./cmd/keygen -tenant local ``` -Full browser Cookie mode: +Store the plaintext key in a secret manager. Configure only its SHA-256 digest. -```bash -CLAUDE_COOKIE='sessionKey=...; sessionKeyLC=...; anthropic-device-id=...; ...' PORT=8080 ./claude2api.exe -``` - -Then use the local base URL: - -```text -http://127.0.0.1:8080/v1 -``` - -## Docker - -GitHub Actions automatically builds and pushes Docker images to GitHub Container Registry: +## Single-tenant configuration ```text -ghcr.io/aurora-develop/claude2api -``` - -Pull the image: - -```bash -docker pull ghcr.io/aurora-develop/claude2api:latest -``` - -Run the published image: - -```bash -docker run --rm -p 8080:8080 \ - -e CLAUDE_SESSION_KEY='your-session-key' \ - ghcr.io/aurora-develop/claude2api:latest +PROXY_API_KEYS=local=<64-character-digest> +CLAUDE_SESSION_KEY= ``` -Build the image locally: +Start the service and check readiness: ```bash -docker build -t claude2api . +./claude2api +curl http://127.0.0.1:8080/health ``` -Run with a session key: - -```bash -docker run --rm -p 8080:8080 \ - -e CLAUDE_SESSION_KEY='your-session-key' \ - claude2api -``` +The expected ready response is `{"status":"ok","ready":true}`. -Or run with Docker Compose: +## Multi-tenant configuration -```bash -CLAUDE_SESSION_KEY='your-session-key' docker compose up --build -``` +Copy `tenants.example.json` outside the repository, protect it with operating-system ACLs, and configure a distinct downstream digest and upstream credential for every tenant. Set only `TENANTS_FILE=/absolute/private/path/tenants.json`; it cannot be combined with global credential variables. -## Quick Test +## Call the API ```bash curl http://127.0.0.1:8080/v1/chat/completions \ - -H 'Authorization: Bearer your-session-key' \ + -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ - -d '{ - "model": "claude-sonnet-5", - "messages": [{"role": "user", "content": "Reply with exactly: pong"}], - "stream": false - }' -``` - -Expected response shape: - -```json -{ - "id": "chatcmpl-...", - "object": "chat.completion", - "model": "claude-sonnet-5", - "choices": [ - { - "index": 0, - "message": {"role": "assistant", "content": "pong"}, - "finish_reason": "stop" - } - ] -} + -d '{"model":"claude-sonnet-5","messages":[{"role":"user","content":"Reply with exactly: pong"}]}' ``` -## API Documentation - -See [API_EN.md](API_EN.md) for endpoint details and examples. +Never distribute the upstream Claude session key or Cookie to clients. -## Notes +## Main settings -- The proxy creates a temporary claude.ai conversation for each completion and deletes it after the request. -- Token usage values are approximate and currently based on output text length. -- If browser and Bearer modes behave differently, prefer full Cookie mode because it carries the same request environment as the browser. -- A `429` response is returned by claude.ai when the upstream account/session is rate-limited. +| Variable | Default | Purpose | +| --- | --- | --- | +| `BIND_ADDR` | `127.0.0.1` | Listen address | +| `PORT` | `8080` | HTTP port | +| `TENANTS_FILE` | empty | Protected multi-tenant JSON file | +| `PROXY_API_KEYS` | empty | Single-tenant `tenant=sha256hex` compatibility setting | +| `CLAUDE_SESSION_KEY` / `CLAUDE_COOKIE` | empty | Single-tenant server-side upstream credential | +| `RATE_LIMIT_RPM` | `60` | Per-tenant refill rate | +| `RATE_LIMIT_BURST` | `10` | Per-tenant burst capacity | +| `MAX_REQUEST_BYTES` | `1048576` | Maximum request body | +| `MAX_CONVERSATIONS_PER_TENANT` | `100` | Persistent conversation cap | +| `CONVERSATION_TTL` | `24h` | Idle persistent conversation mapping lifetime (Go duration) | + +Docker must set `BIND_ADDR=0.0.0.0` inside the container. Publish the port to `127.0.0.1` unless a controlled TLS reverse proxy and firewall are in place. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..31e645b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,82 @@ +# Security model + +Claude2api separates downstream gateway authentication from upstream browser credentials. + +## Trust boundaries + +- Clients send `Authorization: Bearer `. +- A proxy API key identifies exactly one configured tenant. Only its SHA-256 digest is stored. +- Claude `sessionKey` and browser Cookie values are server-side credentials. Client `Cookie`, `X-Claude-Cookie`, and `X-Claude-Session-Key` headers are rejected. +- Persistent conversation IDs are scoped by tenant and bound to that tenant's upstream credential digest. +- Rate limiting uses a separate token bucket per authenticated tenant. +- Public errors contain stable categories and a request ID, not upstream response bodies or credentials. + +## Generate a downstream key + +```bash +go run ./cmd/keygen -tenant local +``` + +The command prints the plaintext API key once and its SHA-256 configuration entry. Store the plaintext key in a secret manager. Do not put it in Git, screenshots, shell history, or documentation. + +## Single-tenant configuration + +For a local single-tenant process, configure the key digest and exactly one server-side upstream credential: + +```text +PROXY_API_KEYS=local=<64-character-sha256-digest> +CLAUDE_SESSION_KEY= +``` + +The client uses the generated proxy key, not the Claude session key: + +```http +Authorization: Bearer c2a_ +``` + +## Multi-tenant configuration + +Multi-tenant mode requires `TENANTS_FILE`. Copy `tenants.example.json` outside the repository, restrict it with operating-system ACLs, and configure one distinct downstream key digest and upstream credential per tenant: + +```json +{ + "tenant-a": { + "api_key_sha256": "", + "claude_session_key": "", + "claude_cookie": "" + }, + "tenant-b": { + "api_key_sha256": "", + "claude_session_key": "", + "claude_cookie": "" + } +} +``` + +Set only: + +```text +TENANTS_FILE=/absolute/private/path/tenants.json +``` + +`TENANTS_FILE` cannot be combined with `PROXY_API_KEYS`, `CLAUDE_SESSION_KEY`, or `CLAUDE_COOKIE`. Duplicate downstream key digests and tenants without an upstream credential are rejected at startup. The application never logs tenant IDs, key digests, or upstream credential values. + +## Built-in controls + +| Control | Default | +| --- | --- | +| Bind address | `127.0.0.1` | +| Per-tenant rate | `60` requests/minute | +| Per-tenant burst | `10` requests | +| Request body limit | `1048576` bytes | +| Persistent conversations per tenant | `100` | +| Idle persistent conversation TTL | `24h` | +| Response cache policy | `private, no-store` | +| Trusted proxies | none | +| Container runtime user | unprivileged `claude2api` user | + +Configure with `BIND_ADDR`, `RATE_LIMIT_RPM`, `RATE_LIMIT_BURST`, `MAX_REQUEST_BYTES`, `MAX_CONVERSATIONS_PER_TENANT`, and `CONVERSATION_TTL`. + +## Remaining production boundary + +These controls harden the local gateway, but they do not make reverse-engineered claude.ai browser endpoints an official or supported production API. Use only accounts and browser sessions you are authorized to use, follow upstream terms, and prefer the official Anthropic API or supported cloud channels for commercial production. diff --git a/claude/client.go b/claude/client.go index d904cf2..995d029 100644 --- a/claude/client.go +++ b/claude/client.go @@ -226,8 +226,7 @@ func (c *Client) GetOrganization(ctx context.Context) (string, error) { defer resp.Body.Close() if resp.StatusCode != 200 { - body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("get organizations: status %d: %s", resp.StatusCode, string(body)) + return "", fmt.Errorf("get organizations: upstream status %d", resp.StatusCode) } var orgs models.ClaudeOrganizationsResponse @@ -264,8 +263,7 @@ func (c *Client) CreateConversation(ctx context.Context, title string) (string, defer resp.Body.Close() if resp.StatusCode != 200 && resp.StatusCode != 201 { - respBody, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("create conversation: status %d: %s", resp.StatusCode, string(respBody)) + return "", fmt.Errorf("create conversation: upstream status %d", resp.StatusCode) } var conv models.ClaudeConversation @@ -291,8 +289,7 @@ func (c *Client) DeleteConversation(ctx context.Context, convID string) error { defer resp.Body.Close() if resp.StatusCode != 200 && resp.StatusCode != 204 { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("delete conversation: status %d: %s", resp.StatusCode, string(body)) + return fmt.Errorf("delete conversation: upstream status %d", resp.StatusCode) } return nil @@ -316,9 +313,8 @@ func (c *Client) SendMessage(ctx context.Context, convID string, req *models.Cla } if resp.StatusCode != 200 { - respBody, _ := io.ReadAll(resp.Body) resp.Body.Close() - return nil, fmt.Errorf("send message: status %d: %s", resp.StatusCode, string(respBody)) + return nil, fmt.Errorf("send message: upstream status %d", resp.StatusCode) } // Parse SSE stream in background @@ -326,46 +322,68 @@ func (c *Client) SendMessage(ctx context.Context, convID string, req *models.Cla go func() { defer close(events) defer resp.Body.Close() + streamCompletionEvents(ctx, resp.Body, events) + }() - scanner := bufio.NewScanner(resp.Body) - scanner.Buffer(make([]byte, 2*1024*1024), 2*1024*1024) + return events, nil +} - var eventType, data string - for scanner.Scan() { - select { - case <-ctx.Done(): - return - default: - } +func streamCompletionEvents(ctx context.Context, reader io.Reader, events chan<- models.ClaudeCompletionEvent) { + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 2*1024*1024), 2*1024*1024) + + var eventType, data string + for scanner.Scan() { + if ctx.Err() != nil { + return + } - line := scanner.Text() + line := scanner.Text() - if line == "" { - if data != "" { - events <- parseCompletionEvent(eventType, data) - } - eventType = "" - data = "" - continue + if line == "" { + if data != "" && !sendCompletionEvent(ctx, events, parseCompletionEvent(eventType, data)) { + return } + eventType = "" + data = "" + continue + } - if strings.HasPrefix(line, "event: ") { - eventType = strings.TrimPrefix(line, "event: ") - } else if strings.HasPrefix(line, "data: ") { - payload := strings.TrimPrefix(line, "data: ") - if data != "" { - data += "\n" - } - data += payload + if strings.HasPrefix(line, "event: ") { + eventType = strings.TrimPrefix(line, "event: ") + } else if strings.HasPrefix(line, "data: ") { + payload := strings.TrimPrefix(line, "data: ") + if data != "" { + data += "\n" } + data += payload } + } - if data != "" { - events <- parseCompletionEvent(eventType, data) + if err := scanner.Err(); err != nil { + if ctx.Err() == nil { + sendCompletionEvent(ctx, events, models.ClaudeCompletionEvent{ + Type: "error", + Error: &models.ClaudeError{ + ErrorType: "transport_error", + Message: "upstream stream read failed", + }, + }) } - }() + return + } + if data != "" { + sendCompletionEvent(ctx, events, parseCompletionEvent(eventType, data)) + } +} - return events, nil +func sendCompletionEvent(ctx context.Context, events chan<- models.ClaudeCompletionEvent, event models.ClaudeCompletionEvent) bool { + select { + case events <- event: + return true + case <-ctx.Done(): + return false + } } func parseCompletionEvent(eventType, data string) models.ClaudeCompletionEvent { diff --git a/claude/client_test.go b/claude/client_test.go new file mode 100644 index 0000000..c816654 --- /dev/null +++ b/claude/client_test.go @@ -0,0 +1,47 @@ +package claude + +import ( + "context" + "errors" + "io" + "strings" + "testing" + + "claude2api/models" +) + +type failingReader struct { + reader io.Reader +} + +func (r *failingReader) Read(buffer []byte) (int, error) { + read, err := r.reader.Read(buffer) + if read > 0 { + return read, nil + } + if err == io.EOF { + return 0, errors.New("synthetic transport failure") + } + return read, err +} + +func TestStreamCompletionEventsReportsRedactedReadFailure(t *testing.T) { + input := "event: content_block_delta\n" + + "data: {\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}\n\n" + events := make(chan models.ClaudeCompletionEvent, 4) + streamCompletionEvents(context.Background(), &failingReader{reader: strings.NewReader(input)}, events) + close(events) + + var foundError bool + for event := range events { + if event.Error != nil { + foundError = true + if event.Error.Message != "upstream stream read failed" { + t.Fatalf("unexpected public scanner error: %q", event.Error.Message) + } + } + } + if !foundError { + t.Fatal("expected a redacted transport error event") + } +} diff --git a/cmd/keygen/main.go b/cmd/keygen/main.go new file mode 100644 index 0000000..79167d0 --- /dev/null +++ b/cmd/keygen/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "flag" + "fmt" + "log" + "regexp" +) + +var tenantPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) + +func main() { + tenantID := flag.String("tenant", "", "tenant ID for the generated proxy API key") + flag.Parse() + if !tenantPattern.MatchString(*tenantID) { + log.Fatal("-tenant must be 1-64 characters using letters, numbers, dot, underscore, or dash") + } + + random := make([]byte, 32) + if _, err := rand.Read(random); err != nil { + log.Fatalf("generate random API key: %v", err) + } + apiKey := "c2a_" + base64.RawURLEncoding.EncodeToString(random) + digest := sha256.Sum256([]byte(apiKey)) + + fmt.Println("Store the API key securely; it is shown only in this output.") + fmt.Printf("API key: %s\n", apiKey) + fmt.Printf("PROXY_API_KEYS entry: %s=%x\n", *tenantID, digest) + fmt.Printf("TENANTS_FILE entry: %q: {\"api_key_sha256\":%q, \"claude_session_key\":\"...\", \"claude_cookie\":\"\"}\n", *tenantID, fmt.Sprintf("%x", digest)) +} diff --git a/config/config.go b/config/config.go index 3275c3a..54228d8 100644 --- a/config/config.go +++ b/config/config.go @@ -1,57 +1,307 @@ package config -import "os" +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "regexp" + "strconv" + "strings" + "time" +) -// Config holds application configuration +const ( + defaultPort = "8080" + defaultBindAddress = "127.0.0.1" + defaultRateLimitPerMinute = 60 + defaultRateLimitBurst = 10 + defaultMaxRequestBytes int64 = 1 << 20 + defaultMaxConversationsPerTenant = 100 + defaultConversationTTL = 24 * time.Hour +) + +var tenantIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) + +// Config holds application configuration. Browser credentials are server-side +// only; callers authenticate with independent proxy API keys. type Config struct { - Port string - ClaudeBaseURL string - SessionKey string - ClaudeCookie string - Timezone string - Locale string - DefaultModel string + BindAddress string + Port string + ClaudeBaseURL string + Timezone string + Locale string + DefaultModel string + Tenants map[string]Tenant + RateLimitPerMinute int + RateLimitBurst int + MaxRequestBytes int64 + MaxConversationsPerTenant int + ConversationTTL time.Duration +} + +// Tenant binds one downstream API key to exactly one server-side browser +// credential. CredentialID is a non-secret digest used for session binding. +type Tenant struct { + APIKeyHash [sha256.Size]byte + SessionKey string + ClaudeCookie string + CredentialID string +} + +type tenantFileEntry struct { + APIKeySHA256 string `json:"api_key_sha256"` + SessionKey string `json:"claude_session_key"` + ClaudeCookie string `json:"claude_cookie"` +} + +// Load creates and validates configuration. No tenant configuration is +// allowed so /health can run in an explicitly not-ready state. +func Load() (*Config, error) { + tenants, err := loadTenants() + if err != nil { + return nil, err + } + ratePerMinute, err := positiveIntEnv("RATE_LIMIT_RPM", defaultRateLimitPerMinute) + if err != nil { + return nil, err + } + burst, err := positiveIntEnv("RATE_LIMIT_BURST", defaultRateLimitBurst) + if err != nil { + return nil, err + } + maxRequestBytes, err := positiveInt64Env("MAX_REQUEST_BYTES", defaultMaxRequestBytes) + if err != nil { + return nil, err + } + maxConversations, err := positiveIntEnv("MAX_CONVERSATIONS_PER_TENANT", defaultMaxConversationsPerTenant) + if err != nil { + return nil, err + } + conversationTTL, err := positiveDurationEnv("CONVERSATION_TTL", defaultConversationTTL) + if err != nil { + return nil, err + } + + return &Config{ + BindAddress: stringEnv("BIND_ADDR", defaultBindAddress), + Port: stringEnv("PORT", defaultPort), + ClaudeBaseURL: stringEnv("CLAUDE_BASE_URL", "https://claude.ai"), + Timezone: stringEnv("CLAUDE_TIMEZONE", "Asia/Singapore"), + Locale: stringEnv("CLAUDE_LOCALE", "en-US"), + DefaultModel: stringEnv("DEFAULT_MODEL", "claude-sonnet-5"), + Tenants: tenants, + RateLimitPerMinute: ratePerMinute, + RateLimitBurst: burst, + MaxRequestBytes: maxRequestBytes, + MaxConversationsPerTenant: maxConversations, + ConversationTTL: conversationTTL, + }, nil } -// New creates a Config from environment variables with sane defaults -func New() *Config { - port := os.Getenv("PORT") - if port == "" { - port = "8080" +func loadTenants() (map[string]Tenant, error) { + filePath := strings.TrimSpace(os.Getenv("TENANTS_FILE")) + if filePath != "" { + if os.Getenv("PROXY_API_KEYS") != "" || os.Getenv("CLAUDE_SESSION_KEY") != "" || os.Getenv("CLAUDE_COOKIE") != "" { + return nil, fmt.Errorf("TENANTS_FILE cannot be combined with PROXY_API_KEYS or global Claude credentials") + } + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("read TENANTS_FILE: %w", err) + } + return ParseTenantsJSON(data) } - baseURL := os.Getenv("CLAUDE_BASE_URL") - if baseURL == "" { - baseURL = "https://claude.ai" + keys, err := ParseProxyAPIKeys(os.Getenv("PROXY_API_KEYS")) + if err != nil { + return nil, err + } + if len(keys) > 1 { + return nil, fmt.Errorf("multiple tenants require TENANTS_FILE so upstream credentials cannot be shared") + } + sessionKey := os.Getenv("CLAUDE_SESSION_KEY") + cookie := os.Getenv("CLAUDE_COOKIE") + hasSession := strings.TrimSpace(sessionKey) != "" + hasCookie := strings.TrimSpace(cookie) != "" + if len(keys) == 0 && (hasSession || hasCookie) { + return nil, fmt.Errorf("global Claude credentials require one PROXY_API_KEYS entry") } + if len(keys) == 1 && hasSession == hasCookie { + return nil, fmt.Errorf("single-tenant mode requires exactly one of CLAUDE_SESSION_KEY or CLAUDE_COOKIE") + } + result := make(map[string]Tenant, len(keys)) + for tenantID, digest := range keys { + result[tenantID] = newTenant(digest, sessionKey, cookie) + } + return result, nil +} - model := os.Getenv("DEFAULT_MODEL") - if model == "" { - model = "claude-sonnet-5" +// ParseTenantsJSON validates a protected tenant credential file. +func ParseTenantsJSON(data []byte) (map[string]Tenant, error) { + var entries map[string]tenantFileEntry + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&entries); err != nil { + return nil, fmt.Errorf("parse TENANTS_FILE: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return nil, fmt.Errorf("parse TENANTS_FILE: unexpected trailing JSON data") + } + if len(entries) == 0 { + return nil, fmt.Errorf("TENANTS_FILE must contain at least one tenant") } - locale := os.Getenv("CLAUDE_LOCALE") - if locale == "" { - locale = "en-US" + result := make(map[string]Tenant, len(entries)) + seenDigests := make(map[[sha256.Size]byte]string) + for tenantID, entry := range entries { + if !tenantIDPattern.MatchString(tenantID) { + return nil, fmt.Errorf("invalid tenant id %q in TENANTS_FILE", tenantID) + } + digest, err := parseDigest(tenantID, entry.APIKeySHA256) + if err != nil { + return nil, err + } + if previous, exists := seenDigests[digest]; exists { + return nil, fmt.Errorf("tenants %q and %q use the same downstream API key digest", previous, tenantID) + } + hasSession := strings.TrimSpace(entry.SessionKey) != "" + hasCookie := strings.TrimSpace(entry.ClaudeCookie) != "" + if hasSession == hasCookie { + return nil, fmt.Errorf("tenant %q must configure exactly one upstream Claude credential", tenantID) + } + seenDigests[digest] = tenantID + result[tenantID] = newTenant(digest, entry.SessionKey, entry.ClaudeCookie) } + return result, nil +} - timezone := os.Getenv("CLAUDE_TIMEZONE") - if timezone == "" { - timezone = "Asia/Singapore" +func newTenant(digest [sha256.Size]byte, sessionKey, cookie string) Tenant { + material := "session:" + sessionKey + if cookie != "" { + material = "cookie:" + cookie + } + credentialDigest := sha256.Sum256([]byte(material)) + return Tenant{ + APIKeyHash: digest, + SessionKey: sessionKey, + ClaudeCookie: cookie, + CredentialID: hex.EncodeToString(credentialDigest[:]), } +} - return &Config{ - Port: port, - ClaudeBaseURL: baseURL, - SessionKey: os.Getenv("CLAUDE_SESSION_KEY"), - ClaudeCookie: os.Getenv("CLAUDE_COOKIE"), - Timezone: timezone, - Locale: locale, - DefaultModel: model, +func stringEnv(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value } + return fallback +} + +func positiveIntEnv(name string, fallback int) (int, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed <= 0 { + return 0, fmt.Errorf("%s must be a positive integer", name) + } + return parsed, nil +} + +func positiveInt64Env(name string, fallback int64) (int64, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed <= 0 { + return 0, fmt.Errorf("%s must be a positive integer", name) + } + return parsed, nil +} + +func positiveDurationEnv(name string, fallback time.Duration) (time.Duration, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback, nil + } + parsed, err := time.ParseDuration(value) + if err != nil || parsed <= 0 { + return 0, fmt.Errorf("%s must be a positive Go duration such as 30m or 24h", name) + } + return parsed, nil +} + +// ParseProxyAPIKeys parses the single-tenant compatibility format. +func ParseProxyAPIKeys(value string) (map[string][sha256.Size]byte, error) { + result := make(map[string][sha256.Size]byte) + if strings.TrimSpace(value) == "" { + return result, nil + } + for _, entry := range strings.Split(value, ",") { + parts := strings.SplitN(strings.TrimSpace(entry), "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("PROXY_API_KEYS entries must use tenant=sha256hex") + } + tenantID := strings.TrimSpace(parts[0]) + if !tenantIDPattern.MatchString(tenantID) { + return nil, fmt.Errorf("invalid tenant id %q in PROXY_API_KEYS", tenantID) + } + if _, exists := result[tenantID]; exists { + return nil, fmt.Errorf("duplicate tenant id %q in PROXY_API_KEYS", tenantID) + } + digest, err := parseDigest(tenantID, parts[1]) + if err != nil { + return nil, err + } + for previousTenant, previousDigest := range result { + if previousDigest == digest { + return nil, fmt.Errorf("tenants %q and %q use the same downstream API key digest", previousTenant, tenantID) + } + } + result[tenantID] = digest + } + return result, nil +} + +func parseDigest(tenantID, value string) ([sha256.Size]byte, error) { + var digest [sha256.Size]byte + digestBytes, err := hex.DecodeString(strings.TrimSpace(value)) + if err != nil || len(digestBytes) != sha256.Size { + return digest, fmt.Errorf("tenant %q must use a 64-character SHA-256 digest", tenantID) + } + copy(digest[:], digestBytes) + return digest, nil +} + +func (c *Config) Ready() bool { + if len(c.Tenants) == 0 { + return false + } + for _, tenant := range c.Tenants { + if tenant.SessionKey == "" && tenant.ClaudeCookie == "" { + return false + } + } + return true +} + +func (c *Config) APIKeyHashes() map[string][sha256.Size]byte { + result := make(map[string][sha256.Size]byte, len(c.Tenants)) + for tenantID, tenant := range c.Tenants { + result[tenantID] = tenant.APIKeyHash + } + return result +} + +func (c *Config) UpstreamForTenant(tenantID string) (Tenant, bool) { + tenant, ok := c.Tenants[tenantID] + return tenant, ok } -// SupportedModels is the set of models exposed by the API var SupportedModels = map[string]string{ "claude-fable-5": "claude-fable-5", "claude-opus-4-8": "claude-opus-4-8", diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..cbe5390 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,104 @@ +package config + +import ( + "crypto/sha256" + "fmt" + "strings" + "testing" +) + +func TestParseProxyAPIKeys(t *testing.T) { + apiKey := "c2a_alpha_test_key_1234567890123456" + digest := sha256.Sum256([]byte(apiKey)) + keys, err := ParseProxyAPIKeys(fmt.Sprintf("alpha=%x", digest)) + if err != nil { + t.Fatalf("ParseProxyAPIKeys returned error: %v", err) + } + if keys["alpha"] != digest { + t.Fatal("parsed digest does not match expected API key digest") + } +} + +func TestParseProxyAPIKeysRejectsMalformedEntries(t *testing.T) { + digest := strings.Repeat("00", sha256.Size) + for _, value := range []string{ + "missing-separator", + "bad tenant=" + digest, + "duplicate=" + digest + ",duplicate=" + strings.Repeat("11", sha256.Size), + "tenant-a=" + digest + ",tenant-b=" + digest, + "short=abcd", + } { + t.Run(value, func(t *testing.T) { + if _, err := ParseProxyAPIKeys(value); err == nil { + t.Fatal("expected malformed entry to fail") + } + }) + } +} + +func TestParseTenantsJSONBindsDistinctCredentials(t *testing.T) { + alphaDigest := sha256.Sum256([]byte("c2a_alpha_test_key_1234567890123456")) + betaDigest := sha256.Sum256([]byte("c2a_beta_test_key_12345678901234567")) + data := []byte(fmt.Sprintf(`{ + "alpha": {"api_key_sha256":"%x", "claude_session_key":"upstream-a"}, + "beta": {"api_key_sha256":"%x", "claude_cookie":"sessionKey=upstream-b"} + }`, alphaDigest, betaDigest)) + + tenants, err := ParseTenantsJSON(data) + if err != nil { + t.Fatalf("ParseTenantsJSON returned error: %v", err) + } + if len(tenants) != 2 || tenants["alpha"].APIKeyHash != alphaDigest || tenants["beta"].APIKeyHash != betaDigest { + t.Fatal("tenant API key bindings were not preserved") + } + if tenants["alpha"].CredentialID == tenants["beta"].CredentialID { + t.Fatal("distinct upstream credentials must have distinct binding IDs") + } + cfg := &Config{Tenants: tenants} + if !cfg.Ready() { + t.Fatal("fully configured tenant map should be ready") + } +} + +func TestParseTenantsJSONRejectsSharedKeysAndMissingUpstream(t *testing.T) { + digest := strings.Repeat("00", sha256.Size) + for _, data := range []string{ + `{}`, + fmt.Sprintf(`{"alpha":{"api_key_sha256":"%s"}}`, digest), + fmt.Sprintf(`{"alpha":{"api_key_sha256":"%s","claude_session_key":"a","claude_cookie":"b"}}`, digest), + fmt.Sprintf(`{"alpha":{"api_key_sha256":"%s","claude_session_key":"a","unknown":"b"}}`, digest), + fmt.Sprintf(`{"alpha":{"api_key_sha256":"%s","claude_session_key":"a"},"beta":{"api_key_sha256":"%s","claude_session_key":"b"}}`, digest, digest), + } { + if _, err := ParseTenantsJSON([]byte(data)); err == nil { + t.Fatalf("expected tenant file to fail: %s", data) + } + } +} + +func TestLoadRejectsImplicitMultiTenantCredentialSharing(t *testing.T) { + t.Setenv("TENANTS_FILE", "") + t.Setenv("PROXY_API_KEYS", "alpha="+strings.Repeat("00", sha256.Size)+",beta="+strings.Repeat("11", sha256.Size)) + t.Setenv("CLAUDE_SESSION_KEY", "shared-upstream") + t.Setenv("CLAUDE_COOKIE", "") + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "TENANTS_FILE") { + t.Fatalf("expected multi-tenant global credential configuration to fail, got %v", err) + } +} + +func TestLoadValidatesConversationTTL(t *testing.T) { + t.Setenv("TENANTS_FILE", "") + t.Setenv("PROXY_API_KEYS", "") + t.Setenv("CONVERSATION_TTL", "not-a-duration") + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "CONVERSATION_TTL") { + t.Fatalf("expected invalid conversation TTL to fail, got %v", err) + } + + t.Setenv("CONVERSATION_TTL", "45m") + cfg, err := Load() + if err != nil { + t.Fatalf("expected valid conversation TTL, got %v", err) + } + if cfg.ConversationTTL.String() != "45m0s" { + t.Fatalf("unexpected conversation TTL: %s", cfg.ConversationTTL) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 450ef0c..d87844e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,12 +5,20 @@ services: container_name: claude2api restart: unless-stopped ports: - - "${PORT:-8080}:8080" + - "${PUBLISH_ADDR:-127.0.0.1}:${PORT:-8080}:8080" environment: PORT: "8080" + BIND_ADDR: "0.0.0.0" CLAUDE_BASE_URL: "${CLAUDE_BASE_URL:-https://claude.ai}" + TENANTS_FILE: "${TENANTS_FILE:-}" + PROXY_API_KEYS: "${PROXY_API_KEYS:-}" CLAUDE_SESSION_KEY: "${CLAUDE_SESSION_KEY:-}" CLAUDE_COOKIE: "${CLAUDE_COOKIE:-}" + RATE_LIMIT_RPM: "${RATE_LIMIT_RPM:-60}" + RATE_LIMIT_BURST: "${RATE_LIMIT_BURST:-10}" + MAX_REQUEST_BYTES: "${MAX_REQUEST_BYTES:-1048576}" + MAX_CONVERSATIONS_PER_TENANT: "${MAX_CONVERSATIONS_PER_TENANT:-100}" + CONVERSATION_TTL: "${CONVERSATION_TTL:-24h}" CLAUDE_TIMEZONE: "${CLAUDE_TIMEZONE:-Asia/Singapore}" CLAUDE_LOCALE: "${CLAUDE_LOCALE:-en-US}" DEFAULT_MODEL: "${DEFAULT_MODEL:-claude-sonnet-5}" diff --git a/go.mod b/go.mod index 77d40bd..9a310ba 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module claude2api -go 1.26.4 +go 1.26.5 require ( github.com/bogdanfinn/fhttp v0.6.8 @@ -35,15 +35,15 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.59.0 // indirect + github.com/quic-go/quic-go v0.59.1 // indirect github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect google.golang.org/protobuf v1.36.10 // indirect ) diff --git a/go.sum b/go.sum index f4cc365..e38f805 100644 --- a/go.sum +++ b/go.sum @@ -67,8 +67,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= -github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= +github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -94,20 +94,20 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/handlers/anthropic.go b/handlers/anthropic.go index 9c6521d..2bd6a20 100644 --- a/handlers/anthropic.go +++ b/handlers/anthropic.go @@ -13,14 +13,16 @@ import ( // AnthropicMessages handles POST /v1/messages (Anthropic format, streaming + non-streaming) func (h *Handler) AnthropicMessages(c *gin.Context) { var req models.AnthropicRequest - if err := c.ShouldBindJSON(&req); err != nil { - badRequest(c, "invalid request body: "+err.Error()) + if !bindJSON(c, &req) { return } if len(req.Messages) == 0 { badRequest(c, "messages must contain at least one message") return } + if !validateConversationID(c, req.ConversationID) { + return + } if req.MaxTokens == 0 { req.MaxTokens = 4096 } @@ -33,7 +35,7 @@ func (h *Handler) AnthropicMessages(c *gin.Context) { client, err := h.newClient(c) if err != nil { - internalError(c, "create client: "+err.Error()) + serviceUnavailable(c) return } @@ -91,9 +93,9 @@ func anthropicContentToString(content interface{}) string { } func (h *Handler) anthropicNonStream(c *gin.Context, client *claude.Client, prompt, claudeModel, effort, conversationID string) { - content, err := h.runCompletion(c.Request.Context(), client, prompt, claudeModel, effort, conversationID, nil) + content, err := h.runCompletion(c.Request.Context(), client, tenantID(c), prompt, claudeModel, effort, conversationID, nil) if err != nil { - upstreamError(c, err.Error()) + completionError(c, err) return } resp := models.AnthropicResponse{ @@ -112,7 +114,7 @@ func (h *Handler) anthropicNonStream(c *gin.Context, client *claude.Client, prom func (h *Handler) anthropicStream(c *gin.Context, client *claude.Client, prompt, claudeModel, effort, conversationID string) { c.Writer.Header().Set("Content-Type", "text/event-stream") - c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Cache-Control", "private, no-store") c.Writer.Header().Set("Connection", "keep-alive") c.Writer.Header().Set("X-Accel-Buffering", "no") flusher, _ := c.Writer.(http.Flusher) @@ -140,7 +142,7 @@ func (h *Handler) anthropicStream(c *gin.Context, client *claude.Client, prompt, }) var outputChars int - _, err := h.runCompletion(c.Request.Context(), client, prompt, claudeModel, effort, conversationID, func(text string) { + _, err := h.runCompletion(c.Request.Context(), client, tenantID(c), prompt, claudeModel, effort, conversationID, func(text string) { outputChars += len(text) writeSSE(c.Writer, models.AnthropicStreamContentBlockDelta{ Type: "content_block_delta", diff --git a/handlers/chat.go b/handlers/chat.go index 45d2400..d11f2a4 100644 --- a/handlers/chat.go +++ b/handlers/chat.go @@ -13,14 +13,16 @@ import ( // ChatCompletion handles POST /v1/chat/completions (OpenAI format, streaming + non-streaming) func (h *Handler) ChatCompletion(c *gin.Context) { var req models.ChatCompletionRequest - if err := c.ShouldBindJSON(&req); err != nil { - badRequest(c, "invalid request body: "+err.Error()) + if !bindJSON(c, &req) { return } if len(req.Messages) == 0 { badRequest(c, "messages must contain at least one message") return } + if !validateConversationID(c, req.ConversationID) { + return + } claudeModel, err := resolveModel(req.Model, h.cfg.DefaultModel) if err != nil { @@ -30,7 +32,7 @@ func (h *Handler) ChatCompletion(c *gin.Context) { client, err := h.newClient(c) if err != nil { - internalError(c, "create client: "+err.Error()) + serviceUnavailable(c) return } @@ -45,9 +47,9 @@ func (h *Handler) ChatCompletion(c *gin.Context) { } func (h *Handler) chatCompletionNonStream(c *gin.Context, client *claude.Client, prompt, claudeModel, effort, conversationID string) { - content, err := h.runCompletion(c.Request.Context(), client, prompt, claudeModel, effort, conversationID, nil) + content, err := h.runCompletion(c.Request.Context(), client, tenantID(c), prompt, claudeModel, effort, conversationID, nil) if err != nil { - upstreamError(c, err.Error()) + completionError(c, err) return } resp := models.ChatCompletionResponse{ @@ -69,7 +71,7 @@ func (h *Handler) chatCompletionNonStream(c *gin.Context, client *claude.Client, func (h *Handler) chatCompletionStream(c *gin.Context, client *claude.Client, prompt, claudeModel, effort, conversationID string) { c.Writer.Header().Set("Content-Type", "text/event-stream") - c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Cache-Control", "private, no-store") c.Writer.Header().Set("Connection", "keep-alive") c.Writer.Header().Set("X-Accel-Buffering", "no") flusher, _ := c.Writer.(http.Flusher) @@ -86,7 +88,7 @@ func (h *Handler) chatCompletionStream(c *gin.Context, client *claude.Client, pr flusher.Flush() } - _, err := h.runCompletion(c.Request.Context(), client, prompt, claudeModel, effort, conversationID, func(text string) { + _, err := h.runCompletion(c.Request.Context(), client, tenantID(c), prompt, claudeModel, effort, conversationID, func(text string) { writeSSE(c.Writer, models.ChatCompletionChunk{ ID: chunkID, Object: "chat.completion.chunk", Created: created, Model: claudeModel, Choices: []models.ChunkChoice{{Index: 0, Delta: models.Delta{Content: text}}}, @@ -98,7 +100,7 @@ func (h *Handler) chatCompletionStream(c *gin.Context, client *claude.Client, pr if err != nil { writeSSE(c.Writer, models.ChatCompletionChunk{ ID: chunkID, Object: "chat.completion.chunk", Created: created, Model: claudeModel, - Choices: []models.ChunkChoice{{Index: 0, Delta: models.Delta{Content: "[error: " + err.Error() + "]"}}}, + Choices: []models.ChunkChoice{{Index: 0, Delta: models.Delta{Content: "[upstream_error]"}}}, }) } diff --git a/handlers/common.go b/handlers/common.go index fed332d..7ee3a3e 100644 --- a/handlers/common.go +++ b/handlers/common.go @@ -3,6 +3,7 @@ package handlers import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -11,6 +12,7 @@ import ( "claude2api/claude" "claude2api/config" + "claude2api/middleware" "claude2api/models" "claude2api/utils" @@ -25,7 +27,10 @@ type Handler struct { // NewHandler creates a handler func NewHandler(cfg *config.Config) *Handler { - return &Handler{cfg: cfg, conversations: newConversationStore()} + return &Handler{ + cfg: cfg, + conversations: newConversationStore(cfg.MaxConversationsPerTenant, cfg.ConversationTTL), + } } // resolveModel returns the claude.ai model id for a requested model, or an error @@ -35,40 +40,78 @@ func resolveModel(requested, fallback string) (string, error) { } m, ok := config.SupportedModels[requested] if !ok { - return "", fmt.Errorf("model '%s' is not supported", requested) + return "", errors.New("model is not supported") } return m, nil } -// newClient builds a claude.ai client from the session key in context +func bindJSON(c *gin.Context, destination interface{}) bool { + if err := c.ShouldBindJSON(destination); err != nil { + var maxBytesError *http.MaxBytesError + if errors.As(err, &maxBytesError) { + writeError(c, http.StatusRequestEntityTooLarge, "request body too large", "invalid_request_error") + return false + } + badRequest(c, "invalid request body") + return false + } + return true +} + +func validateConversationID(c *gin.Context, conversationID string) bool { + if conversationID == "" || validConversationID(conversationID) { + return true + } + badRequest(c, "conversation_id must be 1-128 characters using letters, numbers, dot, underscore, colon, or dash") + return false +} + +// newClient builds a claude.ai client only from server-side credentials. func (h *Handler) newClient(c *gin.Context) (*claude.Client, error) { - sessionKey, _ := c.Get("sessionKey") - claudeCookie, _ := c.Get("claudeCookie") - return claude.NewClient(h.cfg.ClaudeBaseURL, sessionKey.(string), claudeCookie.(string)) + tenant, ok := h.cfg.UpstreamForTenant(tenantID(c)) + if !ok || (tenant.SessionKey == "" && tenant.ClaudeCookie == "") { + return nil, errors.New("upstream credential is not configured") + } + return claude.NewClient(h.cfg.ClaudeBaseURL, tenant.SessionKey, tenant.ClaudeCookie) } // runCompletion drives a full claude.ai round-trip: create conversation, send // prompt, then invoke onText for each incremental text delta. // Returns the full accumulated text or an error. -func (h *Handler) runCompletion(ctx context.Context, client *claude.Client, prompt, claudeModel, effort, conversationID string, +func (h *Handler) runCompletion(ctx context.Context, client *claude.Client, tenantID, prompt, claudeModel, effort, conversationID string, onText func(text string)) (string, error) { convID := "" persistent := conversationID != "" + if persistent && !validConversationID(conversationID) { + return "", errInvalidConversationID + } var state *conversationState if persistent { - if existing, ok := h.conversations.get(conversationID); ok { - state = existing - convID = existing.ClaudeConversationID - } else { + tenant, ok := h.cfg.UpstreamForTenant(tenantID) + if !ok { + return "", errors.New("tenant upstream configuration not found") + } + var err error + state, _, err = h.conversations.acquire(tenantID, conversationID, tenant.CredentialID) + if err != nil { + return "", err + } + state.mu.Lock() + discardEmpty := state.ClaudeConversationID == "" + defer func() { + state.mu.Unlock() + h.conversations.release(state, discardEmpty) + }() + if state.ClaudeConversationID == "" { createdID, err := client.CreateConversation(ctx, "chat") if err != nil { return "", fmt.Errorf("create conversation: %w", err) } - convID = createdID - state = &conversationState{ClientConversationID: conversationID, ClaudeConversationID: convID} - h.conversations.set(state) + state.ClaudeConversationID = createdID + discardEmpty = false } + convID = state.ClaudeConversationID } else { createdID, err := client.CreateConversation(ctx, "chat") if err != nil { @@ -141,7 +184,9 @@ func (h *Handler) runCompletion(ctx context.Context, client *claude.Client, prom } } if persistent { - h.conversations.touch(conversationID, humanUUID, assistantUUID) + state.LastHumanUUID = humanUUID + state.LastAssistantUUID = assistantUUID + state.UpdatedAt = time.Now() } return sb.String(), nil } @@ -177,13 +222,56 @@ func genID(prefix string) string { // error helpers func badRequest(c *gin.Context, msg string) { - c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": msg, "type": "invalid_request_error"}}) + writeError(c, http.StatusBadRequest, msg, "invalid_request_error") } -func internalError(c *gin.Context, msg string) { - c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"message": msg, "type": "internal_error"}}) +func internalError(c *gin.Context) { + writeError(c, http.StatusInternalServerError, "internal server error", "internal_error") +} + +func upstreamError(c *gin.Context) { + writeError(c, http.StatusBadGateway, "upstream request failed", "upstream_error") +} + +func serviceUnavailable(c *gin.Context) { + writeError(c, http.StatusServiceUnavailable, "upstream service is not configured", "configuration_error") +} + +func completionError(c *gin.Context, err error) { + switch { + case errors.Is(err, errInvalidConversationID): + badRequest(c, "conversation_id must be 1-128 characters using letters, numbers, dot, underscore, colon, or dash") + case errors.Is(err, errConversationCredentialMismatch): + writeError(c, http.StatusConflict, "conversation binding conflict", "conflict_error") + case errors.Is(err, errConversationLimit): + writeError(c, http.StatusConflict, "tenant conversation limit reached", "conflict_error") + default: + upstreamError(c) + } +} + +func tenantID(c *gin.Context) string { + return middleware.TenantID(c) +} + +func validConversationID(value string) bool { + if len(value) == 0 || len(value) > 128 { + return false + } + for _, char := range value { + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || + (char >= '0' && char <= '9') || strings.ContainsRune("._:-", char) { + continue + } + return false + } + return true } -func upstreamError(c *gin.Context, msg string) { - c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{"message": msg, "type": "upstream_error"}}) +func writeError(c *gin.Context, status int, message, errorType string) { + c.JSON(status, gin.H{"error": gin.H{ + "message": message, + "type": errorType, + "request_id": middleware.RequestID(c), + }}) } diff --git a/handlers/conversation_store.go b/handlers/conversation_store.go index c15b3f2..54e8f0d 100644 --- a/handlers/conversation_store.go +++ b/handlers/conversation_store.go @@ -1,57 +1,128 @@ package handlers import ( + "errors" "sync" "time" ) +var ( + errInvalidConversationID = errors.New("invalid conversation id") + errConversationCredentialMismatch = errors.New("conversation credential binding mismatch") + errConversationLimit = errors.New("tenant conversation limit reached") + errConversationBusy = errors.New("conversation is currently in use") +) + +type conversationKey struct { + TenantID string + ClientConversationID string +} + type conversationState struct { + mu sync.Mutex + TenantID string + CredentialID string ClientConversationID string ClaudeConversationID string LastHumanUUID string LastAssistantUUID string UpdatedAt time.Time + active int + expiresAt time.Time } type conversationStore struct { - mu sync.RWMutex - data map[string]*conversationState + mu sync.RWMutex + data map[conversationKey]*conversationState + maxPerTenant int + ttl time.Duration + now func() time.Time } -func newConversationStore() *conversationStore { - return &conversationStore{data: make(map[string]*conversationState)} -} - -func (s *conversationStore) get(id string) (*conversationState, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - state, ok := s.data[id] - return state, ok +func newConversationStore(maxPerTenant int, ttl time.Duration) *conversationStore { + return &conversationStore{ + data: make(map[conversationKey]*conversationState), + maxPerTenant: maxPerTenant, + ttl: ttl, + now: time.Now, + } } -func (s *conversationStore) set(state *conversationState) { +func (s *conversationStore) acquire(tenantID, id, credentialID string) (*conversationState, bool, error) { s.mu.Lock() defer s.mu.Unlock() - state.UpdatedAt = time.Now() - s.data[state.ClientConversationID] = state + now := s.now() + s.pruneExpiredLocked(now) + + key := conversationKey{TenantID: tenantID, ClientConversationID: id} + if state, ok := s.data[key]; ok { + if state.CredentialID != credentialID { + return nil, false, errConversationCredentialMismatch + } + state.active++ + state.expiresAt = now.Add(s.ttl) + return state, false, nil + } + + count := 0 + for existingKey := range s.data { + if existingKey.TenantID == tenantID { + count++ + } + } + if count >= s.maxPerTenant { + return nil, false, errConversationLimit + } + + state := &conversationState{ + TenantID: tenantID, + CredentialID: credentialID, + ClientConversationID: id, + UpdatedAt: now, + active: 1, + expiresAt: now.Add(s.ttl), + } + s.data[key] = state + return state, true, nil } -func (s *conversationStore) touch(id, humanUUID, assistantUUID string) { +func (s *conversationStore) release(state *conversationState, discardEmpty bool) { s.mu.Lock() defer s.mu.Unlock() - if state, ok := s.data[id]; ok { - state.LastHumanUUID = humanUUID - state.LastAssistantUUID = assistantUUID - state.UpdatedAt = time.Now() + key := conversationKey{TenantID: state.TenantID, ClientConversationID: state.ClientConversationID} + if s.data[key] != state { + return + } + if state.active > 0 { + state.active-- } + if discardEmpty && state.active == 0 { + delete(s.data, key) + return + } + state.expiresAt = s.now().Add(s.ttl) } -func (s *conversationStore) delete(id string) (*conversationState, bool) { +func (s *conversationStore) delete(tenantID, id, credentialID string) (*conversationState, bool, error) { s.mu.Lock() defer s.mu.Unlock() - state, ok := s.data[id] - if ok { - delete(s.data, id) + s.pruneExpiredLocked(s.now()) + key := conversationKey{TenantID: tenantID, ClientConversationID: id} + state, ok := s.data[key] + if !ok || state.CredentialID != credentialID { + return nil, false, nil + } + if state.active > 0 { + return nil, false, errConversationBusy + } + delete(s.data, key) + return state, true, nil +} + +func (s *conversationStore) pruneExpiredLocked(now time.Time) { + for key, state := range s.data { + if state.active == 0 && !state.expiresAt.After(now) { + delete(s.data, key) + } } - return state, ok } diff --git a/handlers/conversation_store_test.go b/handlers/conversation_store_test.go new file mode 100644 index 0000000..1cf1bb8 --- /dev/null +++ b/handlers/conversation_store_test.go @@ -0,0 +1,115 @@ +package handlers + +import ( + "errors" + "testing" + "time" +) + +func TestConversationStoreTenantAndCredentialIsolation(t *testing.T) { + store := newConversationStore(1, time.Hour) + stateA, created, err := store.acquire("tenant-a", "shared-id", "credential-a") + if err != nil || !created { + t.Fatalf("create tenant-a state: created=%t err=%v", created, err) + } + store.release(stateA, false) + stateB, created, err := store.acquire("tenant-b", "shared-id", "credential-a") + if err != nil || !created { + t.Fatalf("create tenant-b state: created=%t err=%v", created, err) + } + store.release(stateB, false) + if stateA == stateB { + t.Fatal("different tenants must not share conversation state") + } + + if _, _, err := store.acquire("tenant-a", "shared-id", "credential-b"); !errors.Is(err, errConversationCredentialMismatch) { + t.Fatalf("expected credential binding mismatch, got %v", err) + } + if _, _, err := store.acquire("tenant-a", "second-id", "credential-a"); !errors.Is(err, errConversationLimit) { + t.Fatalf("expected per-tenant conversation limit, got %v", err) + } + if _, _, err := store.acquire("tenant-b", "second-id", "credential-a"); !errors.Is(err, errConversationLimit) { + t.Fatalf("tenant-b should enforce its own limit, got %v", err) + } +} + +func TestConversationDeleteDoesNotRevealOtherTenants(t *testing.T) { + store := newConversationStore(2, time.Hour) + state, _, err := store.acquire("tenant-a", "conversation", "credential") + if err != nil { + t.Fatal(err) + } + store.release(state, false) + if _, ok, err := store.delete("tenant-b", "conversation", "credential"); err != nil || ok { + t.Fatal("another tenant must not delete or discover the conversation") + } + if deleted, ok, err := store.delete("tenant-a", "conversation", "credential"); err != nil || !ok || deleted != state { + t.Fatal("owner tenant should delete its conversation") + } +} + +func TestConversationStorePrunesExpiredMappings(t *testing.T) { + now := time.Unix(1000, 0) + store := newConversationStore(1, time.Minute) + store.now = func() time.Time { return now } + state, _, err := store.acquire("tenant", "expired", "credential") + if err != nil { + t.Fatal(err) + } + store.release(state, false) + + now = now.Add(2 * time.Minute) + replacement, created, err := store.acquire("tenant", "replacement", "credential") + if err != nil || !created { + t.Fatalf("expired mapping should release capacity: created=%t err=%v", created, err) + } + store.release(replacement, false) +} + +func TestConversationStoreKeepsActiveMappingsAndRejectsBusyDelete(t *testing.T) { + now := time.Unix(1000, 0) + store := newConversationStore(1, time.Minute) + store.now = func() time.Time { return now } + state, _, err := store.acquire("tenant", "active", "credential") + if err != nil { + t.Fatal(err) + } + now = now.Add(2 * time.Minute) + if _, _, err := store.acquire("tenant", "second", "credential"); !errors.Is(err, errConversationLimit) { + t.Fatalf("active mapping must not be pruned, got %v", err) + } + if _, _, err := store.delete("tenant", "active", "credential"); !errors.Is(err, errConversationBusy) { + t.Fatalf("active mapping delete should fail closed, got %v", err) + } + store.release(state, false) + if _, ok, err := store.delete("tenant", "active", "credential"); err != nil || !ok { + t.Fatalf("released mapping should be deletable: ok=%t err=%v", ok, err) + } +} + +func TestConversationStoreDiscardsFailedEmptyMapping(t *testing.T) { + store := newConversationStore(1, time.Hour) + state, _, err := store.acquire("tenant", "failed", "credential") + if err != nil { + t.Fatal(err) + } + store.release(state, true) + replacement, created, err := store.acquire("tenant", "replacement", "credential") + if err != nil || !created { + t.Fatalf("failed empty mapping should not consume capacity: created=%t err=%v", created, err) + } + store.release(replacement, false) +} + +func TestConversationIDValidation(t *testing.T) { + for _, valid := range []string{"chat-1", "tenant.thread_2", "abc:def"} { + if !validConversationID(valid) { + t.Fatalf("expected valid conversation ID: %q", valid) + } + } + for _, invalid := range []string{"", "../escape", "contains space", string(make([]byte, 129))} { + if validConversationID(invalid) { + t.Fatalf("expected invalid conversation ID: %q", invalid) + } + } +} diff --git a/handlers/conversations.go b/handlers/conversations.go index 122986f..ebfbf1b 100644 --- a/handlers/conversations.go +++ b/handlers/conversations.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "errors" "net/http" "time" @@ -11,27 +12,38 @@ import ( // DeleteConversation deletes a persistent conversation mapping and its upstream claude.ai conversation. func (h *Handler) DeleteConversation(c *gin.Context) { conversationID := c.Param("id") - if conversationID == "" { - badRequest(c, "conversation id is required") + if !validConversationID(conversationID) { + badRequest(c, "conversation id is invalid") return } - state, ok := h.conversations.delete(conversationID) + tenant, configured := h.cfg.UpstreamForTenant(tenantID(c)) + if !configured { + serviceUnavailable(c) + return + } + state, ok, err := h.conversations.delete(tenantID(c), conversationID, tenant.CredentialID) + if errors.Is(err, errConversationBusy) { + writeError(c, http.StatusConflict, "conversation is currently in use", "conflict_error") + return + } if !ok { - c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"message": "conversation not found", "type": "not_found_error"}}) + writeError(c, http.StatusNotFound, "conversation not found", "not_found_error") return } + state.mu.Lock() + defer state.mu.Unlock() client, err := h.newClient(c) if err != nil { - internalError(c, "create client: "+err.Error()) + serviceUnavailable(c) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) defer cancel() if err := client.DeleteConversation(ctx, state.ClaudeConversationID); err != nil { - upstreamError(c, err.Error()) + upstreamError(c) return } diff --git a/handlers/errors_test.go b/handlers/errors_test.go new file mode 100644 index 0000000..3fe03ee --- /dev/null +++ b/handlers/errors_test.go @@ -0,0 +1,74 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "claude2api/middleware" + + "github.com/gin-gonic/gin" +) + +func TestUpstreamErrorIsRedactedAndTraceable(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(middleware.RequestSecurity(1024)) + router.GET("/failure", func(c *gin.Context) { upstreamError(c) }) + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/failure", nil)) + + if response.Code != http.StatusBadGateway { + t.Fatalf("expected 502, got %d", response.Code) + } + if !strings.Contains(response.Body.String(), "upstream request failed") { + t.Fatal("expected stable public upstream error") + } + if !strings.Contains(response.Body.String(), "request_id") || response.Header().Get("X-Request-ID") == "" { + t.Fatal("error must include a non-secret request ID") + } + for _, forbidden := range []string{"sessionKey", "Cookie", "Bearer"} { + if strings.Contains(response.Body.String(), forbidden) { + t.Fatalf("error response leaked forbidden text: %s", forbidden) + } + } +} + +func TestBindJSONReportsOversizedBody(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(middleware.RequestSecurity(8)) + router.POST("/body", func(c *gin.Context) { + var body map[string]interface{} + if bindJSON(c, &body) { + c.Status(http.StatusNoContent) + } + }) + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/body", strings.NewReader(`{"value":"too-large"}`)) + request.Header.Set("Content-Type", "application/json") + router.ServeHTTP(response, request) + + if response.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("expected 413, got %d: %s", response.Code, response.Body.String()) + } + if !strings.Contains(response.Body.String(), "request body too large") { + t.Fatalf("unexpected error body: %s", response.Body.String()) + } +} + +func TestValidateConversationIDRejectsBeforeStreaming(t *testing.T) { + router := gin.New() + router.Use(middleware.RequestSecurity(1024)) + router.POST("/conversation", func(c *gin.Context) { + if validateConversationID(c, "../other-tenant") { + c.Status(http.StatusNoContent) + } + }) + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/conversation", nil)) + if response.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", response.Code, response.Body.String()) + } +} diff --git a/handlers/responses.go b/handlers/responses.go index d7c3bdd..db0ef73 100644 --- a/handlers/responses.go +++ b/handlers/responses.go @@ -14,8 +14,10 @@ import ( // Responses handles POST /v1/responses (OpenAI Responses API, streaming + non-streaming) func (h *Handler) Responses(c *gin.Context) { var req models.ResponsesRequest - if err := c.ShouldBindJSON(&req); err != nil { - badRequest(c, "invalid request body: "+err.Error()) + if !bindJSON(c, &req) { + return + } + if !validateConversationID(c, req.ConversationID) { return } @@ -27,7 +29,7 @@ func (h *Handler) Responses(c *gin.Context) { client, err := h.newClient(c) if err != nil { - internalError(c, "create client: "+err.Error()) + serviceUnavailable(c) return } @@ -112,9 +114,9 @@ func flattenContentParts(parts []interface{}) string { } func (h *Handler) responsesNonStream(c *gin.Context, client *claude.Client, prompt, claudeModel, effort, conversationID string) { - content, err := h.runCompletion(c.Request.Context(), client, prompt, claudeModel, effort, conversationID, nil) + content, err := h.runCompletion(c.Request.Context(), client, tenantID(c), prompt, claudeModel, effort, conversationID, nil) if err != nil { - upstreamError(c, err.Error()) + completionError(c, err) return } respID := genID("resp_") @@ -142,7 +144,7 @@ func (h *Handler) responsesNonStream(c *gin.Context, client *claude.Client, prom func (h *Handler) responsesStream(c *gin.Context, client *claude.Client, prompt, claudeModel, effort, conversationID string) { c.Writer.Header().Set("Content-Type", "text/event-stream") - c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Cache-Control", "private, no-store") c.Writer.Header().Set("Connection", "keep-alive") c.Writer.Header().Set("X-Accel-Buffering", "no") flusher, _ := c.Writer.(http.Flusher) @@ -171,7 +173,7 @@ func (h *Handler) responsesStream(c *gin.Context, client *claude.Client, prompt, }) var full strings.Builder - _, err := h.runCompletion(c.Request.Context(), client, prompt, claudeModel, effort, conversationID, func(text string) { + _, err := h.runCompletion(c.Request.Context(), client, tenantID(c), prompt, claudeModel, effort, conversationID, func(text string) { full.WriteString(text) writeSSE(c.Writer, models.ResponsesOutputTextDelta{ Type: "response.output_text.delta", ItemID: msgID, OutputIndex: 0, ContentIndex: 0, Delta: text, diff --git a/main.go b/main.go index dd9b037..cc7f889 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "context" "log" + "net" "net/http" "os" "os/signal" @@ -17,26 +18,34 @@ import ( ) func main() { - cfg := config.New() + cfg, err := config.Load() + if err != nil { + log.Fatalf("configuration error: %v", err) + } if os.Getenv("GIN_MODE") == "" { gin.SetMode(gin.ReleaseMode) } r := gin.New() - r.Use(gin.Recovery()) - r.Use(gin.Logger()) + r.Use(middleware.RedactedRecovery()) + r.Use(middleware.RequestSecurity(cfg.MaxRequestBytes)) + r.Use(middleware.RedactedAccessLogger()) + if err := r.SetTrustedProxies(nil); err != nil { + log.Fatalf("trusted proxy configuration: %v", err) + } // Health check r.GET("/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "ok"}) + c.JSON(http.StatusOK, gin.H{"status": "ok", "ready": cfg.Ready()}) }) h := handlers.NewHandler(cfg) // OpenAI-compatible endpoints v1 := r.Group("/v1") - v1.Use(middleware.BrowserAuth(cfg.SessionKey, cfg.ClaudeCookie)) + v1.Use(middleware.APIKeyAuth(cfg.APIKeyHashes())) + v1.Use(middleware.NewTenantRateLimiter(cfg.RateLimitPerMinute, cfg.RateLimitBurst).Middleware()) { v1.GET("/models", h.ListModels) v1.POST("/chat/completions", h.ChatCompletion) @@ -46,19 +55,19 @@ func main() { } srv := &http.Server{ - Addr: ":" + cfg.Port, - Handler: r, + Addr: net.JoinHostPort(cfg.BindAddress, cfg.Port), + Handler: r, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 32 << 10, } go func() { - log.Printf("claude2api listening on :%s", cfg.Port) - log.Printf(" Base URL : %s", cfg.ClaudeBaseURL) + log.Printf("claude2api listening on %s", srv.Addr) log.Printf(" Models : %d", len(config.SupportedModels)) - if cfg.SessionKey != "" { - log.Printf(" Auth : env session key configured") - } else { - log.Printf(" Auth : per-request Bearer token required") - } + log.Printf(" Tenants : %d", len(cfg.Tenants)) + log.Printf(" Ready : %t", cfg.Ready()) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("listen: %s", err) } diff --git a/middleware/auth.go b/middleware/auth.go index de05224..b0dee76 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -1,65 +1,71 @@ package middleware import ( + "crypto/sha256" + "crypto/subtle" "net/http" "strings" "github.com/gin-gonic/gin" ) -// BearerAuth provides OpenAI-compatible Bearer token authentication. -// The token is the claude.ai sessionKey. -// If an env-level session key is configured, the Bearer token is optional. -func BearerAuth(envSessionKey string) gin.HandlerFunc { - return BrowserAuth(envSessionKey, "") -} +const tenantContextKey = "tenantID" -// BrowserAuth accepts a sessionKey plus an optional full claude.ai Cookie header. -func BrowserAuth(envSessionKey, envClaudeCookie string) gin.HandlerFunc { +// APIKeyAuth authenticates callers with independent proxy API keys. Browser +// credentials are intentionally rejected at the downstream boundary. +func APIKeyAuth(keyHashes map[string][sha256.Size]byte) gin.HandlerFunc { return func(c *gin.Context) { - // Extract Bearer token from Authorization header - authHeader := c.GetHeader("Authorization") - var token string - if strings.HasPrefix(authHeader, "Bearer ") { - token = strings.TrimPrefix(authHeader, "Bearer ") + if c.GetHeader("Cookie") != "" || c.GetHeader("X-Claude-Cookie") != "" || c.GetHeader("X-Claude-Session-Key") != "" { + abortError(c, http.StatusBadRequest, "browser credentials are not accepted from clients", "invalid_request_error") + return } - - // If env session key is set, use it as fallback - if token == "" && envSessionKey != "" { - token = envSessionKey + if len(keyHashes) == 0 { + abortError(c, http.StatusServiceUnavailable, "gateway authentication is not configured", "configuration_error") + return } - cookie := c.GetHeader("X-Claude-Cookie") - if cookie == "" { - cookie = envClaudeCookie - } + token := bearerToken(c.GetHeader("Authorization")) if token == "" { - token = sessionKeyFromCookie(cookie) + abortError(c, http.StatusUnauthorized, "missing or invalid API key", "authentication_error") + return } - if token == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "error": gin.H{ - "message": "Missing API key. Provide via Authorization: Bearer or X-Claude-Cookie", - "type": "invalid_request_error", - }, - }) + candidate := sha256.Sum256([]byte(token)) + matchedTenant := "" + for tenantID, expected := range keyHashes { + if subtle.ConstantTimeCompare(candidate[:], expected[:]) == 1 { + matchedTenant = tenantID + } + } + if matchedTenant == "" { + abortError(c, http.StatusUnauthorized, "missing or invalid API key", "authentication_error") return } - // Store in context for downstream handlers - c.Set("sessionKey", token) - c.Set("claudeCookie", cookie) + c.Set(tenantContextKey, matchedTenant) c.Next() } } -func sessionKeyFromCookie(cookie string) string { - for _, part := range strings.Split(cookie, ";") { - kv := strings.SplitN(strings.TrimSpace(part), "=", 2) - if len(kv) == 2 && kv[0] == "sessionKey" { - return kv[1] - } +func bearerToken(header string) string { + fields := strings.Fields(header) + if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") { + return "" } - return "" + return fields[1] +} + +// TenantID returns the authenticated tenant identity. +func TenantID(c *gin.Context) string { + return c.GetString(tenantContextKey) +} + +func abortError(c *gin.Context, status int, message, errorType string) { + c.AbortWithStatusJSON(status, gin.H{ + "error": gin.H{ + "message": message, + "type": errorType, + "request_id": RequestID(c), + }, + }) } diff --git a/middleware/auth_test.go b/middleware/auth_test.go new file mode 100644 index 0000000..39af26f --- /dev/null +++ b/middleware/auth_test.go @@ -0,0 +1,96 @@ +package middleware + +import ( + "crypto/sha256" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestAPIKeyAuthSetsTenant(t *testing.T) { + gin.SetMode(gin.TestMode) + apiKey := testAPIKey() + digest := sha256.Sum256([]byte(apiKey)) + router := gin.New() + router.Use(RequestSecurity(1024)) + router.Use(APIKeyAuth(map[string][sha256.Size]byte{"tenant-a": digest})) + router.GET("/v1/models", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"tenant": TenantID(c)}) + }) + + request := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + request.Header.Set("Authorization", "Bearer "+apiKey) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String()) + } + var body map[string]string + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["tenant"] != "tenant-a" { + t.Fatalf("unexpected tenant: %q", body["tenant"]) + } + if response.Header().Get("Cache-Control") != "private, no-store" { + t.Fatal("authenticated responses must disable shared caching") + } +} + +func TestAPIKeyAuthRejectsInvalidAndBrowserCredentials(t *testing.T) { + apiKey := testAPIKey() + digest := sha256.Sum256([]byte(apiKey)) + for _, test := range []struct { + name string + keys map[string][sha256.Size]byte + auth string + cookie string + cookieName string + wantStatus int + }{ + {name: "missing config", keys: nil, auth: "Bearer " + apiKey, wantStatus: http.StatusServiceUnavailable}, + {name: "wrong key", keys: map[string][sha256.Size]byte{"tenant": digest}, auth: "Bearer wrong", wantStatus: http.StatusUnauthorized}, + {name: "browser cookie header", keys: map[string][sha256.Size]byte{"tenant": digest}, auth: "Bearer " + apiKey, cookieName: "Cookie", cookie: "sessionKey=secret", wantStatus: http.StatusBadRequest}, + {name: "browser compatibility header", keys: map[string][sha256.Size]byte{"tenant": digest}, auth: "Bearer " + apiKey, cookieName: "X-Claude-Cookie", cookie: "sessionKey=secret", wantStatus: http.StatusBadRequest}, + } { + t.Run(test.name, func(t *testing.T) { + router := gin.New() + router.Use(APIKeyAuth(test.keys)) + router.GET("/v1/models", func(c *gin.Context) { c.Status(http.StatusOK) }) + request := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + request.Header.Set("Authorization", test.auth) + if test.cookie != "" { + request.Header.Set(test.cookieName, test.cookie) + } + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != test.wantStatus { + t.Fatalf("expected %d, got %d: %s", test.wantStatus, response.Code, response.Body.String()) + } + if test.cookie != "" && stringsContains(response.Body.String(), "secret") { + t.Fatal("response leaked a browser credential") + } + if response.Header().Get("X-Request-ID") != "" && !stringsContains(response.Body.String(), "request_id") { + t.Fatal("error response must include request_id") + } + }) + } +} + +func testAPIKey() string { + return "c2a_" + strings.Repeat("a", 43) +} + +func stringsContains(value, substring string) bool { + for index := 0; index+len(substring) <= len(value); index++ { + if value[index:index+len(substring)] == substring { + return true + } + } + return false +} diff --git a/middleware/rate_limit.go b/middleware/rate_limit.go new file mode 100644 index 0000000..f09a145 --- /dev/null +++ b/middleware/rate_limit.go @@ -0,0 +1,76 @@ +package middleware + +import ( + "math" + "net/http" + "strconv" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +type tenantBucket struct { + tokens float64 + last time.Time +} + +// TenantRateLimiter is an in-memory token bucket keyed only by authenticated +// tenant IDs. The configured tenant set bounds memory use. +type TenantRateLimiter struct { + mu sync.Mutex + buckets map[string]*tenantBucket + tokensPerSecond float64 + burst float64 + now func() time.Time +} + +func NewTenantRateLimiter(requestsPerMinute, burst int) *TenantRateLimiter { + return &TenantRateLimiter{ + buckets: make(map[string]*tenantBucket), + tokensPerSecond: float64(requestsPerMinute) / 60.0, + burst: float64(burst), + now: time.Now, + } +} + +func (l *TenantRateLimiter) Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + retryAfter, ok := l.allow(TenantID(c)) + if !ok { + seconds := int(math.Ceil(retryAfter.Seconds())) + if seconds < 1 { + seconds = 1 + } + c.Header("Retry-After", strconv.Itoa(seconds)) + abortError(c, http.StatusTooManyRequests, "rate limit exceeded", "rate_limit_error") + return + } + c.Next() + } +} + +func (l *TenantRateLimiter) allow(tenantID string) (time.Duration, bool) { + now := l.now() + l.mu.Lock() + defer l.mu.Unlock() + + bucket, ok := l.buckets[tenantID] + if !ok { + bucket = &tenantBucket{tokens: l.burst, last: now} + l.buckets[tenantID] = bucket + } + + elapsed := now.Sub(bucket.last).Seconds() + if elapsed > 0 { + bucket.tokens = math.Min(l.burst, bucket.tokens+elapsed*l.tokensPerSecond) + bucket.last = now + } + if bucket.tokens >= 1 { + bucket.tokens-- + return 0, true + } + + missing := 1 - bucket.tokens + return time.Duration(missing / l.tokensPerSecond * float64(time.Second)), false +} diff --git a/middleware/rate_limit_test.go b/middleware/rate_limit_test.go new file mode 100644 index 0000000..c665a45 --- /dev/null +++ b/middleware/rate_limit_test.go @@ -0,0 +1,30 @@ +package middleware + +import ( + "testing" + "time" +) + +func TestTenantRateLimiterIsolationAndRefill(t *testing.T) { + now := time.Unix(1000, 0) + limiter := NewTenantRateLimiter(60, 2) + limiter.now = func() time.Time { return now } + + if _, ok := limiter.allow("tenant-a"); !ok { + t.Fatal("tenant-a first request should pass") + } + if _, ok := limiter.allow("tenant-a"); !ok { + t.Fatal("tenant-a burst request should pass") + } + if retry, ok := limiter.allow("tenant-a"); ok || retry <= 0 { + t.Fatal("tenant-a should be limited with a positive retry duration") + } + if _, ok := limiter.allow("tenant-b"); !ok { + t.Fatal("tenant-b must have an independent bucket") + } + + now = now.Add(time.Second) + if _, ok := limiter.allow("tenant-a"); !ok { + t.Fatal("tenant-a should refill one token after one second at 60 RPM") + } +} diff --git a/middleware/recovery.go b/middleware/recovery.go new file mode 100644 index 0000000..b00e45c --- /dev/null +++ b/middleware/recovery.go @@ -0,0 +1,30 @@ +package middleware + +import ( + "log" + "net/http" + + "github.com/gin-gonic/gin" +) + +// RedactedRecovery handles panics without dumping request headers. Gin's +// general-purpose recovery output may include headers that this gateway treats +// as credentials. +func RedactedRecovery() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if recovered := recover(); recovered != nil { + requestID := RequestID(c) + log.Printf("recovered panic request_id=%s", requestID) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "message": "internal server error", + "type": "internal_error", + "request_id": requestID, + }, + }) + } + }() + c.Next() + } +} diff --git a/middleware/request.go b/middleware/request.go new file mode 100644 index 0000000..1d8b86f --- /dev/null +++ b/middleware/request.go @@ -0,0 +1,57 @@ +package middleware + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +const requestIDContextKey = "requestID" + +func RequestSecurity(maxRequestBytes int64) gin.HandlerFunc { + return func(c *gin.Context) { + requestID := newRequestID() + c.Set(requestIDContextKey, requestID) + c.Header("X-Request-ID", requestID) + c.Header("Cache-Control", "private, no-store") + c.Header("X-Content-Type-Options", "nosniff") + if c.Request.Body != nil { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxRequestBytes) + } + c.Next() + } +} + +// RedactedAccessLogger logs only bounded routing metadata. It intentionally +// excludes query strings, headers, bodies, tenant IDs, and credentials. +func RedactedAccessLogger() gin.HandlerFunc { + return gin.LoggerWithFormatter(func(params gin.LogFormatterParams) string { + requestID := "request-id-unavailable" + if value, ok := params.Keys[requestIDContextKey].(string); ok && value != "" { + requestID = value + } + return fmt.Sprintf("request_id=%s method=%s path=%q status=%d latency=%s\n", + requestID, + params.Method, + params.Request.URL.Path, + params.StatusCode, + params.Latency.Round(time.Microsecond), + ) + }) +} + +func RequestID(c *gin.Context) string { + return c.GetString(requestIDContextKey) +} + +func newRequestID() string { + var data [16]byte + if _, err := rand.Read(data[:]); err != nil { + return "request-id-unavailable" + } + return hex.EncodeToString(data[:]) +} diff --git a/middleware/request_test.go b/middleware/request_test.go new file mode 100644 index 0000000..0cd85b4 --- /dev/null +++ b/middleware/request_test.go @@ -0,0 +1,91 @@ +package middleware + +import ( + "bytes" + "io" + "log" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestRequestBodyLimitIsEnforced(t *testing.T) { + router := gin.New() + router.Use(RequestSecurity(8)) + router.POST("/body", func(c *gin.Context) { + _, err := io.ReadAll(c.Request.Body) + if err != nil { + c.Status(http.StatusRequestEntityTooLarge) + return + } + c.Status(http.StatusOK) + }) + request := httptest.NewRequest(http.MethodPost, "/body", strings.NewReader("more-than-eight-bytes")) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("expected 413, got %d", response.Code) + } +} + +func TestRecoveryDoesNotLogAuthorization(t *testing.T) { + var logs bytes.Buffer + previousWriter := log.Writer() + previousFlags := log.Flags() + log.SetOutput(&logs) + log.SetFlags(0) + defer func() { + log.SetOutput(previousWriter) + log.SetFlags(previousFlags) + }() + + router := gin.New() + router.Use(RedactedRecovery()) + router.Use(RequestSecurity(1024)) + router.GET("/panic", func(c *gin.Context) { panic("sensitive-panic-detail") }) + request := httptest.NewRequest(http.MethodGet, "/panic", nil) + request.Header.Set("Authorization", "Bearer should-never-appear") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", response.Code) + } + for _, forbidden := range []string{"should-never-appear", "sensitive-panic-detail", "Authorization"} { + if strings.Contains(logs.String(), forbidden) || strings.Contains(response.Body.String(), forbidden) { + t.Fatalf("recovery leaked %q", forbidden) + } + } + if !strings.Contains(response.Body.String(), "request_id") { + t.Fatal("recovery response must include request_id") + } +} + +func TestAccessLoggerIncludesRequestIDWithoutSecrets(t *testing.T) { + var output bytes.Buffer + previousWriter := gin.DefaultWriter + gin.DefaultWriter = &output + defer func() { gin.DefaultWriter = previousWriter }() + + router := gin.New() + router.Use(RequestSecurity(1024)) + router.Use(RedactedAccessLogger()) + router.GET("/items/:id", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + request := httptest.NewRequest(http.MethodGet, "/items/safe-id?token=query-secret", nil) + request.Header.Set("Authorization", "Bearer header-secret") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + requestID := response.Header().Get("X-Request-ID") + if requestID == "" || !strings.Contains(output.String(), "request_id="+requestID) { + t.Fatalf("access log must include response request ID: %q", output.String()) + } + for _, forbidden := range []string{"query-secret", "header-secret", "Authorization"} { + if strings.Contains(output.String(), forbidden) { + t.Fatalf("access log leaked %q: %s", forbidden, output.String()) + } + } +} diff --git a/tenants.example.json b/tenants.example.json new file mode 100644 index 0000000..0960304 --- /dev/null +++ b/tenants.example.json @@ -0,0 +1,7 @@ +{ + "local": { + "api_key_sha256": "replace-with-64-character-sha256-digest", + "claude_session_key": "replace-in-a-private-copy-only", + "claude_cookie": "" + } +}