- 移除 VMess 协议 - VLESS/SS2022 按用户流量统计(AnyTLS 日志解析,SS 节点级) - sing-box v2ray_api stats 支持 - 内存优化:面板 128M,agent 64M,sing-box 64M
270 lines
17 KiB
JavaScript
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 }); }
|
|
});
|