Update server.js via upload script - 2026-07-31 17:57:11

This commit is contained in:
mike 2026-07-31 17:57:32 +08:00
parent afd0792df7
commit 5b23bde6b5
1 changed files with 225 additions and 0 deletions

225
server.js
View File

@ -6,6 +6,9 @@ const rateLimit = require('express-rate-limit');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const net = require('net');
const https = require('https');
const WebSocket = require('ws'); // 需要 npm install ws版本^8即可
const PORT = process.env.PORT || 3210;
// 127.0.0.1 by default: on a VPS, Nginx fronts this with TLS and is the only
@ -107,6 +110,138 @@ function saveAiConfig(apiKey) {
fs.writeFileSync(AI_CONFIG_FILE, JSON.stringify({ apiKeyEnc: encrypt(apiKey) }, null, 2), 'utf8');
}
/* ------------------------------ Telegram config ---------------------------- */
// Just My Socks 线路异常报警用——bot token 用和AI Key一样的AES-256-GCM加密方式落盘
// chat_id 不算敏感信息,明文存就行。
const TELEGRAM_CONFIG_FILE = path.join(__dirname, 'telegram-config.json');
function loadTelegramConfig() {
try {
const raw = JSON.parse(fs.readFileSync(TELEGRAM_CONFIG_FILE, 'utf8'));
return { botToken: decrypt(raw.botTokenEnc || ''), chatId: raw.chatId || '' };
} catch (e) {
return { botToken: '', chatId: '' };
}
}
function saveTelegramConfig(botToken, chatId) {
fs.writeFileSync(TELEGRAM_CONFIG_FILE, JSON.stringify({ botTokenEnc: encrypt(botToken), chatId }, null, 2), 'utf8');
}
async function sendTelegramMessage(text) {
const cfg = loadTelegramConfig();
if (!cfg.botToken || !cfg.chatId) throw new Error('Telegram未配置');
const res = await fetch(`https://api.telegram.org/bot${cfg.botToken}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: cfg.chatId, text, parse_mode: 'HTML' }),
});
const data = await res.json().catch(() => ({}));
if (!data.ok) throw new Error(data.description || 'Telegram发送失败');
return data;
}
/* ------------------------------ JMS 线路监控 ------------------------------- */
// 普通TCP连通性测试——SS / VMess / VLESS Reality 用这个就够它们直连IP没有CDN挡在前面。
function tcpCheck(host, port, timeoutMs = 6000) {
return new Promise((resolve) => {
const socket = new net.Socket();
let done = false;
const finish = (ok) => { if (!done) { done = true; socket.destroy(); resolve(ok); } };
socket.setTimeout(timeoutMs);
socket.once('connect', () => finish(true));
socket.once('timeout', () => finish(false));
socket.once('error', () => finish(false));
socket.connect(Number(port) || 443, host);
});
}
// VLESS+WS+TLS+CDN 专属深度检测CDN边缘节点本身几乎永远在线单纯TCP连通测不出"源站已经挂了"
// 所以这里改用两个更有信号量的信号——
// 1) Cloudflare的520/521/522/523/524是"源站不可达"专属状态码能连上CDN但源站挂了会明确返回这几个码
// 2) 再做一次真正的WebSocket握手直接对应Xray/sing-box监听WS的那一层比单看状态码更准。
// 两个都过才算正常。
function httpStatusCheck(host, wsPath, timeoutMs = 8000) {
return new Promise((resolve) => {
const req = https.request({
hostname: host, port: 443, path: wsPath || '/', method: 'GET', timeout: timeoutMs,
headers: { 'User-Agent': 'Mozilla/5.0' },
}, (res) => {
res.resume();
const badGateway = [520, 521, 522, 523, 524].includes(res.statusCode);
resolve(!badGateway);
});
req.on('timeout', () => { req.destroy(); resolve(false); });
req.on('error', () => resolve(false));
req.end();
});
}
function wsHandshakeCheck(host, wsPath, timeoutMs = 8000) {
return new Promise((resolve) => {
let done = false;
let ws;
const finish = (ok) => {
if (done) return;
done = true;
try { ws && ws.terminate(); } catch (e) {}
resolve(ok);
};
try {
ws = new WebSocket(`wss://${host}${wsPath || '/'}`, { handshakeTimeout: timeoutMs });
ws.on('open', () => finish(true));
ws.on('unexpected-response', () => finish(false));
ws.on('error', () => finish(false));
} catch (e) {
finish(false);
}
setTimeout(() => finish(false), timeoutMs + 500);
});
}
async function checkLineConnectivity(line) {
if (line.protocol === 'vless-cdn') {
const [httpOk, wsOk] = await Promise.all([
httpStatusCheck(line.addr, line.ws_path),
wsHandshakeCheck(line.addr, line.ws_path),
]);
return httpOk && wsOk;
}
return tcpCheck(line.addr, line.port);
}
// 后台定时巡检:每 JMS_CHECK_INTERVAL_MS 跑一轮只在状态发生变化正常↔异常时才发Telegram
// 不会每次检测都刷屏。直接读写 crm:jmsLines 这个通用存储key前端也是存在这里
const JMS_CHECK_INTERVAL_MS = 10 * 60 * 1000; // 10分钟
async function runJmsMonitorCycle() {
const data = readData();
const raw = data['crm:jmsLines'];
if (!raw) return;
let lines;
try { lines = JSON.parse(raw); } catch (e) { return; }
if (!Array.isArray(lines) || !lines.length) return;
for (const line of lines) {
try {
const ok = await checkLineConnectivity(line);
const newStatus = ok ? 'normal' : 'abnormal';
if (line.monitorStatus && line.monitorStatus !== newStatus) {
const name = line.name || line.addr;
const msg = newStatus === 'abnormal'
? `⚠️ <b>${name}</b> 检测异常,无法连接\n协议:${line.protocol}\n地址:${line.addr}:${line.port}`
: `✅ <b>${name}</b> 已恢复正常\n协议:${line.protocol}\n地址:${line.addr}:${line.port}`;
sendTelegramMessage(msg).catch(() => {}); // Telegram没配置/发送失败都不影响检测本身
}
line.monitorStatus = newStatus;
line.lastChecked = Date.now();
} catch (e) {
// 检测本身出错(异常里的异常),跳过这条不写状态,下一轮再试
}
}
data['crm:jmsLines'] = JSON.stringify(lines);
writeData(data);
}
setInterval(() => { runJmsMonitorCycle().catch(() => {}); }, JMS_CHECK_INTERVAL_MS);
setTimeout(() => { runJmsMonitorCycle().catch(() => {}); }, 60 * 1000); // 启动1分钟后先跑一轮
const REGION_LABELS = {
africa: 'Africa', middle_east: 'Middle East', australia: 'Australia/Oceania',
europe: 'Europe', sea: 'Southeast Asia', other: 'Other',
@ -376,6 +511,96 @@ app.post('/api/ai-config/test', requireAuth, async (req, res) => {
}
});
/* ------------------------------ Telegram api ------------------------------- */
app.get('/api/telegram-config', requireAuth, (req, res) => {
const cfg = loadTelegramConfig();
res.json({ hasToken: !!cfg.botToken, chatId: cfg.chatId || '' });
});
app.put('/api/telegram-config', requireAuth, (req, res) => {
const botToken = ((req.body && req.body.botToken) || '').trim();
const chatId = ((req.body && req.body.chatId) || '').trim();
if (!botToken || !chatId) return res.status(400).json({ error: 'botToken和chatId都需要填写' });
saveTelegramConfig(botToken, chatId);
res.json({ ok: true });
});
app.delete('/api/telegram-config', requireAuth, (req, res) => {
try { fs.unlinkSync(TELEGRAM_CONFIG_FILE); } catch (e) {}
res.json({ ok: true });
});
app.post('/api/telegram-config/test', requireAuth, async (req, res) => {
try {
await sendTelegramMessage('✅ 客户档案系统Telegram通知测试成功配置没问题。');
res.json({ ok: true });
} catch (e) {
res.status(400).json({ error: e.message });
}
});
/* --------------------------------- JMS api --------------------------------- */
const jmsCheckLimiter = rateLimit({
windowMs: 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false,
message: { error: '检测太频繁了,请稍等一下再试' },
});
// 手动"立即检测"——对单条线路马上跑一次连通性检测如果状态发生了变化也会像后台巡检一样发Telegram
app.post('/api/jms/check-line', requireAuth, jmsCheckLimiter, async (req, res) => {
const line = req.body && req.body.line;
if (!line || !line.addr) return res.status(400).json({ error: '线路信息不完整' });
try {
const ok = await checkLineConnectivity(line);
const newStatus = ok ? 'normal' : 'abnormal';
if (line.monitorStatus && line.monitorStatus !== newStatus) {
const name = line.name || line.addr;
const msg = newStatus === 'abnormal'
? `⚠️ <b>${name}</b> 检测异常,无法连接\n协议:${line.protocol}\n地址:${line.addr}:${line.port}`
: `✅ <b>${name}</b> 已恢复正常\n协议:${line.protocol}\n地址:${line.addr}:${line.port}`;
sendTelegramMessage(msg).catch(() => {});
}
res.json({ ok: true, status: newStatus, checkedAt: Date.now() });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// 代为请求JMS订阅地址解析 Subscription-Userinfo 响应头拿到流量信息——
// 浏览器直接fetch会被CORS挡住服务器端发请求没有这个限制
app.post('/api/jms/fetch-traffic', requireAuth, async (req, res) => {
const url = ((req.body && req.body.subscriptionUrl) || '').trim();
if (!url) return res.status(400).json({ error: '请先填写标准订阅地址' });
let parsed;
try {
parsed = new URL(url);
if (!/^https?:$/.test(parsed.protocol)) throw new Error('invalid protocol');
} catch (e) {
return res.status(400).json({ error: '订阅地址格式不对' });
}
try {
const response = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
const header = response.headers.get('subscription-userinfo');
if (!header) return res.json({ ok: false, error: '这个订阅地址没有返回流量信息Subscription-Userinfo可能服务商不支持或者地址不对' });
const info = {};
header.split(';').forEach(part => {
const [k, v] = part.trim().split('=');
if (k && v !== undefined) info[k.trim()] = Number(v.trim());
});
res.json({
ok: true,
upload: info.upload || 0,
download: info.download || 0,
total: info.total || 0,
expire: info.expire || null,
});
} catch (e) {
res.status(400).json({ error: '获取订阅信息失败:' + e.message });
}
});
const aiLimiter = rateLimit({
windowMs: 10 * 60 * 1000,
max: 30,