diff --git a/.env.example b/.env.example index 5c3f9f0b..33a34d06 100644 --- a/.env.example +++ b/.env.example @@ -54,4 +54,7 @@ WEB_LOGO_URL=/assets/logo.png # 🛠️ 开发配置 DEBUG=false ENABLE_CORS=true -TRUST_PROXY=true \ No newline at end of file +TRUST_PROXY=true + +# 🔒 客户端限制(可选) +# ALLOW_CUSTOM_CLIENTS=false \ No newline at end of file diff --git a/README.md b/README.md index 7fe4cdcc..850f971d 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ - 🔄 **智能切换**: 账户出问题自动换下一个 - 🚀 **性能优化**: 连接池、缓存,减少延迟 - 📊 **监控面板**: Web界面查看所有数据 -- 🛡️ **安全控制**: 访问限制、速率控制 +- 🛡️ **安全控制**: 访问限制、速率控制、客户端限制 - 🌐 **代理支持**: 支持HTTP/SOCKS5代理 --- @@ -398,7 +398,11 @@ docker-compose.yml 已包含: 1. 点击「API Keys」标签 2. 点击「创建新Key」 3. 给Key起个名字,比如「张三的Key」 -4. 设置使用限制(可选) +4. 设置使用限制(可选): + - **速率限制**: 限制每个时间窗口的请求次数和Token使用量 + - **并发限制**: 限制同时处理的请求数 + - **模型限制**: 限制可访问的模型列表 + - **客户端限制**: 限制只允许特定客户端使用(如ClaudeCode、Gemini-CLI等) 5. 保存,记下生成的Key ### 4. 开始使用Claude code @@ -498,6 +502,63 @@ npm run service:status - 查看更新日志了解是否有破坏性变更 - 如果有数据库结构变更,会自动迁移 +--- + +## 🔒 客户端限制功能 + +### 功能说明 + +客户端限制功能允许你控制每个API Key可以被哪些客户端使用,通过User-Agent识别客户端,提高API的安全性。 + +### 使用方法 + +1. **在创建或编辑API Key时启用客户端限制**: + - 勾选"启用客户端限制" + - 选择允许的客户端(支持多选) + +2. **预定义客户端**: + - **ClaudeCode**: 官方Claude CLI(匹配 `claude-cli/x.x.x (external, cli)` 格式) + - **Gemini-CLI**: Gemini命令行工具(匹配 `GeminiCLI/vx.x.x (platform; arch)` 格式) + +3. **调试和诊断**: + - 系统会在日志中记录所有请求的User-Agent + - 客户端验证失败时会返回403错误并记录详细信息 + - 通过日志可以查看实际的User-Agent格式,方便配置自定义客户端 + +### 自定义客户端配置 + +如需添加自定义客户端,可以修改 `config/config.js` 文件: + +```javascript +clientRestrictions: { + predefinedClients: [ + // ... 现有客户端配置 + { + id: 'my_custom_client', + name: 'My Custom Client', + description: '我的自定义客户端', + userAgentPattern: /^MyClient\/[\d\.]+/i + } + ] +} +``` + +### 日志示例 + +认证成功时的日志: +``` +🔓 Authenticated request from key: 测试Key (key-id) in 5ms + User-Agent: "claude-cli/1.0.58 (external, cli)" +``` + +客户端限制检查日志: +``` +🔍 Checking client restriction for key: key-id (测试Key) + User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" + Allowed clients: claude_code, gemini_cli +🚫 Client restriction failed for key: key-id (测试Key) from 127.0.0.1, User-Agent: Mozilla/5.0... +``` + ### 常见问题处理 **Redis连不上?** diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 6bd9929b..11d56e5b 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -2,6 +2,7 @@ const apiKeyService = require('../services/apiKeyService'); const logger = require('../utils/logger'); const redis = require('../models/redis'); const { RateLimiterRedis } = require('rate-limiter-flexible'); +const config = require('../../config/config'); // 🔑 API Key验证中间件(优化版) const authenticateApiKey = async (req, res, next) => { @@ -42,6 +43,52 @@ const authenticateApiKey = async (req, res, next) => { }); } + // 🔒 检查客户端限制 + if (validation.keyData.enableClientRestriction && validation.keyData.allowedClients?.length > 0) { + const userAgent = req.headers['user-agent'] || ''; + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'; + + // 记录客户端限制检查开始 + logger.api(`🔍 Checking client restriction for key: ${validation.keyData.id} (${validation.keyData.name})`); + logger.api(` User-Agent: "${userAgent}"`); + logger.api(` Allowed clients: ${validation.keyData.allowedClients.join(', ')}`); + + let clientAllowed = false; + let matchedClient = null; + + // 遍历允许的客户端列表 + for (const allowedClientId of validation.keyData.allowedClients) { + // 在预定义客户端列表中查找 + const predefinedClient = config.clientRestrictions.predefinedClients.find( + client => client.id === allowedClientId + ); + + if (predefinedClient) { + // 使用预定义的正则表达式匹配 User-Agent + if (predefinedClient.userAgentPattern.test(userAgent)) { + clientAllowed = true; + matchedClient = predefinedClient.name; + break; + } + } else if (config.clientRestrictions.allowCustomClients) { + // 如果允许自定义客户端,这里可以添加自定义客户端的验证逻辑 + // 目前暂时跳过自定义客户端 + continue; + } + } + + if (!clientAllowed) { + logger.security(`🚫 Client restriction failed for key: ${validation.keyData.id} (${validation.keyData.name}) from ${clientIP}, User-Agent: ${userAgent}`); + return res.status(403).json({ + error: 'Client not allowed', + message: 'Your client is not authorized to use this API key', + allowedClients: validation.keyData.allowedClients + }); + } + + logger.api(`✅ Client validated: ${matchedClient} for key: ${validation.keyData.id} (${validation.keyData.name})`); + logger.api(` Matched client: ${matchedClient} with User-Agent: "${userAgent}"`); + } // 检查并发限制 const concurrencyLimit = validation.keyData.concurrencyLimit || 0; @@ -205,12 +252,16 @@ const authenticateApiKey = async (req, res, next) => { rateLimitRequests: validation.keyData.rateLimitRequests, enableModelRestriction: validation.keyData.enableModelRestriction, restrictedModels: validation.keyData.restrictedModels, + enableClientRestriction: validation.keyData.enableClientRestriction, + allowedClients: validation.keyData.allowedClients, usage: validation.keyData.usage }; req.usage = validation.keyData.usage; const authDuration = Date.now() - startTime; + const userAgent = req.headers['user-agent'] || 'No User-Agent'; logger.api(`🔓 Authenticated request from key: ${validation.keyData.name} (${validation.keyData.id}) in ${authDuration}ms`); + logger.api(` User-Agent: "${userAgent}"`); next(); } catch (error) { diff --git a/src/routes/admin.js b/src/routes/admin.js index 16311a0f..3788a86c 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -12,6 +12,7 @@ const claudeCodeHeadersService = require('../services/claudeCodeHeadersService') const axios = require('axios'); const fs = require('fs'); const path = require('path'); +const config = require('../../config/config'); const router = express.Router(); @@ -236,6 +237,21 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { } }); +// 获取支持的客户端列表 +router.get('/supported-clients', authenticateAdmin, async (req, res) => { + try { + const clients = config.clientRestrictions.predefinedClients.map(client => ({ + id: client.id, + name: client.name, + description: client.description + })); + res.json({ success: true, data: clients }); + } catch (error) { + logger.error('❌ Failed to get supported clients:', error); + res.status(500).json({ error: 'Failed to get supported clients', message: error.message }); + } +}); + // 创建新的API Key router.post('/api-keys', authenticateAdmin, async (req, res) => { try { @@ -251,7 +267,9 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { rateLimitWindow, rateLimitRequests, enableModelRestriction, - restrictedModels + restrictedModels, + enableClientRestriction, + allowedClients } = req.body; // 输入验证 @@ -293,6 +311,15 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { return res.status(400).json({ error: 'Restricted models must be an array' }); } + // 验证客户端限制字段 + if (enableClientRestriction !== undefined && typeof enableClientRestriction !== 'boolean') { + return res.status(400).json({ error: 'Enable client restriction must be a boolean' }); + } + + if (allowedClients !== undefined && !Array.isArray(allowedClients)) { + return res.status(400).json({ error: 'Allowed clients must be an array' }); + } + const newKey = await apiKeyService.generateApiKey({ name, description, @@ -305,7 +332,9 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { rateLimitWindow, rateLimitRequests, enableModelRestriction, - restrictedModels + restrictedModels, + enableClientRestriction, + allowedClients }); logger.success(`🔑 Admin created new API key: ${name}`); @@ -320,7 +349,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { try { const { keyId } = req.params; - const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels, expiresAt } = req.body; + const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, expiresAt } = req.body; // 只允许更新指定字段 const updates = {}; @@ -386,6 +415,21 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.restrictedModels = restrictedModels; } + // 处理客户端限制字段 + if (enableClientRestriction !== undefined) { + if (typeof enableClientRestriction !== 'boolean') { + return res.status(400).json({ error: 'Enable client restriction must be a boolean' }); + } + updates.enableClientRestriction = enableClientRestriction; + } + + if (allowedClients !== undefined) { + if (!Array.isArray(allowedClients)) { + return res.status(400).json({ error: 'Allowed clients must be an array' }); + } + updates.allowedClients = allowedClients; + } + // 处理过期时间字段 if (expiresAt !== undefined) { if (expiresAt === null) { diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 718de0fc..2541b352 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -24,7 +24,9 @@ class ApiKeyService { rateLimitWindow = null, rateLimitRequests = null, enableModelRestriction = false, - restrictedModels = [] + restrictedModels = [], + enableClientRestriction = false, + allowedClients = [] } = options; // 生成简单的API Key (64字符十六进制) @@ -47,6 +49,8 @@ class ApiKeyService { permissions: permissions || 'all', enableModelRestriction: String(enableModelRestriction), restrictedModels: JSON.stringify(restrictedModels || []), + enableClientRestriction: String(enableClientRestriction || false), + allowedClients: JSON.stringify(allowedClients || []), createdAt: new Date().toISOString(), lastUsedAt: '', expiresAt: expiresAt || '', @@ -73,6 +77,8 @@ class ApiKeyService { permissions: keyData.permissions, enableModelRestriction: keyData.enableModelRestriction === 'true', restrictedModels: JSON.parse(keyData.restrictedModels), + enableClientRestriction: keyData.enableClientRestriction === 'true', + allowedClients: JSON.parse(keyData.allowedClients || '[]'), createdAt: keyData.createdAt, expiresAt: keyData.expiresAt, createdBy: keyData.createdBy @@ -122,6 +128,14 @@ class ApiKeyService { restrictedModels = []; } + // 解析允许的客户端 + let allowedClients = []; + try { + allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : []; + } catch (e) { + allowedClients = []; + } + return { valid: true, keyData: { @@ -136,6 +150,8 @@ class ApiKeyService { rateLimitRequests: parseInt(keyData.rateLimitRequests || 0), enableModelRestriction: keyData.enableModelRestriction === 'true', restrictedModels: restrictedModels, + enableClientRestriction: keyData.enableClientRestriction === 'true', + allowedClients: allowedClients, usage } }; @@ -160,12 +176,18 @@ class ApiKeyService { key.currentConcurrency = await redis.getConcurrency(key.id); key.isActive = key.isActive === 'true'; key.enableModelRestriction = key.enableModelRestriction === 'true'; + key.enableClientRestriction = key.enableClientRestriction === 'true'; key.permissions = key.permissions || 'all'; // 兼容旧数据 try { key.restrictedModels = key.restrictedModels ? JSON.parse(key.restrictedModels) : []; } catch (e) { key.restrictedModels = []; } + try { + key.allowedClients = key.allowedClients ? JSON.parse(key.allowedClients) : []; + } catch (e) { + key.allowedClients = []; + } delete key.apiKey; // 不返回哈希后的key } @@ -185,15 +207,15 @@ class ApiKeyService { } // 允许更新的字段 - const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'geminiAccountId', 'permissions', 'expiresAt', 'enableModelRestriction', 'restrictedModels']; + const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'geminiAccountId', 'permissions', 'expiresAt', 'enableModelRestriction', 'restrictedModels', 'enableClientRestriction', 'allowedClients']; const updatedData = { ...keyData }; for (const [field, value] of Object.entries(updates)) { if (allowedUpdates.includes(field)) { - if (field === 'restrictedModels') { - // 特殊处理 restrictedModels 数组 + if (field === 'restrictedModels' || field === 'allowedClients') { + // 特殊处理数组字段 updatedData[field] = JSON.stringify(value || []); - } else if (field === 'enableModelRestriction') { + } else if (field === 'enableModelRestriction' || field === 'enableClientRestriction') { // 布尔值转字符串 updatedData[field] = String(value); } else { diff --git a/web/admin/app.js b/web/admin/app.js index be8421cd..9d7714c4 100644 --- a/web/admin/app.js +++ b/web/admin/app.js @@ -127,6 +127,8 @@ const app = createApp({ enableModelRestriction: false, restrictedModels: [], modelInput: '', + enableClientRestriction: false, + allowedClients: [], expireDuration: '', // 过期时长选择 customExpireDate: '', // 自定义过期日期 expiresAt: null // 实际的过期时间戳 @@ -186,9 +188,14 @@ const app = createApp({ permissions: 'all', enableModelRestriction: false, restrictedModels: [], - modelInput: '' + modelInput: '', + enableClientRestriction: false, + allowedClients: [] }, + // 支持的客户端列表 + supportedClients: [], + // 账户 accounts: [], accountsLoading: false, @@ -346,10 +353,11 @@ const app = createApp({ // 初始化日期筛选器和图表数据 this.initializeDateFilter(); - // 预加载账号列表和API Keys,以便正确显示绑定关系 + // 预加载账号列表、API Keys和支持的客户端,以便正确显示绑定关系 Promise.all([ this.loadAccounts(), - this.loadApiKeys() + this.loadApiKeys(), + this.loadSupportedClients() ]).then(() => { // 根据当前活跃标签页加载数据 this.loadCurrentTabData(); @@ -1778,6 +1786,18 @@ const app = createApp({ } }, + async loadSupportedClients() { + try { + const data = await this.apiRequest('/admin/supported-clients'); + if (data && data.success) { + this.supportedClients = data.data || []; + console.log('Loaded supported clients:', this.supportedClients); + } + } catch (error) { + console.error('Failed to load supported clients:', error); + } + }, + async loadApiKeys() { this.apiKeysLoading = true; console.log('Loading API Keys with time range:', this.apiKeyStatsTimeRange); @@ -1916,6 +1936,8 @@ const app = createApp({ permissions: this.apiKeyForm.permissions || 'all', enableModelRestriction: this.apiKeyForm.enableModelRestriction, restrictedModels: this.apiKeyForm.restrictedModels, + enableClientRestriction: this.apiKeyForm.enableClientRestriction, + allowedClients: this.apiKeyForm.allowedClients, expiresAt: this.apiKeyForm.expiresAt }) }); @@ -1950,6 +1972,8 @@ const app = createApp({ enableModelRestriction: false, restrictedModels: [], modelInput: '', + enableClientRestriction: false, + allowedClients: [], expireDuration: '', customExpireDate: '', expiresAt: null @@ -2117,7 +2141,9 @@ const app = createApp({ permissions: key.permissions || 'all', enableModelRestriction: key.enableModelRestriction || false, restrictedModels: key.restrictedModels ? [...key.restrictedModels] : [], - modelInput: '' + modelInput: '', + enableClientRestriction: key.enableClientRestriction || false, + allowedClients: key.allowedClients ? [...key.allowedClients] : [] }; this.showEditApiKeyModal = true; }, @@ -2136,7 +2162,9 @@ const app = createApp({ permissions: 'all', enableModelRestriction: false, restrictedModels: [], - modelInput: '' + modelInput: '', + enableClientRestriction: false, + allowedClients: [] }; }, @@ -2154,7 +2182,9 @@ const app = createApp({ geminiAccountId: this.editApiKeyForm.geminiAccountId || null, permissions: this.editApiKeyForm.permissions || 'all', enableModelRestriction: this.editApiKeyForm.enableModelRestriction, - restrictedModels: this.editApiKeyForm.restrictedModels + restrictedModels: this.editApiKeyForm.restrictedModels, + enableClientRestriction: this.editApiKeyForm.enableClientRestriction, + allowedClients: this.editApiKeyForm.allowedClients }) }); diff --git a/web/admin/index.html b/web/admin/index.html index 8003b1b3..dfc0e764 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -2205,6 +2205,43 @@ + +
+
+ + +
+ +
+
+ +

勾选允许使用此API Key的客户端

+
+
+ + +
+
+
+
+
+
+ +
+
+ + +
+ +
+
+ +

勾选允许使用此API Key的客户端

+
+
+ + +
+
+
+
+
+