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:
Muse
2026-10-19 22:23:40 +08:00
parent 1678f766ac
commit 084afeb8f4
35 changed files with 2142 additions and 1749 deletions
+92 -133
View File
@@ -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';
+188 -150
View File
@@ -24,7 +24,7 @@ async function waitFor(url) {
throw new Error('test server did not start');
}
test('admin can create resources and queue a mixed-protocol chain', async (t) => {
test('admin can create nodes and manage shared-client lifecycle', async (t) => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'nexusgate-api-'));
const port = await freePort();
const child = spawn(process.execPath, ['server.js'], {
@@ -56,98 +56,136 @@ test('admin can create resources and queue a mixed-protocol chain', async (t) =>
assert.equal(editedRelay.name, '新加坡入口 01');
const editedCustomer = (await request(`/api/customers/${customer.id}`, 'PATCH', { expiresAt: '2030-12-31T23:59:00+08:00', tags: ['VIP'] })).customer;
assert.equal(editedCustomer.expiresAt, '2030-12-31T15:59:00.000Z');
const chain = (await request('/api/chains', 'POST', { name: 'SG → JP', relayServerIds: [relay.id], exitServerId: exit.id, customerIds: [customer.id], relayProtocol: 'vless-reality-vision', exitProtocol: 'shadowsocks-2022-aes128' })).chain;
const premature = await fetch(`${base}/api/chains/${chain.id}/deploy`, { method:'POST', headers:{ cookie, 'x-csrf-token':session.csrf, 'content-type':'application/json' }, body:'{}' });
// Shared node: one entry inbound serves many customers; deploy needs a client first.
const node = (await request('/api/nodes', 'POST', { name: 'SG → JP', serverId: relay.id, protocol: 'vless-reality-vision',
topology: 'forward', exitServerId: exit.id, exitProtocol: 'shadowsocks-2022-aes128' })).node;
const prematureDeploy = await fetch(`${base}/api/nodes/${node.id}/deploy`, { method: 'POST', headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' });
assert.equal(prematureDeploy.status, 400);
assert.match((await prematureDeploy.json()).message, /至少一个客户/);
await request(`/api/nodes/${node.id}/assign`, 'POST', { customerId: customer.id });
const premature = await fetch(`${base}/api/nodes/${node.id}/deploy`, { method: 'POST', headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' });
assert.equal(premature.status, 409);
const agentKeys = {};
for (const server of [relay, exit]) {
const ticket = await request(`/api/servers/${server.id}/enrollment-token`, 'POST', {});
const response = await fetch(`${base}/api/agent/enroll`, { method:'POST', headers:{ 'content-type':'application/json' }, body:JSON.stringify({ token:ticket.token, hostname:'test-agent' }) });
const response = await fetch(`${base}/api/agent/enroll`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: ticket.token, hostname: 'test-agent' }) });
assert.equal(response.status, 201);
agentKeys[server.id] = (await response.json()).agentKey;
}
const certSync = await fetch(`${base}/api/agent/tls-domain`, { method:'POST', headers:{ authorization:`Bearer ${agentKeys[relay.id]}`, 'content-type':'application/json' },
body:JSON.stringify({ domain:'entry.example.com' }) });
const certSync = await fetch(`${base}/api/agent/tls-domain`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[relay.id]}`, 'content-type': 'application/json' },
body: JSON.stringify({ domain: 'entry.example.com' }) });
assert.equal(certSync.status, 200);
assert.equal((await request('/api/servers')).servers.find((item) => item.id === relay.id).tlsDomain, 'entry.example.com');
const idleDatabase = await fs.promises.readFile(path.join(dir, 'data.json'), 'utf8');
for (let count = 0; count < 10; count += 1) {
for (const server of [relay, exit]) {
const headers = { authorization:`Bearer ${agentKeys[server.id]}`, 'content-type':'application/json' };
const poll = await fetch(`${base}/api/agent/poll`, { method:'POST', headers, body:'{}' });
assert.deepEqual(await poll.json(), { job:null });
const heartbeat = await fetch(`${base}/api/agent/heartbeat`, { method:'POST', headers,
body:JSON.stringify({ version:'0.6.8', system:{ memoryAvailable:512 * 1024 * 1024 } }) });
const headers = { authorization: `Bearer ${agentKeys[server.id]}`, 'content-type': 'application/json' };
const poll = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers, body: '{}' });
assert.deepEqual(await poll.json(), { job: null });
const heartbeat = await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers,
body: JSON.stringify({ version: '0.6.8', system: { memoryAvailable: 512 * 1024 * 1024 } }) });
assert.equal(heartbeat.status, 200);
}
}
assert.equal(await fs.promises.readFile(path.join(dir, 'data.json'), 'utf8'), idleDatabase);
const badCertSync = await fetch(`${base}/api/agent/tls-domain`, { method:'POST', headers:{ authorization:`Bearer ${agentKeys[relay.id]}`, 'content-type':'application/json' },
body:JSON.stringify({ domain:'invalid/domain' }) });
const badCertSync = await fetch(`${base}/api/agent/tls-domain`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[relay.id]}`, 'content-type': 'application/json' },
body: JSON.stringify({ domain: 'invalid/domain' }) });
assert.equal(badCertSync.status, 400);
await fetch(`${base}/api/agent/heartbeat`, { method:'POST', headers:{ authorization:`Bearer ${agentKeys[relay.id]}`, 'content-type':'application/json' },
body:JSON.stringify({ version:'0.4.0', engine:{ status:'error', detail:'Xray config validation failed' } }) });
await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[relay.id]}`, 'content-type': 'application/json' },
body: JSON.stringify({ version: '0.4.0', engine: { status: 'error', detail: 'Xray config validation failed' } }) });
// A broken core can be repaired by applying a new resource; keep the Agent job queue available.
const engineBlocked = await fetch(`${base}/api/chains/${chain.id}/deploy`, { method:'POST', headers:{ cookie, 'x-csrf-token':session.csrf, 'content-type':'application/json' }, body:'{}' });
const engineBlocked = await fetch(`${base}/api/nodes/${node.id}/deploy`, { method: 'POST', headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' });
assert.equal(engineBlocked.status, 202);
const deployed = await engineBlocked.json();
await fetch(`${base}/api/agent/heartbeat`, { method:'POST', headers:{ authorization:`Bearer ${agentKeys[relay.id]}`, 'content-type':'application/json' },
body:JSON.stringify({ version:'0.4.0', engine:{ status:'ready', detail:'active' } }) });
assert.equal(deployed.deployments.length, 2);
await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[relay.id]}`, 'content-type': 'application/json' },
body: JSON.stringify({ version: '0.4.0', engine: { status: 'ready', detail: 'active' } }) });
const jobs = await request('/api/jobs');
assert.equal(jobs.jobs.length, 2);
assert.ok(jobs.jobs.every((job) => job.status === 'queued'));
const exitPoll = await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${agentKeys[exit.id]}` } });
const exitPoll = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[exit.id]}` } });
const exitJob = (await exitPoll.json()).job;
const failed = await fetch(`${base}/api/agent/jobs/${exitJob.id}/complete`, { method:'POST',
headers:{ authorization:`Bearer ${agentKeys[exit.id]}`, 'content-type':'application/json' },
body:JSON.stringify({ success:false, error:'PrivateKey: example_secret Password (PublicKey): example_public' }) });
const failed = await fetch(`${base}/api/agent/jobs/${exitJob.id}/complete`, { method: 'POST',
headers: { authorization: `Bearer ${agentKeys[exit.id]}`, 'content-type': 'application/json' },
body: JSON.stringify({ success: false, error: 'PrivateKey: example_secret Password (PublicKey): example_public' }) });
assert.equal(failed.status, 200);
assert.doesNotMatch(JSON.stringify(await request('/api/jobs')), /example_secret/);
assert.doesNotMatch(JSON.stringify(await request('/api/chains')), /example_secret/);
const changed = await request(`/api/chains/${chain.id}`, 'PATCH', { realityServerName: 'www.tesla.com' });
assert.equal(changed.requiresRedeploy, true);
assert.equal(changed.chain.status, 'changes_pending');
assert.doesNotMatch(JSON.stringify(await request('/api/nodes')), /example_secret/);
// Editing a live node rebuilds immediately (3x-ui style); protocol changes reissue client credentials.
const changed = await request(`/api/nodes/${node.id}`, 'PATCH', { realityServerName: 'www.tesla.com' });
assert.equal(changed.node.status, 'deploying');
const customerB = (await request('/api/customers', 'POST', { name: '客户 B', ipLimit: 1, deviceLimit: 1 })).customer;
const direct = (await request('/api/chains', 'POST', { name: 'IPv6 单机节点', topology: 'direct', networkMode: 'ipv6', relayServerIds: [relay.id], customerIds: [customerB.id], relayProtocol: 'shadowsocks-2022-aes256' })).chain;
const directDeploy = await request(`/api/chains/${direct.id}/deploy`, 'POST', {});
assert.equal(directDeploy.deployments.length, 1);
assert.equal(directDeploy.deployments[0].role, 'direct');
const direct = (await request('/api/nodes', 'POST', { name: 'IPv6 单机节点', serverId: relay.id, protocol: 'shadowsocks-2022-aes256',
networkMode: 'ipv6', topology: 'direct' })).node;
await request(`/api/nodes/${direct.id}/assign`, 'POST', { customerId: customerB.id });
await request(`/api/nodes/${direct.id}/deploy`, 'POST', {});
const noActive = await fetch(`${base}/s/${customerB.subscriptionToken}/base64`);
assert.equal(noActive.status, 503);
const enrollment = await request(`/api/servers/${relay.id}/enrollment-token`, 'POST', {});
const enrolled = await fetch(`${base}/api/agent/enroll`, { method:'POST', headers:{ 'content-type':'application/json' }, body:JSON.stringify({ token:enrollment.token, hostname:'test-agent' }) });
const enrolled = await fetch(`${base}/api/agent/enroll`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: enrollment.token, hostname: 'test-agent' }) });
const { agentKey } = await enrolled.json();
assert.equal(enrolled.status, 201);
for (let count = 0; count < 4; count += 1) {
const polled = await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}` } });
agentKeys[relay.id] = agentKey; // re-enrollment revokes the previous agent key
const completeNext = async (key) => {
const polled = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${key}` } });
const { job } = await polled.json();
if (!job) break;
await fetch(`${base}/api/agent/jobs/${job.id}/complete`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, body:JSON.stringify({ success:true, result:{ artifacts:{ realityPublicKey:'test-public-key' } } }) });
assert.ok(job);
const completed = await fetch(`${base}/api/agent/jobs/${job.id}/complete`, { method: 'POST',
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
body: JSON.stringify({ success: true, result: { artifacts: { realityPublicKey: 'test-public-key' } } }) });
assert.equal(completed.status, 200);
return job;
};
// Drain every queued job on both agents (entry + exit + rebuild from the PATCH above).
for (let i = 0; i < 8; i += 1) {
let drained = true;
for (const key of Object.values(agentKeys)) {
const polled = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${key}` } });
const { job } = await polled.json();
if (job) {
drained = false;
await fetch(`${base}/api/agent/jobs/${job.id}/complete`, { method: 'POST',
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
body: JSON.stringify({ success: true, result: { artifacts: { realityPublicKey: 'test-public-key' } } }) });
}
}
if (drained) break;
}
const usageReport = async (uplink, downlink, epoch = 'xray-start-1') => {
const response = await fetch(`${base}/api/agent/usage`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' },
body: JSON.stringify({ epoch, samples:[{ resourceId:directDeploy.deployments[0].resourceId, uplink, downlink }] }) });
const activeNodes = (await request('/api/nodes')).nodes;
assert.equal(activeNodes.find((item) => item.id === node.id).status, 'active');
assert.equal(activeNodes.find((item) => item.id === direct.id).status, 'active');
const usageReport = async (uplink, downlink, epoch = 'sing-box-start-1') => {
const response = await fetch(`${base}/api/agent/usage`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ epoch, samples: [{ kind: 'user', id: customerB.id, uplink, downlink }] }) });
assert.equal(response.status, 200);
};
await usageReport(150, 350);
await usageReport(150, 350); // Retry after a lost HTTP response must not double count.
await usageReport(250, 900);
await usageReport(150, 350); // A late report from the same process must not count a false reset.
await usageReport(10, 20, 'xray-start-2'); // Xray restarted, and its counters began from zero.
await usageReport(250, 900, 'xray-start-1'); // The old request can arrive after the new process reported.
await usageReport(10, 20, 'sing-box-start-2'); // sing-box restarted, and its counters began from zero.
await usageReport(250, 900, 'sing-box-start-1'); // The old request can arrive after the new process reported.
const metered = (await request('/api/customers')).customers.find((item) => item.id === customerB.id);
assert.equal(metered.usedBytes, 1180);
assert.equal(metered.usedUplinkBytes, 260);
assert.equal(metered.usedDownlinkBytes, 920);
const meteredDeployment = (await request('/api/chains')).deployments.find((item) => item.id === directDeploy.deployments[0].id);
assert.deepEqual(meteredDeployment.meteredTraffic, { uplink:260, downlink:920 });
// Inbound-level traffic lands on the node, not the customer.
await fetch(`${base}/api/agent/usage`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ epoch: 'sing-box-start-2', samples: [{ kind: 'inbound', id: direct.id, uplink: 260, downlink: 920 }] }) });
const meteredNode = (await request('/api/nodes')).nodes.find((item) => item.id === direct.id);
assert.deepEqual(meteredNode.meteredTraffic, { uplink: 260, downlink: 920 });
const raw = await fetch(`${base}/s/${customerB.subscriptionToken}/raw`);
assert.equal(raw.status, 200);
assert.match(raw.headers.get('subscription-userinfo'), /upload=260; download=920;/);
await usageReport(1000, 2000, 'xray-start-3'); // A fast restart can exceed previous counters before polling.
await usageReport(1000, 2000, 'xray-start-3');
await usageReport(1000, 2000, 'sing-box-start-3'); // A fast restart can exceed previous counters before polling.
await usageReport(1000, 2000, 'sing-box-start-3');
assert.equal((await request('/api/customers')).customers.find((item) => item.id === customerB.id).usedBytes, 4180);
assert.match(await raw.text(), /^ss:\/\//);
assert.equal(raw.headers.get('cache-control'), 'no-store, private');
@@ -181,170 +219,170 @@ test('admin can create resources and queue a mixed-protocol chain', async (t) =>
assert.equal((await request(`/api/customers/${customerB.id}/access`)).events.length, 0);
assert.equal((await fetch(`${base}/s/${rotated.subscriptionToken}/raw`, { headers: { 'x-device-id': 'another-client' } })).status, 200);
const originalUri = (await request('/api/chains')).deployments.find((item) => item.id === directDeploy.deployments[0].id).clientUri;
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:2000 });
const nodeClients = (await request('/api/nodes')).nodeClients;
const originalUri = nodeClients.find((item) => item.nodeId === direct.id && item.customerId === customerB.id).clientUri;
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 2000 });
assert.equal((await fetch(`${base}/s/${rotated.subscriptionToken}/raw`)).status, 403);
const prematureResume = await fetch(`${base}/api/customers/${customerB.id}`, { method:'PATCH',
headers:{ cookie, 'x-csrf-token':session.csrf, 'content-type':'application/json' }, body:JSON.stringify({ status:'active' }) });
const prematureResume = await fetch(`${base}/api/customers/${customerB.id}`, { method: 'PATCH',
headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: JSON.stringify({ status: 'active' }) });
assert.equal(prematureResume.status, 409);
const deletePoll = await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}` } });
const deletePoll = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } });
const deleteJob = (await deletePoll.json()).job;
assert.equal(deleteJob.action, 'delete_resource');
await fetch(`${base}/api/agent/jobs/${deleteJob.id}/complete`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, body:JSON.stringify({ success:true }) });
assert.equal((await request('/api/chains')).chains.find((item) => item.id === direct.id).status, 'suspended');
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:10000 });
await fetch(`${base}/api/agent/jobs/${deleteJob.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) });
assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === direct.id).status, 'draft');
assert.equal((await request('/api/customers')).customers.find((item) => item.id === customerB.id).status, 'suspended');
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 10000 });
const restoringSubscription = await fetch(`${base}/s/${rotated.subscriptionToken}/raw`);
assert.equal(restoringSubscription.status, 503);
assert.equal(restoringSubscription.headers.get('retry-after'), '15');
assert.match((await restoringSubscription.json()).message, /正在恢复/);
const applyPoll = await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}` } });
const applyPoll = await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } });
const applyJob = (await applyPoll.json()).job;
assert.equal(applyJob.action, 'apply_resource');
await fetch(`${base}/api/agent/jobs/${applyJob.id}/complete`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, body:JSON.stringify({ success:true }) });
assert.equal((await request('/api/chains')).deployments.find((item) => item.id === directDeploy.deployments[0].id).clientUri, originalUri);
assert.equal((await fetch(`${base}/s/${rotated.subscriptionToken}/raw`, { headers:{ 'x-device-id':'another-client' } })).status, 200);
await fetch(`${base}/api/agent/jobs/${applyJob.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) });
const revivedUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === direct.id && item.customerId === customerB.id).clientUri;
assert.equal(revivedUri, originalUri);
assert.equal((await fetch(`${base}/s/${rotated.subscriptionToken}/raw`, { headers: { 'x-device-id': 'another-client' } })).status, 200);
// Raising the limit while a delete is already running still applies only after cleanup.
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:3000 });
const inFlight = (await (await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}` } })).json()).job;
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 3000 });
const inFlight = (await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job;
assert.equal(inFlight.action, 'delete_resource');
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:10000 });
await fetch(`${base}/api/agent/jobs/${inFlight.id}/complete`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, body:JSON.stringify({ success:true }) });
const afterDelete = (await request('/api/chains')).deployments.find((item) => item.id === directDeploy.deployments[0].id);
assert.equal(afterDelete.status, 'queued');
const reapply = (await (await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}` } })).json()).job;
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 10000 });
await fetch(`${base}/api/agent/jobs/${inFlight.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) });
const afterDelete = (await request('/api/nodes')).nodes.find((item) => item.id === direct.id);
assert.equal(afterDelete.status, 'deploying'); // resume queued the apply behind the running delete
const reapply = (await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job;
assert.equal(reapply.action, 'apply_resource');
await fetch(`${base}/api/agent/jobs/${reapply.id}/complete`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, body:JSON.stringify({ success:true }) });
await fetch(`${base}/api/agent/jobs/${reapply.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) });
// A queued deletion can be cancelled before it touches the existing node.
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:3000 });
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:10000 });
assert.equal((await request('/api/chains')).chains.find((item) => item.id === direct.id).status, 'active');
assert.equal((await (await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}` } })).json()).job, null);
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 3000 });
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 10000 });
assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === direct.id).status, 'active');
assert.equal((await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job, null);
// Routes deleted by older versions can recover the original URI with one explicit action.
await request(`/api/chains/${direct.id}/remove`, 'POST', {});
const oldDelete = (await (await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}` } })).json()).job;
await fetch(`${base}/api/agent/jobs/${oldDelete.id}/complete`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, body:JSON.stringify({ success:true }) });
assert.equal((await request('/api/chains')).chains.find((item) => item.id === direct.id).status, 'draft');
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:3000 });
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes:10000 });
assert.equal((await request('/api/chains')).chains.find((item) => item.id === direct.id).status, 'draft');
await request(`/api/chains/${direct.id}/restore`, 'POST', {});
const oldApply = (await (await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}` } })).json()).job;
await fetch(`${base}/api/agent/jobs/${oldApply.id}/complete`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' }, body:JSON.stringify({ success:true }) });
assert.equal((await request('/api/chains')).deployments.find((item) => item.id === directDeploy.deployments[0].id).clientUri, originalUri);
// A removed node keeps its client assignments; redeploying restores the same URI.
await request(`/api/nodes/${direct.id}/remove`, 'POST', {});
const oldDelete = (await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job;
await fetch(`${base}/api/agent/jobs/${oldDelete.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) });
assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === direct.id).status, 'draft');
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 3000 });
await request(`/api/customers/${customerB.id}`, 'PATCH', { trafficLimitBytes: 10000 });
assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === direct.id).status, 'draft');
await request(`/api/nodes/${direct.id}/deploy`, 'POST', {});
const oldApply = (await (await fetch(`${base}/api/agent/poll`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}` } })).json()).job;
await fetch(`${base}/api/agent/jobs/${oldApply.id}/complete`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ success: true }) });
const redeployedUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === direct.id && item.customerId === customerB.id).clientUri;
assert.equal(redeployedUri, originalUri);
// A forward route restores both the exit and the entry with the same ports and client URI.
const customerC = (await request('/api/customers', 'POST', { name:'配额恢复测试', trafficLimitBytes:100 })).customer;
const forward = (await request('/api/chains', 'POST', { name:'保留节点', relayServerIds:[relay.id], exitServerId:exit.id,
customerIds:[customerC.id], relayProtocol:'vless-reality-vision', exitProtocol:'shadowsocks-2022-aes128' })).chain;
const forwardDeploy = (await request(`/api/chains/${forward.id}/deploy`, 'POST', {})).deployments;
const completeNext = async (key) => {
const polled = await fetch(`${base}/api/agent/poll`, { method:'POST', headers:{ authorization:`Bearer ${key}` } });
const { job } = await polled.json();
assert.ok(job);
const completed = await fetch(`${base}/api/agent/jobs/${job.id}/complete`, { method:'POST',
headers:{ authorization:`Bearer ${key}`, 'content-type':'application/json' },
body:JSON.stringify({ success:true, result:{ artifacts:{ realityPublicKey:'test-public-key' } } }) });
assert.equal(completed.status, 200);
return job;
};
// A forward node suspends both exit and entry, and restores them with the same URI.
const customerC = (await request('/api/customers', 'POST', { name: '配额恢复测试', trafficLimitBytes: 100 })).customer;
const forward = (await request('/api/nodes', 'POST', { name: '保留节点', serverId: relay.id, protocol: 'vless-reality-vision',
topology: 'forward', exitServerId: exit.id, exitProtocol: 'shadowsocks-2022-aes128' })).node;
await request(`/api/nodes/${forward.id}/assign`, 'POST', { customerId: customerC.id });
await request(`/api/nodes/${forward.id}/deploy`, 'POST', {});
await completeNext(agentKeys[exit.id]);
await completeNext(agentKey);
const forwardEntry = forwardDeploy.find((item) => item.role === 'relay');
const forwardUri = (await request('/api/chains')).deployments.find((item) => item.id === forwardEntry.id).clientUri;
const forwardUsage = await fetch(`${base}/api/agent/usage`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' },
body:JSON.stringify({ epoch:'fwd-1', samples:[{ resourceId:forwardEntry.resourceId, uplink:2, downlink:5 }] }) });
const forwardUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === forward.id && item.customerId === customerC.id).clientUri;
const forwardUsage = await fetch(`${base}/api/agent/usage`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ epoch: 'fwd-1', samples: [{ kind: 'user', id: customerC.id, uplink: 2, downlink: 5 }] }) });
assert.equal(forwardUsage.status, 200);
await request(`/api/customers/${customerC.id}`, 'PATCH', { trafficLimitBytes:1 });
await request(`/api/customers/${customerC.id}`, 'PATCH', { trafficLimitBytes: 1 });
assert.equal((await completeNext(agentKeys[exit.id])).action, 'delete_resource');
assert.equal((await completeNext(agentKey)).action, 'delete_resource');
assert.equal((await request('/api/chains')).chains.find((item) => item.id === forward.id).status, 'suspended');
await request(`/api/customers/${customerC.id}`, 'PATCH', { trafficLimitBytes:100 });
assert.equal((await request('/api/nodes')).nodes.find((item) => item.id === forward.id).status, 'draft');
await request(`/api/customers/${customerC.id}`, 'PATCH', { trafficLimitBytes: 100 });
assert.equal((await completeNext(agentKey)).action, 'apply_resource');
assert.equal((await fetch(`${base}/s/${customerC.subscriptionToken}/raw`)).status, 503);
assert.equal((await completeNext(agentKeys[exit.id])).action, 'apply_resource');
assert.equal((await request('/api/chains')).deployments.find((item) => item.id === forwardEntry.id).clientUri, forwardUri);
const forwardRevivedUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === forward.id && item.customerId === customerC.id).clientUri;
assert.equal(forwardRevivedUri, forwardUri);
assert.equal((await fetch(`${base}/s/${customerC.subscriptionToken}/raw`)).status, 200);
// AnyTLS is a separate entry engine, but follows the same quota and restore lifecycle.
await request(`/api/servers/${relay.id}`, 'PATCH', { tlsDomain:'entry.example.com' });
const anyCustomer = (await request('/api/customers', 'POST', { name:'AnyTLS 客户', trafficLimitBytes:500 })).customer;
const anyChain = (await request('/api/chains', 'POST', { name:'AnyTLS → VLESS', relayServerIds:[relay.id], exitServerId:exit.id,
customerIds:[anyCustomer.id], relayProtocol:'anytls', exitProtocol:'vless-tcp' })).chain;
const oldAgent = await fetch(`${base}/api/chains/${anyChain.id}/deploy`, { method:'POST',
headers:{ cookie, 'x-csrf-token':session.csrf, 'content-type':'application/json' }, body:'{}' });
// AnyTLS is a separate entry engine, but follows the same quota lifecycle.
await request(`/api/servers/${relay.id}`, 'PATCH', { tlsDomain: 'entry.example.com' });
const anyCustomer = (await request('/api/customers', 'POST', { name: 'AnyTLS 客户', trafficLimitBytes: 500 })).customer;
const anyNode = (await request('/api/nodes', 'POST', { name: 'AnyTLS → VLESS', serverId: relay.id, protocol: 'anytls',
topology: 'forward', exitServerId: exit.id, exitProtocol: 'vless-tcp' })).node;
await request(`/api/nodes/${anyNode.id}/assign`, 'POST', { customerId: anyCustomer.id });
const oldAgent = await fetch(`${base}/api/nodes/${anyNode.id}/deploy`, { method: 'POST',
headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' });
assert.equal(oldAgent.status, 409);
assert.match((await oldAgent.json()).message, /入口证书未由 Agent 确认/);
await fetch(`${base}/api/agent/heartbeat`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' },
body:JSON.stringify({ version:'0.6.5', engine:{ status:'ready', singBoxInstalled:false, certificates:['entry.example.com'] } }) });
const missingEngine = await fetch(`${base}/api/chains/${anyChain.id}/deploy`, { method:'POST',
headers:{ cookie, 'x-csrf-token':session.csrf, 'content-type':'application/json' }, body:'{}' });
await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ version: '0.6.5', engine: { status: 'ready', singBoxInstalled: false, certificates: ['entry.example.com'] } }) });
const missingEngine = await fetch(`${base}/api/nodes/${anyNode.id}/deploy`, { method: 'POST',
headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: '{}' });
assert.equal(missingEngine.status, 409);
assert.match((await missingEngine.json()).message, /ng-agent engine install/);
await fetch(`${base}/api/agent/heartbeat`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' },
body:JSON.stringify({ version:'0.6.5', engine:{ status:'ready', singBoxInstalled:true, certificates:['entry.example.com'] } }) });
const anyDeploy = (await request(`/api/chains/${anyChain.id}/deploy`, 'POST', {})).deployments;
await fetch(`${base}/api/agent/heartbeat`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ version: '0.6.5', engine: { status: 'ready', singBoxInstalled: true, certificates: ['entry.example.com'] } }) });
await request(`/api/nodes/${anyNode.id}/deploy`, 'POST', {});
const anyExitJob = await completeNext(agentKeys[exit.id]);
assert.equal(anyExitJob.payload.resource.inbounds[0].protocol, 'vless');
assert.equal(anyExitJob.payload.resource.inbounds[0].type, 'vless');
const anyEntryJob = await completeNext(agentKey);
assert.equal(anyEntryJob.payload.resource.engine, 'sing-box');
assert.equal(anyEntryJob.payload.resource.outbounds[0].type, 'vless');
const anyEntry = anyDeploy.find((item) => item.role === 'relay');
const anyUri = (await request('/api/chains')).deployments.find((item) => item.id === anyEntry.id).clientUri;
const anyUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === anyNode.id && item.customerId === anyCustomer.id).clientUri;
assert.match(anyUri, /^anytls:\/\//);
assert.equal(decodeURIComponent(anyUri.split('#')[1]), 'AnyTLS → VLESS');
assert.match(await (await fetch(`${base}/s/${anyCustomer.subscriptionToken}/clash`)).text(), /type: "anytls"/);
const anyUsage = await fetch(`${base}/api/agent/usage`, { method:'POST',
headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' },
body:JSON.stringify({ samples:[{ resourceId:anyEntry.resourceId, uplink:120, downlink:400, epoch:'sing-box:123:456' }] }) });
const anyUsage = await fetch(`${base}/api/agent/usage`, { method: 'POST',
headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ samples: [{ kind: 'inbound', id: anyNode.id, uplink: 120, downlink: 400, epoch: 'sing-box:123:456' }] }) });
assert.equal(anyUsage.status, 200);
assert.deepEqual((await request('/api/chains')).deployments.find((item) => item.id === anyEntry.id).meteredTraffic,
{ uplink:120, downlink:400 });
await request(`/api/customers/${anyCustomer.id}`, 'PATCH', { trafficLimitBytes:500 });
assert.deepEqual((await request('/api/nodes')).nodes.find((item) => item.id === anyNode.id).meteredTraffic,
{ uplink: 120, downlink: 400 });
// Customer-level usage for the quota test below.
await fetch(`${base}/api/agent/usage`, { method: 'POST',
headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ epoch: 'sb-user-1', samples: [{ kind: 'user', id: anyCustomer.id, uplink: 300, downlink: 300 }] }) });
await request(`/api/customers/${anyCustomer.id}`, 'PATCH', { trafficLimitBytes: 500 });
assert.equal((await completeNext(agentKeys[exit.id])).action, 'delete_resource');
assert.equal((await completeNext(agentKey)).action, 'delete_resource');
assert.equal((await fetch(`${base}/s/${anyCustomer.subscriptionToken}/raw`)).status, 403);
await request(`/api/customers/${anyCustomer.id}`, 'PATCH', { trafficLimitBytes:1000 });
assert.equal((await completeNext(agentKey)).payload.resource.engine, 'sing-box');
await request(`/api/customers/${anyCustomer.id}`, 'PATCH', { trafficLimitBytes: 1000 });
const anyReapply = await completeNext(agentKey);
assert.equal(anyReapply.payload.resource.engine, 'sing-box');
assert.equal((await completeNext(agentKeys[exit.id])).action, 'apply_resource');
assert.equal((await request('/api/chains')).deployments.find((item) => item.id === anyEntry.id).clientUri, anyUri);
const anyRevivedUri = (await request('/api/nodes')).nodeClients.find((item) => item.nodeId === anyNode.id && item.customerId === anyCustomer.id).clientUri;
assert.equal(anyRevivedUri, anyUri);
// Exit-server source addresses belong to relay machines, never to client IP quota.
await fetch(`${base}/api/agent/observations`, { method:'POST', headers:{ authorization:`Bearer ${agentKeys[exit.id]}`, 'content-type':'application/json' },
body: JSON.stringify({ observations: [{ customerId:customerB.id, ip:'198.51.100.1' }] }) });
await fetch(`${base}/api/agent/observations`, { method: 'POST', headers: { authorization: `Bearer ${agentKeys[exit.id]}`, 'content-type': 'application/json' },
body: JSON.stringify({ observations: [{ customerId: customerB.id, ip: '198.51.100.1' }] }) });
assert.equal((await request(`/api/customers/${customerB.id}/access`)).observedIps.length, 0);
const observed = await fetch(`${base}/api/agent/observations`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' },
body: JSON.stringify({ observations: [{ customerId:customerB.id, ip:'203.0.113.1' }] }) });
const observed = await fetch(`${base}/api/agent/observations`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ observations: [{ customerId: customerB.id, ip: '203.0.113.1' }] }) });
assert.equal(observed.status, 200);
assert.equal((await request(`/api/customers/${customerB.id}/access`)).observedIps.length, 1);
const excessIp = await fetch(`${base}/api/agent/observations`, { method:'POST', headers:{ authorization:`Bearer ${agentKey}`, 'content-type':'application/json' },
body: JSON.stringify({ observations: [{ customerId:customerB.id, ip:'203.0.113.2' }] }) });
const excessIp = await fetch(`${base}/api/agent/observations`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ observations: [{ customerId: customerB.id, ip: '203.0.113.2' }] }) });
assert.equal(excessIp.status, 200);
assert.equal((await request('/api/customers')).customers.find((item) => item.id === customerB.id).suspendReason, 'ip_limit');
// A failed / not-yet-applied route can disappear from the UI immediately,
// while cleanup tombstones retain the ports until agents acknowledge removal.
await request(`/api/chains/${chain.id}/remove`, 'POST', {});
const deleted = await request(`/api/chains/${chain.id}`, 'DELETE');
assert.equal(deleted.cleanupPending, 2);
const afterChainDelete = await request('/api/chains');
assert.equal(afterChainDelete.chains.some((item) => item.id === chain.id), false);
assert.equal(afterChainDelete.deployments.some((item) => item.chainId === chain.id), false);
// Deleting a node removes it from the UI at once and hard-deletes after agents confirm.
await request(`/api/nodes/${node.id}/remove`, 'POST', {});
const deleted = await request(`/api/nodes/${node.id}`, 'DELETE');
assert.equal(deleted.ok, true);
const afterNodeDelete = await request('/api/nodes');
assert.equal(afterNodeDelete.nodes.some((item) => item.id === node.id), false);
assert.equal(afterNodeDelete.nodeClients.some((item) => item.nodeId === node.id), false);
const source = JSON.parse(await fs.promises.readFile(path.join(dir, 'data.json'), 'utf8'));
assert.equal(source.deployments.filter((item) => item.chainId === chain.id && item.archived && item.status === 'removing').length, 2);
assert.equal(source.nodes.some((item) => item.id === node.id), true);
const removedCustomer = await request(`/api/customers/${customer.id}`, 'DELETE');
assert.equal(removedCustomer.ok, true);
assert.equal((await fetch(`${base}/s/${customer.subscriptionToken}/raw`)).status, 404);
const blocked = await fetch(`${base}/api/servers/${exit.id}`, { method:'DELETE', headers:{ cookie, 'x-csrf-token':session.csrf } });
const blocked = await fetch(`${base}/api/servers/${exit.id}`, { method: 'DELETE', headers: { cookie, 'x-csrf-token': session.csrf } });
assert.equal(blocked.status, 409);
const retry = await request(`/api/servers/${exit.id}/cleanup`, 'POST', {});
assert.equal(retry.queued, 0);
const unsafeForget = await fetch(`${base}/api/servers/${exit.id}/forget`, { method:'POST', headers:{ cookie, 'x-csrf-token':session.csrf, 'content-type':'application/json' }, body:JSON.stringify({ confirm:'FORGET', name:'落地 01' }) });
const unsafeForget = await fetch(`${base}/api/servers/${exit.id}/forget`, { method: 'POST', headers: { cookie, 'x-csrf-token': session.csrf, 'content-type': 'application/json' }, body: JSON.stringify({ confirm: 'FORGET', name: '落地 01' }) });
assert.equal(unsafeForget.status, 409);
const renamed = await request('/api/account', 'PATCH', { username:'owner_2', currentPassword:'test-password', newPassword:'new-password-456' });
const renamed = await request('/api/account', 'PATCH', { username: 'owner_2', currentPassword: 'test-password', newPassword: 'new-password-456' });
assert.equal(renamed.reauthenticate, true);
const relogin = await fetch(`${base}/api/auth/login`, { method:'POST', headers:{ 'content-type':'application/json' }, body:JSON.stringify({ username:'owner_2', password:'new-password-456' }) });
const relogin = await fetch(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'owner_2', password: 'new-password-456' }) });
assert.equal(relogin.status, 200);
});
+2 -2
View File
@@ -13,9 +13,9 @@ test('job failures redact a Reality secret and stale completions cannot overwrit
try {
const store = await new Store(path.join(dir, 'data.json')).init();
await store.transaction((data) => {
data.jobs.push({ id:'job_running', status:'running', deploymentId:'dep_missing', action:'apply_resource',
data.jobs.push({ id:'job_running', status:'running', nodeId:'nod_test', action:'apply_resource',
serverId:'srv_test', payload:{}, attempts:1 });
data.jobs.push({ id:'job_requeued', status:'queued', deploymentId:'dep_missing', action:'apply_resource',
data.jobs.push({ id:'job_requeued', status:'queued', nodeId:'nod_test', action:'apply_resource',
serverId:'srv_test', payload:{}, attempts:2 });
});
const orchestrator = new Orchestrator(store);
+167 -71
View File
@@ -3,26 +3,82 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
newCredentialSet, buildExitResource, buildRelayResource, buildDirectResource, buildClientUri,
validateEntryProtocol, validateProtocolPair
newNodeClientCredentials, ensureNodeSecrets, nodeEmail,
buildNodeResource, buildNodeExitResource, buildClientUri,
validateEntryProtocol, validateProtocolPair, resolveBindAddress,
} = require('../lib/protocols');
const customer = { id: 'cus_test', name: '测试客户' };
const relay = { id: 'srv_relay', publicAddress: 'relay.example.com' };
const exit = { id: 'srv_exit', publicAddress: 'exit.example.com' };
const entryServer = { id: 'srv_entry', publicAddress: 'entry.example.com', tlsDomain: 'node.example.com' };
const exitServer = { id: 'srv_exit', publicAddress: 'exit.example.com' };
test('builds a VLESS Reality to Shadowsocks 2022 chain', () => {
const credentials = newCredentialSet('vless-reality-vision', 'shadowsocks-2022-aes128');
const reality = { keyId: 'test-key', serverName: 'www.microsoft.com', destPort: 443 };
const exitResource = buildExitResource({ resourceId: 'res_exit_123', tagPrefix: 'exit-test', port: 32001, protocol: 'shadowsocks-2022-aes128', credentials, customer });
const relayResource = buildRelayResource({ resourceId: 'res_relay_123', tagPrefix: 'relay-test', port: 21001, protocol: 'vless-reality-vision', exitProtocol: 'shadowsocks-2022-aes128', exitServer: exit, exitPort: 32001, credentials, customer, reality });
assert.equal(exitResource.inbounds[0].protocol, 'shadowsocks');
assert.equal(relayResource.inbounds[0].protocol, 'vless');
assert.equal(relayResource.outbounds[0].settings.servers[0].address, 'exit.example.com');
assert.equal(relayResource.inbounds[0].streamSettings.realitySettings.privateKey, '${REALITY_PRIVATE:test-key}');
const uri = buildClientUri({ protocol: 'vless-reality-vision', relayServer: relay, relayPort: 21001, credentials, reality, publicKey: 'public-key', name: '测试' });
function makeNode(overrides = {}) {
const node = {
id: 'nod_test1', name: '测试节点', serverId: entryServer.id,
protocol: 'vless-reality-vision', port: 21001, portMode: 'random',
networkMode: 'ipv4', topology: 'forward',
exitServerId: exitServer.id, exitProtocol: 'shadowsocks-2022-aes128', exitPort: 32001, exitPortMode: 'random',
realityServerName: 'www.microsoft.com', realityDestPort: 443,
resourceId: 'res_entry', exitResourceId: 'res_exit',
...overrides,
};
return ensureNodeSecrets(node);
}
function makeClients(node, customerIds) {
return customerIds.map((customerId) => ({ customerId, credentials: newNodeClientCredentials(node.protocol) }));
}
test('builds a shared VLESS Reality node with multiple clients and an SS2022 exit', () => {
const node = makeNode();
const clients = makeClients(node, ['cus_a', 'cus_b']);
const exitResource = buildNodeExitResource({ resourceId: node.exitResourceId, tagPrefix: 'exit-test', node, server: exitServer });
const entryResource = buildNodeResource({ resourceId: node.resourceId, tagPrefix: 'relay-test', node, nodeClients: clients, server: entryServer, exitServer });
assert.equal(exitResource.meta.kind, 'node-exit');
assert.equal(exitResource.engine, 'sing-box');
assert.equal(exitResource.inbounds[0].type, 'shadowsocks');
assert.equal(entryResource.meta.kind, 'node');
assert.equal(entryResource.inbounds[0].type, 'vless');
// 两个客户共享一个 inbound,各自独立 uuid,且带 ng: 用户名供按用户统计
assert.equal(entryResource.inbounds[0].users.length, 2);
assert.notEqual(entryResource.inbounds[0].users[0].uuid, entryResource.inbounds[0].users[1].uuid);
assert.equal(entryResource.inbounds[0].users[0].name, 'ng:cus_a');
assert.equal(entryResource.inbounds[0].users[1].name, 'ng:cus_b');
assert.deepEqual(entryResource.inbounds[0].tls.reality.short_id, clients.map((c) => c.credentials.shortId));
assert.equal(entryResource.inbounds[0].tls.reality.private_key, '${REALITY_PRIVATE:reality-nod_test1}');
assert.equal(entryResource.outbounds[0].server, 'exit.example.com');
assert.equal(entryResource.outbounds[0].password, node.exitCredentials.password);
// 客户端 URI 按客户凭据生成
const uri = buildClientUri({ protocol: 'vless-reality-vision', relayServer: entryServer, relayPort: 21001,
credentials: { ...clients[0].credentials, wsPath: node.wsPath }, reality: node.reality, publicKey: 'public-key', name: '测试' });
assert.match(uri, /^vless:\/\//);
assert.match(uri, /pbk=public-key/);
assert.match(uri, new RegExp(clients[0].credentials.clientId));
});
test('nodeEmail unifies the per-user identity across protocols', () => {
assert.equal(nodeEmail('cus_1'), 'ng:cus_1');
});
test('every inbound protocol carries a per-user identity for usage stats', () => {
// sing-box 只给 stats.users 里列名的用户记 user>>> 计数器;每个协议的 users 必须带可识别身份。
const cases = [
{ protocol: 'vless-reality-vision', field: 'name' },
{ protocol: 'vless-ws-tls', field: 'name' },
{ protocol: 'shadowsocks-2022-aes128', field: 'name' },
{ protocol: 'shadowsocks-aes256-gcm', field: 'name' },
{ protocol: 'socks5-auth', field: 'username' },
{ protocol: 'hysteria2', field: 'name' },
{ protocol: 'anytls', field: 'name' },
];
for (const { protocol, field } of cases) {
const node = makeNode({ protocol, topology: 'direct' });
const clients = makeClients(node, ['cus_audit']);
const server = protocol === 'hysteria2' || protocol === 'anytls' || protocol === 'vless-ws-tls'
? { ...entryServer, tlsDomain: 'node.example.com' } : entryServer;
const resource = buildNodeResource({ resourceId: `res_audit_${protocol}`, tagPrefix: 'audit', node, nodeClients: clients, server });
const user = resource.inbounds[0].users[0];
assert.equal(user[field], 'ng:cus_audit', `${protocol} users 缺少 ${field},按用户统计会失效`);
}
});
test('rejects unsupported protocol pairs', () => {
@@ -30,77 +86,117 @@ test('rejects unsupported protocol pairs', () => {
assert.doesNotThrow(() => validateProtocolPair('anytls', 'vless-tcp'));
});
test('AnyTLS runs in sing-box with a separate TLS entry and VLESS or Shadowsocks exit', () => {
test('AnyTLS node runs in sing-box with per-user names and a VLESS exit', () => {
const { makeClash, makeSingBox, makeSurge } = require('../lib/subscriptions');
const credentials = newCredentialSet('anytls', 'vless-tcp');
assert.throws(() => buildDirectResource({ resourceId:'res_any_no_cert', tagPrefix:'any', port:23001,
protocol:'anytls', credentials, customer }), /证书域名/);
const relayResource = buildRelayResource({ resourceId:'res_any_relay', tagPrefix:'any', port:23001,
protocol:'anytls', exitProtocol:'vless-tcp', exitServer:exit, exitPort:32001,
credentials, customer, tlsDomain:'entry.example.com' });
assert.equal(relayResource.engine, 'sing-box');
assert.equal(relayResource.inbounds[0].type, 'anytls');
assert.equal(relayResource.inbounds[0].tls.certificate_path, '/etc/nexusgate/tls/entry.example.com/fullchain.pem');
assert.equal(relayResource.outbounds[0].type, 'vless');
assert.equal(relayResource.outbounds[0].uuid, credentials.clientId);
const direct = buildDirectResource({ resourceId:'res_any_direct', tagPrefix:'direct-any', port:23002,
protocol:'anytls', credentials, customer, tlsDomain:'entry.example.com' });
const node = makeNode({ protocol: 'anytls', topology: 'forward', exitProtocol: 'vless-tcp' });
const clients = makeClients(node, ['cus_a']);
assert.throws(() => buildNodeResource({ resourceId: 'res_any_no_cert', tagPrefix: 'any', node,
nodeClients: clients, server: { ...entryServer, tlsDomain: null }, exitServer }), /证书域名/);
const resource = buildNodeResource({ resourceId: 'res_any', tagPrefix: 'any', node, nodeClients: clients, server: entryServer, exitServer });
assert.equal(resource.engine, 'sing-box');
assert.equal(resource.inbounds[0].type, 'anytls');
assert.equal(resource.inbounds[0].users[0].name, 'ng:cus_a');
assert.equal(resource.inbounds[0].users[0].password, clients[0].credentials.relayPassword);
assert.equal(resource.inbounds[0].tls.certificate_path, '/etc/nexusgate/tls/node.example.com/fullchain.pem');
assert.equal(resource.outbounds[0].type, 'vless');
assert.equal(resource.outbounds[0].uuid, node.exitCredentials.clientId);
const direct = buildNodeResource({ resourceId: 'res_any_direct', tagPrefix: 'direct-any',
node: makeNode({ protocol: 'anytls', topology: 'direct' }), nodeClients: clients, server: entryServer });
assert.equal(direct.outbounds[0].type, 'direct');
assert.equal(direct.routingRules[0].outbound, 'direct-any-direct');
const uri = buildClientUri({ protocol:'anytls', relayServer:relay, relayPort:23001,
credentials, tlsDomain:'entry.example.com', name:'AnyTLS 测试' });
const uri = buildClientUri({ protocol: 'anytls', relayServer: entryServer, relayPort: 23001,
credentials: clients[0].credentials, tlsDomain: 'node.example.com', name: 'AnyTLS 测试' });
assert.match(uri, /^anytls:\/\//);
assert.match(makeClash([{ clientUri:uri }]), /type: "anytls"/);
assert.match(makeSingBox([{ clientUri:uri }]), /"type": "anytls"/);
assert.equal(makeSurge([{ clientUri:uri }]), null);
assert.match(makeClash([{ clientUri: uri }]), /type: "anytls"/);
assert.match(makeSingBox([{ clientUri: uri }]), /"type": "anytls"/);
assert.equal(makeSurge([{ clientUri: uri }]), null);
});
test('Hysteria 2 reuses Xray route and emits importable Clash and sing-box credentials', () => {
test('Hysteria 2 node carries multiple clients and emits importable credentials', () => {
const { makeClash, makeSingBox } = require('../lib/subscriptions');
const credentials = newCredentialSet('hysteria2', 'shadowsocks-2022-aes128');
const node = buildRelayResource({ resourceId:'res_hy2_123', tagPrefix:'hy2-test', port:23000,
protocol:'hysteria2', exitProtocol:'shadowsocks-2022-aes128', exitServer:exit, exitPort:32001,
credentials, customer, tlsDomain:'node.example.com' });
assert.equal(node.inbounds[0].protocol, 'hysteria');
assert.equal(node.inbounds[0].streamSettings.method, 'hysteria');
assert.equal(node.inbounds[0].streamSettings.network, 'hysteria');
assert.equal(node.inbounds[0].settings.clients[0].auth, credentials.relayPassword);
assert.equal(node.inbounds[0].settings.users[0].auth, credentials.relayPassword);
assert.equal(node.inbounds[0].streamSettings.tlsSettings.certificates[0].keyFile, '/etc/nexusgate/tls/node.example.com/privkey.pem');
const uri = buildClientUri({ protocol:'hysteria2', relayServer:relay, relayPort:23000,
credentials, tlsDomain:'node.example.com', name:'H2 test' });
const node = makeNode({ protocol: 'hysteria2', topology: 'direct' });
const clients = makeClients(node, ['cus_a', 'cus_b']);
const resource = buildNodeResource({ resourceId: 'res_hy2', tagPrefix: 'hy2-test', node, nodeClients: clients, server: entryServer });
assert.equal(resource.inbounds[0].type, 'hysteria2');
assert.equal(resource.inbounds[0].users.length, 2);
assert.equal(resource.inbounds[0].users[0].password, clients[0].credentials.relayPassword);
assert.equal(resource.inbounds[0].tls.key_path, '/etc/nexusgate/tls/node.example.com/privkey.pem');
const uri = buildClientUri({ protocol: 'hysteria2', relayServer: entryServer, relayPort: 23000,
credentials: clients[0].credentials, tlsDomain: 'node.example.com', name: 'H2 test' });
assert.match(uri, /^hysteria2:\/\//);
assert.match(makeClash([{ clientUri:uri }]), /type: "hysteria2"/);
assert.match(makeSingBox([{ clientUri:uri }]), /"type": "hysteria2"/);
assert.match(makeClash([{ clientUri: uri }]), /type: "hysteria2"/);
assert.match(makeSingBox([{ clientUri: uri }]), /"type": "hysteria2"/);
});
test('builds direct Shadowsocks, SOCKS5 and IPv6 client resources', () => {
const ssCredentials = newCredentialSet('shadowsocks-2022-aes256', null);
const direct = buildDirectResource({ resourceId: 'res_direct_123', tagPrefix: 'direct-test', port: 24001, protocol: 'shadowsocks-2022-aes256', credentials: ssCredentials, customer, networkMode: 'ipv6' });
assert.equal(direct.meta.kind, 'direct');
assert.equal(direct.inbounds[0].listen, '::');
assert.equal(direct.inbounds[0].settings.method, '2022-blake3-aes-256-gcm');
const ipv6Relay = { ...relay, publicAddressV6: '2001:db8::10' };
const uri = buildClientUri({ protocol: 'shadowsocks-2022-aes256', relayServer: ipv6Relay, relayPort: 24001, credentials: ssCredentials, name: 'IPv6 节点', networkMode: 'ipv6' });
test('builds shared Shadowsocks, SOCKS5 and IPv6 node inbounds', () => {
const node = makeNode({ protocol: 'shadowsocks-2022-aes256', topology: 'direct', networkMode: 'ipv6' });
const clients = makeClients(node, ['cus_a', 'cus_b']);
const resource = buildNodeResource({ resourceId: 'res_ss', tagPrefix: 'ss-test', node, nodeClients: clients, server: entryServer });
assert.equal(resource.meta.kind, 'node');
assert.equal(resource.inbounds[0].method, '2022-blake3-aes-256-gcm');
assert.equal(resource.inbounds[0].users.length, 2);
assert.equal(resource.inbounds[0].users[0].name, 'ng:cus_a');
// SS2022 每个用户的密钥长度符合方法要求
assert.equal(Buffer.from(clients[0].credentials.relayPassword, 'base64').length, 32);
const ipv6Server = { ...entryServer, publicAddressV6: '2001:db8::10' };
const v6 = buildNodeResource({ resourceId: 'res_ss_v6', tagPrefix: 'ss-v6', node: makeNode({ protocol: 'shadowsocks-2022-aes256', topology: 'direct', networkMode: 'ipv6' }),
nodeClients: clients, server: ipv6Server, bindAddress: '2001:db8::99' });
assert.equal(v6.inbounds[0].listen, '2001:db8::99');
const uri = buildClientUri({ protocol: 'shadowsocks-2022-aes256', relayServer: ipv6Server, relayPort: 24001,
credentials: clients[0].credentials, name: 'IPv6 节点', networkMode: 'ipv6' });
assert.match(uri, /@\[2001:db8::10\]:24001/);
const socksCredentials = newCredentialSet('socks5-auth', null);
const socks = buildDirectResource({ resourceId: 'res_direct_456', tagPrefix: 'socks-test', port: 24002, protocol: 'socks5-auth', credentials: socksCredentials, customer, networkMode: 'ipv4' });
assert.equal(socks.inbounds[0].protocol, 'socks');
const socksNode = makeNode({ protocol: 'socks5-auth', topology: 'direct' });
const socksClients = makeClients(socksNode, ['cus_a']);
const socks = buildNodeResource({ resourceId: 'res_socks', tagPrefix: 'socks-test', node: socksNode, nodeClients: socksClients, server: entryServer });
assert.equal(socks.inbounds[0].type, 'socks');
// SOCKS 用户名即 ng:<customerId>
assert.equal(socks.inbounds[0].users[0].username, 'ng:cus_a');
assert.doesNotThrow(() => validateEntryProtocol('socks5-auth'));
});
test('VLESS WebSocket TLS requires a node certificate domain and exports TLS clients', () => {
const credentials = newCredentialSet('vless-ws-tls', null);
test('VLESS WebSocket TLS node requires a certificate domain and exports TLS clients', () => {
const node = makeNode({ protocol: 'vless-ws-tls', topology: 'direct' });
const clients = makeClients(node, ['cus_a']);
assert.doesNotThrow(() => validateEntryProtocol('vless-ws-tls'));
assert.throws(() => buildDirectResource({ resourceId:'res_tls_missing', tagPrefix:'tls', port:24444,
protocol:'vless-ws-tls', credentials, customer }), /证书域名/);
const resource = buildDirectResource({ resourceId:'res_tls_123', tagPrefix:'tls', port:24444,
protocol:'vless-ws-tls', credentials, customer, tlsDomain:'node.example.com' });
const stream = resource.inbounds[0].streamSettings;
assert.equal(stream.security, 'tls');
assert.equal(stream.tlsSettings.certificates[0].certificateFile, '/etc/nexusgate/tls/node.example.com/fullchain.pem');
const uri = buildClientUri({ protocol:'vless-ws-tls', relayServer:relay, relayPort:24444,
credentials, tlsDomain:'node.example.com', name:'TLS 节点' });
assert.throws(() => buildNodeResource({ resourceId: 'res_tls_missing', tagPrefix: 'tls', node, nodeClients: clients,
server: { ...entryServer, tlsDomain: null } }), /证书域名/);
const resource = buildNodeResource({ resourceId: 'res_tls', tagPrefix: 'tls', node, nodeClients: clients, server: entryServer });
assert.equal(resource.inbounds[0].tls.enabled, true);
assert.equal(resource.inbounds[0].tls.certificate_path, '/etc/nexusgate/tls/node.example.com/fullchain.pem');
assert.equal(resource.inbounds[0].transport.path, node.wsPath);
assert.equal(resource.inbounds[0].users[0].uuid, clients[0].credentials.clientId);
const uri = buildClientUri({ protocol: 'vless-ws-tls', relayServer: entryServer, relayPort: 24444,
credentials: { ...clients[0].credentials, wsPath: node.wsPath }, tlsDomain: 'node.example.com', name: 'TLS 节点' });
assert.match(uri, /security=tls/);
assert.match(uri, /sni=node.example.com/);
});
test('ensureNodeSecrets fills node-level secrets per protocol', () => {
const ss = ensureNodeSecrets({ id: 'n1', protocol: 'shadowsocks-2022-aes128', topology: 'direct' });
assert.equal(Buffer.from(ss.ssServerKey, 'base64').length, 16);
assert.match(ss.wsPath, /^\//);
const hy2 = ensureNodeSecrets({ id: 'n2', protocol: 'hysteria2', topology: 'forward', exitProtocol: 'vless-tcp' });
assert.ok(hy2.exitCredentials.clientId);
assert.ok(hy2.exitCredentials.password);
const direct = ensureNodeSecrets({ id: 'n3', protocol: 'vless-reality-vision', topology: 'direct' });
assert.equal(direct.exitCredentials, undefined);
});
test('resolveBindAddress resolves per-stack and passes literal IPs through', async () => {
assert.equal(await resolveBindAddress('ipv4', { publicAddress: '203.0.113.10' }), '203.0.113.10');
assert.equal(await resolveBindAddress('ipv6', { publicAddressV6: '2001:db8::10' }), '2001:db8::10');
assert.equal(await resolveBindAddress('dual', { publicAddress: '203.0.113.10', publicAddressV6: '2001:db8::10' }), null);
assert.equal(await resolveBindAddress('ipv4', {}), null);
assert.equal(await resolveBindAddress('ipv6', {}), null);
assert.equal(await resolveBindAddress('ipv4', { publicAddress: 'localhost' }), '127.0.0.1');
await assert.rejects(() => resolveBindAddress('ipv4', { publicAddress: 'not a host!' }), /DNS 解析失败/);
});
test('builders prefer the resolved bindAddress for listen', () => {
const node = makeNode({ protocol: 'shadowsocks-2022-aes256', topology: 'direct', networkMode: 'ipv6' });
const clients = makeClients(node, ['cus_a']);
const server = { id: 'srv1', publicAddress: 'v4.example.com', publicAddressV6: 'v6.example.com' };
const resource = buildNodeResource({ resourceId: 'res_bind', tagPrefix: 'bind-test', node, nodeClients: clients,
server, bindAddress: '2001:db8::99' });
assert.equal(resource.inbounds[0].listen, '2001:db8::99');
});
+111 -52
View File
@@ -2,57 +2,93 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { entries, formatSubscription, makeSingBox, clashProxy } = require('../lib/subscriptions');
const { entries, nodeClientUri, formatSubscription, makeSingBox, clashProxy } = require('../lib/subscriptions');
test('formats active Reality and Shadowsocks entries, excluding exit and incomplete resources', () => {
const customer = { id:'cus-1' };
const reality = 'vless://00000000-0000-4000-8000-000000000001@[2001:db8::1]:443?encryption=none&security=reality&sni=www.tesla.com&fp=chrome&pbk=public-key&sid=abcdef&type=tcp&flow=xtls-rprx-vision#Reality';
const ss = `ss://${Buffer.from('2022-blake3-aes-128-gcm:secret').toString('base64url')}@example.com:1443#SS`;
const data = { deployments:[
{ customerId:customer.id, role:'relay', status:'active', clientUri:reality },
{ customerId:customer.id, role:'direct', status:'active', clientUri:ss },
{ customerId:customer.id, role:'exit', status:'active', clientUri:'should-not-export' },
{ customerId:customer.id, role:'relay', status:'failed', clientUri:'should-not-export' },
{ customerId:customer.id, role:'relay', status:'active', archived:true, clientUri:'should-not-export' }
] };
assert.equal(entries(data, customer.id).length, 2);
const proxy = clashProxy(data.deployments[0]);
// 最小可用数据集:节点 + 服务器 + 分配,entries() 现场生成客户端链接。
function makeData() {
const servers = [
{ id: 'srv_1', publicAddress: '2001:db8::1', tlsDomain: null },
{ id: 'srv_2', publicAddress: 'example.com', tlsDomain: 'entry.example.com' },
];
const nodes = [
{ id: 'nod_reality', name: 'Reality', protocol: 'vless-reality-vision', port: 443, serverId: 'srv_1',
networkMode: 'ipv4', topology: 'direct', status: 'active',
reality: { keyId: 'k1', serverName: 'www.tesla.com', destPort: 443 },
artifacts: { realityPublicKey: 'public-key' } },
{ id: 'nod_ss', name: 'SS', protocol: 'shadowsocks-2022-aes128', port: 1443, serverId: 'srv_2',
networkMode: 'ipv4', topology: 'direct', status: 'active', artifacts: {} },
{ id: 'nod_down', name: '故障节点', protocol: 'shadowsocks-2022-aes128', port: 1444, serverId: 'srv_2',
networkMode: 'ipv4', topology: 'direct', status: 'failed', artifacts: {} },
];
const nodeClients = [
{ nodeId: 'nod_reality', customerId: 'cus-1', status: 'active',
credentials: { clientId: '00000000-0000-4000-8000-000000000001', relayPassword: 'secret', shortId: 'abcdef' } },
{ nodeId: 'nod_ss', customerId: 'cus-1', status: 'active',
credentials: { clientId: '00000000-0000-4000-8000-000000000002', relayPassword: 'c2VjcmV0', shortId: '123456' } },
{ nodeId: 'nod_down', customerId: 'cus-1', status: 'active',
credentials: { clientId: '00000000-0000-4000-8000-000000000003', relayPassword: 'c2VjcmV0', shortId: '123456' } },
{ nodeId: 'nod_ss', customerId: 'cus-1', status: 'suspended',
credentials: { clientId: '00000000-0000-4000-8000-000000000004', relayPassword: 'c2VjcmV0', shortId: '123456' } },
];
return { servers, nodes, nodeClients };
}
test('formats active Reality and Shadowsocks entries, excluding failed nodes and suspended assignments', () => {
const data = makeData();
const items = entries(data, 'cus-1');
assert.equal(items.length, 2);
assert.ok(items[0].clientUri.startsWith('vless://'));
assert.ok(items[1].clientUri.startsWith('ss://'));
const proxy = clashProxy(items[0]);
assert.equal(proxy.server, '2001:db8::1');
assert.equal(proxy['reality-opts']['public-key'], 'public-key');
const config = JSON.parse(makeSingBox(entries(data, customer.id)));
const config = JSON.parse(makeSingBox(items));
assert.equal(config.outbounds[1].tls.reality.short_id, 'abcdef');
assert.equal(config.outbounds[2].method, '2022-blake3-aes-128-gcm');
assert.equal(formatSubscription(data, customer, 'auto', 'Mihomo').contentType, 'text/yaml; charset=utf-8');
assert.equal(formatSubscription(data, customer, 'auto', 'Other').contentType, 'text/plain; charset=utf-8');
assert.doesNotMatch(formatSubscription(data, customer, 'raw').body, /should-not-export/);
assert.equal(formatSubscription(data, { id: 'cus-1' }, 'auto', 'Mihomo').contentType, 'text/yaml; charset=utf-8');
assert.equal(formatSubscription(data, { id: 'cus-1' }, 'auto', 'Other').contentType, 'text/plain; charset=utf-8');
assert.doesNotMatch(formatSubscription(data, { id: 'cus-1' }, 'raw').body, /故障节点/);
});
test('nodeClientUri returns null for inactive nodes', () => {
const data = makeData();
const nc = data.nodeClients.find((item) => item.nodeId === 'nod_down');
assert.equal(nodeClientUri(data, nc), null);
});
test('TLS WebSocket exports valid Mihomo and sing-box TLS settings', () => {
const customer = { id:'cus-tls' };
const data = { deployments:[{ customerId:customer.id, role:'direct', status:'active', clientUri:
'vless://00000000-0000-4000-8000-000000000001@node.example.com:24444?encryption=none&security=tls&sni=node.example.com&type=ws&path=%2Ftest#TLS' }] };
const proxy = clashProxy(data.deployments[0]);
const data = makeData();
data.nodes.push({ id: 'nod_tls', name: 'TLS', protocol: 'vless-ws-tls', port: 24444, serverId: 'srv_2',
networkMode: 'ipv4', topology: 'direct', status: 'active', wsPath: '/test', artifacts: {} });
data.nodeClients.push({ nodeId: 'nod_tls', customerId: 'cus-tls', status: 'active',
credentials: { clientId: '00000000-0000-4000-8000-000000000001', relayPassword: 'secret', shortId: 'abcdef' } });
const items = entries(data, 'cus-tls');
assert.equal(items.length, 1);
const proxy = clashProxy(items[0]);
assert.equal(proxy.tls, true);
assert.equal(proxy.servername, 'node.example.com');
assert.equal(proxy.servername, 'entry.example.com');
assert.equal(proxy['reality-opts'], undefined);
const outbound = JSON.parse(makeSingBox(entries(data, customer.id))).outbounds[1];
const outbound = JSON.parse(makeSingBox(items)).outbounds[1];
assert.equal(outbound.tls.enabled, true);
assert.equal(outbound.tls.reality, undefined);
assert.equal(formatSubscription(data, customer, 'surge'), null);
assert.equal(formatSubscription(data, { id: 'cus-tls' }, 'surge'), null);
});
test('adding HY2 to an AnyTLS subscription keeps ALPN on one valid YAML line and route names clean', () => {
const data = { chains:[{ id:'any-route', name:'US-rak-CN2' }, { id:'hy-route', name:'hy2 cn2' }], deployments:[
{ chainId:'any-route', customerId:'55', role:'direct', status:'active',
clientUri:'anytls://secret@entry.example.com:48924?sni=entry.example.com#US-rak-CN2%20%C2%B7%20rak%20cn2%3A48924' },
{ chainId:'hy-route', customerId:'55', role:'direct', status:'active',
clientUri:'hysteria2://secret@entry.example.com:40003?sni=entry.example.com&alpn=h3#hy2%20cn2%20%C2%B7%20rak%20cn2%3A40003' }
] };
const customer = { id:'55' };
test('adding HY2 to an AnyTLS subscription keeps ALPN on one valid YAML line and node names clean', () => {
const data = makeData();
data.nodes.push(
{ id: 'nod_any', name: 'US-rak-CN2', protocol: 'anytls', port: 48924, serverId: 'srv_2',
networkMode: 'ipv4', topology: 'direct', status: 'active', artifacts: {} },
{ id: 'nod_hy', name: 'hy2 cn2', protocol: 'hysteria2', port: 40003, serverId: 'srv_2',
networkMode: 'ipv4', topology: 'direct', status: 'active', artifacts: {} });
for (const [nodeId, password] of [['nod_any', 'secret'], ['nod_hy', 'secret']]) {
data.nodeClients.push({ nodeId, customerId: '55', status: 'active',
credentials: { clientId: '00000000-0000-4000-8000-000000000001', relayPassword: password, shortId: 'abcdef' } });
}
const customer = { id: '55' };
const raw = formatSubscription(data, customer, 'raw').body;
assert.match(raw, /#US-rak-CN2\n/);
assert.match(raw, /#hy2%20cn2\n/);
assert.doesNotMatch(raw, /rak%20cn2%3A/);
const yaml = formatSubscription(data, customer, 'clash').body;
assert.match(yaml, /^ alpn: \["h3"\]$/m);
assert.match(yaml, /^proxy-groups:$/m);
@@ -61,25 +97,48 @@ test('adding HY2 to an AnyTLS subscription keeps ALPN on one valid YAML line and
assert.match(yaml, /^ - name: "hy2 cn2"$/m);
});
test('legacy VMess subscriptions also use the route name instead of a base64 label', () => {
const payload = { v:'2', ps:'old · entry:20000', add:'entry.example.com', port:'20000', id:'uuid', path:'/ws' };
const data = { chains:[{ id:'vmess-route', name:'VMess US' }], deployments:[{
chainId:'vmess-route', customerId:'vmess-customer', role:'direct', status:'active',
clientUri:`vmess://${Buffer.from(JSON.stringify(payload)).toString('base64')}`
}] };
const exported = entries(data, 'vmess-customer');
assert.equal(clashProxy(exported[0]).name, 'VMess US');
assert.match(formatSubscription(data, { id:'vmess-customer' }, 'clash').body, /^ - name: "VMess US"$/m);
});
test('Clash aliases only route names that collide with built-in groups or policies', () => {
const data = { chains:[{ id:'route-a', name:'节点选择' }, { id:'route-b', name:'DIRECT' }], deployments:[
{ chainId:'route-a', customerId:'one', role:'direct', status:'active', clientUri:'anytls://password@entry.example.com:443?sni=entry.example.com#old' },
{ chainId:'route-b', customerId:'one', role:'direct', status:'active', clientUri:'hysteria2://password@entry.example.com:444?sni=entry.example.com#old' }
] };
const yaml = formatSubscription(data, { id:'one' }, 'clash-smart').body;
test('Clash aliases only node names that collide with built-in groups or policies', () => {
const data = makeData();
data.nodes.push(
{ id: 'nod_a', name: '节点选择', protocol: 'anytls', port: 443, serverId: 'srv_2',
networkMode: 'ipv4', topology: 'direct', status: 'active', artifacts: {} },
{ id: 'nod_b', name: 'DIRECT', protocol: 'hysteria2', port: 444, serverId: 'srv_2',
networkMode: 'ipv4', topology: 'direct', status: 'active', artifacts: {} });
for (const nodeId of ['nod_a', 'nod_b']) {
data.nodeClients.push({ nodeId, customerId: 'one', status: 'active',
credentials: { clientId: '00000000-0000-4000-8000-000000000001', relayPassword: 'password', shortId: 'abcdef' } });
}
const yaml = formatSubscription(data, { id: 'one' }, 'clash-smart').body;
assert.match(yaml, /^ - name: "节点选择 \(节点\)"$/m);
assert.match(yaml, /^ - name: "DIRECT \(节点\)"$/m);
assert.match(yaml, /^ - "节点选择 \(节点\)"$/m);
assert.match(formatSubscription(data, { id:'one' }, 'raw').body, /#%E8%8A%82%E7%82%B9%E9%80%89%E6%8B%A9/);
assert.match(formatSubscription(data, { id: 'one' }, 'raw').body, /#%E8%8A%82%E7%82%B9%E9%80%89%E6%8B%A9/);
});
test('SS2022 client links carry "<server PSK>:<user PSK>" for the EIH identity header', () => {
const data = makeData();
// 2022 node with a server PSK: link password must be colon-joined so the
// sing-box multi-user server can attribute the connection (EIH).
data.nodes.push({ id: 'nod_ss2022', name: 'SS2022', protocol: 'shadowsocks-2022-aes128',
port: 1445, serverId: 'srv_2', networkMode: 'ipv4', topology: 'direct', status: 'active',
ssServerKey: 'c2VydmVyLW1haW4tcHNr', artifacts: {} });
// Classic AEAD multi-user needs no EIH: user password alone.
data.nodes.push({ id: 'nod_ssold', name: 'SSold', protocol: 'shadowsocks-aes128-gcm',
port: 1446, serverId: 'srv_2', networkMode: 'ipv4', topology: 'direct', status: 'active',
ssServerKey: 'c2VydmVyLW1haW4tcHNr', artifacts: {} });
for (const nodeId of ['nod_ss2022', 'nod_ssold']) {
data.nodeClients.push({ nodeId, customerId: 'cus-eih', status: 'active',
credentials: { clientId: '00000000-0000-4000-8000-000000000001', relayPassword: 'dXNlci1wYXNzd29yZA', shortId: 'abcdef' } });
}
const decodeSs = (uri) => {
const payload = Buffer.from(new URL(uri).username, 'base64url').toString('utf8');
return payload.slice(payload.indexOf(':') + 1);
};
const items = entries(data, 'cus-eih');
assert.equal(items.length, 2);
assert.equal(decodeSs(items[0].clientUri), 'c2VydmVyLW1haW4tcHNr:dXNlci1wYXNzd29yZA');
assert.equal(decodeSs(items[1].clientUri), 'dXNlci1wYXNzd29yZA');
// The sing-box subscription outbound inherits the joined password verbatim.
const config = JSON.parse(makeSingBox(items));
assert.equal(config.outbounds[1].password, 'c2VydmVyLW1haW4tcHNr:dXNlci1wYXNzd29yZA');
});