Merge pull request #430 from Wenaixi/main
feat: 定时健康检查系统 + 全面安全强化与稳定性优化
This commit is contained in:
commit
734c63bf7a
16 changed files with 1275 additions and 106 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -15,4 +15,5 @@ api-potluck-keys.json
|
|||
api-potluck-data.json
|
||||
# Codex credentials
|
||||
configs/codex/
|
||||
AGENTS.md
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ services:
|
|||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "8085-8087:8085-8087"
|
||||
- "8085-8087:8085-8087"
|
||||
- "1455:1455"
|
||||
- "19876-19880:19876-19880"
|
||||
volumes:
|
||||
|
|
|
|||
|
|
@ -112,7 +112,15 @@ export class ProviderPoolManager {
|
|||
|
||||
if (configPath && fs.existsSync(configPath)) {
|
||||
try {
|
||||
if (true) {
|
||||
const fileContent = fs.readFileSync(configPath, 'utf-8');
|
||||
const credData = JSON.parse(fileContent);
|
||||
const expiryTime = credData.expiry_date || credData.expiry || credData.expires_at;
|
||||
const nearExpiryMs = (this.globalConfig?.CRON_NEAR_MINUTES || 10) * 60 * 1000;
|
||||
if (!expiryTime) {
|
||||
// 凭据文件缺少 expiry 字段,无法判断是否快过期,作为安全措施强制刷新
|
||||
this._log('warn', `Node ${providerStatus.uuid} (${providerType}) has no expiry field. Forcing refresh as safety measure...`);
|
||||
this._enqueueRefresh(providerType, providerStatus);
|
||||
} else if ((expiryTime - Date.now()) < nearExpiryMs) {
|
||||
this._log('warn', `Node ${providerStatus.uuid} (${providerType}) is near expiration. Enqueuing refresh...`);
|
||||
this._enqueueRefresh(providerType, providerStatus);
|
||||
}
|
||||
|
|
@ -278,6 +286,8 @@ export class ProviderPoolManager {
|
|||
}
|
||||
|
||||
const queue = this.refreshQueues[providerType];
|
||||
// 记录此任务是否持有一个全局槽位(情况1追加的任务不持有)
|
||||
let ownsGlobalSlot = false;
|
||||
|
||||
const runTask = async () => {
|
||||
try {
|
||||
|
|
@ -286,13 +296,13 @@ export class ProviderPoolManager {
|
|||
this._log('error', `Failed to process refresh for node ${uuid}: ${err.message}`);
|
||||
} finally {
|
||||
this.refreshingUuids.delete(uuid);
|
||||
|
||||
|
||||
// 再次获取当前队列引用
|
||||
const currentQueue = this.refreshQueues[providerType];
|
||||
if (!currentQueue) return;
|
||||
|
||||
currentQueue.activeCount--;
|
||||
|
||||
|
||||
// 1. 尝试从当前提供商队列中取下一个任务
|
||||
if (currentQueue.waitingTasks.length > 0) {
|
||||
const nextTask = currentQueue.waitingTasks.shift();
|
||||
|
|
@ -300,14 +310,17 @@ export class ProviderPoolManager {
|
|||
// 使用 Promise.resolve().then 避免过深的递归
|
||||
Promise.resolve().then(nextTask);
|
||||
} else if (currentQueue.activeCount === 0) {
|
||||
// 2. 如果当前提供商的所有任务都完成了,释放全局槽位
|
||||
// 只有在确定队列为空且没有新任务时才清理
|
||||
// 清理空队列:无论是否持有全局槽位,都应删除已无任务的队列对象
|
||||
if (currentQueue.waitingTasks.length === 0 &&
|
||||
this.refreshQueues[providerType] === currentQueue) {
|
||||
this.activeProviderRefreshes--;
|
||||
delete this.refreshQueues[providerType]; // 清理空队列
|
||||
delete this.refreshQueues[providerType];
|
||||
}
|
||||
|
||||
|
||||
// 只有持有全局槽位的任务才能递减计数器
|
||||
if (ownsGlobalSlot) {
|
||||
this.activeProviderRefreshes--;
|
||||
}
|
||||
|
||||
// 3. 尝试启动下一个等待中的提供商队列
|
||||
if (this.globalRefreshWaiters.length > 0) {
|
||||
const nextProviderStart = this.globalRefreshWaiters.shift();
|
||||
|
|
@ -328,15 +341,17 @@ export class ProviderPoolManager {
|
|||
|
||||
// 检查全局并发限制(按提供商分组)
|
||||
// 情况1: 该提供商已经在运行,直接加入其队列(不占用新的全局槽位)
|
||||
if (this.refreshQueues[providerType].activeCount > 0) {
|
||||
const isExistingQueue = this.refreshQueues[providerType].activeCount > 0 || this.refreshQueues[providerType].waitingTasks.length > 0;
|
||||
if (isExistingQueue) {
|
||||
tryStartProviderQueue();
|
||||
}
|
||||
// 情况2: 该提供商未运行,需要检查全局槽位
|
||||
// 情况2: 该提供商未运行,需要检查全局槽位,此路径持有全局槽位
|
||||
else if (this.activeProviderRefreshes < this.refreshConcurrency.global) {
|
||||
ownsGlobalSlot = true;
|
||||
this.activeProviderRefreshes++;
|
||||
tryStartProviderQueue();
|
||||
}
|
||||
// 情况3: 全局槽位已满,进入等待队列
|
||||
// 情况3: 全局槽位已满,进入等待队列,由等待回调负责标记持槽
|
||||
else {
|
||||
this.globalRefreshWaiters.push(() => {
|
||||
// 重新获取最新的队列引用
|
||||
|
|
@ -346,7 +361,8 @@ export class ProviderPoolManager {
|
|||
waitingTasks: []
|
||||
};
|
||||
}
|
||||
// 重要:从等待队列启动时需要增加全局计数
|
||||
// 从等待队列启动时持有全局槽位
|
||||
ownsGlobalSlot = true;
|
||||
this.activeProviderRefreshes++;
|
||||
tryStartProviderQueue();
|
||||
});
|
||||
|
|
@ -389,7 +405,16 @@ export class ProviderPoolManager {
|
|||
// 调用适配器的 refreshToken 方法(内部封装了具体的刷新逻辑)
|
||||
if (typeof serviceAdapter.refreshToken === 'function') {
|
||||
const startTime = Date.now();
|
||||
force ? await serviceAdapter.forceRefreshToken() : await serviceAdapter.refreshToken()
|
||||
if (force) {
|
||||
if (typeof serviceAdapter.forceRefreshToken === 'function') {
|
||||
await serviceAdapter.forceRefreshToken();
|
||||
} else {
|
||||
this._log('warn', `forceRefreshToken not implemented for ${providerType}, falling back to refreshToken`);
|
||||
await serviceAdapter.refreshToken();
|
||||
}
|
||||
} else {
|
||||
await serviceAdapter.refreshToken();
|
||||
}
|
||||
const duration = Date.now() - startTime;
|
||||
this._log('info', `Token refresh successful for node ${providerStatus.uuid} (Duration: ${duration}ms)`);
|
||||
|
||||
|
|
@ -452,7 +477,7 @@ export class ProviderPoolManager {
|
|||
const lastSelectionSeq = config._lastSelectionSeq || 0;
|
||||
if (minSeqInPool === -1) {
|
||||
const pool = this.providerStatus[providerStatus.type] || [];
|
||||
minSeqInPool = Math.min(...pool.map(p => p.config._lastSelectionSeq || 0));
|
||||
minSeqInPool = pool.reduce((min, p) => Math.min(min, p.config._lastSelectionSeq || 0), Infinity);
|
||||
}
|
||||
const relativeSeq = Math.max(0, lastSelectionSeq - minSeqInPool);
|
||||
const cappedRelativeSeq = Math.min(relativeSeq, 100);
|
||||
|
|
@ -1670,17 +1695,36 @@ export class ProviderPoolManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Performs health checks on all providers in the pool.
|
||||
* This method would typically be called periodically (e.g., via cron job).
|
||||
* Performs initial (startup) health checks on selected providers.
|
||||
* Respects SCHEDULED_HEALTH_CHECK.providerTypes configuration.
|
||||
* Called once at server startup.
|
||||
*
|
||||
* 设计决策:如果没有选择任何 provider types,则不进行检查任何 provider。
|
||||
* 这是有意为之的设计 - 如果用户没有明确选择,则不需要自动健康检查。
|
||||
* 区别于原来的逻辑(检查所有 provider),现在的行为更符合用户预期。
|
||||
*/
|
||||
async performHealthChecks(isInit = false) {
|
||||
this._log('info', 'Performing health checks on all providers...');
|
||||
async performInitialHealthChecks() {
|
||||
const scheduledConfig = this.globalConfig?.SCHEDULED_HEALTH_CHECK;
|
||||
const selectedProviderTypes = scheduledConfig?.providerTypes;
|
||||
|
||||
// 如果没有选择任何 provider types,不进行检查
|
||||
// 设计决策:如果用户没有选择任何 provider,明确不执行健康检查是合理的
|
||||
if (!Array.isArray(selectedProviderTypes) || selectedProviderTypes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._log('info', 'Performing health checks on selected providers...');
|
||||
const now = new Date();
|
||||
|
||||
// 首先检查并恢复已到恢复时间的提供商
|
||||
this._checkAndRecoverScheduledProviders();
|
||||
|
||||
for (const providerType in this.providerStatus) {
|
||||
// Only check selected provider types
|
||||
if (!selectedProviderTypes.includes(providerType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const providerStatus of this.providerStatus[providerType]) {
|
||||
const providerConfig = providerStatus.config;
|
||||
|
||||
|
|
@ -1743,6 +1787,91 @@ export class ProviderPoolManager {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs scheduled health checks on all providers.
|
||||
* This method is designed to be called periodically to proactively check provider health.
|
||||
* It respects provider-level isDisabled flag.
|
||||
*/
|
||||
async performHealthChecks() {
|
||||
const scheduledConfig = this.globalConfig?.SCHEDULED_HEALTH_CHECK;
|
||||
const checkStartTime = Date.now();
|
||||
|
||||
// Check if scheduled health checks are disabled
|
||||
if (!scheduledConfig?.enabled) {
|
||||
this._log('debug', '[ScheduledHealthCheck] Scheduled health checks are disabled via configuration');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get selected provider types
|
||||
let selectedProviderTypes = scheduledConfig?.providerTypes;
|
||||
|
||||
// Validate providerTypes is an array
|
||||
if (!Array.isArray(selectedProviderTypes) || selectedProviderTypes.length === 0) {
|
||||
this._log('info', '[ScheduledHealthCheck] No provider types selected, skipping health check');
|
||||
return;
|
||||
}
|
||||
|
||||
// Count providers to be checked
|
||||
let totalProviders = 0;
|
||||
let providersToCheck = [];
|
||||
|
||||
for (const providerType in this.providerStatus) {
|
||||
// Only check selected provider types
|
||||
if (!selectedProviderTypes.includes(providerType)) {
|
||||
this._log('debug', `[ScheduledHealthCheck] Skipping provider type ${providerType}: not in selected types`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const provider of this.providerStatus[providerType]) {
|
||||
// Skip manually disabled providers
|
||||
if (provider.config.isDisabled === true) {
|
||||
this._log('debug', `[ScheduledHealthCheck] Skipping ${provider.config.uuid} (${providerType}): manually disabled`);
|
||||
continue;
|
||||
}
|
||||
|
||||
totalProviders++;
|
||||
providersToCheck.push({ providerType, provider, uuid: provider.config.uuid, customName: provider.config.customName });
|
||||
}
|
||||
}
|
||||
|
||||
this._log('info', `[ScheduledHealthCheck] Starting scheduled health checks: ${totalProviders} provider(s) to check (interval: ${scheduledConfig.interval}ms, types: ${selectedProviderTypes.join(', ')})`);
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const { providerType, provider, uuid, customName } of providersToCheck) {
|
||||
const providerCheckStart = Date.now();
|
||||
const checkModelName = provider.config.checkModelName || ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType] || 'unknown';
|
||||
const displayName = customName || uuid.substring(0, 8);
|
||||
|
||||
try {
|
||||
// Perform health check (health check is based on providerTypes configuration, not per-provider checkHealth flag)
|
||||
const result = await this._checkProviderHealth(providerType, provider.config);
|
||||
const checkDuration = Date.now() - providerCheckStart;
|
||||
|
||||
if (!result.success) {
|
||||
// Provider is unhealthy
|
||||
failCount++;
|
||||
this._log('warn', `[ScheduledHealthCheck] ${displayName} (${providerType}) FAILED: ${result.errorMessage || 'Provider is not responding correctly.'} (${checkDuration}ms)`);
|
||||
this.markProviderUnhealthyImmediately(providerType, provider.config, result.errorMessage);
|
||||
} else {
|
||||
// Provider is healthy
|
||||
successCount++;
|
||||
this._log('info', `[ScheduledHealthCheck] ${displayName} (${providerType}) PASSED: model=${result.modelName || checkModelName} (${checkDuration}ms)`);
|
||||
this.markProviderHealthy(providerType, provider.config, false, result.modelName);
|
||||
}
|
||||
} catch (error) {
|
||||
const checkDuration = Date.now() - providerCheckStart;
|
||||
failCount++;
|
||||
this._log('error', `[ScheduledHealthCheck] ${displayName} (${providerType}) EXCEPTION: ${error.message} (${checkDuration}ms)`);
|
||||
this.markProviderUnhealthyImmediately(providerType, provider.config, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const totalDuration = Date.now() - checkStartTime;
|
||||
this._log('info', `[ScheduledHealthCheck] Completed: ${successCount} passed, ${failCount} failed, ${totalDuration}ms total`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建健康检查请求(返回多种格式用于重试)
|
||||
* @private
|
||||
|
|
@ -1793,17 +1922,17 @@ export class ProviderPoolManager {
|
|||
|
||||
/**
|
||||
* Performs an actual health check for a specific provider.
|
||||
*
|
||||
* 设计决策:不检查 providerConfig.checkHealth 标志。
|
||||
* 健康检查是否执行由上层调用方(performHealthChecks / performInitialHealthChecks)
|
||||
* 通过 providerTypes 数组来决定,不在每个 provider 级别控制。
|
||||
* 这样简化了逻辑,避免 per-provider 的 checkHealth flag 变得无用。
|
||||
*
|
||||
* @param {string} providerType - The type of the provider.
|
||||
* @param {object} providerConfig - The configuration of the provider to check.
|
||||
* @param {boolean} forceCheck - If true, ignore checkHealth config and force the check.
|
||||
* @returns {Promise<{success: boolean, modelName: string, errorMessage: string}|null>} - Health check result object or null if check not implemented.
|
||||
* @returns {Promise<{success: boolean, modelName: string, errorMessage: string}>} - Health check result object.
|
||||
*/
|
||||
async _checkProviderHealth(providerType, providerConfig, forceCheck = false) {
|
||||
// 如果未启用健康检查且不是强制检查,返回 null(提前返回,避免不必要的计算)
|
||||
if (!providerConfig.checkHealth && !forceCheck) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async _checkProviderHealth(providerType, providerConfig) {
|
||||
// 确定健康检查使用的模型名称
|
||||
const modelName = providerConfig.checkModelName ||
|
||||
ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType];
|
||||
|
|
@ -1838,8 +1967,6 @@ export class ProviderPoolManager {
|
|||
const timeoutId = setTimeout(() => abortController.abort(), healthCheckTimeout);
|
||||
|
||||
try {
|
||||
this._log('debug', `Health check attempt ${i + 1}/${healthCheckRequests.length} for ${modelName}: ${JSON.stringify(healthCheckRequest)}`);
|
||||
|
||||
// 尝试将 signal 注入请求体,供支持的适配器使用
|
||||
const requestWithSignal = {
|
||||
...healthCheckRequest,
|
||||
|
|
@ -1849,16 +1976,17 @@ export class ProviderPoolManager {
|
|||
await serviceAdapter.generateContent(modelName, requestWithSignal);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
// 注意:使用量计数由调用方处理(performHealthChecks/performInitialHealthChecks)
|
||||
// 这里只返回成功结果,让调用方统一处理状态更新和计数
|
||||
return { success: true, modelName, errorMessage: null };
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
lastError = error;
|
||||
this._log('debug', `Health check attempt ${i + 1} failed for ${providerType}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 所有尝试都失败
|
||||
this._log('error', `Health check failed for ${providerType} after ${healthCheckRequests.length} attempts: ${lastError?.message}`);
|
||||
this._log('warn', `[HealthCheck] ${providerType} failed after ${healthCheckRequests.length} attempts: ${lastError?.message}`);
|
||||
return { success: false, modelName, errorMessage: lastError?.message || 'All health check attempts failed' };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export function initializeAPIManagement(services) {
|
|||
logger.info(`[Heartbeat] Server is running. Current time: ${new Date().toLocaleString()}`, Object.keys(services));
|
||||
// 循环遍历所有已初始化的服务适配器,并尝试刷新令牌
|
||||
// if (getProviderPoolManager()) {
|
||||
// await getProviderPoolManager().performHealthChecks(); // 定期执行健康检查
|
||||
// await getProviderPoolManager().performInitialHealthChecks(); // 定期执行健康检查
|
||||
// }
|
||||
for (const providerKey in services) {
|
||||
const serviceAdapter = services[providerKey];
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { initializeAPIManagement } from './api-manager.js';
|
|||
import { createRequestHandler } from '../handlers/request-handler.js';
|
||||
import { discoverPlugins, getPluginManager } from '../core/plugin-manager.js';
|
||||
import { getTLSSidecar } from '../utils/tls-sidecar.js';
|
||||
import { HEALTH_CHECK } from '../utils/constants.js';
|
||||
|
||||
/**
|
||||
* @license
|
||||
|
|
@ -313,7 +314,7 @@ async function startServer() {
|
|||
logger.info(` System Prompt Mode: ${CONFIG.SYSTEM_PROMPT_MODE}`);
|
||||
logger.info(` Host: ${CONFIG.HOST}`);
|
||||
logger.info(` Port: ${CONFIG.SERVER_PORT}`);
|
||||
logger.info(` Required API Key: ${CONFIG.REQUIRED_API_KEY}`);
|
||||
logger.info(` Required API Key: ${CONFIG.REQUIRED_API_KEY ? CONFIG.REQUIRED_API_KEY.slice(0, 4) + '****' : '(none)'}`);
|
||||
logger.info(` Prompt Logging: ${CONFIG.PROMPT_LOG_MODE}${CONFIG.PROMPT_LOG_FILENAME ? ` (to ${CONFIG.PROMPT_LOG_FILENAME})` : ''}`);
|
||||
logger.info(`------------------------------------------`);
|
||||
logger.info(`\nUnified API Server running on http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}`);
|
||||
|
|
@ -358,7 +359,86 @@ async function startServer() {
|
|||
const poolManager = getProviderPoolManager();
|
||||
if (poolManager) {
|
||||
logger.info('[Initialization] Performing initial health checks for provider pools...');
|
||||
poolManager.performHealthChecks(true);
|
||||
poolManager.performInitialHealthChecks();
|
||||
}
|
||||
|
||||
// 定时健康检查
|
||||
// 注意:无论初始 enabled 状态如何,都注册 reloadHealthCheckTimer,
|
||||
// 使得热更新时(从 disabled→enabled)config-api 能调用它启动 timer。
|
||||
const scheduledConfig = CONFIG.SCHEDULED_HEALTH_CHECK;
|
||||
{
|
||||
const DEFAULT_INTERVAL = CONFIG.CRON_NEAR_MINUTES * 60 * 1000;
|
||||
|
||||
let isHealthCheckRunning = false;
|
||||
let healthCheckTimerId = null;
|
||||
|
||||
// 定时健康检查函数(始终注册,无论初始 enabled 状态)
|
||||
const runHealthCheckTimer = (interval) => {
|
||||
// 清除旧的 timer
|
||||
if (healthCheckTimerId) {
|
||||
clearInterval(healthCheckTimerId);
|
||||
healthCheckTimerId = null;
|
||||
}
|
||||
// 重置运行状态,允许新的 timer 立即触发
|
||||
// 否则如果 reload 时正在运行,新 timer 的第一次触发会被跳过
|
||||
isHealthCheckRunning = false;
|
||||
|
||||
// 设置定时任务
|
||||
// 设计决策:只验证最小值,不设最大值。
|
||||
// 前端有 max=3600000 (1小时) 的 UI 限制,但后端允许更大值以支持特殊需求。
|
||||
const safeInterval = (typeof interval === 'number' && interval >= HEALTH_CHECK.MIN_INTERVAL_MS) ? interval : DEFAULT_INTERVAL;
|
||||
healthCheckTimerId = setInterval(async () => {
|
||||
if (isHealthCheckRunning) {
|
||||
logger.debug('[ScheduledHealthCheck] Skipping - previous run still in progress');
|
||||
return;
|
||||
}
|
||||
isHealthCheckRunning = true;
|
||||
try {
|
||||
await poolManager.performHealthChecks();
|
||||
} catch (error) {
|
||||
logger.error('[ScheduledHealthCheck] Error:', error);
|
||||
} finally {
|
||||
isHealthCheckRunning = false;
|
||||
}
|
||||
}, safeInterval);
|
||||
logger.info(`[ScheduledHealthCheck] Scheduled every ${safeInterval}ms`);
|
||||
return safeInterval;
|
||||
};
|
||||
|
||||
// 注册重载/停止函数到 globalThis(供 config-api 热更新使用)
|
||||
// 必须在 enabled 检查外注册,保证热更新时可访问
|
||||
globalThis.reloadHealthCheckTimer = runHealthCheckTimer;
|
||||
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...');
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await poolManager.performHealthChecks();
|
||||
} catch (error) {
|
||||
logger.error('[ScheduledHealthCheck] Startup run error:', error);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// 设置定时任务
|
||||
const activeInterval = runHealthCheckTimer(interval);
|
||||
globalThis._activeHealthCheckInterval = activeInterval;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是子进程,通知主进程已就绪
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import path from 'path';
|
|||
import crypto from 'crypto';
|
||||
import { CONFIG } from '../core/config-manager.js';
|
||||
import { getClientIp } from '../utils/common.js';
|
||||
import { PASSWORD } from '../utils/constants.js';
|
||||
|
||||
// Token存储到本地文件中
|
||||
const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json');
|
||||
|
|
@ -48,10 +49,27 @@ export async function readPasswordFile() {
|
|||
*/
|
||||
export async function validateCredentials(password) {
|
||||
const storedPassword = await readPasswordFile();
|
||||
logger.info('[Auth] Validating password, stored password length:', storedPassword ? storedPassword.length : 0, ', input password length:', password ? password.length : 0);
|
||||
const isValid = storedPassword && password === storedPassword;
|
||||
logger.info('[Auth] Password validation result:', isValid);
|
||||
return isValid;
|
||||
if (!storedPassword || !password) return false;
|
||||
|
||||
// 新格式:pbkdf2:salt:hash
|
||||
if (storedPassword.startsWith('pbkdf2:')) {
|
||||
const parts = storedPassword.split(':');
|
||||
if (parts.length !== 3) return false;
|
||||
const [, salt, storedHash] = parts;
|
||||
const inputHash = await new Promise((resolve, reject) =>
|
||||
crypto.pbkdf2(password.trim(), salt, PASSWORD.PBKDF2_ITERATIONS, PASSWORD.PBKDF2_KEYLEN, PASSWORD.PBKDF2_DIGEST, (err, key) =>
|
||||
err ? reject(err) : resolve(key.toString('hex'))
|
||||
)
|
||||
);
|
||||
return crypto.timingSafeEqual(Buffer.from(inputHash, 'hex'), Buffer.from(storedHash, 'hex'));
|
||||
}
|
||||
|
||||
// 旧格式:明文(兼容迁移期,建议通过 UI 重新设置密码以升级为哈希格式)
|
||||
// 使用 timingSafeEqual 防止时序攻击
|
||||
const a = Buffer.from(password.trim());
|
||||
const b = Buffer.from(storedPassword);
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|||
import logger from '../utils/logger.js';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { CONFIG } from '../core/config-manager.js';
|
||||
import { serviceInstances } from '../providers/adapter.js';
|
||||
import { initApiService } from '../services/service-manager.js';
|
||||
import { getRequestBody } from '../utils/common.js';
|
||||
import { broadcastEvent } from '../ui-modules/event-broadcast.js';
|
||||
import { HEALTH_CHECK, PASSWORD, NETWORK, RETRY } from '../utils/constants.js';
|
||||
|
||||
/**
|
||||
* 重载配置文件
|
||||
|
|
@ -57,11 +59,48 @@ export async function handleGetConfig(req, res, currentConfig) {
|
|||
}
|
||||
}
|
||||
|
||||
// 白名单过滤:只返回前端需要的字段,避免泄露凭据路径、内部状态等敏感信息
|
||||
const safeConfig = {
|
||||
HOST: currentConfig.HOST,
|
||||
SERVER_PORT: currentConfig.SERVER_PORT,
|
||||
MODEL_PROVIDER: currentConfig.MODEL_PROVIDER,
|
||||
SYSTEM_PROMPT_FILE_PATH: currentConfig.SYSTEM_PROMPT_FILE_PATH,
|
||||
SYSTEM_PROMPT_MODE: currentConfig.SYSTEM_PROMPT_MODE,
|
||||
PROMPT_LOG_BASE_NAME: currentConfig.PROMPT_LOG_BASE_NAME,
|
||||
PROMPT_LOG_MODE: currentConfig.PROMPT_LOG_MODE,
|
||||
REQUEST_MAX_RETRIES: currentConfig.REQUEST_MAX_RETRIES,
|
||||
REQUEST_BASE_DELAY: currentConfig.REQUEST_BASE_DELAY,
|
||||
CREDENTIAL_SWITCH_MAX_RETRIES: currentConfig.CREDENTIAL_SWITCH_MAX_RETRIES,
|
||||
CRON_NEAR_MINUTES: currentConfig.CRON_NEAR_MINUTES,
|
||||
CRON_REFRESH_TOKEN: currentConfig.CRON_REFRESH_TOKEN,
|
||||
LOGIN_EXPIRY: currentConfig.LOGIN_EXPIRY,
|
||||
PROVIDER_POOLS_FILE_PATH: currentConfig.PROVIDER_POOLS_FILE_PATH,
|
||||
MAX_ERROR_COUNT: currentConfig.MAX_ERROR_COUNT,
|
||||
WARMUP_TARGET: currentConfig.WARMUP_TARGET,
|
||||
REFRESH_CONCURRENCY_PER_PROVIDER: currentConfig.REFRESH_CONCURRENCY_PER_PROVIDER,
|
||||
providerFallbackChain: currentConfig.providerFallbackChain,
|
||||
modelFallbackMapping: currentConfig.modelFallbackMapping,
|
||||
PROXY_URL: currentConfig.PROXY_URL,
|
||||
PROXY_ENABLED_PROVIDERS: currentConfig.PROXY_ENABLED_PROVIDERS,
|
||||
TLS_SIDECAR_ENABLED: currentConfig.TLS_SIDECAR_ENABLED,
|
||||
TLS_SIDECAR_ENABLED_PROVIDERS: currentConfig.TLS_SIDECAR_ENABLED_PROVIDERS,
|
||||
TLS_SIDECAR_PORT: currentConfig.TLS_SIDECAR_PORT,
|
||||
TLS_SIDECAR_PROXY_URL: currentConfig.TLS_SIDECAR_PROXY_URL,
|
||||
LOG_ENABLED: currentConfig.LOG_ENABLED,
|
||||
LOG_OUTPUT_MODE: currentConfig.LOG_OUTPUT_MODE,
|
||||
LOG_LEVEL: currentConfig.LOG_LEVEL,
|
||||
LOG_DIR: currentConfig.LOG_DIR,
|
||||
LOG_INCLUDE_REQUEST_ID: currentConfig.LOG_INCLUDE_REQUEST_ID,
|
||||
LOG_INCLUDE_TIMESTAMP: currentConfig.LOG_INCLUDE_TIMESTAMP,
|
||||
LOG_MAX_FILE_SIZE: currentConfig.LOG_MAX_FILE_SIZE,
|
||||
LOG_MAX_FILES: currentConfig.LOG_MAX_FILES,
|
||||
SCHEDULED_HEALTH_CHECK: currentConfig.SCHEDULED_HEALTH_CHECK,
|
||||
// 脱敏:只返回是否设置了 API Key,不返回原文
|
||||
REQUIRED_API_KEY: currentConfig.REQUIRED_API_KEY ? '******' : '',
|
||||
systemPrompt,
|
||||
};
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
...currentConfig,
|
||||
systemPrompt
|
||||
}));
|
||||
res.end(JSON.stringify(safeConfig));
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -73,16 +112,47 @@ export async function handleUpdateConfig(req, res, currentConfig) {
|
|||
const body = await getRequestBody(req);
|
||||
const newConfig = body;
|
||||
|
||||
// Update config values in memory
|
||||
if (newConfig.REQUIRED_API_KEY !== undefined) currentConfig.REQUIRED_API_KEY = newConfig.REQUIRED_API_KEY;
|
||||
if (newConfig.HOST !== undefined) currentConfig.HOST = newConfig.HOST;
|
||||
if (newConfig.SERVER_PORT !== undefined) currentConfig.SERVER_PORT = newConfig.SERVER_PORT;
|
||||
// Update config values in memory(含类型校验)
|
||||
if (newConfig.REQUIRED_API_KEY !== undefined) {
|
||||
if (typeof newConfig.REQUIRED_API_KEY === 'string') currentConfig.REQUIRED_API_KEY = newConfig.REQUIRED_API_KEY;
|
||||
}
|
||||
if (newConfig.HOST !== undefined) {
|
||||
if (typeof newConfig.HOST === 'string' && newConfig.HOST.length > 0) currentConfig.HOST = newConfig.HOST;
|
||||
}
|
||||
if (newConfig.SERVER_PORT !== undefined) {
|
||||
const port = Number(newConfig.SERVER_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.SYSTEM_PROMPT_FILE_PATH !== undefined) currentConfig.SYSTEM_PROMPT_FILE_PATH = newConfig.SYSTEM_PROMPT_FILE_PATH;
|
||||
if (newConfig.SYSTEM_PROMPT_FILE_PATH !== undefined) {
|
||||
const p = String(newConfig.SYSTEM_PROMPT_FILE_PATH);
|
||||
// 防止路径遍历:解析后的绝对路径必须在工作目录内
|
||||
const resolved = path.resolve(process.cwd(), p);
|
||||
const cwd = process.cwd();
|
||||
|
||||
// 使用 path.relative 和 path.isAbsolute 进行更严格的校验
|
||||
const relativePath = path.relative(cwd, resolved);
|
||||
const isInsideCwd = !path.isAbsolute(relativePath) && !relativePath.startsWith('..') && relativePath !== '..';
|
||||
|
||||
// Windows 大小写不敏感兼容:仅在 Windows 平台统一转换为小写比较
|
||||
const isWindows = process.platform === 'win32';
|
||||
const normalizedResolved = (isWindows ? resolved.toLowerCase() : resolved).replace(/\\/g, '/');
|
||||
const normalizedCwd = (isWindows ? cwd.toLowerCase() : cwd).replace(/\\/g, '/');
|
||||
const startsWithCwd = normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd;
|
||||
|
||||
if (isInsideCwd && startsWithCwd) {
|
||||
currentConfig.SYSTEM_PROMPT_FILE_PATH = p;
|
||||
} else {
|
||||
logger.warn(`[UI API] Rejected SYSTEM_PROMPT_FILE_PATH traversal attempt: ${p}`);
|
||||
}
|
||||
}
|
||||
if (newConfig.SYSTEM_PROMPT_MODE !== undefined) currentConfig.SYSTEM_PROMPT_MODE = newConfig.SYSTEM_PROMPT_MODE;
|
||||
if (newConfig.PROMPT_LOG_BASE_NAME !== undefined) currentConfig.PROMPT_LOG_BASE_NAME = newConfig.PROMPT_LOG_BASE_NAME;
|
||||
if (newConfig.PROMPT_LOG_MODE !== undefined) currentConfig.PROMPT_LOG_MODE = newConfig.PROMPT_LOG_MODE;
|
||||
if (newConfig.REQUEST_MAX_RETRIES !== undefined) currentConfig.REQUEST_MAX_RETRIES = newConfig.REQUEST_MAX_RETRIES;
|
||||
if (newConfig.REQUEST_MAX_RETRIES !== undefined) {
|
||||
const v = Number(newConfig.REQUEST_MAX_RETRIES);
|
||||
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.CREDENTIAL_SWITCH_MAX_RETRIES !== undefined) currentConfig.CREDENTIAL_SWITCH_MAX_RETRIES = newConfig.CREDENTIAL_SWITCH_MAX_RETRIES;
|
||||
if (newConfig.CRON_NEAR_MINUTES !== undefined) currentConfig.CRON_NEAR_MINUTES = newConfig.CRON_NEAR_MINUTES;
|
||||
|
|
@ -109,12 +179,76 @@ export async function handleUpdateConfig(req, res, currentConfig) {
|
|||
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_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();
|
||||
|
||||
// 使用 path.relative 和 path.isAbsolute 进行更严格的校验
|
||||
const relativePath = path.relative(cwd, resolved);
|
||||
const isInsideCwd = !path.isAbsolute(relativePath) && !relativePath.startsWith('..') && relativePath !== '..';
|
||||
|
||||
// Windows 大小写不敏感兼容:仅在 Windows 平台统一转换为小写比较
|
||||
const isWindows = process.platform === 'win32';
|
||||
const normalizedResolved = (isWindows ? resolved.toLowerCase() : resolved).replace(/\\/g, '/');
|
||||
const normalizedCwd = (isWindows ? cwd.toLowerCase() : cwd).replace(/\\/g, '/');
|
||||
const startsWithCwd = normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd;
|
||||
|
||||
if (isInsideCwd && startsWithCwd) {
|
||||
currentConfig.LOG_DIR = p;
|
||||
} else {
|
||||
logger.warn(`[UI API] Rejected LOG_DIR traversal attempt: ${p}`);
|
||||
}
|
||||
}
|
||||
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_MAX_FILE_SIZE !== undefined) currentConfig.LOG_MAX_FILE_SIZE = newConfig.LOG_MAX_FILE_SIZE;
|
||||
if (newConfig.LOG_MAX_FILES !== undefined) currentConfig.LOG_MAX_FILES = newConfig.LOG_MAX_FILES;
|
||||
|
||||
// Scheduled Health Check settings
|
||||
if (newConfig.SCHEDULED_HEALTH_CHECK !== undefined) {
|
||||
const incoming = newConfig.SCHEDULED_HEALTH_CHECK;
|
||||
|
||||
// 检测 enabled 状态变化(在更新配置之前保存旧状态)
|
||||
const prevConfig = currentConfig.SCHEDULED_HEALTH_CHECK || {};
|
||||
const wasEnabled = prevConfig.enabled === true;
|
||||
const nowEnabled = incoming?.enabled === true;
|
||||
|
||||
const newInterval = (() => {
|
||||
const val = Number(incoming?.interval);
|
||||
return isNaN(val) ? HEALTH_CHECK.DEFAULT_INTERVAL_MS : Math.max(HEALTH_CHECK.MIN_INTERVAL_MS, Math.min(HEALTH_CHECK.MAX_INTERVAL_MS, val));
|
||||
})();
|
||||
|
||||
// 先保存旧的 interval 用于比较
|
||||
const oldInterval = globalThis._activeHealthCheckInterval;
|
||||
|
||||
// 更新配置
|
||||
currentConfig.SCHEDULED_HEALTH_CHECK = {
|
||||
enabled: nowEnabled,
|
||||
startupRun: incoming?.startupRun !== false,
|
||||
interval: newInterval,
|
||||
providerTypes: Array.isArray(incoming?.providerTypes) ? incoming.providerTypes : []
|
||||
};
|
||||
|
||||
// 处理 timer 状态变化
|
||||
// 当 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 !== oldInterval && globalThis.reloadHealthCheckTimer) {
|
||||
globalThis._activeHealthCheckInterval = newInterval;
|
||||
globalThis.reloadHealthCheckTimer(newInterval);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle system prompt update
|
||||
if (newConfig.systemPrompt !== undefined) {
|
||||
const promptPath = currentConfig.SYSTEM_PROMPT_FILE_PATH || 'configs/input_system_prompt.txt';
|
||||
|
|
@ -175,7 +309,8 @@ export async function handleUpdateConfig(req, res, currentConfig) {
|
|||
TLS_SIDECAR_ENABLED: currentConfig.TLS_SIDECAR_ENABLED,
|
||||
TLS_SIDECAR_ENABLED_PROVIDERS: currentConfig.TLS_SIDECAR_ENABLED_PROVIDERS,
|
||||
TLS_SIDECAR_PORT: currentConfig.TLS_SIDECAR_PORT,
|
||||
TLS_SIDECAR_PROXY_URL: currentConfig.TLS_SIDECAR_PROXY_URL
|
||||
TLS_SIDECAR_PROXY_URL: currentConfig.TLS_SIDECAR_PROXY_URL,
|
||||
SCHEDULED_HEALTH_CHECK: currentConfig.SCHEDULED_HEALTH_CHECK
|
||||
};
|
||||
|
||||
writeFileSync(configPath, JSON.stringify(configToSave, null, 2), 'utf-8');
|
||||
|
|
@ -266,17 +401,27 @@ export async function handleUpdateAdminPassword(req, res) {
|
|||
|
||||
if (!password || password.trim() === '') {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
error: {
|
||||
message: 'Password cannot be empty'
|
||||
}
|
||||
}));
|
||||
res.end(JSON.stringify({ error: { message: 'Password cannot be empty' } }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// 写入密码到 pwd 文件
|
||||
if (password.trim().length < PASSWORD.MIN_LENGTH) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: `Password must be at least ${PASSWORD.MIN_LENGTH} characters` } }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// 使用 PBKDF2 哈希存储密码,避免明文写入文件
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = await new Promise((resolve, reject) =>
|
||||
crypto.pbkdf2(password.trim(), salt, PASSWORD.PBKDF2_ITERATIONS, PASSWORD.PBKDF2_KEYLEN, PASSWORD.PBKDF2_DIGEST, (err, key) =>
|
||||
err ? reject(err) : resolve(key.toString('hex'))
|
||||
)
|
||||
);
|
||||
const stored = `pbkdf2:${salt}:${hash}`;
|
||||
|
||||
const pwdFilePath = path.join(process.cwd(), 'configs', 'pwd');
|
||||
await fs.writeFile(pwdFilePath, password.trim(), 'utf-8');
|
||||
await fs.writeFile(pwdFilePath, stored, 'utf-8');
|
||||
|
||||
logger.info('[UI API] Admin password updated successfully');
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,69 @@ import { generateUUID, createProviderConfig, formatSystemPath, detectProviderFro
|
|||
import { broadcastEvent } from './event-broadcast.js';
|
||||
import { getRegisteredProviders } from '../providers/adapter.js';
|
||||
|
||||
// 文件级互斥锁:防止并发读写导致数据丢失
|
||||
// 安全净化:移除用户输入字段中的危险内容(script、事件处理器、javascript:协议等),
|
||||
// 存储原始文本。HTML 转义统一由前端 escHtml() 负责,避免双编码问题。
|
||||
function sanitizeProviderData(provider) {
|
||||
if (!provider || typeof provider !== 'object') return provider;
|
||||
const sanitized = { ...provider };
|
||||
if (typeof sanitized.customName === 'string') {
|
||||
let name = sanitized.customName;
|
||||
|
||||
// 拒绝包含危险协议
|
||||
if (/(?:data|javascript|vbscript)\s*:/i.test(name)) {
|
||||
sanitized.customName = '';
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// 移除所有 HTML 标签(更安全的方式)
|
||||
name = name.replace(/<[^>]*>/g, '');
|
||||
|
||||
// 移除 HTML 事件处理器属性(onclick/onerror 等)
|
||||
name = name.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '');
|
||||
|
||||
// 移除潜在的 HTML 实体编码攻击
|
||||
name = name.replace(/&[#\w]+;/g, '');
|
||||
|
||||
sanitized.customName = name.trim();
|
||||
}
|
||||
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();
|
||||
|
||||
// 超时包装函数:防止操作永久挂起导致锁链阻塞
|
||||
function withTimeout(promise, ms = 30000) {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Operation timeout after ${ms}ms`)), ms)
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
function withFileLock(fn) {
|
||||
const next = _fileLockChain
|
||||
.then(() => withTimeout(fn(), 30000))
|
||||
.catch(err => {
|
||||
// 记录错误并抛出,中断操作
|
||||
logger.error('[FileLock] Operation failed:', err?.message || err);
|
||||
throw err;
|
||||
});
|
||||
_fileLockChain = next.then(() => {}).catch(() => {});
|
||||
return next;
|
||||
}
|
||||
/**
|
||||
* 获取提供商池摘要
|
||||
*/
|
||||
|
|
@ -24,7 +87,7 @@ export async function handleGetProviders(req, res, currentConfig, providerPoolMa
|
|||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(providerPools));
|
||||
res.end(JSON.stringify(sanitizeProviderPools(providerPools)));
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +122,7 @@ export async function handleGetProviderType(req, res, currentConfig, providerPoo
|
|||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
providerType,
|
||||
providers,
|
||||
providers: providers.map(sanitizeProviderData),
|
||||
totalCount: providers.length,
|
||||
healthyCount: providers.filter(p => p.isHealthy).length
|
||||
}));
|
||||
|
|
@ -93,6 +156,13 @@ export async function handleGetProviderTypeModels(req, res, providerType) {
|
|||
* 添加新的提供商配置
|
||||
*/
|
||||
export async function handleAddProvider(req, res, currentConfig, providerPoolManager) {
|
||||
return withFileLock(() => _handleAddProvider(req, res, currentConfig, providerPoolManager)).catch(err => {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } }));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
async function _handleAddProvider(req, res, currentConfig, providerPoolManager) {
|
||||
try {
|
||||
const body = await getRequestBody(req);
|
||||
const { providerType, providerConfig } = body;
|
||||
|
|
@ -115,7 +185,7 @@ export async function handleAddProvider(req, res, currentConfig, providerPoolMan
|
|||
providerConfig.errorCount = providerConfig.errorCount || 0;
|
||||
providerConfig.lastErrorTime = providerConfig.lastErrorTime || null;
|
||||
|
||||
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json';
|
||||
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
|
||||
let providerPools = {};
|
||||
|
||||
// Load existing pools
|
||||
|
|
@ -149,7 +219,7 @@ export async function handleAddProvider(req, res, currentConfig, providerPoolMan
|
|||
action: 'add',
|
||||
filePath: filePath,
|
||||
providerType,
|
||||
providerConfig,
|
||||
providerConfig: sanitizeProviderData(providerConfig),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
|
|
@ -157,7 +227,7 @@ export async function handleAddProvider(req, res, currentConfig, providerPoolMan
|
|||
broadcastEvent('provider_update', {
|
||||
action: 'add',
|
||||
providerType,
|
||||
providerConfig,
|
||||
providerConfig: sanitizeProviderData(providerConfig),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
|
|
@ -165,7 +235,7 @@ export async function handleAddProvider(req, res, currentConfig, providerPoolMan
|
|||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Provider added successfully',
|
||||
provider: providerConfig,
|
||||
provider: sanitizeProviderData(providerConfig),
|
||||
providerType
|
||||
}));
|
||||
return true;
|
||||
|
|
@ -180,6 +250,13 @@ export async function handleAddProvider(req, res, currentConfig, providerPoolMan
|
|||
* 更新特定提供商配置
|
||||
*/
|
||||
export async function handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) {
|
||||
return withFileLock(() => _handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid)).catch(err => {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } }));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
async function _handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) {
|
||||
try {
|
||||
const body = await getRequestBody(req);
|
||||
const { providerConfig } = body;
|
||||
|
|
@ -244,7 +321,7 @@ export async function handleUpdateProvider(req, res, currentConfig, providerPool
|
|||
action: 'update',
|
||||
filePath: filePath,
|
||||
providerType,
|
||||
providerConfig: updatedProvider,
|
||||
providerConfig: sanitizeProviderData(updatedProvider),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
|
|
@ -252,7 +329,7 @@ export async function handleUpdateProvider(req, res, currentConfig, providerPool
|
|||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Provider updated successfully',
|
||||
provider: updatedProvider
|
||||
provider: sanitizeProviderData(updatedProvider)
|
||||
}));
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
|
@ -266,6 +343,13 @@ export async function handleUpdateProvider(req, res, currentConfig, providerPool
|
|||
* 删除特定提供商配置
|
||||
*/
|
||||
export async function handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) {
|
||||
return withFileLock(() => _handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid)).catch(err => {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } }));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
async function _handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) {
|
||||
try {
|
||||
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
|
||||
let providerPools = {};
|
||||
|
|
@ -315,7 +399,7 @@ export async function handleDeleteProvider(req, res, currentConfig, providerPool
|
|||
action: 'delete',
|
||||
filePath: filePath,
|
||||
providerType,
|
||||
providerConfig: deletedProvider,
|
||||
providerConfig: sanitizeProviderData(deletedProvider),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
|
|
@ -323,7 +407,7 @@ export async function handleDeleteProvider(req, res, currentConfig, providerPool
|
|||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Provider deleted successfully',
|
||||
deletedProvider
|
||||
deletedProvider: sanitizeProviderData(deletedProvider)
|
||||
}));
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
|
@ -337,6 +421,13 @@ export async function handleDeleteProvider(req, res, currentConfig, providerPool
|
|||
* 禁用/启用特定提供商配置
|
||||
*/
|
||||
export async function handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action) {
|
||||
return withFileLock(() => _handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action)).catch(err => {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'File operation failed: ' + err.message } }));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
async function _handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action) {
|
||||
try {
|
||||
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
|
||||
let providerPools = {};
|
||||
|
|
@ -388,7 +479,7 @@ export async function handleDisableEnableProvider(req, res, currentConfig, provi
|
|||
action: action,
|
||||
filePath: filePath,
|
||||
providerType,
|
||||
providerConfig: provider,
|
||||
providerConfig: sanitizeProviderData(provider),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
|
|
@ -396,7 +487,7 @@ export async function handleDisableEnableProvider(req, res, currentConfig, provi
|
|||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
message: `Provider ${action}d successfully`,
|
||||
provider: provider
|
||||
provider: sanitizeProviderData(provider)
|
||||
}));
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
|
@ -550,7 +641,7 @@ export async function handleDeleteUnhealthyProviders(req, res, currentConfig, pr
|
|||
filePath: filePath,
|
||||
providerType,
|
||||
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()
|
||||
});
|
||||
|
||||
|
|
@ -641,7 +732,7 @@ export async function handleRefreshUnhealthyUuids(req, res, currentConfig, provi
|
|||
filePath: filePath,
|
||||
providerType,
|
||||
refreshedCount: refreshedProviders.length,
|
||||
refreshedProviders,
|
||||
refreshedProviders: refreshedProviders.map(p => sanitizeProviderData(p)),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
|
|
@ -698,7 +789,7 @@ export async function handleHealthCheck(req, res, currentConfig, providerPoolMan
|
|||
|
||||
logger.info(`[UI API] Starting health check for ${unhealthyProviders.length} unhealthy providers in ${providerType} (total: ${providers.length})`);
|
||||
|
||||
// 执行健康检测(强制检查,忽略 checkHealth 配置)
|
||||
// 执行健康检测(检查所有未禁用的 unhealthy providers)
|
||||
const results = [];
|
||||
for (const providerStatus of unhealthyProviders) {
|
||||
const providerConfig = providerStatus.config;
|
||||
|
|
@ -709,18 +800,8 @@ export async function handleHealthCheck(req, res, currentConfig, providerPoolMan
|
|||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// 传递 forceCheck = true 强制执行健康检查,忽略 checkHealth 配置
|
||||
const healthResult = await providerPoolManager._checkProviderHealth(providerType, providerConfig, true);
|
||||
|
||||
if (healthResult === null) {
|
||||
results.push({
|
||||
uuid: providerConfig.uuid,
|
||||
success: null,
|
||||
message: 'Health check not supported for this provider type'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const healthResult = await providerPoolManager._checkProviderHealth(providerType, providerConfig);
|
||||
|
||||
if (healthResult.success) {
|
||||
providerPoolManager.markProviderHealthy(providerType, providerConfig, false, healthResult.modelName);
|
||||
|
|
@ -797,7 +878,7 @@ export async function handleHealthCheck(req, res, currentConfig, providerPoolMan
|
|||
action: 'health_check',
|
||||
filePath: filePath,
|
||||
providerType,
|
||||
results,
|
||||
results: results.map(r => ({ ...r, message: sanitizeProviderData({ message: r.message }).message })),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
|
|
@ -1037,7 +1118,7 @@ export async function handleRefreshProviderUuid(req, res, currentConfig, provide
|
|||
message: 'UUID refreshed successfully',
|
||||
oldUuid,
|
||||
newUuid,
|
||||
provider: providerPools[providerType][providerIndex]
|
||||
provider: sanitizeProviderData(providerPools[providerType][providerIndex])
|
||||
}));
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
|
|
|||
42
src/utils/constants.js
Normal file
42
src/utils/constants.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* 共享常量定义
|
||||
* 集中管理各处使用的硬编码值
|
||||
*/
|
||||
|
||||
// 定时健康检查相关常量
|
||||
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 = {
|
||||
// 最小密码长度(最少12位,与现代安全实践一致)
|
||||
MIN_LENGTH: 12,
|
||||
// PBKDF2迭代次数(OWASP 2023建议 SHA-512 ≥310,000次)
|
||||
PBKDF2_ITERATIONS: 310000,
|
||||
// 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
|
||||
};
|
||||
|
|
@ -41,6 +41,7 @@ export const PROVIDER_MAPPINGS = [
|
|||
providerType: 'openai-qwen-oauth',
|
||||
credPathKey: 'QWEN_OAUTH_CREDS_FILE_PATH',
|
||||
defaultCheckModel: 'qwen3-coder-plus',
|
||||
defaultCheckHealth: true,
|
||||
displayName: 'Qwen OAuth',
|
||||
needsProjectId: false,
|
||||
urlKeys: ['QWEN_BASE_URL', 'QWEN_OAUTH_BASE_URL']
|
||||
|
|
@ -93,9 +94,14 @@ export const PROVIDER_MAPPINGS = [
|
|||
|
||||
/**
|
||||
* 生成 UUID
|
||||
* 兼容旧版 Node.js(<14.17.0):如果 crypto.randomUUID 不存在则使用 Math.random 回退方案
|
||||
* @returns {string} UUID 字符串
|
||||
*/
|
||||
export function generateUUID() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// 回退方案:使用 Math.random 生成标准 UUID v4
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
|
|
@ -324,13 +330,13 @@ export async function isValidOAuthCredentials(filePath) {
|
|||
* @returns {Object} 新的提供商配置对象
|
||||
*/
|
||||
export function createProviderConfig(options) {
|
||||
const { credPathKey, credPath, defaultCheckModel, needsProjectId, urlKeys } = options;
|
||||
const { credPathKey, credPath, defaultCheckModel, defaultCheckHealth, needsProjectId, urlKeys } = options;
|
||||
|
||||
const newProvider = {
|
||||
[credPathKey]: credPath,
|
||||
uuid: generateUUID(),
|
||||
checkModelName: defaultCheckModel,
|
||||
checkHealth: false,
|
||||
checkHealth: defaultCheckHealth ?? false,
|
||||
isHealthy: true,
|
||||
isDisabled: false,
|
||||
lastUsed: null,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ function updateConfigProviderConfigs(configs) {
|
|||
if (tlsSidecarProvidersEl) {
|
||||
renderProviderTags(tlsSidecarProvidersEl, configs, false);
|
||||
}
|
||||
|
||||
// 渲染定时健康检查的提供商选择
|
||||
const scheduledHealthCheckProvidersEl = document.getElementById('scheduledHealthCheckProviders');
|
||||
if (scheduledHealthCheckProvidersEl) {
|
||||
renderProviderTags(scheduledHealthCheckProvidersEl, configs, false);
|
||||
}
|
||||
|
||||
// 重新加载当前配置以恢复选中状态
|
||||
loadConfiguration();
|
||||
|
|
@ -47,10 +53,11 @@ function renderProviderTags(container, configs, isRequired) {
|
|||
// 过滤掉不可见的提供商
|
||||
const visibleConfigs = configs.filter(c => c.visible !== false);
|
||||
|
||||
const escHtml = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
container.innerHTML = visibleConfigs.map(c => `
|
||||
<button type="button" class="provider-tag" data-value="${c.id}">
|
||||
<i class="fas ${c.icon || 'fa-server'}"></i>
|
||||
<span>${c.name}</span>
|
||||
<button type="button" class="provider-tag" data-value="${escHtml(c.id)}">
|
||||
<i class="fas ${escHtml(c.icon || 'fa-server')}"></i>
|
||||
<span>${escHtml(c.name)}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
|
|
@ -151,7 +158,7 @@ async function loadConfiguration() {
|
|||
if (cronNearMinutesEl) cronNearMinutesEl.value = data.CRON_NEAR_MINUTES || 1;
|
||||
if (cronRefreshTokenEl) cronRefreshTokenEl.checked = data.CRON_REFRESH_TOKEN || false;
|
||||
if (loginExpiryEl) loginExpiryEl.value = data.LOGIN_EXPIRY || 3600;
|
||||
if (providerPoolsFilePathEl) providerPoolsFilePathEl.value = data.PROVIDER_POOLS_FILE_PATH;
|
||||
if (providerPoolsFilePathEl) providerPoolsFilePathEl.value = data.PROVIDER_POOLS_FILE_PATH || '';
|
||||
if (maxErrorCountEl) maxErrorCountEl.value = data.MAX_ERROR_COUNT || 10;
|
||||
if (warmupTargetEl) warmupTargetEl.value = data.WARMUP_TARGET || 0;
|
||||
if (refreshConcurrencyPerProviderEl) refreshConcurrencyPerProviderEl.value = data.REFRESH_CONCURRENCY_PER_PROVIDER || 1;
|
||||
|
|
@ -236,6 +243,50 @@ async function loadConfiguration() {
|
|||
});
|
||||
}
|
||||
|
||||
// 定时健康检查配置
|
||||
const scheduledHealthCheckEnabledEl = document.getElementById('scheduledHealthCheckEnabled');
|
||||
const scheduledHealthCheckStartupRunEl = document.getElementById('scheduledHealthCheckStartupRun');
|
||||
const scheduledHealthCheckIntervalEl = document.getElementById('scheduledHealthCheckInterval');
|
||||
|
||||
if (data.SCHEDULED_HEALTH_CHECK) {
|
||||
if (scheduledHealthCheckEnabledEl) scheduledHealthCheckEnabledEl.checked = data.SCHEDULED_HEALTH_CHECK.enabled === true;
|
||||
if (scheduledHealthCheckStartupRunEl) scheduledHealthCheckStartupRunEl.checked = data.SCHEDULED_HEALTH_CHECK.startupRun !== false;
|
||||
if (scheduledHealthCheckIntervalEl) scheduledHealthCheckIntervalEl.value = data.SCHEDULED_HEALTH_CHECK.interval || 600000;
|
||||
} else {
|
||||
if (scheduledHealthCheckEnabledEl) scheduledHealthCheckEnabledEl.checked = true;
|
||||
if (scheduledHealthCheckStartupRunEl) scheduledHealthCheckStartupRunEl.checked = true;
|
||||
if (scheduledHealthCheckIntervalEl) scheduledHealthCheckIntervalEl.value = 600000;
|
||||
}
|
||||
|
||||
// 加载定时健康检查的供应商选择
|
||||
const scheduledHealthCheckProvidersEl = document.getElementById('scheduledHealthCheckProviders');
|
||||
if (scheduledHealthCheckProvidersEl) {
|
||||
const enabledProviders = data.SCHEDULED_HEALTH_CHECK?.providerTypes || [];
|
||||
const tags = scheduledHealthCheckProvidersEl.querySelectorAll('.provider-tag');
|
||||
tags.forEach(tag => {
|
||||
const value = tag.getAttribute('data-value');
|
||||
if (enabledProviders.includes(value)) {
|
||||
tag.classList.add('selected');
|
||||
} else {
|
||||
tag.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 定时健康检查间隔快捷按钮(防止重复绑定)
|
||||
const intervalQuickBtns = document.querySelectorAll('#scheduledHealthCheckInterval + .quick-select-btns button');
|
||||
intervalQuickBtns.forEach(btn => {
|
||||
if (btn.dataset.listenerAttached) return; // 防止重复绑定
|
||||
btn.dataset.listenerAttached = 'true';
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const value = parseInt(btn.getAttribute('data-value'));
|
||||
if (scheduledHealthCheckIntervalEl) {
|
||||
scheduledHealthCheckIntervalEl.value = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load configuration:', error);
|
||||
}
|
||||
|
|
@ -346,6 +397,24 @@ async function saveConfiguration() {
|
|||
} else {
|
||||
config.TLS_SIDECAR_ENABLED_PROVIDERS = [];
|
||||
}
|
||||
|
||||
// 定时健康检查配置
|
||||
const scheduledHealthCheckProvidersEl = document.getElementById('scheduledHealthCheckProviders');
|
||||
const scheduledHealthCheckProviderTypes = scheduledHealthCheckProvidersEl
|
||||
? Array.from(scheduledHealthCheckProvidersEl.querySelectorAll('.provider-tag.selected'))
|
||||
.map(tag => tag.getAttribute('data-value'))
|
||||
: [];
|
||||
|
||||
// 验证并规范化 interval 值
|
||||
const rawInterval = parseInt(document.getElementById('scheduledHealthCheckInterval')?.value);
|
||||
const validatedInterval = isNaN(rawInterval) ? 600000 : Math.max(60000, Math.min(3600000, rawInterval));
|
||||
|
||||
config.SCHEDULED_HEALTH_CHECK = {
|
||||
enabled: document.getElementById('scheduledHealthCheckEnabled')?.checked !== false,
|
||||
startupRun: document.getElementById('scheduledHealthCheckStartupRun')?.checked !== false,
|
||||
interval: validatedInterval,
|
||||
providerTypes: scheduledHealthCheckProviderTypes
|
||||
};
|
||||
|
||||
try {
|
||||
await window.apiClient.post('/config', config);
|
||||
|
|
|
|||
|
|
@ -343,6 +343,13 @@ const translations = {
|
|||
'config.proxy.tlsSidecarProxyUrl': 'Sidecar 上游代理',
|
||||
'config.proxy.tlsSidecarEnabledProviders': '启用 TLS Sidecar 的提供商',
|
||||
'config.proxy.tlsSidecarNote': '启用后选中的提供商请求将通过 Go uTLS sidecar 转发,完美模拟 Chrome TLS/H2 指纹绕过 Cloudflare(需重启服务)',
|
||||
'config.healthCheck.title': '定时健康检查',
|
||||
'config.healthCheck.enabled': '启用定时检查',
|
||||
'config.healthCheck.startupRun': '启动时运行',
|
||||
'config.healthCheck.interval': '检查间隔',
|
||||
'config.healthCheck.intervalNote': '单位毫秒,最小60000ms(1分钟),最大3600000ms(1小时),可手动输入或点击快捷按钮',
|
||||
'config.healthCheck.providerTypes': '定时检查的供应商',
|
||||
'config.healthCheck.providerTypesNote': '选择需要进行定时健康检查的供应商类型,留空则不进行任何检查',
|
||||
'config.log.title': '日志设置',
|
||||
'config.log.enabled': '启用日志',
|
||||
'config.log.outputMode': '日志输出模式',
|
||||
|
|
@ -1188,6 +1195,13 @@ const translations = {
|
|||
'config.proxy.tlsSidecarProxyUrl': 'Sidecar Upstream Proxy',
|
||||
'config.proxy.tlsSidecarEnabledProviders': 'Providers Using TLS Sidecar',
|
||||
'config.proxy.tlsSidecarNote': 'When enabled, requests for selected providers are routed through Go uTLS sidecar for perfect Chrome TLS/H2 fingerprint to bypass Cloudflare (requires restart)',
|
||||
'config.healthCheck.title': 'Scheduled Health Check',
|
||||
'config.healthCheck.enabled': 'Enable Scheduled Check',
|
||||
'config.healthCheck.startupRun': 'Run on Startup',
|
||||
'config.healthCheck.interval': 'Check Interval',
|
||||
'config.healthCheck.intervalNote': 'In milliseconds. Minimum 60000ms (1 min), maximum 3600000ms (1 hour). Enter manually or use quick select buttons',
|
||||
'config.healthCheck.providerTypes': 'Providers to Check',
|
||||
'config.healthCheck.providerTypesNote': 'Select provider types for scheduled health checks. Leave empty to skip all checks',
|
||||
'config.log.title': 'Log Settings',
|
||||
'config.log.enabled': 'Enable Logging',
|
||||
'config.log.outputMode': 'Log Output Mode',
|
||||
|
|
|
|||
|
|
@ -7,11 +7,6 @@
|
|||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.config-form {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
|
@ -49,6 +44,33 @@ textarea.form-control {
|
|||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* 带快捷选择的输入框 */
|
||||
.input-with-quick-select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-with-quick-select .form-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quick-select-btns {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.quick-select-btns .btn {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.config-form {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 密码输入框样式 */
|
||||
.password-input-group {
|
||||
position: relative;
|
||||
|
|
@ -151,7 +173,7 @@ textarea.form-control {
|
|||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 2px;
|
||||
background-color: white;
|
||||
background-color: var(--bg-primary, white);
|
||||
transition: var(--transition);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 3px var(--neutral-shadow-30);
|
||||
|
|
|
|||
|
|
@ -62,10 +62,7 @@
|
|||
<i class="fas fa-reply"></i>
|
||||
<span>OpenAI Responses</span>
|
||||
</button>
|
||||
<button type="button" class="provider-tag" data-value="openai-iflow">
|
||||
<i class="fas fa-stream"></i>
|
||||
<span data-i18n="dashboard.routing.nodeName.iflow">iFlow OAuth</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="provider-tag" data-value="openai-codex-oauth">
|
||||
<i class="fas fa-code"></i>
|
||||
<span data-i18n="dashboard.routing.nodeName.codex">OpenAI Codex OAuth</span>
|
||||
|
|
@ -118,10 +115,7 @@
|
|||
<i class="fas fa-reply"></i>
|
||||
<span>OpenAI Responses</span>
|
||||
</button>
|
||||
<button type="button" class="provider-tag" data-value="openai-iflow">
|
||||
<i class="fas fa-stream"></i>
|
||||
<span>iFlow OAuth</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="provider-tag" data-value="openai-codex-oauth">
|
||||
<i class="fas fa-code"></i>
|
||||
<span>OpenAI Codex OAuth</span>
|
||||
|
|
@ -228,6 +222,45 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 定时健康检查 -->
|
||||
<div class="config-group-section">
|
||||
<h3 data-i18n="config.healthCheck.title"><i class="fas fa-heartbeat"></i> 定时健康检查</h3>
|
||||
<div class="config-row">
|
||||
<div class="form-group">
|
||||
<span class="toggle-label" data-i18n="config.healthCheck.enabled">启用定时检查</span>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="scheduledHealthCheckEnabled">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="toggle-label" data-i18n="config.healthCheck.startupRun">启动时运行</span>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="scheduledHealthCheckStartupRun">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="scheduledHealthCheckInterval" data-i18n="config.healthCheck.interval">检查间隔</label>
|
||||
<div class="input-with-quick-select">
|
||||
<input type="number" id="scheduledHealthCheckInterval" class="form-control" min="60000" max="3600000" step="60000" value="600000" placeholder="毫秒">
|
||||
<div class="quick-select-btns">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-value="300000">5分钟</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-value="600000">10分钟</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-value="1800000">30分钟</button>
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-text" data-i18n="config.healthCheck.intervalNote">单位毫秒,最小60000ms(1分钟),最大3600000ms(1小时)</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.healthCheck.providerTypes">定时检查的供应商</label>
|
||||
<div id="scheduledHealthCheckProviders" class="provider-tags">
|
||||
<!-- 由 config-manager.js updateConfigProviderConfigs 动态渲染,勿在此处硬编码 -->
|
||||
</div>
|
||||
<small class="form-text" data-i18n="config.healthCheck.providerTypesNote">选择需要进行定时健康检查的供应商类型</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志管理 -->
|
||||
<div class="config-group-section">
|
||||
|
|
|
|||
325
tests/security-fixes.test.js
Normal file
325
tests/security-fixes.test.js
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
/**
|
||||
* Security Fixes Integration Test Suite
|
||||
*
|
||||
* 测试最近修复的安全问题:
|
||||
* 1. XSS 防护 - sanitizeProviderData
|
||||
* 2. 路径遍历防护 - 路径验证逻辑
|
||||
* 3. 文件锁超时机制
|
||||
* 4. 健康检查方法调用
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from '@jest/globals';
|
||||
import { fetch } from 'undici';
|
||||
|
||||
const TEST_SERVER_BASE_URL = process.env.TEST_SERVER_BASE_URL || 'http://localhost:3000';
|
||||
const TEST_API_KEY = process.env.TEST_API_KEY || '123456';
|
||||
|
||||
describe('Security Fixes Integration Tests', () => {
|
||||
|
||||
describe('XSS Protection', () => {
|
||||
test('should remove script tags from customName', async () => {
|
||||
const maliciousName = '<script>alert("XSS")</script>TestProvider';
|
||||
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/providers`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TEST_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
providerType: 'openai-custom',
|
||||
providerConfig: {
|
||||
customName: maliciousName,
|
||||
OPENAI_CUSTOM_BASE_URL: 'https://api.example.com',
|
||||
OPENAI_CUSTOM_API_KEY: 'test-key'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.provider.customName).not.toContain('<script>');
|
||||
expect(data.provider.customName).not.toContain('</script>');
|
||||
expect(data.provider.customName).toContain('TestProvider');
|
||||
});
|
||||
|
||||
test('should reject javascript: protocol', async () => {
|
||||
const maliciousName = 'javascript:alert("XSS")';
|
||||
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/providers`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TEST_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
providerType: 'openai-custom',
|
||||
providerConfig: {
|
||||
customName: maliciousName,
|
||||
OPENAI_CUSTOM_BASE_URL: 'https://api.example.com',
|
||||
OPENAI_CUSTOM_API_KEY: 'test-key'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.provider.customName).toBe('');
|
||||
});
|
||||
|
||||
test('should reject data: protocol', async () => {
|
||||
const maliciousName = 'data:text/html,<script>alert(1)</script>';
|
||||
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/providers`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TEST_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
providerType: 'openai-custom',
|
||||
providerConfig: {
|
||||
customName: maliciousName,
|
||||
OPENAI_CUSTOM_BASE_URL: 'https://api.example.com',
|
||||
OPENAI_CUSTOM_API_KEY: 'test-key'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.provider.customName).toBe('');
|
||||
});
|
||||
|
||||
test('should remove HTML event handlers', async () => {
|
||||
const maliciousName = '<img src=x onerror="alert(1)">';
|
||||
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/providers`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TEST_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
providerType: 'openai-custom',
|
||||
providerConfig: {
|
||||
customName: maliciousName,
|
||||
OPENAI_CUSTOM_BASE_URL: 'https://api.example.com',
|
||||
OPENAI_CUSTOM_API_KEY: 'test-key'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.provider.customName).not.toContain('onerror');
|
||||
expect(data.provider.customName).not.toContain('<img');
|
||||
});
|
||||
|
||||
test('should remove HTML entities', async () => {
|
||||
const maliciousName = '<script>alert(1)</script>';
|
||||
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/providers`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TEST_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
providerType: 'openai-custom',
|
||||
providerConfig: {
|
||||
customName: maliciousName,
|
||||
OPENAI_CUSTOM_BASE_URL: 'https://api.example.com',
|
||||
OPENAI_CUSTOM_API_KEY: 'test-key'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.provider.customName).not.toContain('<');
|
||||
expect(data.provider.customName).not.toContain('>');
|
||||
});
|
||||
|
||||
test('should preserve normal text', async () => {
|
||||
const normalName = 'My Test Provider 123';
|
||||
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/providers`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TEST_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
providerType: 'openai-custom',
|
||||
providerConfig: {
|
||||
customName: normalName,
|
||||
OPENAI_CUSTOM_BASE_URL: 'https://api.example.com',
|
||||
OPENAI_CUSTOM_API_KEY: 'test-key'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.provider.customName).toBe(normalName);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path Traversal Protection', () => {
|
||||
test('should reject paths with ..', async () => {
|
||||
const maliciousPath = '../../../etc/passwd';
|
||||
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TEST_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
SYSTEM_PROMPT_FILE_PATH: maliciousPath
|
||||
})
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const getResponse = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TEST_API_KEY}`
|
||||
}
|
||||
});
|
||||
const config = await getResponse.json();
|
||||
expect(config.SYSTEM_PROMPT_FILE_PATH).not.toBe(maliciousPath);
|
||||
});
|
||||
|
||||
test('should accept valid paths within working directory', async () => {
|
||||
const validPath = 'configs/my_prompt.txt';
|
||||
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TEST_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
SYSTEM_PROMPT_FILE_PATH: validPath
|
||||
})
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const getResponse = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TEST_API_KEY}`
|
||||
}
|
||||
});
|
||||
const config = await getResponse.json();
|
||||
expect(config.SYSTEM_PROMPT_FILE_PATH).toBe(validPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Health Check Configuration', () => {
|
||||
test('should save scheduled health check settings', async () => {
|
||||
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TEST_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
SCHEDULED_HEALTH_CHECK: {
|
||||
enabled: true,
|
||||
startupRun: true,
|
||||
interval: 300000,
|
||||
providerTypes: ['openai-custom']
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const getResponse = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TEST_API_KEY}`
|
||||
}
|
||||
});
|
||||
const config = await getResponse.json();
|
||||
expect(config.SCHEDULED_HEALTH_CHECK).toBeDefined();
|
||||
expect(config.SCHEDULED_HEALTH_CHECK.enabled).toBe(true);
|
||||
expect(config.SCHEDULED_HEALTH_CHECK.interval).toBe(300000);
|
||||
});
|
||||
|
||||
test('should enforce minimum interval', async () => {
|
||||
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TEST_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
SCHEDULED_HEALTH_CHECK: {
|
||||
enabled: true,
|
||||
interval: 30000
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const getResponse = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TEST_API_KEY}`
|
||||
}
|
||||
});
|
||||
const config = await getResponse.json();
|
||||
expect(config.SCHEDULED_HEALTH_CHECK.interval).toBeGreaterThanOrEqual(60000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configuration Validation', () => {
|
||||
test('should reject invalid port numbers', async () => {
|
||||
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TEST_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
SERVER_PORT: 99999
|
||||
})
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const getResponse = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TEST_API_KEY}`
|
||||
}
|
||||
});
|
||||
const config = await getResponse.json();
|
||||
expect(config.SERVER_PORT).not.toBe(99999);
|
||||
});
|
||||
|
||||
test('should reject excessive retry counts', async () => {
|
||||
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TEST_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
REQUEST_MAX_RETRIES: 999
|
||||
})
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const getResponse = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TEST_API_KEY}`
|
||||
}
|
||||
});
|
||||
const config = await getResponse.json();
|
||||
expect(config.REQUEST_MAX_RETRIES).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
test('should mask API key in response', async () => {
|
||||
const response = await fetch(`${TEST_SERVER_BASE_URL}/api/config`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TEST_API_KEY}`
|
||||
}
|
||||
});
|
||||
const config = await response.json();
|
||||
|
||||
if (config.REQUIRED_API_KEY) {
|
||||
expect(config.REQUIRED_API_KEY).toContain('*');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
205
tests/security-fixes.unit.test.js
Normal file
205
tests/security-fixes.unit.test.js
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
/**
|
||||
* Unit Tests for Security Fixes
|
||||
*
|
||||
* 这些是不需要运行服务器的纯单元测试
|
||||
* 可以直接运行: npm test -- tests/security-fixes.unit.test.js
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from '@jest/globals';
|
||||
|
||||
// ========== 模拟 sanitizeProviderData 函数 ==========
|
||||
function sanitizeProviderData(provider) {
|
||||
if (!provider || typeof provider !== 'object') return provider;
|
||||
const sanitized = { ...provider };
|
||||
if (typeof sanitized.customName === 'string') {
|
||||
let name = sanitized.customName;
|
||||
|
||||
// 拒绝包含危险协议
|
||||
if (/(?:data|javascript|vbscript)\s*:/i.test(name)) {
|
||||
sanitized.customName = '';
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// 移除所有 HTML 标签
|
||||
name = name.replace(/<[^>]*>/g, '');
|
||||
|
||||
// 移除 HTML 事件处理器属性
|
||||
name = name.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '');
|
||||
|
||||
// 移除潜在的 HTML 实体编码攻击
|
||||
name = name.replace(/&[#\w]+;/g, '');
|
||||
|
||||
sanitized.customName = name.trim();
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// ========== 模拟 withTimeout 函数 ==========
|
||||
function withTimeout(promise, ms = 30000) {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Operation timeout after ${ms}ms`)), ms)
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
// ========== 模拟路径验证逻辑 ==========
|
||||
import path from 'path';
|
||||
|
||||
function validatePath(inputPath, cwd) {
|
||||
const resolved = path.resolve(cwd, inputPath);
|
||||
const relativePath = path.relative(cwd, resolved);
|
||||
const isInsideCwd = !path.isAbsolute(relativePath) && !relativePath.startsWith('..') && relativePath !== '..';
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const normalizedResolved = (isWindows ? resolved.toLowerCase() : resolved).replace(/\\/g, '/');
|
||||
const normalizedCwd = (isWindows ? cwd.toLowerCase() : cwd).replace(/\\/g, '/');
|
||||
const startsWithCwd = normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd;
|
||||
|
||||
return isInsideCwd && startsWithCwd;
|
||||
}
|
||||
|
||||
describe('Unit Tests - sanitizeProviderData', () => {
|
||||
test('should remove script tags', () => {
|
||||
const input = { customName: '<script>alert("XSS")</script>TestProvider' };
|
||||
const result = sanitizeProviderData(input);
|
||||
expect(result.customName).not.toContain('<script>');
|
||||
expect(result.customName).not.toContain('</script>');
|
||||
expect(result.customName).toContain('TestProvider');
|
||||
});
|
||||
|
||||
test('should reject javascript: protocol', () => {
|
||||
const input = { customName: 'javascript:alert("XSS")' };
|
||||
const result = sanitizeProviderData(input);
|
||||
expect(result.customName).toBe('');
|
||||
});
|
||||
|
||||
test('should reject data: protocol', () => {
|
||||
const input = { customName: 'data:text/html,<script>alert(1)</script>' };
|
||||
const result = sanitizeProviderData(input);
|
||||
expect(result.customName).toBe('');
|
||||
});
|
||||
|
||||
test('should reject vbscript: protocol', () => {
|
||||
const input = { customName: 'vbscript:msgbox("XSS")' };
|
||||
const result = sanitizeProviderData(input);
|
||||
expect(result.customName).toBe('');
|
||||
});
|
||||
|
||||
test('should remove all HTML tags', () => {
|
||||
const input = { customName: '<div><img src=x><span>Test</span></div>' };
|
||||
const result = sanitizeProviderData(input);
|
||||
expect(result.customName).toBe('Test');
|
||||
expect(result.customName).not.toContain('<');
|
||||
expect(result.customName).not.toContain('>');
|
||||
});
|
||||
|
||||
test('should remove event handlers', () => {
|
||||
const input = { customName: 'Test onclick="alert(1)" Provider' };
|
||||
const result = sanitizeProviderData(input);
|
||||
expect(result.customName).not.toContain('onclick');
|
||||
expect(result.customName).toContain('Test');
|
||||
expect(result.customName).toContain('Provider');
|
||||
});
|
||||
|
||||
test('should remove HTML entities', () => {
|
||||
const input = { customName: 'Test<script>Provider'' };
|
||||
const result = sanitizeProviderData(input);
|
||||
expect(result.customName).not.toContain('<');
|
||||
expect(result.customName).not.toContain('>');
|
||||
expect(result.customName).not.toContain(''');
|
||||
});
|
||||
|
||||
test('should preserve normal text', () => {
|
||||
const input = { customName: 'My Test Provider 123' };
|
||||
const result = sanitizeProviderData(input);
|
||||
expect(result.customName).toBe('My Test Provider 123');
|
||||
});
|
||||
|
||||
test('should handle empty string', () => {
|
||||
const input = { customName: '' };
|
||||
const result = sanitizeProviderData(input);
|
||||
expect(result.customName).toBe('');
|
||||
});
|
||||
|
||||
test('should handle null/undefined', () => {
|
||||
expect(sanitizeProviderData(null)).toBe(null);
|
||||
expect(sanitizeProviderData(undefined)).toBe(undefined);
|
||||
});
|
||||
|
||||
test('should handle object without customName', () => {
|
||||
const input = { uuid: '123', type: 'test' };
|
||||
const result = sanitizeProviderData(input);
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
|
||||
test('should handle complex XSS vectors', () => {
|
||||
const vectors = [
|
||||
'<img src=x onerror="alert(1)">',
|
||||
'<svg onload="alert(1)">',
|
||||
'<iframe src="javascript:alert(1)">',
|
||||
'"><script>alert(1)</script>',
|
||||
];
|
||||
|
||||
vectors.forEach(vector => {
|
||||
const result = sanitizeProviderData({ customName: vector });
|
||||
expect(result.customName).not.toContain('<script>');
|
||||
expect(result.customName).not.toContain('onerror');
|
||||
expect(result.customName).not.toContain('onload');
|
||||
expect(result.customName).not.toContain('javascript');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unit Tests - withTimeout', () => {
|
||||
test('should complete before timeout', async () => {
|
||||
const fastOperation = Promise.resolve('success');
|
||||
const result = await withTimeout(fastOperation, 1000);
|
||||
expect(result).toBe('success');
|
||||
});
|
||||
|
||||
test('should reject slow operations after timeout', async () => {
|
||||
const slowOperation = new Promise(resolve => setTimeout(() => resolve('done'), 2000));
|
||||
await expect(withTimeout(slowOperation, 100)).rejects.toThrow('Operation timeout after 100ms');
|
||||
});
|
||||
|
||||
test('should use default timeout of 30 seconds', async () => {
|
||||
const slowOperation = new Promise(resolve => setTimeout(() => resolve('done'), 35000));
|
||||
await expect(withTimeout(slowOperation)).rejects.toThrow('Operation timeout after 30000ms');
|
||||
}, 35000);
|
||||
|
||||
test('should propagate operation errors', async () => {
|
||||
const failingOperation = Promise.reject(new Error('Operation failed'));
|
||||
await expect(withTimeout(failingOperation, 1000)).rejects.toThrow('Operation failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unit Tests - Path Validation', () => {
|
||||
test('should accept paths within working directory', () => {
|
||||
const cwd = process.platform === 'win32' ? 'C:\\Users\\Test\\Project' : '/home/user/project';
|
||||
expect(validatePath('configs/test.txt', cwd)).toBe(true);
|
||||
expect(validatePath('./configs/test.txt', cwd)).toBe(true);
|
||||
expect(validatePath('test.txt', cwd)).toBe(true);
|
||||
});
|
||||
|
||||
test('should reject paths with directory traversal', () => {
|
||||
const cwd = process.platform === 'win32' ? 'C:\\Users\\Test\\Project' : '/home/user/project';
|
||||
expect(validatePath('../../../etc/passwd', cwd)).toBe(false);
|
||||
expect(validatePath('configs/../../etc/passwd', cwd)).toBe(false);
|
||||
});
|
||||
|
||||
test('should reject absolute paths outside working directory', () => {
|
||||
const cwd = process.platform === 'win32' ? 'C:\\Users\\Test\\Project' : '/home/user/project';
|
||||
expect(validatePath('/etc/passwd', cwd)).toBe(false);
|
||||
expect(validatePath('/tmp/test.txt', cwd)).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle Windows paths correctly', () => {
|
||||
if (process.platform === 'win32') {
|
||||
const cwd = 'C:\\Users\\Test\\Project';
|
||||
expect(validatePath('configs\\test.txt', cwd)).toBe(true);
|
||||
expect(validatePath('CONFIGS\\TEST.TXT', cwd)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue