Merge pull request #23 from tytsxai/main

security: 修复多个 P0 级安全漏洞
This commit is contained in:
coso
2025-12-22 20:10:00 +08:00
committed by GitHub
73 changed files with 2098 additions and 483 deletions
+20 -17
View File
@@ -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"
}
}
+14
View File
@@ -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
+2 -2
View File
@@ -97,8 +97,8 @@ jobs:
```
### 默认配置
- **端口**: 3001
- **API Key**: proxycast-key
- **端口**: 8999
- **API Key**: 首次启动自动生成,可在设置页查看/修改
releaseDraft: false
prerelease: false
args: --target ${{ matrix.target }}
+20 -7
View File
@@ -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
View File
@@ -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": {
+5 -5
View File
@@ -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"
}]
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 配置,再启动应用。
## 配置同步
### 跨设备同步
+5 -5
View File
@@ -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" \
+1 -1
View File
@@ -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 '{
+17 -1
View File
@@ -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 过期前刷新:
+3 -3
View File
@@ -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`
+2 -2
View File
@@ -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
View File
@@ -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 启用后会对失败认证进行短期限制,避免暴力尝试。
- 建议仅在内网使用,并配合独立强密钥。
+20 -2
View File
@@ -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",
+13
View File
@@ -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",
+4 -1
View File
@@ -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"
+7
View File
@@ -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()
}
+12 -3
View File
@@ -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,
})
+14 -10
View File
@@ -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),
});
}
+33 -93
View File
@@ -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),
+2 -1
View File
@@ -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)),
+43
View File
@@ -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 {
+20 -11
View File
@@ -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, &current, &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());
// 凭证池中脱敏的条目应被移除
+6 -5
View File
@@ -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};
+20 -24
View File
@@ -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 密钥应被清空"
);
}
}
+21 -1
View File
@@ -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 {
+50 -7
View File
@@ -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);
+3 -1
View File
@@ -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
},
+15 -15
View File
@@ -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(())
}
+10 -8
View File
@@ -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)))
}
+38
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
+111 -1
View File
@@ -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",
+57 -1
View File
@@ -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,
+12 -3
View File
@@ -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()),
+12
View File
@@ -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)]
+5
View File
@@ -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(())),
}
}
+2 -1
View File
@@ -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())),
}
+335 -33
View File
@@ -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(),
+45 -63
View File
@@ -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(),
+5 -2
View File
@@ -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 {
+6 -6
View File
@@ -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))
}
+25 -29
View File
@@ -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 (
+143
View File
@@ -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
}
}
+104 -3
View File
@@ -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
View File
@@ -1,3 +1,4 @@
pub mod backup_service;
pub mod live_sync;
pub mod mcp_service;
pub mod mcp_sync;
+112 -28
View File
@@ -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))?;
+16
View File
@@ -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);
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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) {
+3 -3
View File
@@ -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 => {
+5 -5
View File
@@ -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>
)}
</>
),
+1 -1
View File
@@ -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 ? "保存中..." : "保存远程管理设置"}
+32 -4
View File
@@ -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 设置"}
+4 -4
View File
@@ -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") {
+6 -1
View File
@@ -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(