fix: 深度代码审查修复——空队列泄漏、XSS防护、docker代理清理
- fix(provider-pool): 修复 ownsGlobalSlot=false 时空队列未清理的内存泄漏 - fix(provider-api): 新增 sanitizeProviderData/ProviderPools,对 customName 等用户输入字段做 HTML 转义,防止 XSS - fix(docker): 删除 docker-compose.yml 中的代理硬编码配置 - fix(api-server): 重构定时健康检查 timer 管理,支持热更新 enabled 状态(stopHealthCheckTimer + 状态变化追踪) - fix(constants): 提取 HEALTH_CHECK/PASSWORD/NETWORK/RETRY 常量到 constants.js - style(api-server): 移除日志中密码长度记录,防止敏感元信息泄露
This commit is contained in:
parent
617109f887
commit
740f930f34
9 changed files with 203 additions and 114 deletions
|
|
@ -14,12 +14,6 @@ services:
|
||||||
- ./configs:/app/configs
|
- ./configs:/app/configs
|
||||||
environment:
|
environment:
|
||||||
- ARGS=
|
- ARGS=
|
||||||
- HTTP_PROXY=http://host.docker.internal:10801
|
|
||||||
- http_proxy=http://host.docker.internal:10801
|
|
||||||
- HTTPS_PROXY=http://host.docker.internal:10801
|
|
||||||
- https_proxy=http://host.docker.internal:10801
|
|
||||||
- NO_PROXY=localhost,127.0.0.1,host.docker.internal
|
|
||||||
- no_proxy=localhost,127.0.0.1,host.docker.internal
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "healthcheck.js"]
|
test: ["CMD", "node", "healthcheck.js"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|
|
||||||
|
|
@ -307,13 +307,15 @@ export class ProviderPoolManager {
|
||||||
// 使用 Promise.resolve().then 避免过深的递归
|
// 使用 Promise.resolve().then 避免过深的递归
|
||||||
Promise.resolve().then(nextTask);
|
Promise.resolve().then(nextTask);
|
||||||
} else if (currentQueue.activeCount === 0) {
|
} else if (currentQueue.activeCount === 0) {
|
||||||
// 2. 如果当前提供商的所有任务都完成了,释放全局槽位
|
// 清理空队列:无论是否持有全局槽位,都应删除已无任务的队列对象
|
||||||
// 只有持有全局槽位的任务才能递减计数器,避免负值
|
if (currentQueue.waitingTasks.length === 0 &&
|
||||||
if (ownsGlobalSlot &&
|
|
||||||
currentQueue.waitingTasks.length === 0 &&
|
|
||||||
this.refreshQueues[providerType] === currentQueue) {
|
this.refreshQueues[providerType] === currentQueue) {
|
||||||
|
delete this.refreshQueues[providerType];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有持有全局槽位的任务才能递减计数器
|
||||||
|
if (ownsGlobalSlot) {
|
||||||
this.activeProviderRefreshes--;
|
this.activeProviderRefreshes--;
|
||||||
delete this.refreshQueues[providerType]; // 清理空队列
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 尝试启动下一个等待中的提供商队列
|
// 3. 尝试启动下一个等待中的提供商队列
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { initializeAPIManagement } from './api-manager.js';
|
||||||
import { createRequestHandler } from '../handlers/request-handler.js';
|
import { createRequestHandler } from '../handlers/request-handler.js';
|
||||||
import { discoverPlugins, getPluginManager } from '../core/plugin-manager.js';
|
import { discoverPlugins, getPluginManager } from '../core/plugin-manager.js';
|
||||||
import { getTLSSidecar } from '../utils/tls-sidecar.js';
|
import { getTLSSidecar } from '../utils/tls-sidecar.js';
|
||||||
|
import { HEALTH_CHECK } from '../utils/constants.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @license
|
* @license
|
||||||
|
|
@ -362,45 +363,30 @@ async function startServer() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定时健康检查
|
// 定时健康检查
|
||||||
|
// 注意:无论初始 enabled 状态如何,都注册 reloadHealthCheckTimer,
|
||||||
|
// 使得热更新时(从 disabled→enabled)config-api 能调用它启动 timer。
|
||||||
const scheduledConfig = CONFIG.SCHEDULED_HEALTH_CHECK;
|
const scheduledConfig = CONFIG.SCHEDULED_HEALTH_CHECK;
|
||||||
if (scheduledConfig?.enabled) {
|
{
|
||||||
// 设计决策:只验证最小值 60000ms,不设最大值。
|
|
||||||
// 前端有 max=3600000 (1小时) 的 UI 限制,但后端允许更大值以支持特殊需求。
|
|
||||||
// 如果用户需要超长的间隔,可以通过 API 直接设置。
|
|
||||||
const DEFAULT_INTERVAL = CONFIG.CRON_NEAR_MINUTES * 60 * 1000;
|
const DEFAULT_INTERVAL = CONFIG.CRON_NEAR_MINUTES * 60 * 1000;
|
||||||
let interval = scheduledConfig.interval;
|
|
||||||
if (typeof interval !== 'number' || interval < 60000) {
|
|
||||||
logger.warn(`[ScheduledHealthCheck] Invalid interval ${interval}, using default ${DEFAULT_INTERVAL}`);
|
|
||||||
interval = DEFAULT_INTERVAL;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启动时运行健康检查
|
|
||||||
if (scheduledConfig.startupRun !== false) {
|
|
||||||
logger.info('[ScheduledHealthCheck] Running scheduled health check on startup...');
|
|
||||||
// 使用 setImmediate 确保在事件循环的下一阶段执行,此时服务器已完全就绪
|
|
||||||
setImmediate(async () => {
|
|
||||||
try {
|
|
||||||
await poolManager.performScheduledHealthChecks();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[ScheduledHealthCheck] Startup run error:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let isHealthCheckRunning = false;
|
let isHealthCheckRunning = false;
|
||||||
let healthCheckTimerId = null;
|
let healthCheckTimerId = null;
|
||||||
|
|
||||||
// 定时健康检查函数
|
// 定时健康检查函数(始终注册,无论初始 enabled 状态)
|
||||||
const runHealthCheckTimer = (interval) => {
|
const runHealthCheckTimer = (interval) => {
|
||||||
// 清除旧的 timer
|
// 清除旧的 timer
|
||||||
if (healthCheckTimerId) {
|
if (healthCheckTimerId) {
|
||||||
clearInterval(healthCheckTimerId);
|
clearInterval(healthCheckTimerId);
|
||||||
|
healthCheckTimerId = null;
|
||||||
}
|
}
|
||||||
// 重置运行状态,允许新的 timer 立即触发
|
// 重置运行状态,允许新的 timer 立即触发
|
||||||
// 否则如果 reload 时正在运行,新 timer 的第一次触发会被跳过
|
// 否则如果 reload 时正在运行,新 timer 的第一次触发会被跳过
|
||||||
isHealthCheckRunning = false;
|
isHealthCheckRunning = false;
|
||||||
|
|
||||||
// 设置定时任务
|
// 设置定时任务
|
||||||
|
// 设计决策:只验证最小值,不设最大值。
|
||||||
|
// 前端有 max=3600000 (1小时) 的 UI 限制,但后端允许更大值以支持特殊需求。
|
||||||
|
const safeInterval = (typeof interval === 'number' && interval >= HEALTH_CHECK.MIN_INTERVAL_MS) ? interval : DEFAULT_INTERVAL;
|
||||||
healthCheckTimerId = setInterval(async () => {
|
healthCheckTimerId = setInterval(async () => {
|
||||||
if (isHealthCheckRunning) {
|
if (isHealthCheckRunning) {
|
||||||
logger.debug('[ScheduledHealthCheck] Skipping - previous run still in progress');
|
logger.debug('[ScheduledHealthCheck] Skipping - previous run still in progress');
|
||||||
|
|
@ -414,16 +400,45 @@ async function startServer() {
|
||||||
} finally {
|
} finally {
|
||||||
isHealthCheckRunning = false;
|
isHealthCheckRunning = false;
|
||||||
}
|
}
|
||||||
}, interval);
|
}, safeInterval);
|
||||||
logger.info(`[ScheduledHealthCheck] Scheduled every ${interval}ms`);
|
logger.info(`[ScheduledHealthCheck] Scheduled every ${safeInterval}ms`);
|
||||||
|
return safeInterval;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 设置定时任务
|
// 注册重载/停止函数到 globalThis(供 config-api 热更新使用)
|
||||||
runHealthCheckTimer(interval);
|
// 必须在 enabled 检查外注册,保证热更新时可访问
|
||||||
|
|
||||||
// 注册重载函数和初始 interval 到 globalThis(供 config-api 热更新使用)
|
|
||||||
globalThis.reloadHealthCheckTimer = runHealthCheckTimer;
|
globalThis.reloadHealthCheckTimer = runHealthCheckTimer;
|
||||||
globalThis._activeHealthCheckInterval = interval;
|
globalThis.stopHealthCheckTimer = () => {
|
||||||
|
if (healthCheckTimerId) {
|
||||||
|
clearInterval(healthCheckTimerId);
|
||||||
|
healthCheckTimerId = null;
|
||||||
|
logger.info('[ScheduledHealthCheck] Timer stopped');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (scheduledConfig?.enabled) {
|
||||||
|
let interval = scheduledConfig.interval;
|
||||||
|
if (typeof interval !== 'number' || interval < HEALTH_CHECK.MIN_INTERVAL_MS) {
|
||||||
|
logger.warn(`[ScheduledHealthCheck] Invalid interval ${interval}, using default ${DEFAULT_INTERVAL}`);
|
||||||
|
interval = DEFAULT_INTERVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动时运行健康检查
|
||||||
|
if (scheduledConfig.startupRun !== false) {
|
||||||
|
logger.info('[ScheduledHealthCheck] Running scheduled health check on startup...');
|
||||||
|
setImmediate(async () => {
|
||||||
|
try {
|
||||||
|
await poolManager.performScheduledHealthChecks();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[ScheduledHealthCheck] Startup run error:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置定时任务
|
||||||
|
const activeInterval = runHealthCheckTimer(interval);
|
||||||
|
globalThis._activeHealthCheckInterval = activeInterval;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是子进程,通知主进程已就绪
|
// 如果是子进程,通知主进程已就绪
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import path from 'path';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { CONFIG } from '../core/config-manager.js';
|
import { CONFIG } from '../core/config-manager.js';
|
||||||
import { getClientIp } from '../utils/common.js';
|
import { getClientIp } from '../utils/common.js';
|
||||||
|
import { PASSWORD } from '../utils/constants.js';
|
||||||
|
|
||||||
// Token存储到本地文件中
|
// Token存储到本地文件中
|
||||||
const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json');
|
const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json');
|
||||||
|
|
@ -56,7 +57,7 @@ export async function validateCredentials(password) {
|
||||||
if (parts.length !== 3) return false;
|
if (parts.length !== 3) return false;
|
||||||
const [, salt, storedHash] = parts;
|
const [, salt, storedHash] = parts;
|
||||||
const inputHash = await new Promise((resolve, reject) =>
|
const inputHash = await new Promise((resolve, reject) =>
|
||||||
crypto.pbkdf2(password.trim(), salt, 100000, 64, 'sha512', (err, key) =>
|
crypto.pbkdf2(password.trim(), salt, PASSWORD.PBKDF2_ITERATIONS, PASSWORD.PBKDF2_KEYLEN, PASSWORD.PBKDF2_DIGEST, (err, key) =>
|
||||||
err ? reject(err) : resolve(key.toString('hex'))
|
err ? reject(err) : resolve(key.toString('hex'))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
@ -64,7 +65,11 @@ export async function validateCredentials(password) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 旧格式:明文(兼容迁移期,建议通过 UI 重新设置密码以升级为哈希格式)
|
// 旧格式:明文(兼容迁移期,建议通过 UI 重新设置密码以升级为哈希格式)
|
||||||
return password === storedPassword;
|
// 使用 timingSafeEqual 防止时序攻击
|
||||||
|
const a = Buffer.from(password.trim());
|
||||||
|
const b = Buffer.from(storedPassword);
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return crypto.timingSafeEqual(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { serviceInstances } from '../providers/adapter.js';
|
||||||
import { initApiService } from '../services/service-manager.js';
|
import { initApiService } from '../services/service-manager.js';
|
||||||
import { getRequestBody } from '../utils/common.js';
|
import { getRequestBody } from '../utils/common.js';
|
||||||
import { broadcastEvent } from '../ui-modules/event-broadcast.js';
|
import { broadcastEvent } from '../ui-modules/event-broadcast.js';
|
||||||
|
import { HEALTH_CHECK, PASSWORD, NETWORK, RETRY } from '../utils/constants.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重载配置文件
|
* 重载配置文件
|
||||||
|
|
@ -120,14 +121,18 @@ export async function handleUpdateConfig(req, res, currentConfig) {
|
||||||
}
|
}
|
||||||
if (newConfig.SERVER_PORT !== undefined) {
|
if (newConfig.SERVER_PORT !== undefined) {
|
||||||
const port = Number(newConfig.SERVER_PORT);
|
const port = Number(newConfig.SERVER_PORT);
|
||||||
if (Number.isInteger(port) && port > 0 && port < 65536) currentConfig.SERVER_PORT = port;
|
if (Number.isInteger(port) && port >= NETWORK.MIN_PORT && port <= NETWORK.MAX_PORT) currentConfig.SERVER_PORT = port;
|
||||||
}
|
}
|
||||||
if (newConfig.MODEL_PROVIDER !== undefined) currentConfig.MODEL_PROVIDER = newConfig.MODEL_PROVIDER;
|
if (newConfig.MODEL_PROVIDER !== undefined) currentConfig.MODEL_PROVIDER = newConfig.MODEL_PROVIDER;
|
||||||
if (newConfig.SYSTEM_PROMPT_FILE_PATH !== undefined) {
|
if (newConfig.SYSTEM_PROMPT_FILE_PATH !== undefined) {
|
||||||
const p = String(newConfig.SYSTEM_PROMPT_FILE_PATH);
|
const p = String(newConfig.SYSTEM_PROMPT_FILE_PATH);
|
||||||
// 防止路径遍历:解析后的绝对路径必须在工作目录内
|
// 防止路径遍历:解析后的绝对路径必须在工作目录内
|
||||||
const resolved = path.resolve(process.cwd(), p);
|
const resolved = path.resolve(process.cwd(), p);
|
||||||
if (resolved.startsWith(process.cwd() + path.sep) || resolved === process.cwd()) {
|
const cwd = process.cwd();
|
||||||
|
// Windows兼容:统一使用正斜杠进行比较
|
||||||
|
const normalizedResolved = resolved.replace(/\\/g, '/');
|
||||||
|
const normalizedCwd = cwd.replace(/\\/g, '/');
|
||||||
|
if (normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd) {
|
||||||
currentConfig.SYSTEM_PROMPT_FILE_PATH = p;
|
currentConfig.SYSTEM_PROMPT_FILE_PATH = p;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -136,7 +141,7 @@ export async function handleUpdateConfig(req, res, currentConfig) {
|
||||||
if (newConfig.PROMPT_LOG_MODE !== undefined) currentConfig.PROMPT_LOG_MODE = newConfig.PROMPT_LOG_MODE;
|
if (newConfig.PROMPT_LOG_MODE !== undefined) currentConfig.PROMPT_LOG_MODE = newConfig.PROMPT_LOG_MODE;
|
||||||
if (newConfig.REQUEST_MAX_RETRIES !== undefined) {
|
if (newConfig.REQUEST_MAX_RETRIES !== undefined) {
|
||||||
const v = Number(newConfig.REQUEST_MAX_RETRIES);
|
const v = Number(newConfig.REQUEST_MAX_RETRIES);
|
||||||
if (Number.isInteger(v) && v >= 0 && v <= 100) currentConfig.REQUEST_MAX_RETRIES = v;
|
if (Number.isInteger(v) && v >= 0 && v <= RETRY.MAX_RETRIES) currentConfig.REQUEST_MAX_RETRIES = v;
|
||||||
}
|
}
|
||||||
if (newConfig.REQUEST_BASE_DELAY !== undefined) currentConfig.REQUEST_BASE_DELAY = newConfig.REQUEST_BASE_DELAY;
|
if (newConfig.REQUEST_BASE_DELAY !== undefined) currentConfig.REQUEST_BASE_DELAY = newConfig.REQUEST_BASE_DELAY;
|
||||||
if (newConfig.CREDENTIAL_SWITCH_MAX_RETRIES !== undefined) currentConfig.CREDENTIAL_SWITCH_MAX_RETRIES = newConfig.CREDENTIAL_SWITCH_MAX_RETRIES;
|
if (newConfig.CREDENTIAL_SWITCH_MAX_RETRIES !== undefined) currentConfig.CREDENTIAL_SWITCH_MAX_RETRIES = newConfig.CREDENTIAL_SWITCH_MAX_RETRIES;
|
||||||
|
|
@ -164,7 +169,18 @@ export async function handleUpdateConfig(req, res, currentConfig) {
|
||||||
if (newConfig.LOG_ENABLED !== undefined) currentConfig.LOG_ENABLED = newConfig.LOG_ENABLED;
|
if (newConfig.LOG_ENABLED !== undefined) currentConfig.LOG_ENABLED = newConfig.LOG_ENABLED;
|
||||||
if (newConfig.LOG_OUTPUT_MODE !== undefined) currentConfig.LOG_OUTPUT_MODE = newConfig.LOG_OUTPUT_MODE;
|
if (newConfig.LOG_OUTPUT_MODE !== undefined) currentConfig.LOG_OUTPUT_MODE = newConfig.LOG_OUTPUT_MODE;
|
||||||
if (newConfig.LOG_LEVEL !== undefined) currentConfig.LOG_LEVEL = newConfig.LOG_LEVEL;
|
if (newConfig.LOG_LEVEL !== undefined) currentConfig.LOG_LEVEL = newConfig.LOG_LEVEL;
|
||||||
if (newConfig.LOG_DIR !== undefined) currentConfig.LOG_DIR = newConfig.LOG_DIR;
|
if (newConfig.LOG_DIR !== undefined) {
|
||||||
|
const p = String(newConfig.LOG_DIR);
|
||||||
|
// 防止路径遍历:解析后的绝对路径必须在工作目录内
|
||||||
|
const resolved = path.resolve(process.cwd(), p);
|
||||||
|
const cwd = process.cwd();
|
||||||
|
// Windows兼容:统一使用正斜杠进行比较
|
||||||
|
const normalizedResolved = resolved.replace(/\\/g, '/');
|
||||||
|
const normalizedCwd = cwd.replace(/\\/g, '/');
|
||||||
|
if (normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd) {
|
||||||
|
currentConfig.LOG_DIR = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (newConfig.LOG_INCLUDE_REQUEST_ID !== undefined) currentConfig.LOG_INCLUDE_REQUEST_ID = newConfig.LOG_INCLUDE_REQUEST_ID;
|
if (newConfig.LOG_INCLUDE_REQUEST_ID !== undefined) currentConfig.LOG_INCLUDE_REQUEST_ID = newConfig.LOG_INCLUDE_REQUEST_ID;
|
||||||
if (newConfig.LOG_INCLUDE_TIMESTAMP !== undefined) currentConfig.LOG_INCLUDE_TIMESTAMP = newConfig.LOG_INCLUDE_TIMESTAMP;
|
if (newConfig.LOG_INCLUDE_TIMESTAMP !== undefined) currentConfig.LOG_INCLUDE_TIMESTAMP = newConfig.LOG_INCLUDE_TIMESTAMP;
|
||||||
if (newConfig.LOG_MAX_FILE_SIZE !== undefined) currentConfig.LOG_MAX_FILE_SIZE = newConfig.LOG_MAX_FILE_SIZE;
|
if (newConfig.LOG_MAX_FILE_SIZE !== undefined) currentConfig.LOG_MAX_FILE_SIZE = newConfig.LOG_MAX_FILE_SIZE;
|
||||||
|
|
@ -175,7 +191,7 @@ export async function handleUpdateConfig(req, res, currentConfig) {
|
||||||
const incoming = newConfig.SCHEDULED_HEALTH_CHECK;
|
const incoming = newConfig.SCHEDULED_HEALTH_CHECK;
|
||||||
const newInterval = (() => {
|
const newInterval = (() => {
|
||||||
const val = Number(incoming?.interval);
|
const val = Number(incoming?.interval);
|
||||||
return isNaN(val) ? 600000 : Math.max(60000, val);
|
return isNaN(val) ? HEALTH_CHECK.DEFAULT_INTERVAL_MS : Math.max(HEALTH_CHECK.MIN_INTERVAL_MS, val);
|
||||||
})();
|
})();
|
||||||
currentConfig.SCHEDULED_HEALTH_CHECK = {
|
currentConfig.SCHEDULED_HEALTH_CHECK = {
|
||||||
enabled: incoming?.enabled === true,
|
enabled: incoming?.enabled === true,
|
||||||
|
|
@ -184,10 +200,26 @@ export async function handleUpdateConfig(req, res, currentConfig) {
|
||||||
providerTypes: Array.isArray(incoming?.providerTypes) ? incoming.providerTypes : []
|
providerTypes: Array.isArray(incoming?.providerTypes) ? incoming.providerTypes : []
|
||||||
};
|
};
|
||||||
|
|
||||||
// 仅在 interval 实际变化时重新加载 timer(_activeInterval 存在内存变量中,不写入配置文件)
|
// 检测 enabled 状态变化
|
||||||
if (globalThis.reloadHealthCheckTimer && currentConfig.SCHEDULED_HEALTH_CHECK.enabled && newInterval !== globalThis._activeHealthCheckInterval) {
|
const wasEnabled = currentConfig.SCHEDULED_HEALTH_CHECK?.enabled === true;
|
||||||
globalThis._activeHealthCheckInterval = newInterval;
|
const nowEnabled = incoming?.enabled === true;
|
||||||
globalThis.reloadHealthCheckTimer(newInterval);
|
|
||||||
|
if (currentConfig.SCHEDULED_HEALTH_CHECK) {
|
||||||
|
// 当 enabled 从 true -> false 时,清除 timer
|
||||||
|
if (wasEnabled && !nowEnabled && globalThis.stopHealthCheckTimer) {
|
||||||
|
globalThis.stopHealthCheckTimer();
|
||||||
|
globalThis._activeHealthCheckInterval = undefined;
|
||||||
|
}
|
||||||
|
// 当 enabled 从 false -> true 时,启动 timer
|
||||||
|
else if (!wasEnabled && nowEnabled && globalThis.reloadHealthCheckTimer) {
|
||||||
|
globalThis._activeHealthCheckInterval = newInterval;
|
||||||
|
globalThis.reloadHealthCheckTimer(newInterval);
|
||||||
|
}
|
||||||
|
// 当 enabled=true 且 interval 变化时,重启 timer
|
||||||
|
else if (nowEnabled && newInterval !== globalThis._activeHealthCheckInterval) {
|
||||||
|
globalThis._activeHealthCheckInterval = newInterval;
|
||||||
|
globalThis.reloadHealthCheckTimer(newInterval);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -347,16 +379,16 @@ export async function handleUpdateAdminPassword(req, res) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.trim().length < 8) {
|
if (password.trim().length < PASSWORD.MIN_LENGTH) {
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: { message: 'Password must be at least 8 characters' } }));
|
res.end(JSON.stringify({ error: { message: `Password must be at least ${PASSWORD.MIN_LENGTH} characters` } }));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 PBKDF2 哈希存储密码,避免明文写入文件
|
// 使用 PBKDF2 哈希存储密码,避免明文写入文件
|
||||||
const salt = crypto.randomBytes(16).toString('hex');
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
const hash = await new Promise((resolve, reject) =>
|
const hash = await new Promise((resolve, reject) =>
|
||||||
crypto.pbkdf2(password.trim(), salt, 100000, 64, 'sha512', (err, key) =>
|
crypto.pbkdf2(password.trim(), salt, PASSWORD.PBKDF2_ITERATIONS, PASSWORD.PBKDF2_KEYLEN, PASSWORD.PBKDF2_DIGEST, (err, key) =>
|
||||||
err ? reject(err) : resolve(key.toString('hex'))
|
err ? reject(err) : resolve(key.toString('hex'))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,43 @@ import { broadcastEvent } from './event-broadcast.js';
|
||||||
import { getRegisteredProviders } from '../providers/adapter.js';
|
import { getRegisteredProviders } from '../providers/adapter.js';
|
||||||
|
|
||||||
// 文件级互斥锁:防止并发读写导致数据丢失
|
// 文件级互斥锁:防止并发读写导致数据丢失
|
||||||
|
// HTML 脱敏:移除用户输入字段中的 HTML/JS,防止 XSS
|
||||||
|
function sanitizeProviderData(provider) {
|
||||||
|
if (!provider || typeof provider !== 'object') return provider;
|
||||||
|
const sanitized = { ...provider };
|
||||||
|
// 允许在前端显示的纯文本字段做 HTML 转义
|
||||||
|
if (typeof sanitized.customName === 'string') {
|
||||||
|
sanitized.customName = sanitized.customName
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeProviderPools(pools) {
|
||||||
|
if (!pools || typeof pools !== 'object') return pools;
|
||||||
|
const sanitized = {};
|
||||||
|
for (const [type, providers] of Object.entries(pools)) {
|
||||||
|
sanitized[type] = Array.isArray(providers)
|
||||||
|
? providers.map(sanitizeProviderData)
|
||||||
|
: providers;
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
// 使用 Promise 链式队列,确保文件操作顺序执行
|
||||||
let _fileLockChain = Promise.resolve();
|
let _fileLockChain = Promise.resolve();
|
||||||
function withFileLock(fn) {
|
function withFileLock(fn) {
|
||||||
const next = _fileLockChain.then(() => fn());
|
const next = _fileLockChain
|
||||||
_fileLockChain = next.catch(() => {});
|
.then(() => fn())
|
||||||
|
.catch(err => {
|
||||||
|
// 记录错误但继续链式执行,防止死锁
|
||||||
|
logger.error('[FileLock] Operation failed:', err?.message || err);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
_fileLockChain = next.then(() => {}).catch(() => {});
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
|
@ -31,7 +64,7 @@ export async function handleGetProviders(req, res, currentConfig, providerPoolMa
|
||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify(providerPools));
|
res.end(JSON.stringify(sanitizeProviderPools(providerPools)));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,7 +99,7 @@ export async function handleGetProviderType(req, res, currentConfig, providerPoo
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({
|
res.end(JSON.stringify({
|
||||||
providerType,
|
providerType,
|
||||||
providers,
|
providers: providers.map(sanitizeProviderData),
|
||||||
totalCount: providers.length,
|
totalCount: providers.length,
|
||||||
healthyCount: providers.filter(p => p.isHealthy).length
|
healthyCount: providers.filter(p => p.isHealthy).length
|
||||||
}));
|
}));
|
||||||
|
|
@ -159,7 +192,7 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager)
|
||||||
action: 'add',
|
action: 'add',
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
providerType,
|
providerType,
|
||||||
providerConfig,
|
providerConfig: sanitizeProviderData(providerConfig),
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -167,7 +200,7 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager)
|
||||||
broadcastEvent('provider_update', {
|
broadcastEvent('provider_update', {
|
||||||
action: 'add',
|
action: 'add',
|
||||||
providerType,
|
providerType,
|
||||||
providerConfig,
|
providerConfig: sanitizeProviderData(providerConfig),
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -175,7 +208,7 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager)
|
||||||
res.end(JSON.stringify({
|
res.end(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Provider added successfully',
|
message: 'Provider added successfully',
|
||||||
provider: providerConfig,
|
provider: sanitizeProviderData(providerConfig),
|
||||||
providerType
|
providerType
|
||||||
}));
|
}));
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -257,7 +290,7 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage
|
||||||
action: 'update',
|
action: 'update',
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
providerType,
|
providerType,
|
||||||
providerConfig: updatedProvider,
|
providerConfig: sanitizeProviderData(updatedProvider),
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -265,7 +298,7 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage
|
||||||
res.end(JSON.stringify({
|
res.end(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Provider updated successfully',
|
message: 'Provider updated successfully',
|
||||||
provider: updatedProvider
|
provider: sanitizeProviderData(updatedProvider)
|
||||||
}));
|
}));
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -331,7 +364,7 @@ async function _handleDeleteProvider(req, res, currentConfig, providerPoolManage
|
||||||
action: 'delete',
|
action: 'delete',
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
providerType,
|
providerType,
|
||||||
providerConfig: deletedProvider,
|
providerConfig: sanitizeProviderData(deletedProvider),
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -339,7 +372,7 @@ async function _handleDeleteProvider(req, res, currentConfig, providerPoolManage
|
||||||
res.end(JSON.stringify({
|
res.end(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Provider deleted successfully',
|
message: 'Provider deleted successfully',
|
||||||
deletedProvider
|
deletedProvider: sanitizeProviderData(deletedProvider)
|
||||||
}));
|
}));
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -407,7 +440,7 @@ async function _handleDisableEnableProvider(req, res, currentConfig, providerPoo
|
||||||
action: action,
|
action: action,
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
providerType,
|
providerType,
|
||||||
providerConfig: provider,
|
providerConfig: sanitizeProviderData(provider),
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -415,7 +448,7 @@ async function _handleDisableEnableProvider(req, res, currentConfig, providerPoo
|
||||||
res.end(JSON.stringify({
|
res.end(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Provider ${action}d successfully`,
|
message: `Provider ${action}d successfully`,
|
||||||
provider: provider
|
provider: sanitizeProviderData(provider)
|
||||||
}));
|
}));
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -569,7 +602,7 @@ export async function handleDeleteUnhealthyProviders(req, res, currentConfig, pr
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
providerType,
|
providerType,
|
||||||
deletedCount: unhealthyProviders.length,
|
deletedCount: unhealthyProviders.length,
|
||||||
deletedProviders: unhealthyProviders.map(p => ({ uuid: p.uuid, customName: p.customName })),
|
deletedProviders: unhealthyProviders.map(p => sanitizeProviderData({ uuid: p.uuid, customName: p.customName })),
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -660,7 +693,7 @@ export async function handleRefreshUnhealthyUuids(req, res, currentConfig, provi
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
providerType,
|
providerType,
|
||||||
refreshedCount: refreshedProviders.length,
|
refreshedCount: refreshedProviders.length,
|
||||||
refreshedProviders,
|
refreshedProviders: refreshedProviders.map(p => sanitizeProviderData(p)),
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -806,7 +839,7 @@ export async function handleHealthCheck(req, res, currentConfig, providerPoolMan
|
||||||
action: 'health_check',
|
action: 'health_check',
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
providerType,
|
providerType,
|
||||||
results,
|
results: results.map(r => ({ ...r, message: sanitizeProviderData({ message: r.message }).message })),
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1046,7 +1079,7 @@ export async function handleRefreshProviderUuid(req, res, currentConfig, provide
|
||||||
message: 'UUID refreshed successfully',
|
message: 'UUID refreshed successfully',
|
||||||
oldUuid,
|
oldUuid,
|
||||||
newUuid,
|
newUuid,
|
||||||
provider: providerPools[providerType][providerIndex]
|
provider: sanitizeProviderData(providerPools[providerType][providerIndex])
|
||||||
}));
|
}));
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
44
src/utils/constants.js
Normal file
44
src/utils/constants.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
/**
|
||||||
|
* 共享常量定义
|
||||||
|
* 集中管理各处使用的硬编码值
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 定时健康检查相关常量
|
||||||
|
export const HEALTH_CHECK = {
|
||||||
|
// 最小检查间隔:60秒(60000毫秒)
|
||||||
|
MIN_INTERVAL_MS: 60000,
|
||||||
|
// 默认检查间隔:10分钟(600000毫秒)
|
||||||
|
DEFAULT_INTERVAL_MS: 600000,
|
||||||
|
// 最大检查间隔:1小时(3600000毫秒)- 仅用于前端UI限制
|
||||||
|
MAX_INTERVAL_MS: 3600000
|
||||||
|
};
|
||||||
|
|
||||||
|
// 密码安全相关常量
|
||||||
|
export const PASSWORD = {
|
||||||
|
// 最小密码长度
|
||||||
|
MIN_LENGTH: 8,
|
||||||
|
// PBKDF2迭代次数
|
||||||
|
PBKDF2_ITERATIONS: 100000,
|
||||||
|
// PBKDF2密钥长度(字节)
|
||||||
|
PBKDF2_KEYLEN: 64,
|
||||||
|
// PBKDF2哈希算法
|
||||||
|
PBKDF2_DIGEST: 'sha512'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 网络相关常量
|
||||||
|
export const NETWORK = {
|
||||||
|
// 最小端口号
|
||||||
|
MIN_PORT: 1,
|
||||||
|
// 最大端口号
|
||||||
|
MAX_PORT: 65535,
|
||||||
|
// 默认服务器端口
|
||||||
|
DEFAULT_PORT: 3000
|
||||||
|
};
|
||||||
|
|
||||||
|
// 请求重试相关常量
|
||||||
|
export const RETRY = {
|
||||||
|
// 最大重试次数
|
||||||
|
MAX_RETRIES: 100,
|
||||||
|
// 默认重试次数
|
||||||
|
DEFAULT_RETRIES: 3
|
||||||
|
};
|
||||||
|
|
@ -53,7 +53,7 @@ function renderProviderTags(container, configs, isRequired) {
|
||||||
// 过滤掉不可见的提供商
|
// 过滤掉不可见的提供商
|
||||||
const visibleConfigs = configs.filter(c => c.visible !== false);
|
const visibleConfigs = configs.filter(c => c.visible !== false);
|
||||||
|
|
||||||
const escHtml = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
const escHtml = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||||
container.innerHTML = visibleConfigs.map(c => `
|
container.innerHTML = visibleConfigs.map(c => `
|
||||||
<button type="button" class="provider-tag" data-value="${escHtml(c.id)}">
|
<button type="button" class="provider-tag" data-value="${escHtml(c.id)}">
|
||||||
<i class="fas ${escHtml(c.icon || 'fa-server')}"></i>
|
<i class="fas ${escHtml(c.icon || 'fa-server')}"></i>
|
||||||
|
|
|
||||||
|
|
@ -256,43 +256,7 @@
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label data-i18n="config.healthCheck.providerTypes">定时检查的供应商</label>
|
<label data-i18n="config.healthCheck.providerTypes">定时检查的供应商</label>
|
||||||
<div id="scheduledHealthCheckProviders" class="provider-tags">
|
<div id="scheduledHealthCheckProviders" class="provider-tags">
|
||||||
<button type="button" class="provider-tag" data-value="gemini-cli-oauth">
|
<!-- 由 config-manager.js updateConfigProviderConfigs 动态渲染,勿在此处硬编码 -->
|
||||||
<i class="fas fa-robot"></i>
|
|
||||||
<span>Gemini CLI OAuth</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="provider-tag" data-value="gemini-antigravity">
|
|
||||||
<i class="fas fa-rocket"></i>
|
|
||||||
<span>Gemini Antigravity</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="provider-tag" data-value="openai-custom">
|
|
||||||
<i class="fas fa-brain"></i>
|
|
||||||
<span>OpenAI Custom</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="provider-tag" data-value="claude-custom">
|
|
||||||
<i class="fas fa-comment-dots"></i>
|
|
||||||
<span>Claude Custom</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="provider-tag" data-value="claude-kiro-oauth">
|
|
||||||
<i class="fas fa-key"></i>
|
|
||||||
<span>Claude Kiro OAuth</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="provider-tag" data-value="openai-qwen-oauth">
|
|
||||||
<i class="fas fa-cloud"></i>
|
|
||||||
<span>Qwen OAuth</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="provider-tag" data-value="openaiResponses-custom">
|
|
||||||
<i class="fas fa-reply"></i>
|
|
||||||
<span>OpenAI Responses</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button type="button" class="provider-tag" data-value="openai-codex-oauth">
|
|
||||||
<i class="fas fa-code"></i>
|
|
||||||
<span>OpenAI Codex OAuth</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="provider-tag" data-value="grok-custom">
|
|
||||||
<i class="fas fa-search"></i>
|
|
||||||
<span>Grok Reverse</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<small class="form-text" data-i18n="config.healthCheck.providerTypesNote">选择需要进行定时健康检查的供应商类型</small>
|
<small class="form-text" data-i18n="config.healthCheck.providerTypesNote">选择需要进行定时健康检查的供应商类型</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue