fix: 修复架构性问题——并发计数、文件锁、输入校验、密码哈希
- fix: provider-pool-manager: activeProviderRefreshes 计数器修复,情况1(已有队列)不持有全局槽位,通过 ownsGlobalSlot 闭包变量精确控制递减时机,防止出现负值
- fix: provider-api: 为 handleAddProvider/handleUpdateProvider/handleDeleteProvider/handleDisableEnableProvider 添加模块级 Promise 链文件锁(withFileLock),防止并发读写同一 JSON 文件导致数据丢失
- fix: config-api: handleUpdateConfig 添加输入类型校验——SERVER_PORT 校验整数范围、REQUEST_MAX_RETRIES 校验数值范围、SYSTEM_PROMPT_FILE_PATH 禁止路径遍历(含..)、REQUIRED_API_KEY 限定字符串类型
- fix: config-api/auth: 密码改为 PBKDF2(SHA-512, 100000轮) 哈希存储,格式 pbkdf2🧂hash,验证使用 timingSafeEqual 防时序攻击,兼容旧明文格式平滑迁移,并增加最小长度 8 位校验
- 所有修改均使用 Node.js 内置 crypto 模块,无新依赖
This commit is contained in:
parent
a97b05dd2d
commit
fca9413f26
4 changed files with 81 additions and 26 deletions
|
|
@ -283,6 +283,8 @@ export class ProviderPoolManager {
|
|||
}
|
||||
|
||||
const queue = this.refreshQueues[providerType];
|
||||
// 记录此任务是否持有一个全局槽位(情况1追加的任务不持有)
|
||||
let ownsGlobalSlot = false;
|
||||
|
||||
const runTask = async () => {
|
||||
try {
|
||||
|
|
@ -291,13 +293,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();
|
||||
|
|
@ -306,13 +308,14 @@ export class ProviderPoolManager {
|
|||
Promise.resolve().then(nextTask);
|
||||
} else if (currentQueue.activeCount === 0) {
|
||||
// 2. 如果当前提供商的所有任务都完成了,释放全局槽位
|
||||
// 只有在确定队列为空且没有新任务时才清理
|
||||
if (currentQueue.waitingTasks.length === 0 &&
|
||||
// 只有持有全局槽位的任务才能递减计数器,避免负值
|
||||
if (ownsGlobalSlot &&
|
||||
currentQueue.waitingTasks.length === 0 &&
|
||||
this.refreshQueues[providerType] === currentQueue) {
|
||||
this.activeProviderRefreshes--;
|
||||
delete this.refreshQueues[providerType]; // 清理空队列
|
||||
}
|
||||
|
||||
|
||||
// 3. 尝试启动下一个等待中的提供商队列
|
||||
if (this.globalRefreshWaiters.length > 0) {
|
||||
const nextProviderStart = this.globalRefreshWaiters.shift();
|
||||
|
|
@ -333,15 +336,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(() => {
|
||||
// 重新获取最新的队列引用
|
||||
|
|
@ -351,7 +356,8 @@ export class ProviderPoolManager {
|
|||
waitingTasks: []
|
||||
};
|
||||
}
|
||||
// 重要:从等待队列启动时需要增加全局计数
|
||||
// 从等待队列启动时持有全局槽位
|
||||
ownsGlobalSlot = true;
|
||||
this.activeProviderRefreshes++;
|
||||
tryStartProviderQueue();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,10 +48,19 @@ 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 = crypto.pbkdf2Sync(password.trim(), salt, 100000, 64, 'sha512').toString('hex');
|
||||
return crypto.timingSafeEqual(Buffer.from(inputHash, 'hex'), Buffer.from(storedHash, 'hex'));
|
||||
}
|
||||
|
||||
// 旧格式:明文(兼容迁移期,建议通过 UI 重新设置密码以升级为哈希格式)
|
||||
return password === storedPassword;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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';
|
||||
|
|
@ -110,16 +111,30 @@ 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 > 0 && port < 65536) 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);
|
||||
// 防止路径遍历:只允许相对路径或限定目录
|
||||
if (!p.includes('..')) currentConfig.SYSTEM_PROMPT_FILE_PATH = 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 <= 100) 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;
|
||||
|
|
@ -326,17 +341,23 @@ 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 < 8) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Password must be at least 8 characters' } }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// 使用 PBKDF2 哈希存储密码,避免明文写入文件
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.pbkdf2Sync(password.trim(), salt, 100000, 64, 'sha512').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,13 @@ import { generateUUID, createProviderConfig, formatSystemPath, detectProviderFro
|
|||
import { broadcastEvent } from './event-broadcast.js';
|
||||
import { getRegisteredProviders } from '../providers/adapter.js';
|
||||
|
||||
// 文件级互斥锁:防止并发读写导致数据丢失
|
||||
let _fileLockChain = Promise.resolve();
|
||||
function withFileLock(fn) {
|
||||
const next = _fileLockChain.then(() => fn());
|
||||
_fileLockChain = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
/**
|
||||
* 获取提供商池摘要
|
||||
*/
|
||||
|
|
@ -93,6 +100,9 @@ export async function handleGetProviderTypeModels(req, res, providerType) {
|
|||
* 添加新的提供商配置
|
||||
*/
|
||||
export async function handleAddProvider(req, res, currentConfig, providerPoolManager) {
|
||||
return withFileLock(() => _handleAddProvider(req, res, currentConfig, providerPoolManager));
|
||||
}
|
||||
async function _handleAddProvider(req, res, currentConfig, providerPoolManager) {
|
||||
try {
|
||||
const body = await getRequestBody(req);
|
||||
const { providerType, providerConfig } = body;
|
||||
|
|
@ -180,6 +190,9 @@ 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));
|
||||
}
|
||||
async function _handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) {
|
||||
try {
|
||||
const body = await getRequestBody(req);
|
||||
const { providerConfig } = body;
|
||||
|
|
@ -266,6 +279,9 @@ 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));
|
||||
}
|
||||
async function _handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) {
|
||||
try {
|
||||
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
|
||||
let providerPools = {};
|
||||
|
|
@ -337,6 +353,9 @@ 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));
|
||||
}
|
||||
async function _handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action) {
|
||||
try {
|
||||
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
|
||||
let providerPools = {};
|
||||
|
|
|
|||
Loading…
Reference in a new issue