3x-ui 式共享节点改造:nodes 状态机 + nodeClients 各自凭据
- 移除 VMess 协议 - VLESS/SS2022 按用户流量统计(AnyTLS 日志解析,SS 节点级) - sing-box v2ray_api stats 支持 - 内存优化:面板 128M,agent 64M,sing-box 64M
This commit is contained in:
+148
-246
@@ -3,34 +3,25 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const net = require('node:net');
|
||||
const crypto = require('node:crypto');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const VERSION = '0.6.8';
|
||||
const CONTROLLER = String(process.env.NG_CONTROLLER || '').replace(/\/+$/, '');
|
||||
const AGENT_KEY = process.env.NG_AGENT_KEY || '';
|
||||
const XRAY_BIN = process.env.NG_XRAY_BIN || '/usr/local/bin/xray';
|
||||
const SINGBOX_BIN = process.env.NG_SINGBOX_BIN || '/usr/local/bin/nexusgate-sing-box';
|
||||
const SINGBOX_STATS_BIN = process.env.NG_SINGBOX_STATS_BIN || '/usr/local/bin/nexusgate-sing-box-stats';
|
||||
const ROOT = process.env.NG_CONFIG_DIR || '/etc/nexusgate/xray';
|
||||
const ROOT = process.env.NG_CONFIG_DIR || '/etc/nexusgate/agent';
|
||||
const RESOURCE_DIR = path.join(ROOT, 'resources');
|
||||
const CONFIG_FILE = path.join(ROOT, 'config.json');
|
||||
const SINGBOX_CONFIG = process.env.NG_SINGBOX_CONFIG || '/etc/nexusgate/sing-box/config.json';
|
||||
const SINGBOX_LOG = process.env.NG_SINGBOX_LOG || '/var/log/nexusgate/sing-box-access.log';
|
||||
const SINGBOX_CURSOR = process.env.NG_SINGBOX_CURSOR || '/etc/nexusgate/sing-box-cursor.json';
|
||||
const KEY_FILE = process.env.NG_KEY_FILE || '/etc/nexusgate/keys.json';
|
||||
const ACCESS_LOG = process.env.NG_XRAY_ACCESS_LOG || '/var/log/nexusgate/xray-access.log';
|
||||
const CURSOR_FILE = process.env.NG_ACCESS_CURSOR || '/etc/nexusgate/access-cursor.json';
|
||||
const POLL_MS = Math.max(3, Number(process.env.NG_POLL_SECONDS || 8)) * 1000;
|
||||
let lastEngineError = '';
|
||||
let certificateCache = { fingerprint:'', checkedAt:0, domains:[] };
|
||||
|
||||
function parseX25519(output) {
|
||||
const privateKey = (output.match(/\bPrivate\s*Key\s*:\s*([A-Za-z0-9_-]+)/i) || [])[1];
|
||||
const publicKey = (output.match(/\b(?:Public\s*Key|Password\s*\(\s*Public\s*Key\s*\)|Password)\s*:\s*([A-Za-z0-9_-]+)/i) || [])[1];
|
||||
if (!privateKey || !publicKey) throw new Error('Unable to parse x25519 output (keys suppressed); update the Agent and Xray');
|
||||
return { privateKey, publicKey };
|
||||
}
|
||||
|
||||
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
||||
function log(...args) { console.log(new Date().toISOString(), ...args); }
|
||||
function safeError(value) {
|
||||
@@ -71,11 +62,11 @@ function saveKeys(keys) {
|
||||
function realityKey(keyId) {
|
||||
const keys = loadKeys();
|
||||
if (keys[keyId]) return keys[keyId];
|
||||
const result = spawnSync(XRAY_BIN, ['x25519'], { encoding: 'utf8', timeout: 15000 });
|
||||
if (result.status !== 0) throw new Error('xray x25519 failed; run ng-agent doctor to inspect Xray locally');
|
||||
const output = `${result.stdout}\n${result.stderr}`;
|
||||
const { privateKey, publicKey } = parseX25519(output);
|
||||
keys[keyId] = { privateKey, publicKey, createdAt: new Date().toISOString() };
|
||||
const kp = crypto.generateKeyPairSync('x25519');
|
||||
const priv = kp.privateKey.export({ format: 'jwk' });
|
||||
const pub = kp.publicKey.export({ format: 'jwk' });
|
||||
if (!priv.d || !pub.x) throw new Error('X25519 密钥生成失败');
|
||||
keys[keyId] = { privateKey: priv.d, publicKey: pub.x, createdAt: new Date().toISOString() };
|
||||
saveKeys(keys);
|
||||
return keys[keyId];
|
||||
}
|
||||
@@ -95,49 +86,33 @@ function readResources() {
|
||||
return fs.readdirSync(RESOURCE_DIR).filter((name) => name.endsWith('.json')).sort().map((name) => JSON.parse(fs.readFileSync(path.join(RESOURCE_DIR, name), 'utf8')));
|
||||
}
|
||||
|
||||
function compatibleXrayInbound(inbound) {
|
||||
if (inbound.protocol !== 'hysteria') return inbound;
|
||||
// Older Agent resources contain only `method` and `users`. Xray 26.3.27
|
||||
// silently ignores both fields, falling back to TCP with no HY2 accounts.
|
||||
// Repair the effective configuration without changing the saved resource,
|
||||
// port, password or the customer's existing subscription link.
|
||||
const accounts = inbound.settings?.clients || inbound.settings?.users;
|
||||
if (!Array.isArray(accounts) || !accounts.length || accounts.some((user) => !user.auth))
|
||||
throw new Error('Hysteria 2 入口没有认证账户;拒绝启动未受保护的入口');
|
||||
return { ...inbound,
|
||||
settings: { ...inbound.settings, clients: accounts, users: accounts },
|
||||
streamSettings: { ...inbound.streamSettings, network:'hysteria', method:'hysteria' } };
|
||||
}
|
||||
|
||||
function combinedConfig(resources) {
|
||||
const apiPort = Number(process.env.NG_XRAY_API_PORT || 10085);
|
||||
const config = {
|
||||
log: { loglevel: process.env.NG_XRAY_LOG_LEVEL || 'warning', access: '/var/log/nexusgate/xray-access.log', error: '/var/log/nexusgate/xray-error.log' },
|
||||
api: { tag: 'api', services: ['StatsService'] },
|
||||
stats: {},
|
||||
policy: { levels: { '0': { statsUserUplink: true, statsUserDownlink: true } }, system: { statsInboundUplink: true, statsInboundDownlink: true } },
|
||||
inbounds: [{ tag: 'api-in', listen: '127.0.0.1', port: apiPort, protocol: 'dokodemo-door', settings: { address: '127.0.0.1' } }],
|
||||
outbounds: [],
|
||||
routing: { domainStrategy: 'AsIs', rules: [{ type: 'field', inboundTag: ['api-in'], outboundTag: 'api' }] }
|
||||
};
|
||||
for (const resource of resources) {
|
||||
if (resource.engine === 'sing-box') continue;
|
||||
config.inbounds.push(...(resource.inbounds || []).map(compatibleXrayInbound));
|
||||
config.outbounds.push(...(resource.outbounds || []));
|
||||
config.routing.rules.push(...(resource.routingRules || []));
|
||||
// 云机器的公网 IP 通常是 NAT 地址,不在本地网卡上;直接 bind 会
|
||||
// "cannot assign requested address" 导致引擎起不来。非本地地址回退到对应栈的通配符。
|
||||
function normalizeInboundListen(inbound) {
|
||||
if (!inbound || typeof inbound !== 'object') return inbound;
|
||||
const text = typeof inbound.listen === 'string' ? inbound.listen.trim() : '';
|
||||
if (!text || !net.isIP(text) || text === '0.0.0.0' || text === '::') return inbound;
|
||||
let local = false;
|
||||
for (const list of Object.values(os.networkInterfaces())) {
|
||||
for (const info of list || []) if (info && info.address === text) { local = true; break; }
|
||||
if (local) break;
|
||||
}
|
||||
return config;
|
||||
if (local) return inbound;
|
||||
const fallback = net.isIPv6(text) ? '::' : '0.0.0.0';
|
||||
log(`Listen ${text} 不是本机地址,回退为 ${fallback}`);
|
||||
return { ...inbound, listen: fallback };
|
||||
}
|
||||
|
||||
function combinedSingBoxConfig(resources) {
|
||||
const selected = resources.filter((item) => item.engine === 'sing-box');
|
||||
return {
|
||||
log: { level: 'info', output: SINGBOX_LOG, timestamp: true },
|
||||
inbounds: selected.flatMap((item) => item.inbounds || []),
|
||||
outbounds: [{ type: 'direct', tag: 'ng-fallback-direct' }, ...selected.flatMap((item) => item.outbounds || [])],
|
||||
route: { rules: selected.flatMap((item) => item.routingRules || []), final: 'ng-fallback-direct' },
|
||||
inbounds: resources.flatMap((item) => item.inbounds || []).map(normalizeInboundListen),
|
||||
outbounds: [{ type: 'direct', tag: 'ng-fallback-direct' }, ...resources.flatMap((item) => item.outbounds || [])],
|
||||
route: { rules: resources.flatMap((item) => item.routingRules || []), final: 'ng-fallback-direct' },
|
||||
experimental: { v2ray_api: { listen: `127.0.0.1:${process.env.NG_SINGBOX_API_PORT || 10086}`,
|
||||
stats: { enabled: true, inbounds: selected.flatMap((item) => item.inbounds || []).map((item) => item.tag) } } }
|
||||
stats: { enabled: true, inbounds: resources.flatMap((item) => item.inbounds || []).map((item) => item.tag),
|
||||
// sing-box 只对显式列出的用户维护 user>>> 计数器;AnyTLS/SS 用 name,VLESS 用 name,SOCKS5 用 username。
|
||||
users: resources.flatMap((item) => item.inbounds || []).flatMap((item) => item.users || []).map((item) => item.name || item.username).filter(Boolean) } } }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,23 +122,17 @@ function run(command, args, timeout = 30000) {
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function restartXray() {
|
||||
if (fs.existsSync('/run/systemd/system')) return run('systemctl', ['restart', 'nexusgate-xray.service']);
|
||||
return run('rc-service', ['nexusgate-xray', 'restart']);
|
||||
}
|
||||
|
||||
function serviceCommand(action, service) {
|
||||
if (fs.existsSync('/run/systemd/system')) return run('systemctl', [action, `${service}.service`]);
|
||||
return run('rc-service', [service, action]);
|
||||
}
|
||||
|
||||
function activateSingBox() {
|
||||
const resources = readResources().filter((item) => item.engine === 'sing-box');
|
||||
const resources = readResources();
|
||||
if (!resources.length) {
|
||||
if (fs.existsSync(SINGBOX_CONFIG)) {
|
||||
serviceCommand('stop', 'nexusgate-sing-box');
|
||||
fs.rmSync(SINGBOX_CONFIG, { force: true });
|
||||
}
|
||||
// 无资源时必须确保引擎停下(不能只在配置文件存在时停,否则全新安装会陷入重启循环)
|
||||
try { serviceCommand('stop', 'nexusgate-sing-box'); } catch { /* 已经停了 */ }
|
||||
if (fs.existsSync(SINGBOX_CONFIG)) fs.rmSync(SINGBOX_CONFIG, { force: true });
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(SINGBOX_BIN) || !fs.existsSync(SINGBOX_STATS_BIN)) throw new Error('sing-box 及统计组件未安装;请在入口机器运行 ng-agent engine install');
|
||||
@@ -186,56 +155,17 @@ function activateSingBox() {
|
||||
}
|
||||
}
|
||||
|
||||
function activateResourceEngine(engine) { return engine === 'sing-box' ? activateSingBox() : activateConfig(); }
|
||||
|
||||
function activateConfig() {
|
||||
// Xray detects JSON from the file extension; a .candidate suffix is rejected.
|
||||
const candidate = `${CONFIG_FILE}.candidate.json`;
|
||||
const previous = `${CONFIG_FILE}.previous`;
|
||||
const resources = readResources();
|
||||
const nextConfig = `${JSON.stringify(combinedConfig(resources), null, 2)}\n`;
|
||||
if (!resources.some((item) => item.engine !== 'sing-box')) {
|
||||
// An AnyTLS-only entry does not need a resident Xray process. Keep a
|
||||
// minimal, safe config so a manual service start cannot revive old ports.
|
||||
if (!fs.existsSync(CONFIG_FILE) || fs.readFileSync(CONFIG_FILE, 'utf8') !== nextConfig) {
|
||||
fs.writeFileSync(candidate, nextConfig, { mode: 0o600 });
|
||||
run(XRAY_BIN, ['run', '-test', '-config', candidate]);
|
||||
fs.renameSync(candidate, CONFIG_FILE);
|
||||
}
|
||||
if (serviceHealth('nexusgate-xray').status === 'ready') serviceCommand('stop', 'nexusgate-xray');
|
||||
return;
|
||||
}
|
||||
// An Agent update must not restart a healthy Xray with identical configuration:
|
||||
// its in-memory traffic counters would be discarded before the next report.
|
||||
if (fs.existsSync(CONFIG_FILE) && fs.readFileSync(CONFIG_FILE, 'utf8') === nextConfig &&
|
||||
engineHealth().status === 'ready') return;
|
||||
fs.writeFileSync(candidate, nextConfig, { mode: 0o600 });
|
||||
run(XRAY_BIN, ['run', '-test', '-config', candidate]);
|
||||
if (fs.existsSync(CONFIG_FILE)) fs.copyFileSync(CONFIG_FILE, previous);
|
||||
fs.renameSync(candidate, CONFIG_FILE);
|
||||
try {
|
||||
restartXray();
|
||||
assertHysteriaListeners(resources);
|
||||
} catch (error) {
|
||||
if (fs.existsSync(previous)) {
|
||||
fs.copyFileSync(previous, CONFIG_FILE);
|
||||
try { restartXray(); } catch { /* preserve original error */ }
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function safeResourcePath(resourceId) {
|
||||
if (!/^[a-zA-Z0-9_-]{8,100}$/.test(String(resourceId))) throw new Error('Invalid resource id');
|
||||
return path.join(RESOURCE_DIR, `${resourceId}.json`);
|
||||
}
|
||||
|
||||
async function flushUsageBeforeChange(engine) {
|
||||
const live = readResources().some((resource) => (resource.engine || 'xray') === engine &&
|
||||
resource.meta?.metricsTag && ['relay', 'direct'].includes(resource.meta.kind));
|
||||
if (!live || serviceHealth(engine === 'sing-box' ? 'nexusgate-sing-box' : 'nexusgate-xray').status !== 'ready') return;
|
||||
async function flushUsageBeforeChange() {
|
||||
const live = readResources().some((resource) =>
|
||||
resource.meta?.metricsTag && resource.meta.kind === 'node');
|
||||
if (!live || serviceHealth('nexusgate-sing-box').status !== 'ready') return;
|
||||
const report = queryUsage();
|
||||
if (report.engineErrors.includes(engine)) throw new Error(`${engine} 重载前无法读取入口计数;请先运行 ng-agent doctor`);
|
||||
if (report.engineErrors.includes('sing-box')) throw new Error('sing-box 重载前无法读取入口计数;请先运行 ng-agent doctor');
|
||||
// Await the controller's durable write before a restart destroys the
|
||||
// in-memory counters. The periodic loop can report concurrently; the
|
||||
// controller ignores repeated and out-of-order samples for one process.
|
||||
@@ -243,50 +173,50 @@ async function flushUsageBeforeChange(engine) {
|
||||
}
|
||||
|
||||
async function flushAllUsageBeforeRestart() {
|
||||
for (const engine of ['xray', 'sing-box']) await flushUsageBeforeChange(engine);
|
||||
await flushUsageBeforeChange();
|
||||
}
|
||||
|
||||
async function applyResource(payload) {
|
||||
if (!payload || !payload.resource || !payload.resource.id) throw new Error('Missing resource payload');
|
||||
const artifacts = {};
|
||||
const resource = materialize(payload.resource, artifacts);
|
||||
if (resource.engine && resource.engine !== 'sing-box') throw new Error(`Unsupported engine: ${resource.engine}`);
|
||||
if (resource.engine && resource.engine !== 'sing-box')
|
||||
throw new Error(`Unsupported engine: ${resource.engine}(Xray 已弃用,只支持 sing-box)`);
|
||||
for (const inbound of resource.inbounds || []) {
|
||||
const tls = resource.engine === 'sing-box' ? inbound.tls : inbound.streamSettings && inbound.streamSettings.tlsSettings;
|
||||
// Hysteria 2 无认证账户时拒绝启动未受保护的入口。
|
||||
if (inbound.type === 'hysteria2') {
|
||||
const users = inbound.users || [];
|
||||
if (!users.length || users.some((user) => !user.password))
|
||||
throw new Error('Hysteria 2 入口没有认证账户;拒绝启动未受保护的入口');
|
||||
}
|
||||
const tls = inbound.tls;
|
||||
if (!tls) continue;
|
||||
const domain = resource.engine === 'sing-box' ? tls.server_name : tls.serverName;
|
||||
if (tls.reality && tls.reality.enabled) continue; // Reality 无证书,不走证书检查
|
||||
const domain = tls.server_name;
|
||||
if (!/^(?:[a-z0-9-]+\.)+[a-z]{2,63}$/.test(domain || '')) throw new Error('TLS 域名无效');
|
||||
const expected = `/etc/nexusgate/tls/${domain}/`;
|
||||
const certs = resource.engine === 'sing-box'
|
||||
? [{ certificateFile: tls.certificate_path, keyFile: tls.key_path }] : tls.certificates || [];
|
||||
for (const cert of certs) {
|
||||
if (!cert.certificateFile.startsWith(expected) || !cert.keyFile.startsWith(expected) ||
|
||||
!fs.existsSync(cert.certificateFile) || !fs.existsSync(cert.keyFile)) {
|
||||
throw new Error(`TLS certificate for ${domain} is missing; run ng-agent cert issue ${domain} EMAIL or ng-agent cert import`);
|
||||
}
|
||||
run('openssl', ['x509', '-in', cert.certificateFile, '-noout', '-checkend', '86400']);
|
||||
const nameCheck = run('openssl', ['x509', '-in', cert.certificateFile, '-noout', '-checkhost', domain]);
|
||||
if (!nameCheck.includes('does match')) throw new Error(`TLS certificate does not match ${domain}`);
|
||||
const cert = { certificateFile: tls.certificate_path, keyFile: tls.key_path };
|
||||
if (!cert.certificateFile || !cert.keyFile)
|
||||
throw new Error(`TLS certificate for ${domain} is missing; run ng-agent cert issue ${domain} EMAIL or ng-agent cert import`);
|
||||
if (!cert.certificateFile.startsWith(expected) || !cert.keyFile.startsWith(expected) ||
|
||||
!fs.existsSync(cert.certificateFile) || !fs.existsSync(cert.keyFile)) {
|
||||
throw new Error(`TLS certificate for ${domain} is missing; run ng-agent cert issue ${domain} EMAIL or ng-agent cert import`);
|
||||
}
|
||||
run('openssl', ['x509', '-in', cert.certificateFile, '-noout', '-checkend', '86400']);
|
||||
const nameCheck = run('openssl', ['x509', '-in', cert.certificateFile, '-noout', '-checkhost', domain]);
|
||||
if (!nameCheck.includes('does match')) throw new Error(`TLS certificate does not match ${domain}`);
|
||||
}
|
||||
const target = safeResourcePath(resource.id);
|
||||
const backup = fs.existsSync(target) ? fs.readFileSync(target) : null;
|
||||
const oldEngine = backup ? JSON.parse(backup).engine || 'xray' : null;
|
||||
const newEngine = resource.engine || 'xray';
|
||||
await flushUsageBeforeChange(newEngine);
|
||||
if (oldEngine && oldEngine !== newEngine) await flushUsageBeforeChange(oldEngine);
|
||||
await flushUsageBeforeChange();
|
||||
fs.writeFileSync(target, `${JSON.stringify(resource, null, 2)}\n`, { mode: 0o600 });
|
||||
try {
|
||||
activateResourceEngine(newEngine);
|
||||
if (oldEngine !== newEngine && oldEngine) activateResourceEngine(oldEngine);
|
||||
activateSingBox();
|
||||
lastEngineError = '';
|
||||
}
|
||||
catch (error) {
|
||||
if (backup) fs.writeFileSync(target, backup, { mode: 0o600 }); else fs.rmSync(target, { force: true });
|
||||
try { activateResourceEngine(newEngine); } catch { /* preserve original error */ }
|
||||
if (oldEngine && oldEngine !== newEngine) {
|
||||
try { activateResourceEngine(oldEngine); } catch { /* preserve original error */ }
|
||||
}
|
||||
try { activateSingBox(); } catch { /* preserve original error */ }
|
||||
throw error;
|
||||
}
|
||||
return { artifacts };
|
||||
@@ -296,10 +226,9 @@ async function deleteResource(payload) {
|
||||
const target = safeResourcePath(payload.resourceId);
|
||||
if (!fs.existsSync(target)) return { alreadyAbsent: true };
|
||||
const backup = fs.readFileSync(target);
|
||||
const engine = JSON.parse(backup).engine || 'xray';
|
||||
await flushUsageBeforeChange(engine);
|
||||
await flushUsageBeforeChange();
|
||||
fs.rmSync(target);
|
||||
try { activateResourceEngine(engine); }
|
||||
try { activateSingBox(); }
|
||||
catch (error) { fs.writeFileSync(target, backup, { mode: 0o600 }); throw error; }
|
||||
return { removed: true };
|
||||
}
|
||||
@@ -343,28 +272,28 @@ function systemInfo() {
|
||||
|
||||
function engineHealth() {
|
||||
const resources = readResources();
|
||||
const xray = resources.some((item) => item.engine !== 'sing-box') ? serviceHealth('nexusgate-xray')
|
||||
: { status:'ready', detail:'无 Xray 资源,已停止空闲进程' };
|
||||
if (xray.status === 'ready') {
|
||||
const installed = fs.existsSync(SINGBOX_BIN) && fs.existsSync(SINGBOX_STATS_BIN);
|
||||
const service = serviceHealth('nexusgate-sing-box');
|
||||
const result = {
|
||||
status: installed ? service.status : 'error',
|
||||
detail: installed ? service.detail : 'sing-box 及统计组件未安装;请在入口机器运行 ng-agent engine install',
|
||||
singBoxInstalled: installed,
|
||||
certificates: installedCertificates(),
|
||||
singBoxStatus: service.status,
|
||||
};
|
||||
if (result.status === 'ready' && resources.length) {
|
||||
try {
|
||||
const missing = missingHysteriaListeners(resources);
|
||||
if (missing.length) {
|
||||
xray.status = 'error';
|
||||
xray.detail = `Hysteria 2 UDP 未监听:${missing.join(', ')}`;
|
||||
result.status = 'error';
|
||||
result.detail = `Hysteria 2 UDP 未监听:${missing.join(', ')}`;
|
||||
}
|
||||
} catch (error) {
|
||||
xray.status = 'error';
|
||||
xray.detail = safeError(error.message);
|
||||
result.status = 'error';
|
||||
result.detail = safeError(error.message);
|
||||
}
|
||||
}
|
||||
xray.singBoxInstalled = fs.existsSync(SINGBOX_BIN) && fs.existsSync(SINGBOX_STATS_BIN);
|
||||
xray.certificates = installedCertificates();
|
||||
if (resources.some((item) => item.engine === 'sing-box')) {
|
||||
const singbox = serviceHealth('nexusgate-sing-box');
|
||||
xray.singBoxStatus = singbox.status;
|
||||
if (singbox.status !== 'ready') return { ...xray, status: 'error', detail: `sing-box: ${singbox.detail}` };
|
||||
}
|
||||
return xray;
|
||||
return result;
|
||||
}
|
||||
|
||||
function installedCertificates() {
|
||||
@@ -414,16 +343,18 @@ async function heartbeat() {
|
||||
catch (error) { log('Heartbeat reached controller but readiness file failed', error.message); }
|
||||
}
|
||||
|
||||
function parseUsageStats(output, resources) {
|
||||
function parseUsageStats(output) {
|
||||
const parsed = JSON.parse(output);
|
||||
if (!parsed || typeof parsed !== 'object' || (parsed.stat !== undefined && !Array.isArray(parsed.stat)))
|
||||
// 零流量时 Go 端输出 {"stat":null},视为无数据而非格式错误。
|
||||
const statList = parsed && typeof parsed === 'object' ? (parsed.stat == null ? [] : parsed.stat) : null;
|
||||
if (!Array.isArray(statList))
|
||||
throw new Error('统计接口返回的 JSON stat 格式无效');
|
||||
const expected = new Set(resources.flatMap((resource) => ['uplink', 'downlink']
|
||||
.map((direction) => `inbound>>>${resource.meta.metricsTag}>>>traffic>>>${direction}`)));
|
||||
const byName = new Map();
|
||||
for (const stat of parsed.stat || []) {
|
||||
if (!expected.has(stat.name)) continue;
|
||||
// Protobuf JSON omits a scalar with its default value (zero). Xray can
|
||||
const inbounds = new Map(), users = new Map();
|
||||
for (const stat of statList) {
|
||||
const inboundMatch = /^inbound>>>(.+)>>>traffic>>>(uplink|downlink)$/.exec(stat.name || '');
|
||||
const userMatch = inboundMatch ? null : /^user>>>(.+)>>>traffic>>>(uplink|downlink)$/.exec(stat.name || '');
|
||||
if (!inboundMatch && !userMatch) continue;
|
||||
// Protobuf JSON omits a scalar with its default value (zero). The engine can
|
||||
// therefore return a named counter without a `value` field until traffic.
|
||||
const value = Object.hasOwn(stat, 'value') ? Number(stat.value) : 0;
|
||||
if (!Number.isSafeInteger(value) || value < 0 || stat.value === null || stat.value === '') {
|
||||
@@ -431,16 +362,20 @@ function parseUsageStats(output, resources) {
|
||||
? (stat.value.startsWith('-') ? '负数' : '超出安全整数范围') : `格式 ${typeof stat.value}`;
|
||||
throw new Error(`入口计数异常(${reason}):${stat.name};请运行 ng-agent doctor 查看原始统计`);
|
||||
}
|
||||
byName.set(stat.name, value);
|
||||
const bucket = inboundMatch ? inbounds : users;
|
||||
const key = (inboundMatch || userMatch)[1];
|
||||
const direction = (inboundMatch || userMatch)[2];
|
||||
let entry = bucket.get(key);
|
||||
if (!entry) { entry = { uplink: 0, downlink: 0 }; bucket.set(key, entry); }
|
||||
entry[direction] = value;
|
||||
}
|
||||
return resources.flatMap((resource) => {
|
||||
const prefix = `inbound>>>${resource.meta.metricsTag}>>>traffic>>>`;
|
||||
if (!byName.has(`${prefix}uplink`) && !byName.has(`${prefix}downlink`)) return [];
|
||||
return [{ resourceId: resource.id, uplink: byName.get(`${prefix}uplink`) || 0, downlink: byName.get(`${prefix}downlink`) || 0 }];
|
||||
});
|
||||
return {
|
||||
inbounds: [...inbounds].map(([tag, counters]) => ({ tag, ...counters })),
|
||||
users: [...users].map(([name, counters]) => ({ name, ...counters })),
|
||||
};
|
||||
}
|
||||
|
||||
function serviceEpoch(service = 'nexusgate-xray') {
|
||||
function serviceEpoch(service = 'nexusgate-sing-box') {
|
||||
let pid;
|
||||
if (fs.existsSync('/run/systemd/system')) {
|
||||
const result = spawnSync('systemctl', ['show', '-p', 'MainPID', '--value', `${service}.service`], { encoding:'utf8', timeout:3000 });
|
||||
@@ -450,7 +385,7 @@ function serviceEpoch(service = 'nexusgate-xray') {
|
||||
}
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
||||
try {
|
||||
// PID alone can be reused; the Linux process start tick distinguishes a new Xray instance.
|
||||
// PID alone can be reused; the Linux process start tick distinguishes a new sing-box instance.
|
||||
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
|
||||
const startTick = stat.slice(stat.lastIndexOf(') ') + 2).trim().split(/\s+/)[19];
|
||||
return startTick ? `${pid}:${startTick}` : null;
|
||||
@@ -478,12 +413,11 @@ function udpPortsForPid(pid) {
|
||||
}
|
||||
|
||||
function missingHysteriaListeners(resources) {
|
||||
const expected = [...new Set(resources.filter((item) => item.engine !== 'sing-box')
|
||||
.flatMap((item) => item.inbounds || []).filter((item) => item.protocol === 'hysteria')
|
||||
.map((item) => item.port))];
|
||||
const expected = [...new Set(resources.flatMap((item) => item.inbounds || [])
|
||||
.filter((item) => item.type === 'hysteria2').map((item) => item.listen_port))];
|
||||
if (!expected.length) return [];
|
||||
const epoch = serviceEpoch();
|
||||
if (!epoch) throw new Error('无法确认 Xray 进程,无法验证 Hysteria 2 UDP 监听');
|
||||
const epoch = serviceEpoch('nexusgate-sing-box');
|
||||
if (!epoch) throw new Error('无法确认 sing-box 进程,无法验证 Hysteria 2 UDP 监听');
|
||||
const actual = udpPortsForPid(Number(epoch.split(':')[0]));
|
||||
return expected.filter((port) => !actual.has(port));
|
||||
}
|
||||
@@ -498,33 +432,45 @@ function assertHysteriaListeners(resources) {
|
||||
if (attempt < 9) spawnSync('sleep', ['0.2']);
|
||||
}
|
||||
const missing = missingHysteriaListeners(resources);
|
||||
throw new Error(`Hysteria 2 未监听 UDP ${missing.join(', ')};检查 Xray 配置与本机端口冲突`);
|
||||
throw new Error(`Hysteria 2 未监听 UDP ${missing.join(', ')};检查 sing-box 配置与本机端口冲突`);
|
||||
}
|
||||
|
||||
function queryUsage() {
|
||||
// Count client ingress once; counting the exit as well doubles forwarded traffic.
|
||||
const resources = readResources().filter((resource) => resource.meta && resource.meta.metricsTag &&
|
||||
['relay', 'direct'].includes(resource.meta.kind));
|
||||
resource.meta.kind === 'node');
|
||||
const samples = [], errors = [], engineErrors = [];
|
||||
for (const engine of ['xray', 'sing-box']) {
|
||||
const selected = resources.filter((item) => (item.engine || 'xray') === engine);
|
||||
if (!selected.length) continue;
|
||||
const service = engine === 'xray' ? 'nexusgate-xray' : 'nexusgate-sing-box';
|
||||
const before = serviceEpoch(service);
|
||||
const port = engine === 'xray' ? (process.env.NG_XRAY_API_PORT || 10085) : (process.env.NG_SINGBOX_API_PORT || 10086);
|
||||
const binary = engine === 'xray' ? XRAY_BIN : SINGBOX_STATS_BIN;
|
||||
const args = engine === 'xray' ? ['api', 'statsquery', `--server=127.0.0.1:${port}`, '-pattern', 'inbound>>>']
|
||||
: [`--server=127.0.0.1:${port}`];
|
||||
const result = spawnSync(binary, args,
|
||||
{ encoding: 'utf8', timeout: 15000 });
|
||||
const after = serviceEpoch(service);
|
||||
if (before && after && before !== after) { errors.push(`${engine} restarted during statistics query`); engineErrors.push(engine); continue; }
|
||||
if (result.status !== 0) { errors.push(`${engine} statsquery failed: ${safeError(result.stderr || result.error?.message || result.stdout).slice(0, 160)}`); engineErrors.push(engine); continue; }
|
||||
if (!resources.length) return { samples, error: null, engineErrors };
|
||||
const service = 'nexusgate-sing-box';
|
||||
const before = serviceEpoch(service);
|
||||
const port = process.env.NG_SINGBOX_API_PORT || 10086;
|
||||
const result = spawnSync(SINGBOX_STATS_BIN, [`--server=127.0.0.1:${port}`], { encoding: 'utf8', timeout: 15000 });
|
||||
const after = serviceEpoch(service);
|
||||
let failed = false;
|
||||
if (before && after && before !== after) { errors.push('sing-box restarted during statistics query'); engineErrors.push('sing-box'); failed = true; }
|
||||
else if (result.status !== 0) { errors.push(`sing-box statsquery failed: ${safeError(result.stderr || result.error?.message || result.stdout).slice(0, 160)}`); engineErrors.push('sing-box'); failed = true; }
|
||||
const parsed = { inbounds: [], users: [] };
|
||||
if (!failed) {
|
||||
try {
|
||||
const parsed = parseUsageStats(result.stdout, selected);
|
||||
samples.push(...parsed.map((item) => ({ ...item, epoch: `${engine}:${before || after || 'unknown'}` })));
|
||||
if (!parsed.length) errors.push(`${engine} 入口尚无统计记录`);
|
||||
} catch (error) { errors.push(`${engine}: ${safeError(error.message)}`); engineErrors.push(engine); }
|
||||
const part = parseUsageStats(result.stdout);
|
||||
parsed.inbounds.push(...part.inbounds);
|
||||
parsed.users.push(...part.users);
|
||||
} catch (error) { errors.push(`sing-box: ${safeError(error.message)}`); engineErrors.push('sing-box'); failed = true; }
|
||||
}
|
||||
if (!failed) {
|
||||
const epoch = `sing-box:${before || serviceEpoch(service) || 'unknown'}`;
|
||||
const tagNodes = new Map(resources.map((item) => [item.meta.metricsTag, item.meta.nodeId]));
|
||||
for (const inbound of parsed.inbounds) {
|
||||
const nodeId = tagNodes.get(inbound.tag);
|
||||
if (!nodeId) continue;
|
||||
samples.push({ kind: 'inbound', id: nodeId, uplink: inbound.uplink, downlink: inbound.downlink, epoch });
|
||||
}
|
||||
for (const user of parsed.users) {
|
||||
const match = /^ng:(.+)$/.exec(user.name);
|
||||
if (!match) continue;
|
||||
samples.push({ kind: 'user', id: match[1], uplink: user.uplink, downlink: user.downlink, epoch });
|
||||
}
|
||||
if (!parsed.inbounds.length && !parsed.users.length) errors.push('sing-box 入口尚无统计记录');
|
||||
}
|
||||
return { samples, error: errors.join('; ') || null, engineErrors };
|
||||
}
|
||||
@@ -542,59 +488,27 @@ function readSingBoxObservations() {
|
||||
try { fs.readSync(fd, buffer, 0, buffer.length, start); } finally { fs.closeSync(fd); }
|
||||
const end = buffer.lastIndexOf(10);
|
||||
if (end < 0) return { observations: [], offset: null };
|
||||
const tagCustomers = new Map(readResources().filter((item) => item.engine === 'sing-box' &&
|
||||
['relay', 'direct'].includes(item.meta?.kind)).map((item) => [item.meta.metricsTag, item.meta.customerId]));
|
||||
// AnyTLS 用户名本身就是 ng:<customerId>,身份直接写在日志行里,无需 tag 映射。
|
||||
const entryTags = new Set(readResources().filter((item) =>
|
||||
item.meta?.kind === 'node').map((item) => item.meta.metricsTag));
|
||||
const sourceById = new Map(), unique = new Map();
|
||||
for (const line of buffer.subarray(0, end).toString('utf8').split('\n')) {
|
||||
const context = line.match(/\[(\d+)\s+\d+ms\]\s+inbound\/anytls\[([^\]]+)\]:\s+(.*)$/);
|
||||
if (!context || !tagCustomers.has(context[2])) continue;
|
||||
if (!context || !entryTags.has(context[2])) continue;
|
||||
const key = `${context[2]}:${context[1]}`;
|
||||
const source = context[3].match(/inbound connection from\s+(\[[^\]]+\]|[\d.]+):\d+/);
|
||||
if (source) sourceById.set(key, source[1].replace(/^\[|\]$/g, ''));
|
||||
// Only count authenticated AnyTLS sessions, never unauthenticated port probes.
|
||||
if (/\[ng:[^\]]+\] inbound connection to /.test(context[3]) && sourceById.has(key)) {
|
||||
const customerId = tagCustomers.get(context[2]);
|
||||
if (context[3].includes(`[ng:${customerId}]`)) {
|
||||
const ip = sourceById.get(key);
|
||||
unique.set(`${customerId}|${ip}`, { customerId, ip });
|
||||
}
|
||||
const authed = context[3].match(/\[ng:([^\]]+)\] inbound connection to /);
|
||||
if (authed && sourceById.has(key)) {
|
||||
const customerId = authed[1];
|
||||
const ip = sourceById.get(key);
|
||||
unique.set(`${customerId}|${ip}`, { customerId, ip });
|
||||
}
|
||||
}
|
||||
return { observations: [...unique.values()], offset: start + end + 1 };
|
||||
}
|
||||
|
||||
function readObservations() {
|
||||
if (!fs.existsSync(ACCESS_LOG)) return { observations: [], offset: null };
|
||||
let cursor = { offset: 0 };
|
||||
try { cursor = JSON.parse(fs.readFileSync(CURSOR_FILE, 'utf8')); } catch { /* first read */ }
|
||||
const stat = fs.statSync(ACCESS_LOG);
|
||||
if (stat.size < cursor.offset) cursor.offset = 0;
|
||||
const start = Math.max(cursor.offset, stat.size - 2 * 1024 * 1024);
|
||||
if (start >= stat.size) return { observations: [], offset: null };
|
||||
const length = stat.size - start;
|
||||
const buffer = Buffer.alloc(length);
|
||||
const fd = fs.openSync(ACCESS_LOG, 'r');
|
||||
try { fs.readSync(fd, buffer, 0, length, start); } finally { fs.closeSync(fd); }
|
||||
const finalNewline = buffer.lastIndexOf(10);
|
||||
if (finalNewline < 0) return { observations: [], offset: null };
|
||||
const offset = start + finalNewline + 1;
|
||||
// The exit inbound sees relay server IPs, not client IPs. Never count those as customer devices.
|
||||
const tagCustomers = new Map(readResources().filter((item) => item.meta &&
|
||||
['relay', 'direct'].includes(item.meta.kind) && item.meta.metricsTag && item.meta.customerId)
|
||||
.map((item) => [item.meta.metricsTag, item.meta.customerId]));
|
||||
const unique = new Map();
|
||||
for (const line of buffer.subarray(0, finalNewline).toString('utf8').split('\n')) {
|
||||
const source = line.match(/(?:from|accepted from)\s+(?:tcp|udp):(?:\[([^\]]+)\]|([^:\s]+)):\d+/i)
|
||||
|| line.match(/\s(?:tcp:)?(?:\[([^\]]+)\]|((?:\d{1,3}\.){3}\d{1,3})):\d+\s+accepted\s/i);
|
||||
if (!source) continue;
|
||||
const ip = source[1] || source[2];
|
||||
for (const [tag, customerId] of tagCustomers) {
|
||||
if (line.includes(`[${tag} ->`) || line.includes(`[${tag}]`)) unique.set(`${customerId}|${ip}`, { customerId, ip });
|
||||
}
|
||||
}
|
||||
return { observations: [...unique.values()], offset };
|
||||
}
|
||||
|
||||
async function usageLoop() {
|
||||
while (true) {
|
||||
await sleep(60000);
|
||||
@@ -605,12 +519,9 @@ async function usageLoop() {
|
||||
await request('/api/agent/usage', { method: 'POST', body: JSON.stringify(report) });
|
||||
} catch (error) { log('Usage report failed', safeError(error.message)); }
|
||||
try {
|
||||
const { observations, offset } = readObservations();
|
||||
if (observations.length) await request('/api/agent/observations', { method: 'POST', body: JSON.stringify({ observations }) });
|
||||
// A failed upload must not advance the cursor; retry the same log lines next cycle.
|
||||
if (offset !== null) fs.writeFileSync(CURSOR_FILE, `${JSON.stringify({ offset })}\n`, { mode: 0o600 });
|
||||
const sing = readSingBoxObservations();
|
||||
if (sing.observations.length) await request('/api/agent/observations', { method: 'POST', body: JSON.stringify({ observations: sing.observations }) });
|
||||
// A failed upload must not advance the cursor; retry the same log lines next cycle.
|
||||
if (sing.offset !== null) fs.writeFileSync(SINGBOX_CURSOR, `${JSON.stringify({ offset: sing.offset })}\n`, { mode: 0o600 });
|
||||
} catch (error) { log('IP observation report failed', safeError(error.message)); }
|
||||
}
|
||||
@@ -632,15 +543,6 @@ async function main() {
|
||||
log(`NexusGate Agent v${VERSION} starting`);
|
||||
// Keep the control channel alive even when a stale node configuration cannot start.
|
||||
// The administrator can then see the engine error and a repair job can be claimed.
|
||||
try {
|
||||
// A first upgrade from the old installer can restart Xray before the new
|
||||
// updater's preflight runs. Persist the old process counters here as well.
|
||||
const resources = readResources();
|
||||
const next = `${JSON.stringify(combinedConfig(resources), null, 2)}\n`;
|
||||
if (fs.existsSync(CONFIG_FILE) && fs.readFileSync(CONFIG_FILE, 'utf8') !== next)
|
||||
await flushUsageBeforeChange('xray');
|
||||
activateConfig();
|
||||
} catch (error) { lastEngineError = safeError(error.message); log('Initial Xray activation failed', lastEngineError); }
|
||||
try { activateSingBox(); } catch (error) { lastEngineError = safeError(error.message); log('Initial sing-box activation failed', lastEngineError); }
|
||||
try { await heartbeat(); } catch (error) { log('Initial heartbeat failed', error.message); }
|
||||
setInterval(() => heartbeat().catch((error) => log('Heartbeat failed', error.message)), 30000).unref();
|
||||
@@ -653,6 +555,6 @@ if (require.main === module) {
|
||||
action.catch((error) => { console.error(safeError(error.message)); process.exitCode = 1; });
|
||||
}
|
||||
|
||||
module.exports = { parseX25519, activateConfig, activateSingBox, combinedConfig, combinedSingBoxConfig, applyResource,
|
||||
readObservations, readSingBoxObservations, parseUsageStats, queryUsage, installedCertificates,
|
||||
module.exports = { activateSingBox, combinedSingBoxConfig, applyResource, realityKey,
|
||||
readSingBoxObservations, parseUsageStats, queryUsage, installedCertificates,
|
||||
udpPortsForPid, missingHysteriaListeners, systemInfo };
|
||||
|
||||
@@ -24,8 +24,10 @@ func main() {
|
||||
if err != nil { fail(err) }
|
||||
defer conn.Close()
|
||||
var response v2rayapi.QueryStatsResponse
|
||||
// Query both inbound-level and user-level stats (sing-box requires users
|
||||
// to be explicitly listed in the v2ray_api stats config).
|
||||
err = conn.Invoke(ctx, "/v2ray.core.app.stats.command.StatsService/QueryStats",
|
||||
&v2rayapi.QueryStatsRequest{Patterns: []string{"inbound>>>"}}, &response)
|
||||
&v2rayapi.QueryStatsRequest{Patterns: []string{"inbound>>>", "user>>>"}}, &response)
|
||||
if err != nil { fail(err) }
|
||||
result := struct { Stat []struct { Name string `json:"name"`; Value string `json:"value"` } `json:"stat"` }{}
|
||||
for _, stat := range response.Stat {
|
||||
|
||||
Reference in New Issue
Block a user