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];
|
const queue = this.refreshQueues[providerType];
|
||||||
|
// 记录此任务是否持有一个全局槽位(情况1追加的任务不持有)
|
||||||
|
let ownsGlobalSlot = false;
|
||||||
|
|
||||||
const runTask = async () => {
|
const runTask = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -306,8 +308,9 @@ export class ProviderPoolManager {
|
||||||
Promise.resolve().then(nextTask);
|
Promise.resolve().then(nextTask);
|
||||||
} else if (currentQueue.activeCount === 0) {
|
} else if (currentQueue.activeCount === 0) {
|
||||||
// 2. 如果当前提供商的所有任务都完成了,释放全局槽位
|
// 2. 如果当前提供商的所有任务都完成了,释放全局槽位
|
||||||
// 只有在确定队列为空且没有新任务时才清理
|
// 只有持有全局槽位的任务才能递减计数器,避免负值
|
||||||
if (currentQueue.waitingTasks.length === 0 &&
|
if (ownsGlobalSlot &&
|
||||||
|
currentQueue.waitingTasks.length === 0 &&
|
||||||
this.refreshQueues[providerType] === currentQueue) {
|
this.refreshQueues[providerType] === currentQueue) {
|
||||||
this.activeProviderRefreshes--;
|
this.activeProviderRefreshes--;
|
||||||
delete this.refreshQueues[providerType]; // 清理空队列
|
delete this.refreshQueues[providerType]; // 清理空队列
|
||||||
|
|
@ -333,15 +336,17 @@ export class ProviderPoolManager {
|
||||||
|
|
||||||
// 检查全局并发限制(按提供商分组)
|
// 检查全局并发限制(按提供商分组)
|
||||||
// 情况1: 该提供商已经在运行,直接加入其队列(不占用新的全局槽位)
|
// 情况1: 该提供商已经在运行,直接加入其队列(不占用新的全局槽位)
|
||||||
if (this.refreshQueues[providerType].activeCount > 0) {
|
const isExistingQueue = this.refreshQueues[providerType].activeCount > 0 || this.refreshQueues[providerType].waitingTasks.length > 0;
|
||||||
|
if (isExistingQueue) {
|
||||||
tryStartProviderQueue();
|
tryStartProviderQueue();
|
||||||
}
|
}
|
||||||
// 情况2: 该提供商未运行,需要检查全局槽位
|
// 情况2: 该提供商未运行,需要检查全局槽位,此路径持有全局槽位
|
||||||
else if (this.activeProviderRefreshes < this.refreshConcurrency.global) {
|
else if (this.activeProviderRefreshes < this.refreshConcurrency.global) {
|
||||||
|
ownsGlobalSlot = true;
|
||||||
this.activeProviderRefreshes++;
|
this.activeProviderRefreshes++;
|
||||||
tryStartProviderQueue();
|
tryStartProviderQueue();
|
||||||
}
|
}
|
||||||
// 情况3: 全局槽位已满,进入等待队列
|
// 情况3: 全局槽位已满,进入等待队列,由等待回调负责标记持槽
|
||||||
else {
|
else {
|
||||||
this.globalRefreshWaiters.push(() => {
|
this.globalRefreshWaiters.push(() => {
|
||||||
// 重新获取最新的队列引用
|
// 重新获取最新的队列引用
|
||||||
|
|
@ -351,7 +356,8 @@ export class ProviderPoolManager {
|
||||||
waitingTasks: []
|
waitingTasks: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// 重要:从等待队列启动时需要增加全局计数
|
// 从等待队列启动时持有全局槽位
|
||||||
|
ownsGlobalSlot = true;
|
||||||
this.activeProviderRefreshes++;
|
this.activeProviderRefreshes++;
|
||||||
tryStartProviderQueue();
|
tryStartProviderQueue();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,19 @@ export async function readPasswordFile() {
|
||||||
*/
|
*/
|
||||||
export async function validateCredentials(password) {
|
export async function validateCredentials(password) {
|
||||||
const storedPassword = await readPasswordFile();
|
const storedPassword = await readPasswordFile();
|
||||||
logger.info('[Auth] Validating password, stored password length:', storedPassword ? storedPassword.length : 0, ', input password length:', password ? password.length : 0);
|
if (!storedPassword || !password) return false;
|
||||||
const isValid = storedPassword && password === storedPassword;
|
|
||||||
logger.info('[Auth] Password validation result:', isValid);
|
// 新格式:pbkdf2:salt:hash
|
||||||
return isValid;
|
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 logger from '../utils/logger.js';
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import crypto from 'crypto';
|
||||||
import { CONFIG } from '../core/config-manager.js';
|
import { CONFIG } from '../core/config-manager.js';
|
||||||
import { serviceInstances } from '../providers/adapter.js';
|
import { serviceInstances } from '../providers/adapter.js';
|
||||||
import { initApiService } from '../services/service-manager.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 body = await getRequestBody(req);
|
||||||
const newConfig = body;
|
const newConfig = body;
|
||||||
|
|
||||||
// Update config values in memory
|
// Update config values in memory(含类型校验)
|
||||||
if (newConfig.REQUIRED_API_KEY !== undefined) currentConfig.REQUIRED_API_KEY = newConfig.REQUIRED_API_KEY;
|
if (newConfig.REQUIRED_API_KEY !== undefined) {
|
||||||
if (newConfig.HOST !== undefined) currentConfig.HOST = newConfig.HOST;
|
if (typeof newConfig.REQUIRED_API_KEY === 'string') currentConfig.REQUIRED_API_KEY = newConfig.REQUIRED_API_KEY;
|
||||||
if (newConfig.SERVER_PORT !== undefined) currentConfig.SERVER_PORT = newConfig.SERVER_PORT;
|
}
|
||||||
|
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.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.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_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.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.REQUEST_BASE_DELAY !== undefined) currentConfig.REQUEST_BASE_DELAY = newConfig.REQUEST_BASE_DELAY;
|
||||||
if (newConfig.CREDENTIAL_SWITCH_MAX_RETRIES !== undefined) currentConfig.CREDENTIAL_SWITCH_MAX_RETRIES = newConfig.CREDENTIAL_SWITCH_MAX_RETRIES;
|
if (newConfig.CREDENTIAL_SWITCH_MAX_RETRIES !== undefined) currentConfig.CREDENTIAL_SWITCH_MAX_RETRIES = newConfig.CREDENTIAL_SWITCH_MAX_RETRIES;
|
||||||
if (newConfig.CRON_NEAR_MINUTES !== undefined) currentConfig.CRON_NEAR_MINUTES = newConfig.CRON_NEAR_MINUTES;
|
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() === '') {
|
if (!password || password.trim() === '') {
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({
|
res.end(JSON.stringify({ error: { message: 'Password cannot be empty' } }));
|
||||||
error: {
|
|
||||||
message: 'Password cannot be empty'
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
return true;
|
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');
|
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');
|
logger.info('[UI API] Admin password updated successfully');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,13 @@ import { generateUUID, createProviderConfig, formatSystemPath, detectProviderFro
|
||||||
import { broadcastEvent } from './event-broadcast.js';
|
import { broadcastEvent } from './event-broadcast.js';
|
||||||
import { getRegisteredProviders } from '../providers/adapter.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) {
|
export async function handleAddProvider(req, res, currentConfig, providerPoolManager) {
|
||||||
|
return withFileLock(() => _handleAddProvider(req, res, currentConfig, providerPoolManager));
|
||||||
|
}
|
||||||
|
async function _handleAddProvider(req, res, currentConfig, providerPoolManager) {
|
||||||
try {
|
try {
|
||||||
const body = await getRequestBody(req);
|
const body = await getRequestBody(req);
|
||||||
const { providerType, providerConfig } = body;
|
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) {
|
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 {
|
try {
|
||||||
const body = await getRequestBody(req);
|
const body = await getRequestBody(req);
|
||||||
const { providerConfig } = body;
|
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) {
|
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 {
|
try {
|
||||||
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
|
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
|
||||||
let providerPools = {};
|
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) {
|
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 {
|
try {
|
||||||
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
|
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
|
||||||
let providerPools = {};
|
let providerPools = {};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue