diff --git a/server.js b/server.js index 82c7486..7dd4598 100644 --- a/server.js +++ b/server.js @@ -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' + ? `⚠️ ${name} 检测异常,无法连接\n协议:${line.protocol}\n地址:${line.addr}:${line.port}` + : `✅ ${name} 已恢复正常\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' + ? `⚠️ ${name} 检测异常,无法连接\n协议:${line.protocol}\n地址:${line.addr}:${line.port}` + : `✅ ${name} 已恢复正常\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,