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:
+92
-133
@@ -7,7 +7,7 @@ const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const dgram = require('node:dgram');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { parseX25519, parseUsageStats, combinedConfig, udpPortsForPid, systemInfo } = require('../agent/agent');
|
||||
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', () => {
|
||||
@@ -17,46 +17,34 @@ test('Agent reports usable memory and system disk without reading per-connection
|
||||
if (info.diskTotal !== null) assert.ok(info.diskTotal >= info.diskAvailable && info.diskAvailable >= 0);
|
||||
});
|
||||
|
||||
test('parses both current and older Xray x25519 output without including private keys in errors', () => {
|
||||
const current = parseX25519('PrivateKey: current_private\nPassword (PublicKey): current_public\nHash32: ignored');
|
||||
assert.deepEqual(current, { privateKey:'current_private', publicKey:'current_public' });
|
||||
const older = parseX25519('Private key: older_private\nPublic key: older_public');
|
||||
assert.equal(older.publicKey, 'older_public');
|
||||
assert.throws(() => parseX25519('PrivateKey: very_secret\nUnknown: broken'), (error) => !error.message.includes('very_secret'));
|
||||
assert.equal(redactSecrets('Unable to parse: PrivateKey: very_secret Password (PublicKey): public'),
|
||||
'Unable to parse: PrivateKey: [REDACTED] Password (PublicKey): public');
|
||||
});
|
||||
|
||||
test('Xray candidate configuration has a JSON extension at validation time', () => {
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-agent-test-'));
|
||||
test('realityKey generates an X25519 pair with Node crypto and caches it', () => {
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-reality-'));
|
||||
try {
|
||||
const fakeXray = path.join(temp, 'fake-xray');
|
||||
const configDir = path.join(temp, 'xray');
|
||||
fs.mkdirSync(path.join(configDir, 'resources'), { recursive:true });
|
||||
fs.writeFileSync(fakeXray, '#!/bin/sh\nprintf "%s\\n" "$4" > "' + path.join(temp, 'argument') + '"\nexit 41\n', { mode:0o755 });
|
||||
// Module paths are read at import time; test the real activation logic in a child.
|
||||
const keyFile = path.join(temp, 'keys.json');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const result = spawnSync(process.execPath, ['-e', `require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).activateConfig()`], {
|
||||
env:{ ...process.env, NG_CONFIG_DIR:configDir, NG_XRAY_BIN:fakeXray }, encoding:'utf8'
|
||||
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.notEqual(result.status, 0);
|
||||
const candidate = fs.readFileSync(path.join(temp, 'argument'), 'utf8').trim();
|
||||
assert.equal(candidate, path.join(configDir, 'config.json.candidate.json'));
|
||||
assert.equal(JSON.parse(fs.readFileSync(candidate, 'utf8')).inbounds[0].tag, 'api-in');
|
||||
} finally { fs.rmSync(temp, { recursive:true, force:true }); }
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
assert.equal(run.stdout, 'OK');
|
||||
} finally { fs.rmSync(temp, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('old Hysteria 2 resources are repaired without rotating authentication or changing saved resources', () => {
|
||||
const legacy = { id:'hy2-entry', inbounds:[{ tag:'hy2-in', protocol:'hysteria', port:34537,
|
||||
settings:{ version:2, users:[{ auth:'unchanged-password', email:'ng:customer' }] },
|
||||
streamSettings:{ method:'hysteria', security:'tls', hysteriaSettings:{ version:2 } } }] };
|
||||
const before = JSON.stringify(legacy);
|
||||
const inbound = combinedConfig([legacy]).inbounds[1];
|
||||
assert.equal(inbound.streamSettings.network, 'hysteria');
|
||||
assert.equal(inbound.settings.clients[0].auth, 'unchanged-password');
|
||||
assert.deepEqual(inbound.settings.clients, inbound.settings.users);
|
||||
assert.equal(JSON.stringify(legacy), before);
|
||||
assert.throws(() => combinedConfig([{ inbounds:[{ protocol:'hysteria', settings:{ version:2 } }] }]), /认证账户/);
|
||||
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 () => {
|
||||
@@ -69,81 +57,38 @@ test('UDP readiness inspection finds only ports owned by the target process', as
|
||||
} finally { socket.close(); other.kill(); }
|
||||
});
|
||||
|
||||
test('Agent restart preserves a healthy Xray process when configuration is unchanged', () => {
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-no-restart-'));
|
||||
try {
|
||||
const configDir = path.join(temp, 'xray');
|
||||
const binDir = path.join(temp, 'bin');
|
||||
fs.mkdirSync(path.join(configDir, 'resources'), { recursive:true });
|
||||
fs.mkdirSync(binDir);
|
||||
const resource = { id:'entry-resource', meta:{ kind:'direct', metricsTag:'entry-in' },
|
||||
inbounds:[], outbounds:[], routingRules:[] };
|
||||
fs.writeFileSync(path.join(configDir, 'resources', 'entry.json'), JSON.stringify(resource));
|
||||
const agentFile = path.join(__dirname, '../agent/agent.js');
|
||||
const script = `const agent=require(${JSON.stringify(agentFile)});const fs=require('node:fs');fs.writeFileSync(${JSON.stringify(path.join(configDir,'config.json'))}, JSON.stringify(agent.combinedConfig([${JSON.stringify(resource)}]), null, 2)+'\\n');agent.activateConfig()`;
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const serviceCommand = fs.existsSync('/run/systemd/system') ? 'systemctl' : 'rc-service';
|
||||
fs.writeFileSync(path.join(binDir, serviceCommand), '#!/bin/sh\nexit 0\n', { mode:0o755 });
|
||||
const fakeXray = path.join(binDir, 'xray');
|
||||
fs.writeFileSync(fakeXray, '#!/bin/sh\nexit 91\n', { mode:0o755 });
|
||||
const result = spawnSync(process.execPath, ['-e', script], {
|
||||
env:{ ...process.env, PATH:`${binDir}:${process.env.PATH}`, NG_CONFIG_DIR:configDir, NG_XRAY_BIN:fakeXray }, encoding:'utf8'
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(fs.existsSync(path.join(configDir, 'config.json.candidate.json')), false);
|
||||
} finally { fs.rmSync(temp, { recursive:true, force:true }); }
|
||||
});
|
||||
|
||||
test('IP observations exclude exit hop addresses and leave partial log lines unread', () => {
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-observations-'));
|
||||
try {
|
||||
const dir = path.join(temp, 'config');
|
||||
fs.mkdirSync(path.join(dir, 'resources'), { recursive:true });
|
||||
fs.writeFileSync(path.join(dir, 'resources', 'relay.json'), JSON.stringify({ id:'relay', meta:{ kind:'relay', metricsTag:'entry-tag', customerId:'customer-a' } }));
|
||||
fs.writeFileSync(path.join(dir, 'resources', 'exit.json'), JSON.stringify({ id:'exit', meta:{ kind:'exit', metricsTag:'exit-tag', customerId:'customer-a' } }));
|
||||
const accessLog = path.join(temp, 'access.log');
|
||||
const cursorFile = path.join(temp, 'cursor.json');
|
||||
const entry = '2026/09/23 from tcp:198.51.100.40:1234 accepted tcp:example.com:443 [entry-tag -> direct]';
|
||||
const exit = '2026/09/23 from tcp:192.0.2.20:3344 accepted tcp:example.com:443 [exit-tag -> direct]';
|
||||
fs.writeFileSync(accessLog, `${entry}\n${exit}\n${entry.slice(0, 28)}`);
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const script = `process.stdout.write(JSON.stringify(require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).readObservations()))`;
|
||||
const run = spawnSync(process.execPath, ['-e', script], {
|
||||
env:{ ...process.env, NG_CONFIG_DIR:dir, NG_XRAY_ACCESS_LOG:accessLog, NG_ACCESS_CURSOR:cursorFile }, encoding:'utf8'
|
||||
});
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
const result = JSON.parse(run.stdout);
|
||||
assert.deepEqual(result.observations, [{ customerId:'customer-a', ip:'198.51.100.40' }]);
|
||||
assert.equal(result.offset, Buffer.byteLength(`${entry}\n${exit}\n`));
|
||||
assert.equal(fs.existsSync(cursorFile), false);
|
||||
} finally { fs.rmSync(temp, { recursive:true, force:true }); }
|
||||
});
|
||||
|
||||
test('Xray JSON counters are matched by name, including quoted values in either order', () => {
|
||||
const resources = [{ id:'resource-1', meta:{ kind:'direct', metricsTag:'ng-in' } }];
|
||||
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:'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, resources), [{ resourceId:'resource-1', uplink:128, downlink:2048 }]);
|
||||
assert.deepEqual(parseUsageStats('{"stat":[]}', resources), []);
|
||||
assert.deepEqual(parseUsageStats('{}', resources), []);
|
||||
assert.deepEqual(parseUsageStats(JSON.stringify({ stat:[
|
||||
{ name:'inbound>>>unrelated>>>traffic>>>uplink', value:'bad' },
|
||||
{ name:'inbound>>>ng-in>>>traffic>>>uplink', value:'42' }
|
||||
] }), resources), [{ resourceId:'resource-1', uplink:42, downlink:0 }]);
|
||||
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' }
|
||||
] }), resources), [{ resourceId:'resource-1', uplink:0, downlink:1024 }]);
|
||||
] })), { inbounds:[{ tag:'ng-in', uplink:0, downlink:1024 }], users:[] });
|
||||
assert.throws(() => parseUsageStats(JSON.stringify({ stat:[
|
||||
{ name:'inbound>>>ng-in>>>traffic>>>uplink', value:'undefined' }
|
||||
] }), resources), /入口计数异常/);
|
||||
{ name:'user>>>ng:cus-a>>>traffic>>>uplink', value:'undefined' }
|
||||
] })), /入口计数异常/);
|
||||
assert.throws(() => parseUsageStats(JSON.stringify({ stat:[
|
||||
{ name:'inbound>>>ng-in>>>traffic>>>uplink', value:'-1' }
|
||||
] }), resources), /入口计数异常(负数)/);
|
||||
assert.throws(() => parseUsageStats('not json', resources));
|
||||
] })), /入口计数异常(负数)/);
|
||||
assert.throws(() => parseUsageStats('not json'));
|
||||
});
|
||||
|
||||
test('Agent does not advertise an untrusted self-signed node certificate as ready', () => {
|
||||
@@ -175,35 +120,39 @@ test('Agent does not advertise an untrusted self-signed node certificate as read
|
||||
} finally { fs.rmSync(temp, { recursive:true, force:true }); }
|
||||
});
|
||||
|
||||
test('Agent reads cumulative counters without resetting Xray', () => {
|
||||
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, 'xray');
|
||||
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:'direct', metricsTag:'entry-in' } }));
|
||||
const fakeXray = path.join(temp, 'xray-bin');
|
||||
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(fakeXray, `#!/bin/sh\nprintf '%s\\n' "$@" > ${JSON.stringify(argumentsFile)}\nprintf '{"stat":[{"name":"inbound>>>entry-in>>>traffic>>>uplink","value":"12"},{"name":"inbound>>>entry-in>>>traffic>>>downlink","value":"34"}]}'\n`, { mode:0o755 });
|
||||
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_XRAY_BIN:fakeXray }, encoding:'utf8' });
|
||||
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, [{ resourceId:'entry', uplink:12, downlink:34, epoch:'xray:unknown' }]);
|
||||
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 an Xray resource submits old ingress counters before restarting the engine', async () => {
|
||||
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, 'xray');
|
||||
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', meta:{ kind:'direct', metricsTag:'old-in' }, inbounds:[], outbounds:[], routingRules:[] };
|
||||
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, 'xray'), '#!/bin/sh\nif [ "$1" = api ]; then printf \'{"stat":[{"name":"inbound>>>old-in>>>traffic>>>uplink","value":"123"},{"name":"inbound>>>old-in>>>traffic>>>downlink","value":"456"}]}\'; fi\n', { mode:0o755 });
|
||||
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 = [];
|
||||
@@ -216,9 +165,11 @@ test('adding an Xray resource submits old ingress counters before restarting the
|
||||
});
|
||||
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', meta:{ kind:'direct', metricsTag:'new-in' }, inbounds:[], outbounds:[], routingRules:[] })}}).catch(error=>{console.error(error);process.exitCode=1})`;
|
||||
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_XRAY_BIN:path.join(bin, 'xray'), NG_CONTROLLER:`http://127.0.0.1:${controller.address().port}`,
|
||||
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; });
|
||||
@@ -226,19 +177,23 @@ test('adding an Xray resource submits old ingress counters before restarting the
|
||||
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(({ resourceId, uplink, downlink }) => ({ resourceId, uplink, downlink })),
|
||||
[{ resourceId:'old-resource', uplink:123, downlink:456 }]);
|
||||
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_XRAY_BIN:path.join(bin, 'xray'), NG_CONTROLLER:`http://127.0.0.1:${controller.address().port}`,
|
||||
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, 'xray'), '#!/bin/sh\nif [ "$1" = api ]; then printf \'{"stat":[{"name":"inbound>>>old-in>>>traffic>>>uplink","value":"invalid"}]}\'; fi\n', { mode:0o755 });
|
||||
const badScript = `require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).applyResource({resource:${JSON.stringify({ id:'third-resource', meta:{ kind:'direct', metricsTag:'third-in' }, inbounds:[], outbounds:[], routingRules:[] })}}).catch(error=>{console.error(error);process.exitCode=1})`;
|
||||
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_XRAY_BIN:path.join(bin, 'xray'), NG_CONTROLLER:`http://127.0.0.1:${controller.address().port}`,
|
||||
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);
|
||||
@@ -248,42 +203,46 @@ test('adding an Xray resource submits old ingress counters before restarting the
|
||||
}
|
||||
});
|
||||
|
||||
test('sing-box config isolates AnyTLS from Xray and meters each entry once', () => {
|
||||
const { combinedConfig, combinedSingBoxConfig } = require('../agent/agent');
|
||||
const entry = { id:'entry', engine:'sing-box', meta:{ kind:'direct', customerId:'customer-a', metricsTag:'any-in' },
|
||||
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 xray = combinedConfig([entry]);
|
||||
assert.equal(xray.inbounds.length, 1);
|
||||
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' } ] }), [entry]),
|
||||
[{ resourceId:'entry', uplink:10, downlink:90 }]);
|
||||
{ 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, 'xray');
|
||||
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:'direct', metricsTag:'any-in' }
|
||||
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"}]}'\n`, { mode:0o755 });
|
||||
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, NG_XRAY_BIN:'/nonexistent/xray' }, encoding:'utf8'
|
||||
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, [{ resourceId:'anytls', uplink:1024, downlink:4096, epoch:'sing-box:unknown' }]);
|
||||
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 }); }
|
||||
});
|
||||
@@ -294,7 +253,7 @@ test('AnyTLS IP observations require matching authenticated session and preserve
|
||||
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:'relay', metricsTag:'any-in', customerId:'customer-a' } }));
|
||||
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';
|
||||
|
||||
Reference in New Issue
Block a user