diff --git a/docs/guide/architecture/cliproxy-integration.md b/docs/guide/architecture/cliproxy-integration.md index 3c5e9c6a..2ba2438d 100644 --- a/docs/guide/architecture/cliproxy-integration.md +++ b/docs/guide/architecture/cliproxy-integration.md @@ -5,19 +5,255 @@ outline: deep # CLIProxyAPI 集成位置 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 +CLIProxyAPI(以下简称 CPA)是承接 Codex、Claude、Gemini 三类 CLI OAuth 账号的独立服务。AutoRouter 并不接管 OAuth 凭据,而是把 CPA 当作一个具备账号池能力的上游服务来挂接:在 AutoRouter 侧记录实例连接信息和账号元数据缓存,转发时按池上游或单账号映射两种形态分发,OAuth token 始终留在 CPA 自身的持久化目录里。 + +本文档梳理 CPA 在 AutoRouter 架构里的具体位置:数据表、服务模块、转发路径、管理 API、前端入口,以及受管 sidecar 与外部服务两种部署模式在代码层面的差异。 + +## 数据模型 + +CPA 相关共有两张表,以及 `upstreams` 表中三个关联字段。 + +### `cliproxy_instances` — CPA 实例 + +每条记录代表一个可访问的 CPA 服务,字段定义见 `src/lib/db/schema-pg.ts:718-738`: + +| 字段 | 类型 | 说明 | +| -------------------------- | ------------------ | ---------------------------------------------------------------------- | +| `id` | uuid PK | 主键 | +| `name` | varchar(64) unique | 实例显示名 | +| `mode` | varchar(16) | `managed`(受管 sidecar)或 `external`(外部独立服务),默认 `managed` | +| `base_url` | text | CPA 代理转发基础地址,用于拼接池上游的 `baseUrl` | +| `management_url` | text | CPA 管理 API 基础地址,用于列表/同步/启停账号、发起 OAuth 等管理操作 | +| `client_api_key_encrypted` | text | CPA 客户端 API Key 的 Fernet 密文 | +| `management_key_encrypted` | text | CPA 管理 API 密钥的 Fernet 密文 | +| `enabled` | boolean | 实例是否启用,默认 `true` | +| `description` | text | 备注 | + +模式取值由 `src/lib/services/cliproxy-instance-crud.ts:20` 的 `CLIPROXY_INSTANCE_MODES` 常量约束: + +```ts +export const CLIPROXY_INSTANCE_MODES = ["managed", "external"] as const; +``` + +凭据明文从不落库。`getDecryptedClientApiKey` 在 `cliproxy-instance-crud.ts` 中按需对 Fernet 密文做解密,用完即弃。 + +### `cliproxy_auth_accounts` — OAuth 账号元数据缓存 + +每条记录映射 CPA 上的一个 auth-file,字段见 `src/lib/db/schema-pg.ts:744-771`: + +| 字段 | 类型 | 说明 | +| ------------------- | --------------------------------------------------- | --------------------------------------------- | +| `id` | uuid PK | 主键 | +| `instance_id` | uuid FK → `cliproxy_instances.id` ON DELETE CASCADE | 所属实例 | +| `auth_file_name` | text | CPA 侧 auth-file 名 | +| `provider` | text | 服务商(`codex` / `anthropic` / `gemini` 等) | +| `email` / `status` | text | CPA 同步过来的快照字段 | +| `disabled` | boolean | 账号是否被禁用,默认 `false` | +| `prefix` | text | 模型名前缀,用于单账号固定路由 | +| `model_count` | integer | 该账号当前能用的模型数 | +| `priority` / `note` | integer / text | 管理员维护的优先级与备注 | +| `raw_metadata` | jsonb | CPA 响应字段的非敏感快照,禁止包含 token | +| `last_synced_at` | timestamptz | 上次同步成功时间 | + +`(instance_id, auth_file_name)` 上有唯一约束(`schema-pg.ts:768`),保证同步幂等。 + +::: warning OAuth token 不入库 +表注释明确写明:「OAuth token material is never stored here; it stays in CLIProxyAPI's auth-dir」(`schema-pg.ts:742`)。AutoRouter 缓存的全部是非敏感元数据。`raw_metadata` 由 `buildRawMetadata` 过滤后写入,token 字段在过滤步骤被剔除。 +::: + +### `upstreams` 表的 CPA 关联字段 + +`src/lib/db/schema-pg.ts:114-119` 为 `upstreams` 表追加了三个可空字段: + +```ts +cliproxyInstanceId: uuid("cliproxy_instance_id").references(() => cliproxyInstances.id, { + onDelete: "set null", +}), +cliproxyAuthFileName: text("cliproxy_auth_file_name"), +cliproxyProvider: varchar("cliproxy_provider", { length: 32 }), +``` + +普通上游三个字段全为 `null`。CPA 上游按下面规则判定形态,没有专门的 `is_cliproxy_pool` 标志位: + +| 形态 | `cliproxyInstanceId` | `cliproxyAuthFileName` | 说明 | +| -------------- | -------------------- | ---------------------- | -------------------------------------------------- | +| 普通上游 | `null` | `null` | 不走 CPA | +| 池上游 | 有值 | `null` | 落到该实例的某个服务商池,CPA 内部按负载策略选账号 | +| 单账号映射上游 | 有值 | 有值 | 固定路由到该 auth-file 对应账号 | + +实例的删除受守卫保护,并非依赖 FK 级联兜底。`deleteCliproxyInstance`(`cliproxy-instance-crud.ts:290-324`)在删除前依次检查: + +1. 实例下是否仍有缓存的 OAuth 账号——若有则抛 `CliproxyInstanceInUseError`,提示「请先移除账号后再删除实例」 +2. 是否仍有关联该实例的上游(含池上游与单账号映射上游)——若有则抛同类错误,提示「请先删除相关上游后再删除实例」 + +只有当账号和上游都已清空时,实例本身才会被删除。三个 CPA 关联字段中只有 `cliproxyInstanceId` 带 FK `ON DELETE SET NULL`,但这条 FK 在正常路径下不会触发,仅作兜底,例如直接 SQL 删除绕过应用层守卫的极端情况。`cliproxyAuthFileName` 和 `cliproxyProvider` 是纯文本字段,没有外键,删除实例后不会自动清理。 + +## 服务模块分工 + +`src/lib/services/cliproxy-*` 共六个文件,各自承担独立职责。 + +| 文件 | 职责 | +| ---------------------------------- | ---------------------------------------------------------------------------------- | +| `cliproxy-instance-crud.ts` | 实例 CRUD、凭据加解密、按 mode 分支的地址校验 | +| `cliproxy-management-client.ts` | 封装 CPA 管理 API 的 HTTP 调用(列 auth-files、查模型、改字段、查 OAuth URL/状态) | +| `cliproxy-auth-account-service.ts` | OAuth 账号本地缓存的列表、读取、同步、字段更新、启停 | +| `cliproxy-oauth-login-service.ts` | OAuth 登录流程编排(发起授权、轮询状态、登录成功后触发同步) | +| `cliproxy-upstream-preset.ts` | 按服务商一键创建池上游与单账号映射上游,封装路径后缀、路由能力、前缀拼接 | +| `cliproxy-connection-tester.ts` | CPA 管理 API 连通性检测(独立于普通上游连通性测试) | + +服务商预设在 `cliproxy-upstream-preset.ts:38-54` 集中维护: + +```ts +export const CLIPROXY_UPSTREAM_PRESETS: Record = { + codex: { + pathSuffix: "/v1", + routeCapabilities: ["codex_cli_responses", "openai_responses"], + label: "Codex", + }, + anthropic: { + pathSuffix: "/api/provider/anthropic/v1", + routeCapabilities: ["claude_code_messages", "anthropic_messages"], + label: "Claude", + }, + gemini: { + pathSuffix: "/api/provider/google", + routeCapabilities: ["gemini_native_generate"], + label: "Gemini", + }, +}; +``` + +CPA 调整对外约定时,路径后缀与默认路由能力的改动集中在这一处。 + +## OAuth 账号同步机制 + +`syncCliproxyAuthAccounts` 在 `cliproxy-auth-account-service.ts:128-201` 实现,方向是**单向拉取**:CPA 管理 API → AutoRouter 数据库。 + +流程: + +1. 通过 `listAuthFiles` 调用 CPA 的 `GET /v0/management/auth-files`,取得当前所有 auth-file 列表 +2. 对每个 auth-file 逐条 upsert 到 `cliproxy_auth_accounts`(按 `(instance_id, auth_file_name)`) +3. 单条 auth-file 的模型数查询通过 `getAuthFileModels` 单独发起;失败时不中断,回退到上次值或 0 +4. CPA 侧已不存在的本地缓存条目会被删除(`auth-account-service.ts:189-197`) +5. 单次同步返回 `{ added, updated, removed, total }` 计数 + +触发时机有两处:OAuth 登录成功后自动触发(`cliproxy-oauth-login-service.ts:91`),以及管理员手动调用 `POST /api/admin/cliproxy/instances/:id/auth-accounts/sync`。 + +## 转发路径中的 CPA 分支 + +CPA 上游在请求生命周期里只有一处特殊处理,即单账号映射上游的模型前缀注入,发生在 `src/app/api/proxy/v1/[...path]/route.ts:1511-1526`: + +```ts +let cliproxyModelOverride: string | undefined; +if (selectedUpstream.cliproxyAuthFileName && selectedUpstream.cliproxyInstanceId && requestModel) { + const accountPrefix = await resolveCliproxyAccountPrefix( + selectedUpstream.cliproxyInstanceId, + selectedUpstream.cliproxyAuthFileName + ); + if (accountPrefix) { + cliproxyModelOverride = buildCliproxyPrefixedModel(accountPrefix, requestModel); + } +} +``` + +判断条件是 `cliproxyAuthFileName` 和 `cliproxyInstanceId` 同时有值,即仅单账号映射上游会进入这一分支;普通上游和池上游都会跳过。 + +拼接后的 `/` 形态通过 `forwardRequest` 的 `modelOverride` 参数传到 `proxy-client.ts:896` 的 `applyModelOverride` 函数:OpenAI / Anthropic 协议改写 JSON body 中的 `model` 字段;Gemini 原生协议改写 URL 路径中的模型段(`proxy-client.ts:887` 的 `GEMINI_NATIVE_MODEL_SEGMENT` 正则匹配)。 + +::: tip 池上游不依赖前缀 +池上游的 baseUrl 已经拼好了服务商路径后缀(如 `/api/provider/anthropic/v1`),CPA 收到请求后会按 CPA 自身的账号选择策略分发,AutoRouter 不再额外注入前缀。 ::: -## 计划覆盖的内容 +## 受管 sidecar 与外部服务的差异 + +两种模式的代码差异集中在地址校验和容器编排上。 + +### 地址安全校验 + +`validateInstanceAddress`(`cliproxy-instance-crud.ts:105-128`)按 mode 分支: + +| 模式 | 校验内容 | +| ---------- | --------------------------------------------------------------------------------------------------------------- | +| `managed` | 仅校验 URL 格式与 `http:` / `https:` 协议;允许私有与内网地址(因为 sidecar 在同一 Docker 网络) | +| `external` | 在上述基础上额外执行 `isUrlSafe`:拦截 `localhost`、字符串形态的私有 IP / loopback / link-local / IPv6 私网等等 | + +`isUrlSafe` 是同步函数,只校验 URL 协议与字面 hostname;当 hostname 是域名时,**不做 DNS 解析**,因此该路径**不包括** [安全模型](./security#ssrf-三重校验) 文档里的第三重 `resolveAndValidateHostname` 校验。换言之,一个解析到 `127.0.0.1` 或 AWS 元数据地址的恶意域名理论上能通过 external 模式的实例创建校验。普通上游创建与连通性测试都会跑第三重 DNS 校验(参见上游连通性测试与探针),CPA 实例 external 模式目前是个例外。 + +填写 sidecar 的容器服务名 `http://cliproxyapi:8317` 之所以能通过校验,正是因为 managed 模式跳过了整个 `isUrlSafe` 那一层。 + +### 容器编排(仅 managed) + +`docker-compose.cliproxy.yml` 是可选叠加文件,启用方式: + +```bash +docker compose -f docker-compose.yml -f docker-compose.cliproxy.yml up -d +``` + +关键编排约定: + +- 镜像 `${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api:latest}` +- 入口脚本 `/cliproxy/docker-entrypoint.sh` 先按环境变量渲染 `config.yaml.template` 再启动 CPA 进程 +- 命名卷 `cliproxy-auth` 挂到 `/root/.cli-proxy-api`,承载 OAuth 凭据目录,跨容器重启保留 +- 命名卷 `cliproxy-logs` 挂到 `/CLIProxyAPI/logs`,承载运行日志 +- 默认只在 `autorouter-net` 网络内可达,不暴露宿主机端口 + +`.env` 中的 `CLIPROXY_CLIENT_API_KEY` 与 `CLIPROXY_MANAGEMENT_KEY` 必须与 AutoRouter 实例记录里 Fernet 密文对应的明文一致,否则 AutoRouter 调 CPA 管理 API 会失败。 + +### 外部模式 + +`external` 模式下 AutoRouter 不参与容器编排,CPA 由运维独立部署。`base_url` 和 `management_url` 填外部地址,受 SSRF 校验约束,不能填私网或 loopback。 + +## 连通性检测 + +CPA 实例的连通性检测与普通上游完全分离。 + +`cliproxy-connection-tester.ts` 中的 `testCliproxyConnection` 调用 CPA 的 `GET /v0/management/auth-files` 端点验证管理 API 可达性与凭据有效性。普通上游的 `upstream-connection-tester.ts:322` 中的 `testUpstreamConnection` 没有 CPA 特殊分支——池上游和单账号映射上游虽然落库在 `upstreams` 表,但管理面板上的「测试连接」按钮对它们走的是普通上游的 OpenAI 兼容探测路径,不再去 CPA 管理 API 验账号。 + +实例本身的连通性测试由两条 Admin API 触发:保存前预检 `POST /api/admin/cliproxy/instances/test`,以及对已保存实例的 `POST /api/admin/cliproxy/instances/:id/test`。 + +## Admin API 全貌 + +`src/app/api/admin/cliproxy/instances/` 下的全部路由: + +| 路径 | 方法 | 职责 | +| ----------------------------------------------------------------------- | -------------------- | ------------------------------------------- | +| `/api/admin/cliproxy/instances` | GET | 列出全部实例 | +| `/api/admin/cliproxy/instances` | POST | 创建实例 | +| `/api/admin/cliproxy/instances/test` | POST | 未保存配置的创建前连通性预检 | +| `/api/admin/cliproxy/instances/:id` | GET / PATCH / DELETE | 实例详情、更新、删除 | +| `/api/admin/cliproxy/instances/:id/test` | POST | 已保存实例的连通性检测 | +| `/api/admin/cliproxy/instances/:id/oauth-login` | POST | 发起 OAuth 登录,返回授权 URL 和 state | +| `/api/admin/cliproxy/instances/:id/oauth-login/status` | GET | 轮询登录状态;成功时触发账号同步 | +| `/api/admin/cliproxy/instances/:id/auth-accounts` | GET | 列出实例下缓存的 OAuth 账号 | +| `/api/admin/cliproxy/instances/:id/auth-accounts/sync` | POST | 手动触发账号同步 | +| `/api/admin/cliproxy/instances/:id/auth-accounts/:accountName` | PATCH | 更新账号字段(prefix / priority / note 等) | +| `/api/admin/cliproxy/instances/:id/auth-accounts/:accountName/status` | PATCH | 启停账号 | +| `/api/admin/cliproxy/instances/:id/auth-accounts/:accountName/upstream` | POST | 创建单账号映射上游 | +| `/api/admin/cliproxy/instances/:id/pool-upstreams` | POST | 按服务商一键创建 OAuth 池上游 | + +所有路由都要求管理员 Bearer Token 鉴权。 + +## 前端入口 + +CPA 管理面板的唯一页面为 `src/app/[locale]/(dashboard)/system/cliproxy/page.tsx`,引用的核心组件按职责划分: -CPA 在整体架构中的位置、`cliproxy_instances` 表如何与 `upstreams` 表挂钩、池上游的本质。 +| 组件 | 职责 | +| -------------------------------------- | ---------------------------------- | +| `cliproxy-instances-table.tsx` | 实例列表表格 | +| `cliproxy-instance-form-dialog.tsx` | 实例创建 / 编辑表单 | +| `cliproxy-connection-test-dialog.tsx` | 连通性检测对话框 | +| `cliproxy-accounts-panel.tsx` | OAuth 账号面板入口 | +| `cliproxy-accounts-table.tsx` | OAuth 账号表格(在 panel 内嵌套) | +| `cliproxy-oauth-login-dialog.tsx` | OAuth 登录流程对话框 | +| `cliproxy-pool-upstream-dialog.tsx` | 按服务商一键创建池上游 | +| `cliproxy-account-upstream-dialog.tsx` | 单账号映射上游创建对话框 | +| `cliproxy-account-fields-dialog.tsx` | 账号 prefix / priority / note 编辑 | -## 在正文就绪前的临时建议 +实例表单的 `mode` 选择决定下方 `base_url` / `management_url` 的字段提示策略:受管模式提示固定为 `http://cliproxyapi:8317`,外部模式切换为「填外部 CPA 的转发地址」。这块 UI 内嵌指南由 Issue #167 的 Phase 3 跟踪。 -在该文档正文上线之前,可以参考以下材料获取等价信息: +## 与其他架构文档的衔接 -- 项目仓库根目录的 [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) +- 上游表 schema 全字段细节见 [上游模型](./upstream-model) +- 转发请求的完整生命周期(含 CPA 模型前缀注入位置)见 [请求生命周期](./request-lifecycle) +- 实例凭据 Fernet 加密、`isUrlSafe` 三重 SSRF 校验见 [安全模型](./security) +- 部署形态选择(受管 sidecar vs 外部)以及 sidecar 启用步骤见现有长篇 [`cliproxy-deployment`](/cliproxy-deployment) diff --git a/docs/guide/architecture/i18n.md b/docs/guide/architecture/i18n.md index 3d9a680a..f90c9695 100644 --- a/docs/guide/architecture/i18n.md +++ b/docs/guide/architecture/i18n.md @@ -5,19 +5,272 @@ outline: deep # 国际化机制 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 +AutoRouter 的管理面板基于 next-intl 实现 zh-CN 与 en 双语切换。所有面向用户的页面、组件文案、错误提示都通过翻译键引用 `src/messages/` 下的 JSON 资源,URL 中始终携带 locale 前缀,切换语言走 Cookie + URL 改写两条通道。 + +文档站本身(即正在阅读的 VitePress 站点)有独立的 i18n 体系,与应用层 next-intl 完全解耦。本文档先讲应用层,再交代两者的边界。 + +## 依赖与版本 + +`package.json:52`: + +```json +"next-intl": "^4.9.2" +``` + +## 配置文件分层 + +`src/i18n/` 下四个文件按职责拆分: + +| 文件 | 职责 | +| --------------- | ------------------------------------------------------------------ | +| `config.ts` | locale 常量(locales、defaultLocale、cookie 名)和 Locale 类型 | +| `routing.ts` | next-intl 路由配置(前缀策略、cookie 参数) | +| `request.ts` | 服务端 `getRequestConfig`,按 locale 动态 import 翻译文件 | +| `navigation.ts` | 导出 i18n-aware 的 `Link` / `useRouter` / `usePathname` 等导航工具 | + +### `config.ts` + +```ts +export const locales = ["zh-CN", "en"] as const; +export const defaultLocale: Locale = "zh-CN"; +export const localeCookieName = "NEXT_LOCALE"; +export const localeCookieMaxAge = 60 * 60 * 24 * 365; + +export const localeNames: Record = { + "zh-CN": "简体中文", + en: "English", +}; +``` + +支持两种语言,默认中文,Cookie 名沿用 Next.js 约定的 `NEXT_LOCALE`,有效期一年。 + +### `routing.ts` + +```ts +export const routing = defineRouting({ + locales, + defaultLocale, + localePrefix: "always", + localeCookie: { + name: localeCookieName, + maxAge: localeCookieMaxAge, + sameSite: "lax", + }, +}); +``` + +`localePrefix: "always"` 表示**所有路径都强制带 locale 段**,即 `/zh-CN/dashboard` 而非 `/dashboard`。默认 locale 不享受省略特权。这种策略下 URL 总是显式表达语言,对外分享链接和后端日志的归类都更直接。 + +`sameSite: "lax"` 允许顶级导航跨站携带 Cookie,符合「用户从外站点开链接进来」的常见场景。 + +### `request.ts` + +```ts +export default getRequestConfig(async ({ requestLocale }) => { + let locale = await requestLocale; + + if (!locale || !routing.locales.includes(locale as Locale)) { + locale = routing.defaultLocale; + } + + return { + locale, + messages: (await import(`../messages/${locale}.json`)).default, + }; +}); +``` + +服务端入口。`requestLocale` 由 next-intl 从当前请求的 URL 段解析;非法或缺失时回退到 `defaultLocale`,避免抛错。翻译资源走动态 `import()`,被 Next.js 打包时按语言切分 chunk。 + +### `navigation.ts` + +```ts +export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing); +``` + +封装出 i18n-aware 的导航 API。组件里用这套替代 `next/link` / `next/navigation`,跳转时自动维护 locale 前缀。 + +## 路由层 + +``` +src/app/ +├── layout.tsx # 根 layout:HTML 结构、字体、不引入 next-intl +└── [locale]/ + ├── layout.tsx # locale-aware layout:注入 NextIntlClientProvider + ├── page.tsx # 首页(locale 根) + ├── (auth)/ + │ └── login/page.tsx + └── (dashboard)/ + ├── layout.tsx + ├── dashboard/ + ├── keys/ + ├── logs/ + ├── settings/ + ├── system/ + └── upstreams/ +``` + +### 根 vs locale 分工 + +`src/app/layout.tsx` 只负责 HTML 骨架、字体变量、`` 标签的 `lang` 属性,**不引入 next-intl**。所有 i18n 上下文集中在 `src/app/[locale]/layout.tsx`: + +```ts +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} + +export default async function LocaleLayout({ children, params }: LocaleLayoutProps) { + const { locale } = await params; + + if (!routing.locales.includes(locale as Locale)) { + notFound(); + } + + setRequestLocale(locale); + const messages = await getMessages(); + + return ( + + + + + +
{children}
+ +
+
+
+
+
+ ); +} +``` + +关键点: + +- `generateStaticParams` 遍历 `routing.locales`,让 Next.js 为每种语言预生成静态参数 +- locale 不在白名单时调 `notFound()`,触发 404 页面而非静默回退 +- `setRequestLocale(locale)` 启用静态渲染优化,让下层 server component 在打包阶段就能确定 locale +- `getMessages()` 间接调到 `request.ts` 的 `getRequestConfig`,把完整翻译资源批量注入给 `NextIntlClientProvider`,下层任何 client component 都能直接读取 +- `(auth)` 和 `(dashboard)` 是路由分组(route group),不出现在 URL 中,仅在文件系统层组织代码 + +## 中间件 + +`src/proxy.ts` 是 Next.js 的 middleware 入口(文件名不是惯用的 `middleware.ts`,但 Next.js 同时支持 `proxy.ts`): + +```ts +import createMiddleware from "next-intl/middleware"; +import { routing } from "./i18n/routing"; + +export default createMiddleware(routing); + +export const config = { + matcher: ["/((?!_next|api|.*\\..*).*)"], +}; +``` + +matcher 排除三类路径: + +| 模式 | 含义 | +| --------- | ---------------------------------------------------------- | +| `_next` | Next.js 内部资源 | +| `api` | 所有 API 路由 | +| `.*\\..*` | 任何包含 `.` 的路径(静态文件,如 `favicon.ico`、`*.png`) | + +其余请求统一交给 next-intl 中间件处理:补 locale 前缀、读写 `NEXT_LOCALE` Cookie、按 `Accept-Language` 头协商默认语言。 + +API 路由完全不走 i18n 中间件,对外不暴露语言概念——API 返回的错误码统一英文,由前端按当前 locale 翻译展示。 + +## 翻译文件组织 + +`src/messages/` 下两个 JSON: + +| 文件 | 行数 | +| ------------ | ---- | +| `zh-CN.json` | 1551 | +| `en.json` | 1546 | + +按功能 / 页面分 19 个顶层 namespace(按 `en.json` 出现顺序): + +``` +common · nav · repository · auth · dashboard · keys · logs · upstreams · +circuitBreaker · errors · language · theme · system · billing · +backgroundSync · trafficRecording · upstreamFailureRules · compensation · cliproxy +``` + +两份文件顶层 namespace 完全对齐,子树结构基本一致(5 行差异来自 zh-CN 部分键值有额外的注释字符串)。 + +::: tip namespace 命名约定 +namespace 以「**页面或功能模块**」而非「组件」为粒度。例如 `keys` 容纳 API Key 管理整个页面的全部文案,`upstreams` 容纳上游管理页面,`circuitBreaker` 容纳熔断器子页面,跨页面共用的字段统一放 `common`。新增页面时同步在两份 JSON 加新 namespace。 ::: -## 计划覆盖的内容 +## 客户端与服务端的使用模式 + +### 客户端 + +next-intl 的两个核心 hook: + +```ts +// src/app/[locale]/(auth)/login/page.tsx:118-119 +const t = useTranslations("auth"); +const tCommon = useTranslations("common"); +``` + +```ts +// src/components/dashboard/time-range-selector.tsx:36 +const locale = useLocale(); +``` + +`useTranslations` 接受 namespace 字符串,返回的 `t` 函数按相对路径取值;`useLocale` 用于需要按当前语言切换展示逻辑的场景(比如日期格式化)。 + +### 服务端 + +项目中服务端翻译统一走 `[locale]/layout.tsx` 注入的 messages,没有直接调用 `getTranslations`。如有 server component 需要单独取翻译,可按 next-intl 文档使用 `getTranslations` / `getLocale`。 + +## 语言切换组件 + +`src/components/language-switcher.tsx`: + +```ts +const handleLocaleChange = (nextLocale: Locale) => { + if (nextLocale === locale) return; + const queryString = searchParams.toString(); + const targetPath = queryString ? `${pathname}?${queryString}` : pathname; + router.replace(targetPath, { locale: nextLocale }); +}; +``` + +关键点: + +- `pathname` 和 `useRouter` 均来自 `@/i18n/navigation`,即 i18n-aware 版本,自动处理 locale 前缀替换 +- `router.replace(targetPath, { locale: nextLocale })` 触发跳转的同时,next-intl 中间件会更新 `NEXT_LOCALE` Cookie,下次访问任意路径都按新语言渲染 +- 当前 query string 被保留,避免切语言把分页 / 筛选条件清空 + +UI 用 `DropdownMenuRadioGroup` 把两种语言渲染为单选项,当前 locale 旁加 ✓ 图标。 + +## 文档站 i18n 与应用 i18n 的边界 + +VitePress 文档站(即你正在阅读的站点)在 `docs/.vitepress/config.ts:60-107` 单独配置了 `locales`: + +```ts +locales: { + root: { label: "简体中文", lang: "zh-CN", themeConfig: { /* 完整侧边栏 */ } }, + en: { label: "English", lang: "en-US", link: "/en/", themeConfig: { /* WIP 占位 */ } }, +} +``` + +这是 VitePress 原生机制,与应用层 next-intl **完全独立**: -next-intl `[locale]` 路由、`src/messages/` 翻译文件的组织方式。 +| 维度 | 应用 i18n | 文档站 i18n | +| -------- | ------------------------------- | ---------------------------------------------- | +| 引擎 | next-intl 4.x | VitePress 原生 | +| 翻译源 | `src/messages/*.json` | 各页面 `docs/` / `docs/en/` 下的 Markdown 文件 | +| 路由策略 | `/zh-CN/...` `/en/...` 强制前缀 | `/...`(zh-CN root)/ `/en/...` | +| 部署 | Next.js 应用主体 | GitHub Pages 静态文档站 | -## 在正文就绪前的临时建议 +两套体系各自维护翻译,互不共享。这种分离是有意的:应用面文案与产品交互绑定,迭代节奏快;文档面内容偏稳定,迭代节奏慢,分别交给两套合适的工具维护。当前英文文档仅有「Overview (WIP)」占位页,完整英文化由 [Issue #167](https://github.com/g1331/AutoRouter/issues/167) 的后续阶段跟进。 -在该文档正文上线之前,可以参考以下材料获取等价信息: +## 与其他架构文档的衔接 -- 项目仓库根目录的 [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) +- 路由分组(`(auth)` / `(dashboard)`)的整体结构以及鉴权流程见 [安全模型](./security#登录会话) +- API 路由不走 i18n 中间件的整体路由结构见 [请求生命周期](./request-lifecycle) diff --git a/docs/guide/architecture/security.md b/docs/guide/architecture/security.md index f549b8c7..668f90fa 100644 --- a/docs/guide/architecture/security.md +++ b/docs/guide/architecture/security.md @@ -5,19 +5,325 @@ outline: deep # 安全模型 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 +AutoRouter 同时承担两类敏感数据:客户端 API Key(用户用来请求代理服务的凭据)和上游 API Key(AutoRouter 用来调用 OpenAI、Anthropic 等服务商的凭据)。两类凭据的保护策略不同:客户端 Key 使用 bcrypt 单向哈希存储,配合 Fernet 密文实现可控的「揭示」能力;上游 Key 必须能被解密用于实际转发,因此采用 Fernet 对称加密。 + +本文档梳理项目当前的安全机制:管理员鉴权、客户端 Key 哈希与揭示、上游 Key 加密、SSRF 三重校验,以及登录会话与中间件的边界划分。 + +## 管理员鉴权 + +管理员通过环境变量 `ADMIN_TOKEN` 配置静态 Bearer Token。`src/lib/utils/config.ts:25` 用 Zod schema 约束其非空: + +```ts +adminToken: z.string().min(1).optional(), +``` + +`config.ts:124-130` 的 `validateAdminToken` 直接做字符串相等比对: + +```ts +export function validateAdminToken(token: string | null): boolean { + if (!config.adminToken) { + return false; // 未配置时拒绝所有访问 + } + return token === config.adminToken; +} +``` + +请求侧封装在 `src/lib/utils/auth.ts:70-73` 的 `validateAdminAuth`:先从 `Authorization` 头中通过 `extractApiKey` 提取(同时支持 `Bearer ` 和原始 token 两种形式,见 `auth.ts:40-52`),再交给 `validateAdminToken`。 + +所有 `/api/admin/*` 路由都在入口处调用这个函数。以 `src/app/api/admin/keys/route.ts:42-45` 的 GET 为例: + +```ts +const authHeader = request.headers.get("authorization"); +if (!validateAdminAuth(authHeader)) { + return errorResponse("Unauthorized", 401); +} +``` + +::: warning Token 是单值密钥 +当前未实现多管理员或细粒度权限。`ADMIN_TOKEN` 是单一全局密钥,任何持有该值的客户端都拥有全部管理员能力。生产环境务必使用足够强的随机值,并通过 `.env` 文件或密钥管理工具下发,避免明文出现在 shell 历史或镜像里。 +::: + +## 客户端 API Key 双轨存储 + +每条 API Key 在 `api_keys` 表里同时落两个字段(`src/lib/db/schema-pg.ts:48-49`): + +| 字段 | 用途 | +| --------------------- | ----------------------------------------------------- | +| `key_hash` | bcrypt 哈希,cost factor 12,用于请求时的常量时间验证 | +| `key_value_encrypted` | Fernet 密文,用于「揭示」时还原明文 | + +bcrypt 由 `src/lib/utils/auth.ts:5-30` 封装: + +```ts +const BCRYPT_ROUNDS = 12; + +export async function hashApiKey(key: string): Promise { + return bcryptjs.hash(key, BCRYPT_ROUNDS); +} + +export async function verifyApiKey(key: string, hash: string): Promise { + try { + return await bcryptjs.compare(key, hash); + } catch { + return false; + } +} +``` + +### 创建时 + +`src/lib/services/key-manager.ts:286-303` 生成明文 Key,并行计算 bcrypt 哈希与 Fernet 密文,连同前 12 字符 `keyPrefix`(用于查询时缩小候选范围)一起入库: + +```ts +const keyValue = generateApiKey(); +const keyPrefix = keyValue.slice(0, 12); // 'sk-auto-xxxx' +const keyHash = await hashApiKey(keyValue); // bcrypt +const keyValueEncrypted = encrypt(keyValue); // Fernet +``` + +### 转发时的验证 + +代理路由 `src/app/api/proxy/v1/[...path]/route.ts:2452-2473` 用前缀查候选行,再对候选逐条 bcrypt 比对: + +```ts +const keyPrefix = getKeyPrefix(keyValue); +const candidates = await db.query.apiKeys.findMany({ + where: and(eq(apiKeys.keyPrefix, keyPrefix), eq(apiKeys.isActive, true)), +}); + +for (const candidate of candidates) { + const isValid = await verifyApiKey(keyValue, candidate.keyHash); + if (isValid) { + if (candidate.expiresAt && candidate.expiresAt < new Date()) { + return NextResponse.json({ error: "API key has expired" }, { status: 401 }); + } + validApiKey = candidate; + break; + } +} +``` + +前缀索引把 bcrypt 比对次数从「所有活跃 Key」降到「同前缀候选」,正常情况下只有 1 条记录。 + +### 揭示(可选能力) + +`ALLOW_KEY_REVEAL=true` 时管理员可以通过 `/api/admin/keys/:id/reveal` 拿回明文。`src/app/api/admin/keys/[id]/reveal/route.ts:27-36` 做两道闸门: + +```ts +const authHeader = request.headers.get("authorization"); +if (!validateAdminAuth(authHeader)) { + return errorResponse("Unauthorized", 401); +} +if (!config.allowKeyReveal) { + return errorResponse("Key reveal is disabled. ...", 403); +} +``` + +通过后调用 `revealApiKey`,内部在 `src/lib/utils/auth.ts:83-108` 先 `decrypt(encryptedKey)`,再用 `verifyApiKey(decryptedKey, keyHash)` 做 bcrypt 二次校验,防止数据库被篡改。 + +`ALLOW_KEY_REVEAL` 默认 `false`(`config.ts:31`),即使管理员通过鉴权也无法揭示,需要显式开启。 + +::: tip 历史 Legacy Key +存量数据中可能有只存了 `key_hash`、没有 `key_value_encrypted` 的 Legacy Key(早期版本)。`revealApiKey` 在 `auth.ts:84-86` 直接抛 `LegacyApiKeyError`,揭示路由返回 400「Legacy keys (bcrypt-only) cannot be revealed」。 ::: -## 计划覆盖的内容 +## 上游 API Key Fernet 加密 + +上游 Key 必须能解密回明文用于实际转发,因此使用对称加密。AutoRouter 自实现了 Fernet 兼容格式(`src/lib/utils/encryption.ts`),不依赖第三方库。 + +### 密钥与格式 + +`encryption.ts:6-17` 注释定义的 Fernet 帧格式: + +| 段 | 长度 | 说明 | +| ----------- | -------- | --------------------------------------------- | +| Version | 1 byte | 固定 `0x80` | +| Timestamp | 8 bytes | big-endian 秒级 Unix 时间,可用于未来扩展过期 | +| IV | 16 bytes | 随机初始化向量 | +| Ciphertext | 变长 | AES-128-CBC,PKCS7 padding | +| HMAC-SHA256 | 32 bytes | 对前四段做认证 | + +`ENCRYPTION_KEY` 是一个 base64 编码的 32 字节密钥(44 个字符含 padding)。`encryption.ts:35-68` 的 `loadEncryptionKey` 按下面顺序加载: + +```ts +const keyStr = process.env.ENCRYPTION_KEY; +const keyFile = process.env.ENCRYPTION_KEY_FILE; +// 优先环境变量,未设置时从 ENCRYPTION_KEY_FILE 指向的文件读取 +``` + +base64 解码后必须恰好 32 字节,前 16 字节作 HMAC signing key,后 16 字节作 AES encrypt key(`encryption.ts:60-64`)。 + +### 加解密 + +`encrypt`(`encryption.ts:85-109`): + +1. 生成 16 字节随机 IV、当前时间戳 +2. AES-128-CBC 加密明文 +3. 拼接 `version + timestamp + iv + ciphertext` 作为 HMAC 输入 +4. 用 signing key 计算 HMAC-SHA256 +5. 整帧编码为 base64 + +`decrypt`(`encryption.ts:117-161`):解析五段、校验 version、计算 HMAC 并用常量时间 `.equals()` 比对(`encryption.ts:146`),失败抛 `EncryptionError("HMAC verification failed")`,校验通过后 AES 解密。 + +### 调用点 -Admin Bearer Token、客户端 API Key bcrypt 哈希、上游 Key Fernet 加密、SSRF 防护(IP / URL / DNS 三重校验)。 +上游 Key 字段为 `upstreams.api_key_encrypted`(`schema-pg.ts:81`)。 -## 在正文就绪前的临时建议 +| 操作 | 文件位置 | +| ---------------- | ------------------------------------------------------------------------------ | +| 创建时加密 | `src/lib/services/upstream-crud.ts:480`(`encrypt(apiKey)`) | +| 更新时加密 | `upstream-crud.ts:575`(仅在请求带 `apiKey` 时改写) | +| 转发时解密 | `src/lib/services/proxy-client.ts:1293`(`decrypt(upstream.apiKeyEncrypted)`) | +| 健康检查解密 | `src/lib/services/health-checker.ts:283,559` | +| 管理面板掩码展示 | `upstream-crud.ts:759`(取明文后做星号掩码再返回) | + +### `ENCRYPTION_KEY` 丢失的影响 + +`encryption.ts:51-56` 在密钥未配置时直接抛错: + +```ts +throw new EncryptionError( + "ENCRYPTION_KEY is required. " + + "Generate one with: node -e \"console.log(require('crypto').randomBytes(32).toString('base64'))\"" +); +``` + +任何加解密调用都会 fail-fast——服务启动后第一次访问加密路径就会 5xx。 + +如果丢失的是旧 `ENCRYPTION_KEY`(被新值替换),所有存量的上游 Key 密文都将无法解密,但 bcrypt 哈希的客户端 Key 不受影响。**该密钥务必随同数据库一起做备份**,否则只能让管理员重新填一遍上游 Key。 + +## SSRF 三重校验 + +`src/lib/services/upstream-ssrf-validator.ts` 实现三层校验,按调用顺序逐层加固,对应外部用户填入上游地址时可能出现的攻击面。 + +### 第一层 `isIpSafe`:IP 段拦截 + +`upstream-ssrf-validator.ts:7-63`,对原始 IP 字符串做拦截: + +| 范围 | 拦截原因 | +| ----------------------------------------------- | --------------------------------------------------------------------- | +| `127.0.0.0/8`、`::1` | loopback,防止读到本机服务 | +| `10.0.0.0/8`、`172.16.0.0/12`、`192.168.0.0/16` | IPv4 私网 | +| `169.254.0.0/16` | link-local,覆盖 AWS / GCP / Azure 元数据端点(如 `169.254.169.254`) | +| `fc00::/7`、`fd00::/7` | IPv6 ULA 私有 | +| `fe80::/10` | IPv6 link-local | +| `ff00::/8` | IPv6 multicast | +| `::ffff:x.x.x.x`、`::x.x.x.x` | IPv4-mapped / IPv4-compatible IPv6 | + +### 第二层 `isUrlSafe`:URL 协议与字符串 hostname + +`upstream-ssrf-validator.ts:69-94`: + +```ts +if (url.protocol !== "http:" && url.protocol !== "https:") { + return { safe: false, reason: "Only HTTP and HTTPS protocols are allowed" }; +} + +if (hostname === "localhost") { + return { safe: false, reason: "Loopback addresses are not allowed" }; +} + +if (hostname.match(/^[\d.:]+$/)) { + return isIpSafe(hostname); +} +``` + +仅允许 `http:` / `https:`(屏蔽 `file:` / `gopher:` / `ftp:` 等高风险协议),并把字符串形式的 IP 转给第一层处理。 + +### 第三层 `resolveAndValidateHostname`:DNS 解析 + +`upstream-ssrf-validator.ts:99-153`,防御 DNS rebinding。对域名依次尝试 `resolve4` / `resolve6` / `lookup`,把解析出的全部 IP 都交给 `isIpSafe` 验证: + +```ts +for (const ip of addresses) { + const ipCheck = isIpSafe(ip); + if (!ipCheck.safe) { + return { + safe: false, + reason: `Hostname resolves to blocked IP: ${ipCheck.reason}`, + }; + } +} +``` + +解析失败也视为不安全(`upstream-ssrf-validator.ts:129-131`)。只验前两层会被解析到 `127.0.0.1` 的私有域名绕过,第三层补上这道闸。 + +### 调用点 + +| 场景 | 文件:行 | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| 上游连通性测试 | `upstream-connection-tester.ts:375`(`isUrlSafe`)、`:392`(`resolveAndValidateHostname`) | +| 上游探针 | `upstream-probe-service.ts:310`、`:316` | +| CPA `external` 模式实例创建 | `cliproxy-instance-crud.ts:121`(仅 `isUrlSafe`,参见 [CLIProxyAPI 集成位置](./cliproxy-integration#受管-sidecar-与外部服务的差异)) | + +CPA `managed` 模式跳过 SSRF 校验,因为 sidecar 故意需要走 Docker 内网容器服务名,那条路径上 SSRF 不构成实际威胁。 + +## CORS + +`CORS_ORIGINS` 在 `config.ts:42-45` 解析为字符串数组: + +```ts +corsOrigins: z + .string() + .optional() + .transform((s) => (s ? s.split(",").map((o) => o.trim()) : ["http://localhost:3000"])), +``` + +未设置时默认 `["http://localhost:3000"]`。该字段在 `config.ts` 中定义,但在当前中间件层(`src/proxy.ts`)和各 API 路由中没有引用——CORS 处理目前依赖 Next.js 自身的默认行为,并未基于 `config.corsOrigins` 做白名单。生产部署若需要严格 CORS,建议在反向代理层(Nginx / Caddy)配置。 + +## 中间件层 + +`src/proxy.ts` 是 Next.js 的 middleware 入口,仅做 i18n: + +```ts +import createMiddleware from "next-intl/middleware"; +import { routing } from "./i18n/routing"; + +export default createMiddleware(routing); + +export const config = { + matcher: ["/((?!_next|api|.*\\..*).*)"], +}; +``` + +`matcher` 明确排除 `/api/*`,因此鉴权、CORS、SSRF 校验全部在各 API 路由内联完成,没有中央拦截层。这种约定下新增 admin 路由时务必手动加上 `validateAdminAuth` 调用,避免遗漏。 + +## 登录会话 + +管理员登录流程极简,不依赖 JWT / iron-session / Cookie。 + +`src/app/[locale]/(auth)/login/page.tsx:144-147`:用户输入 token 后构造临时 `createApiClient` 调 `/admin/keys?page=1&page_size=1` 探测;探测通过则调 `setToken(inputValue)`。 + +`src/providers/auth-provider.tsx:18,71-75`: + +```ts +const STORAGE_KEY = "admin_token"; + +const setToken = useCallback((newToken: string) => { + sessionStorage.setItem(STORAGE_KEY, newToken); + window.dispatchEvent(new StorageEvent("storage", { key: STORAGE_KEY })); +}, []); +``` + +Token 完全存在浏览器 `sessionStorage`,使用 React `useSyncExternalStore`(`auth-provider.tsx:59`)订阅 `storage` 事件以保持多组件同步。每次 API 请求由 `auth-provider.tsx:101-104` 的 `createApiClient` 用 `getToken: () => token` 回调读取并拼成 `Authorization: Bearer `。 + +`auth-provider.tsx:91-98` 的 `handleUnauthorized` 在收到 401 时清掉 `sessionStorage` 并跳回 `/login`: + +```ts +const handleUnauthorized = useCallback(() => { + if (pathname === "/login") return; + clearToken(); + toast.error("认证已过期,请重新登录"); + router.push("/login"); +}, [clearToken, pathname, router]); +``` + +::: tip sessionStorage 而非 localStorage +关闭浏览器标签页就丢失登录态。短期 / 单次操作场景下足够,但不支持「保持登录」语义。如果未来需要长期会话,需引入正式的 session 存储机制并配套 CSRF 防护。 +::: -在该文档正文上线之前,可以参考以下材料获取等价信息: +## 与其他架构文档的衔接 -- 项目仓库根目录的 [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) +- 数据表 schema(含 `api_keys` / `upstreams` / `cliproxy_instances` 字段细节)见 [数据库 schema](./database-schema) +- 上游连通性测试与健康检查的调用流程见 [上游模型](./upstream-model)、[失败转移与熔断](./failover-circuit) +- CPA 实例凭据加密与 `managed` / `external` 校验差异见 [CLIProxyAPI 集成位置](./cliproxy-integration)