mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 20:41:03 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc57133230 | ||
|
|
1f06af4a56 | ||
|
|
6165fad090 | ||
|
|
d53a399d41 | ||
|
|
982cca1020 | ||
|
|
792ba51290 | ||
|
|
74d138a2fb | ||
|
|
b88698191e | ||
|
|
11c38b23d1 | ||
|
|
b2dfc2eb25 | ||
|
|
59ce0f091c | ||
|
|
67c20fa30e | ||
|
|
671451253f |
@@ -1,5 +1,10 @@
|
||||
# Claude Relay Service
|
||||
|
||||
> [!CAUTION]
|
||||
> **安全更新通知**:v1.1.248 及以下版本存在严重的管理员认证绕过漏洞,攻击者可未授权访问管理面板。
|
||||
>
|
||||
> **请立即更新到 v1.1.249+ 版本**,或迁移到新一代项目 **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Claude Relay Service
|
||||
|
||||
> [!CAUTION]
|
||||
> **Security Update**: v1.1.248 and below contain a critical admin authentication bypass vulnerability allowing unauthorized access to the admin panel.
|
||||
>
|
||||
> **Please update to v1.1.249+ immediately**, or migrate to the next-generation project **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -892,6 +892,7 @@
|
||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
@@ -3000,6 +3001,7 @@
|
||||
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -3081,6 +3083,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3536,6 +3539,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001737",
|
||||
"electron-to-chromium": "^1.5.211",
|
||||
@@ -4423,6 +4427,7 @@
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -4479,6 +4484,7 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -7586,6 +7592,7 @@
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -9104,6 +9111,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz",
|
||||
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@colors/colors": "^1.6.0",
|
||||
"@dabh/diagnostics": "^2.0.2",
|
||||
|
||||
74
src/app.js
74
src/app.js
@@ -68,6 +68,10 @@ class Application {
|
||||
logger.info('🔄 Initializing admin credentials...')
|
||||
await this.initializeAdmin()
|
||||
|
||||
// 🔒 安全启动:清理无效/伪造的管理员会话
|
||||
logger.info('🔒 Cleaning up invalid admin sessions...')
|
||||
await this.cleanupInvalidSessions()
|
||||
|
||||
// 💰 初始化费用数据
|
||||
logger.info('💰 Checking cost data initialization...')
|
||||
const costInitService = require('./services/costInitService')
|
||||
@@ -426,6 +430,54 @@ class Application {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔒 清理无效/伪造的管理员会话(安全启动检查)
|
||||
async cleanupInvalidSessions() {
|
||||
try {
|
||||
const client = redis.getClient()
|
||||
|
||||
// 获取所有 session:* 键
|
||||
const sessionKeys = await client.keys('session:*')
|
||||
|
||||
let validCount = 0
|
||||
let invalidCount = 0
|
||||
|
||||
for (const key of sessionKeys) {
|
||||
// 跳过 admin_credentials(系统凭据)
|
||||
if (key === 'session:admin_credentials') {
|
||||
continue
|
||||
}
|
||||
|
||||
const sessionData = await client.hgetall(key)
|
||||
|
||||
// 检查会话完整性:必须有 username 和 loginTime
|
||||
const hasUsername = !!sessionData.username
|
||||
const hasLoginTime = !!sessionData.loginTime
|
||||
|
||||
if (!hasUsername || !hasLoginTime) {
|
||||
// 无效会话 - 可能是漏洞利用创建的伪造会话
|
||||
invalidCount++
|
||||
logger.security(
|
||||
`🔒 Removing invalid session: ${key} (username: ${hasUsername}, loginTime: ${hasLoginTime})`
|
||||
)
|
||||
await client.del(key)
|
||||
} else {
|
||||
validCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidCount > 0) {
|
||||
logger.security(`🔒 Startup security check: Removed ${invalidCount} invalid sessions`)
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`✅ Session cleanup completed: ${validCount} valid, ${invalidCount} invalid removed`
|
||||
)
|
||||
} catch (error) {
|
||||
// 清理失败不应阻止服务启动
|
||||
logger.error('❌ Failed to cleanup invalid sessions:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 Redis健康检查
|
||||
async checkRedisHealth() {
|
||||
try {
|
||||
@@ -581,10 +633,11 @@ class Application {
|
||||
|
||||
const now = Date.now()
|
||||
let totalCleaned = 0
|
||||
let legacyCleaned = 0
|
||||
|
||||
// 使用 Lua 脚本批量清理所有过期项
|
||||
for (const key of keys) {
|
||||
// 跳过非 Sorted Set 类型的键(这些键有各自的清理逻辑)
|
||||
// 跳过已知非 Sorted Set 类型的键(这些键有各自的清理逻辑)
|
||||
// - concurrency:queue:stats:* 是 Hash 类型
|
||||
// - concurrency:queue:wait_times:* 是 List 类型
|
||||
// - concurrency:queue:* (不含stats/wait_times) 是 String 类型
|
||||
@@ -599,11 +652,21 @@ class Application {
|
||||
}
|
||||
|
||||
try {
|
||||
const cleaned = await redis.client.eval(
|
||||
// 使用原子 Lua 脚本:先检查类型,再执行清理
|
||||
// 返回值:0 = 正常清理无删除,1 = 清理后删除空键,-1 = 遗留键已删除
|
||||
const result = await redis.client.eval(
|
||||
`
|
||||
local key = KEYS[1]
|
||||
local now = tonumber(ARGV[1])
|
||||
|
||||
-- 先检查键类型,只对 Sorted Set 执行清理
|
||||
local keyType = redis.call('TYPE', key)
|
||||
if keyType.ok ~= 'zset' then
|
||||
-- 非 ZSET 类型的遗留键,直接删除
|
||||
redis.call('DEL', key)
|
||||
return -1
|
||||
end
|
||||
|
||||
-- 清理过期项
|
||||
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
|
||||
|
||||
@@ -622,8 +685,10 @@ class Application {
|
||||
key,
|
||||
now
|
||||
)
|
||||
if (cleaned === 1) {
|
||||
if (result === 1) {
|
||||
totalCleaned++
|
||||
} else if (result === -1) {
|
||||
legacyCleaned++
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to clean concurrency key ${key}:`, error)
|
||||
@@ -633,6 +698,9 @@ class Application {
|
||||
if (totalCleaned > 0) {
|
||||
logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`)
|
||||
}
|
||||
if (legacyCleaned > 0) {
|
||||
logger.warn(`🧹 Concurrency cleanup: removed ${legacyCleaned} legacy keys (wrong type)`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Concurrency cleanup task failed:', error)
|
||||
}
|
||||
|
||||
@@ -1389,6 +1389,18 @@ const authenticateAdmin = async (req, res, next) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 🔒 安全修复:验证会话必须字段(防止伪造会话绕过认证)
|
||||
if (!adminSession.username || !adminSession.loginTime) {
|
||||
logger.security(
|
||||
`🔒 Corrupted admin session from ${req.ip || 'unknown'} - missing required fields (username: ${!!adminSession.username}, loginTime: ${!!adminSession.loginTime})`
|
||||
)
|
||||
await redis.deleteSession(token) // 清理无效/伪造的会话
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session',
|
||||
message: 'Session data corrupted or incomplete'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查会话活跃性(可选:检查最后活动时间)
|
||||
const now = new Date()
|
||||
const lastActivity = new Date(adminSession.lastActivity || adminSession.loginTime)
|
||||
|
||||
@@ -2140,6 +2140,27 @@ class RedisClient {
|
||||
const results = []
|
||||
|
||||
for (const key of keys) {
|
||||
// 跳过已知非 Sorted Set 类型的键
|
||||
// - concurrency:queue:stats:* 是 Hash 类型
|
||||
// - concurrency:queue:wait_times:* 是 List 类型
|
||||
// - concurrency:queue:* (不含stats/wait_times) 是 String 类型
|
||||
if (
|
||||
key.startsWith('concurrency:queue:stats:') ||
|
||||
key.startsWith('concurrency:queue:wait_times:') ||
|
||||
(key.startsWith('concurrency:queue:') &&
|
||||
!key.includes(':stats:') &&
|
||||
!key.includes(':wait_times:'))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查键类型,只处理 Sorted Set
|
||||
const keyType = await client.type(key)
|
||||
if (keyType !== 'zset') {
|
||||
logger.debug(`🔢 getAllConcurrencyStatus skipped non-zset key: ${key} (type: ${keyType})`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 提取 apiKeyId(去掉 concurrency: 前缀)
|
||||
const apiKeyId = key.replace('concurrency:', '')
|
||||
|
||||
@@ -2202,6 +2223,23 @@ class RedisClient {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查键类型,只处理 Sorted Set
|
||||
const keyType = await client.type(key)
|
||||
if (keyType !== 'zset') {
|
||||
logger.warn(
|
||||
`⚠️ getConcurrencyStatus: key ${key} has unexpected type: ${keyType}, expected zset`
|
||||
)
|
||||
return {
|
||||
apiKeyId,
|
||||
key,
|
||||
activeCount: 0,
|
||||
expiredCount: 0,
|
||||
activeRequests: [],
|
||||
exists: true,
|
||||
invalidType: keyType
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有成员和分数
|
||||
const allMembers = await client.zrange(key, 0, -1, 'WITHSCORES')
|
||||
|
||||
@@ -2251,20 +2289,36 @@ class RedisClient {
|
||||
const client = this.getClientSafe()
|
||||
const key = `concurrency:${apiKeyId}`
|
||||
|
||||
// 获取清理前的状态
|
||||
const beforeCount = await client.zcard(key)
|
||||
// 检查键类型
|
||||
const keyType = await client.type(key)
|
||||
|
||||
// 删除整个 key
|
||||
let beforeCount = 0
|
||||
let isLegacy = false
|
||||
|
||||
if (keyType === 'zset') {
|
||||
// 正常的 zset 键,获取条目数
|
||||
beforeCount = await client.zcard(key)
|
||||
} else if (keyType !== 'none') {
|
||||
// 非 zset 且非空的遗留键
|
||||
isLegacy = true
|
||||
logger.warn(
|
||||
`⚠️ forceClearConcurrency: key ${key} has unexpected type: ${keyType}, will be deleted`
|
||||
)
|
||||
}
|
||||
|
||||
// 删除键(无论什么类型)
|
||||
await client.del(key)
|
||||
|
||||
logger.warn(
|
||||
`🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries`
|
||||
`🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries${isLegacy ? ' (legacy key)' : ''}`
|
||||
)
|
||||
|
||||
return {
|
||||
apiKeyId,
|
||||
key,
|
||||
clearedCount: beforeCount,
|
||||
type: keyType,
|
||||
legacy: isLegacy,
|
||||
success: true
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -2283,25 +2337,47 @@ class RedisClient {
|
||||
const keys = await client.keys('concurrency:*')
|
||||
|
||||
let totalCleared = 0
|
||||
let legacyCleared = 0
|
||||
const clearedKeys = []
|
||||
|
||||
for (const key of keys) {
|
||||
// 跳过 queue 相关的键(它们有各自的清理逻辑)
|
||||
if (key.startsWith('concurrency:queue:')) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查键类型
|
||||
const keyType = await client.type(key)
|
||||
if (keyType === 'zset') {
|
||||
const count = await client.zcard(key)
|
||||
await client.del(key)
|
||||
totalCleared += count
|
||||
clearedKeys.push({
|
||||
key,
|
||||
clearedCount: count
|
||||
clearedCount: count,
|
||||
type: 'zset'
|
||||
})
|
||||
} else {
|
||||
// 非 zset 类型的遗留键,直接删除
|
||||
await client.del(key)
|
||||
legacyCleared++
|
||||
clearedKeys.push({
|
||||
key,
|
||||
clearedCount: 0,
|
||||
type: keyType,
|
||||
legacy: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`🧹 Force cleared all concurrency: ${keys.length} keys, ${totalCleared} total entries`
|
||||
`🧹 Force cleared all concurrency: ${clearedKeys.length} keys, ${totalCleared} entries, ${legacyCleared} legacy keys`
|
||||
)
|
||||
|
||||
return {
|
||||
keysCleared: keys.length,
|
||||
keysCleared: clearedKeys.length,
|
||||
totalEntriesCleared: totalCleared,
|
||||
legacyKeysCleared: legacyCleared,
|
||||
clearedKeys,
|
||||
success: true
|
||||
}
|
||||
@@ -2329,9 +2405,30 @@ class RedisClient {
|
||||
}
|
||||
|
||||
let totalCleaned = 0
|
||||
let legacyCleaned = 0
|
||||
const cleanedKeys = []
|
||||
|
||||
for (const key of keys) {
|
||||
// 跳过 queue 相关的键(它们有各自的清理逻辑)
|
||||
if (key.startsWith('concurrency:queue:')) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查键类型
|
||||
const keyType = await client.type(key)
|
||||
if (keyType !== 'zset') {
|
||||
// 非 zset 类型的遗留键,直接删除
|
||||
await client.del(key)
|
||||
legacyCleaned++
|
||||
cleanedKeys.push({
|
||||
key,
|
||||
cleanedCount: 0,
|
||||
type: keyType,
|
||||
legacy: true
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 只清理过期的条目
|
||||
const cleaned = await client.zremrangebyscore(key, '-inf', now)
|
||||
if (cleaned > 0) {
|
||||
@@ -2350,13 +2447,14 @@ class RedisClient {
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🧹 Cleaned up expired concurrency: ${totalCleaned} entries from ${cleanedKeys.length} keys`
|
||||
`🧹 Cleaned up expired concurrency: ${totalCleaned} entries from ${cleanedKeys.length} keys, ${legacyCleaned} legacy keys removed`
|
||||
)
|
||||
|
||||
return {
|
||||
keysProcessed: keys.length,
|
||||
keysCleaned: cleanedKeys.length,
|
||||
totalEntriesCleaned: totalCleaned,
|
||||
legacyKeysRemoved: legacyCleaned,
|
||||
cleanedKeys,
|
||||
success: true
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const usageStatsRoutes = require('./usageStats')
|
||||
const systemRoutes = require('./system')
|
||||
const concurrencyRoutes = require('./concurrency')
|
||||
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
||||
const syncRoutes = require('./sync')
|
||||
|
||||
// 挂载所有子路由
|
||||
// 使用完整路径的模块(直接挂载到根路径)
|
||||
@@ -39,6 +40,7 @@ router.use('/', usageStatsRoutes)
|
||||
router.use('/', systemRoutes)
|
||||
router.use('/', concurrencyRoutes)
|
||||
router.use('/', claudeRelayConfigRoutes)
|
||||
router.use('/', syncRoutes)
|
||||
|
||||
// 使用相对路径的模块(需要指定基础路径前缀)
|
||||
router.use('/account-groups', accountGroupsRoutes)
|
||||
|
||||
460
src/routes/admin/sync.js
Normal file
460
src/routes/admin/sync.js
Normal file
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* Admin Routes - Sync / Export (for migration)
|
||||
* Exports account data (including secrets) for safe server-to-server syncing.
|
||||
*/
|
||||
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
const redis = require('../../models/redis')
|
||||
const claudeAccountService = require('../../services/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
|
||||
const openaiAccountService = require('../../services/openaiAccountService')
|
||||
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||
const logger = require('../../utils/logger')
|
||||
|
||||
function toBool(value, defaultValue = false) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return defaultValue
|
||||
}
|
||||
if (value === true || value === 'true') {
|
||||
return true
|
||||
}
|
||||
if (value === false || value === 'false') {
|
||||
return false
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
function normalizeProxy(proxy) {
|
||||
if (!proxy || typeof proxy !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const protocol = proxy.protocol || proxy.type || proxy.scheme || ''
|
||||
const host = proxy.host || ''
|
||||
const port = Number(proxy.port || 0)
|
||||
|
||||
if (!protocol || !host || !Number.isFinite(port) || port <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
protocol: String(protocol),
|
||||
host: String(host),
|
||||
port,
|
||||
username: proxy.username ? String(proxy.username) : '',
|
||||
password: proxy.password ? String(proxy.password) : ''
|
||||
}
|
||||
}
|
||||
|
||||
function buildModelMappingFromSupportedModels(supportedModels) {
|
||||
if (!supportedModels) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Array.isArray(supportedModels)) {
|
||||
const mapping = {}
|
||||
for (const model of supportedModels) {
|
||||
if (typeof model === 'string' && model.trim()) {
|
||||
mapping[model.trim()] = model.trim()
|
||||
}
|
||||
}
|
||||
return Object.keys(mapping).length ? mapping : null
|
||||
}
|
||||
|
||||
if (typeof supportedModels === 'object') {
|
||||
const mapping = {}
|
||||
for (const [from, to] of Object.entries(supportedModels)) {
|
||||
if (typeof from === 'string' && typeof to === 'string' && from.trim() && to.trim()) {
|
||||
mapping[from.trim()] = to.trim()
|
||||
}
|
||||
}
|
||||
return Object.keys(mapping).length ? mapping : null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function safeParseJson(raw, fallback = null) {
|
||||
if (!raw || typeof raw !== 'string') {
|
||||
return fallback
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch (_) {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Export accounts for migration (includes secrets).
|
||||
// GET /admin/sync/export-accounts?include_secrets=true
|
||||
router.get('/sync/export-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const includeSecrets = toBool(req.query.include_secrets, false)
|
||||
if (!includeSecrets) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'include_secrets_required',
|
||||
message: 'Set include_secrets=true to export secrets'
|
||||
})
|
||||
}
|
||||
|
||||
// ===== Claude official OAuth / Setup Token accounts =====
|
||||
const rawClaudeAccounts = await redis.getAllClaudeAccounts()
|
||||
const claudeAccounts = rawClaudeAccounts.map((account) => {
|
||||
// Backward compatible extraction: prefer individual fields, fallback to claudeAiOauth JSON blob.
|
||||
let decryptedClaudeAiOauth = null
|
||||
if (account.claudeAiOauth) {
|
||||
try {
|
||||
const raw = claudeAccountService._decryptSensitiveData(account.claudeAiOauth)
|
||||
decryptedClaudeAiOauth = raw ? JSON.parse(raw) : null
|
||||
} catch (_) {
|
||||
decryptedClaudeAiOauth = null
|
||||
}
|
||||
}
|
||||
|
||||
const rawScopes =
|
||||
account.scopes && account.scopes.trim()
|
||||
? account.scopes
|
||||
: decryptedClaudeAiOauth?.scopes
|
||||
? decryptedClaudeAiOauth.scopes.join(' ')
|
||||
: ''
|
||||
|
||||
const scopes = rawScopes && rawScopes.trim() ? rawScopes.trim().split(' ') : []
|
||||
const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference')
|
||||
const authType = isOAuth ? 'oauth' : 'setup-token'
|
||||
|
||||
const accessToken =
|
||||
account.accessToken && String(account.accessToken).trim()
|
||||
? claudeAccountService._decryptSensitiveData(account.accessToken)
|
||||
: decryptedClaudeAiOauth?.accessToken || ''
|
||||
|
||||
const refreshToken =
|
||||
account.refreshToken && String(account.refreshToken).trim()
|
||||
? claudeAccountService._decryptSensitiveData(account.refreshToken)
|
||||
: decryptedClaudeAiOauth?.refreshToken || ''
|
||||
|
||||
let expiresAt = null
|
||||
const expiresAtMs = Number.parseInt(account.expiresAt, 10)
|
||||
if (Number.isFinite(expiresAtMs) && expiresAtMs > 0) {
|
||||
expiresAt = new Date(expiresAtMs).toISOString()
|
||||
} else if (decryptedClaudeAiOauth?.expiresAt) {
|
||||
try {
|
||||
expiresAt = new Date(Number(decryptedClaudeAiOauth.expiresAt)).toISOString()
|
||||
} catch (_) {
|
||||
expiresAt = null
|
||||
}
|
||||
}
|
||||
|
||||
const proxy = account.proxy ? normalizeProxy(safeParseJson(account.proxy)) : null
|
||||
|
||||
// 🔧 Parse subscriptionInfo to extract org_uuid and account_uuid
|
||||
let orgUuid = null
|
||||
let accountUuid = null
|
||||
if (account.subscriptionInfo) {
|
||||
try {
|
||||
const subscriptionInfo = JSON.parse(account.subscriptionInfo)
|
||||
orgUuid = subscriptionInfo.organizationUuid || null
|
||||
accountUuid = subscriptionInfo.accountUuid || null
|
||||
} catch (_) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 Calculate expires_in from expires_at
|
||||
let expiresIn = null
|
||||
if (expiresAt) {
|
||||
try {
|
||||
const expiresAtTime = new Date(expiresAt).getTime()
|
||||
const nowTime = Date.now()
|
||||
const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000)
|
||||
if (diffSeconds > 0) {
|
||||
expiresIn = diffSeconds
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore calculation errors
|
||||
}
|
||||
}
|
||||
// 🔧 Use default expires_in if calculation failed (Anthropic OAuth: 8 hours)
|
||||
if (!expiresIn && isOAuth) {
|
||||
expiresIn = 28800 // 8 hours
|
||||
}
|
||||
|
||||
const credentials = {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken || undefined,
|
||||
expires_at: expiresAt || undefined,
|
||||
expires_in: expiresIn || undefined,
|
||||
scope: scopes.join(' ') || undefined,
|
||||
token_type: 'Bearer'
|
||||
}
|
||||
// 🔧 Add auth info as top-level credentials fields
|
||||
if (orgUuid) {
|
||||
credentials.org_uuid = orgUuid
|
||||
}
|
||||
if (accountUuid) {
|
||||
credentials.account_uuid = accountUuid
|
||||
}
|
||||
|
||||
// 🔧 Store complete original CRS data in extra
|
||||
const extra = {
|
||||
crs_account_id: account.id,
|
||||
crs_kind: 'claude-account',
|
||||
crs_id: account.id,
|
||||
crs_name: account.name,
|
||||
crs_description: account.description || '',
|
||||
crs_platform: account.platform || 'claude',
|
||||
crs_auth_type: authType,
|
||||
crs_is_active: account.isActive === 'true',
|
||||
crs_schedulable: account.schedulable !== 'false',
|
||||
crs_priority: Number.parseInt(account.priority, 10) || 50,
|
||||
crs_status: account.status || 'active',
|
||||
crs_scopes: scopes,
|
||||
crs_subscription_info: account.subscriptionInfo || undefined
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'claude-account',
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
description: account.description || '',
|
||||
platform: account.platform || 'claude',
|
||||
authType,
|
||||
isActive: account.isActive === 'true',
|
||||
schedulable: account.schedulable !== 'false',
|
||||
priority: Number.parseInt(account.priority, 10) || 50,
|
||||
status: account.status || 'active',
|
||||
proxy,
|
||||
credentials,
|
||||
extra
|
||||
}
|
||||
})
|
||||
|
||||
// ===== Claude Console API Key accounts =====
|
||||
const claudeConsoleSummaries = await claudeConsoleAccountService.getAllAccounts()
|
||||
const claudeConsoleAccounts = []
|
||||
for (const summary of claudeConsoleSummaries) {
|
||||
const full = await claudeConsoleAccountService.getAccount(summary.id)
|
||||
if (!full) {
|
||||
continue
|
||||
}
|
||||
|
||||
const proxy = normalizeProxy(full.proxy)
|
||||
const modelMapping = buildModelMappingFromSupportedModels(full.supportedModels)
|
||||
|
||||
const credentials = {
|
||||
api_key: full.apiKey,
|
||||
base_url: full.apiUrl
|
||||
}
|
||||
|
||||
if (modelMapping) {
|
||||
credentials.model_mapping = modelMapping
|
||||
}
|
||||
|
||||
if (full.userAgent) {
|
||||
credentials.user_agent = full.userAgent
|
||||
}
|
||||
|
||||
claudeConsoleAccounts.push({
|
||||
kind: 'claude-console-account',
|
||||
id: full.id,
|
||||
name: full.name,
|
||||
description: full.description || '',
|
||||
platform: full.platform || 'claude-console',
|
||||
isActive: full.isActive === true,
|
||||
schedulable: full.schedulable !== false,
|
||||
priority: Number.parseInt(full.priority, 10) || 50,
|
||||
status: full.status || 'active',
|
||||
proxy,
|
||||
maxConcurrentTasks: Number.parseInt(full.maxConcurrentTasks, 10) || 0,
|
||||
credentials,
|
||||
extra: {
|
||||
crs_account_id: full.id,
|
||||
crs_kind: 'claude-console-account',
|
||||
crs_id: full.id,
|
||||
crs_name: full.name,
|
||||
crs_description: full.description || '',
|
||||
crs_platform: full.platform || 'claude-console',
|
||||
crs_is_active: full.isActive === true,
|
||||
crs_schedulable: full.schedulable !== false,
|
||||
crs_priority: Number.parseInt(full.priority, 10) || 50,
|
||||
crs_status: full.status || 'active'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===== OpenAI OAuth accounts =====
|
||||
const openaiOAuthAccounts = []
|
||||
{
|
||||
const client = redis.getClientSafe()
|
||||
const openaiKeys = await client.keys('openai:account:*')
|
||||
for (const key of openaiKeys) {
|
||||
const id = key.split(':').slice(2).join(':')
|
||||
const account = await openaiAccountService.getAccount(id)
|
||||
if (!account) {
|
||||
continue
|
||||
}
|
||||
|
||||
const accessToken = account.accessToken
|
||||
? openaiAccountService.decrypt(account.accessToken)
|
||||
: ''
|
||||
if (!accessToken) {
|
||||
// Skip broken/legacy records without decryptable token
|
||||
continue
|
||||
}
|
||||
|
||||
const scopes =
|
||||
account.scopes && typeof account.scopes === 'string' && account.scopes.trim()
|
||||
? account.scopes.trim().split(' ')
|
||||
: []
|
||||
|
||||
const proxy = normalizeProxy(account.proxy)
|
||||
|
||||
// 🔧 Calculate expires_in from expires_at
|
||||
let expiresIn = null
|
||||
if (account.expiresAt) {
|
||||
try {
|
||||
const expiresAtTime = new Date(account.expiresAt).getTime()
|
||||
const nowTime = Date.now()
|
||||
const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000)
|
||||
if (diffSeconds > 0) {
|
||||
expiresIn = diffSeconds
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore calculation errors
|
||||
}
|
||||
}
|
||||
// 🔧 Use default expires_in if calculation failed (OpenAI OAuth: 10 days)
|
||||
if (!expiresIn) {
|
||||
expiresIn = 864000 // 10 days
|
||||
}
|
||||
|
||||
const credentials = {
|
||||
access_token: accessToken,
|
||||
refresh_token: account.refreshToken || undefined,
|
||||
id_token: account.idToken || undefined,
|
||||
expires_at: account.expiresAt || undefined,
|
||||
expires_in: expiresIn || undefined,
|
||||
scope: scopes.join(' ') || undefined,
|
||||
token_type: 'Bearer'
|
||||
}
|
||||
// 🔧 Add auth info as top-level credentials fields
|
||||
if (account.accountId) {
|
||||
credentials.chatgpt_account_id = account.accountId
|
||||
}
|
||||
if (account.chatgptUserId) {
|
||||
credentials.chatgpt_user_id = account.chatgptUserId
|
||||
}
|
||||
if (account.organizationId) {
|
||||
credentials.organization_id = account.organizationId
|
||||
}
|
||||
|
||||
// 🔧 Store complete original CRS data in extra
|
||||
const extra = {
|
||||
crs_account_id: account.id,
|
||||
crs_kind: 'openai-oauth-account',
|
||||
crs_id: account.id,
|
||||
crs_name: account.name,
|
||||
crs_description: account.description || '',
|
||||
crs_platform: account.platform || 'openai',
|
||||
crs_is_active: account.isActive === 'true',
|
||||
crs_schedulable: account.schedulable !== 'false',
|
||||
crs_priority: Number.parseInt(account.priority, 10) || 50,
|
||||
crs_status: account.status || 'active',
|
||||
crs_scopes: scopes,
|
||||
crs_email: account.email || undefined,
|
||||
crs_chatgpt_account_id: account.accountId || undefined,
|
||||
crs_chatgpt_user_id: account.chatgptUserId || undefined,
|
||||
crs_organization_id: account.organizationId || undefined
|
||||
}
|
||||
|
||||
openaiOAuthAccounts.push({
|
||||
kind: 'openai-oauth-account',
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
description: account.description || '',
|
||||
platform: account.platform || 'openai',
|
||||
authType: 'oauth',
|
||||
isActive: account.isActive === 'true',
|
||||
schedulable: account.schedulable !== 'false',
|
||||
priority: Number.parseInt(account.priority, 10) || 50,
|
||||
status: account.status || 'active',
|
||||
proxy,
|
||||
credentials,
|
||||
extra
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ===== OpenAI Responses API Key accounts =====
|
||||
const openaiResponsesAccounts = []
|
||||
const client = redis.getClientSafe()
|
||||
const openaiResponseKeys = await client.keys('openai_responses_account:*')
|
||||
for (const key of openaiResponseKeys) {
|
||||
const id = key.split(':').slice(1).join(':')
|
||||
const full = await openaiResponsesAccountService.getAccount(id)
|
||||
if (!full) {
|
||||
continue
|
||||
}
|
||||
|
||||
const proxy = normalizeProxy(full.proxy)
|
||||
|
||||
const credentials = {
|
||||
api_key: full.apiKey,
|
||||
base_url: full.baseApi
|
||||
}
|
||||
|
||||
if (full.userAgent) {
|
||||
credentials.user_agent = full.userAgent
|
||||
}
|
||||
|
||||
openaiResponsesAccounts.push({
|
||||
kind: 'openai-responses-account',
|
||||
id: full.id,
|
||||
name: full.name,
|
||||
description: full.description || '',
|
||||
platform: full.platform || 'openai-responses',
|
||||
isActive: full.isActive === 'true',
|
||||
schedulable: full.schedulable !== 'false',
|
||||
priority: Number.parseInt(full.priority, 10) || 50,
|
||||
status: full.status || 'active',
|
||||
proxy,
|
||||
credentials,
|
||||
extra: {
|
||||
crs_account_id: full.id,
|
||||
crs_kind: 'openai-responses-account',
|
||||
crs_id: full.id,
|
||||
crs_name: full.name,
|
||||
crs_description: full.description || '',
|
||||
crs_platform: full.platform || 'openai-responses',
|
||||
crs_is_active: full.isActive === 'true',
|
||||
crs_schedulable: full.schedulable !== 'false',
|
||||
crs_priority: Number.parseInt(full.priority, 10) || 50,
|
||||
crs_status: full.status || 'active'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
exportedAt: new Date().toISOString(),
|
||||
claudeAccounts,
|
||||
claudeConsoleAccounts,
|
||||
openaiOAuthAccounts,
|
||||
openaiResponsesAccounts
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to export accounts for sync:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'export_failed',
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -164,13 +164,27 @@ router.post('/auth/change-password', async (req, res) => {
|
||||
|
||||
// 获取当前会话
|
||||
const sessionData = await redis.getSession(token)
|
||||
if (!sessionData) {
|
||||
|
||||
// 🔒 安全修复:检查空对象
|
||||
if (!sessionData || Object.keys(sessionData).length === 0) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid token',
|
||||
message: 'Session expired or invalid'
|
||||
})
|
||||
}
|
||||
|
||||
// 🔒 安全修复:验证会话完整性
|
||||
if (!sessionData.username || !sessionData.loginTime) {
|
||||
logger.security(
|
||||
`🔒 Invalid session structure in /auth/change-password from ${req.ip || 'unknown'}`
|
||||
)
|
||||
await redis.deleteSession(token)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session',
|
||||
message: 'Session data corrupted or incomplete'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取当前管理员信息
|
||||
const adminData = await redis.getSession('admin_credentials')
|
||||
if (!adminData) {
|
||||
@@ -269,13 +283,25 @@ router.get('/auth/user', async (req, res) => {
|
||||
|
||||
// 获取当前会话
|
||||
const sessionData = await redis.getSession(token)
|
||||
if (!sessionData) {
|
||||
|
||||
// 🔒 安全修复:检查空对象
|
||||
if (!sessionData || Object.keys(sessionData).length === 0) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid token',
|
||||
message: 'Session expired or invalid'
|
||||
})
|
||||
}
|
||||
|
||||
// 🔒 安全修复:验证会话完整性
|
||||
if (!sessionData.username || !sessionData.loginTime) {
|
||||
logger.security(`🔒 Invalid session structure in /auth/user from ${req.ip || 'unknown'}`)
|
||||
await redis.deleteSession(token)
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session',
|
||||
message: 'Session data corrupted or incomplete'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取管理员信息
|
||||
const adminData = await redis.getSession('admin_credentials')
|
||||
if (!adminData) {
|
||||
@@ -316,13 +342,24 @@ router.post('/auth/refresh', async (req, res) => {
|
||||
|
||||
const sessionData = await redis.getSession(token)
|
||||
|
||||
if (!sessionData) {
|
||||
// 🔒 安全修复:检查空对象(hgetall 对不存在的 key 返回 {})
|
||||
if (!sessionData || Object.keys(sessionData).length === 0) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid token',
|
||||
message: 'Session expired or invalid'
|
||||
})
|
||||
}
|
||||
|
||||
// 🔒 安全修复:验证会话完整性(必须有 username 和 loginTime)
|
||||
if (!sessionData.username || !sessionData.loginTime) {
|
||||
logger.security(`🔒 Invalid session structure detected from ${req.ip || 'unknown'}`)
|
||||
await redis.deleteSession(token) // 清理无效/伪造的会话
|
||||
return res.status(401).json({
|
||||
error: 'Invalid session',
|
||||
message: 'Session data corrupted or incomplete'
|
||||
})
|
||||
}
|
||||
|
||||
// 更新最后活动时间
|
||||
sessionData.lastActivity = new Date().toISOString()
|
||||
await redis.setSession(token, sessionData, config.security.adminSessionTimeout)
|
||||
|
||||
@@ -333,7 +333,14 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
// 发送请求到Claude API(传入回调以获取请求对象)
|
||||
const response = await this._makeClaudeRequest(
|
||||
// 🔄 403 重试机制:仅对 claude-official 类型账户(OAuth 或 Setup Token)
|
||||
const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0
|
||||
let retryCount = 0
|
||||
let response
|
||||
let shouldRetry = false
|
||||
|
||||
do {
|
||||
response = await this._makeClaudeRequest(
|
||||
processedBody,
|
||||
accessToken,
|
||||
proxyAgent,
|
||||
@@ -345,6 +352,28 @@ class ClaudeRelayService {
|
||||
options
|
||||
)
|
||||
|
||||
// 检查是否需要重试 403
|
||||
shouldRetry = response.statusCode === 403 && retryCount < maxRetries
|
||||
if (shouldRetry) {
|
||||
retryCount++
|
||||
logger.warn(
|
||||
`🔄 403 error for account ${accountId}, retry ${retryCount}/${maxRetries} after 2s`
|
||||
)
|
||||
await this._sleep(2000)
|
||||
}
|
||||
} while (shouldRetry)
|
||||
|
||||
// 如果进行了重试,记录最终结果
|
||||
if (retryCount > 0) {
|
||||
if (response.statusCode === 403) {
|
||||
logger.error(`🚫 403 error persists for account ${accountId} after ${retryCount} retries`)
|
||||
} else {
|
||||
logger.info(
|
||||
`✅ 403 retry successful for account ${accountId} on attempt ${retryCount}, got status ${response.statusCode}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
|
||||
// 因为 Claude API 限流基于请求发送时刻计算(RPM),不是请求完成时刻
|
||||
if (queueLockAcquired && queueRequestId && selectedAccountId) {
|
||||
@@ -408,9 +437,10 @@ class ClaudeRelayService {
|
||||
}
|
||||
}
|
||||
// 检查是否为403状态码(禁止访问)
|
||||
// 注意:如果进行了重试,retryCount > 0;这里的 403 是重试后最终的结果
|
||||
else if (response.statusCode === 403) {
|
||||
logger.error(
|
||||
`🚫 Forbidden error (403) detected for account ${accountId}, marking as blocked`
|
||||
`🚫 Forbidden error (403) detected for account ${accountId}${retryCount > 0 ? ` after ${retryCount} retries` : ''}, marking as blocked`
|
||||
)
|
||||
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
|
||||
}
|
||||
@@ -1517,8 +1547,10 @@ class ClaudeRelayService {
|
||||
streamTransformer = null,
|
||||
requestOptions = {},
|
||||
isDedicatedOfficialAccount = false,
|
||||
onResponseStart = null // 📬 新增:收到响应头时的回调,用于提前释放队列锁
|
||||
onResponseStart = null, // 📬 新增:收到响应头时的回调,用于提前释放队列锁
|
||||
retryCount = 0 // 🔄 403 重试计数器
|
||||
) {
|
||||
const maxRetries = 2 // 最大重试次数
|
||||
// 获取账户信息用于统一 User-Agent
|
||||
const account = await claudeAccountService.getAccount(accountId)
|
||||
|
||||
@@ -1631,6 +1663,51 @@ class ClaudeRelayService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 403 重试机制(必须在设置 res.on('data')/res.on('end') 之前处理)
|
||||
// 否则重试时旧响应的 on('end') 会与新请求产生竞态条件
|
||||
if (res.statusCode === 403) {
|
||||
const canRetry =
|
||||
this._shouldRetryOn403(accountType) &&
|
||||
retryCount < maxRetries &&
|
||||
!responseStream.headersSent
|
||||
|
||||
if (canRetry) {
|
||||
logger.warn(
|
||||
`🔄 [Stream] 403 error for account ${accountId}, retry ${retryCount + 1}/${maxRetries} after 2s`
|
||||
)
|
||||
// 消费当前响应并销毁请求
|
||||
res.resume()
|
||||
req.destroy()
|
||||
|
||||
// 等待 2 秒后递归重试
|
||||
await this._sleep(2000)
|
||||
|
||||
try {
|
||||
// 递归调用自身进行重试
|
||||
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
|
||||
body,
|
||||
accessToken,
|
||||
proxyAgent,
|
||||
clientHeaders,
|
||||
responseStream,
|
||||
usageCallback,
|
||||
accountId,
|
||||
accountType,
|
||||
sessionHash,
|
||||
streamTransformer,
|
||||
requestOptions,
|
||||
isDedicatedOfficialAccount,
|
||||
onResponseStart,
|
||||
retryCount + 1
|
||||
)
|
||||
resolve(retryResult)
|
||||
} catch (retryError) {
|
||||
reject(retryError)
|
||||
}
|
||||
return // 重要:提前返回,不设置后续的错误处理器
|
||||
}
|
||||
}
|
||||
|
||||
// 将错误处理逻辑封装在一个异步函数中
|
||||
const handleErrorResponse = async () => {
|
||||
if (res.statusCode === 401) {
|
||||
@@ -1654,8 +1731,10 @@ class ClaudeRelayService {
|
||||
)
|
||||
}
|
||||
} else if (res.statusCode === 403) {
|
||||
// 403 处理:走到这里说明重试已用尽或不适用重试,直接标记 blocked
|
||||
// 注意:重试逻辑已在 handleErrorResponse 外部提前处理
|
||||
logger.error(
|
||||
`🚫 [Stream] Forbidden error (403) detected for account ${accountId}, marking as blocked`
|
||||
`🚫 [Stream] Forbidden error (403) detected for account ${accountId}${retryCount > 0 ? ` after ${retryCount} retries` : ''}, marking as blocked`
|
||||
)
|
||||
await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash)
|
||||
} else if (res.statusCode === 529) {
|
||||
@@ -2693,6 +2772,17 @@ class ClaudeRelayService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 判断账户是否应该在 403 错误时进行重试
|
||||
// 仅 claude-official 类型账户(OAuth 或 Setup Token 授权)需要重试
|
||||
_shouldRetryOn403(accountType) {
|
||||
return accountType === 'claude-official'
|
||||
}
|
||||
|
||||
// ⏱️ 等待指定毫秒数
|
||||
_sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ClaudeRelayService()
|
||||
|
||||
Reference in New Issue
Block a user