Files
Muse 084afeb8f4 3x-ui 式共享节点改造:nodes 状态机 + nodeClients 各自凭据
- 移除 VMess 协议
- VLESS/SS2022 按用户流量统计(AnyTLS 日志解析,SS 节点级)
- sing-box v2ray_api stats 支持
- 内存优化:面板 128M,agent 64M,sing-box 64M
2026-10-19 22:23:40 +08:00

270 lines
17 KiB
JavaScript

'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const dgram = require('node:dgram');
const { spawn } = require('node:child_process');
const { parseUsageStats, combinedSingBoxConfig, applyResource, queryUsage, udpPortsForPid, systemInfo } = require('../agent/agent');
const { redactSecrets } = require('../lib/redact');
test('Agent reports usable memory and system disk without reading per-connection state', () => {
const info = systemInfo();
assert.ok(info.memoryTotal >= info.memoryAvailable && info.memoryAvailable >= 0);
assert.ok(info.cpuCount >= 1);
if (info.diskTotal !== null) assert.ok(info.diskTotal >= info.diskAvailable && info.diskAvailable >= 0);
});
test('realityKey generates an X25519 pair with Node crypto and caches it', () => {
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-reality-'));
try {
const keyFile = path.join(temp, 'keys.json');
const { spawnSync } = require('node:child_process');
const script = `const { realityKey } = require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))});
const a = realityKey('test-key');
const b = realityKey('test-key');
if (a.privateKey !== b.privateKey || a.publicKey !== b.publicKey) throw new Error('key was not cached');
for (const k of [a.privateKey, a.publicKey]) if (!/^[A-Za-z0-9_-]{43}$/.test(k)) throw new Error('bad key format');
process.stdout.write('OK');`;
const run = spawnSync(process.execPath, ['-e', script], {
env: { ...process.env, NG_KEY_FILE: keyFile }, encoding: 'utf8'
});
assert.equal(run.status, 0, run.stderr);
assert.equal(run.stdout, 'OK');
} finally { fs.rmSync(temp, { recursive: true, force: true }); }
});
test('applyResource rejects Xray resources and unprotected Hysteria 2 inbounds', async () => {
const { applyResource } = require('../agent/agent');
await assert.rejects(
applyResource({ resource: { id: 'xray-resource-1', engine: 'xray', inbounds: [] } }),
/只支持 sing-box/);
await assert.rejects(
applyResource({ resource: { id: 'hy2-noauth-1', engine: 'sing-box',
inbounds: [{ type: 'hysteria2', tag: 'hy2', users: [] }] } }),
/认证账户/);
});
test('UDP readiness inspection finds only ports owned by the target process', async () => {
const socket = dgram.createSocket('udp4');
const other = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio:'ignore' });
try {
await new Promise((resolve) => socket.bind(0, '127.0.0.1', resolve));
assert.ok(udpPortsForPid(process.pid).has(socket.address().port));
assert.equal(udpPortsForPid(other.pid).has(socket.address().port), false);
} finally { socket.close(); other.kill(); }
});
test('sing-box JSON counters are matched by name, inbound and per-user, including quoted values', () => {
const output = JSON.stringify({ stat:[
{ name:'inbound>>>other>>>traffic>>>downlink', value:'8000' },
{ name:'inbound>>>ng-in>>>traffic>>>downlink', value:'2048' },
{ name:'inbound>>>ng-in>>>traffic>>>uplink', value:'128' },
{ name:'user>>>ng:cus-a>>>traffic>>>uplink', value:'10' },
{ name:'user>>>ng:cus-a>>>traffic>>>downlink', value:'20' },
{ name:'user>>>unrelated>>>traffic>>>uplink', value:'5' }
] });
assert.deepEqual(parseUsageStats(output), {
inbounds: [
{ tag:'other', uplink:0, downlink:8000 },
{ tag:'ng-in', uplink:128, downlink:2048 },
],
users: [
{ name:'ng:cus-a', uplink:10, downlink:20 },
{ name:'unrelated', uplink:5, downlink:0 },
],
});
assert.deepEqual(parseUsageStats('{"stat":[]}'), { inbounds:[], users:[] });
assert.deepEqual(parseUsageStats('{}'), { inbounds:[], users:[] });
assert.deepEqual(parseUsageStats(JSON.stringify({ stat:[
{ name:'inbound>>>ng-in>>>traffic>>>uplink' },
{ name:'inbound>>>ng-in>>>traffic>>>downlink', value:'1024' }
] })), { inbounds:[{ tag:'ng-in', uplink:0, downlink:1024 }], users:[] });
assert.throws(() => parseUsageStats(JSON.stringify({ stat:[
{ name:'user>>>ng:cus-a>>>traffic>>>uplink', value:'undefined' }
] })), /入口计数异常/);
assert.throws(() => parseUsageStats(JSON.stringify({ stat:[
{ name:'inbound>>>ng-in>>>traffic>>>uplink', value:'-1' }
] })), /入口计数异常(负数)/);
assert.throws(() => parseUsageStats('not json'));
});
test('Agent does not advertise an untrusted self-signed node certificate as ready', () => {
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-certs-'));
try {
const domain = 'entry.example.com';
const dir = path.join(temp, domain);
fs.mkdirSync(dir);
const result = require('node:child_process').spawnSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes',
'-days', '2', '-keyout', path.join(dir, 'privkey.pem'), '-out', path.join(dir, 'fullchain.pem'),
'-subj', `/CN=${domain}`, '-addext', `subjectAltName=DNS:${domain}`], { encoding:'utf8' });
assert.equal(result.status, 0, result.stderr);
const script = `process.stdout.write(JSON.stringify(require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).installedCertificates()))`;
const read = require('node:child_process').spawnSync(process.execPath, ['-e', script], {
env:{ ...process.env, NG_TLS_DIR:temp }, encoding:'utf8'
});
assert.equal(read.status, 0, read.stderr);
assert.deepEqual(JSON.parse(read.stdout), []);
const trusted = require('node:child_process').spawnSync(process.execPath, ['-e', script], {
env:{ ...process.env, NG_TLS_DIR:temp, SSL_CERT_FILE:path.join(dir, 'fullchain.pem') }, encoding:'utf8'
});
assert.equal(trusted.status, 0, trusted.stderr);
assert.deepEqual(JSON.parse(trusted.stdout), [domain]);
fs.writeFileSync(path.join(dir, 'privkey.pem'), 'not a private key');
const mismatch = require('node:child_process').spawnSync(process.execPath, ['-e', script], {
env:{ ...process.env, NG_TLS_DIR:temp, SSL_CERT_FILE:path.join(dir, 'fullchain.pem') }, encoding:'utf8'
});
assert.deepEqual(JSON.parse(mismatch.stdout), []);
} finally { fs.rmSync(temp, { recursive:true, force:true }); }
});
test('Agent reads cumulative sing-box counters without resetting them', () => {
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-usage-'));
try {
const dir = path.join(temp, 'agent');
fs.mkdirSync(path.join(dir, 'resources'), { recursive:true });
fs.writeFileSync(path.join(dir, 'resources', 'entry.json'), JSON.stringify({ id:'entry', meta:{ kind:'node', nodeId:'nod_1', metricsTag:'entry-in' } }));
const helper = path.join(temp, 'stats');
const argumentsFile = path.join(temp, 'args');
fs.writeFileSync(helper, `#!/bin/sh\nprintf '%s\\n' "$@" > ${JSON.stringify(argumentsFile)}\nprintf '{"stat":[{"name":"user>>>ng:cus-a>>>traffic>>>uplink","value":"7"},{"name":"inbound>>>entry-in>>>traffic>>>uplink","value":"12"},{"name":"inbound>>>entry-in>>>traffic>>>downlink","value":"34"}]}'\n`, { mode:0o755 });
const { spawnSync } = require('node:child_process');
const script = `process.stdout.write(JSON.stringify(require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).queryUsage()))`;
const run = spawnSync(process.execPath, ['-e', script], { env:{ ...process.env, NG_CONFIG_DIR:dir, NG_SINGBOX_STATS_BIN:helper }, encoding:'utf8' });
assert.equal(run.status, 0, run.stderr);
assert.deepEqual(JSON.parse(run.stdout).samples, [
{ kind:'inbound', id:'nod_1', uplink:12, downlink:34, epoch:'sing-box:unknown' },
{ kind:'user', id:'cus-a', uplink:7, downlink:0, epoch:'sing-box:unknown' },
]);
assert.doesNotMatch(fs.readFileSync(argumentsFile, 'utf8'), /reset/);
} finally { fs.rmSync(temp, { recursive:true, force:true }); }
});
test('adding a sing-box resource submits old ingress counters before restarting the engine', async () => {
const http = require('node:http');
const { spawn } = require('node:child_process');
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-flush-'));
const dir = path.join(temp, 'agent');
const bin = path.join(temp, 'bin');
fs.mkdirSync(path.join(dir, 'resources'), { recursive:true });
fs.mkdirSync(bin);
const old = { id:'old-resource', engine:'sing-box', meta:{ kind:'node', nodeId:'nod_1', metricsTag:'old-in' }, inbounds:[], outbounds:[], routingRules:[] };
fs.writeFileSync(path.join(dir, 'resources', 'old-resource.json'), JSON.stringify(old));
fs.writeFileSync(path.join(bin, 'stats'), '#!/bin/sh\nprintf \'{"stat":[{"name":"inbound>>>old-in>>>traffic>>>uplink","value":"123"},{"name":"inbound>>>old-in>>>traffic>>>downlink","value":"456"}]}\'\n', { mode:0o755 });
fs.writeFileSync(path.join(bin, 'singbox'), '#!/bin/sh\nexit 0\n', { mode:0o755 });
const manager = fs.existsSync('/run/systemd/system') ? 'systemctl' : 'rc-service';
fs.writeFileSync(path.join(bin, manager), `#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(path.join(temp, 'service-commands'))}\nexit 0\n`, { mode:0o755 });
const seen = [];
const controller = http.createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
seen.push({ path:req.url, body:JSON.parse(Buffer.concat(chunks).toString('utf8')),
newResourceAlreadyWritten:fs.existsSync(path.join(dir, 'resources', 'new-resource.json')) });
res.setHeader('content-type', 'application/json'); res.end('{}');
});
try {
await new Promise((resolve) => controller.listen(0, '127.0.0.1', resolve));
const script = `require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).applyResource({resource:${JSON.stringify({ id:'new-resource', engine:'sing-box', meta:{ kind:'node', nodeId:'nod_1', metricsTag:'new-in' }, inbounds:[], outbounds:[], routingRules:[] })}}).catch(error=>{console.error(error);process.exitCode=1})`;
const child = spawn(process.execPath, ['-e', script], { env:{ ...process.env,
NG_CONFIG_DIR:dir, NG_SINGBOX_BIN:path.join(bin, 'singbox'), NG_SINGBOX_STATS_BIN:path.join(bin, 'stats'),
NG_SINGBOX_CONFIG:path.join(temp, 'sing-box.json'),
NG_CONTROLLER:`http://127.0.0.1:${controller.address().port}`,
NG_AGENT_KEY:'test-agent-key', PATH:`${bin}:${process.env.PATH}` }, stdio:['ignore','pipe','pipe'] });
let stderr = '';
child.stderr.on('data', (chunk) => { stderr += chunk; });
const exit = await new Promise((resolve) => child.on('exit', resolve));
assert.equal(exit, 0, stderr);
assert.equal(seen.length, 1);
assert.equal(seen[0].path, '/api/agent/usage');
assert.deepEqual(seen[0].body.samples.map(({ kind, id, uplink, downlink }) => ({ kind, id, uplink, downlink })),
[{ kind:'inbound', id:'nod_1', uplink:123, downlink:456 }]);
assert.equal(seen[0].newResourceAlreadyWritten, false);
assert.match(fs.readFileSync(path.join(temp, 'service-commands'), 'utf8'), /restart/);
const flush = spawn(process.execPath, [path.join(__dirname, '../agent/agent.js'), 'flush-usage'], { env:{ ...process.env,
NG_CONFIG_DIR:dir, NG_SINGBOX_BIN:path.join(bin, 'singbox'), NG_SINGBOX_STATS_BIN:path.join(bin, 'stats'),
NG_SINGBOX_CONFIG:path.join(temp, 'sing-box.json'),
NG_CONTROLLER:`http://127.0.0.1:${controller.address().port}`,
NG_AGENT_KEY:'test-agent-key', PATH:`${bin}:${process.env.PATH}` }, stdio:'ignore' });
assert.equal(await new Promise((resolve) => flush.on('exit', resolve)), 0);
assert.equal(seen[1].path, '/api/agent/usage');
fs.writeFileSync(path.join(bin, 'stats'), '#!/bin/sh\nprintf \'{"stat":[{"name":"inbound>>>old-in>>>traffic>>>uplink","value":"invalid"}]}\'\n', { mode:0o755 });
const badScript = `require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).applyResource({resource:${JSON.stringify({ id:'third-resource', engine:'sing-box', meta:{ kind:'node', nodeId:'nod_1', metricsTag:'third-in' }, inbounds:[], outbounds:[], routingRules:[] })}}).catch(error=>{console.error(error);process.exitCode=1})`;
const broken = spawn(process.execPath, ['-e', badScript], { env:{ ...process.env,
NG_CONFIG_DIR:dir, NG_SINGBOX_BIN:path.join(bin, 'singbox'), NG_SINGBOX_STATS_BIN:path.join(bin, 'stats'),
NG_SINGBOX_CONFIG:path.join(temp, 'sing-box.json'),
NG_CONTROLLER:`http://127.0.0.1:${controller.address().port}`,
NG_AGENT_KEY:'test-agent-key', PATH:`${bin}:${process.env.PATH}` }, stdio:'ignore' });
assert.equal(await new Promise((resolve) => broken.on('exit', resolve)), 1);
assert.equal(fs.existsSync(path.join(dir, 'resources', 'third-resource.json')), false);
assert.equal(seen.length, 2, 'invalid counters must block the restart before publishing a partial report');
} finally {
controller.close(); fs.rmSync(temp, { recursive:true, force:true });
}
});
test('sing-box config carries AnyTLS entries and meters each entry once', () => {
const { combinedSingBoxConfig } = require('../agent/agent');
const entry = { id:'entry', engine:'sing-box', meta:{ kind:'node', nodeId:'nod_1', metricsTag:'any-in' },
inbounds:[{ type:'anytls', tag:'any-in', listen:'0.0.0.0', listen_port:23000,
users:[{ name:'ng:customer-a', password:'secret' }], tls:{ enabled:true, server_name:'entry.example.com',
certificate_path:'/etc/nexusgate/tls/entry.example.com/fullchain.pem', key_path:'/etc/nexusgate/tls/entry.example.com/privkey.pem' } }],
outbounds:[{ type:'direct', tag:'any-direct' }], routingRules:[{ inbound:['any-in'], action:'route', outbound:'any-direct' }] };
const sing = combinedSingBoxConfig([entry]);
assert.equal(sing.inbounds[0].tag, 'any-in');
assert.deepEqual(sing.experimental.v2ray_api.stats.inbounds, ['any-in']);
// 按用户统计需要显式列出用户名
assert.deepEqual(sing.experimental.v2ray_api.stats.users, ['ng:customer-a']);
assert.equal(sing.route.rules[0].outbound, 'any-direct');
assert.deepEqual(parseUsageStats(JSON.stringify({ stat:[
{ name:'inbound>>>any-in>>>traffic>>>uplink', value:'10' },
{ name:'inbound>>>any-in>>>traffic>>>downlink', value:'90' },
{ name:'user>>>ng:customer-a>>>traffic>>>uplink', value:'5' } ] })),
{ inbounds:[{ tag:'any-in', uplink:10, downlink:90 }], users:[{ name:'ng:customer-a', uplink:5, downlink:0 }] });
});
test('Agent queries sing-box counters with its own helper and never resets them', () => {
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-singbox-usage-'));
try {
const dir = path.join(temp, 'agent');
fs.mkdirSync(path.join(dir, 'resources'), { recursive:true });
fs.writeFileSync(path.join(dir, 'resources', 'anytls.json'), JSON.stringify({
id:'anytls', engine:'sing-box', meta:{ kind:'node', nodeId:'nod_1', metricsTag:'any-in' }
}));
const helper = path.join(temp, 'stats');
const argumentsFile = path.join(temp, 'args');
fs.writeFileSync(helper, `#!/bin/sh\nprintf '%s\\n' "$@" > ${JSON.stringify(argumentsFile)}\nprintf '{"stat":[{"name":"inbound>>>any-in>>>traffic>>>uplink","value":"1024"},{"name":"inbound>>>any-in>>>traffic>>>downlink","value":"4096"},{"name":"user>>>ng:cus-a>>>traffic>>>uplink","value":"7"}]}'\n`, { mode:0o755 });
const script = `process.stdout.write(JSON.stringify(require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).queryUsage()))`;
const run = require('node:child_process').spawnSync(process.execPath, ['-e', script], {
env:{ ...process.env, NG_CONFIG_DIR:dir, NG_SINGBOX_STATS_BIN:helper }, encoding:'utf8'
});
assert.equal(run.status, 0, run.stderr);
assert.deepEqual(JSON.parse(run.stdout).samples, [
{ kind:'inbound', id:'nod_1', uplink:1024, downlink:4096, epoch:'sing-box:unknown' },
{ kind:'user', id:'cus-a', uplink:7, downlink:0, epoch:'sing-box:unknown' },
]);
assert.deepEqual(fs.readFileSync(argumentsFile, 'utf8').trim().split('\n'), ['--server=127.0.0.1:10086']);
} finally { fs.rmSync(temp, { recursive:true, force:true }); }
});
test('AnyTLS IP observations require matching authenticated session and preserve partial lines', () => {
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-anytls-ip-'));
try {
const dir = path.join(temp, 'config');
fs.mkdirSync(path.join(dir, 'resources'), { recursive:true });
fs.writeFileSync(path.join(dir, 'resources', 'entry.json'), JSON.stringify({ id:'entry', engine:'sing-box',
meta:{ kind:'node', nodeId:'nod_1', metricsTag:'any-in' } }));
const file = path.join(temp, 'sing-box.log');
const source = '+0000 2026-09-23 18:00:00 INFO [31200 0ms] inbound/anytls[any-in]: inbound connection from 198.51.100.12:50000';
const authenticated = '+0000 2026-09-23 18:00:01 INFO [31200 1ms] inbound/anytls[any-in]: [ng:customer-a] inbound connection to example.com:443';
const wrongUser = '+0000 2026-09-23 18:00:02 INFO [31201 1ms] inbound/anytls[any-in]: [ng:other] inbound connection to example.com:443';
fs.writeFileSync(file, `${source}\n${authenticated}\n${wrongUser}\n${source.slice(0, 35)}`);
const script = `process.stdout.write(JSON.stringify(require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).readSingBoxObservations()))`;
const run = require('node:child_process').spawnSync(process.execPath, ['-e', script], { encoding:'utf8',
env:{ ...process.env, NG_CONFIG_DIR:dir, NG_SINGBOX_LOG:file, NG_SINGBOX_CURSOR:path.join(temp, 'cursor.json') } });
assert.equal(run.status, 0, run.stderr);
assert.deepEqual(JSON.parse(run.stdout), { observations:[{ customerId:'customer-a', ip:'198.51.100.12' }],
offset:Buffer.byteLength(`${source}\n${authenticated}\n${wrongUser}\n`) });
} finally { fs.rmSync(temp, { recursive:true, force:true }); }
});