mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
+20
-17
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -97,8 +97,8 @@ jobs:
|
||||
```
|
||||
|
||||
### 默认配置
|
||||
- **端口**: 3001
|
||||
- **API Key**: proxycast-key
|
||||
- **端口**: 8999
|
||||
- **API Key**: 首次启动自动生成,可在设置页查看/修改
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
args: --target ${{ matrix.target }}
|
||||
|
||||
@@ -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",
|
||||
|
||||
+4
-4
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 '{
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
@@ -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 配置,再启动应用。
|
||||
|
||||
## 配置同步
|
||||
|
||||
### 跨设备同步
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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 '{
|
||||
|
||||
@@ -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 过期前刷新:
|
||||
|
||||
@@ -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`
|
||||
|
||||
可在设置中修改主机和端口。
|
||||
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
获取服务器状态信息。
|
||||
|
||||
@@ -30,10 +30,10 @@ navigation:
|
||||
```bash
|
||||
# 查找占用端口的进程
|
||||
# macOS/Linux
|
||||
lsof -i :9090
|
||||
lsof -i :8999
|
||||
|
||||
# Windows
|
||||
netstat -ano | findstr :9090
|
||||
netstat -ano | findstr :8999
|
||||
```
|
||||
|
||||
或在设置中更改端口号。
|
||||
|
||||
@@ -152,7 +152,7 @@ export NO_PROXY=localhost,127.0.0.1
|
||||
| 端口 | 用途 |
|
||||
|------|------|
|
||||
| 443 | HTTPS 请求 |
|
||||
| 9090 | ProxyCast API(默认) |
|
||||
| 8999 | ProxyCast API(默认) |
|
||||
|
||||
### macOS 防火墙
|
||||
|
||||
|
||||
@@ -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`
|
||||
@@ -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 '{
|
||||
|
||||
+87
@@ -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 启用后会对失败认证进行短期限制,避免暴力尝试。
|
||||
- 建议仅在内网使用,并配合独立强密钥。
|
||||
Generated
+20
-2
@@ -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",
|
||||
|
||||
Generated
+13
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ pub fn get_config_status(app_type: String) -> Result<ConfigStatus, String> {
|
||||
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<ConfigStatus, String> {
|
||||
}
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
// 复制并重命名文件到应用存储目录
|
||||
@@ -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<String, String> {
|
||||
use crate::providers::kiro::KiroProvider;
|
||||
@@ -884,31 +888,15 @@ pub async fn debug_kiro_credentials() -> Result<String, String> {
|
||||
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<String, String> {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// P0 安全修复:release 构建中禁用 debug 命令
|
||||
#[cfg(not(debug_assertions))]
|
||||
#[tauri::command]
|
||||
pub async fn debug_kiro_credentials() -> Result<String, String> {
|
||||
Err("此调试命令仅在开发构建中可用".to_string())
|
||||
}
|
||||
|
||||
/// 测试用户上传的凭证文件
|
||||
/// P0 安全修复:仅在 debug 构建中可用,且不输出敏感信息
|
||||
#[cfg(debug_assertions)]
|
||||
#[tauri::command]
|
||||
pub async fn test_user_credentials() -> Result<String, String> {
|
||||
use crate::providers::kiro::KiroProvider;
|
||||
@@ -938,7 +935,8 @@ pub async fn test_user_credentials() -> Result<String, String> {
|
||||
"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<String, String> {
|
||||
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::<serde_json::Value>(&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<String, String> {
|
||||
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<String, String> {
|
||||
"✅ 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<String, String> {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// P0 安全修复:release 构建中禁用 test_user_credentials 命令
|
||||
#[cfg(not(debug_assertions))]
|
||||
#[tauri::command]
|
||||
pub async fn test_user_credentials() -> Result<String, String> {
|
||||
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),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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::<std::net::IpAddr>()
|
||||
.map(|addr| addr.is_loopback())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 热重载状态
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct HotReloadStatus {
|
||||
|
||||
@@ -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());
|
||||
// 凭证池中脱敏的条目应被移除
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ use tempfile::NamedTempFile;
|
||||
fn arb_host() -> impl Strategy<Value = String> {
|
||||
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 密钥应被清空"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Config, Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
// 主配置优先写入 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<dyn std::error::Error
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
if path.exists() {
|
||||
let backup_path = path.with_extension("yaml.backup");
|
||||
let _ = std::fs::copy(&path, &backup_path);
|
||||
}
|
||||
let content = serde_yaml::to_string(config)?;
|
||||
std::fs::write(&path, content)?;
|
||||
Ok(())
|
||||
@@ -708,7 +751,7 @@ mod unit_tests {
|
||||
fn test_parse_yaml_minimal() {
|
||||
let yaml = r#"
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
host: "127.0.0.1"
|
||||
port: 9000
|
||||
api_key: "test-key"
|
||||
providers:
|
||||
@@ -716,7 +759,7 @@ providers:
|
||||
enabled: true
|
||||
"#;
|
||||
let config = ConfigManager::parse_yaml(yaml).unwrap();
|
||||
assert_eq!(config.server.host, "0.0.0.0");
|
||||
assert_eq!(config.server.host, "127.0.0.1");
|
||||
assert_eq!(config.server.port, 9000);
|
||||
assert_eq!(config.server.api_key, "test-key");
|
||||
assert!(config.providers.kiro.enabled);
|
||||
|
||||
@@ -326,8 +326,10 @@ pub fn convert_openai_to_codewhisperer(
|
||||
CWTool {
|
||||
tool_specification: ToolSpecification {
|
||||
name: t.function.name.clone(),
|
||||
// P1 安全修复:使用字符边界安全的截断,防止 UTF-8 panic
|
||||
description: if desc.len() > 500 {
|
||||
format!("{}...", &desc[..497])
|
||||
let truncated: String = desc.chars().take(497).collect();
|
||||
format!("{}...", truncated)
|
||||
} else {
|
||||
desc
|
||||
},
|
||||
|
||||
@@ -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<dyn std::error::Error + Send + Sync>> {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -9,20 +9,22 @@ use std::sync::{Arc, Mutex};
|
||||
pub type DbConnection = Arc<Mutex<Connection>>;
|
||||
|
||||
/// 获取数据库文件路径
|
||||
pub fn get_db_path() -> PathBuf {
|
||||
let home = dirs::home_dir().expect("Cannot find home directory");
|
||||
pub fn get_db_path() -> Result<PathBuf, String> {
|
||||
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<DbConnection, rusqlite::Error> {
|
||||
let db_path = get_db_path();
|
||||
let conn = Connection::open(&db_path)?;
|
||||
pub fn init_database() -> Result<DbConnection, String> {
|
||||
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)))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+89
-9
@@ -200,6 +200,10 @@ mod tests {
|
||||
pub type AppState = Arc<RwLock<server::ServerState>>;
|
||||
pub type LogState = Arc<RwLock<logger::LogStore>>;
|
||||
|
||||
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<Vec<EnvV
|
||||
let creds = &s.kiro_provider.credentials;
|
||||
let mut vars = Vec::new();
|
||||
|
||||
// P0 安全修复:不再返回明文敏感凭证,仅返回 masked 版本
|
||||
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),
|
||||
});
|
||||
}
|
||||
@@ -1375,12 +1394,57 @@ async fn test_api(
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let config = config::load_config().unwrap_or_default();
|
||||
let state: AppState = Arc::new(RwLock::new(server::ServerState::new(config)));
|
||||
let logs: LogState = Arc::new(RwLock::new(logger::LogStore::new()));
|
||||
let mut config = match config::load_config() {
|
||||
Ok(cfg) => 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::<std::net::IpAddr>() {
|
||||
Ok(addr) => addr.is_loopback(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
+276
-10
@@ -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<LogEntry>,
|
||||
max_logs: usize,
|
||||
config: LogStoreConfig,
|
||||
log_file_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
@@ -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::<Utc>::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::<Utc>::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<RwLock<LogStore>>;
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Instant>,
|
||||
last_access: Instant,
|
||||
}
|
||||
|
||||
fn failure_map() -> &'static Mutex<std::collections::HashMap<String, FailureState>> {
|
||||
static FAILURES: std::sync::OnceLock<Mutex<std::collections::HashMap<String, FailureState>>> =
|
||||
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<S> ManagementAuthService<S> {
|
||||
.get::<axum::extract::ConnectInfo<SocketAddr>>()
|
||||
.map(|ci| ci.0)
|
||||
}
|
||||
|
||||
fn get_client_id(req: &Request<Body>) -> 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<S> Service<Request<Body>> for ManagementAuthService<S>
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Body> {
|
||||
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<Body> {
|
||||
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,
|
||||
|
||||
@@ -72,7 +72,12 @@ pub enum CredentialData {
|
||||
excluded_models: Vec<String>,
|
||||
},
|
||||
/// Codex OAuth 凭证(OpenAI Codex)
|
||||
CodexOAuth { creds_file_path: String },
|
||||
CodexOAuth {
|
||||
creds_file_path: String,
|
||||
/// API Base URL(可选,默认使用凭证文件中的配置)
|
||||
#[serde(default)]
|
||||
api_base_url: Option<String>,
|
||||
},
|
||||
/// 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<String> {
|
||||
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()),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -58,6 +58,8 @@ pub struct RequestProcessor {
|
||||
pub tokens: Arc<ParkingLotRwLock<TokenTracker>>,
|
||||
/// 凭证池服务
|
||||
pub pool_service: Arc<ProviderPoolService>,
|
||||
/// 热重载协调锁(避免配置更新期间请求读取不一致的配置)
|
||||
pub reload_lock: Arc<RwLock<()>>,
|
||||
}
|
||||
|
||||
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(())),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())),
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// API Base URL(可选)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", alias = "apiBaseUrl")]
|
||||
pub api_base_url: Option<String>,
|
||||
/// OpenAI account identifier
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", alias = "accountId")]
|
||||
pub account_id: Option<String>,
|
||||
@@ -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<String>,
|
||||
@@ -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<dyn Error + Send + Sync>> {
|
||||
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::<serde_json::Value>(&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<String, Box<dyn Error + Send + Sync>> {
|
||||
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<String, Box<dyn Error + Send + Sync>> {
|
||||
// 兼容 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<String, Box<dyn Error + Send + Sync>> {
|
||||
// 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<reqwest::Response, Box<dyn Error + Send + Sync>> {
|
||||
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(),
|
||||
|
||||
@@ -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::<KiroCredentials>(&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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -17,8 +17,8 @@ fn arb_hostname() -> impl Strategy<Value = String> {
|
||||
/// 生成有效的 socks5 代理 URL
|
||||
fn arb_socks5_url() -> impl Strategy<Value = String> {
|
||||
(
|
||||
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<Value = String> {
|
||||
/// 生成有效的 http 代理 URL
|
||||
fn arb_http_url() -> impl Strategy<Value = String> {
|
||||
(
|
||||
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<Value = String> {
|
||||
/// 生成有效的 https 代理 URL
|
||||
fn arb_https_url() -> impl Strategy<Value = String> {
|
||||
(
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<Self, String> {
|
||||
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<Self, String> {
|
||||
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<PathBuf, String> {
|
||||
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<PathBuf, String> {
|
||||
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<fn(rusqlite::backup::Progress)> = 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<fn(rusqlite::backup::Progress)> = None;
|
||||
conn.restore(DatabaseName::Main, backup_path, progress)
|
||||
.map_err(|e| format!("恢复失败: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_backups(&self) -> Result<Vec<PathBuf>, 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::<Utc>::from(modified);
|
||||
if modified < cutoff {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn backup_dir(&self) -> &PathBuf {
|
||||
&self.backup_dir
|
||||
}
|
||||
}
|
||||
@@ -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<PathBuf> {
|
||||
@@ -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<String> = 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod backup_service;
|
||||
pub mod live_sync;
|
||||
pub mod mcp_service;
|
||||
pub mod mcp_sync;
|
||||
|
||||
@@ -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::<String>()
|
||||
))
|
||||
}
|
||||
}
|
||||
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),
|
||||
};
|
||||
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -142,7 +142,7 @@ const presets: Record<AppType, ProviderPreset[]> = {
|
||||
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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -53,7 +53,7 @@ const providerModels: Record<PoolProviderType, string[]> = {
|
||||
],
|
||||
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",
|
||||
|
||||
@@ -56,7 +56,7 @@ const providerLabels: Record<PoolProviderType, string> = {
|
||||
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",
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FileImportForm
|
||||
credsFilePath={credsFilePath}
|
||||
setCredsFilePath={setCredsFilePath}
|
||||
onSelectFile={onSelectFile}
|
||||
placeholder="选择 auth.json 或 oauth.json..."
|
||||
hint="默认路径: ~/.codex/auth.json 或 Codex CLI 的凭证文件"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<FileImportForm
|
||||
credsFilePath={credsFilePath}
|
||||
setCredsFilePath={setCredsFilePath}
|
||||
onSelectFile={onSelectFile}
|
||||
placeholder="选择 auth.json 或 oauth.json..."
|
||||
hint="默认路径: ~/.codex/auth.json 或 Codex CLI 的凭证文件"
|
||||
/>
|
||||
|
||||
{/* API Base URL 输入框 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
API Base URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={apiBaseUrl}
|
||||
onChange={(e) => setApiBaseUrl(e.target.value)}
|
||||
placeholder="https://yunyi.cfd/codex"
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
云驿代理默认:
|
||||
https://yunyi.cfd/codex(留空则使用凭证文件中的配置)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -173,7 +173,7 @@ export function ProxySettings() {
|
||||
...config,
|
||||
server: {
|
||||
...config.server,
|
||||
port: parseInt(e.target.value) || 3001,
|
||||
port: parseInt(e.target.value) || 8999,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
@@ -121,6 +129,13 @@ export function RemoteManagementSettings() {
|
||||
)}
|
||||
|
||||
<div className="p-4 rounded-lg border space-y-4">
|
||||
{!remoteAccessSupported && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<span>当前版本未启用 TLS,暂不支持远程管理访问,请保持关闭。</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 管理密钥 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">管理密钥</label>
|
||||
@@ -179,7 +194,7 @@ export function RemoteManagementSettings() {
|
||||
|
||||
{/* 允许远程访问 */}
|
||||
<label
|
||||
className={`flex items-center justify-between p-3 rounded-lg border cursor-pointer hover:bg-muted/50 ${!isEnabled ? "opacity-50 pointer-events-none" : ""}`}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border cursor-pointer hover:bg-muted/50 ${allowRemoteToggleDisabled ? "opacity-50 pointer-events-none" : ""}`}
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm font-medium">允许远程访问</span>
|
||||
@@ -190,11 +205,14 @@ export function RemoteManagementSettings() {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rm.allow_remote}
|
||||
onChange={(e) =>
|
||||
updateRemoteManagement({ allow_remote: e.target.checked })
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked && !allowRemoteToggleEnabled) {
|
||||
return;
|
||||
}
|
||||
updateRemoteManagement({ allow_remote: e.target.checked });
|
||||
}}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
disabled={!isEnabled}
|
||||
disabled={allowRemoteToggleDisabled}
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -222,7 +240,7 @@ export function RemoteManagementSettings() {
|
||||
</label>
|
||||
|
||||
{/* 警告提示 */}
|
||||
{isEnabled && rm.allow_remote && (
|
||||
{remoteAccessSupported && isEnabled && rm.allow_remote && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
@@ -233,7 +251,7 @@ export function RemoteManagementSettings() {
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
disabled={saving || remoteAccessUnsupportedEnabled}
|
||||
className="w-full px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "保存中..." : "保存远程管理设置"}
|
||||
|
||||
@@ -89,8 +89,10 @@ export function TlsSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
const tlsSupported = false;
|
||||
const tls = config.server.tls;
|
||||
const isConfigValid = !tls.enable || (tls.cert_path && tls.key_path);
|
||||
const tlsUnsupportedEnabled = tls.enable && !tlsSupported;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -121,6 +123,16 @@ export function TlsSettings() {
|
||||
)}
|
||||
|
||||
<div className="p-4 rounded-lg border space-y-4">
|
||||
{!tlsSupported && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
当前版本暂不支持 TLS。启用后服务将无法启动,请使用反向代理或 TLS
|
||||
终止。
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 启用开关 */}
|
||||
<label className="flex items-center justify-between p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
|
||||
<div>
|
||||
@@ -132,13 +144,23 @@ export function TlsSettings() {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tls.enable}
|
||||
onChange={(e) => updateTls({ enable: e.target.checked })}
|
||||
onChange={(e) => {
|
||||
if (!tlsSupported && e.target.checked) {
|
||||
return;
|
||||
}
|
||||
updateTls({ enable: e.target.checked });
|
||||
}}
|
||||
disabled={!tlsSupported && !tls.enable}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* 证书路径 */}
|
||||
<div className={tls.enable ? "" : "opacity-50 pointer-events-none"}>
|
||||
<div
|
||||
className={
|
||||
tls.enable && tlsSupported ? "" : "opacity-50 pointer-events-none"
|
||||
}
|
||||
>
|
||||
<label className="block text-sm font-medium mb-1.5">
|
||||
证书文件路径 {tls.enable && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
@@ -165,7 +187,11 @@ export function TlsSettings() {
|
||||
</div>
|
||||
|
||||
{/* 私钥路径 */}
|
||||
<div className={tls.enable ? "" : "opacity-50 pointer-events-none"}>
|
||||
<div
|
||||
className={
|
||||
tls.enable && tlsSupported ? "" : "opacity-50 pointer-events-none"
|
||||
}
|
||||
>
|
||||
<label className="block text-sm font-medium mb-1.5">
|
||||
私钥文件路径 {tls.enable && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
@@ -201,7 +227,9 @@ export function TlsSettings() {
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || (tls.enable && !isConfigValid)}
|
||||
disabled={
|
||||
saving || tlsUnsupportedEnabled || (tls.enable && !isConfigValid)
|
||||
}
|
||||
className="w-full px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "保存中..." : "保存 TLS 设置"}
|
||||
|
||||
@@ -136,7 +136,7 @@ const presets: Record<AppType, ProviderPreset[]> = {
|
||||
name: "ProxyCast",
|
||||
category: "proxy",
|
||||
iconColor: "#3b82f6",
|
||||
defaultBaseUrl: "http://127.0.0.1:3001",
|
||||
defaultBaseUrl: "http://127.0.0.1:8999",
|
||||
},
|
||||
// 自定义
|
||||
{
|
||||
@@ -179,7 +179,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",
|
||||
},
|
||||
},
|
||||
// 自定义
|
||||
@@ -208,7 +208,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",
|
||||
},
|
||||
},
|
||||
@@ -487,7 +487,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") {
|
||||
|
||||
@@ -333,9 +333,14 @@ export const providerPoolApi = {
|
||||
|
||||
async addCodexOAuth(
|
||||
credsFilePath: string,
|
||||
apiBaseUrl?: string,
|
||||
name?: string,
|
||||
): Promise<ProviderCredential> {
|
||||
return invoke("add_codex_oauth_credential", { credsFilePath, name });
|
||||
return invoke("add_codex_oauth_credential", {
|
||||
credsFilePath,
|
||||
apiBaseUrl,
|
||||
name,
|
||||
});
|
||||
},
|
||||
|
||||
async addClaudeOAuth(
|
||||
|
||||
Reference in New Issue
Block a user