'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 }); } });