Compare commits

...

17 Commits

Author SHA1 Message Date
github-actions[bot]
03dfedc3d9 chore: sync VERSION file with release v1.1.267 [skip ci] 2026-01-25 05:12:51 +00:00
Wesley Liddick
866806301f Merge pull request #924 from DaydreamCoding/feat/codex_exec
feat(codex): 添加 codex_exec 用户代理支持
2026-01-25 13:12:37 +08:00
QTom
816c47b51d feat(codex): 添加 codex_exec 用户代理支持
支持 Codex CLI 的非交互式/脚本模式(codex exec),使其与 codex_vscode 和 codex_cli_rs 共享相同的验证逻辑和权限配置。修复 codex exec 0.89.0 版本因客户端限制导致的 403 错误。
2026-01-25 12:24:19 +08:00
github-actions[bot]
c97bfb6478 chore: sync VERSION file with release v1.1.266 [skip ci] 2026-01-24 12:22:09 +00:00
Wesley Liddick
efda870e96 Merge pull request #923 from DaydreamCoding/feature/fix_api_auth
fix(auth): 修复客户端限制绕过漏洞,添加路径白名单检查
2026-01-24 20:21:52 +08:00
Wesley Liddick
1ae310f2a1 Merge pull request #920 from arksou/main [skip ci]
fix: 配额超限优化
2026-01-24 20:21:34 +08:00
QTom
6dc85b39c9 refactor(validators): 消除重复代码,使用映射表和复用函数
代码审查后的重构:
- isPathAllowedForClient 复用 getClientDefinitionById 避免重复查找
- validateRequest 中使用 getClientDefinitionById 替代内联查找
- 使用 VALIDATOR_MAP 映射表替代 switch 语句
- getSupportedClients 改为从映射表动态获取,避免硬编码
- 导入 CLIENT_IDS 枚举,提高类型安全性

这些改动提高了代码的可维护性,添加新客户端时只需修改映射表。
2026-01-24 17:45:13 +08:00
QTom
6c4670213e fix(auth): 修复客户端限制绕过漏洞,添加路径白名单检查
当 API Key 启用客户端限制(如仅允许 Claude Code)时,攻击者可通过
/api/v1/chat/completions 等 OpenAI 兼容端点绕过验证。原因是
ClaudeCodeValidator 对非 messages 路径仅检查 User-Agent。

修复方案:
- 为每个客户端类型定义允许的路径白名单
- 在客户端验证前进行路径检查
- 路径不在白名单中则直接拒绝,无需继续验证

修改文件:
- src/validators/clientDefinitions.js:添加 allowedPathPrefixes 配置
- src/validators/clientValidator.js:添加路径白名单前置检查

Claude Code 限制时的路由保护:
- 允许访问:/api/v1/messages, /claude/v1/messages 等原生端点
- 拒绝访问:/api/v1/chat/completions, /openai/claude/v1/chat/completions 等
- 其他客户端类型(Gemini CLI、Codex CLI、Droid CLI)也同样适用

相关问题:/api/v1/chat/completions 端点在启用 Claude Code 限制后
依然可以使用,深入分析原因并提供修复方案 #security #client-restriction
2026-01-24 17:37:42 +08:00
gaozitian
d16b75293d fix: optimize Claude Console quota exceeded status display
- Keep account status as 'active' when quota exceeded (not 'quota_exceeded')
- Keep isActive as true, only use quotaStoppedAt to mark quota exceeded
- Show green status in UI for quota exceeded accounts (normal state)
- Show '余额不足' as unschedulable reason instead of '已暂停'
- Simplify resetDailyUsage() to only check quotaStoppedAt field

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 12:06:09 +08:00
github-actions[bot]
2ac31a5706 chore: sync VERSION file with release v1.1.265 [skip ci] 2026-01-23 11:16:24 +00:00
Wesley Liddick
a3a922ac09 Merge pull request #919 from arksou/hotfix/claude-console-quota-exceeded-recovery
fix: Claude Console 配额超限状态优化,支持主动自动恢复
2026-01-23 19:16:10 +08:00
Wesley Liddick
0073d40299 Merge pull request #916 from enzyme2013/fix/allow-new-session-after-clear [skip ci]
fix: allow new session binding after /clear command
2026-01-23 19:15:59 +08:00
jett.gao
d812af9159 fix: Claude Console 配额超限状态优化,支持主动自动恢复
- 新增 rateLimitCleanupService 配额超限恢复检查(每5分钟)
- 调度器预检查配额超限账户,到达重置时间自动恢复
- 前端显示"余额不足"替代默认的"手动停止调度"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 18:19:34 +08:00
github-actions[bot]
4ed5cc631a chore: sync VERSION file with release v1.1.264 [skip ci] 2026-01-23 02:41:20 +00:00
Wesley Liddick
4019b043ec Merge pull request #918 from Chapoly1305/fix/crypto-import
fix: add missing crypto module import in geminiAccountService
2026-01-23 10:41:04 +08:00
Junming Chen
9d70110139 fix: add missing crypto module import in geminiAccountService 2026-01-22 21:32:49 -05:00
enzyme2013
16e2bcfedb fix: allow new session binding after /clear command
- 移除 isOldSession 检查,信任客户端的 session ID 作为新会话标识
- 将 sessionBindingTtlDays 默认值从 30 天改为 1 天,避免 Redis 内存累积
- 添加新会话绑定的监控日志(包含 sessionId、messages 数量、accountId 等)
- 完美支持 Claude Code /clear 等合法的新会话场景
- 同步更新前端界面的默认值配置

问题背景:
用户在 Claude Code 中执行 /clear 后,会生成新的 session ID,
但旧的逻辑会检查请求内容判定为"旧会话",导致返回"本地session已污染"错误。

修复方案:
采用方案2(放宽新会话检测)+ TTL 优化,信任客户端的 session ID,
不再检查请求内容是否"看起来像旧会话",由 1 天的 TTL 自动清理过期绑定。

影响范围:
- src/routes/api.js (流式和非流式两处)
- src/services/claudeRelayConfigService.js
- web/admin-spa/src/views/SettingsView.vue
2026-01-22 17:31:11 +08:00
14 changed files with 237 additions and 91 deletions

View File

@@ -1 +1 @@
1.1.263 1.1.267

View File

@@ -377,19 +377,13 @@ async function handleMessagesRequest(req, res) {
accountId && accountId &&
accountType === 'claude-official' accountType === 'claude-official'
) { ) {
// 🚫 检测旧会话(污染的会话 // 🆕 允许新 session ID 创建绑定(支持 Claude Code /clear 等场景
if (isOldSession(req.body)) { // 信任客户端的 session ID 作为新会话的标识,不再检查请求内容
const cfg = await claudeRelayConfigService.getConfig() logger.info(
logger.warn( `🔗 Creating new session binding: sessionId=${originalSessionIdForBinding}, ` +
`🚫 Old session rejected: sessionId=${originalSessionIdForBinding}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true` `messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, ` +
) `accountId=${accountId}, accountType=${accountType}`
return res.status(400).json({ )
error: {
type: 'session_binding_error',
message: cfg.sessionBindingErrorMessage || '你的本地session已污染请清理后使用。'
}
})
}
// 创建绑定 // 创建绑定
try { try {
@@ -944,19 +938,13 @@ async function handleMessagesRequest(req, res) {
accountId && accountId &&
accountType === 'claude-official' accountType === 'claude-official'
) { ) {
// 🚫 检测旧会话(污染的会话 // 🆕 允许新 session ID 创建绑定(支持 Claude Code /clear 等场景
if (isOldSession(req.body)) { // 信任客户端的 session ID 作为新会话的标识,不再检查请求内容
const cfg = await claudeRelayConfigService.getConfig() logger.info(
logger.warn( `🔗 Creating new session binding (non-stream): sessionId=${originalSessionIdForBindingNonStream}, ` +
`🚫 Old session rejected (non-stream): sessionId=${originalSessionIdForBindingNonStream}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true` `messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, ` +
) `accountId=${accountId}, accountType=${accountType}`
return res.status(400).json({ )
error: {
type: 'session_binding_error',
message: cfg.sessionBindingErrorMessage || '你的本地session已污染请清理后使用。'
}
})
}
// 创建绑定 // 创建绑定
try { try {

View File

@@ -264,8 +264,9 @@ const handleResponses = async (req, res) => {
const isStream = req.body?.stream !== false // 默认为流式(兼容现有行为) const isStream = req.body?.stream !== false // 默认为流式(兼容现有行为)
// 判断是否为 Codex CLI 的请求(基于 User-Agent // 判断是否为 Codex CLI 的请求(基于 User-Agent
// 支持: codex_vscode, codex_cli_rs, codex_exec (非交互式/脚本模式)
const userAgent = req.headers['user-agent'] || '' const userAgent = req.headers['user-agent'] || ''
const codexCliPattern = /^(codex_vscode|codex_cli_rs)\/[\d.]+/i const codexCliPattern = /^(codex_vscode|codex_cli_rs|codex_exec)\/[\d.]+/i
const isCodexCLI = codexCliPattern.test(userAgent) const isCodexCLI = codexCliPattern.test(userAgent)
// 如果不是 Codex CLI 请求,则进行适配 // 如果不是 Codex CLI 请求,则进行适配

View File

@@ -1295,7 +1295,7 @@ class ClaudeConsoleAccountService {
} }
// 检查是否已经因额度停用(避免重复操作) // 检查是否已经因额度停用(避免重复操作)
if (!accountData.isActive && accountData.quotaStoppedAt) { if (accountData.quotaStoppedAt) {
return return
} }
@@ -1311,9 +1311,9 @@ class ClaudeConsoleAccountService {
return // 已经被其他进程处理 return // 已经被其他进程处理
} }
// 超过额度,停用账户 // 超过额度,停止调度但保持账户状态正常
// 不修改 isActive 和 status只用独立字段标记配额超限
const updates = { const updates = {
isActive: false,
quotaStoppedAt: new Date().toISOString(), quotaStoppedAt: new Date().toISOString(),
errorMessage: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`, errorMessage: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
schedulable: false, // 停止调度 schedulable: false, // 停止调度
@@ -1321,13 +1321,6 @@ class ClaudeConsoleAccountService {
quotaAutoStopped: 'true' quotaAutoStopped: 'true'
} }
// 只有当前状态是active时才改为quota_exceeded
// 如果是rate_limited等其他状态保持原状态不变
const currentStatus = await client.hget(accountKey, 'status')
if (currentStatus === 'active') {
updates.status = 'quota_exceeded'
}
await this.updateAccount(accountId, updates) await this.updateAccount(accountId, updates)
logger.warn( logger.warn(
@@ -1371,15 +1364,10 @@ class ClaudeConsoleAccountService {
lastResetDate: today lastResetDate: today
} }
// 如果账户是因为超额被停用,恢复账户 // 如果账户因配额超限被停用,恢复账户
// 注意:状态可能是 quota_exceeded 或 rate_limited如果429错误时也超额了 // 新逻辑:不再依赖 isActive === false 和 status 判断
if ( // 只要有 quotaStoppedAt 就说明是因配额超限被停止的
accountData.quotaStoppedAt && if (accountData.quotaStoppedAt) {
accountData.isActive === false &&
(accountData.status === 'quota_exceeded' || accountData.status === 'rate_limited')
) {
updates.isActive = true
updates.status = 'active'
updates.errorMessage = '' updates.errorMessage = ''
updates.quotaStoppedAt = '' updates.quotaStoppedAt = ''
@@ -1389,16 +1377,7 @@ class ClaudeConsoleAccountService {
updates.quotaAutoStopped = '' updates.quotaAutoStopped = ''
} }
// 如果是rate_limited状态也清除限流相关字段 logger.info(`✅ Restored account ${accountId} after daily quota reset`)
if (accountData.status === 'rate_limited') {
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus', 'rateLimitAutoStopped')
}
logger.info(
`✅ Restored account ${accountId} after daily reset (was ${accountData.status})`
)
} }
await this.updateAccount(accountId, updates) await this.updateAccount(accountId, updates)

View File

@@ -14,7 +14,7 @@ const DEFAULT_CONFIG = {
claudeCodeOnlyEnabled: false, claudeCodeOnlyEnabled: false,
globalSessionBindingEnabled: false, globalSessionBindingEnabled: false,
sessionBindingErrorMessage: '你的本地session已污染请清理后使用。', sessionBindingErrorMessage: '你的本地session已污染请清理后使用。',
sessionBindingTtlDays: 30, // 会话绑定 TTL默认30天 sessionBindingTtlDays: 1, // 会话绑定 TTL默认1天支持 /clear 场景,避免 Redis 累积)
// 用户消息队列配置 // 用户消息队列配置
userMessageQueueEnabled: false, // 是否启用用户消息队列(默认关闭) userMessageQueueEnabled: false, // 是否启用用户消息队列(默认关闭)
userMessageQueueDelayMs: 200, // 请求间隔(毫秒) userMessageQueueDelayMs: 200, // 请求间隔(毫秒)

View File

@@ -1,5 +1,6 @@
const redisClient = require('../models/redis') const redisClient = require('../models/redis')
const { v4: uuidv4 } = require('uuid') const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto')
const https = require('https') const https = require('https')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { OAuth2Client } = require('google-auth-library') const { OAuth2Client } = require('google-auth-library')

View File

@@ -73,6 +73,7 @@ class RateLimitCleanupService {
openai: { checked: 0, cleared: 0, errors: [] }, openai: { checked: 0, cleared: 0, errors: [] },
claude: { checked: 0, cleared: 0, errors: [] }, claude: { checked: 0, cleared: 0, errors: [] },
claudeConsole: { checked: 0, cleared: 0, errors: [] }, claudeConsole: { checked: 0, cleared: 0, errors: [] },
quotaExceeded: { checked: 0, cleared: 0, errors: [] },
tokenRefresh: { checked: 0, refreshed: 0, errors: [] } tokenRefresh: { checked: 0, refreshed: 0, errors: [] }
} }
@@ -85,13 +86,22 @@ class RateLimitCleanupService {
// 清理 Claude Console 账号 // 清理 Claude Console 账号
await this.cleanupClaudeConsoleAccounts(results.claudeConsole) await this.cleanupClaudeConsoleAccounts(results.claudeConsole)
// 清理 Claude Console 配额超限状态
await this.cleanupClaudeConsoleQuotaExceeded(results.quotaExceeded)
// 主动刷新等待重置的 Claude 账户 Token防止 5小时/7天 等待期间 Token 过期) // 主动刷新等待重置的 Claude 账户 Token防止 5小时/7天 等待期间 Token 过期)
await this.proactiveRefreshClaudeTokens(results.tokenRefresh) await this.proactiveRefreshClaudeTokens(results.tokenRefresh)
const totalChecked = const totalChecked =
results.openai.checked + results.claude.checked + results.claudeConsole.checked results.openai.checked +
results.claude.checked +
results.claudeConsole.checked +
results.quotaExceeded.checked
const totalCleared = const totalCleared =
results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared results.openai.cleared +
results.claude.cleared +
results.claudeConsole.cleared +
results.quotaExceeded.cleared
const duration = Date.now() - startTime const duration = Date.now() - startTime
if (totalCleared > 0 || results.tokenRefresh.refreshed > 0) { if (totalCleared > 0 || results.tokenRefresh.refreshed > 0) {
@@ -103,6 +113,9 @@ class RateLimitCleanupService {
logger.info( logger.info(
` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}` ` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}`
) )
logger.info(
` Quota Exceeded: ${results.quotaExceeded.cleared}/${results.quotaExceeded.checked}`
)
if (results.tokenRefresh.checked > 0 || results.tokenRefresh.refreshed > 0) { if (results.tokenRefresh.checked > 0 || results.tokenRefresh.refreshed > 0) {
logger.info( logger.info(
` Token Refresh: ${results.tokenRefresh.refreshed}/${results.tokenRefresh.checked} refreshed` ` Token Refresh: ${results.tokenRefresh.refreshed}/${results.tokenRefresh.checked} refreshed`
@@ -124,6 +137,7 @@ class RateLimitCleanupService {
...results.openai.errors, ...results.openai.errors,
...results.claude.errors, ...results.claude.errors,
...results.claudeConsole.errors, ...results.claudeConsole.errors,
...results.quotaExceeded.errors,
...results.tokenRefresh.errors ...results.tokenRefresh.errors
] ]
if (allErrors.length > 0) { if (allErrors.length > 0) {
@@ -358,6 +372,54 @@ class RateLimitCleanupService {
} }
} }
/**
* 检查并恢复 Claude Console 账号的配额超限状态
*/
async cleanupClaudeConsoleQuotaExceeded(result) {
try {
const accounts = await claudeConsoleAccountService.getAllAccounts()
for (const account of accounts) {
// 检查是否处于配额超限状态
if (account.status === 'quota_exceeded' || account.quotaStoppedAt) {
result.checked++
try {
// 使用 isAccountQuotaExceeded 方法,它会自动触发恢复
const isStillExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(
account.id
)
if (!isStillExceeded) {
result.cleared++
logger.info(
`🧹 Auto-recovered quota exceeded for Claude Console account: ${account.name} (${account.id})`
)
// 记录已恢复的账户信息
this.clearedAccounts.push({
platform: 'Claude Console',
accountId: account.id,
accountName: account.name,
previousStatus: 'quota_exceeded',
currentStatus: 'active'
})
}
} catch (error) {
result.errors.push({
accountId: account.id,
accountName: account.name,
error: error.message
})
}
}
}
} catch (error) {
logger.error('Failed to cleanup Claude Console quota exceeded accounts:', error)
result.errors.push({ error: error.message })
}
}
/** /**
* 主动刷新 Claude 账户 Token防止等待重置期间 Token 过期) * 主动刷新 Claude 账户 Token防止等待重置期间 Token 过期)
* 仅对因限流/配额限制而等待重置的账户执行刷新: * 仅对因限流/配额限制而等待重置的账户执行刷新:

View File

@@ -673,6 +673,23 @@ class UnifiedClaudeScheduler {
} }
} }
// 主动检查配额超限状态并尝试恢复(在过滤之前执行,确保可以恢复配额超限的账户)
if (currentAccount.status === 'quota_exceeded') {
// 触发配额检查,如果已到重置时间会自动恢复账户
const isStillExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(
currentAccount.id
)
if (!isStillExceeded) {
// 重新获取账户最新状态
const refreshedAccount = await claudeConsoleAccountService.getAccount(currentAccount.id)
if (refreshedAccount) {
// 更新当前循环中的账户数据
currentAccount = refreshedAccount
logger.info(`✅ Account ${currentAccount.name} recovered from quota_exceeded status`)
}
}
}
logger.info( logger.info(
`🔍 Checking Claude Console account: ${currentAccount.name} - isActive: ${currentAccount.isActive}, status: ${currentAccount.status}, accountType: ${currentAccount.accountType}, schedulable: ${currentAccount.schedulable}` `🔍 Checking Claude Console account: ${currentAccount.name} - isActive: ${currentAccount.isActive}, status: ${currentAccount.status}, accountType: ${currentAccount.accountType}, schedulable: ${currentAccount.schedulable}`
) )

View File

@@ -1,6 +1,10 @@
/** /**
* 客户端定义配置 * 客户端定义配置
* 定义所有支持的客户端类型和它们的属性 * 定义所有支持的客户端类型和它们的属性
*
* allowedPathPrefixes: 允许访问的路径前缀白名单
* - 当启用客户端限制时,只有匹配白名单的路径才允许访问
* - 防止通过其他兼容端点(如 /v1/chat/completions绕过客户端限制
*/ */
const CLIENT_DEFINITIONS = { const CLIENT_DEFINITIONS = {
@@ -9,7 +13,27 @@ const CLIENT_DEFINITIONS = {
name: 'Claude Code', name: 'Claude Code',
displayName: 'Claude Code CLI', displayName: 'Claude Code CLI',
description: 'Claude Code command-line interface', description: 'Claude Code command-line interface',
icon: '🤖' icon: '🤖',
// Claude Code 仅允许访问 Claude 原生端点,禁止访问 OpenAI 兼容端点
allowedPathPrefixes: [
'/api/v1/messages',
'/api/v1/models',
'/api/v1/me',
'/api/v1/usage',
'/api/v1/key-info',
'/api/v1/organizations',
'/claude/v1/messages',
'/claude/v1/models',
'/antigravity/api/',
'/gemini-cli/api/',
'/api/event_logging',
'/v1/messages',
'/v1/models',
'/v1/me',
'/v1/usage',
'/v1/key-info',
'/v1/organizations'
]
}, },
GEMINI_CLI: { GEMINI_CLI: {
@@ -17,7 +41,9 @@ const CLIENT_DEFINITIONS = {
name: 'Gemini CLI', name: 'Gemini CLI',
displayName: 'Gemini Command Line Tool', displayName: 'Gemini Command Line Tool',
description: 'Google Gemini API command-line interface', description: 'Google Gemini API command-line interface',
icon: '💎' icon: '💎',
// Gemini CLI 仅允许访问 Gemini 端点
allowedPathPrefixes: ['/gemini/']
}, },
CODEX_CLI: { CODEX_CLI: {
@@ -25,7 +51,9 @@ const CLIENT_DEFINITIONS = {
name: 'Codex CLI', name: 'Codex CLI',
displayName: 'Codex Command Line Tool', displayName: 'Codex Command Line Tool',
description: 'Cursor/Codex command-line interface', description: 'Cursor/Codex command-line interface',
icon: '🔷' icon: '🔷',
// Codex CLI 仅允许访问 OpenAI Responses 和 Azure 端点
allowedPathPrefixes: ['/openai/responses', '/openai/v1/responses', '/azure/']
}, },
DROID_CLI: { DROID_CLI: {
@@ -33,7 +61,9 @@ const CLIENT_DEFINITIONS = {
name: 'Droid CLI', name: 'Droid CLI',
displayName: 'Factory Droid CLI', displayName: 'Factory Droid CLI',
description: 'Factory Droid platform command-line interface', description: 'Factory Droid platform command-line interface',
icon: '🤖' icon: '🤖',
// Droid CLI 仅允许访问 Droid 端点
allowedPathPrefixes: ['/droid/']
} }
} }
@@ -60,10 +90,34 @@ function isValidClientId(clientId) {
return Object.values(CLIENT_IDS).includes(clientId) return Object.values(CLIENT_IDS).includes(clientId)
} }
/**
* 检查路径是否允许指定客户端访问
* @param {string} clientId - 客户端ID
* @param {string} path - 请求路径 (originalUrl 或 path)
* @returns {boolean} 是否允许
*/
function isPathAllowedForClient(clientId, path) {
const definition = getClientDefinitionById(clientId)
if (!definition) {
return false
}
// 如果没有定义 allowedPathPrefixes则不限制路径向后兼容
if (!definition.allowedPathPrefixes || definition.allowedPathPrefixes.length === 0) {
return true
}
const normalizedPath = (path || '').toLowerCase()
return definition.allowedPathPrefixes.some((prefix) =>
normalizedPath.startsWith(prefix.toLowerCase())
)
}
module.exports = { module.exports = {
CLIENT_DEFINITIONS, CLIENT_DEFINITIONS,
CLIENT_IDS, CLIENT_IDS,
getAllClientDefinitions, getAllClientDefinitions,
getClientDefinitionById, getClientDefinitionById,
isValidClientId isValidClientId,
isPathAllowedForClient
} }

View File

@@ -4,12 +4,25 @@
*/ */
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { CLIENT_DEFINITIONS, getAllClientDefinitions } = require('./clientDefinitions') const {
CLIENT_IDS,
getAllClientDefinitions,
getClientDefinitionById,
isPathAllowedForClient
} = require('./clientDefinitions')
const ClaudeCodeValidator = require('./clients/claudeCodeValidator') const ClaudeCodeValidator = require('./clients/claudeCodeValidator')
const GeminiCliValidator = require('./clients/geminiCliValidator') const GeminiCliValidator = require('./clients/geminiCliValidator')
const CodexCliValidator = require('./clients/codexCliValidator') const CodexCliValidator = require('./clients/codexCliValidator')
const DroidCliValidator = require('./clients/droidCliValidator') const DroidCliValidator = require('./clients/droidCliValidator')
// 客户端ID到验证器的映射表
const VALIDATOR_MAP = {
[CLIENT_IDS.CLAUDE_CODE]: ClaudeCodeValidator,
[CLIENT_IDS.GEMINI_CLI]: GeminiCliValidator,
[CLIENT_IDS.CODEX_CLI]: CodexCliValidator,
[CLIENT_IDS.DROID_CLI]: DroidCliValidator
}
/** /**
* 客户端验证器类 * 客户端验证器类
*/ */
@@ -20,19 +33,12 @@ class ClientValidator {
* @returns {Object|null} 验证器实例 * @returns {Object|null} 验证器实例
*/ */
static getValidator(clientId) { static getValidator(clientId) {
switch (clientId) { const validator = VALIDATOR_MAP[clientId]
case 'claude_code': if (!validator) {
return ClaudeCodeValidator logger.warn(`Unknown client ID: ${clientId}`)
case 'gemini_cli': return null
return GeminiCliValidator
case 'codex_cli':
return CodexCliValidator
case 'droid_cli':
return DroidCliValidator
default:
logger.warn(`Unknown client ID: ${clientId}`)
return null
} }
return validator
} }
/** /**
@@ -40,7 +46,7 @@ class ClientValidator {
* @returns {Array<string>} 客户端ID列表 * @returns {Array<string>} 客户端ID列表
*/ */
static getSupportedClients() { static getSupportedClients() {
return ['claude_code', 'gemini_cli', 'codex_cli', 'droid_cli'] return Object.keys(VALIDATOR_MAP)
} }
/** /**
@@ -67,6 +73,7 @@ class ClientValidator {
/** /**
* 验证请求是否来自允许的客户端列表中的任一客户端 * 验证请求是否来自允许的客户端列表中的任一客户端
* 包含路径白名单检查,防止通过其他兼容端点绕过客户端限制
* @param {Array<string>} allowedClients - 允许的客户端ID列表 * @param {Array<string>} allowedClients - 允许的客户端ID列表
* @param {Object} req - Express请求对象 * @param {Object} req - Express请求对象
* @returns {Object} 验证结果对象 * @returns {Object} 验证结果对象
@@ -74,10 +81,12 @@ class ClientValidator {
static validateRequest(allowedClients, req) { static validateRequest(allowedClients, req) {
const userAgent = req.headers['user-agent'] || '' const userAgent = req.headers['user-agent'] || ''
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
const requestPath = req.originalUrl || req.path || ''
// 记录验证开始 // 记录验证开始
logger.api(`🔍 Starting client validation for User-Agent: "${userAgent}"`) logger.api(`🔍 Starting client validation for User-Agent: "${userAgent}"`)
logger.api(` Allowed clients: ${allowedClients.join(', ')}`) logger.api(` Allowed clients: ${allowedClients.join(', ')}`)
logger.api(` Request path: ${requestPath}`)
logger.api(` Request from IP: ${clientIP}`) logger.api(` Request from IP: ${clientIP}`)
// 遍历所有允许的客户端进行验证 // 遍历所有允许的客户端进行验证
@@ -89,6 +98,12 @@ class ClientValidator {
continue continue
} }
// 路径白名单检查:先检查路径是否允许该客户端访问
if (!isPathAllowedForClient(clientId, requestPath)) {
logger.debug(`Path "${requestPath}" not allowed for ${validator.getName()}, skipping`)
continue
}
logger.debug(`Checking against ${validator.getName()}...`) logger.debug(`Checking against ${validator.getName()}...`)
try { try {
@@ -96,12 +111,13 @@ class ClientValidator {
// 验证成功 // 验证成功
logger.api(`✅ Client validated: ${validator.getName()} (${clientId})`) logger.api(`✅ Client validated: ${validator.getName()} (${clientId})`)
logger.api(` Matched User-Agent: "${userAgent}"`) logger.api(` Matched User-Agent: "${userAgent}"`)
logger.api(` Allowed path: "${requestPath}"`)
return { return {
allowed: true, allowed: true,
matchedClient: clientId, matchedClient: clientId,
clientName: validator.getName(), clientName: validator.getName(),
clientInfo: Object.values(CLIENT_DEFINITIONS).find((def) => def.id === clientId) clientInfo: getClientDefinitionById(clientId)
} }
} }
} catch (error) { } catch (error) {
@@ -111,11 +127,15 @@ class ClientValidator {
} }
// 没有匹配的客户端 // 没有匹配的客户端
logger.api(`❌ No matching client found for User-Agent: "${userAgent}"`) logger.api(
`❌ No matching client found for User-Agent: "${userAgent}" and path: "${requestPath}"`
)
return { return {
allowed: false, allowed: false,
matchedClient: null, matchedClient: null,
reason: 'No matching client found' reason: 'No matching client found or path not allowed',
userAgent,
requestPath
} }
} }

View File

@@ -42,7 +42,8 @@ class CodexCliValidator {
// Codex CLI 的 UA 格式: // Codex CLI 的 UA 格式:
// - codex_vscode/0.35.0 (Windows 10.0.26100; x86_64) unknown (Cursor; 0.4.10) // - codex_vscode/0.35.0 (Windows 10.0.26100; x86_64) unknown (Cursor; 0.4.10)
// - codex_cli_rs/0.38.0 (Ubuntu 22.4.0; x86_64) WindowsTerminal // - codex_cli_rs/0.38.0 (Ubuntu 22.4.0; x86_64) WindowsTerminal
const codexCliPattern = /^(codex_vscode|codex_cli_rs)\/[\d.]+/i // - codex_exec/0.89.0 (Mac OS 26.2.0; arm64) xterm-256color (非交互式/脚本模式)
const codexCliPattern = /^(codex_vscode|codex_cli_rs|codex_exec)\/[\d.]+/i
const uaMatch = userAgent.match(codexCliPattern) const uaMatch = userAgent.match(codexCliPattern)
if (!uaMatch) { if (!uaMatch) {

View File

@@ -3789,7 +3789,7 @@
}, },
"node_modules/prettier-plugin-tailwindcss": { "node_modules/prettier-plugin-tailwindcss": {
"version": "0.6.14", "version": "0.6.14",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", "resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
"integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",

View File

@@ -4119,12 +4119,24 @@ const getSchedulableReason = (account) => {
if (account.status === 'unauthorized') { if (account.status === 'unauthorized') {
return 'API Key无效或已过期401错误' return 'API Key无效或已过期401错误'
} }
// 检查配额超限状态
if (account.status === 'quota_exceeded') {
return '余额不足'
}
if (account.overloadStatus === 'overloaded') { if (account.overloadStatus === 'overloaded') {
return '服务过载529错误' return '服务过载529错误'
} }
if (account.rateLimitStatus === 'limited') { if (account.rateLimitStatus === 'limited') {
return '触发限流429错误' return '触发限流429错误'
} }
// 检查配额超限状态quotaAutoStopped 或 quotaStoppedAt 任一存在即表示配额超限)
if (
account.quotaAutoStopped === 'true' ||
account.quotaAutoStopped === true ||
account.quotaStoppedAt
) {
return '余额不足'
}
if (account.status === 'blocked' && account.errorMessage) { if (account.status === 'blocked' && account.errorMessage) {
return account.errorMessage return account.errorMessage
} }
@@ -4203,6 +4215,15 @@ const getSchedulableReason = (account) => {
return '手动停止调度' return '手动停止调度'
} }
// 检查是否是配额超限状态(用于状态显示判断)
const isQuotaExceeded = (account) => {
return (
account.quotaAutoStopped === 'true' ||
account.quotaAutoStopped === true ||
!!account.quotaStoppedAt
)
}
// 获取账户状态文本 // 获取账户状态文本
const getAccountStatusText = (account) => { const getAccountStatusText = (account) => {
// 检查是否被封锁 // 检查是否被封锁
@@ -4221,9 +4242,9 @@ const getAccountStatusText = (account) => {
if (account.status === 'temp_error') return '临时异常' if (account.status === 'temp_error') return '临时异常'
// 检查是否错误 // 检查是否错误
if (account.status === 'error' || !account.isActive) return '错误' if (account.status === 'error' || !account.isActive) return '错误'
// 检查是否可调度 // 配额超限时显示"正常"(不显示"已暂停"
if (account.schedulable === false) return '已暂停' if (account.schedulable === false && !isQuotaExceeded(account)) return '已暂停'
// 否则正常 // 否则正常(包括配额超限状态)
return '正常' return '正常'
} }
@@ -4249,7 +4270,8 @@ const getAccountStatusClass = (account) => {
if (account.status === 'error' || !account.isActive) { if (account.status === 'error' || !account.isActive) {
return 'bg-red-100 text-red-800' return 'bg-red-100 text-red-800'
} }
if (account.schedulable === false) { // 配额超限时显示绿色(正常)
if (account.schedulable === false && !isQuotaExceeded(account)) {
return 'bg-gray-100 text-gray-800' return 'bg-gray-100 text-gray-800'
} }
return 'bg-green-100 text-green-800' return 'bg-green-100 text-green-800'
@@ -4277,7 +4299,8 @@ const getAccountStatusDotClass = (account) => {
if (account.status === 'error' || !account.isActive) { if (account.status === 'error' || !account.isActive) {
return 'bg-red-500' return 'bg-red-500'
} }
if (account.schedulable === false) { // 配额超限时显示绿色(正常)
if (account.schedulable === false && !isQuotaExceeded(account)) {
return 'bg-gray-500' return 'bg-gray-500'
} }
return 'bg-green-500' return 'bg-green-500'

View File

@@ -1904,7 +1904,7 @@ const claudeConfig = ref({
claudeCodeOnlyEnabled: false, claudeCodeOnlyEnabled: false,
globalSessionBindingEnabled: false, globalSessionBindingEnabled: false,
sessionBindingErrorMessage: '你的本地session已污染请清理后使用。', sessionBindingErrorMessage: '你的本地session已污染请清理后使用。',
sessionBindingTtlDays: 30, sessionBindingTtlDays: 1,
userMessageQueueEnabled: false, // 与后端默认值保持一致 userMessageQueueEnabled: false, // 与后端默认值保持一致
userMessageQueueDelayMs: 200, userMessageQueueDelayMs: 200,
userMessageQueueTimeoutMs: 5000, // 与后端默认值保持一致(优化后锁持有时间短无需长等待) userMessageQueueTimeoutMs: 5000, // 与后端默认值保持一致(优化后锁持有时间短无需长等待)
@@ -2203,7 +2203,7 @@ const loadClaudeConfig = async () => {
globalSessionBindingEnabled: response.config?.globalSessionBindingEnabled ?? false, globalSessionBindingEnabled: response.config?.globalSessionBindingEnabled ?? false,
sessionBindingErrorMessage: sessionBindingErrorMessage:
response.config?.sessionBindingErrorMessage || '你的本地session已污染请清理后使用。', response.config?.sessionBindingErrorMessage || '你的本地session已污染请清理后使用。',
sessionBindingTtlDays: response.config?.sessionBindingTtlDays ?? 30, sessionBindingTtlDays: response.config?.sessionBindingTtlDays ?? 1,
userMessageQueueEnabled: response.config?.userMessageQueueEnabled ?? false, // 与后端默认值保持一致 userMessageQueueEnabled: response.config?.userMessageQueueEnabled ?? false, // 与后端默认值保持一致
userMessageQueueDelayMs: response.config?.userMessageQueueDelayMs ?? 200, userMessageQueueDelayMs: response.config?.userMessageQueueDelayMs ?? 200,
userMessageQueueTimeoutMs: response.config?.userMessageQueueTimeoutMs ?? 5000, // 与后端默认值保持一致 userMessageQueueTimeoutMs: response.config?.userMessageQueueTimeoutMs ?? 5000, // 与后端默认值保持一致