fix: 修复代码审查发现的10个安全与正确性问题
- fix: provider-pool-manager: 移除 if(true) 占位符,改为读取凭据文件真实过期时间 - fix: provider-pool-manager: Math.min 展开大数组改为 reduce,防止栈溢出 - fix: provider-pool-manager: forceRefreshToken 调用前检查方法是否实现,不存在则 fallback - fix: provider-api: handleAddProvider 默认路径统一为 configs/provider_pools.json - fix: config-api: handleGetConfig 改为白名单字段过滤,REQUIRED_API_KEY 脱敏返回 - fix: api-server: 启动日志中 API Key 遮码处理 - fix: utils: generateUUID 改用 crypto.randomUUID() 替代 Math.random() - fix: config-manager: renderProviderTags innerHTML 加 escHtml 防 XSS 注入 - fix: config-manager: PROVIDER_POOLS_FILE_PATH 未定义时加 || '' 兜底 - fix: section-config.css: white 改为 var(--bg-primary, white) 支持暗黑模式 - chore: .gitignore 添加 AGENTS.md - chore: docker-compose.yml 添加代理环境变量
This commit is contained in:
parent
801917b478
commit
a97b05dd2d
9 changed files with 89 additions and 32 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
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ services:
|
|||
- ./configs:/app/configs
|
||||
environment:
|
||||
- ARGS=
|
||||
- HTTP_PROXY=http://host.docker.internal:10801
|
||||
- http_proxy=http://host.docker.internal:10801
|
||||
- HTTPS_PROXY=http://host.docker.internal:10801
|
||||
- https_proxy=http://host.docker.internal:10801
|
||||
- NO_PROXY=localhost,127.0.0.1,host.docker.internal
|
||||
- no_proxy=localhost,127.0.0.1,host.docker.internal
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "healthcheck.js"]
|
||||
interval: 30s
|
||||
|
|
|
|||
|
|
@ -112,7 +112,12 @@ 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 = (currentConfig?.CRON_NEAR_MINUTES || 10) * 60 * 1000;
|
||||
const isNearExpiry = expiryTime && (expiryTime - Date.now()) < nearExpiryMs;
|
||||
if (isNearExpiry) {
|
||||
this._log('warn', `Node ${providerStatus.uuid} (${providerType}) is near expiration. Enqueuing refresh...`);
|
||||
this._enqueueRefresh(providerType, providerStatus);
|
||||
}
|
||||
|
|
@ -389,7 +394,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 +466,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);
|
||||
|
|
@ -1819,14 +1833,14 @@ export class ProviderPoolManager {
|
|||
continue;
|
||||
}
|
||||
|
||||
const checkStartTime = Date.now();
|
||||
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() - checkStartTime;
|
||||
const checkDuration = Date.now() - providerCheckStart;
|
||||
|
||||
if (!result.success) {
|
||||
// Provider is unhealthy
|
||||
|
|
@ -1840,7 +1854,7 @@ export class ProviderPoolManager {
|
|||
this.markProviderHealthy(providerType, provider.config, false, result.modelName);
|
||||
}
|
||||
} catch (error) {
|
||||
const checkDuration = Date.now() - checkStartTime;
|
||||
const checkDuration = Date.now() - providerCheckStart;
|
||||
failCount++;
|
||||
this._log('error', `[ScheduledHealthCheck] ${displayName} (${providerType}) EXCEPTION: ${error.message} (${checkDuration}ms)`);
|
||||
this.markProviderUnhealthyImmediately(providerType, provider.config, error.message);
|
||||
|
|
|
|||
|
|
@ -313,7 +313,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}`);
|
||||
|
|
@ -355,14 +355,14 @@ async function startServer() {
|
|||
setInterval(heartbeatAndRefreshToken, CONFIG.CRON_NEAR_MINUTES * 60 * 1000);
|
||||
}
|
||||
// 服务器完全启动后,执行初始健康检查
|
||||
const poolManager = getProviderPoolManager();
|
||||
if (poolManager) {
|
||||
logger.info('[Initialization] Performing initial health checks for provider pools...');
|
||||
poolManager.performHealthChecks();
|
||||
}
|
||||
const poolManager = getProviderPoolManager();
|
||||
if (poolManager) {
|
||||
logger.info('[Initialization] Performing initial health checks for provider pools...');
|
||||
poolManager.performHealthChecks();
|
||||
}
|
||||
|
||||
// 定时健康检查
|
||||
const scheduledConfig = CONFIG.SCHEDULED_HEALTH_CHECK;
|
||||
const scheduledConfig = CONFIG.SCHEDULED_HEALTH_CHECK;
|
||||
if (scheduledConfig?.enabled) {
|
||||
// 设计决策:只验证最小值 60000ms,不设最大值。
|
||||
// 前端有 max=3600000 (1小时) 的 UI 限制,但后端允许更大值以支持特殊需求。
|
||||
|
|
|
|||
|
|
@ -57,11 +57,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;
|
||||
}
|
||||
|
||||
|
|
@ -129,8 +166,10 @@ export async function handleUpdateConfig(req, res, currentConfig) {
|
|||
providerTypes: Array.isArray(incoming?.providerTypes) ? incoming.providerTypes : []
|
||||
};
|
||||
|
||||
// 如果定时器已存在且 enabled,重新加载 timer(interval 变化时)
|
||||
if (globalThis.reloadHealthCheckTimer && currentConfig.SCHEDULED_HEALTH_CHECK.enabled) {
|
||||
// 如果定时器已存在且 enabled,仅在 interval 实际变化时重新加载 timer
|
||||
const previousInterval = currentConfig.SCHEDULED_HEALTH_CHECK._activeInterval;
|
||||
if (globalThis.reloadHealthCheckTimer && currentConfig.SCHEDULED_HEALTH_CHECK.enabled && newInterval !== previousInterval) {
|
||||
currentConfig.SCHEDULED_HEALTH_CHECK._activeInterval = newInterval;
|
||||
globalThis.reloadHealthCheckTimer(newInterval);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,7 +115,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
|
||||
|
|
|
|||
|
|
@ -97,11 +97,7 @@ export const PROVIDER_MAPPINGS = [
|
|||
* @returns {string} UUID 字符串
|
||||
*/
|
||||
export function generateUUID() {
|
||||
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);
|
||||
return v.toString(16);
|
||||
});
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -53,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,'"');
|
||||
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('');
|
||||
|
||||
|
|
@ -157,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;
|
||||
|
|
@ -248,7 +249,7 @@ async function loadConfiguration() {
|
|||
const scheduledHealthCheckIntervalEl = document.getElementById('scheduledHealthCheckInterval');
|
||||
|
||||
if (data.SCHEDULED_HEALTH_CHECK) {
|
||||
if (scheduledHealthCheckEnabledEl) scheduledHealthCheckEnabledEl.checked = data.SCHEDULED_HEALTH_CHECK.enabled !== false;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -173,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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue