251 lines
8.6 KiB
JavaScript
251 lines
8.6 KiB
JavaScript
#!/usr/bin/env node
|
||
// ═══════════════════════════════════════════════
|
||
// 🔺 Sovereign: TCS-0002∞ | Root: SYS-GLW-0001
|
||
// 📜 Copyright: 国作登字-2026-A-00037559
|
||
// ═══════════════════════════════════════════════
|
||
// server/proxy/service/traffic-monitor.js
|
||
// 📊 铸渊专线 · 流量监控Agent
|
||
//
|
||
// ⚠️ [DEPRECATED · D58] V1节点已停用
|
||
// 铸渊专线2.0已测试通过并正式启用,V1节点不再使用。
|
||
// V2流量监控: traffic-monitor-v2.js (共享流量池2000GB/月)
|
||
// 此文件保留作为V1历史参考,不再部署运行。
|
||
//
|
||
// 定期查询Xray Stats API获取流量数据
|
||
// 更新quota-status.json供订阅服务和仪表盘使用
|
||
//
|
||
// 运行方式: PM2 managed (zy-proxy-monitor)
|
||
// 检查间隔: 每5分钟
|
||
// ═══════════════════════════════════════════════
|
||
|
||
'use strict';
|
||
|
||
const { execSync, execFileSync } = require('child_process');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const DATA_DIR = process.env.ZY_PROXY_DATA_DIR || '/opt/zhuyuan/proxy/data';
|
||
const QUOTA_FILE = path.join(DATA_DIR, 'quota-status.json');
|
||
const HISTORY_FILE = path.join(DATA_DIR, 'traffic-history.json');
|
||
const XRAY_API_HOST = '127.0.0.1';
|
||
const XRAY_API_PORT = 10085;
|
||
const CHECK_INTERVAL = 5 * 60 * 1000; // 5分钟
|
||
const MONTHLY_QUOTA = 500 * 1024 * 1024 * 1024; // 500GB in bytes
|
||
|
||
// ── 确保数据目录存在 ────────────────────────
|
||
function ensureDataDir() {
|
||
if (!fs.existsSync(DATA_DIR)) {
|
||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||
}
|
||
}
|
||
|
||
// ── 查询Xray流量统计 ────────────────────────
|
||
function queryXrayStats() {
|
||
try {
|
||
const result = execSync(
|
||
`xray api statsquery --server=${XRAY_API_HOST}:${XRAY_API_PORT} -pattern ""`,
|
||
{ encoding: 'utf8', timeout: 10000 }
|
||
);
|
||
return JSON.parse(result);
|
||
} catch (err) {
|
||
console.error('[流量监控] Xray Stats API查询失败:', err.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ── 解析统计数据 ─────────────────────────────
|
||
function parseStats(stats) {
|
||
if (!stats || !stats.stat) return { upload: 0, download: 0 };
|
||
|
||
let upload = 0;
|
||
let download = 0;
|
||
|
||
for (const item of stats.stat) {
|
||
if (item.name && item.name.includes('uplink')) {
|
||
upload += parseInt(item.value || '0', 10);
|
||
}
|
||
if (item.name && item.name.includes('downlink')) {
|
||
download += parseInt(item.value || '0', 10);
|
||
}
|
||
}
|
||
|
||
return { upload, download };
|
||
}
|
||
|
||
// ── 读取当前配额状态 ────────────────────────
|
||
function readQuotaStatus() {
|
||
try {
|
||
return JSON.parse(fs.readFileSync(QUOTA_FILE, 'utf8'));
|
||
} catch {
|
||
const now = new Date();
|
||
return {
|
||
total_bytes: MONTHLY_QUOTA,
|
||
upload_bytes: 0,
|
||
download_bytes: 0,
|
||
used_bytes: 0,
|
||
reset_day: 1,
|
||
period: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`,
|
||
created_at: now.toISOString(),
|
||
updated_at: now.toISOString(),
|
||
alerts_sent: { p80: false, p90: false, p100: false }
|
||
};
|
||
}
|
||
}
|
||
|
||
// ── 检查是否需要月度重置 ─────────────────────
|
||
function checkMonthlyReset(quota) {
|
||
const now = new Date();
|
||
const currentPeriod = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||
|
||
if (quota.period !== currentPeriod) {
|
||
console.log(`[流量监控] 月度重置: ${quota.period} → ${currentPeriod}`);
|
||
|
||
// 保存上月记录到历史
|
||
appendHistory(quota);
|
||
|
||
// 重置Xray统计
|
||
try {
|
||
execSync(
|
||
`xray api statsquery --server=${XRAY_API_HOST}:${XRAY_API_PORT} -pattern "" -reset`,
|
||
{ encoding: 'utf8', timeout: 10000 }
|
||
);
|
||
} catch {
|
||
console.error('[流量监控] Xray统计重置失败');
|
||
}
|
||
|
||
return {
|
||
...quota,
|
||
upload_bytes: 0,
|
||
download_bytes: 0,
|
||
used_bytes: 0,
|
||
period: currentPeriod,
|
||
updated_at: now.toISOString(),
|
||
alerts_sent: { p80: false, p90: false, p100: false }
|
||
};
|
||
}
|
||
|
||
return quota;
|
||
}
|
||
|
||
// ── 追加历史记录 ─────────────────────────────
|
||
function appendHistory(quota) {
|
||
let history = [];
|
||
try {
|
||
history = JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf8'));
|
||
} catch { /* ignore */ }
|
||
|
||
history.push({
|
||
period: quota.period,
|
||
upload_bytes: quota.upload_bytes,
|
||
download_bytes: quota.download_bytes,
|
||
total_used: quota.upload_bytes + quota.download_bytes,
|
||
total_quota: quota.total_bytes,
|
||
recorded_at: new Date().toISOString()
|
||
});
|
||
|
||
// 只保留最近12个月
|
||
if (history.length > 12) {
|
||
history = history.slice(-12);
|
||
}
|
||
|
||
fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2));
|
||
}
|
||
|
||
// ── 保存配额状态 ─────────────────────────────
|
||
function saveQuotaStatus(quota) {
|
||
fs.writeFileSync(QUOTA_FILE, JSON.stringify(quota, null, 2));
|
||
}
|
||
|
||
// ── 检查配额告警 ─────────────────────────────
|
||
function checkAlerts(quota) {
|
||
const usedPercent = ((quota.upload_bytes + quota.download_bytes) / quota.total_bytes) * 100;
|
||
const alerts = [];
|
||
|
||
if (usedPercent >= 100 && !quota.alerts_sent.p100) {
|
||
alerts.push({ level: 'critical', percent: 100, message: '⚠️ 本月500GB配额已用完!等待下月重置。' });
|
||
quota.alerts_sent.p100 = true;
|
||
} else if (usedPercent >= 90 && !quota.alerts_sent.p90) {
|
||
alerts.push({ level: 'warning', percent: 90, message: '⚠️ 本月配额已使用90%,请注意用量。' });
|
||
quota.alerts_sent.p90 = true;
|
||
} else if (usedPercent >= 80 && !quota.alerts_sent.p80) {
|
||
alerts.push({ level: 'info', percent: 80, message: '📊 本月配额已使用80%。' });
|
||
quota.alerts_sent.p80 = true;
|
||
}
|
||
|
||
return alerts;
|
||
}
|
||
|
||
// ── 发送告警邮件 ─────────────────────────────
|
||
async function sendAlert(alert, quota) {
|
||
try {
|
||
const sendScript = path.join(__dirname, 'send-subscription.js');
|
||
const usedGB = ((quota.upload_bytes + quota.download_bytes) / (1024 ** 3)).toFixed(2);
|
||
const totalGB = (quota.total_bytes / (1024 ** 3)).toFixed(0);
|
||
|
||
// 使用execFileSync避免Shell命令注入 (不经过shell解释器)
|
||
execFileSync('node', [sendScript, 'alert', `${alert.message} 已用 ${usedGB}GB / ${totalGB}GB`], {
|
||
encoding: 'utf8',
|
||
timeout: 30000
|
||
});
|
||
console.log(`[流量监控] 告警邮件已发送: ${alert.level}`);
|
||
} catch (err) {
|
||
console.error('[流量监控] 告警邮件发送失败:', err.message);
|
||
}
|
||
}
|
||
|
||
// ── 主循环 ───────────────────────────────────
|
||
async function monitor() {
|
||
console.log('[流量监控] 开始检查...');
|
||
|
||
ensureDataDir();
|
||
|
||
// 读取当前状态
|
||
let quota = readQuotaStatus();
|
||
|
||
// 检查月度重置
|
||
quota = checkMonthlyReset(quota);
|
||
|
||
// 查询Xray统计
|
||
const stats = queryXrayStats();
|
||
|
||
if (stats) {
|
||
const { upload, download } = parseStats(stats);
|
||
quota.upload_bytes = upload;
|
||
quota.download_bytes = download;
|
||
quota.used_bytes = upload + download;
|
||
quota.updated_at = new Date().toISOString();
|
||
|
||
const usedGB = ((upload + download) / (1024 ** 3)).toFixed(2);
|
||
const totalGB = (quota.total_bytes / (1024 ** 3)).toFixed(0);
|
||
const pct = ((quota.used_bytes / quota.total_bytes) * 100).toFixed(1);
|
||
console.log(`[流量监控] 已用: ${usedGB}GB / ${totalGB}GB (${pct}%)`);
|
||
} else {
|
||
quota.updated_at = new Date().toISOString();
|
||
console.log('[流量监控] Xray统计暂不可用,保持上次数据');
|
||
}
|
||
|
||
// 检查告警
|
||
const alerts = checkAlerts(quota);
|
||
for (const alert of alerts) {
|
||
await sendAlert(alert, quota);
|
||
}
|
||
|
||
// 保存状态
|
||
saveQuotaStatus(quota);
|
||
console.log('[流量监控] 检查完成');
|
||
}
|
||
|
||
// ── 启动监控循环 ─────────────────────────────
|
||
console.log('📊 铸渊专线流量监控启动');
|
||
console.log(` 检查间隔: ${CHECK_INTERVAL / 1000}秒`);
|
||
console.log(` 月配额: ${MONTHLY_QUOTA / (1024 ** 3)}GB`);
|
||
console.log(` 数据目录: ${DATA_DIR}`);
|
||
|
||
// 立即执行一次
|
||
monitor().catch(console.error);
|
||
|
||
// 定期执行
|
||
setInterval(() => {
|
||
monitor().catch(console.error);
|
||
}, CHECK_INTERVAL);
|