diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f6fc03548..34f25dab2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,24 +1,27 @@ { "permissions": { "allow": [ - "Bash(npm run format:*)", - "Bash(npm run lint)", - "Bash(npx tsc:*)", - "Bash(cargo test:*)", - "Bash(cargo build:*)", - "Bash(npm run check:*)", - "Bash(npm run:*)", - "Bash(tree:*)", - "Bash(find:*)", - "Bash(cargo check:*)", - "Bash(rm:*)", - "Bash(cargo clippy:*)", - "Bash(cargo fmt:*)", - "Bash(lsof:*)", - "Bash(xargs kill:*)", - "Bash(cargo run:*)" + "Bash", + "Read(*)", + "Write(*)", + "Edit(*)", + "MultiEdit(*)", + "Glob(*)", + "Grep(*)", + "Task(*)", + "TaskOutput(*)", + "LSP(*)", + "NotebookEdit(*)", + "TodoWrite(*)", + "AskUserQuestion(*)", + "EnterPlanMode(*)", + "ExitPlanMode(*)", + "KillShell(*)", + "WebFetch(domain:*)", + "Skill(*)", + "SlashCommand(*)" ], "deny": [], - "ask": [] + "defaultMode": "bypassPermissions" } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6e925bfa..06d485bb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,3 +47,17 @@ jobs: - name: Check Rust build working-directory: src-tauri run: cargo check --all-targets + + - name: Lint frontend + run: npm run lint + + - name: Test frontend + run: npm test + + - name: Clippy + working-directory: src-tauri + run: cargo clippy --all-targets + + - name: Test Rust + working-directory: src-tauri + run: cargo test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5bd33873..0aa8d8a17 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -97,8 +97,8 @@ jobs: ``` ### 默认配置 - - **端口**: 3001 - - **API Key**: proxycast-key + - **端口**: 8999 + - **API Key**: 首次启动自动生成,可在设置页查看/修改 releaseDraft: false prerelease: false args: --target ${{ matrix.target }} diff --git a/README.md b/README.md index 156d27180..be083699c 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ - **Per-Key 代理** - 为每个凭证单独配置代理 ### 🔐 安全与管理 -- **TLS/HTTPS 支持** - 可选启用 HTTPS 加密通信 +- **HTTPS 部署** - 当前版本不内置 TLS,请使用反向代理进行 HTTPS 终止 - **远程管理 API** - 通过 API 远程管理配置和凭证 - **访问控制** - 支持 localhost 限制和密钥认证 @@ -88,8 +88,12 @@ - `/v1/models` - 模型列表 - `/v1/messages` - Anthropic Messages API - `/v1/messages/count_tokens` - Token 计数 +- `/health` - 健康检查 +- `/ready` - 就绪检查 - `/api/provider/{provider}/v1/*` - Provider 路由 - `/v0/management/*` - 远程管理 API + - `/v0/management/backup` - 触发数据库备份 + - `/v0/management/restore` - 从备份恢复 --- @@ -135,20 +139,29 @@ 3. **启动服务** - 在 Dashboard 点击"启动服务器" 4. **配置客户端** - 在 Cherry-Studio、Cline 等工具中配置: ``` - API Base URL: http://localhost:3001/v1 - API Key: proxycast-key + API Base URL: http://localhost:8999/v1 + API Key: 启动时自动生成的密钥(可在设置页查看/修改) ``` --- +## 🧰 运维提示 + +- **自动备份**:数据库默认每天自动备份到 `~/.proxycast/backups/`,保留 7 天。 +- **配置备份**:每次写入配置会生成 `config.yaml.backup` 以便回滚。 +- **日志归档**:7 天游离线日志自动压缩,30 天前压缩日志自动清理。 +- **生产 HTTPS**:当前版本不内置 TLS,生产环境需反向代理终止 HTTPS。 + +--- + ## 🔧 API 使用示例 ### OpenAI Chat Completions ```bash -curl http://localhost:3001/v1/chat/completions \ +curl http://localhost:8999/v1/chat/completions \ -H "Content-Type: application/json" \ - -H "Authorization: Bearer proxycast-key" \ + -H "Authorization: Bearer your-api-key" \ -d '{ "model": "claude-sonnet-4-5-20250514", "messages": [ @@ -161,9 +174,9 @@ curl http://localhost:3001/v1/chat/completions \ ### Anthropic Messages API ```bash -curl http://localhost:3001/v1/messages \ +curl http://localhost:8999/v1/messages \ -H "Content-Type: application/json" \ - -H "x-api-key: proxycast-key" \ + -H "x-api-key: your-api-key" \ -H "anthropic-version: 2023-06-01" \ -d '{ "model": "claude-sonnet-4-5-20250514", diff --git a/docs/TECH_SPEC.md b/docs/TECH_SPEC.md index 198fc0590..a9fa1c35f 100644 --- a/docs/TECH_SPEC.md +++ b/docs/TECH_SPEC.md @@ -85,8 +85,8 @@ src/ ### 路由模式 ``` -http://localhost:3000/{provider}/v1/chat/completions -http://localhost:3000/{provider}/v1/messages +http://localhost:8999/{provider}/v1/chat/completions +http://localhost:8999/{provider}/v1/messages ``` ### 支持的端点 @@ -104,8 +104,8 @@ http://localhost:3000/{provider}/v1/messages { "server": { "host": "127.0.0.1", - "port": 3000, - "apiKey": "proxycast-key" + "port": 8999, + "apiKey": "your-api-key" }, "providers": { "kiro": { diff --git a/docs/content/01.introduction/3.quickstart.md b/docs/content/01.introduction/3.quickstart.md index ea6cce540..55f1aee9d 100644 --- a/docs/content/01.introduction/3.quickstart.md +++ b/docs/content/01.introduction/3.quickstart.md @@ -46,7 +46,7 @@ ProxyCast 会自动检测本地的 AI 客户端凭证文件。 1. 在仪表盘点击 **启动服务** 2. 服务状态变为"运行中" -3. 记下 API 地址(默认 `http://127.0.0.1:9090`) +3. 记下 API 地址(默认 `http://127.0.0.1:8999`) ## 步骤 4: 测试 API @@ -61,7 +61,7 @@ ProxyCast 会自动检测本地的 AI 客户端凭证文件。 **OpenAI 格式:** ```bash -curl http://127.0.0.1:9090/v1/chat/completions \ +curl http://127.0.0.1:8999/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer your-api-key" \ -d '{ @@ -73,7 +73,7 @@ curl http://127.0.0.1:9090/v1/chat/completions \ **Claude 格式:** ```bash -curl http://127.0.0.1:9090/v1/messages \ +curl http://127.0.0.1:8999/v1/messages \ -H "Content-Type: application/json" \ -H "x-api-key: your-api-key" \ -H "anthropic-version: 2023-06-01" \ @@ -90,7 +90,7 @@ curl http://127.0.0.1:9090/v1/messages \ 在 Cursor 设置中配置 OpenAI API: -- API Base URL: `http://127.0.0.1:9090/v1` +- API Base URL: `http://127.0.0.1:8999/v1` - API Key: 你在 ProxyCast 中设置的 API Key ### Continue 配置 @@ -103,7 +103,7 @@ curl http://127.0.0.1:9090/v1/messages \ "title": "ProxyCast Claude", "provider": "openai", "model": "claude-sonnet-4-20250514", - "apiBase": "http://127.0.0.1:9090/v1", + "apiBase": "http://127.0.0.1:8999/v1", "apiKey": "your-api-key" }] } diff --git a/docs/content/02.user-guide/1.dashboard.md b/docs/content/02.user-guide/1.dashboard.md index cc7f04281..c0d820de7 100644 --- a/docs/content/02.user-guide/1.dashboard.md +++ b/docs/content/02.user-guide/1.dashboard.md @@ -29,7 +29,7 @@ navigation: 服务运行时显示: -- **API 地址**: 本地 API 端点(如 `http://127.0.0.1:9090`) +- **API 地址**: 本地 API 端点(如 `http://127.0.0.1:8999`) - **API Key**: 当前配置的访问密钥 - **复制按钮**: 一键复制 API 地址或 Key diff --git a/docs/content/02.user-guide/11.skills.md b/docs/content/02.user-guide/11.skills.md index 44dfe4378..1ef9c6403 100644 --- a/docs/content/02.user-guide/11.skills.md +++ b/docs/content/02.user-guide/11.skills.md @@ -101,7 +101,7 @@ coding: ### 通过 API 调用 ```bash -curl http://127.0.0.1:9090/v1/chat/completions \ +curl http://127.0.0.1:8999/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer your-api-key" \ -d '{ diff --git a/docs/content/02.user-guide/4.configuration-example.md b/docs/content/02.user-guide/4.configuration-example.md index b25224a62..eae3c1cc7 100644 --- a/docs/content/02.user-guide/4.configuration-example.md +++ b/docs/content/02.user-guide/4.configuration-example.md @@ -16,7 +16,7 @@ navigation: server: host: "127.0.0.1" port: 8999 - api_key: "proxy_cast" + api_key: "your-api-key" # TLS/HTTPS 配置 tls: @@ -24,6 +24,8 @@ server: cert_path: "/path/to/cert.pem" key_path: "/path/to/key.pem" +# 注意:当前版本暂不支持 TLS。启用后服务将无法启动,请使用反向代理做 TLS 终止。 + # 全局代理 URL(支持 socks5/http/https) proxy_url: "socks5://127.0.0.1:1080" @@ -278,7 +280,7 @@ injection: server: host: "127.0.0.1" port: 8999 - api_key: "proxy_cast" + api_key: "your-api-key" tls: enable: false cert_path: "" diff --git a/docs/content/02.user-guide/6.config-management.md b/docs/content/02.user-guide/6.config-management.md index 6b0023bd3..ad245078e 100644 --- a/docs/content/02.user-guide/6.config-management.md +++ b/docs/content/02.user-guide/6.config-management.md @@ -56,7 +56,7 @@ resilience: server: host: "127.0.0.1" - port: 9090 + port: 8999 ``` ### 敏感信息处理 @@ -107,15 +107,15 @@ server: ```bash # ProxyCast API Configuration -PROXYCAST_API_BASE=http://127.0.0.1:9090/v1 +PROXYCAST_API_BASE=http://127.0.0.1:8999/v1 PROXYCAST_API_KEY=your-api-key # OpenAI Compatible -OPENAI_API_BASE=http://127.0.0.1:9090/v1 +OPENAI_API_BASE=http://127.0.0.1:8999/v1 OPENAI_API_KEY=your-api-key # Claude Compatible -ANTHROPIC_API_BASE=http://127.0.0.1:9090 +ANTHROPIC_API_BASE=http://127.0.0.1:8999 ANTHROPIC_API_KEY=your-api-key ``` @@ -142,6 +142,25 @@ ProxyCast 会自动备份配置: 3. 选择要恢复的版本 4. 点击 **恢复** +## 完整备份与恢复(生产建议) + +仅导出配置无法覆盖数据库与凭证文件。生产环境建议定期备份以下路径: + +- 配置文件:macOS `~/Library/Application Support/proxycast/config.yaml`;Linux `~/.config/proxycast/config.yaml`;Windows `%APPDATA%\\proxycast\\config.yaml` +- 凭证副本目录:macOS `~/Library/Application Support/proxycast/credentials/`;Linux `~/.local/share/proxycast/credentials/`;Windows `%APPDATA%\\proxycast\\credentials\\` +- 数据库与日志:`~/.proxycast/`(含 `proxycast.db`、`logs/`、`request_logs/`、`auth/`) + +```bash +# 示例:备份数据库与日志目录 +cp -a ~/.proxycast ~/.proxycast.backup-$(date +%Y%m%d%H%M%S) +``` + +恢复时将备份内容替换回原路径,并确保应用已退出。 + +## 旧版本迁移说明 + +如果检测到旧版 `~/.proxycast/config.json`,当前版本会阻止启动并提示手动迁移。请先导出旧配置或重新导入 YAML 配置,再启动应用。 + ## 配置同步 ### 跨设备同步 diff --git a/docs/content/02.user-guide/8.api-server.md b/docs/content/02.user-guide/8.api-server.md index ca041f8d0..fc94ea0dd 100644 --- a/docs/content/02.user-guide/8.api-server.md +++ b/docs/content/02.user-guide/8.api-server.md @@ -16,7 +16,7 @@ API Server 是 ProxyCast 的核心组件,提供 OpenAI/Claude 兼容的 API | 选项 | 默认值 | 说明 | |------|--------|------| | 主机地址 | `127.0.0.1` | 监听地址 | -| 端口 | `9090` | 监听端口 | +| 端口 | `8999` | 监听端口 | | API Key | 自动生成 | 访问密钥 | ### 配置步骤 @@ -31,10 +31,10 @@ API Server 是 ProxyCast 的核心组件,提供 OpenAI/Claude 兼容的 API | 地址 | 说明 | |------|------| | `127.0.0.1` | 仅本机访问 | -| `0.0.0.0` | 允许局域网访问 | +| `localhost` | 仅本机访问 | ::alert{type="warning"} -使用 `0.0.0.0` 时请确保配置了 API Key 认证。 +当前版本仅支持本地监听(127.0.0.1/localhost/::1),不支持对外开放。 :: ## API 端点 @@ -99,7 +99,7 @@ API Server 是 ProxyCast 的核心组件,提供 OpenAI/Claude 兼容的 API **OpenAI 格式:** ```bash -curl http://127.0.0.1:9090/v1/chat/completions \ +curl http://127.0.0.1:8999/v1/chat/completions \ -H "Authorization: Bearer your-api-key" \ -H "Content-Type: application/json" \ -d '{"model": "gpt-4", "messages": [...]}' @@ -107,7 +107,7 @@ curl http://127.0.0.1:9090/v1/chat/completions \ **Claude 格式:** ```bash -curl http://127.0.0.1:9090/v1/messages \ +curl http://127.0.0.1:8999/v1/messages \ -H "x-api-key: your-api-key" \ -H "anthropic-version: 2023-06-01" \ -H "Content-Type: application/json" \ diff --git a/docs/content/02.user-guide/9.mcp.md b/docs/content/02.user-guide/9.mcp.md index cf3c251aa..d6f234639 100644 --- a/docs/content/02.user-guide/9.mcp.md +++ b/docs/content/02.user-guide/9.mcp.md @@ -93,7 +93,7 @@ MCP 定义了 AI 模型与外部系统交互的标准方式: 通过 API 调用 MCP 工具: ```bash -curl http://127.0.0.1:9090/v1/chat/completions \ +curl http://127.0.0.1:8999/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer your-api-key" \ -d '{ diff --git a/docs/content/03.providers/7.codex.md b/docs/content/03.providers/7.codex.md index 4083b385f..7b6358c5d 100644 --- a/docs/content/03.providers/7.codex.md +++ b/docs/content/03.providers/7.codex.md @@ -11,7 +11,8 @@ navigation: ## 概述 -Codex Provider 允许你使用 OpenAI Codex 的 OAuth 凭证访问 GPT 模型,无需 API Key。 +Codex Provider 允许你使用 OpenAI Codex 的 OAuth 凭证访问 GPT 模型。 +同时,为了兼容 Codex CLI 的「API Key 登录」用户(`~/.codex/auth.json` 只有 `api_key`),ProxyCast 也支持读取 `api_key` 并作为 Bearer Token 使用(无需刷新)。 ## 支持的模型 @@ -54,6 +55,8 @@ credential_pool: ### Token 文件格式 +#### OAuth 模式(推荐用于 Codex OAuth) + ```json { "access_token": "eyJ...", @@ -63,6 +66,19 @@ credential_pool: } ``` +#### API Key 模式(兼容 Codex CLI) + +```json +{ + "api_key": "sk-xxx", + "api_base_url": "https://api.openai.com" +} +``` + +说明: +- `api_key` / `apiKey`:必填 +- `api_base_url` / `apiBaseUrl`:可选;可填写 `https://api.openai.com` 或带 `/v1` 的地址(例如网关、反代、Azure 兼容地址) + ## Token 刷新 ProxyCast 会自动在 Token 过期前刷新: diff --git a/docs/content/04.api-reference/1.overview.md b/docs/content/04.api-reference/1.overview.md index 1067a7965..2b3919ec1 100644 --- a/docs/content/04.api-reference/1.overview.md +++ b/docs/content/04.api-reference/1.overview.md @@ -50,7 +50,7 @@ ProxyCast 提供 OpenAI 和 Claude 兼容的 API 端点。 使用 `Authorization` 头: ```bash -curl http://127.0.0.1:9090/v1/chat/completions \ +curl http://127.0.0.1:8999/v1/chat/completions \ -H "Authorization: Bearer your-api-key" \ -H "Content-Type: application/json" \ -d '...' @@ -61,7 +61,7 @@ curl http://127.0.0.1:9090/v1/chat/completions \ 使用 `x-api-key` 头: ```bash -curl http://127.0.0.1:9090/v1/messages \ +curl http://127.0.0.1:8999/v1/messages \ -H "x-api-key: your-api-key" \ -H "anthropic-version: 2023-06-01" \ -H "Content-Type: application/json" \ @@ -70,7 +70,7 @@ curl http://127.0.0.1:9090/v1/messages \ ## 基础 URL -默认地址:`http://127.0.0.1:9090` +默认地址:`http://127.0.0.1:8999` 可在设置中修改主机和端口。 diff --git a/docs/content/04.api-reference/2.openai-api.md b/docs/content/04.api-reference/2.openai-api.md index 30d35ba91..eecdd54d3 100644 --- a/docs/content/04.api-reference/2.openai-api.md +++ b/docs/content/04.api-reference/2.openai-api.md @@ -92,7 +92,7 @@ Authorization: Bearer your-api-key 设置 `stream: true` 启用流式响应: ```bash -curl http://127.0.0.1:9090/v1/chat/completions \ +curl http://127.0.0.1:8999/v1/chat/completions \ -H "Authorization: Bearer your-api-key" \ -H "Content-Type: application/json" \ -d '{ @@ -202,7 +202,7 @@ Authorization: Bearer your-api-key import openai client = openai.OpenAI( - base_url="http://127.0.0.1:9090/v1", + base_url="http://127.0.0.1:8999/v1", api_key="your-api-key" ) @@ -220,7 +220,7 @@ print(response.choices[0].message.content) import OpenAI from 'openai'; const client = new OpenAI({ - baseURL: 'http://127.0.0.1:9090/v1', + baseURL: 'http://127.0.0.1:8999/v1', apiKey: 'your-api-key' }); diff --git a/docs/content/04.api-reference/3.claude-api.md b/docs/content/04.api-reference/3.claude-api.md index cbafe61fb..e5055e037 100644 --- a/docs/content/04.api-reference/3.claude-api.md +++ b/docs/content/04.api-reference/3.claude-api.md @@ -93,7 +93,7 @@ anthropic-version: 2023-06-01 设置 `stream: true` 启用流式响应: ```bash -curl http://127.0.0.1:9090/v1/messages \ +curl http://127.0.0.1:8999/v1/messages \ -H "x-api-key: your-api-key" \ -H "anthropic-version: 2023-06-01" \ -H "Content-Type: application/json" \ @@ -200,7 +200,7 @@ anthropic-version: 2023-06-01 import anthropic client = anthropic.Anthropic( - base_url="http://127.0.0.1:9090", + base_url="http://127.0.0.1:8999", api_key="your-api-key" ) @@ -219,7 +219,7 @@ print(message.content[0].text) import Anthropic from '@anthropic-ai/sdk'; const client = new Anthropic({ - baseURL: 'http://127.0.0.1:9090', + baseURL: 'http://127.0.0.1:8999', apiKey: 'your-api-key' }); diff --git a/docs/content/04.api-reference/4.management-api.md b/docs/content/04.api-reference/4.management-api.md index 84515cdd9..481130e55 100644 --- a/docs/content/04.api-reference/4.management-api.md +++ b/docs/content/04.api-reference/4.management-api.md @@ -26,6 +26,10 @@ Authorization: Bearer your-secret-key | `secret_key` | 管理密钥,为空时禁用所有管理端点(返回 404) | | `allow_remote` | 是否允许远程访问,为 false 时仅允许 localhost | +::alert{type="warning"} +当前版本未启用 TLS,仅支持本地访问,`allow_remote` 必须保持为 `false`。 +:: + ## /v0/management/status 获取服务器状态信息。 diff --git a/docs/content/05.troubleshooting/1.common-issues.md b/docs/content/05.troubleshooting/1.common-issues.md index c8d3e4b4a..ddf5e6de4 100644 --- a/docs/content/05.troubleshooting/1.common-issues.md +++ b/docs/content/05.troubleshooting/1.common-issues.md @@ -30,10 +30,10 @@ navigation: ```bash # 查找占用端口的进程 # macOS/Linux -lsof -i :9090 +lsof -i :8999 # Windows -netstat -ano | findstr :9090 +netstat -ano | findstr :8999 ``` 或在设置中更改端口号。 diff --git a/docs/content/05.troubleshooting/3.connection-issues.md b/docs/content/05.troubleshooting/3.connection-issues.md index a702342f5..b23dd7bd4 100644 --- a/docs/content/05.troubleshooting/3.connection-issues.md +++ b/docs/content/05.troubleshooting/3.connection-issues.md @@ -152,7 +152,7 @@ export NO_PROXY=localhost,127.0.0.1 | 端口 | 用途 | |------|------| | 443 | HTTPS 请求 | -| 9090 | ProxyCast API(默认) | +| 8999 | ProxyCast API(默认) | ### macOS 防火墙 diff --git a/docs/content/06.development/4.operations.md b/docs/content/06.development/4.operations.md new file mode 100644 index 000000000..6403cf3cb --- /dev/null +++ b/docs/content/06.development/4.operations.md @@ -0,0 +1,55 @@ +--- +title: 上线运维 +description: 生产就绪的最小运维清单 +navigation: + icon: i-heroicons-wrench-screwdriver +--- + +# 上线运行与运维(生产就绪最小版) + +本页面用于“马上上线且长期稳定运行”的最小运维闭环,避免上线后因配置、备份或回滚缺失导致不可恢复的问题。 + +## 上线前检查 + +- 确认服务仅本地监听:`server.host = 127.0.0.1`(当前版本仅支持本地监听) +- 设置强 API Key:不要使用默认值 `proxy_cast` +- 确认日志保留策略:`logging.retention_days` 合理(建议 >= 7 天) +- 确认凭证与配置已正确导入,并完成一次启动 + 健康检查 + +## 运行健康检查 + +- HTTP 健康检查:`GET /health` +- 关键字段应包含 `status=healthy` 与 `version` +- 建议在上线后做一次 API 冒烟请求(如 `/v1/models`) + +## 备份与恢复(必须) + +当前版本需要手动备份以下路径: + +- 配置文件(macOS: `~/Library/Application Support/proxycast/config.yaml`,Linux: `~/.config/proxycast/config.yaml`,Windows: `%APPDATA%\\proxycast\\config.yaml`) +- 凭证池副本目录(导入的凭证文件):macOS `~/Library/Application Support/proxycast/credentials/`,Linux `~/.local/share/proxycast/credentials/`,Windows `%APPDATA%\\proxycast\\credentials\\` +- OAuth 凭证目录:`~/.proxycast/auth/` +- 数据库文件:`~/.proxycast/proxycast.db` +- 日志目录:`~/.proxycast/logs/`、`~/.proxycast/request_logs/` + +恢复步骤(顺序建议): + +1. 停止应用 +2. 恢复 `config.yaml` +3. 恢复 `credentials` 目录、`auth` 目录与数据库 `proxycast.db` +4. 如需保留历史日志,恢复 `logs/` 与 `request_logs/` +5. 启动应用并验证 `/health` 与关键功能 + +## 回滚策略 + +- 如果升级失败,恢复备份的 `config.yaml` 与 `proxycast.db` +- 使用上一版本安装包覆盖安装 +- 完成健康检查与冒烟测试 + +## 发布质量门槛(最小) + +- `cd src-tauri && cargo test` +- `cd src-tauri && cargo clippy` +- `npm test` +- `npm run lint` +- `npm run build` diff --git a/docs/content/index.md b/docs/content/index.md index 98798d15a..b7a0701c2 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -81,12 +81,12 @@ ProxyCast 会自动检测本地的 AI 客户端凭证文件: ### 3. 启动服务 -点击仪表盘的「启动服务」按钮,API Server 默认运行在 `http://127.0.0.1:9090`。 +点击仪表盘的「启动服务」按钮,API Server 默认运行在 `http://127.0.0.1:8999`。 ### 4. 测试 API ```bash -curl http://127.0.0.1:9090/v1/chat/completions \ +curl http://127.0.0.1:8999/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer your-api-key" \ -d '{ diff --git a/docs/ops.md b/docs/ops.md new file mode 100644 index 000000000..4713e78a2 --- /dev/null +++ b/docs/ops.md @@ -0,0 +1,87 @@ +# 生产运维与上线就绪清单 + +本文档用于生产环境部署、运行、备份与恢复的最小操作规范,避免上线后缺少可执行流程。 + +## 部署前检查 + +- 确认 API Key 已更换(禁止使用默认值 `proxy_cast`)。 +- 确认监听地址: + - 本机使用 `127.0.0.1`/`localhost`。 +- 当前版本仅支持本地监听,不支持对外服务。 +- 若需要 HTTPS,请使用反向代理终止 TLS;当前服务端未启用内置 TLS。 +- 确认磁盘权限可写:`~/.proxycast/`、`~/.proxycast/request_logs/`、应用数据目录(macOS: `~/Library/Application Support/proxycast/`,Linux: `~/.local/share/proxycast/`,Windows: `%APPDATA%\\proxycast\\`)。 + +## 配置路径与加载顺序 + +- YAML 配置(优先): + - macOS: `~/Library/Application Support/proxycast/config.yaml` + - Linux: `~/.config/proxycast/config.yaml` + - Windows: `%APPDATA%\\proxycast\\config.yaml` +- JSON 配置(兼容):macOS `~/Library/Application Support/proxycast/config.json`,Linux `~/.config/proxycast/config.json`,Windows `%APPDATA%\\proxycast\\config.json` +- 旧版遗留路径:`~/.proxycast/config.json`(检测到会提示手动迁移) +- 两者都不存在时使用默认配置。 + - 首次启动会自动生成强随机 API Key 并写入配置。 + +## 数据与日志位置 + +- SQLite 数据库:`~/.proxycast/proxycast.db` +- 凭证池副本目录:macOS `~/Library/Application Support/proxycast/credentials/`,Linux `~/.local/share/proxycast/credentials/`,Windows `%APPDATA%\\proxycast\\credentials\\` +- OAuth/Token 目录(默认):`~/.proxycast/auth/` +- 日志目录:`~/.proxycast/logs/` +- 请求日志目录:`~/.proxycast/request_logs/` +- 数据库备份目录:`~/.proxycast/backups/` + +## 备份与恢复 + +### 备份 + +1. 可使用管理端点触发备份(需配置管理密钥): + - `POST /v0/management/backup` +2. 或手动备份(建议停服后执行): + - 复制以下路径: + - 配置文件(macOS: `~/Library/Application Support/proxycast/config.yaml`,Linux: `~/.config/proxycast/config.yaml`,Windows: `%APPDATA%\\proxycast\\config.yaml`) + - 配置备份文件:`config.yaml.backup` + - 凭证池副本目录(macOS: `~/Library/Application Support/proxycast/credentials/`,Linux: `~/.local/share/proxycast/credentials/`,Windows: `%APPDATA%\\proxycast\\credentials\\`) + - `~/.proxycast/proxycast.db` + - `~/.proxycast/auth/`(如需要保留 OAuth/Token) + - `~/.proxycast/logs/`、`~/.proxycast/request_logs/`(如需保留日志) +3. 将备份文件存入受控存储(加密磁盘或安全存储)。 + +### 自动备份 + +- 服务运行期间每 24 小时自动创建数据库备份到 `~/.proxycast/backups/`。 +- 备份默认保留 7 天,过期文件会被清理。 + +### 恢复 + +1. 停止 ProxyCast 服务。 +2. 使用管理端点恢复(建议停服后执行,执行时会锁定数据库并短暂阻塞请求): + - `POST /v0/management/restore`,请求体:`{"backup_path": "/path/to/proxycast_YYYYMMDD_HHMMSS.db"}` +3. 或手动恢复上述文件到原路径。 +4. 启动服务并检查 `/health` 与 `/ready`。 + +## 升级与回滚 + +- 升级前执行备份流程。 +- 升级后若出现异常: + - 恢复备份文件。 + - 回滚到上一个稳定版本的安装包。 + +## 运行与排障 + +- 健康检查:`GET /health` +- 就绪检查:`GET /ready` +- 常见问题排查: + - 端口占用:修改配置端口或释放占用端口。 + - 配置解析失败:检查 YAML/JSON 语法,确认缩进正确。 + - 数据库初始化失败:检查 `~/.proxycast/` 权限与磁盘空间。 + +## 安全基线 + +- 禁止默认 API key。 +- 当前版本未实现内置 TLS,远程管理必须保持关闭且仅本地访问。 + +## 管理 API 基线 + +- 管理 API 启用后会对失败认证进行短期限制,避免暴力尝试。 +- 建议仅在内网使用,并配合独立强密钥。 diff --git a/package-lock.json b/package-lock.json index aa6b336ad..0c6187051 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "proxycast", - "version": "0.14.2", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxycast", - "version": "0.14.2", + "version": "0.15.0", "dependencies": { "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", @@ -97,6 +97,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2780,6 +2781,7 @@ "integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2797,6 +2799,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2808,6 +2811,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2847,6 +2851,7 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -3169,6 +3174,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3387,6 +3393,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3734,6 +3741,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4368,6 +4376,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4846,6 +4855,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5068,6 +5078,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5080,6 +5091,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5572,6 +5584,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5647,6 +5660,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5759,6 +5773,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -5819,6 +5834,7 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -6375,6 +6391,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6388,6 +6405,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1a91f4991..c5e66f3f5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1289,6 +1289,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -3379,6 +3389,8 @@ dependencies = [ "chrono", "dashmap", "dirs 5.0.1", + "flate2", + "fs2", "futures", "indexmap 2.12.1", "md5", @@ -3397,6 +3409,7 @@ dependencies = [ "serde_urlencoded", "serde_yaml", "sha2", + "subtle", "tauri", "tauri-build", "tauri-plugin-autostart", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e8c388559..62e5322e5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,7 +38,10 @@ async-stream = "0.3" regex = "1" md5 = "0.7" urlencoding = "2" -rusqlite = { version = "0.31", features = ["bundled"] } +subtle = "2.5" +flate2 = "1" +fs2 = "0.4" +rusqlite = { version = "0.31", features = ["bundled", "backup"] } serde_yaml = "0.9" indexmap = { version = "2", features = ["serde"] } zip = "0.6" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e6a..e878ef8d0 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,10 @@ fn main() { + // tauri::generate_context! 在编译期会校验 `frontendDist` 路径是否存在。 + // 开发/CI 场景下可能只跑 `cargo check/test` 而未先构建前端,从而导致宏 panic。 + // 这里提前创建配置中的 `../dist` 目录,避免无关的编译阻塞。 + if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { + let dist_dir = std::path::PathBuf::from(manifest_dir).join("../dist"); + let _ = std::fs::create_dir_all(dist_dir); + } tauri_build::build() } diff --git a/src-tauri/src/commands/config_cmd.rs b/src-tauri/src/commands/config_cmd.rs index 3d3f3d925..5c5d26cf9 100644 --- a/src-tauri/src/commands/config_cmd.rs +++ b/src-tauri/src/commands/config_cmd.rs @@ -38,7 +38,7 @@ pub fn get_config_status(app_type: String) -> Result { AppType::Claude => config_dir.join("settings.json"), AppType::Codex => config_dir.join("auth.json"), AppType::Gemini => config_dir.join(".env"), - AppType::ProxyCast => config_dir.join("config.json"), + AppType::ProxyCast => config_dir.join("config.yaml"), }; let has_env = match app { @@ -50,11 +50,20 @@ pub fn get_config_status(app_type: String) -> Result { } AppType::Codex => config_dir.join("auth.json").exists(), AppType::Gemini => config_dir.join(".env").exists(), - AppType::ProxyCast => config_dir.join("config.json").exists(), + AppType::ProxyCast => { + config_dir.join("config.yaml").exists() || config_dir.join("config.json").exists() + } + }; + + let exists = match app { + AppType::ProxyCast => { + config_dir.join("config.yaml").exists() || config_dir.join("config.json").exists() + } + _ => main_config.exists(), }; Ok(ConfigStatus { - exists: main_config.exists(), + exists, path: config_dir.to_string_lossy().to_string(), has_env, }) diff --git a/src-tauri/src/commands/oauth_cmd.rs b/src-tauri/src/commands/oauth_cmd.rs index c775360b0..044e2b880 100644 --- a/src-tauri/src/commands/oauth_cmd.rs +++ b/src-tauri/src/commands/oauth_cmd.rs @@ -206,11 +206,12 @@ pub async fn refresh_oauth_token( }; match result { - Ok(token) => { + Ok(_token) => { logs.write() .await .add("info", &format!("[{display_name}] Token 刷新成功")); - Ok(token) + // P0 安全修复:不返回明文 token + Ok("Token 刷新成功".to_string()) } Err(e) => { logs.write() @@ -234,31 +235,32 @@ pub async fn get_oauth_env_variables( match provider_type { OAuthProvider::Kiro => { let creds = &s.kiro_provider.credentials; + // P0 安全修复:不返回明文敏感凭证 if let Some(token) = &creds.access_token { vars.push(EnvVariable { key: "KIRO_ACCESS_TOKEN".to_string(), - value: token.clone(), + value: String::new(), masked: mask_token(token), }); } if let Some(token) = &creds.refresh_token { vars.push(EnvVariable { key: "KIRO_REFRESH_TOKEN".to_string(), - value: token.clone(), + value: String::new(), masked: mask_token(token), }); } if let Some(id) = &creds.client_id { vars.push(EnvVariable { key: "KIRO_CLIENT_ID".to_string(), - value: id.clone(), + value: String::new(), masked: mask_token(id), }); } if let Some(secret) = &creds.client_secret { vars.push(EnvVariable { key: "KIRO_CLIENT_SECRET".to_string(), - value: secret.clone(), + value: String::new(), masked: mask_token(secret), }); } @@ -286,17 +288,18 @@ pub async fn get_oauth_env_variables( } OAuthProvider::Gemini => { let creds = &s.gemini_provider.credentials; + // P0 安全修复:不返回明文敏感凭证 if let Some(token) = &creds.access_token { vars.push(EnvVariable { key: "GEMINI_ACCESS_TOKEN".to_string(), - value: token.clone(), + value: String::new(), masked: mask_token(token), }); } if let Some(token) = &creds.refresh_token { vars.push(EnvVariable { key: "GEMINI_REFRESH_TOKEN".to_string(), - value: token.clone(), + value: String::new(), masked: mask_token(token), }); } @@ -311,17 +314,18 @@ pub async fn get_oauth_env_variables( } OAuthProvider::Qwen => { let creds = &s.qwen_provider.credentials; + // P0 安全修复:不返回明文敏感凭证 if let Some(token) = &creds.access_token { vars.push(EnvVariable { key: "QWEN_ACCESS_TOKEN".to_string(), - value: token.clone(), + value: String::new(), masked: mask_token(token), }); } if let Some(token) = &creds.refresh_token { vars.push(EnvVariable { key: "QWEN_REFRESH_TOKEN".to_string(), - value: token.clone(), + value: String::new(), masked: mask_token(token), }); } diff --git a/src-tauri/src/commands/provider_pool_cmd.rs b/src-tauri/src/commands/provider_pool_cmd.rs index dc86cf4aa..a6f144f11 100644 --- a/src-tauri/src/commands/provider_pool_cmd.rs +++ b/src-tauri/src/commands/provider_pool_cmd.rs @@ -742,6 +742,7 @@ pub fn add_codex_oauth_credential( db: State<'_, DbConnection>, pool_service: State<'_, ProviderPoolServiceState>, creds_file_path: String, + api_base_url: Option, name: Option, ) -> Result { // 复制并重命名文件到应用存储目录 @@ -752,6 +753,7 @@ pub fn add_codex_oauth_credential( "codex", CredentialData::CodexOAuth { creds_file_path: stored_file_path, + api_base_url, }, name, Some(true), @@ -855,6 +857,8 @@ pub fn get_pool_credential_oauth_status( } /// 调试 Kiro 凭证加载(从默认路径) +/// P0 安全修复:仅在 debug 构建中可用 +#[cfg(debug_assertions)] #[tauri::command] pub async fn debug_kiro_credentials() -> Result { use crate::providers::kiro::KiroProvider; @@ -884,31 +888,15 @@ pub async fn debug_kiro_credentials() -> Result { provider.credentials.client_id_hash.is_some() )); - if let Some(hash) = &provider.credentials.client_id_hash { - result.push_str(&format!("🔗 clientIdHash: {}\n", hash)); - } - + // P0 安全修复:不再输出敏感信息(clientIdHash、token 前缀等) let detected_method = provider.detect_auth_method(); result.push_str(&format!("🎯 检测到的认证方式: {}\n", detected_method)); - let refresh_url = provider.get_refresh_url(); - result.push_str(&format!("🌐 刷新端点: {}\n", refresh_url)); - - if let Some(client_id) = &provider.credentials.client_id { - result.push_str(&format!( - "🆔 client_id 前缀: {}...\n", - &client_id[..std::cmp::min(20, client_id.len())] - )); - } - result.push_str("\n🚀 尝试刷新 token...\n"); match provider.refresh_token().await { Ok(token) => { result.push_str(&format!("✅ Token 刷新成功! Token 长度: {}\n", token.len())); - result.push_str(&format!( - "🎫 Token 前缀: {}...\n", - &token[..std::cmp::min(50, token.len())] - )); + // 不再输出 token 前缀 } Err(e) => { result.push_str(&format!("❌ Token 刷新失败: {}\n", e)); @@ -923,7 +911,16 @@ pub async fn debug_kiro_credentials() -> Result { Ok(result) } +/// P0 安全修复:release 构建中禁用 debug 命令 +#[cfg(not(debug_assertions))] +#[tauri::command] +pub async fn debug_kiro_credentials() -> Result { + Err("此调试命令仅在开发构建中可用".to_string()) +} + /// 测试用户上传的凭证文件 +/// P0 安全修复:仅在 debug 构建中可用,且不输出敏感信息 +#[cfg(debug_assertions)] #[tauri::command] pub async fn test_user_credentials() -> Result { use crate::providers::kiro::KiroProvider; @@ -938,7 +935,8 @@ pub async fn test_user_credentials() -> Result { "Library/Application Support/proxycast/credentials/kiro_d8da9d58_1765757992_kiro.json", ); - result.push_str(&format!("📂 用户凭证路径: {}\n", user_creds_path.display())); + // P0 安全修复:不输出完整路径,仅显示文件是否存在 + result.push_str("📂 检查用户凭证文件...\n"); // 检查文件是否存在 if !user_creds_path.exists() { @@ -960,88 +958,27 @@ pub async fn test_user_credentials() -> Result { Ok(json) => { result.push_str("✅ JSON 格式有效\n"); - // 检查关键字段 + // 检查关键字段(仅显示是否存在,不显示值) let has_access_token = json.get("accessToken").and_then(|v| v.as_str()).is_some(); let has_refresh_token = json.get("refreshToken").and_then(|v| v.as_str()).is_some(); let auth_method = json.get("authMethod").and_then(|v| v.as_str()); - let client_id_hash = json.get("clientIdHash").and_then(|v| v.as_str()); + let has_client_id_hash = + json.get("clientIdHash").and_then(|v| v.as_str()).is_some(); let region = json.get("region").and_then(|v| v.as_str()); result.push_str(&format!("🔑 有 accessToken: {}\n", has_access_token)); result.push_str(&format!("🔄 有 refreshToken: {}\n", has_refresh_token)); result.push_str(&format!("📄 authMethod: {:?}\n", auth_method)); - result.push_str(&format!("🏷️ clientIdHash: {:?}\n", client_id_hash)); + // P0 安全修复:不输出 clientIdHash 值 + result.push_str(&format!("🏷️ 有 clientIdHash: {}\n", has_client_id_hash)); result.push_str(&format!("🌍 region: {:?}\n", region)); - if let Some(hash) = client_id_hash { - // 检查 clientIdHash 对应的文件 - let hash_file_path = dirs::home_dir() - .unwrap() - .join(".aws/sso/cache") - .join(format!("{}.json", hash)); - - result.push_str(&format!( - "\n🔗 检查 clientIdHash 文件: {}\n", - hash_file_path.display() - )); - - if hash_file_path.exists() { - result.push_str("✅ clientIdHash 文件存在\n"); - - match std::fs::read_to_string(&hash_file_path) { - Ok(hash_content) => { - match serde_json::from_str::(&hash_content) { - Ok(hash_json) => { - let has_client_id = hash_json - .get("clientId") - .and_then(|v| v.as_str()) - .is_some(); - let has_client_secret = hash_json - .get("clientSecret") - .and_then(|v| v.as_str()) - .is_some(); - - result.push_str(&format!( - "🆔 hash 文件有 clientId: {}\n", - has_client_id - )); - result.push_str(&format!( - "🔒 hash 文件有 clientSecret: {}\n", - has_client_secret - )); - - if has_client_id && has_client_secret { - result.push_str("✅ IdC 认证配置完整!\n"); - } else { - result.push_str( - "⚠️ IdC 认证配置不完整,将使用 social 认证\n", - ); - } - } - Err(e) => { - result.push_str(&format!( - "❌ 无法解析 hash 文件 JSON: {}\n", - e - )); - } - } - } - Err(e) => { - result.push_str(&format!("❌ 无法读取 hash 文件: {}\n", e)); - } - } - } else { - result.push_str("❌ clientIdHash 文件不存在\n"); - } - } - - // 现在使用我们的 KiroProvider 来测试加载 + // 使用 KiroProvider 测试加载 result.push_str("\n🔧 使用 KiroProvider 测试加载...\n"); let mut provider = KiroProvider::new(); - // 设置凭证路径到用户文件 provider.creds_path = Some(user_creds_path.clone()); match provider @@ -1066,9 +1003,6 @@ pub async fn test_user_credentials() -> Result { let detected_method = provider.detect_auth_method(); result.push_str(&format!("🎯 检测到的认证方式: {}\n", detected_method)); - let refresh_url = provider.get_refresh_url(); - result.push_str(&format!("🌐 刷新端点: {}\n", refresh_url)); - result.push_str("\n🚀 尝试刷新 token...\n"); match provider.refresh_token().await { Ok(token) => { @@ -1076,10 +1010,7 @@ pub async fn test_user_credentials() -> Result { "✅ Token 刷新成功! Token 长度: {}\n", token.len() )); - result.push_str(&format!( - "🎫 Token 前缀: {}...\n", - &token[..std::cmp::min(50, token.len())] - )); + // P0 安全修复:不输出 token 前缀 } Err(e) => { result.push_str(&format!("❌ Token 刷新失败: {}\n", e)); @@ -1104,6 +1035,13 @@ pub async fn test_user_credentials() -> Result { Ok(result) } +/// P0 安全修复:release 构建中禁用 test_user_credentials 命令 +#[cfg(not(debug_assertions))] +#[tauri::command] +pub async fn test_user_credentials() -> Result { + Err("此调试命令仅在开发构建中可用".to_string()) +} + /// 迁移 Private 配置到凭证池 /// /// 从 providers 配置中读取单个凭证配置,迁移到凭证池中并标记为 Private 来源 @@ -1300,6 +1238,7 @@ pub async fn get_codex_auth_url_and_wait( "codex", CredentialData::CodexOAuth { creds_file_path: result.creds_file_path, + api_base_url: None, }, name, Some(true), @@ -1340,6 +1279,7 @@ pub async fn start_codex_oauth_login( "codex", CredentialData::CodexOAuth { creds_file_path: result.creds_file_path, + api_base_url: None, }, name, Some(true), diff --git a/src-tauri/src/commands/route_cmd.rs b/src-tauri/src/commands/route_cmd.rs index 4d80992ef..0ffc93fd6 100644 --- a/src-tauri/src/commands/route_cmd.rs +++ b/src-tauri/src/commands/route_cmd.rs @@ -74,7 +74,8 @@ pub async fn get_route_curl_examples( } }); - let api_key = &config.server.api_key; + // P0 安全修复:curl 示例使用占位符,不暴露真实 API Key + let api_key = "${PROXYCAST_API_KEY}"; match route { Some(r) => Ok(r.generate_curl_examples(api_key)), diff --git a/src-tauri/src/config/hot_reload.rs b/src-tauri/src/config/hot_reload.rs index 9da083b79..e224bca15 100644 --- a/src-tauri/src/config/hot_reload.rs +++ b/src-tauri/src/config/hot_reload.rs @@ -372,6 +372,8 @@ impl HotReloadManager { /// 验证配置 fn validate_config(&self, config: &Config) -> Result<(), HotReloadError> { + let is_localhost = is_localhost_host(&config.server.host); + // 验证端口范围 if config.server.port == 0 { return Err(HotReloadError::ValidationError( @@ -379,6 +381,12 @@ impl HotReloadManager { )); } + if !is_localhost { + return Err(HotReloadError::ValidationError( + "当前版本仅支持本地监听,请使用 127.0.0.1/localhost/::1".to_string(), + )); + } + // 验证重试配置 if config.retry.max_retries > 100 { return Err(HotReloadError::ValidationError( @@ -399,6 +407,32 @@ impl HotReloadManager { )); } + if config.server.api_key.trim().is_empty() { + return Err(HotReloadError::ValidationError( + "API Key 不能为空".to_string(), + )); + } + + if (!is_localhost || config.remote_management.allow_remote) + && crate::config::is_default_api_key(&config.server.api_key) + { + return Err(HotReloadError::ValidationError( + "非本地访问场景下禁止使用默认 API Key,请设置强口令".to_string(), + )); + } + + if config.server.tls.enable { + return Err(HotReloadError::ValidationError( + "当前版本暂不支持 TLS,请关闭 TLS 配置".to_string(), + )); + } + + if config.remote_management.allow_remote { + return Err(HotReloadError::ValidationError( + "当前版本未启用 TLS,禁止开启远程管理".to_string(), + )); + } + Ok(()) } @@ -439,6 +473,15 @@ impl HotReloadManager { } } +fn is_localhost_host(host: &str) -> bool { + if host == "localhost" { + return true; + } + host.parse::() + .map(|addr| addr.is_loopback()) + .unwrap_or(false) +} + /// 热重载状态 #[derive(Debug, Clone, serde::Serialize)] pub struct HotReloadStatus { diff --git a/src-tauri/src/config/import.rs b/src-tauri/src/config/import.rs index 94d866703..e86ccd25e 100644 --- a/src-tauri/src/config/import.rs +++ b/src-tauri/src/config/import.rs @@ -313,7 +313,10 @@ impl ImportService { // 如果是脱敏数据,清理凭证池中的占位符 if bundle.redacted { - Self::clean_redacted_credentials(&mut config); + let server_key_cleared = Self::clean_redacted_credentials(&mut config); + if server_key_cleared { + warnings.push("检测到脱敏的服务器 API Key,已清空,需要手动设置".to_string()); + } } Ok(ImportResult::success_with_warnings(config, warnings)) @@ -469,7 +472,9 @@ impl ImportService { /// 清理脱敏的凭证数据 /// /// 移除凭证池中使用占位符的条目 - fn clean_redacted_credentials(config: &mut Config) { + fn clean_redacted_credentials(config: &mut Config) -> bool { + let mut server_key_cleared = false; + // 清理 OpenAI 凭证池中的脱敏条目 config .credential_pool @@ -490,10 +495,13 @@ impl ImportService { config.providers.claude.api_key = None; } - // 清理服务器 API 密钥(如果是脱敏的,恢复默认值) + // 清理服务器 API 密钥(如果是脱敏的,清空并提示手动设置) if config.server.api_key == REDACTED_PLACEHOLDER { - config.server.api_key = "proxy_cast".to_string(); + config.server.api_key = String::new(); + server_key_cleared = true; } + + server_key_cleared } /// 从文件导入配置 @@ -638,7 +646,7 @@ server: let current = Config::default(); let yaml = r#" server: - host: 0.0.0.0 + host: 127.0.0.1 port: 9000 api_key: new_key "#; @@ -646,7 +654,7 @@ server: let result = ImportService::import_yaml(yaml, ¤t, &options).expect("导入应成功"); assert!(result.success); - assert_eq!(result.config.server.host, "0.0.0.0"); + assert_eq!(result.config.server.host, "127.0.0.1"); assert_eq!(result.config.server.port, 9000); assert_eq!(result.config.server.api_key, "new_key"); } @@ -664,7 +672,7 @@ server: let yaml = r#" server: - host: 0.0.0.0 + host: 127.0.0.1 port: 9000 api_key: new_key credential_pool: @@ -677,7 +685,7 @@ credential_pool: assert!(result.success); // 服务器配置应被更新 - assert_eq!(result.config.server.host, "0.0.0.0"); + assert_eq!(result.config.server.host, "127.0.0.1"); // 凭证池应合并 assert_eq!(result.config.credential_pool.openai.len(), 2); } @@ -757,10 +765,11 @@ credential_pool: proxy_url: None, }); - ImportService::clean_redacted_credentials(&mut config); + let server_key_cleared = ImportService::clean_redacted_credentials(&mut config); - // 服务器 API 密钥应恢复默认值 - assert_eq!(config.server.api_key, "proxy_cast"); + // 服务器 API 密钥应被清空并提示手动设置 + assert!(server_key_cleared); + assert_eq!(config.server.api_key, ""); // Provider API 密钥应被清除 assert!(config.providers.openai.api_key.is_none()); // 凭证池中脱敏的条目应被移除 diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index 0dc928aaa..6423cdafb 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -17,11 +17,12 @@ pub use hot_reload::{ pub use import::{ImportOptions, ImportService, ValidationResult}; pub use path_utils::{collapse_tilde, contains_tilde, expand_tilde}; pub use types::{ - AmpConfig, AmpModelMapping, ApiKeyEntry, Config, CredentialEntry, CredentialPoolConfig, - CustomProviderConfig, GeminiApiKeyEntry, IFlowCredentialEntry, InjectionRuleConfig, - InjectionSettings, LoggingConfig, ProviderConfig, ProvidersConfig, QuotaExceededConfig, - RemoteManagementConfig, RetrySettings, RoutingConfig, RoutingRuleConfig, ServerConfig, - TlsConfig, VertexApiKeyEntry, VertexModelAlias, + generate_secure_api_key, is_default_api_key, AmpConfig, AmpModelMapping, ApiKeyEntry, Config, + CredentialEntry, CredentialPoolConfig, CustomProviderConfig, GeminiApiKeyEntry, + IFlowCredentialEntry, InjectionRuleConfig, InjectionSettings, LoggingConfig, ProviderConfig, + ProvidersConfig, QuotaExceededConfig, RemoteManagementConfig, RetrySettings, RoutingConfig, + RoutingRuleConfig, ServerConfig, TlsConfig, VertexApiKeyEntry, VertexModelAlias, + DEFAULT_API_KEY, }; pub use yaml::{load_config, save_config, ConfigError, ConfigManager, YamlService}; diff --git a/src-tauri/src/config/tests.rs b/src-tauri/src/config/tests.rs index d60909cce..5d6a6c9ac 100644 --- a/src-tauri/src/config/tests.rs +++ b/src-tauri/src/config/tests.rs @@ -15,8 +15,8 @@ use tempfile::NamedTempFile; fn arb_host() -> impl Strategy { prop_oneof![ Just("127.0.0.1".to_string()), - Just("0.0.0.0".to_string()), Just("localhost".to_string()), + Just("::1".to_string()), "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}".prop_map(|s| s), ] } @@ -2120,40 +2120,36 @@ proptest! { /// **Validates: Requirements 5.5** #[test] fn prop_export_import_redacted_loses_secrets(config in arb_config_with_secrets()) { - // 导出为 YAML(脱敏) - let yaml = ExportService::export_yaml(&config, true) + // 导出为脱敏 bundle + let options = ExportOptions::redacted(); + let bundle = ExportService::export(&config, &options, "1.0.0") .expect("导出应成功"); - // 导入 YAML + // 导入 bundle(脱敏数据会触发清理) let empty_config = Config::default(); - let options = ImportOptions::replace(); - let result = ImportService::import_yaml(&yaml, &empty_config, &options) + let import_options = ImportOptions::replace(); + let result = ImportService::import( + &bundle, + &empty_config, + &import_options, + &config.auth_dir, + ) .expect("导入应成功"); - // 清理脱敏数据 - let mut imported = result.config; - ImportService::import( - &ExportBundle::new("1.0.0"), - &imported, - &ImportOptions::merge(), - &config.auth_dir, - ).ok(); // 忽略结果,只是为了触发清理 - // 验证脱敏后的配置不包含原始敏感信息 - // 服务器 API 密钥应为脱敏占位符或默认值 - prop_assert!( - imported.server.api_key == REDACTED_PLACEHOLDER || - imported.server.api_key == "proxy_cast", - "脱敏后服务器 API 密钥应为占位符或默认值: {}", - imported.server.api_key + // 服务器 API 密钥应被清空 + prop_assert_eq!( + result.config.server.api_key, + "", + "脱敏后服务器 API 密钥应被清空" ); // 如果原始配置有 OpenAI API 密钥,导入后应为脱敏占位符 if config.providers.openai.api_key.is_some() { prop_assert_eq!( - imported.providers.openai.api_key, - Some(REDACTED_PLACEHOLDER.to_string()), - "脱敏后 OpenAI API 密钥应为占位符" + result.config.providers.openai.api_key, + None, + "脱敏后 OpenAI API 密钥应被清空" ); } } diff --git a/src-tauri/src/config/types.rs b/src-tauri/src/config/types.rs index cc4fe08fb..5e636855e 100644 --- a/src-tauri/src/config/types.rs +++ b/src-tauri/src/config/types.rs @@ -334,8 +334,28 @@ fn default_port() -> u16 { 8999 } +pub const DEFAULT_API_KEY: &str = "proxy_cast"; + fn default_api_key() -> String { - "proxy_cast".to_string() + DEFAULT_API_KEY.to_string() +} + +/// 生成安全 API Key(32 字节随机) +pub fn generate_secure_api_key() -> String { + use rand::distributions::Alphanumeric; + use rand::Rng; + + let token: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(32) + .map(char::from) + .collect(); + format!("pc_{token}") +} + +/// 是否为默认 API Key +pub fn is_default_api_key(api_key: &str) -> bool { + api_key == DEFAULT_API_KEY } impl Default for ServerConfig { diff --git a/src-tauri/src/config/yaml.rs b/src-tauri/src/config/yaml.rs index b00fa58c8..71b308ac7 100644 --- a/src-tauri/src/config/yaml.rs +++ b/src-tauri/src/config/yaml.rs @@ -104,6 +104,10 @@ impl ConfigManager { std::fs::create_dir_all(parent).map_err(|e| ConfigError::WriteError(e.to_string()))?; } + if path.exists() { + let backup_path = path.with_extension("yaml.backup"); + let _ = std::fs::copy(path, backup_path); + } let yaml = Self::to_yaml(&self.config)?; std::fs::write(path, yaml).map_err(|e| ConfigError::WriteError(e.to_string())) } @@ -656,30 +660,65 @@ fn json_config_path() -> std::path::PathBuf { /// 加载配置(向后兼容) /// /// 优先加载 YAML 配置,如果不存在则尝试加载 JSON 配置 +/// 首次启动时自动生成强随机 API Key 并保存配置 pub fn load_config() -> Result> { + use super::types::{generate_secure_api_key, is_default_api_key}; + let yaml_path = ConfigManager::default_config_path(); let json_path = json_config_path(); // 优先尝试 YAML 配置 if yaml_path.exists() { let content = std::fs::read_to_string(&yaml_path)?; - let config = serde_yaml::from_str(&content)?; + let mut config: Config = serde_yaml::from_str(&content)?; + // 如果配置中使用默认 API Key,生成强随机 Key 并保存 + if is_default_api_key(&config.server.api_key) { + let new_key = generate_secure_api_key(); + tracing::warn!("[CONFIG] 检测到默认 API Key,已自动生成强随机 Key"); + config.server.api_key = new_key; + // 保存更新后的配置 + if let Err(e) = save_config_yaml(&config) { + tracing::error!("[CONFIG] 保存配置失败: {}", e); + } + } return Ok(config); } // 回退到 JSON 配置 if json_path.exists() { let content = std::fs::read_to_string(&json_path)?; - let config = serde_json::from_str(&content)?; + let mut config: Config = serde_json::from_str(&content)?; + // 如果配置中使用默认 API Key,生成强随机 Key 并保存 + if is_default_api_key(&config.server.api_key) { + let new_key = generate_secure_api_key(); + tracing::warn!("[CONFIG] 检测到默认 API Key,已自动生成强随机 Key"); + config.server.api_key = new_key; + // 保存更新后的配置(迁移到 YAML) + if let Err(e) = save_config_yaml(&config) { + tracing::error!("[CONFIG] 保存配置失败: {}", e); + } + } return Ok(config); } - // 都不存在,返回默认配置 - Ok(Config::default()) + // 都不存在,创建默认配置并生成强随机 API Key + let mut config = Config::default(); + let new_key = generate_secure_api_key(); + tracing::info!("[CONFIG] 首次启动,已生成强随机 API Key"); + config.server.api_key = new_key; + // 保存初始配置 + if let Err(e) = save_config_yaml(&config) { + tracing::error!("[CONFIG] 保存初始配置失败: {}", e); + } + Ok(config) } -/// 保存配置(向后兼容,使用 JSON 格式) +/// 保存配置(同时写入 YAML 与 JSON,兼容旧版) pub fn save_config(config: &Config) -> Result<(), Box> { + // 主配置优先写入 YAML + save_config_yaml(config)?; + + // 兼容旧版 JSON 配置 let path = json_config_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; @@ -695,6 +734,10 @@ pub fn save_config_yaml(config: &Config) -> Result<(), Box 500 { - format!("{}...", &desc[..497]) + let truncated: String = desc.chars().take(497).collect(); + format!("{}...", truncated) } else { desc }, diff --git a/src-tauri/src/database/migration.rs b/src-tauri/src/database/migration.rs index 2dfbf9a36..d0b748bdf 100644 --- a/src-tauri/src/database/migration.rs +++ b/src-tauri/src/database/migration.rs @@ -1,11 +1,8 @@ use rusqlite::Connection; -use serde_json::Value; /// 从旧的 JSON 配置迁移数据到 SQLite #[allow(dead_code)] -pub fn migrate_from_json( - conn: &Connection, -) -> Result<(), Box> { +pub fn migrate_from_json(conn: &Connection) -> Result<(), String> { // 检查是否已经迁移过 let migrated: bool = conn .query_row( @@ -20,27 +17,30 @@ pub fn migrate_from_json( return Ok(()); } - // 读取旧配置文件 - let home = dirs::home_dir().ok_or("Cannot find home directory")?; + // 读取旧配置文件(历史路径) + let home = dirs::home_dir().ok_or_else(|| "无法获取主目录".to_string())?; let config_path = home.join(".proxycast").join("config.json"); if config_path.exists() { - let content = std::fs::read_to_string(&config_path)?; - let _config: Value = serde_json::from_str(&content)?; + // 备份旧配置,避免误覆盖 + let backup_path = config_path.with_file_name("config.json.backup"); + if !backup_path.exists() { + std::fs::copy(&config_path, &backup_path) + .map_err(|e| format!("备份旧配置失败: {}", e))?; + } - // TODO: 解析旧配置并插入到数据库 - // 这里需要根据实际的旧配置格式来实现 - - // 备份旧配置 - let backup_path = home.join(".proxycast").join("config.json.backup"); - std::fs::copy(&config_path, &backup_path)?; + return Err( + "检测到旧版 config.json(~/.proxycast/config.json),当前版本尚未支持自动迁移。请手动导出/重建配置后再启动。" + .to_string(), + ); } // 标记迁移完成 conn.execute( "INSERT OR REPLACE INTO settings (key, value) VALUES ('migrated_from_json', 'true')", [], - )?; + ) + .map_err(|e| e.to_string())?; Ok(()) } diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index f21195d62..b13e4ea0a 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -9,20 +9,22 @@ use std::sync::{Arc, Mutex}; pub type DbConnection = Arc>; /// 获取数据库文件路径 -pub fn get_db_path() -> PathBuf { - let home = dirs::home_dir().expect("Cannot find home directory"); +pub fn get_db_path() -> Result { + let home = dirs::home_dir().ok_or_else(|| "无法获取主目录".to_string())?; let db_dir = home.join(".proxycast"); - std::fs::create_dir_all(&db_dir).expect("Cannot create .proxycast directory"); - db_dir.join("proxycast.db") + std::fs::create_dir_all(&db_dir) + .map_err(|e| format!("无法创建数据库目录 {:?}: {}", db_dir, e))?; + Ok(db_dir.join("proxycast.db")) } /// 初始化数据库连接 -pub fn init_database() -> Result { - let db_path = get_db_path(); - let conn = Connection::open(&db_path)?; +pub fn init_database() -> Result { + let db_path = get_db_path()?; + let conn = Connection::open(&db_path).map_err(|e| e.to_string())?; // 创建表结构 - schema::create_tables(&conn)?; + schema::create_tables(&conn).map_err(|e| e.to_string())?; + migration::migrate_from_json(&conn)?; Ok(Arc::new(Mutex::new(conn))) } diff --git a/src-tauri/src/injection/types.rs b/src-tauri/src/injection/types.rs index 300ef203e..178253c23 100644 --- a/src-tauri/src/injection/types.rs +++ b/src-tauri/src/injection/types.rs @@ -4,6 +4,30 @@ use serde::{Deserialize, Serialize}; +/// 允许注入的参数白名单 +/// 这些参数是安全的,不会影响请求的核心行为 +const ALLOWED_INJECTION_PARAMS: &[&str] = &[ + "temperature", + "max_tokens", + "top_p", + "top_k", + "frequency_penalty", + "presence_penalty", + "stop", + "seed", + "n", +]; + +/// 禁止注入的参数黑名单(即使在白名单中也不允许 Override 模式) +const BLOCKED_OVERRIDE_PARAMS: &[&str] = &[ + "model", + "messages", + "tools", + "tool_choice", + "stream", + "response_format", +]; + /// 注入模式 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] @@ -213,6 +237,20 @@ impl Injector { let mut rule_applied = false; for (key, value) in params { + // 安全修复:检查参数是否在白名单中 + if !ALLOWED_INJECTION_PARAMS.contains(&key.as_str()) { + tracing::warn!("[INJECTION] 参数 {} 不在白名单中,跳过注入", key); + continue; + } + + // 安全修复:Override 模式下检查黑名单 + if rule.mode == InjectionMode::Override + && BLOCKED_OVERRIDE_PARAMS.contains(&key.as_str()) + { + tracing::warn!("[INJECTION] 参数 {} 禁止使用 Override 模式", key); + continue; + } + let should_inject = match rule.mode { InjectionMode::Merge => !obj.contains_key(key), InjectionMode::Override => true, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 56d11d4a5..cb44fc4f4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -200,6 +200,10 @@ mod tests { pub type AppState = Arc>; pub type LogState = Arc>; +fn generate_api_key() -> String { + config::generate_secure_api_key() +} + #[tauri::command] async fn start_server( state: tauri::State<'_, AppState>, @@ -258,6 +262,20 @@ async fn save_config( state: tauri::State<'_, AppState>, config: config::Config, ) -> Result<(), String> { + // P0 安全修复:禁止危险的网络配置 + let host = config.server.host.to_lowercase(); + if host == "0.0.0.0" || host == "::" { + return Err( + "安全限制:不允许监听所有网络接口 (0.0.0.0 或 ::)。请使用 127.0.0.1 或 localhost" + .to_string(), + ); + } + + // 禁止开启远程管理 + if config.remote_management.allow_remote { + return Err("安全限制:不允许开启远程管理功能".to_string()); + } + let mut s = state.write().await; s.config = config.clone(); config::save_config(&config).map_err(|e| e.to_string()) @@ -377,31 +395,32 @@ async fn get_env_variables(state: tauri::State<'_, AppState>) -> Result cfg, + Err(err) => { + tracing::error!("配置加载失败,已中止启动: {}", err); + eprintln!("配置加载失败,已中止启动: {}", err); + return; + } + }; + if config.server.api_key == config::DEFAULT_API_KEY { + let new_key = generate_api_key(); + config.server.api_key = new_key.clone(); + if let Err(err) = config::save_config(&config) { + tracing::error!("自动生成 API key 失败,无法保存配置,已中止启动: {}", err); + eprintln!("自动生成 API key 失败,无法保存配置,已中止启动: {}", err); + return; + } + tracing::info!("检测到默认 API key,已自动生成并保存新密钥"); + eprintln!("检测到默认 API key,已自动生成并保存新密钥"); + } + if !is_loopback_host(&config.server.host) { + tracing::error!("当前版本仅支持本地监听,请使用 127.0.0.1/localhost/::1。"); + eprintln!("当前版本仅支持本地监听,请使用 127.0.0.1/localhost/::1。"); + return; + } + if config.server.api_key == config::DEFAULT_API_KEY { + tracing::error!("检测到使用默认 API key,已中止启动。请配置强密钥。"); + eprintln!("检测到使用默认 API key,已中止启动。请配置强密钥。"); + return; + } + if config.server.tls.enable { + tracing::error!("检测到 TLS 配置已启用,但当前版本尚未支持 TLS,已中止启动。"); + eprintln!("检测到 TLS 配置已启用,但当前版本尚未支持 TLS,已中止启动。"); + return; + } + if config.remote_management.allow_remote { + tracing::error!("检测到远程管理已开启,但当前版本未启用 TLS,已中止启动。"); + eprintln!("检测到远程管理已开启,但当前版本未启用 TLS,已中止启动。"); + return; + } + let state: AppState = Arc::new(RwLock::new(server::ServerState::new(config.clone()))); + let logs: LogState = Arc::new(RwLock::new(logger::LogStore::with_config(&config.logging))); // Initialize database for Switch functionality - let db = database::init_database().expect("Failed to initialize database"); + let db = match database::init_database() { + Ok(conn) => conn, + Err(err) => { + tracing::error!("数据库初始化失败,已中止启动: {}", err); + eprintln!("数据库初始化失败,已中止启动: {}", err); + return; + } + }; // Initialize SkillService let skill_service = SkillService::new().expect("Failed to initialize SkillService"); @@ -1417,8 +1481,14 @@ pub fn run() { let shared_tokens = Arc::new(parking_lot::RwLock::new( telemetry::TokenTracker::with_defaults(), )); + let log_rotation = telemetry::LogRotationConfig { + max_memory_logs: 10000, + retention_days: config.logging.retention_days, + max_file_size: 10 * 1024 * 1024, + enable_file_logging: config.logging.enabled, + }; let shared_logger = Arc::new( - telemetry::RequestLogger::with_defaults().expect("Failed to create RequestLogger"), + telemetry::RequestLogger::new(log_rotation).expect("Failed to create RequestLogger"), ); // Initialize TelemetryState with shared instances @@ -2014,3 +2084,13 @@ pub fn run() { .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +fn is_loopback_host(host: &str) -> bool { + if host == "localhost" { + return true; + } + match host.parse::() { + Ok(addr) => addr.is_loopback(), + Err(_) => false, + } +} diff --git a/src-tauri/src/logger.rs b/src-tauri/src/logger.rs index bd86e4e7c..cf2871ca1 100644 --- a/src-tauri/src/logger.rs +++ b/src-tauri/src/logger.rs @@ -1,13 +1,35 @@ //! 日志管理模块 -use chrono::{Local, Utc}; +use chrono::{Duration, Local, Utc}; +use flate2::write::GzEncoder; +use flate2::Compression; +use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::VecDeque; use std::fs::{self, OpenOptions}; -use std::io::Write; +use std::io::{Read, Write}; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; +#[derive(Debug, Clone)] +pub struct LogStoreConfig { + pub max_logs: usize, + pub retention_days: u32, + pub max_file_size: u64, + pub enable_file_logging: bool, +} + +impl Default for LogStoreConfig { + fn default() -> Self { + Self { + max_logs: 1000, + retention_days: 7, + max_file_size: 10 * 1024 * 1024, + enable_file_logging: true, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LogEntry { pub timestamp: String, @@ -18,6 +40,7 @@ pub struct LogEntry { pub struct LogStore { logs: VecDeque, max_logs: usize, + config: LogStoreConfig, log_file_path: Option, } @@ -34,9 +57,12 @@ impl Default for LogStore { let log_file = log_dir.join("proxycast.log"); + let config = LogStoreConfig::default(); + Self { logs: VecDeque::new(), - max_logs: 1000, + max_logs: config.max_logs, + config, log_file_path: Some(log_file), } } @@ -47,23 +73,36 @@ impl LogStore { Self::default() } + pub fn with_config(logging: &crate::config::LoggingConfig) -> Self { + let mut store = Self::default(); + store.config.retention_days = logging.retention_days; + store.config.enable_file_logging = logging.enabled; + store.max_logs = store.config.max_logs; + store + } + pub fn add(&mut self, level: &str, message: &str) { + let sanitized = sanitize_log_message(message); let now = Utc::now(); let entry = LogEntry { timestamp: now.to_rfc3339(), level: level.to_string(), - message: message.to_string(), + message: sanitized.clone(), }; self.logs.push_back(entry.clone()); // 写入日志文件 - if let Some(ref path) = self.log_file_path { - let local_time = Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); - let log_line = format!("{} [{}] {}\n", local_time, level.to_uppercase(), message); + if self.config.enable_file_logging { + if let Some(ref path) = self.log_file_path { + self.rotate_log_file_if_needed(path); + let local_time = Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); + let log_line = format!("{} [{}] {}\n", local_time, level.to_uppercase(), sanitized); - if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) { - let _ = file.write_all(log_line.as_bytes()); + if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) { + let _ = file.write_all(log_line.as_bytes()); + } + self.prune_old_logs(path); } } @@ -78,6 +117,7 @@ impl LogStore { if let Some(ref log_path) = self.log_file_path { let log_dir = log_path.parent().unwrap_or(std::path::Path::new(".")); let raw_file = log_dir.join(format!("raw_response_{request_id}.txt")); + let sanitized = sanitize_log_message(body); if let Ok(mut file) = OpenOptions::new() .create(true) @@ -85,7 +125,7 @@ impl LogStore { .write(true) .open(&raw_file) { - let _ = file.write_all(body.as_bytes()); + let _ = file.write_all(sanitized.as_bytes()); } } } @@ -103,7 +143,233 @@ impl LogStore { .as_ref() .map(|p| p.to_string_lossy().to_string()) } + + fn rotate_log_file_if_needed(&self, path: &PathBuf) { + let Ok(metadata) = fs::metadata(path) else { + return; + }; + + if metadata.len() <= self.config.max_file_size { + return; + } + + let suffix = Local::now().format("%Y%m%d-%H%M%S"); + let rotated = path.with_file_name(format!( + "{}.{}", + path.file_name().unwrap_or_default().to_string_lossy(), + suffix + )); + + let _ = fs::rename(path, &rotated); + self.prune_old_logs(path); + } + + fn prune_old_logs(&self, path: &PathBuf) { + let Some(dir) = path.parent() else { + return; + }; + self.archive_old_logs(path); + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + let cutoff = Utc::now() - Duration::days(self.config.retention_days as i64); + let prefix = format!( + "{}.", + path.file_name().unwrap_or_default().to_string_lossy() + ); + + for entry in entries.flatten() { + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + if !file_name.starts_with(&prefix) { + continue; + } + let Ok(metadata) = entry.metadata() else { + continue; + }; + let Ok(modified) = metadata.modified() else { + continue; + }; + let modified = chrono::DateTime::::from(modified); + if modified < cutoff { + let _ = fs::remove_file(entry.path()); + } + } + } + + fn archive_old_logs(&self, path: &PathBuf) { + let Some(dir) = path.parent() else { + return; + }; + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + let archive_cutoff = Utc::now() - Duration::days(7); + let delete_cutoff = Utc::now() - Duration::days(30); + let prefix = format!( + "{}.", + path.file_name().unwrap_or_default().to_string_lossy() + ); + + for entry in entries.flatten() { + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + if !file_name.starts_with(&prefix) { + continue; + } + let path = entry.path(); + let Ok(metadata) = entry.metadata() else { + continue; + }; + let Ok(modified) = metadata.modified() else { + continue; + }; + let modified = chrono::DateTime::::from(modified); + + if file_name.ends_with(".gz") { + if modified < delete_cutoff { + let _ = fs::remove_file(path); + } + continue; + } + + if modified >= archive_cutoff { + continue; + } + + let mut input = Vec::new(); + if let Ok(mut file) = fs::File::open(&path) { + if file.read_to_end(&mut input).is_err() { + continue; + } + } else { + continue; + } + + let gz_path = path.with_extension(format!( + "{}.gz", + path.extension().unwrap_or_default().to_string_lossy() + )); + if let Ok(gz_file) = fs::File::create(&gz_path) { + let mut encoder = GzEncoder::new(gz_file, Compression::default()); + if encoder.write_all(&input).is_ok() && encoder.finish().is_ok() { + let _ = fs::remove_file(&path); + } + } + } + } } #[allow(dead_code)] pub type SharedLogStore = Arc>; + +/// P2 安全修复:扩展日志脱敏规则,覆盖更多敏感字段 +pub fn sanitize_log_message(message: &str) -> String { + let patterns = [ + // Bearer token + (r"Bearer\s+[A-Za-z0-9._-]+", "Bearer ***"), + // API key 各种格式 + ( + r#"api[_-]?key["']?\s*[:=]\s*["']?[A-Za-z0-9._-]+"#, + "api_key: ***", + ), + // 通用 token + (r#"token["']?\s*[:=]\s*["']?[A-Za-z0-9._-]+"#, "token: ***"), + // P2 新增:access_token + ( + r#"access[_-]?token["']?\s*[:=]\s*["']?[A-Za-z0-9._-]+"#, + "access_token: ***", + ), + // P2 新增:refresh_token + ( + r#"refresh[_-]?token["']?\s*[:=]\s*["']?[A-Za-z0-9._-]+"#, + "refresh_token: ***", + ), + // P2 新增:client_secret + ( + r#"client[_-]?secret["']?\s*[:=]\s*["']?[A-Za-z0-9._-]+"#, + "client_secret: ***", + ), + // P2 新增:authorization header + ( + r#"[Aa]uthorization["']?\s*[:=]\s*["']?[A-Za-z0-9._\s-]+"#, + "authorization: ***", + ), + // P2 新增:password + (r#"password["']?\s*[:=]\s*["']?[^\s"',}]+"#, "password: ***"), + // P2 新增:secret + ( + r#"secret["']?\s*[:=]\s*["']?[A-Za-z0-9._-]+"#, + "secret: ***", + ), + ]; + + let mut sanitized = message.to_string(); + for (pattern, replacement) in patterns { + if let Ok(re) = Regex::new(pattern) { + sanitized = re.replace_all(&sanitized, replacement).to_string(); + } + } + sanitized +} + +#[cfg(test)] +mod tests { + use super::sanitize_log_message; + + #[test] + fn test_sanitize_bearer_token() { + let input = "Authorization: Bearer abcDEF123._-XYZ"; + let output = sanitize_log_message(input); + // 验证敏感 token 被脱敏 + assert!(!output.contains("abcDEF123")); + assert!(output.contains("***")); + } + + #[test] + fn test_sanitize_api_key() { + let input = r#"request api_key="sk-test_123.456-ABC" end"#; + let output = sanitize_log_message(input); + assert!(output.contains("api_key: ***")); + assert!(!output.contains("sk-test_123")); + } + + #[test] + fn test_sanitize_access_token() { + let input = "access_token=atk_12345"; + let output = sanitize_log_message(input); + assert!(output.contains("access_token: ***")); + assert!(!output.contains("atk_12345")); + } + + #[test] + fn test_sanitize_refresh_token() { + let input = "refresh_token: rtk_ABCDE-123"; + let output = sanitize_log_message(input); + assert!(output.contains("refresh_token: ***")); + assert!(!output.contains("rtk_ABCDE")); + } + + #[test] + fn test_sanitize_client_secret() { + let input = "client_secret = \"cs_SeCreT-999\""; + let output = sanitize_log_message(input); + assert!(output.contains("client_secret: ***")); + assert!(!output.contains("cs_SeCreT")); + } + + #[test] + fn test_sanitize_password() { + let input = r#"{"password":"p@ssW0rd!"}"#; + let output = sanitize_log_message(input); + assert!(output.contains("password: ***")); + assert!(!output.contains("p@ssW0rd!")); + } + + #[test] + fn test_plain_text_unchanged() { + let input = "这是一段普通日志,不包含任何敏感字段。"; + let output = sanitize_log_message(input); + assert_eq!(output, input); + } +} diff --git a/src-tauri/src/middleware/management_auth.rs b/src-tauri/src/middleware/management_auth.rs index 6cf4dd0c5..ca5b4ccdc 100644 --- a/src-tauri/src/middleware/management_auth.rs +++ b/src-tauri/src/middleware/management_auth.rs @@ -20,10 +20,39 @@ use futures::future::BoxFuture; use std::{ net::{IpAddr, SocketAddr}, sync::Arc, + sync::Mutex, task::{Context, Poll}, + time::{Duration, Instant}, }; +use subtle::ConstantTimeEq; use tower::{Layer, Service}; +const MAX_AUTH_FAILURES: u32 = 5; +const FAILURE_WINDOW_SECS: u64 = 60; +const BLOCK_SECS: u64 = 300; +// 安全修复:限制 failure_map 最大条目数,防止内存 DoS +const MAX_FAILURE_ENTRIES: usize = 10000; +const ENTRY_EXPIRE_SECS: u64 = 3600; + +struct FailureState { + count: u32, + window_start: Instant, + blocked_until: Option, + last_access: Instant, +} + +fn failure_map() -> &'static Mutex> { + static FAILURES: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + FAILURES.get_or_init(|| Mutex::new(std::collections::HashMap::new())) +} + +#[cfg(test)] +pub(crate) fn clear_auth_failure_state() { + let mut map = failure_map().lock().unwrap(); + map.clear(); +} + /// Management API 认证层 /// /// 用于包装需要认证的管理端点 @@ -98,6 +127,76 @@ impl ManagementAuthService { .get::>() .map(|ci| ci.0) } + + fn get_client_id(req: &Request) -> String { + // 安全修复:只使用真实的连接地址,不信任 X-Forwarded-For + // X-Forwarded-For 可被伪造,用于绕过限速或导致 failure_map 无界增长 + if let Some(addr) = Self::get_client_addr(req) { + return addr.ip().to_string(); + } + "unknown".to_string() + } + + fn check_rate_limit(client_id: &str) -> bool { + let now = Instant::now(); + let mut map = failure_map().lock().unwrap(); + if let Some(state) = map.get_mut(client_id) { + state.last_access = now; + if let Some(blocked_until) = state.blocked_until { + if blocked_until > now { + return false; + } + state.blocked_until = None; + state.count = 0; + state.window_start = now; + } + if now.duration_since(state.window_start).as_secs() > FAILURE_WINDOW_SECS { + state.count = 0; + state.window_start = now; + } + } + true + } + + fn record_failure(client_id: &str) { + let now = Instant::now(); + let mut map = failure_map().lock().unwrap(); + + // 安全修复:容量保护,超过上限时清理长时间未访问的条目 + if map.len() > MAX_FAILURE_ENTRIES { + map.retain(|_, state| { + now.duration_since(state.last_access).as_secs() <= ENTRY_EXPIRE_SECS + }); + } + + let entry = map.entry(client_id.to_string()).or_insert(FailureState { + count: 0, + window_start: now, + blocked_until: None, + last_access: now, + }); + entry.last_access = now; + + if now.duration_since(entry.window_start).as_secs() > FAILURE_WINDOW_SECS { + entry.count = 0; + entry.window_start = now; + entry.blocked_until = None; + } + + entry.count += 1; + if entry.count >= MAX_AUTH_FAILURES { + entry.blocked_until = Some(now + Duration::from_secs(BLOCK_SECS)); + } + } + + fn record_success(client_id: &str) { + let mut map = failure_map().lock().unwrap(); + map.remove(client_id); + } + + fn secret_key_matches(provided: &str, expected: &str) -> bool { + provided.as_bytes().ct_eq(expected.as_bytes()).into() + } } impl Service> for ManagementAuthService @@ -118,6 +217,14 @@ where let mut inner = self.inner.clone(); Box::pin(async move { + let client_id = Self::get_client_id(&req); + if !Self::check_rate_limit(&client_id) { + return Ok(create_error_response( + StatusCode::TOO_MANY_REQUESTS, + "Too many failed authentication attempts", + )); + } + // 1. 检查 secret_key 是否为空(禁用管理 API) let secret_key = match &config.secret_key { Some(key) if !key.is_empty() => key.clone(), @@ -148,9 +255,10 @@ where // 3. 验证 secret_key let provided_key = Self::extract_secret_key(&req); match provided_key { - Some(key) if key == secret_key => { + Some(key) if Self::secret_key_matches(&key, &secret_key) => { // 认证成功,继续处理请求 tracing::debug!("[MANAGEMENT_AUTH] Auth successful from {:?}", client_addr); + Self::record_success(&client_id); inner.call(req).await } Some(_) => { @@ -158,6 +266,7 @@ where "[MANAGEMENT_AUTH] Invalid secret_key from {:?}", client_addr ); + Self::record_failure(&client_id); Ok(create_error_response( StatusCode::UNAUTHORIZED, "Invalid secret key", @@ -168,6 +277,7 @@ where "[MANAGEMENT_AUTH] Missing secret_key from {:?}", client_addr ); + Self::record_failure(&client_id); Ok(create_error_response( StatusCode::UNAUTHORIZED, "Missing secret key", diff --git a/src-tauri/src/middleware/tests.rs b/src-tauri/src/middleware/tests.rs index 4f9585836..92832dd57 100644 --- a/src-tauri/src/middleware/tests.rs +++ b/src-tauri/src/middleware/tests.rs @@ -3,9 +3,12 @@ //! 使用 proptest 进行属性测试 use crate::config::RemoteManagementConfig; -use crate::middleware::management_auth::{ManagementAuthLayer, ManagementAuthService}; +use crate::middleware::management_auth::{ + clear_auth_failure_state, ManagementAuthLayer, ManagementAuthService, +}; use axum::{ body::Body, + extract::ConnectInfo, http::{Request, Response, StatusCode}, }; use proptest::prelude::*; @@ -102,6 +105,54 @@ fn create_request_with_management_key(key: Option<&str>) -> Request { builder.body(Body::empty()).unwrap() } +/// Helper to create a request with X-Management-Key and X-Forwarded-For headers +fn create_request_with_management_key_and_forwarded( + key: Option<&str>, + forwarded_for: Option<&str>, +) -> Request { + let mut builder = Request::builder().uri("/v0/management/status"); + + if let Some(k) = key { + builder = builder.header("x-management-key", k); + } + + if let Some(addr) = forwarded_for { + builder = builder.header("x-forwarded-for", addr); + } + + builder.body(Body::empty()).unwrap() +} + +#[test] +fn test_management_auth_rate_limit_after_failures() { + clear_auth_failure_state(); + let config = RemoteManagementConfig { + allow_remote: true, + secret_key: Some("valid_key".to_string()), + disable_control_panel: false, + }; + let layer = ManagementAuthLayer::new(config); + let mut service = layer.layer(MockService); + let rt = tokio::runtime::Runtime::new().unwrap(); + + // 使用唯一的 IP 地址避免测试间干扰 + let client_ip = format!("203.0.113.{}", std::process::id() % 256); + let addr: SocketAddr = format!("{}:12345", client_ip).parse().unwrap(); + + for _ in 0..5 { + let mut req = create_request_with_management_key(Some("invalid")); + // 安全修复后不再信任 X-Forwarded-For,需要注入 ConnectInfo + req.extensions_mut().insert(ConnectInfo(addr)); + let response = rt.block_on(async { service.call(req).await.unwrap() }); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + let mut req = create_request_with_management_key(Some("invalid")); + req.extensions_mut().insert(ConnectInfo(addr)); + let response = rt.block_on(async { service.call(req).await.unwrap() }); + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); +} + proptest! { #![proptest_config(ProptestConfig::with_cases(100))] @@ -112,6 +163,7 @@ proptest! { fn prop_management_auth_rejection_missing_key( secret_key in arb_secret_key() ) { + clear_auth_failure_state(); // Create config with a valid secret_key let config = RemoteManagementConfig { allow_remote: true, @@ -147,6 +199,7 @@ proptest! { fn prop_management_auth_rejection_invalid_key( secret_key in arb_secret_key() ) { + clear_auth_failure_state(); // Create config with a valid secret_key let config = RemoteManagementConfig { allow_remote: true, @@ -183,6 +236,7 @@ proptest! { fn prop_management_auth_acceptance_valid_key( secret_key in arb_secret_key() ) { + clear_auth_failure_state(); // Create config with a valid secret_key let config = RemoteManagementConfig { allow_remote: true, @@ -218,6 +272,7 @@ proptest! { fn prop_management_auth_acceptance_x_management_key( secret_key in arb_secret_key() ) { + clear_auth_failure_state(); // Create config with a valid secret_key let config = RemoteManagementConfig { allow_remote: true, @@ -253,6 +308,7 @@ proptest! { fn prop_management_auth_rejection_invalid_x_management_key( secret_key in arb_secret_key() ) { + clear_auth_failure_state(); // Create config with a valid secret_key let config = RemoteManagementConfig { allow_remote: true, diff --git a/src-tauri/src/models/provider_pool_model.rs b/src-tauri/src/models/provider_pool_model.rs index 0e15aca6d..187167ad7 100644 --- a/src-tauri/src/models/provider_pool_model.rs +++ b/src-tauri/src/models/provider_pool_model.rs @@ -72,7 +72,12 @@ pub enum CredentialData { excluded_models: Vec, }, /// Codex OAuth 凭证(OpenAI Codex) - CodexOAuth { creds_file_path: String }, + CodexOAuth { + creds_file_path: String, + /// API Base URL(可选,默认使用凭证文件中的配置) + #[serde(default)] + api_base_url: Option, + }, /// Claude OAuth 凭证(Anthropic OAuth) ClaudeOAuth { creds_file_path: String }, /// iFlow OAuth 凭证 @@ -113,7 +118,9 @@ impl CredentialData { CredentialData::GeminiApiKey { api_key, .. } => { format!("Gemini API Key: {}", mask_key(api_key)) } - CredentialData::CodexOAuth { creds_file_path } => { + CredentialData::CodexOAuth { + creds_file_path, .. + } => { format!("Codex OAuth: {}", mask_path(creds_file_path)) } CredentialData::ClaudeOAuth { creds_file_path } => { @@ -550,7 +557,9 @@ pub fn get_oauth_creds_path(cred: &CredentialData) -> Option { CredentialData::AntigravityOAuth { creds_file_path, .. } => Some(creds_file_path.clone()), - CredentialData::CodexOAuth { creds_file_path } => Some(creds_file_path.clone()), + CredentialData::CodexOAuth { + creds_file_path, .. + } => Some(creds_file_path.clone()), CredentialData::ClaudeOAuth { creds_file_path } => Some(creds_file_path.clone()), CredentialData::IFlowOAuth { creds_file_path } => Some(creds_file_path.clone()), CredentialData::IFlowCookie { creds_file_path } => Some(creds_file_path.clone()), diff --git a/src-tauri/src/processor/error.rs b/src-tauri/src/processor/error.rs index 7b5cf461b..3bd89675e 100644 --- a/src-tauri/src/processor/error.rs +++ b/src-tauri/src/processor/error.rs @@ -126,6 +126,18 @@ impl ProcessError { ProcessError::Cancelled => "cancelled", } } + + /// 记录带上下文的错误日志 + pub fn log_with_context(&self, request_id: &str, provider: &str, model: &str) { + tracing::error!( + request_id = %request_id, + provider = %provider, + model = %model, + error_type = %self.error_type(), + error_message = %self.to_string(), + "Request processing failed" + ); + } } #[cfg(test)] diff --git a/src-tauri/src/processor/mod.rs b/src-tauri/src/processor/mod.rs index 8ec3dfaa7..2cfb48b5b 100644 --- a/src-tauri/src/processor/mod.rs +++ b/src-tauri/src/processor/mod.rs @@ -58,6 +58,8 @@ pub struct RequestProcessor { pub tokens: Arc>, /// 凭证池服务 pub pool_service: Arc, + /// 热重载协调锁(避免配置更新期间请求读取不一致的配置) + pub reload_lock: Arc>, } impl RequestProcessor { @@ -85,6 +87,7 @@ impl RequestProcessor { stats, tokens, pool_service, + reload_lock: Arc::new(RwLock::new(())), } } @@ -102,6 +105,7 @@ impl RequestProcessor { stats: Arc::new(ParkingLotRwLock::new(StatsAggregator::with_defaults())), tokens: Arc::new(ParkingLotRwLock::new(TokenTracker::with_defaults())), pool_service, + reload_lock: Arc::new(RwLock::new(())), } } @@ -126,6 +130,7 @@ impl RequestProcessor { stats, tokens, pool_service, + reload_lock: Arc::new(RwLock::new(())), } } diff --git a/src-tauri/src/processor/steps/auth.rs b/src-tauri/src/processor/steps/auth.rs index d67559b79..fd302ad8e 100644 --- a/src-tauri/src/processor/steps/auth.rs +++ b/src-tauri/src/processor/steps/auth.rs @@ -5,6 +5,7 @@ use super::traits::{PipelineStep, StepError}; use crate::processor::RequestContext; use async_trait::async_trait; +use subtle::ConstantTimeEq; /// 认证步骤 /// @@ -34,7 +35,7 @@ impl AuthStep { /// 验证 API Key pub fn verify(&self, provided_key: Option<&str>) -> Result<(), StepError> { match provided_key { - Some(key) if key == self.expected_key => Ok(()), + Some(key) if key.as_bytes().ct_eq(self.expected_key.as_bytes()).into() => Ok(()), Some(_) => Err(StepError::Auth("Invalid API key".to_string())), None => Err(StepError::Auth("No API key provided".to_string())), } diff --git a/src-tauri/src/providers/codex.rs b/src-tauri/src/providers/codex.rs index b4c6baf85..f09eed36b 100644 --- a/src-tauri/src/providers/codex.rs +++ b/src-tauri/src/providers/codex.rs @@ -17,6 +17,7 @@ const OPENAI_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; const OPENAI_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const DEFAULT_CALLBACK_PORT: u16 = 1455; const CODEX_API_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; +const DEFAULT_API_BASE_URL: &str = "https://api.openai.com"; /// Codex OAuth credentials storage /// @@ -26,6 +27,10 @@ const CODEX_API_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; /// Supports multiple field name formats: /// - snake_case: `refresh_token`, `access_token`, `id_token`, `account_id`, `last_refresh` /// - camelCase: `refreshToken`, `accessToken`, `idToken`, `accountId`, `lastRefresh` +/// +/// 同时兼容 Codex CLI 的 API Key 登录格式: +/// - `api_key` / `apiKey` +/// - `api_base_url` / `apiBaseUrl` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CodexCredentials { /// JWT ID token containing user claims @@ -45,6 +50,18 @@ pub struct CodexCredentials { alias = "refreshToken" )] pub refresh_token: Option, + /// API Key(Codex CLI 支持通过 API Key 登录) + /// 支持字段名: api_key, apiKey, OPENAI_API_KEY + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "apiKey", + alias = "OPENAI_API_KEY" + )] + pub api_key: Option, + /// API Base URL(可选) + #[serde(default, skip_serializing_if = "Option::is_none", alias = "apiBaseUrl")] + pub api_base_url: Option, /// OpenAI account identifier #[serde(default, skip_serializing_if = "Option::is_none", alias = "accountId")] pub account_id: Option, @@ -66,8 +83,7 @@ pub struct CodexCredentials { #[serde( default, skip_serializing_if = "Option::is_none", - rename = "expired", - alias = "expires_at", + alias = "expired", alias = "expiresAt" )] pub expires_at: Option, @@ -83,6 +99,8 @@ impl Default for CodexCredentials { id_token: None, access_token: None, refresh_token: None, + api_key: None, + api_base_url: None, account_id: None, last_refresh: None, email: None, @@ -428,6 +446,38 @@ impl CodexProvider { CODEX_API_BASE_URL } + /// 获取已配置的 API Key(trim 后的非空值) + fn get_api_key(&self) -> Option<&str> { + self.credentials + .api_key + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + } + + pub(crate) fn build_responses_url(base_url: &str) -> String { + let base = base_url.trim_end_matches('/'); + + // 规则说明: + // - 如果 base_url 以 /v1 结尾:直接拼 /responses + // - 如果 base_url 只有域名(path 为空或 /):拼 /v1/responses(OpenAI 标准) + // - 如果 base_url 已包含路径前缀(如 https://yunyi.cfd/codex):认为前缀已包含路由信息,拼 /responses + if base.ends_with("/v1") { + return format!("{}/responses", base); + } + + if let Ok(parsed) = url::Url::parse(base) { + let path = parsed.path().trim_end_matches('/'); + if path.is_empty() || path == "/" { + return format!("{}/v1/responses", base); + } + return format!("{}/responses", base); + } + + // 兜底:保持旧行为 + format!("{}/v1/responses", base) + } + /// Load credentials from the default path pub async fn load_credentials(&mut self) -> Result<(), Box> { let path = Self::default_creds_path(); @@ -457,9 +507,14 @@ impl CodexProvider { })?; // 检查关键字段 - if creds.refresh_token.is_none() { + let has_api_key = creds + .api_key + .as_deref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + if creds.refresh_token.is_none() && !has_api_key { tracing::warn!( - "[CODEX] 凭证文件缺少 refresh_token 字段。支持的字段名: refresh_token, refreshToken" + "[CODEX] 凭证文件缺少 refresh_token/api_key 字段。支持的字段名: refresh_token, refreshToken, api_key, apiKey" ); // 打印文件中的顶级字段名,帮助调试 if let Ok(json_value) = serde_json::from_str::(&content) { @@ -471,9 +526,10 @@ impl CodexProvider { } tracing::info!( - "[CODEX] 凭证加载成功: has_access={}, has_refresh={}, email={:?}, path={:?}", + "[CODEX] 凭证加载成功: has_access={}, has_refresh={}, has_api_key={}, email={:?}, path={:?}", creds.access_token.is_some(), creds.refresh_token.is_some(), + has_api_key, creds.email, path ); @@ -505,6 +561,11 @@ impl CodexProvider { /// Check if the access token is expired pub fn is_token_expired(&self) -> bool { + // API Key 模式:不涉及过期概念 + if self.get_api_key().is_some() { + return false; + } + if let Some(expires_str) = &self.credentials.expires_at { if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(expires_str) { let now = chrono::Utc::now(); @@ -518,6 +579,9 @@ impl CodexProvider { /// Check if credentials are valid (has access token and not expired) pub fn is_valid(&self) -> bool { + if self.get_api_key().is_some() { + return true; + } self.credentials.access_token.is_some() && !self.is_token_expired() } @@ -608,6 +672,8 @@ impl CodexProvider { id_token, access_token: Some(access_token), refresh_token, + api_key: None, + api_base_url: None, account_id, last_refresh: Some(chrono::Utc::now().to_rfc3339()), email, @@ -626,13 +692,55 @@ impl CodexProvider { } /// Refresh the access token using the refresh token + /// + /// Supports three authentication modes (in priority order): + /// 1. **API Key Mode**: Returns the API key directly (no refresh needed) + /// 2. **OAuth Mode**: Refreshes the access token using the refresh token + /// 3. **Access Token Mode**: Returns the existing access token (may be expired) + /// + /// # Returns + /// * `Ok(String)` - The access token or API key + /// * `Err` - If no credentials are available + /// + /// # Examples + /// ```ignore + /// // API Key mode + /// provider.credentials.api_key = Some("sk-test".to_string()); + /// let token = provider.refresh_token().await?; // Returns "sk-test" + /// + /// // OAuth mode + /// provider.credentials.refresh_token = Some("refresh_token".to_string()); + /// let token = provider.refresh_token().await?; // Refreshes and returns new access_token + /// + /// // Access Token mode (fallback) + /// provider.credentials.access_token = Some("access_token".to_string()); + /// let token = provider.refresh_token().await?; // Returns "access_token" (with warning) + /// ``` pub async fn refresh_token(&mut self) -> Result> { - let refresh_token = self.credentials.refresh_token.as_ref().ok_or_else(|| { - create_config_error( - "没有可用的 refresh_token。请确保凭证文件包含 refresh_token 或 refreshToken 字段,\ - 或使用 OAuth 登录功能重新获取凭证", - ) - })?; + // 1. API Key 模式无需刷新(优先级最高) + if let Some(api_key) = self.get_api_key() { + return Ok(api_key.to_string()); + } + + // 2. 无 refresh_token 时的降级处理 + if self.credentials.refresh_token.is_none() { + // 2a. 有 access_token:返回(可能过期,由上层处理) + if let Some(ref access_token) = self.credentials.access_token { + tracing::warn!("[CODEX] 没有 refresh_token,返回现有 access_token(可能已过期)"); + return Ok(access_token.clone()); + } + + // 2b. 无任何凭证:清晰的错误指导 + return Err(create_config_error( + "没有可用的认证凭证。请配置以下任一方式:\n\ + 1. API Key 模式:在凭证文件中添加 api_key/apiKey 字段\n\ + 2. OAuth 模式:使用 OAuth 登录获取 refresh_token\n\ + 3. Access Token 模式:在凭证文件中添加 access_token/accessToken 字段", + )); + } + + // 3. OAuth 刷新流程(标准流程) + let refresh_token = self.credentials.refresh_token.as_ref().unwrap(); tracing::info!("[CODEX] 正在刷新 access token"); @@ -768,6 +876,11 @@ impl CodexProvider { /// Check if token needs refresh (expiring within the specified duration) pub fn needs_refresh(&self, lead_time: chrono::Duration) -> bool { + // API Key 模式无需刷新 + if self.get_api_key().is_some() { + return false; + } + if self.credentials.access_token.is_none() { return true; } @@ -788,6 +901,11 @@ impl CodexProvider { /// This is the recommended method to call before making API requests. /// It will automatically refresh the token if it's expired or about to expire. pub async fn ensure_valid_token(&mut self) -> Result> { + // 兼容 Codex CLI 的 API Key 登录:auth.json 只有 api_key,没有 refresh_token + if let Some(api_key) = self.get_api_key() { + return Ok(api_key.to_string()); + } + // Refresh if token expires within 5 minutes let lead_time = chrono::Duration::minutes(5); @@ -811,6 +929,11 @@ impl CodexProvider { /// Get the access token, refreshing if necessary pub async fn get_access_token(&mut self) -> Result> { + // API Key 模式直接返回 + if let Some(api_key) = self.get_api_key() { + return Ok(api_key.to_string()); + } + if self.is_token_expired() { self.refresh_token().await?; } @@ -919,42 +1042,99 @@ impl CodexProvider { &self, request: &serde_json::Value, ) -> Result> { - let token = self - .credentials - .access_token - .as_ref() - .ok_or("No access token available")?; + enum AuthMode { + ApiKey, + OAuth, + } + + let (token, mode) = match self.get_api_key() { + Some(api_key) => (api_key, AuthMode::ApiKey), + None => ( + self.credentials + .access_token + .as_deref() + .ok_or("No access token or api_key available")?, + AuthMode::OAuth, + ), + }; // Build the Codex API URL - let url = format!("{}/responses", CODEX_API_BASE_URL); + let url = match mode { + AuthMode::ApiKey => { + let has_custom_base_url = self + .credentials + .api_base_url + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .is_some(); + + let base_url = self + .credentials + .api_base_url + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .unwrap_or(DEFAULT_API_BASE_URL); + + // Warn if API key doesn't look like OpenAI format but no custom base URL is set + if !has_custom_base_url && !token.starts_with("sk-") { + tracing::warn!( + "[CODEX] API key does not appear to be an OpenAI key (doesn't start with 'sk-'), \ + but no api_base_url is configured. Requests will be sent to {}. \ + If you're using a third-party API provider, please add 'api_base_url' to ~/.codex/auth.json", + DEFAULT_API_BASE_URL + ); + } + + Self::build_responses_url(base_url) + } + AuthMode::OAuth => format!("{}/responses", CODEX_API_BASE_URL), + }; // Transform OpenAI chat completion request to Codex format let codex_request = transform_to_codex_format(request)?; tracing::debug!("[CODEX] Calling API: {}", url); - let resp = self + let mut req = self .client .post(&url) .header("Authorization", format!("Bearer {}", token)) .header("Content-Type", "application/json") .header("Accept", "text/event-stream") - .header("Version", "0.21.0") .header("Openai-Beta", "responses=experimental") - .header( - "User-Agent", - "codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464", - ) - .header("Originator", "codex_cli_rs") - .header("Session_id", uuid::Uuid::new_v4().to_string()) - // Add account ID header if available - .header( - "Chatgpt-Account-Id", - self.credentials.account_id.as_deref().unwrap_or(""), - ) - .json(&codex_request) - .send() - .await?; + .json(&codex_request); + + // 部分三方 Codex 代理(如 Yunyi)会依赖 Codex CLI 的特征 headers; + // 仅在 OAuth 模式或显式配置了自定义 base_url 时附加,避免影响 OpenAI 官方 Key 模式。 + let should_add_codex_cli_headers = matches!(mode, AuthMode::OAuth) + || (matches!(mode, AuthMode::ApiKey) + && self + .credentials + .api_base_url + .as_deref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false)); + + if should_add_codex_cli_headers { + req = req + .header("Version", "0.21.0") + .header( + "User-Agent", + "codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464", + ) + .header("Originator", "codex_cli_rs") + .header("Session_id", uuid::Uuid::new_v4().to_string()) + .header("Conversation_id", uuid::Uuid::new_v4().to_string()) + // Add account ID header if available + .header( + "Chatgpt-Account-Id", + self.credentials.account_id.as_deref().unwrap_or(""), + ); + } + + let resp = req.send().await?; Ok(resp) } @@ -1163,6 +1343,7 @@ mod tests { let creds = CodexCredentials::default(); assert!(creds.access_token.is_none()); assert!(creds.refresh_token.is_none()); + assert!(creds.api_key.is_none()); assert_eq!(creds.r#type, "codex"); } @@ -1232,6 +1413,32 @@ mod tests { assert_eq!(creds.expires_at, Some("2024-12-31T23:59:59Z".to_string())); } + #[test] + fn test_codex_credentials_api_key_fields() { + let json = r#"{ + "api_key": "sk-test", + "api_base_url": "https://api.openai.com/v1" + }"#; + + let creds: CodexCredentials = serde_json::from_str(json).unwrap(); + assert_eq!(creds.api_key, Some("sk-test".to_string())); + assert_eq!( + creds.api_base_url, + Some("https://api.openai.com/v1".to_string()) + ); + + let json2 = r#"{ + "apiKey": "sk-test-2", + "apiBaseUrl": "https://example.com/v1" + }"#; + let creds2: CodexCredentials = serde_json::from_str(json2).unwrap(); + assert_eq!(creds2.api_key, Some("sk-test-2".to_string())); + assert_eq!( + creds2.api_base_url, + Some("https://example.com/v1".to_string()) + ); + } + #[test] fn test_codex_credentials_expires_at_alias() { // 测试 expires_at 字段的多种别名 @@ -1264,6 +1471,35 @@ mod tests { assert!(provider.credentials.access_token.is_none()); } + #[test] + fn test_build_responses_url() { + assert_eq!( + CodexProvider::build_responses_url("https://api.openai.com"), + "https://api.openai.com/v1/responses" + ); + assert_eq!( + CodexProvider::build_responses_url("https://api.openai.com/v1"), + "https://api.openai.com/v1/responses" + ); + assert_eq!( + CodexProvider::build_responses_url("https://example.com/v1/"), + "https://example.com/v1/responses" + ); + assert_eq!( + CodexProvider::build_responses_url("https://yunyi.cfd/codex"), + "https://yunyi.cfd/codex/responses" + ); + } + + #[tokio::test] + async fn test_ensure_valid_token_prefers_api_key() { + let mut provider = CodexProvider::new(); + provider.credentials.api_key = Some("sk-test".to_string()); + + let token = provider.ensure_valid_token().await.unwrap(); + assert_eq!(token, "sk-test"); + } + #[test] fn test_generate_auth_url() { let provider = CodexProvider::new(); @@ -1342,7 +1578,12 @@ mod tests { // No expiry - should be considered expired assert!(provider.is_token_expired()); + // API Key 模式 - 不应视为过期 + provider.credentials.api_key = Some("sk-test".to_string()); + assert!(!provider.is_token_expired()); + // Expired token + provider.credentials.api_key = None; provider.credentials.expires_at = Some("2020-01-01T00:00:00Z".to_string()); assert!(provider.is_token_expired()); @@ -1440,6 +1681,65 @@ mod tests { assert_eq!(result["max_output_tokens"], 1000); assert_eq!(result["top_p"], 0.9); } + + #[tokio::test] + async fn test_refresh_token_with_only_access_token() { + // 场景:只有 access_token(无 refresh_token 和 api_key) + let mut provider = CodexProvider::new(); + provider.credentials.access_token = Some("test_access_token".to_string()); + provider.credentials.refresh_token = None; + provider.credentials.api_key = None; + + let result = provider.refresh_token().await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "test_access_token"); + } + + #[tokio::test] + async fn test_refresh_token_with_no_credentials() { + // 场景:无任何凭证(api_key、refresh_token、access_token 均为 None) + let mut provider = CodexProvider::new(); + provider.credentials.api_key = None; + provider.credentials.refresh_token = None; + provider.credentials.access_token = None; + + let result = provider.refresh_token().await; + assert!(result.is_err()); + let error_msg = result.unwrap_err().to_string(); + assert!(error_msg.contains("没有可用的认证凭证")); + assert!(error_msg.contains("API Key 模式")); + assert!(error_msg.contains("OAuth 模式")); + assert!(error_msg.contains("Access Token 模式")); + } + + #[tokio::test] + async fn test_api_key_priority_over_refresh_token() { + // 场景:同时有 api_key 和 refresh_token + let mut provider = CodexProvider::new(); + provider.credentials.api_key = Some("sk-test-api-key".to_string()); + provider.credentials.refresh_token = Some("test_refresh_token".to_string()); + provider.credentials.access_token = Some("test_access_token".to_string()); + + let result = provider.refresh_token().await; + assert!(result.is_ok()); + // 应该返回 API Key(优先级最高) + assert_eq!(result.unwrap(), "sk-test-api-key"); + } + + #[tokio::test] + async fn test_refresh_token_with_expired_access_token() { + // 场景:只有 access_token(已过期) + let mut provider = CodexProvider::new(); + provider.credentials.access_token = Some("expired_access_token".to_string()); + provider.credentials.expires_at = Some("2020-01-01T00:00:00Z".to_string()); + provider.credentials.refresh_token = None; + provider.credentials.api_key = None; + + let result = provider.refresh_token().await; + assert!(result.is_ok()); + // 应该返回 access_token(即使已过期,由上层处理) + assert_eq!(result.unwrap(), "expired_access_token"); + } } // ============================================================================ @@ -1716,6 +2016,8 @@ pub async fn start_codex_oauth_server_and_get_url() -> Result< id_token, access_token: Some(access_token.to_string()), refresh_token, + api_key: None, + api_base_url: None, account_id, last_refresh: Some(now.to_rfc3339()), email: email.clone(), diff --git a/src-tauri/src/providers/kiro.rs b/src-tauri/src/providers/kiro.rs index bd94da244..1fbfb0623 100644 --- a/src-tauri/src/providers/kiro.rs +++ b/src-tauri/src/providers/kiro.rs @@ -322,27 +322,8 @@ impl KiroProvider { tracing::info!("[KIRO] 没有 clientIdHash 字段"); } - // 读取目录中其他 JSON 文件 - if tokio::fs::try_exists(dir).await.unwrap_or(false) { - let mut entries = tokio::fs::read_dir(dir).await?; - while let Some(entry) = entries.next_entry().await? { - let file_path = entry.path(); - if file_path.extension().map(|e| e == "json").unwrap_or(false) && file_path != path - { - if let Ok(content) = tokio::fs::read_to_string(&file_path).await { - if let Ok(creds) = serde_json::from_str::(&content) { - tracing::info!( - "[KIRO] Extra file {:?}: has_client_id={}, has_client_secret={}", - file_path.file_name(), - creds.client_id.is_some(), - creds.client_secret.is_some() - ); - merge_credentials(&mut merged, &creds); - } - } - } - } - } + // 安全修复:不再遍历目录中其他 JSON 文件,避免串凭证/串账号风险 + // 只信任主凭证文件和 clientIdHash 指向的文件 tracing::info!( "[KIRO] Final merged: has_access={}, has_refresh={}, has_client_id={}, has_client_secret={}, auth_method={:?}", @@ -625,11 +606,8 @@ impl KiroProvider { token_len < 100 || refresh_token.ends_with("...") || refresh_token.contains("..."); if is_truncated { - tracing::error!( - "[KIRO] 检测到 refreshToken 被截断!长度: {}, 内容: {}...", - token_len, - &refresh_token[..std::cmp::min(30, token_len)] - ); + // 安全修复:不打印 token 内容,只打印长度 + tracing::error!("[KIRO] 检测到 refreshToken 被截断!长度: {}", token_len); return Err(format!( "refreshToken 已被截断(长度: {} 字符)。\n\n⚠️ 这通常是 Kiro IDE 为了防止凭证被第三方工具使用而故意截断的。\n\n💡 解决方案:\n1. 使用 Kir-Manager 工具获取完整的凭证\n2. 或者使用其他方式获取未截断的凭证文件\n3. 正常的 refreshToken 长度应该在 500+ 字符", token_len @@ -957,45 +935,49 @@ impl KiroProvider { let cw_request = convert_openai_to_codewhisperer(request, profile_arn.clone()); let url = self.get_base_url(); - // Debug: 记录转换后的请求 - if let Ok(json_str) = serde_json::to_string_pretty(&cw_request) { - // 保存到文件用于调试 - let uuid_prefix = uuid::Uuid::new_v4() - .to_string() - .split('-') - .next() - .unwrap_or("unknown") - .to_string(); - let debug_path = dirs::home_dir() - .unwrap_or_default() - .join(".proxycast") - .join("logs") - .join(format!("cw_request_{uuid_prefix}.json")); - let _ = tokio::fs::write(&debug_path, &json_str).await; - tracing::debug!("[CW_REQ] Request saved to {:?}", debug_path); - - // 记录历史消息数量和 tool_results 情况 - let history_len = cw_request - .conversation_state - .history - .as_ref() - .map(|h| h.len()) - .unwrap_or(0); - let current_has_tools = cw_request - .conversation_state - .current_message - .user_input_message - .user_input_message_context - .as_ref() - .map(|ctx| ctx.tool_results.as_ref().map(|tr| tr.len()).unwrap_or(0)) - .unwrap_or(0); - tracing::info!( - "[CW_REQ] history={} current_tool_results={}", - history_len, - current_has_tools - ); + // 安全修复:仅在 PROXYCAST_DEBUG=1 时写入请求调试文件,避免泄露敏感信息 + let debug_enabled = std::env::var("PROXYCAST_DEBUG") + .map(|v| v == "1") + .unwrap_or(false); + if debug_enabled { + if let Ok(json_str) = serde_json::to_string_pretty(&cw_request) { + let uuid_prefix = uuid::Uuid::new_v4() + .to_string() + .split('-') + .next() + .unwrap_or("unknown") + .to_string(); + let debug_path = dirs::home_dir() + .unwrap_or_default() + .join(".proxycast") + .join("logs") + .join(format!("cw_request_{uuid_prefix}.json")); + let _ = tokio::fs::write(&debug_path, &json_str).await; + tracing::debug!("[CW_REQ] Request saved to {:?}", debug_path); + } } + // 记录历史消息数量和 tool_results 情况(不落盘) + let history_len = cw_request + .conversation_state + .history + .as_ref() + .map(|h| h.len()) + .unwrap_or(0); + let current_has_tools = cw_request + .conversation_state + .current_message + .user_input_message + .user_input_message_context + .as_ref() + .map(|ctx| ctx.tool_results.as_ref().map(|tr| tr.len()).unwrap_or(0)) + .unwrap_or(0); + tracing::info!( + "[CW_REQ] history={} current_tool_results={}", + history_len, + current_has_tools + ); + // 生成基于凭证的唯一 Machine ID(关键改进:每个账号独立指纹) let machine_id = generate_machine_id_from_credentials( profile_arn.as_deref(), diff --git a/src-tauri/src/providers/qwen.rs b/src-tauri/src/providers/qwen.rs index cbc4af10b..7c8ad5951 100644 --- a/src-tauri/src/providers/qwen.rs +++ b/src-tauri/src/providers/qwen.rs @@ -138,8 +138,10 @@ impl QwenProvider { if let Some(expire_str) = &self.credentials.expire { if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(expire_str) { let now = chrono::Utc::now(); + // 安全修复:显式转换为 Utc 时区再比较 + let expires_utc = expires.with_timezone(&chrono::Utc); // Token 有效期需要超过 30 秒 - return expires > now + chrono::Duration::seconds(30); + return expires_utc > now + chrono::Duration::seconds(30); } } @@ -149,7 +151,8 @@ impl QwenProvider { return expiry > now + 30_000; } - true + // 安全修复:没有过期时间时采用保守策略,认为 token 无效 + false } pub fn get_base_url(&self) -> String { diff --git a/src-tauri/src/proxy/tests.rs b/src-tauri/src/proxy/tests.rs index 38ab31a99..4533d6619 100644 --- a/src-tauri/src/proxy/tests.rs +++ b/src-tauri/src/proxy/tests.rs @@ -17,8 +17,8 @@ fn arb_hostname() -> impl Strategy { /// 生成有效的 socks5 代理 URL fn arb_socks5_url() -> impl Strategy { ( - arb_hostname(), // host - 1024u16..65535u16, // port + "[a-z][a-z0-9]{0,19}", // host: 必须以字母开头 + 1024u16..65535u16, // port ) .prop_map(|(host, port)| format!("socks5://{}:{}", host, port)) } @@ -26,8 +26,8 @@ fn arb_socks5_url() -> impl Strategy { /// 生成有效的 http 代理 URL fn arb_http_url() -> impl Strategy { ( - arb_hostname(), // host - 1024u16..65535u16, // port + "[a-z][a-z0-9]{0,19}", // host: 必须以字母开头 + 1024u16..65535u16, // port ) .prop_map(|(host, port)| format!("http://{}:{}", host, port)) } @@ -35,8 +35,8 @@ fn arb_http_url() -> impl Strategy { /// 生成有效的 https 代理 URL fn arb_https_url() -> impl Strategy { ( - arb_hostname(), // host - 1024u16..65535u16, // port + "[a-z][a-z0-9]{0,19}", // host: 必须以字母开头 + 1024u16..65535u16, // port ) .prop_map(|(host, port)| format!("https://{}:{}", host, port)) } diff --git a/src-tauri/src/router/provider_router.rs b/src-tauri/src/router/provider_router.rs index c302eb951..a1454fd3a 100644 --- a/src-tauri/src/router/provider_router.rs +++ b/src-tauri/src/router/provider_router.rs @@ -101,22 +101,16 @@ impl ProviderRouter { // /{selector}/v1/messages [selector, "v1", "messages"] => { let registry = self.registry.read().await; - let route = registry - .find_by_selector(selector) - .cloned() - .unwrap_or_else(|| { - // 创建一个临时的选择器路由 - RegisteredRoute { - path_pattern: format!("/{}/v1/messages", selector), - route_type: RouteType::CredentialSelector, - provider_type: None, - credential_uuid: None, - credential_name: Some(selector.to_string()), - protocols: vec!["claude".to_string()], - enabled: true, - priority: 50, - } - }); + let route = registry.find_by_selector(selector).cloned(); + + // 安全修复:未注册的 selector 不创建临时路由,直接返回 None + let route = match route { + Some(r) => r, + None => { + tracing::warn!("[ROUTER] 未注册的 selector: {},拒绝请求", selector); + return None; + } + }; Some(RouteMatch { route, @@ -128,19 +122,16 @@ impl ProviderRouter { // /{selector}/v1/chat/completions [selector, "v1", "chat", "completions"] => { let registry = self.registry.read().await; - let route = registry - .find_by_selector(selector) - .cloned() - .unwrap_or_else(|| RegisteredRoute { - path_pattern: format!("/{}/v1/chat/completions", selector), - route_type: RouteType::CredentialSelector, - provider_type: None, - credential_uuid: None, - credential_name: Some(selector.to_string()), - protocols: vec!["openai".to_string()], - enabled: true, - priority: 50, - }); + let route = registry.find_by_selector(selector).cloned(); + + // 安全修复:未注册的 selector 不创建临时路由,直接返回 None + let route = match route { + Some(r) => r, + None => { + tracing::warn!("[ROUTER] 未注册的 selector: {},拒绝请求", selector); + return None; + } + }; Some(RouteMatch { route, @@ -231,6 +222,11 @@ mod tests { let registry = Arc::new(RwLock::new(RouteRegistry::new())); let router = ProviderRouter::new(registry); + // 安全修复后,未注册的 selector 会返回 None,需要先注册 + router + .register_credential("kiro", "uuid-selector-test", Some("my-kiro")) + .await; + let match1 = router.resolve("/my-kiro/v1/messages").await.unwrap(); assert_eq!(match1.protocol, "claude"); assert_eq!(match1.selector, Some("my-kiro".to_string())); diff --git a/src-tauri/src/server/handlers/management.rs b/src-tauri/src/server/handlers/management.rs index 2ca09fb80..4e751e249 100644 --- a/src-tauri/src/server/handlers/management.rs +++ b/src-tauri/src/server/handlers/management.rs @@ -383,6 +383,7 @@ pub async fn management_add_credential( if let Some(token_file) = request.token_file { CredentialData::CodexOAuth { creds_file_path: token_file, + api_base_url: request.base_url.clone(), } } else { return ( diff --git a/src-tauri/src/services/backup_service.rs b/src-tauri/src/services/backup_service.rs new file mode 100644 index 000000000..f79af51ae --- /dev/null +++ b/src-tauri/src/services/backup_service.rs @@ -0,0 +1,143 @@ +//! 备份服务 +//! +//! 提供数据库与配置备份的基础能力 + +use crate::database::{get_db_path, DbConnection}; +use chrono::{DateTime, Duration, Utc}; +use rusqlite::DatabaseName; +use std::path::{Path, PathBuf}; + +#[derive(Clone)] +pub struct BackupService { + backup_dir: PathBuf, + retention_days: u32, +} + +impl BackupService { + pub fn new(backup_dir: PathBuf, retention_days: u32) -> Result { + std::fs::create_dir_all(&backup_dir) + .map_err(|e| format!("无法创建备份目录 {:?}: {}", backup_dir, e))?; + Ok(Self { + backup_dir, + retention_days, + }) + } + + pub fn with_defaults() -> Result { + let home = dirs::home_dir().ok_or_else(|| "无法获取主目录".to_string())?; + let backup_dir = home.join(".proxycast").join("backups"); + Self::new(backup_dir, 7) + } + + pub fn backup_database(&self) -> Result { + let db_path = get_db_path()?; + let timestamp = Utc::now().format("%Y%m%d_%H%M%S"); + let backup_path = self.backup_dir.join(format!("proxycast_{}.db", timestamp)); + + std::fs::copy(&db_path, &backup_path).map_err(|e| format!("备份失败: {}", e))?; + + self.cleanup_old_backups()?; + Ok(backup_path) + } + + pub fn backup_database_with_connection(&self, db: &DbConnection) -> Result { + let timestamp = Utc::now().format("%Y%m%d_%H%M%S"); + let backup_path = self.backup_dir.join(format!("proxycast_{}.db", timestamp)); + let conn = db.lock().map_err(|_| "数据库锁已被占用".to_string())?; + let progress: Option = None; + conn.backup(DatabaseName::Main, &backup_path, progress) + .map_err(|e| format!("备份失败: {}", e))?; + + self.cleanup_old_backups()?; + Ok(backup_path) + } + + pub fn restore_database(&self, backup_path: &Path) -> Result<(), String> { + // P1 安全修复:验证备份路径在白名单目录内 + let canonical_backup = backup_path + .canonicalize() + .map_err(|e| format!("无法解析备份路径: {}", e))?; + let canonical_backup_dir = self + .backup_dir + .canonicalize() + .map_err(|e| format!("无法解析备份目录: {}", e))?; + + if !canonical_backup.starts_with(&canonical_backup_dir) { + return Err("安全限制:只能从备份目录恢复数据库".to_string()); + } + + if !backup_path.exists() { + return Err("备份文件不存在".to_string()); + } + let db_path = get_db_path()?; + std::fs::copy(backup_path, db_path).map_err(|e| format!("恢复失败: {}", e))?; + Ok(()) + } + + pub fn restore_database_with_connection( + &self, + db: &DbConnection, + backup_path: &Path, + ) -> Result<(), String> { + // P1 安全修复:验证备份路径在白名单目录内 + let canonical_backup = backup_path + .canonicalize() + .map_err(|e| format!("无法解析备份路径: {}", e))?; + let canonical_backup_dir = self + .backup_dir + .canonicalize() + .map_err(|e| format!("无法解析备份目录: {}", e))?; + + if !canonical_backup.starts_with(&canonical_backup_dir) { + return Err("安全限制:只能从备份目录恢复数据库".to_string()); + } + + if !backup_path.exists() { + return Err("备份文件不存在".to_string()); + } + let mut conn = db.lock().map_err(|_| "数据库锁已被占用".to_string())?; + let progress: Option = None; + conn.restore(DatabaseName::Main, backup_path, progress) + .map_err(|e| format!("恢复失败: {}", e))?; + Ok(()) + } + + pub fn list_backups(&self) -> Result, String> { + let mut backups = Vec::new(); + let entries = + std::fs::read_dir(&self.backup_dir).map_err(|e| format!("无法读取备份目录: {}", e))?; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map(|e| e == "db").unwrap_or(false) { + backups.push(path); + } + } + backups.sort(); + Ok(backups) + } + + pub fn cleanup_old_backups(&self) -> Result<(), String> { + let entries = + std::fs::read_dir(&self.backup_dir).map_err(|e| format!("无法读取备份目录: {}", e))?; + let cutoff = Utc::now() - Duration::days(self.retention_days as i64); + + for entry in entries.flatten() { + let path = entry.path(); + let Ok(metadata) = entry.metadata() else { + continue; + }; + let Ok(modified) = metadata.modified() else { + continue; + }; + let modified = DateTime::::from(modified); + if modified < cutoff { + let _ = std::fs::remove_file(path); + } + } + Ok(()) + } + + pub fn backup_dir(&self) -> &PathBuf { + &self.backup_dir + } +} diff --git a/src-tauri/src/services/mcp_sync.rs b/src-tauri/src/services/mcp_sync.rs index ccc3b0715..e3ce4ce61 100644 --- a/src-tauri/src/services/mcp_sync.rs +++ b/src-tauri/src/services/mcp_sync.rs @@ -2,6 +2,23 @@ use crate::models::{AppType, McpServer}; use serde_json::{json, Map, Value}; use std::path::PathBuf; +/// P0 安全修复:校验 TOML 键名是否合法(仅允许字母、数字、下划线和连字符) +fn is_valid_toml_key(key: &str) -> bool { + !key.is_empty() + && key + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +/// P0 安全修复:转义 TOML 字符串值中的特殊字符 +fn escape_toml_string(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t") +} + /// Get the MCP config file path for an app type #[allow(dead_code)] pub fn get_mcp_config_path(app_type: &AppType) -> Option { @@ -136,20 +153,30 @@ fn sync_mcp_to_codex( // Add new MCP server sections - use name as key for server in servers { + // P0 安全修复:校验 server.name 防止 TOML 注入 + if !is_valid_toml_key(&server.name) { + tracing::warn!( + "[MCP Sync] 跳过无效的服务器名称: {} (仅允许字母、数字、下划线和连字符)", + server.name + ); + continue; + } + new_lines.push(String::new()); new_lines.push(format!("[mcp_servers.{}]", server.name)); if let Some(config) = server.server_config.as_object() { // Convert JSON config to TOML format if let Some(command) = config.get("command").and_then(|v| v.as_str()) { - new_lines.push(format!("command = \"{command}\"")); + // P0 安全修复:转义 TOML 字符串值 + new_lines.push(format!("command = \"{}\"", escape_toml_string(command))); } if let Some(args) = config.get("args").and_then(|v| v.as_array()) { let args_str: Vec = args .iter() .filter_map(|a| a.as_str()) - .map(|s| format!("\"{s}\"")) + .map(|s| format!("\"{}\"", escape_toml_string(s))) .collect(); new_lines.push(format!("args = [{}]", args_str.join(", "))); } @@ -157,8 +184,13 @@ fn sync_mcp_to_codex( if let Some(env) = config.get("env").and_then(|v| v.as_object()) { new_lines.push("[mcp_servers.".to_string() + &server.name + ".env]"); for (key, value) in env { + // P0 安全修复:校验 env key 并转义值 + if !is_valid_toml_key(key) { + tracing::warn!("[MCP Sync] 跳过无效的环境变量名: {}", key); + continue; + } if let Some(val) = value.as_str() { - new_lines.push(format!("{key} = \"{val}\"")); + new_lines.push(format!("{} = \"{}\"", key, escape_toml_string(val))); } } } @@ -532,3 +564,72 @@ pub fn import_mcp_from_app( AppType::ProxyCast => Ok(Vec::new()), } } + +#[cfg(test)] +mod tests { + use super::{escape_toml_string, is_valid_toml_key}; + + #[test] + fn test_valid_toml_key_accepts_alphanumeric() { + assert!(is_valid_toml_key("abc")); + assert!(is_valid_toml_key("ABC123")); + assert!(is_valid_toml_key("test_server")); + assert!(is_valid_toml_key("my-server")); + assert!(is_valid_toml_key("server_1-test")); + } + + #[test] + fn test_valid_toml_key_rejects_invalid() { + // 含 ] 的注入尝试 + assert!(!is_valid_toml_key("bad]")); + assert!(!is_valid_toml_key("bad]\n[evil]")); + // 含换行 + assert!(!is_valid_toml_key("bad\nkey")); + // 含空格 + assert!(!is_valid_toml_key("bad key")); + // 含点号 + assert!(!is_valid_toml_key("bad.key")); + // 空字符串 + assert!(!is_valid_toml_key("")); + // 含特殊字符 + assert!(!is_valid_toml_key("bad=key")); + assert!(!is_valid_toml_key("bad[key")); + } + + #[test] + fn test_escape_toml_string_backslash() { + assert_eq!(escape_toml_string(r"path\to\file"), r"path\\to\\file"); + } + + #[test] + fn test_escape_toml_string_quote() { + assert_eq!(escape_toml_string(r#"say "hello""#), r#"say \"hello\""#); + } + + #[test] + fn test_escape_toml_string_newline() { + assert_eq!(escape_toml_string("line1\nline2"), r"line1\nline2"); + } + + #[test] + fn test_escape_toml_string_carriage_return() { + assert_eq!(escape_toml_string("line1\rline2"), r"line1\rline2"); + } + + #[test] + fn test_escape_toml_string_tab() { + assert_eq!(escape_toml_string("col1\tcol2"), r"col1\tcol2"); + } + + #[test] + fn test_escape_toml_string_combined() { + let input = "path\\to\\file\nwith \"quotes\"\tand\rtabs"; + let output = escape_toml_string(input); + assert!(!output.contains('\n')); + assert!(!output.contains('\r')); + assert!(!output.contains('\t')); + assert!(output.contains("\\n")); + assert!(output.contains("\\r")); + assert!(output.contains("\\t")); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 07768fb05..2cd31f0d4 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,3 +1,4 @@ +pub mod backup_service; pub mod live_sync; pub mod mcp_service; pub mod mcp_sync; diff --git a/src-tauri/src/services/provider_pool_service.rs b/src-tauri/src/services/provider_pool_service.rs index 465758d9d..ab669f31e 100644 --- a/src-tauri/src/services/provider_pool_service.rs +++ b/src-tauri/src/services/provider_pool_service.rs @@ -487,8 +487,12 @@ impl ProviderPoolService { self.check_gemini_api_key_health(api_key, base_url.as_deref(), model) .await } - CredentialData::CodexOAuth { creds_file_path } => { - self.check_codex_health(creds_file_path, model).await + CredentialData::CodexOAuth { + creds_file_path, + api_base_url, + } => { + self.check_codex_health(creds_file_path, api_base_url.as_deref(), model) + .await } CredentialData::ClaudeOAuth { creds_file_path } => { self.check_claude_oauth_health(creds_file_path, model).await @@ -924,7 +928,13 @@ impl ProviderPoolService { } // Codex 健康检查 - async fn check_codex_health(&self, creds_path: &str, model: &str) -> Result<(), String> { + // 支持 Yunyi 等代理使用 responses API 格式 + async fn check_codex_health( + &self, + creds_path: &str, + override_base_url: Option<&str>, + model: &str, + ) -> Result<(), String> { use crate::providers::codex::CodexProvider; let mut provider = CodexProvider::new(); @@ -933,33 +943,85 @@ impl ProviderPoolService { .await .map_err(|e| format!("加载 Codex 凭证失败: {}", e))?; - let token = provider - .ensure_valid_token() - .await - .map_err(|e| format!("获取 Codex Token 失败: {}", e))?; + let token = provider.ensure_valid_token().await.map_err(|e| { + format!( + "获取 Codex Token 失败: 配置错误,请检查凭证设置。详情:{}", + e + ) + })?; - // 使用 OpenAI 兼容 API 进行健康检查 - let url = "https://api.openai.com/v1/chat/completions"; - let request_body = serde_json::json!({ - "model": model, - "messages": [{"role": "user", "content": "Say OK"}], - "max_tokens": 10 - }); + // 优先使用 override_base_url(来自 CredentialData),其次使用凭证文件中的配置 + let base_url = override_base_url + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .or_else(|| { + provider + .credentials + .api_base_url + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + }); - let response = self - .client - .post(url) - .header("Authorization", format!("Bearer {}", token)) - .json(&request_body) - .timeout(self.health_check_timeout) - .send() - .await - .map_err(|e| format!("请求失败: {}", e))?; + match base_url { + Some(base) => { + // 使用自定义 base_url (如 Yunyi),与 CodexProvider 的 URL/headers 行为保持一致 + let url = CodexProvider::build_responses_url(base); - if response.status().is_success() { - Ok(()) - } else { - Err(format!("HTTP {}", response.status())) + // Codex/Yunyi 使用 responses API 格式;云驿等代理要求 stream 必须为 true + let request_body = serde_json::json!({ + "model": model, + "input": [{ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Say OK"}] + }], + "max_output_tokens": 10, + "stream": true + }); + + tracing::debug!( + "[HEALTH_CHECK] Codex responses API URL: {}, model: {}", + url, + model + ); + + let response = self + .client + .post(&url) + .bearer_auth(&token) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream") + .header("Openai-Beta", "responses=experimental") + .header("Originator", "codex_cli_rs") + .header("Session_id", uuid::Uuid::new_v4().to_string()) + .header("Conversation_id", uuid::Uuid::new_v4().to_string()) + .header( + "User-Agent", + "codex_cli_rs/0.77.0 (ProxyCast health check; Mac OS; arm64)", + ) + .json(&request_body) + .timeout(self.health_check_timeout) + .send() + .await + .map_err(|e| format!("请求失败: {}", e))?; + + if response.status().is_success() { + Ok(()) + } else { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + Err(format!( + "HTTP {} - {}", + status, + body.chars().take(200).collect::() + )) + } + } + None => { + // 没有自定义 base_url,使用 OpenAI 官方 chat/completions API + self.check_openai_health(&token, None, model).await + } } } @@ -1165,12 +1227,20 @@ impl ProviderPoolService { let creds: serde_json::Value = serde_json::from_str(&content).map_err(|e| format!("解析凭证文件失败: {}", e))?; - let has_access_token = creds + let has_api_key = creds + .get("apiKey") + .or_else(|| creds.get("api_key")) + .map(|v| v.as_str().is_some()) + .unwrap_or(false); + + let has_oauth_access_token = creds .get("accessToken") .or_else(|| creds.get("access_token")) .map(|v| v.as_str().is_some()) .unwrap_or(false); + let has_access_token = has_oauth_access_token || has_api_key; + let has_refresh_token = creds .get("refreshToken") .or_else(|| creds.get("refresh_token")) @@ -1201,6 +1271,20 @@ impl ProviderPoolService { (has_access_token, None) } } + "codex" => { + // Codex: 兼容 OAuth token 或 Codex CLI 的 API Key 登录 + if has_api_key { + (true, None) + } else { + let expires_at = creds + .get("expiresAt") + .or_else(|| creds.get("expires_at")) + .or_else(|| creds.get("expired")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + (has_oauth_access_token, expires_at) + } + } _ => (has_access_token, None), }; diff --git a/src-tauri/src/services/token_cache_service.rs b/src-tauri/src/services/token_cache_service.rs index ccab11b5d..927e551fa 100644 --- a/src-tauri/src/services/token_cache_service.rs +++ b/src-tauri/src/services/token_cache_service.rs @@ -282,9 +282,9 @@ impl TokenCacheService { last_refresh_error: None, }) } - CredentialData::CodexOAuth { creds_file_path } => { - self.refresh_codex(creds_file_path).await - } + CredentialData::CodexOAuth { + creds_file_path, .. + } => self.refresh_codex(creds_file_path).await, CredentialData::ClaudeOAuth { creds_file_path } => { self.refresh_claude_oauth(creds_file_path).await } @@ -764,7 +764,9 @@ impl TokenCacheService { refresh_error_count: 0, last_refresh_error: None, }), - CredentialData::CodexOAuth { creds_file_path } => { + CredentialData::CodexOAuth { + creds_file_path, .. + } => { let content = tokio::fs::read_to_string(creds_file_path) .await .map_err(|e| format!("读取 Codex 凭证文件失败: {}", e))?; diff --git a/src-tauri/src/websocket/handler.rs b/src-tauri/src/websocket/handler.rs index adc5d3568..089a8f841 100644 --- a/src-tauri/src/websocket/handler.rs +++ b/src-tauri/src/websocket/handler.rs @@ -126,6 +126,22 @@ async fn handle_socket(socket: WebSocket, state: WsHandlerState, client_info: Op while let Some(msg) = receiver.next().await { match msg { Ok(Message::Text(text)) => { + // P1 安全修复:限制消息大小防止 DoS + const MAX_MESSAGE_SIZE: usize = 10 * 1024 * 1024; // 10MB + if text.len() > MAX_MESSAGE_SIZE { + state.manager.on_error(); + let error = WsMessage::Error(WsError::invalid_message(format!( + "Message too large: {} bytes (max: {} bytes)", + text.len(), + MAX_MESSAGE_SIZE + ))); + let error_text = serde_json::to_string(&error).unwrap_or_default(); + if sender.send(Message::Text(error_text.into())).await.is_err() { + break; + } + continue; + } + state.manager.on_message(); state.manager.increment_request_count(&conn_id); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 08c61984e..9bfe51501 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -21,7 +21,7 @@ } ], "security": { - "csp": null + "csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:* http://[::1]:* ws://[::1]:*; frame-src 'none'; object-src 'none'" } }, "bundle": { diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 9196f9727..534972239 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -153,7 +153,7 @@ export function Dashboard() { const serverUrl = status ? `http://${status.host}:${status.port}` - : "http://localhost:3001"; + : "http://localhost:8999"; const getProviderName = (id: string) => { switch (id) { diff --git a/src/components/api-server/ApiServerPage.tsx b/src/components/api-server/ApiServerPage.tsx index 24e0a28db..72d09685a 100644 --- a/src/components/api-server/ApiServerPage.tsx +++ b/src/components/api-server/ApiServerPage.tsx @@ -148,7 +148,7 @@ export function ApiServerPage() { ...config, server: { ...config.server, - port: parseInt(editPort) || 3001, + port: parseInt(editPort) || 8999, api_key: editApiKey, }, }; @@ -236,8 +236,8 @@ export function ApiServerPage() { const serverUrl = status ? `http://${status.host}:${status.port}` - : "http://localhost:3001"; - const apiKey = config?.server.api_key || "proxycast-key"; + : `http://localhost:${config?.server.port ?? 8999}`; + const apiKey = config?.server.api_key ?? ""; // 根据 Provider 类型获取测试模型 const getTestModel = (provider: string): string => { diff --git a/src/components/clients/ProviderForm.tsx b/src/components/clients/ProviderForm.tsx index ed67f76a4..e370b6a97 100644 --- a/src/components/clients/ProviderForm.tsx +++ b/src/components/clients/ProviderForm.tsx @@ -142,7 +142,7 @@ const presets: Record = { name: "ProxyCast", category: "proxy", iconColor: "#3b82f6", - defaultBaseUrl: "http://127.0.0.1:3001", + defaultBaseUrl: "http://127.0.0.1:8999", }, // 从凭证池导入 { @@ -192,7 +192,7 @@ model = "gpt-4o" iconColor: "#3b82f6", defaultCodexAuth: { api_key: "proxycast", - api_base_url: "http://127.0.0.1:3001/v1", + api_base_url: "http://127.0.0.1:8999/v1", }, }, // 从凭证池导入 @@ -228,7 +228,7 @@ model = "gpt-4o" iconColor: "#3b82f6", defaultGeminiEnv: { GEMINI_API_KEY: "proxycast", - GOOGLE_GEMINI_BASE_URL: "http://127.0.0.1:3001", + GOOGLE_GEMINI_BASE_URL: "http://127.0.0.1:8999", GEMINI_MODEL: "gemini-2.0-flash", }, }, @@ -562,7 +562,7 @@ export function ProviderForm({ const config = await getConfig(); const proxyApiKey = config.server.api_key || ""; const proxyHost = config.server.host || "127.0.0.1"; - const proxyPort = config.server.port || 3001; + const proxyPort = config.server.port || 8999; const proxyBaseUrl = `http://${proxyHost}:${proxyPort}`; if (appType === "claude") { @@ -681,7 +681,7 @@ export function ProviderForm({ const config = await getConfig(); const proxyApiKey = config.server.api_key || ""; const proxyHost = config.server.host || "127.0.0.1"; - const proxyPort = config.server.port || 3001; + const proxyPort = config.server.port || 8999; const proxyBaseUrl = `http://${proxyHost}:${proxyPort}`; if (appType === "claude") { diff --git a/src/components/provider-pool/AddCredentialModal.tsx b/src/components/provider-pool/AddCredentialModal.tsx index d5c35f187..3919181e4 100644 --- a/src/components/provider-pool/AddCredentialModal.tsx +++ b/src/components/provider-pool/AddCredentialModal.tsx @@ -35,6 +35,7 @@ export function AddCredentialModal({ defaultCredsPath[providerType] || "", ); const [projectId, setProjectId] = useState(""); + const [apiBaseUrl, setApiBaseUrl] = useState(""); // API Key 字段 const [apiKey, setApiKey] = useState(""); @@ -77,6 +78,8 @@ export function AddCredentialModal({ name, credsFilePath, setCredsFilePath, + apiBaseUrl, + setApiBaseUrl, onSelectFile: handleSelectFile, loading, setLoading, diff --git a/src/components/provider-pool/EditCredentialModal.tsx b/src/components/provider-pool/EditCredentialModal.tsx index 391975926..18fa230bf 100644 --- a/src/components/provider-pool/EditCredentialModal.tsx +++ b/src/components/provider-pool/EditCredentialModal.tsx @@ -53,7 +53,7 @@ const providerModels: Record = { ], openai: [], // 自定义 API,无预设模型 claude: [], // 自定义 API,无预设模型 - codex: ["gpt-4o", "gpt-4o-mini", "o1", "o1-mini", "o3-mini"], // Codex OAuth + codex: ["gpt-4o", "gpt-4o-mini", "o1", "o1-mini", "o3-mini"], // Codex(OAuth / API Key) claude_oauth: [ "claude-3-5-sonnet-latest", "claude-3-5-haiku-latest", diff --git a/src/components/provider-pool/ProviderPoolPage.tsx b/src/components/provider-pool/ProviderPoolPage.tsx index 976bc761c..4e984035b 100644 --- a/src/components/provider-pool/ProviderPoolPage.tsx +++ b/src/components/provider-pool/ProviderPoolPage.tsx @@ -56,7 +56,7 @@ const providerLabels: Record = { antigravity: "Antigravity (Gemini 3 Pro)", openai: "OpenAI", claude: "Claude (Anthropic)", - codex: "Codex (OpenAI OAuth)", + codex: "Codex (OAuth / API Key)", claude_oauth: "Claude OAuth", iflow: "iFlow", }; diff --git a/src/components/provider-pool/credential-forms/CodexForm.tsx b/src/components/provider-pool/credential-forms/CodexForm.tsx index 94f3c1846..b22327a10 100644 --- a/src/components/provider-pool/credential-forms/CodexForm.tsx +++ b/src/components/provider-pool/credential-forms/CodexForm.tsx @@ -14,6 +14,8 @@ interface CodexFormProps { name: string; credsFilePath: string; setCredsFilePath: (path: string) => void; + apiBaseUrl: string; + setApiBaseUrl: (url: string) => void; onSelectFile: () => void; loading: boolean; setLoading: (loading: boolean) => void; @@ -25,6 +27,8 @@ export function CodexForm({ name, credsFilePath, setCredsFilePath, + apiBaseUrl, + setApiBaseUrl, onSelectFile, loading: _loading, setLoading, @@ -87,7 +91,12 @@ export function CodexForm({ try { const trimmedName = name.trim() || undefined; - await providerPoolApi.addCodexOAuth(credsFilePath, trimmedName); + const trimmedUrl = apiBaseUrl.trim() || undefined; + await providerPoolApi.addCodexOAuth( + credsFilePath, + trimmedUrl, + trimmedName, + ); onSuccess(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -130,13 +139,33 @@ export function CodexForm({ /> ) : ( - +
+ + + {/* API Base URL 输入框 */} +
+ + setApiBaseUrl(e.target.value)} + placeholder="https://yunyi.cfd/codex" + className="w-full rounded-md border bg-background px-3 py-2 text-sm" + /> +

+ 云驿代理默认: + https://yunyi.cfd/codex(留空则使用凭证文件中的配置) +

+
+
)} ), diff --git a/src/components/settings/ProxySettings.tsx b/src/components/settings/ProxySettings.tsx index fee178288..c21d25f9c 100644 --- a/src/components/settings/ProxySettings.tsx +++ b/src/components/settings/ProxySettings.tsx @@ -173,7 +173,7 @@ export function ProxySettings() { ...config, server: { ...config.server, - port: parseInt(e.target.value) || 3001, + port: parseInt(e.target.value) || 8999, }, }) } diff --git a/src/components/settings/RemoteManagementSettings.tsx b/src/components/settings/RemoteManagementSettings.tsx index bb3865cbd..866f6a33a 100644 --- a/src/components/settings/RemoteManagementSettings.tsx +++ b/src/components/settings/RemoteManagementSettings.tsx @@ -70,12 +70,14 @@ export function RemoteManagementSettings() { }; const generateSecretKey = () => { - const chars = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let key = ""; - for (let i = 0; i < 32; i++) { - key += chars.charAt(Math.floor(Math.random() * chars.length)); - } + // 安全修复:使用 WebCrypto API 生成安全随机密钥 + const array = new Uint8Array(32); + crypto.getRandomValues(array); + // 转换为 base64url 格式(URL 安全的 base64) + const key = btoa(String.fromCharCode(...array)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); updateRemoteManagement({ secret_key: key }); }; @@ -88,7 +90,13 @@ export function RemoteManagementSettings() { } const rm = config.remote_management; - const isEnabled = rm.secret_key && rm.secret_key.length > 0; + const isEnabled = Boolean(rm.secret_key && rm.secret_key.length > 0); + const remoteAccessSupported = false; + const allowRemoteToggleEnabled = remoteAccessSupported && isEnabled; + const allowRemoteToggleDisabled = + !allowRemoteToggleEnabled && !rm.allow_remote; + const remoteAccessUnsupportedEnabled = + rm.allow_remote && !remoteAccessSupported; return (
@@ -121,6 +129,13 @@ export function RemoteManagementSettings() { )}
+ {!remoteAccessSupported && ( +
+ + 当前版本未启用 TLS,暂不支持远程管理访问,请保持关闭。 +
+ )} + {/* 管理密钥 */}
@@ -179,7 +194,7 @@ export function RemoteManagementSettings() { {/* 允许远程访问 */}