From fc1acdfabe7aa5f3b1e239489648344d01d702a4 Mon Sep 17 00:00:00 2001 From: umaru Date: Sat, 23 May 2026 21:30:15 +0800 Subject: [PATCH 1/5] docs(usage): fill cliproxy-modes + cliproxy-egress-proxy (#167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 承接 PR #181 的 CLIProxyAPI 首批文档,补齐两篇姊妹篇: - cliproxy-modes:以源码事实(cliproxy-instance-crud.ts 的 validateInstanceAddress 分支)阐明 managed/external 的真实差异(SSRF 校验、URL 校验、地址填法), 纠正 enabled 字段「关闭即停摆」的误印象(实际任何调度代码都不读 enabled), 说明删除实例时上游记录 onDelete:set null 的副作用。 - cliproxy-egress-proxy:拆清三方责任——AutoRouter 自身不支持出站代理 (proxy-client.ts 直接用 Node fetch),全局 CLIPROXY_PROXY_URL 通过 docker-compose env → docker-entrypoint.sh → config.yaml.template 注入 CPA 容器,账号粒度 proxy_url 经管理 API PATCH /v0/management/auth-files/fields 下发;并说明两者优先级、生效时机差异、external 模式下 .env 失效。 Phase 2 使用侧进度 4/9 → 6/9,剩余:logs-stats、request-recording、troubleshooting。 --- docs/guide/usage/cliproxy-egress-proxy.md | 162 ++++++++++++++++++++-- docs/guide/usage/cliproxy-modes.md | 129 +++++++++++++++-- 2 files changed, 269 insertions(+), 22 deletions(-) diff --git a/docs/guide/usage/cliproxy-egress-proxy.md b/docs/guide/usage/cliproxy-egress-proxy.md index c180382f..8cde219b 100644 --- a/docs/guide/usage/cliproxy-egress-proxy.md +++ b/docs/guide/usage/cliproxy-egress-proxy.md @@ -5,19 +5,159 @@ outline: deep # CLIProxyAPI 出站代理配置 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 -::: +在受限网络环境(GFW、企业出口白名单)里,CLIProxyAPI(下称 CPA)访问 Codex / Claude / Gemini 的登录端点和模型 API 往往必须经一层 HTTP/SOCKS 代理。AutoRouter 自身**不参与**这一层代理——出站代理由 CPA 容器自己消费,AutoRouter 只在「为某个账号设置覆盖」这一点上有管理 API 入口。本页讲清楚两种粒度的代理是怎么生效的,何时该用哪个,以及怎么验证。 -## 计划覆盖的内容 +## 责任划分 -受限网络下 `CLIPROXY_PROXY_URL` 的使用、生效方式、验证流程。 +``` +┌───────────────────────────────────────────────────────────────┐ +│ AutoRouter(Next.js / proxy-client.ts) │ +│ 不读 HTTP_PROXY / HTTPS_PROXY / ALL_PROXY │ +│ 不为 fetch 注入 dispatcher / agent │ +│ AR → 上游(含 CPA 上游)的请求不走任何出站代理 │ +└──────────────────────────┬────────────────────────────────────┘ + │ 仅当上游是 CPA 池/单账号上游 + ▼ +┌───────────────────────────────────────────────────────────────┐ +│ CLIProxyAPI 容器 │ +│ 全局:CLIPROXY_PROXY_URL(env → config.yaml proxy-url) │ +│ 账号:每个 auth-file 的 proxy_url 字段覆盖全局 │ +│ CPA → Codex / Claude / Gemini 的请求走这两层代理 │ +└───────────────────────────────────────────────────────────────┘ +``` -## 在正文就绪前的临时建议 +`src/lib/services/proxy-client.ts:12-21` 的 `UpstreamForProxy` 接口字段里只有 `id` / `name` / `providerType` / `baseUrl` / `apiKey` / `timeout`,没有任何代理字段;同文件 `:139` 的 fetch 调用是 Node.js 原生 `fetch`,没有传 `dispatcher` 也没有读 `process.env.HTTP_PROXY`。这意味着即使在宿主机设了 `HTTPS_PROXY`,AutoRouter 的转发也不会走代理(Node `fetch` 默认行为)。 -在该文档正文上线之前,可以参考以下材料获取等价信息: +也就是说: -- 项目仓库根目录的 [README.md](https://github.com/g1331/AutoRouter/blob/master/README.md) -- 现有长篇 [`docs/cliproxy-deployment.md`](/cliproxy-deployment) -- 现有长篇 [`docs/circuit-breaker.md`](/circuit-breaker) -- 项目 [Issue 列表](https://github.com/g1331/AutoRouter/issues) 与 [OpenSpec 提案](https://github.com/g1331/AutoRouter/tree/master/openspec) +- AutoRouter 自身要出网的场景(向上游发请求)**不能配代理**。如果 AR 部署在受限网络里需要直接访问 OpenAI/Anthropic,要么换部署位置,要么在容器外用 transparent proxy 解决。 +- CPA 上游链路(AR → CPA → 模型 API)里,AR → CPA 这一段在同一网络内不需要代理;CPA → 模型 API 这一段由 CPA 自己消费代理配置。 + +## 全局代理 `CLIPROXY_PROXY_URL` + +适用范围:CPA 容器内**所有** auth-file 默认使用的出站代理。 + +### 配置链路 + +只对 `managed`(sidecar)模式有效。配置链路: + +1. `.env` 文件中的 `CLIPROXY_PROXY_URL`(`.env.example:131-133`)。 +2. `docker-compose.cliproxy.yml` 把该值注入 CPA 容器 environment: + ```yaml + environment: + - CLIPROXY_PROXY_URL=${CLIPROXY_PROXY_URL:-} + ``` + (`docker-compose.cliproxy.yml:25`) +3. CPA 容器 `docker-entrypoint.sh` 将 env 渲染进 `config.yaml`: + ```bash + export CLIPROXY_PROXY_URL="${CLIPROXY_PROXY_URL:-}" + ... + CONTENT=$(render_literal "$CONTENT" '${CLIPROXY_PROXY_URL}' "$CLIPROXY_PROXY_URL") + ``` + (`cliproxy/docker-entrypoint.sh:34, 67`) +4. `cliproxy/config.yaml.template:32-34` 的 `proxy-url: "${CLIPROXY_PROXY_URL}"` 被替换为真实值。 +5. CPA 启动时读取 `proxy-url`,对所有 auth-file 默认应用。 + +### 支持的格式 + +| 协议 | 示例 | +| -------- | --------------------------------------------- | +| `http` | `http://proxy-host:8080` | +| `https` | `https://proxy-host:8443` | +| `socks5` | `socks5://proxy-host:1080` | +| 留空 | 不使用代理(`.env` 中 `CLIPROXY_PROXY_URL=`) | + +### 生效时机 + +环境变量在 `docker-compose` 配置中是**容器启动期注入**——修改 `.env` 后需要 `docker compose up -d` 或 `docker compose restart cliproxyapi` 让 CPA 重新读配置。运行中改 `.env` 不会自动生效。 + +### 外部模式(`external`)下 + +`external` 模式下 CPA 不由 AutoRouter 的 docker-compose 拉起,`CLIPROXY_PROXY_URL` 这条注入链路完全不存在。要给外部 CPA 配出站代理,必须在 CPA 自己的运行环境(systemd unit、docker-compose、Kubernetes manifest 等)里设置 `proxy-url`。AutoRouter `.env` 里的 `CLIPROXY_PROXY_URL` 在 `external` 模式下是无效字段。 + +## 账号粒度 `proxy_url` + +适用范围:**单个 auth-file**,覆盖全局 `proxy-url`。 + +### 何时需要 + +- 不同 OAuth 账号属于不同地理区域(例如美区/欧区账号要走不同跳板); +- 部分账号要直连、其他账号走代理; +- 同一台 CPA 服务多个团队,每个团队的账号通过各自代理出网。 + +如果没有以上需求,**只设全局 `CLIPROXY_PROXY_URL` 即可**,账号粒度字段留空。 + +### 设置入口 + +管理后台账号列表的「编辑账号」对话框会写这个字段,对应 API: + +``` +PATCH /api/admin/cliproxy/instances/:id/auth-accounts/:accountName +Body: { "proxy_url": "socks5://team-a-proxy:1080", ... } +``` + +字段约束(`src/app/api/admin/cliproxy/instances/[id]/auth-accounts/[accountName]/route.ts:14-23`):`proxy_url` 是可选 string,trim 后最长 512 字符。 + +### 下发链路 + +1. PATCH 路由把 `proxy_url` 转给 `updateCliproxyAuthAccountFields`(`src/lib/services/cliproxy-auth-account-service.ts:238-244`)。 +2. 服务层调用 `patchAuthFileFields`,向 CPA 管理 API 发: + ``` + PATCH /v0/management/auth-files/fields + Body: { name, prefix, proxy_url, priority, note } + ``` + (`src/lib/services/cliproxy-management-client.ts:212-221`) +3. CPA 收到后更新内部 auth-file 配置,**立即生效**——不需要重启容器(与全局 `CLIPROXY_PROXY_URL` 的重启要求不同)。 +4. AutoRouter **不缓存** `proxy_url`:本地 `cliproxy_auth_accounts` 表的字段列里没有 `proxy_url`(`src/lib/db/schema-pg.ts:744-770`),实际值始终以 CPA 侧为准。下次想看某个账号当前的 `proxy_url`,要从 CPA 的 `/v0/management/auth-files` 列表读。 + +### 与全局的优先级 + +CPA 侧的行为:账号粒度 `proxy_url` 非空时覆盖全局;为空(或字段不存在)时使用全局 `CLIPROXY_PROXY_URL`;都为空则不使用代理。 + +注意:把账号粒度 `proxy_url` 设回空串与「未设置」在 CPA 不同版本里可能行为略有差异。最稳妥的「回到全局」做法是通过 CPA 管理 API 把字段删掉(AutoRouter 当前 PATCH 接口的 `proxy_url` 是 optional,不传该字段表示不更新,传空串需要 CPA 侧明确支持「空串 = 清除」语义)。如果发现「设了空串后仍然走老代理」,可以把账号删掉重建,或者直接到 CPA 侧改配置。 + +## 何时用哪个 + +| 场景 | 全局 `CLIPROXY_PROXY_URL` | 账号 `proxy_url` | +| ---------------------------------- | ---------------------------- | ---------------- | +| 整台 CPA 都要走同一个代理 | ✓ | | +| 90% 账号走代理 A、几个特例走代理 B | ✓(代理 A) | ✓(代理 B) | +| 每个账号都不同代理 | | ✓ | +| 部分账号直连、其他账号走代理 | | ✓ | +| 全部账号直连 | | | +| 仅 `external` 模式 CPA | (AR 无法设置,到 CPA 自配) | ✓ | + +## 校验方法 + +### 全局代理是否生效 + +1. 改完 `.env` 并 `docker compose -f docker-compose.yml -f docker-compose.cliproxy.yml up -d` 后,检查容器 env: + ``` + docker compose exec cliproxyapi env | grep CLIPROXY_PROXY_URL + ``` +2. 检查渲染后的 config.yaml: + ``` + docker compose exec cliproxyapi cat /app/config/config.yaml | grep proxy-url + ``` +3. 触发一次实际调用(管理后台列出账号 / 客户端发请求),CPA 日志中应能看到代理握手。 + +### 账号代理是否覆盖 + +通过管理后台编辑账号、保存 `proxy_url`,再调用 CPA `/v0/management/auth-files` 列表确认字段已更新。**不要**通过 AutoRouter 本地 `cliproxy_auth_accounts` 表来校验——该表无 `proxy_url` 字段,是缓存表,不反映 CPA 侧真实值。 + +### 常见症状速查 + +| 症状 | 排查方向 | +| -------------------------------------- | --------------------------------------------------------------------------------------------- | +| 改了 `.env` 但 CPA 不走代理 | 没重启 CPA 容器;或当前是 `external` 模式(AR `.env` 不会注入到外部 CPA) | +| AR 本身访问上游被墙 | AR 不支持出站代理,不可配;考虑在 AR 容器外解决,或换部署位置 | +| 账号 `proxy_url` 设了但 CPA 还在用全局 | 检查 CPA 版本对「空串 vs 未设置」的处理;或直接到 CPA 侧 `/v0/management/auth-files` 看真实值 | +| OAuth 登录卡住 | 出站代理本身不通;先用宿主 `curl -x ... https://...` 自测代理可达 | +| 管理后台显示账号正常但调用失败 | 账号粒度 `proxy_url` 写错;从 CPA 侧(不是 AR 缓存)取真实值核对 | + +## 不在本页范围内 + +- AR 容器在受限网络下访问数据库 / 监控 / DNS 等其它出站需求:与本页无关,按基础设施层方案处理。 +- CPA `proxy-url` 的更高级配置(按目标域名分流、TLS 验证等):见 CPA 自身文档。 +- CPA 实例的 `mode` 字段差异:见 [CLIProxyAPI 外部 vs sidecar 选择](./cliproxy-modes)。 +- 全部 sidecar 部署变量:见 [环境变量参考](../deployment/env-reference) 与 [CI 部署后追加 CLIProxyAPI sidecar](../deployment/cliproxy-sidecar)。 diff --git a/docs/guide/usage/cliproxy-modes.md b/docs/guide/usage/cliproxy-modes.md index 8660d570..f565689e 100644 --- a/docs/guide/usage/cliproxy-modes.md +++ b/docs/guide/usage/cliproxy-modes.md @@ -5,19 +5,126 @@ outline: deep # CLIProxyAPI 外部 vs sidecar 选择 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 -::: +`cliproxy_instances` 表的 `mode` 字段只有两个取值:`managed` 与 `external`(`src/lib/services/cliproxy-instance-crud.ts:20` 常量 `CLIPROXY_INSTANCE_MODES`)。这两个值不是单纯的展示标签——它们在地址校验、网络拓扑、运维边界上有真实差异。本页把这些差异列清楚,再给出一份选型建议。 -## 计划覆盖的内容 +> 前置阅读:[CLIProxyAPI 首次使用指南](./cliproxy-first-time)(实例字段总览、OAuth 流程、池上游创建)。本页只讨论模式选择本身。 -两种模式各自的适用场景、运维差异、字段填写差异。 +## 一图看懂 -## 在正文就绪前的临时建议 +``` +┌──────────────────────────────┐ ┌──────────────────────────────┐ +│ managed(sidecar) │ │ external(远端独立服务) │ +├──────────────────────────────┤ ├──────────────────────────────┤ +│ AutoRouter 与 CPA 同一 docker │ │ CPA 跑在另一台主机/另一网络 │ +│ 网络,通过服务名互访 │ │ 通过公网 / 内网域名访问 │ +│ base_url: cliproxyapi │ │ base_url: cpa.example.com │ +│ management_url: cliproxyapi │ │ management_url: 同上 │ +│ 允许私有/内网/loopback 地址 │ │ 拒绝私有/内网/loopback/元数据 │ +│ 适合自托管单实例 / 小团队 │ │ 适合多 AR 共享一个 CPA 服务 │ +└──────────────────────────────┘ └──────────────────────────────┘ +``` -在该文档正文上线之前,可以参考以下材料获取等价信息: +## 模式差异 -- 项目仓库根目录的 [README.md](https://github.com/g1331/AutoRouter/blob/master/README.md) -- 现有长篇 [`docs/cliproxy-deployment.md`](/cliproxy-deployment) -- 现有长篇 [`docs/circuit-breaker.md`](/circuit-breaker) -- 项目 [Issue 列表](https://github.com/g1331/AutoRouter/issues) 与 [OpenSpec 提案](https://github.com/g1331/AutoRouter/tree/master/openspec) +### 地址校验 + +两种模式的核心差异在 `validateInstanceAddress`(`src/lib/services/cliproxy-instance-crud.ts:105-128`): + +```ts +if (mode === "external") { + const result = isUrlSafe(urlString); + if (!result.safe) { + throw new InvalidCliproxyInstanceAddressError( + `${label} 未通过地址安全校验:${result.reason ?? "地址不被允许"}` + ); + } +} +``` + +| 模式 | URL 格式 | 协议(http / https) | SSRF 校验 | 私有 IP / loopback / 云元数据端点 | +| ---------- | -------- | -------------------- | ---------------------- | --------------------------------- | +| `managed` | 必须 | 必须 | **跳过** | **允许** | +| `external` | 必须 | 必须 | 走 `isUrlSafe`(SSRF) | **拦截** | + +`isUrlSafe` 是 AutoRouter 通用的 SSRF 防护函数(来自 `src/lib/services/upstream-ssrf-validator.ts`,也用于上游字段校验),会拒绝指向 169.254.169.254 这类云元数据端点、私有网段、loopback 等地址。 + +为什么要差异化处理: + +- `managed` 模式下 CPA 在同一个 Docker 网络里,地址通常是 `http://cliproxyapi:port` 形式的服务名,或 `172.18.0.x` 之类的私有 IP。如果套上 SSRF 校验,正常的 sidecar 拓扑直接被拒。 +- `external` 模式下 CPA 是远端独立服务,地址通常是公网域名。如果不做 SSRF 校验,攻击者可以登记一个指向云元数据端点的恶意「实例」,再通过 OAuth 登录/凭据测试等管理 API 请求做 SSRF 探测。 + +简记:**managed 牺牲一道防御换内网兼容性,external 牺牲内网兼容性换防御**。一旦把模式设错,要么填地址被无故拒(managed 应填的地址在 external 下被 SSRF 拦),要么把内网管理面无意中开放给 SSRF 利用面。 + +### 拓扑与地址填法 + +`managed`(sidecar 模式): + +- 部署形态:[CI 部署后追加 CLIProxyAPI sidecar](../deployment/cliproxy-sidecar) 描述的 `docker-compose.cliproxy.yml` 叠加文件。 +- 地址格式:`http://:`。`base_url` 与 `management_url` 通常**填同一个值**——CPA 的代理转发端点与管理 API 端点是同一个 HTTP 服务的不同路径。 +- 严禁使用 `localhost` / `127.0.0.1`:这两个在 AutoRouter 容器里指向自身,不是 CPA。 + +`external`(外部服务模式): + +- 部署形态:CPA 跑在另一台主机、另一台容器、甚至公网托管。 +- 地址格式:`https://cpa.example.com` 或公网 IP + 端口。 +- `base_url` 与 `management_url` 仍然通常相同;若管理面与转发面被反代到不同子路径,两者可以分别填。 +- 实际填的地址必须能通过 SSRF 校验(公网域名或显式放行的非私有 IP)。 + +### `enabled` 字段的实际语义 + +`enabled` 字段在 schema、UI、管理后台 badge 都存在(`src/lib/db/schema-pg.ts:731`、`src/components/admin/cliproxy-instances-table.tsx:79-80`),但**当前版本任何路由 / 调度代码都没有读它**: + +- 上游选路(`load-balancer.ts`)只看 `upstreams.is_active`,不看 `cliproxy_instances.enabled`。 +- 池上游创建、连通性测试、OAuth 登录、账号同步等管理 API 也不在入口处检查 `enabled`。 +- 把 `enabled` 设为 `false` **不会**让依赖该实例的池上游自动停止接流量;只是管理后台显示一个 `disabled` 角标。 + +要让一个实例下的池上游全部停摆,目前的可靠做法是把对应的**上游**(`upstreams` 表)逐条 `is_active = false`,或者直接删除该实例(删除时 `cliproxyInstanceId` 受 `onDelete: set null` 约束,相关上游的关联字段被清空但记录本身保留,仍按 `base_url` 直连——见下一节)。 + +### 删除实例的影响 + +`cliproxyInstanceId` 列声明(`src/lib/db/schema-pg.ts:115-117`): + +```ts +cliproxyInstanceId: uuid("cliproxy_instance_id").references(() => cliproxyInstances.id, { + onDelete: "set null", +}), +``` + +删除一个 CPA 实例时: + +| 关联对象 | 行为 | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| 池上游 / 单账号上游 | `cliproxyInstanceId` 置 NULL,`cliproxyProvider` / `cliproxyAuthFileName` 保留;上游记录**不删**,仍按 `base_url` 字面值发请求 | +| `cliproxy_auth_accounts` 本地缓存 | 不会被自动清理,需要单独处理 | +| 仍在跑的客户端请求 | 已经选定上游的请求继续发到原 `base_url`,CPA 关掉后会自然超时失败 | + +换言之,**删除实例不是优雅停机的方式**。要替换模式或迁移 CPA,先把相关上游 `is_active = false` 让流量自然停掉,再删实例。 + +## 何时选哪个 + +| 场景 | 选 `managed` | 选 `external` | +| ------------------------------------------------------- | ------------ | ------------- | +| 自托管单实例,AutoRouter + CPA 同一台机 | ✓ | | +| 用 `docker-compose.cliproxy.yml` 叠加文件部署的标准形态 | ✓ | | +| 团队内一套 CPA 共用、多个 AR 远程接 | | ✓ | +| CPA 跑在公网托管、靠域名访问 | | ✓ | +| 跨网络管理面、需要 SSRF 校验作为附加防御 | | ✓ | +| 临时本地开发,AR 在宿主、CPA 在 docker(端口映射访问) | ✓ 或 ✗ | ✓ 或 ✗ | + +最后一行需要展开:本地开发若把 AR 跑在宿主、CPA 跑在 docker 暴露的 `127.0.0.1:port`,填 `http://127.0.0.1:port` 会在 `external` 下被 SSRF 拦掉,必须选 `managed`。反过来,若 AR 在 docker、CPA 也在 docker 但属于不同 compose 网络,靠映射端口互通,那也只能用 `managed`。 + +## 切换模式 + +`mode` 字段允许通过 PATCH 接口修改(`src/lib/services/cliproxy-instance-crud.ts:238`,`mode = input.mode ?? (current.mode as CliproxyInstanceMode)`)。但要注意切换方向: + +- `managed` → `external`:切换会立即触发地址重新校验,如果当前 `base_url` / `management_url` 是内网地址,校验失败,PATCH 被拒。先把地址改成符合 SSRF 校验的形式(或同时改 mode + 地址),再保存。 +- `external` → `managed`:放宽校验,地址原值不变。切换后立即生效。 + +无论哪个方向,切换 `mode` 不影响已存在的池上游、OAuth 账号缓存、转发流量;仅影响后续创建 / 编辑实例时的地址校验。 + +## 不在本页范围内 + +- CPA sidecar 的 docker-compose 配置细节:见 [CI 部署后追加 CLIProxyAPI sidecar](../deployment/cliproxy-sidecar) 与 [现有长篇 `docs/cliproxy-deployment.md`](/cliproxy-deployment)。 +- 从 0 登记实例 / OAuth 登录 / 创建池上游的具体步骤:见 [CLIProxyAPI 首次使用指南](./cliproxy-first-time)。 +- CPA 在受限网络环境下的出站代理配置:见 [CLIProxyAPI 出站代理配置](./cliproxy-egress-proxy)。 +- AutoRouter 自身与上游、池上游之间的转发逻辑:见 [请求生命周期](../architecture/request-lifecycle)。 From 64858ed920c5bede7d9d072b7682711553063f57 Mon Sep 17 00:00:00 2001 From: umaru Date: Sat, 23 May 2026 21:56:36 +0800 Subject: [PATCH 2/5] docs(usage): fix three review findings on PR #182 (#167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 cliproxy-modes 删除行为:原文档说删除实例时外键 set null 会让上游记录 保留并继续按 base_url 直发;实际上 deleteCliproxyInstance (cliproxy-instance-crud.ts:290-320) 在 SQL 删除前会先校验缓存账号与上游引用, 任一存在即抛 CliproxyInstanceInUseError,路由层返 HTTP 409。 onDelete: set null 仅在数据被绕过应用层删除时兜底。改为给出正确的三步 删除顺序(先上游、再账号、最后实例),并强调没有强制删除开关。 P2 cliproxy-modes SSRF 校验:原文档暗示 external 模式能拦截所有指向私有 地址的请求;实际上 isUrlSafe (upstream-ssrf-validator.ts:69-94) 在 hostname 不是 IP 字面量时直接 safe: true、不查 DNS。补一个 warning 说明 SSRF 防护 只对 IP 字面量生效,对 cpa.internal 这种内网域名即使解析到 192.168.x.x 也会通过;同时把校验差异表加一列「域名解析到私有 IP」以体现该限制。 P3 cliproxy-egress-proxy 配置路径:docker-entrypoint.sh:12 默认把渲染后的 config.yaml 写到 /CLIProxyAPI/config.yaml,而非原文档写的 /app/config/config.yaml。 更新校验命令路径,并提醒可以被 CLIPROXY_CONFIG_TARGET 覆盖。 --- docs/guide/usage/cliproxy-egress-proxy.md | 4 +- docs/guide/usage/cliproxy-modes.md | 65 ++++++++++++++++------- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/docs/guide/usage/cliproxy-egress-proxy.md b/docs/guide/usage/cliproxy-egress-proxy.md index 8cde219b..1ed5f6e1 100644 --- a/docs/guide/usage/cliproxy-egress-proxy.md +++ b/docs/guide/usage/cliproxy-egress-proxy.md @@ -135,9 +135,9 @@ CPA 侧的行为:账号粒度 `proxy_url` 非空时覆盖全局;为空(或 ``` docker compose exec cliproxyapi env | grep CLIPROXY_PROXY_URL ``` -2. 检查渲染后的 config.yaml: +2. 检查渲染后的 config.yaml(默认路径 `/CLIProxyAPI/config.yaml`,由 `cliproxy/docker-entrypoint.sh:12` 的 `CLIPROXY_CONFIG_TARGET` 决定;若覆盖了该变量按实际值看): ``` - docker compose exec cliproxyapi cat /app/config/config.yaml | grep proxy-url + docker compose exec cliproxyapi cat /CLIProxyAPI/config.yaml | grep proxy-url ``` 3. 触发一次实际调用(管理后台列出账号 / 客户端发请求),CPA 日志中应能看到代理握手。 diff --git a/docs/guide/usage/cliproxy-modes.md b/docs/guide/usage/cliproxy-modes.md index f565689e..4eebb54f 100644 --- a/docs/guide/usage/cliproxy-modes.md +++ b/docs/guide/usage/cliproxy-modes.md @@ -41,19 +41,29 @@ if (mode === "external") { } ``` -| 模式 | URL 格式 | 协议(http / https) | SSRF 校验 | 私有 IP / loopback / 云元数据端点 | -| ---------- | -------- | -------------------- | ---------------------- | --------------------------------- | -| `managed` | 必须 | 必须 | **跳过** | **允许** | -| `external` | 必须 | 必须 | 走 `isUrlSafe`(SSRF) | **拦截** | +| 模式 | URL 格式 | 协议(http / https) | SSRF 校验 | IP 字面量为私有 / loopback / 元数据端点 | 域名解析到私有 IP | +| ---------- | -------- | -------------------- | ---------------------- | --------------------------------------- | ------------------ | +| `managed` | 必须 | 必须 | **跳过** | **允许** | **允许** | +| `external` | 必须 | 必须 | 走 `isUrlSafe`(SSRF) | **拦截** | **不解析、不拦截** | -`isUrlSafe` 是 AutoRouter 通用的 SSRF 防护函数(来自 `src/lib/services/upstream-ssrf-validator.ts`,也用于上游字段校验),会拒绝指向 169.254.169.254 这类云元数据端点、私有网段、loopback 等地址。 +`isUrlSafe`(`src/lib/services/upstream-ssrf-validator.ts:69-94`)的判定逻辑分两路: + +- 解析出的 hostname 是 `localhost`:拒绝。 +- hostname 字面是 IP(正则 `^[\d.:]+$` 命中):交给 `isIpSafe` 校验,私有网段(10/8、172.16/12、192.168/16)、loopback(127/8、`::1`)、链路本地(169.254/16,含 AWS 元数据端点)、IPv6 ULA/链路本地/multicast 等全部拒绝。 +- hostname 是普通域名:**直接返回 `safe: true`**,**不做 DNS 解析**。 + +::: warning 重要限制:DNS 不解析 +`isUrlSafe` 对非 IP 字面量的 hostname 不查 DNS。因此 `external` 模式下登记 `http://cpa.internal` 这种域名地址,即使它最终解析到 `192.168.x.x` 或 `169.254.169.254`,也能通过校验。SSRF 防护实际上**只在 IP 字面量上生效**。 + +仓库里另有一个 `resolveAndValidateHostname`(同文件 `:99-153`)会做 DNS 解析并校验所有解析结果,但 `validateInstanceAddress` 当前没有调用它。如果对外部 CPA 的拓扑信任度不够,建议把 `base_url` / `management_url` 直接写成公网 IP 字面量(让 IP 校验起作用),或者通过反向代理网关收敛入口。 +::: 为什么要差异化处理: - `managed` 模式下 CPA 在同一个 Docker 网络里,地址通常是 `http://cliproxyapi:port` 形式的服务名,或 `172.18.0.x` 之类的私有 IP。如果套上 SSRF 校验,正常的 sidecar 拓扑直接被拒。 -- `external` 模式下 CPA 是远端独立服务,地址通常是公网域名。如果不做 SSRF 校验,攻击者可以登记一个指向云元数据端点的恶意「实例」,再通过 OAuth 登录/凭据测试等管理 API 请求做 SSRF 探测。 +- `external` 模式下 CPA 是远端独立服务,地址通常是公网域名。即使 SSRF 校验只在 IP 字面量上生效,对显式填写私有 IP 的恶意登记仍然有效,是一道有限但有意义的防御。 -简记:**managed 牺牲一道防御换内网兼容性,external 牺牲内网兼容性换防御**。一旦把模式设错,要么填地址被无故拒(managed 应填的地址在 external 下被 SSRF 拦),要么把内网管理面无意中开放给 SSRF 利用面。 +简记:**managed 完全跳过 SSRF 校验,external 启用基于 IP 字面量的 SSRF 校验**。一旦把模式设错,要么填地址被无故拒(managed 应填的内网 IP 在 external 下被 SSRF 拦),要么把内网管理面无意中开放给 SSRF 利用面。 ### 拓扑与地址填法 @@ -68,7 +78,7 @@ if (mode === "external") { - 部署形态:CPA 跑在另一台主机、另一台容器、甚至公网托管。 - 地址格式:`https://cpa.example.com` 或公网 IP + 端口。 - `base_url` 与 `management_url` 仍然通常相同;若管理面与转发面被反代到不同子路径,两者可以分别填。 -- 实际填的地址必须能通过 SSRF 校验(公网域名或显式放行的非私有 IP)。 +- 实际填的地址必须能通过 SSRF 校验:任何非 `localhost` 的域名都能通过,IP 字面量必须是非私有 / 非 loopback / 非元数据网段。注意域名校验**不查 DNS**(见上一节警告),如需更强保护请填公网 IP 字面量。 ### `enabled` 字段的实际语义 @@ -78,27 +88,42 @@ if (mode === "external") { - 池上游创建、连通性测试、OAuth 登录、账号同步等管理 API 也不在入口处检查 `enabled`。 - 把 `enabled` 设为 `false` **不会**让依赖该实例的池上游自动停止接流量;只是管理后台显示一个 `disabled` 角标。 -要让一个实例下的池上游全部停摆,目前的可靠做法是把对应的**上游**(`upstreams` 表)逐条 `is_active = false`,或者直接删除该实例(删除时 `cliproxyInstanceId` 受 `onDelete: set null` 约束,相关上游的关联字段被清空但记录本身保留,仍按 `base_url` 直连——见下一节)。 +要让一个实例下的池上游全部停摆,可靠做法是把对应的**上游**(`upstreams` 表)逐条 `is_active = false`。直接尝试删除实例并不能跳过这一步——见下一节。 ### 删除实例的影响 -`cliproxyInstanceId` 列声明(`src/lib/db/schema-pg.ts:115-117`): +`DELETE /api/admin/cliproxy/instances/:id` 路由调用 `deleteCliproxyInstance`(`src/lib/services/cliproxy-instance-crud.ts:290-320`),删除前依次做两轮引用校验: ```ts -cliproxyInstanceId: uuid("cliproxy_instance_id").references(() => cliproxyInstances.id, { - onDelete: "set null", -}), +// 1) 缓存账号引用校验 +if (referencingAccounts.length > 0) { + throw new CliproxyInstanceInUseError( + instanceId, + "该实例下仍存在缓存的 OAuth 账号,请先移除账号后再删除实例" + ); +} +// 2) 上游引用校验,外键 set null 仅作兜底 +if (referencingUpstreams.length > 0) { + throw new CliproxyInstanceInUseError( + instanceId, + "该实例下仍存在关联的池上游或单账号上游,请先删除相关上游后再删除实例" + ); +} ``` -删除一个 CPA 实例时: +`CliproxyInstanceInUseError` 被路由层映射成 HTTP **409 Conflict**。`cliproxyInstanceId` 列虽然声明了 `onDelete: set null`(`src/lib/db/schema-pg.ts:115-117`),但应用层校验在 SQL 删除之前就会拒绝,外键策略只在数据被绕过应用层(直连 DB 删除)时才生效。 + +因此正确的删除顺序是: + +| 步骤 | 操作 | +| ---- | ------------------------------------------------------------------------------------------ | +| 1 | 该实例下所有**池上游 / 单账号上游**逐条删除(或先 `is_active = false` 让流量停掉,再删除) | +| 2 | 该实例下所有缓存的 **OAuth 账号**逐条删除(管理后台账号列表的删除按钮) | +| 3 | 删除实例本身 | -| 关联对象 | 行为 | -| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| 池上游 / 单账号上游 | `cliproxyInstanceId` 置 NULL,`cliproxyProvider` / `cliproxyAuthFileName` 保留;上游记录**不删**,仍按 `base_url` 字面值发请求 | -| `cliproxy_auth_accounts` 本地缓存 | 不会被自动清理,需要单独处理 | -| 仍在跑的客户端请求 | 已经选定上游的请求继续发到原 `base_url`,CPA 关掉后会自然超时失败 | +任何一步漏做都会让第三步返 409,提示中明确写出还有哪一类引用未清理。**没有「强制删除」开关**——这是有意设计,避免把还在接流量的池上游意外切断。 -换言之,**删除实例不是优雅停机的方式**。要替换模式或迁移 CPA,先把相关上游 `is_active = false` 让流量自然停掉,再删实例。 +如果需要迁移到另一台 CPA:先建好新实例并把流量切过去(新建池上游 + 老池上游 `is_active = false` 让权重自然停止流入),观察一段无流量后再按上面三步删除老实例。 ## 何时选哪个 From d64d55bdda21fd3dd1ea4021d6fbc23755028c0e Mon Sep 17 00:00:00 2001 From: umaru Date: Sat, 23 May 2026 21:57:05 +0800 Subject: [PATCH 3/5] docs(usage): sync ASCII diagram with SSRF-limit warning (#167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「一图看懂」框图里仍写「external 拒绝私有/内网/loopback/元数据」,与下文的 SSRF 仅在 IP 字面量生效的 warning 矛盾。改为「拒绝私有/loopback IP 字面量」 以避免误导读者认为 cpa.internal 也会被拦。 --- docs/guide/usage/cliproxy-modes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/usage/cliproxy-modes.md b/docs/guide/usage/cliproxy-modes.md index 4eebb54f..2d4d6e26 100644 --- a/docs/guide/usage/cliproxy-modes.md +++ b/docs/guide/usage/cliproxy-modes.md @@ -19,7 +19,7 @@ outline: deep │ 网络,通过服务名互访 │ │ 通过公网 / 内网域名访问 │ │ base_url: cliproxyapi │ │ base_url: cpa.example.com │ │ management_url: cliproxyapi │ │ management_url: 同上 │ -│ 允许私有/内网/loopback 地址 │ │ 拒绝私有/内网/loopback/元数据 │ +│ 允许私有/内网/loopback 地址 │ │ 拒绝私有/loopback IP 字面量 │ │ 适合自托管单实例 / 小团队 │ │ 适合多 AR 共享一个 CPA 服务 │ └──────────────────────────────┘ └──────────────────────────────┘ ``` From df73c5f6becd52027e405b4157a3120f6ad99e3c Mon Sep 17 00:00:00 2001 From: umaru Date: Sat, 23 May 2026 22:16:19 +0800 Subject: [PATCH 4/5] docs(usage): fix three review findings round 2 on PR #182 (#167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 IPv6 SSRF:原 warning 把限制描述为「只对 IP 字面量生效」,但实测 new URL("http://[::1]/").hostname === "[::1]"(保留方括号),正则 /^[\d.:]+$/ 不匹配,IPv6 字面量同样走「普通域名」分支被 safe: true。 也就是说仅 IPv4 字面量会被拦,IPv6 字面量 + 域名都通过。修正 warning、 表格列分拆为「IPv4 字面量 / IPv6 字面量 / 域名解析到私有 IP」三列,明确 external 模式无法挡住 [::1] / [fc00::1] / [fe80::1] 等。 P2 账号删除步骤:原文档让用户去管理后台账号列表点「删除按钮」,但当前账号 子路由([accountName]/route.ts + status + upstream)都没导出 DELETE, UI 也只有启停/编辑字段/映射上游三个动作,没有删除账号的 AutoRouter 操作。 改为说明唯一可行路径:在 CPA 侧删 auth-file → 触发 sync → 同步逻辑 (cliproxy-auth-account-service.ts:189-197)会把本地 cliproxy_auth_accounts 中 CPA 侧已不存在的行 db.delete 清掉;并补 CPA 不可达时直接操作表的兜底。 P3 cliproxy-first-time enabled 描述:first-time 文档字段表里仍写 「关闭后所有依赖该实例的池上游不可用」,与 modes 章节里「任何调度代码都 不读 enabled」直接矛盾。同步纠正为「仅作管理后台标识,不参与路由 / 调度」, 并引用 modes 文档作为详细说明。 --- docs/guide/usage/cliproxy-first-time.md | 20 +++++------ docs/guide/usage/cliproxy-modes.md | 45 ++++++++++++++++--------- 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/docs/guide/usage/cliproxy-first-time.md b/docs/guide/usage/cliproxy-first-time.md index d035f073..a75b0d3d 100644 --- a/docs/guide/usage/cliproxy-first-time.md +++ b/docs/guide/usage/cliproxy-first-time.md @@ -18,16 +18,16 @@ outline: deep 实例字段对应数据库表 `cliproxy_instances`(`src/lib/db/schema-pg.ts:718`),表单字段如下: -| 字段 | 必填 | 含义 | -| ---------------- | ---------------------- | ----------------------------------------------------------------------------------------- | -| `name` | 是,唯一,最长 64 字符 | 实例名称,仅用于管理后台显示 | -| `mode` | 是,默认 `managed` | `managed`(sidecar,与 AutoRouter 同 Docker 网络)/ `external`(独立运行的远端 CPA 服务) | -| `base_url` | 是 | 客户端代理转发的基础地址。后续创建池上游时会拼接 provider 后缀作为上游 `base_url` | -| `management_url` | 是 | 管理 API 基础地址。AutoRouter 调用 `/v0/management/*` 拉取账号、发起 OAuth 等都走这里 | -| `client_api_key` | 创建必填,编辑可留空 | 转发流量时注入到 `Authorization` 头的密钥;DB 中以 Fernet 加密存(`schema-pg.ts:727`) | -| `management_key` | 创建必填,编辑可留空 | 管理 API 鉴权密钥;DB 中同样 Fernet 加密 | -| `enabled` | 否,默认 true | 关闭后所有依赖该实例的池上游不可用 | -| `description` | 否,最长 512 字符 | 备注 | +| 字段 | 必填 | 含义 | +| ---------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | 是,唯一,最长 64 字符 | 实例名称,仅用于管理后台显示 | +| `mode` | 是,默认 `managed` | `managed`(sidecar,与 AutoRouter 同 Docker 网络)/ `external`(独立运行的远端 CPA 服务) | +| `base_url` | 是 | 客户端代理转发的基础地址。后续创建池上游时会拼接 provider 后缀作为上游 `base_url` | +| `management_url` | 是 | 管理 API 基础地址。AutoRouter 调用 `/v0/management/*` 拉取账号、发起 OAuth 等都走这里 | +| `client_api_key` | 创建必填,编辑可留空 | 转发流量时注入到 `Authorization` 头的密钥;DB 中以 Fernet 加密存(`schema-pg.ts:727`) | +| `management_key` | 创建必填,编辑可留空 | 管理 API 鉴权密钥;DB 中同样 Fernet 加密 | +| `enabled` | 否,默认 true | 仅作为管理后台的 enabled / disabled 标识,**不参与路由 / 调度**——把它设为 false 不会让依赖该实例的池上游自动停止接流量;要停流量请在上游层 `is_active = false`。详见 [外部 vs sidecar 选择](./cliproxy-modes) | +| `description` | 否,最长 512 字符 | 备注 | ### sidecar 拓扑下的 base_url 与 management_url 怎么填 diff --git a/docs/guide/usage/cliproxy-modes.md b/docs/guide/usage/cliproxy-modes.md index 2d4d6e26..adc7f97a 100644 --- a/docs/guide/usage/cliproxy-modes.md +++ b/docs/guide/usage/cliproxy-modes.md @@ -41,21 +41,24 @@ if (mode === "external") { } ``` -| 模式 | URL 格式 | 协议(http / https) | SSRF 校验 | IP 字面量为私有 / loopback / 元数据端点 | 域名解析到私有 IP | -| ---------- | -------- | -------------------- | ---------------------- | --------------------------------------- | ------------------ | -| `managed` | 必须 | 必须 | **跳过** | **允许** | **允许** | -| `external` | 必须 | 必须 | 走 `isUrlSafe`(SSRF) | **拦截** | **不解析、不拦截** | +| 模式 | URL 格式 | 协议(http / https) | SSRF 校验 | IPv4 字面量为私有 / loopback / 元数据 | IPv6 字面量(含 loopback / ULA) | 域名解析到私有 IP | +| ---------- | -------- | -------------------- | ---------------------- | ------------------------------------- | -------------------------------- | ------------------ | +| `managed` | 必须 | 必须 | **跳过** | **允许** | **允许** | **允许** | +| `external` | 必须 | 必须 | 走 `isUrlSafe`(SSRF) | **拦截** | **不拦截**(hostname 含方括号) | **不解析、不拦截** | -`isUrlSafe`(`src/lib/services/upstream-ssrf-validator.ts:69-94`)的判定逻辑分两路: +`isUrlSafe`(`src/lib/services/upstream-ssrf-validator.ts:69-94`)的判定逻辑分三路: - 解析出的 hostname 是 `localhost`:拒绝。 -- hostname 字面是 IP(正则 `^[\d.:]+$` 命中):交给 `isIpSafe` 校验,私有网段(10/8、172.16/12、192.168/16)、loopback(127/8、`::1`)、链路本地(169.254/16,含 AWS 元数据端点)、IPv6 ULA/链路本地/multicast 等全部拒绝。 -- hostname 是普通域名:**直接返回 `safe: true`**,**不做 DNS 解析**。 +- hostname 字面是 IPv4 字面量(正则 `^[\d.:]+$` 命中):交给 `isIpSafe` 校验,私有网段(10/8、172.16/12、192.168/16)、loopback(127/8)、链路本地(169.254/16,含 AWS 元数据端点)等全部拒绝。 +- 其余情况(普通域名 + IPv6 字面量):**直接返回 `safe: true`**,**不做 DNS 解析**。 -::: warning 重要限制:DNS 不解析 -`isUrlSafe` 对非 IP 字面量的 hostname 不查 DNS。因此 `external` 模式下登记 `http://cpa.internal` 这种域名地址,即使它最终解析到 `192.168.x.x` 或 `169.254.169.254`,也能通过校验。SSRF 防护实际上**只在 IP 字面量上生效**。 +::: warning 重要限制:仅 IPv4 字面量被拦截 +`isUrlSafe` 的拦截能力只覆盖 **IPv4 字面量**。两类危险地址会逃过校验: -仓库里另有一个 `resolveAndValidateHostname`(同文件 `:99-153`)会做 DNS 解析并校验所有解析结果,但 `validateInstanceAddress` 当前没有调用它。如果对外部 CPA 的拓扑信任度不够,建议把 `base_url` / `management_url` 直接写成公网 IP 字面量(让 IP 校验起作用),或者通过反向代理网关收敛入口。 +1. **IPv6 字面量**:`new URL("http://[::1]/").hostname === "[::1]"`(含方括号),正则 `/^[\d.:]+$/` 因为方括号不匹配,hostname 被当作普通域名直接 `safe: true`。`http://[::1]`、`http://[fc00::1]`、`http://[fe80::1]` 等 IPv6 loopback / ULA / 链路本地地址在 `external` 模式下**都能通过校验**。 +2. **域名**:`http://cpa.internal` 即使最终解析到 `192.168.x.x` 或 `169.254.169.254`,也能通过校验,因为 `isUrlSafe` 不做 DNS 解析。 + +仓库里另有一个 `resolveAndValidateHostname`(同文件 `:99-153`)会做 DNS 解析并校验所有解析结果(含 IPv6),但 `validateInstanceAddress` 当前没有调用它。如果对外部 CPA 的拓扑信任度不够,建议把 `base_url` / `management_url` 写成公网 IPv4 字面量(让 IP 校验起作用),或者通过反向代理网关收敛入口;至少不要依赖 `external` 模式的 SSRF 校验来挡住 IPv6 或域名形式的内网地址。 ::: 为什么要差异化处理: @@ -115,14 +118,26 @@ if (referencingUpstreams.length > 0) { 因此正确的删除顺序是: -| 步骤 | 操作 | -| ---- | ------------------------------------------------------------------------------------------ | -| 1 | 该实例下所有**池上游 / 单账号上游**逐条删除(或先 `is_active = false` 让流量停掉,再删除) | -| 2 | 该实例下所有缓存的 **OAuth 账号**逐条删除(管理后台账号列表的删除按钮) | -| 3 | 删除实例本身 | +| 步骤 | 操作 | +| ---- | --------------------------------------------------------------------------------------------------------------- | +| 1 | 该实例下所有**池上游 / 单账号上游**逐条删除(或先 `is_active = false` 让流量停掉,再删除) | +| 2 | 在 **CPA 侧**删掉对应的 auth-file,再回到 AutoRouter 触发**账号同步**,让本地缓存条目随同步被清除(见下方说明) | +| 3 | 删除实例本身 | 任何一步漏做都会让第三步返 409,提示中明确写出还有哪一类引用未清理。**没有「强制删除」开关**——这是有意设计,避免把还在接流量的池上游意外切断。 +::: warning 账号列表当前没有「删除账号」按钮 +账号子路由 `src/app/api/admin/cliproxy/instances/[id]/auth-accounts/[accountName]/route.ts` 只导出了 `PATCH`(字段更新),同目录下另有 `status/route.ts`(启停)与 `upstream/route.ts`(映射上游),但 AutoRouter **没有**为账号实现 `DELETE` 路由。管理后台账号表 UI 只提供「启用/禁用」「编辑字段」「映射为上游」三个动作。 + +因此「清掉本地缓存账号」的可行做法是借助 sync 反向清理: + +1. 在 CPA 自己的管理界面(或直接操作其 auth-files 目录)删掉对应 OAuth 凭据文件。 +2. 回到 AutoRouter 触发账号同步(管理后台账号列表的「同步」按钮,或调用 `POST /api/admin/cliproxy/instances/:id/auth-accounts/sync`)。 +3. 同步流程在 `cliproxy-auth-account-service.ts:189-197` 会把本地 `cliproxy_auth_accounts` 中「CPA 侧已不存在」的行 `db.delete` 掉,`sync` 结果里的 `removed` 字段记录了清理条数。 + +如果 CPA 也不可达、根本同步不动,又必须删除 AutoRouter 侧的实例,最后的兜底是直接对 `cliproxy_auth_accounts` 表执行 `DELETE FROM cliproxy_auth_accounts WHERE instance_id = ''`(生产环境慎用,建议先备份)。 +::: + 如果需要迁移到另一台 CPA:先建好新实例并把流量切过去(新建池上游 + 老池上游 `is_active = false` 让权重自然停止流入),观察一段无流量后再按上面三步删除老实例。 ## 何时选哪个 From 6c6e8bf90b5991478a0c1c66654074aab4e884b2 Mon Sep 17 00:00:00 2001 From: umaru Date: Sat, 23 May 2026 22:28:28 +0800 Subject: [PATCH 5/5] docs(usage): fix two review findings round 3 on PR #182 (#167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 docker compose restart:原文档建议 `docker compose restart cliproxyapi` 让 CPA 重新读 .env,但实际上 restart 只是重启已有容器,env 仍是容器创建 时捕获的旧值。改为推荐用叠加文件再次 up -d(compose 检测到 env 变化会 自动重建容器),并给出显式 --force-recreate 的替代写法;同时同步「校验 方法」一节的引用文案。 P3 managed→external 校验:原文档说切到 external 时「内网地址」会被校验 失败。这与已经修正过的 SSRF 限制矛盾——isUrlSafe 不查 DNS、不拦 IPv6 字面量、不拦域名,只有 IPv4 字面量为私有/loopback/链路本地时才被拦。 http://cliproxyapi:8317 这种服务名能通过校验。改为:只有内网 IPv4 字面量 会被拦,但服务名/域名虽能通过校验,切到 external 后仍不可达,因此切 mode 时仍应同步改地址,「校验通过」不是判定能切的依据。 --- docs/guide/usage/cliproxy-egress-proxy.md | 14 ++++++++++++-- docs/guide/usage/cliproxy-modes.md | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/guide/usage/cliproxy-egress-proxy.md b/docs/guide/usage/cliproxy-egress-proxy.md index 1ed5f6e1..041931dd 100644 --- a/docs/guide/usage/cliproxy-egress-proxy.md +++ b/docs/guide/usage/cliproxy-egress-proxy.md @@ -69,7 +69,17 @@ outline: deep ### 生效时机 -环境变量在 `docker-compose` 配置中是**容器启动期注入**——修改 `.env` 后需要 `docker compose up -d` 或 `docker compose restart cliproxyapi` 让 CPA 重新读配置。运行中改 `.env` 不会自动生效。 +环境变量在 `docker-compose` 配置中是**容器启动期注入**——修改 `.env` 后必须**重建容器**,CPA 才会读到新值。注意:`docker compose restart cliproxyapi` **不行**,它只是重启已有容器,仍然使用容器创建时捕获的旧 env。正确做法是用叠加文件再次 `up -d`(compose 会检测到 env 变化并自动重建受影响的容器),或者显式 `--force-recreate`: + +``` +# 推荐:compose 自动判断需要重建哪些容器 +docker compose -f docker-compose.yml -f docker-compose.cliproxy.yml up -d + +# 或显式强制重建 CPA 容器 +docker compose -f docker-compose.yml -f docker-compose.cliproxy.yml up -d --force-recreate cliproxyapi +``` + +运行中改 `.env` 不会自动生效。 ### 外部模式(`external`)下 @@ -131,7 +141,7 @@ CPA 侧的行为:账号粒度 `proxy_url` 非空时覆盖全局;为空(或 ### 全局代理是否生效 -1. 改完 `.env` 并 `docker compose -f docker-compose.yml -f docker-compose.cliproxy.yml up -d` 后,检查容器 env: +1. 改完 `.env` 并 `docker compose -f docker-compose.yml -f docker-compose.cliproxy.yml up -d`(容器会被重建,参见上一节)后,检查容器 env: ``` docker compose exec cliproxyapi env | grep CLIPROXY_PROXY_URL ``` diff --git a/docs/guide/usage/cliproxy-modes.md b/docs/guide/usage/cliproxy-modes.md index adc7f97a..9dd39519 100644 --- a/docs/guide/usage/cliproxy-modes.md +++ b/docs/guide/usage/cliproxy-modes.md @@ -157,7 +157,7 @@ if (referencingUpstreams.length > 0) { `mode` 字段允许通过 PATCH 接口修改(`src/lib/services/cliproxy-instance-crud.ts:238`,`mode = input.mode ?? (current.mode as CliproxyInstanceMode)`)。但要注意切换方向: -- `managed` → `external`:切换会立即触发地址重新校验,如果当前 `base_url` / `management_url` 是内网地址,校验失败,PATCH 被拒。先把地址改成符合 SSRF 校验的形式(或同时改 mode + 地址),再保存。 +- `managed` → `external`:切换会立即触发地址重新校验。**只有内网 IPv4 字面量**(如 `http://172.20.0.5:8317`)会被 `isUrlSafe` 拦下,PATCH 被拒;Docker 服务名(如 `http://cliproxyapi:8317`)、IPv6 字面量、任意域名都会**通过校验**——参见上文「地址校验」一节的限制说明。也就是说大多数 sidecar 拓扑下的 managed 实例切到 external 时**不会因为校验失败而被拒**,但这些值原本就不适合外部模式(服务名只在 docker 网络内可解析),切换后实例虽然能保存,CPA 调用仍会因连不上而失败。要换 mode 时仍应该把地址改成外部可达的真实地址,校验通不通过都不是判定能不能切的依据。 - `external` → `managed`:放宽校验,地址原值不变。切换后立即生效。 无论哪个方向,切换 `mode` 不影响已存在的池上游、OAuth 账号缓存、转发流量;仅影响后续创建 / 编辑实例时的地址校验。