3x-ui 式共享节点改造:nodes 状态机 + nodeClients 各自凭据
- 移除 VMess 协议 - VLESS/SS2022 按用户流量统计(AnyTLS 日志解析,SS 节点级) - sing-box v2ray_api stats 支持 - 内存优化:面板 128M,agent 64M,sing-box 64M
This commit is contained in:
+92
-133
@@ -7,7 +7,7 @@ const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const dgram = require('node:dgram');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { parseX25519, parseUsageStats, combinedConfig, udpPortsForPid, systemInfo } = require('../agent/agent');
|
||||
const { parseUsageStats, combinedSingBoxConfig, applyResource, queryUsage, udpPortsForPid, systemInfo } = require('../agent/agent');
|
||||
const { redactSecrets } = require('../lib/redact');
|
||||
|
||||
test('Agent reports usable memory and system disk without reading per-connection state', () => {
|
||||
@@ -17,46 +17,34 @@ test('Agent reports usable memory and system disk without reading per-connection
|
||||
if (info.diskTotal !== null) assert.ok(info.diskTotal >= info.diskAvailable && info.diskAvailable >= 0);
|
||||
});
|
||||
|
||||
test('parses both current and older Xray x25519 output without including private keys in errors', () => {
|
||||
const current = parseX25519('PrivateKey: current_private\nPassword (PublicKey): current_public\nHash32: ignored');
|
||||
assert.deepEqual(current, { privateKey:'current_private', publicKey:'current_public' });
|
||||
const older = parseX25519('Private key: older_private\nPublic key: older_public');
|
||||
assert.equal(older.publicKey, 'older_public');
|
||||
assert.throws(() => parseX25519('PrivateKey: very_secret\nUnknown: broken'), (error) => !error.message.includes('very_secret'));
|
||||
assert.equal(redactSecrets('Unable to parse: PrivateKey: very_secret Password (PublicKey): public'),
|
||||
'Unable to parse: PrivateKey: [REDACTED] Password (PublicKey): public');
|
||||
});
|
||||
|
||||
test('Xray candidate configuration has a JSON extension at validation time', () => {
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-agent-test-'));
|
||||
test('realityKey generates an X25519 pair with Node crypto and caches it', () => {
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-reality-'));
|
||||
try {
|
||||
const fakeXray = path.join(temp, 'fake-xray');
|
||||
const configDir = path.join(temp, 'xray');
|
||||
fs.mkdirSync(path.join(configDir, 'resources'), { recursive:true });
|
||||
fs.writeFileSync(fakeXray, '#!/bin/sh\nprintf "%s\\n" "$4" > "' + path.join(temp, 'argument') + '"\nexit 41\n', { mode:0o755 });
|
||||
// Module paths are read at import time; test the real activation logic in a child.
|
||||
const keyFile = path.join(temp, 'keys.json');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const result = spawnSync(process.execPath, ['-e', `require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).activateConfig()`], {
|
||||
env:{ ...process.env, NG_CONFIG_DIR:configDir, NG_XRAY_BIN:fakeXray }, encoding:'utf8'
|
||||
const script = `const { realityKey } = require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))});
|
||||
const a = realityKey('test-key');
|
||||
const b = realityKey('test-key');
|
||||
if (a.privateKey !== b.privateKey || a.publicKey !== b.publicKey) throw new Error('key was not cached');
|
||||
for (const k of [a.privateKey, a.publicKey]) if (!/^[A-Za-z0-9_-]{43}$/.test(k)) throw new Error('bad key format');
|
||||
process.stdout.write('OK');`;
|
||||
const run = spawnSync(process.execPath, ['-e', script], {
|
||||
env: { ...process.env, NG_KEY_FILE: keyFile }, encoding: 'utf8'
|
||||
});
|
||||
assert.notEqual(result.status, 0);
|
||||
const candidate = fs.readFileSync(path.join(temp, 'argument'), 'utf8').trim();
|
||||
assert.equal(candidate, path.join(configDir, 'config.json.candidate.json'));
|
||||
assert.equal(JSON.parse(fs.readFileSync(candidate, 'utf8')).inbounds[0].tag, 'api-in');
|
||||
} finally { fs.rmSync(temp, { recursive:true, force:true }); }
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
assert.equal(run.stdout, 'OK');
|
||||
} finally { fs.rmSync(temp, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('old Hysteria 2 resources are repaired without rotating authentication or changing saved resources', () => {
|
||||
const legacy = { id:'hy2-entry', inbounds:[{ tag:'hy2-in', protocol:'hysteria', port:34537,
|
||||
settings:{ version:2, users:[{ auth:'unchanged-password', email:'ng:customer' }] },
|
||||
streamSettings:{ method:'hysteria', security:'tls', hysteriaSettings:{ version:2 } } }] };
|
||||
const before = JSON.stringify(legacy);
|
||||
const inbound = combinedConfig([legacy]).inbounds[1];
|
||||
assert.equal(inbound.streamSettings.network, 'hysteria');
|
||||
assert.equal(inbound.settings.clients[0].auth, 'unchanged-password');
|
||||
assert.deepEqual(inbound.settings.clients, inbound.settings.users);
|
||||
assert.equal(JSON.stringify(legacy), before);
|
||||
assert.throws(() => combinedConfig([{ inbounds:[{ protocol:'hysteria', settings:{ version:2 } }] }]), /认证账户/);
|
||||
test('applyResource rejects Xray resources and unprotected Hysteria 2 inbounds', async () => {
|
||||
const { applyResource } = require('../agent/agent');
|
||||
await assert.rejects(
|
||||
applyResource({ resource: { id: 'xray-resource-1', engine: 'xray', inbounds: [] } }),
|
||||
/只支持 sing-box/);
|
||||
await assert.rejects(
|
||||
applyResource({ resource: { id: 'hy2-noauth-1', engine: 'sing-box',
|
||||
inbounds: [{ type: 'hysteria2', tag: 'hy2', users: [] }] } }),
|
||||
/认证账户/);
|
||||
});
|
||||
|
||||
test('UDP readiness inspection finds only ports owned by the target process', async () => {
|
||||
@@ -69,81 +57,38 @@ test('UDP readiness inspection finds only ports owned by the target process', as
|
||||
} finally { socket.close(); other.kill(); }
|
||||
});
|
||||
|
||||
test('Agent restart preserves a healthy Xray process when configuration is unchanged', () => {
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-no-restart-'));
|
||||
try {
|
||||
const configDir = path.join(temp, 'xray');
|
||||
const binDir = path.join(temp, 'bin');
|
||||
fs.mkdirSync(path.join(configDir, 'resources'), { recursive:true });
|
||||
fs.mkdirSync(binDir);
|
||||
const resource = { id:'entry-resource', meta:{ kind:'direct', metricsTag:'entry-in' },
|
||||
inbounds:[], outbounds:[], routingRules:[] };
|
||||
fs.writeFileSync(path.join(configDir, 'resources', 'entry.json'), JSON.stringify(resource));
|
||||
const agentFile = path.join(__dirname, '../agent/agent.js');
|
||||
const script = `const agent=require(${JSON.stringify(agentFile)});const fs=require('node:fs');fs.writeFileSync(${JSON.stringify(path.join(configDir,'config.json'))}, JSON.stringify(agent.combinedConfig([${JSON.stringify(resource)}]), null, 2)+'\\n');agent.activateConfig()`;
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const serviceCommand = fs.existsSync('/run/systemd/system') ? 'systemctl' : 'rc-service';
|
||||
fs.writeFileSync(path.join(binDir, serviceCommand), '#!/bin/sh\nexit 0\n', { mode:0o755 });
|
||||
const fakeXray = path.join(binDir, 'xray');
|
||||
fs.writeFileSync(fakeXray, '#!/bin/sh\nexit 91\n', { mode:0o755 });
|
||||
const result = spawnSync(process.execPath, ['-e', script], {
|
||||
env:{ ...process.env, PATH:`${binDir}:${process.env.PATH}`, NG_CONFIG_DIR:configDir, NG_XRAY_BIN:fakeXray }, encoding:'utf8'
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(fs.existsSync(path.join(configDir, 'config.json.candidate.json')), false);
|
||||
} finally { fs.rmSync(temp, { recursive:true, force:true }); }
|
||||
});
|
||||
|
||||
test('IP observations exclude exit hop addresses and leave partial log lines unread', () => {
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-observations-'));
|
||||
try {
|
||||
const dir = path.join(temp, 'config');
|
||||
fs.mkdirSync(path.join(dir, 'resources'), { recursive:true });
|
||||
fs.writeFileSync(path.join(dir, 'resources', 'relay.json'), JSON.stringify({ id:'relay', meta:{ kind:'relay', metricsTag:'entry-tag', customerId:'customer-a' } }));
|
||||
fs.writeFileSync(path.join(dir, 'resources', 'exit.json'), JSON.stringify({ id:'exit', meta:{ kind:'exit', metricsTag:'exit-tag', customerId:'customer-a' } }));
|
||||
const accessLog = path.join(temp, 'access.log');
|
||||
const cursorFile = path.join(temp, 'cursor.json');
|
||||
const entry = '2026/09/23 from tcp:198.51.100.40:1234 accepted tcp:example.com:443 [entry-tag -> direct]';
|
||||
const exit = '2026/09/23 from tcp:192.0.2.20:3344 accepted tcp:example.com:443 [exit-tag -> direct]';
|
||||
fs.writeFileSync(accessLog, `${entry}\n${exit}\n${entry.slice(0, 28)}`);
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const script = `process.stdout.write(JSON.stringify(require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).readObservations()))`;
|
||||
const run = spawnSync(process.execPath, ['-e', script], {
|
||||
env:{ ...process.env, NG_CONFIG_DIR:dir, NG_XRAY_ACCESS_LOG:accessLog, NG_ACCESS_CURSOR:cursorFile }, encoding:'utf8'
|
||||
});
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
const result = JSON.parse(run.stdout);
|
||||
assert.deepEqual(result.observations, [{ customerId:'customer-a', ip:'198.51.100.40' }]);
|
||||
assert.equal(result.offset, Buffer.byteLength(`${entry}\n${exit}\n`));
|
||||
assert.equal(fs.existsSync(cursorFile), false);
|
||||
} finally { fs.rmSync(temp, { recursive:true, force:true }); }
|
||||
});
|
||||
|
||||
test('Xray JSON counters are matched by name, including quoted values in either order', () => {
|
||||
const resources = [{ id:'resource-1', meta:{ kind:'direct', metricsTag:'ng-in' } }];
|
||||
test('sing-box JSON counters are matched by name, inbound and per-user, including quoted values', () => {
|
||||
const output = JSON.stringify({ stat:[
|
||||
{ name:'inbound>>>other>>>traffic>>>downlink', value:'8000' },
|
||||
{ name:'inbound>>>ng-in>>>traffic>>>downlink', value:'2048' },
|
||||
{ name:'inbound>>>ng-in>>>traffic>>>uplink', value:'128' }
|
||||
{ name:'inbound>>>ng-in>>>traffic>>>uplink', value:'128' },
|
||||
{ name:'user>>>ng:cus-a>>>traffic>>>uplink', value:'10' },
|
||||
{ name:'user>>>ng:cus-a>>>traffic>>>downlink', value:'20' },
|
||||
{ name:'user>>>unrelated>>>traffic>>>uplink', value:'5' }
|
||||
] });
|
||||
assert.deepEqual(parseUsageStats(output, resources), [{ resourceId:'resource-1', uplink:128, downlink:2048 }]);
|
||||
assert.deepEqual(parseUsageStats('{"stat":[]}', resources), []);
|
||||
assert.deepEqual(parseUsageStats('{}', resources), []);
|
||||
assert.deepEqual(parseUsageStats(JSON.stringify({ stat:[
|
||||
{ name:'inbound>>>unrelated>>>traffic>>>uplink', value:'bad' },
|
||||
{ name:'inbound>>>ng-in>>>traffic>>>uplink', value:'42' }
|
||||
] }), resources), [{ resourceId:'resource-1', uplink:42, downlink:0 }]);
|
||||
assert.deepEqual(parseUsageStats(output), {
|
||||
inbounds: [
|
||||
{ tag:'other', uplink:0, downlink:8000 },
|
||||
{ tag:'ng-in', uplink:128, downlink:2048 },
|
||||
],
|
||||
users: [
|
||||
{ name:'ng:cus-a', uplink:10, downlink:20 },
|
||||
{ name:'unrelated', uplink:5, downlink:0 },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(parseUsageStats('{"stat":[]}'), { inbounds:[], users:[] });
|
||||
assert.deepEqual(parseUsageStats('{}'), { inbounds:[], users:[] });
|
||||
assert.deepEqual(parseUsageStats(JSON.stringify({ stat:[
|
||||
{ name:'inbound>>>ng-in>>>traffic>>>uplink' },
|
||||
{ name:'inbound>>>ng-in>>>traffic>>>downlink', value:'1024' }
|
||||
] }), resources), [{ resourceId:'resource-1', uplink:0, downlink:1024 }]);
|
||||
] })), { inbounds:[{ tag:'ng-in', uplink:0, downlink:1024 }], users:[] });
|
||||
assert.throws(() => parseUsageStats(JSON.stringify({ stat:[
|
||||
{ name:'inbound>>>ng-in>>>traffic>>>uplink', value:'undefined' }
|
||||
] }), resources), /入口计数异常/);
|
||||
{ name:'user>>>ng:cus-a>>>traffic>>>uplink', value:'undefined' }
|
||||
] })), /入口计数异常/);
|
||||
assert.throws(() => parseUsageStats(JSON.stringify({ stat:[
|
||||
{ name:'inbound>>>ng-in>>>traffic>>>uplink', value:'-1' }
|
||||
] }), resources), /入口计数异常(负数)/);
|
||||
assert.throws(() => parseUsageStats('not json', resources));
|
||||
] })), /入口计数异常(负数)/);
|
||||
assert.throws(() => parseUsageStats('not json'));
|
||||
});
|
||||
|
||||
test('Agent does not advertise an untrusted self-signed node certificate as ready', () => {
|
||||
@@ -175,35 +120,39 @@ test('Agent does not advertise an untrusted self-signed node certificate as read
|
||||
} finally { fs.rmSync(temp, { recursive:true, force:true }); }
|
||||
});
|
||||
|
||||
test('Agent reads cumulative counters without resetting Xray', () => {
|
||||
test('Agent reads cumulative sing-box counters without resetting them', () => {
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-usage-'));
|
||||
try {
|
||||
const dir = path.join(temp, 'xray');
|
||||
const dir = path.join(temp, 'agent');
|
||||
fs.mkdirSync(path.join(dir, 'resources'), { recursive:true });
|
||||
fs.writeFileSync(path.join(dir, 'resources', 'entry.json'), JSON.stringify({ id:'entry', meta:{ kind:'direct', metricsTag:'entry-in' } }));
|
||||
const fakeXray = path.join(temp, 'xray-bin');
|
||||
fs.writeFileSync(path.join(dir, 'resources', 'entry.json'), JSON.stringify({ id:'entry', meta:{ kind:'node', nodeId:'nod_1', metricsTag:'entry-in' } }));
|
||||
const helper = path.join(temp, 'stats');
|
||||
const argumentsFile = path.join(temp, 'args');
|
||||
fs.writeFileSync(fakeXray, `#!/bin/sh\nprintf '%s\\n' "$@" > ${JSON.stringify(argumentsFile)}\nprintf '{"stat":[{"name":"inbound>>>entry-in>>>traffic>>>uplink","value":"12"},{"name":"inbound>>>entry-in>>>traffic>>>downlink","value":"34"}]}'\n`, { mode:0o755 });
|
||||
fs.writeFileSync(helper, `#!/bin/sh\nprintf '%s\\n' "$@" > ${JSON.stringify(argumentsFile)}\nprintf '{"stat":[{"name":"user>>>ng:cus-a>>>traffic>>>uplink","value":"7"},{"name":"inbound>>>entry-in>>>traffic>>>uplink","value":"12"},{"name":"inbound>>>entry-in>>>traffic>>>downlink","value":"34"}]}'\n`, { mode:0o755 });
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const script = `process.stdout.write(JSON.stringify(require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).queryUsage()))`;
|
||||
const run = spawnSync(process.execPath, ['-e', script], { env:{ ...process.env, NG_CONFIG_DIR:dir, NG_XRAY_BIN:fakeXray }, encoding:'utf8' });
|
||||
const run = spawnSync(process.execPath, ['-e', script], { env:{ ...process.env, NG_CONFIG_DIR:dir, NG_SINGBOX_STATS_BIN:helper }, encoding:'utf8' });
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
assert.deepEqual(JSON.parse(run.stdout).samples, [{ resourceId:'entry', uplink:12, downlink:34, epoch:'xray:unknown' }]);
|
||||
assert.deepEqual(JSON.parse(run.stdout).samples, [
|
||||
{ kind:'inbound', id:'nod_1', uplink:12, downlink:34, epoch:'sing-box:unknown' },
|
||||
{ kind:'user', id:'cus-a', uplink:7, downlink:0, epoch:'sing-box:unknown' },
|
||||
]);
|
||||
assert.doesNotMatch(fs.readFileSync(argumentsFile, 'utf8'), /reset/);
|
||||
} finally { fs.rmSync(temp, { recursive:true, force:true }); }
|
||||
});
|
||||
|
||||
test('adding an Xray resource submits old ingress counters before restarting the engine', async () => {
|
||||
test('adding a sing-box resource submits old ingress counters before restarting the engine', async () => {
|
||||
const http = require('node:http');
|
||||
const { spawn } = require('node:child_process');
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-flush-'));
|
||||
const dir = path.join(temp, 'xray');
|
||||
const dir = path.join(temp, 'agent');
|
||||
const bin = path.join(temp, 'bin');
|
||||
fs.mkdirSync(path.join(dir, 'resources'), { recursive:true });
|
||||
fs.mkdirSync(bin);
|
||||
const old = { id:'old-resource', meta:{ kind:'direct', metricsTag:'old-in' }, inbounds:[], outbounds:[], routingRules:[] };
|
||||
const old = { id:'old-resource', engine:'sing-box', meta:{ kind:'node', nodeId:'nod_1', metricsTag:'old-in' }, inbounds:[], outbounds:[], routingRules:[] };
|
||||
fs.writeFileSync(path.join(dir, 'resources', 'old-resource.json'), JSON.stringify(old));
|
||||
fs.writeFileSync(path.join(bin, 'xray'), '#!/bin/sh\nif [ "$1" = api ]; then printf \'{"stat":[{"name":"inbound>>>old-in>>>traffic>>>uplink","value":"123"},{"name":"inbound>>>old-in>>>traffic>>>downlink","value":"456"}]}\'; fi\n', { mode:0o755 });
|
||||
fs.writeFileSync(path.join(bin, 'stats'), '#!/bin/sh\nprintf \'{"stat":[{"name":"inbound>>>old-in>>>traffic>>>uplink","value":"123"},{"name":"inbound>>>old-in>>>traffic>>>downlink","value":"456"}]}\'\n', { mode:0o755 });
|
||||
fs.writeFileSync(path.join(bin, 'singbox'), '#!/bin/sh\nexit 0\n', { mode:0o755 });
|
||||
const manager = fs.existsSync('/run/systemd/system') ? 'systemctl' : 'rc-service';
|
||||
fs.writeFileSync(path.join(bin, manager), `#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(path.join(temp, 'service-commands'))}\nexit 0\n`, { mode:0o755 });
|
||||
const seen = [];
|
||||
@@ -216,9 +165,11 @@ test('adding an Xray resource submits old ingress counters before restarting the
|
||||
});
|
||||
try {
|
||||
await new Promise((resolve) => controller.listen(0, '127.0.0.1', resolve));
|
||||
const script = `require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).applyResource({resource:${JSON.stringify({ id:'new-resource', meta:{ kind:'direct', metricsTag:'new-in' }, inbounds:[], outbounds:[], routingRules:[] })}}).catch(error=>{console.error(error);process.exitCode=1})`;
|
||||
const script = `require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).applyResource({resource:${JSON.stringify({ id:'new-resource', engine:'sing-box', meta:{ kind:'node', nodeId:'nod_1', metricsTag:'new-in' }, inbounds:[], outbounds:[], routingRules:[] })}}).catch(error=>{console.error(error);process.exitCode=1})`;
|
||||
const child = spawn(process.execPath, ['-e', script], { env:{ ...process.env,
|
||||
NG_CONFIG_DIR:dir, NG_XRAY_BIN:path.join(bin, 'xray'), NG_CONTROLLER:`http://127.0.0.1:${controller.address().port}`,
|
||||
NG_CONFIG_DIR:dir, NG_SINGBOX_BIN:path.join(bin, 'singbox'), NG_SINGBOX_STATS_BIN:path.join(bin, 'stats'),
|
||||
NG_SINGBOX_CONFIG:path.join(temp, 'sing-box.json'),
|
||||
NG_CONTROLLER:`http://127.0.0.1:${controller.address().port}`,
|
||||
NG_AGENT_KEY:'test-agent-key', PATH:`${bin}:${process.env.PATH}` }, stdio:['ignore','pipe','pipe'] });
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
@@ -226,19 +177,23 @@ test('adding an Xray resource submits old ingress counters before restarting the
|
||||
assert.equal(exit, 0, stderr);
|
||||
assert.equal(seen.length, 1);
|
||||
assert.equal(seen[0].path, '/api/agent/usage');
|
||||
assert.deepEqual(seen[0].body.samples.map(({ resourceId, uplink, downlink }) => ({ resourceId, uplink, downlink })),
|
||||
[{ resourceId:'old-resource', uplink:123, downlink:456 }]);
|
||||
assert.deepEqual(seen[0].body.samples.map(({ kind, id, uplink, downlink }) => ({ kind, id, uplink, downlink })),
|
||||
[{ kind:'inbound', id:'nod_1', uplink:123, downlink:456 }]);
|
||||
assert.equal(seen[0].newResourceAlreadyWritten, false);
|
||||
assert.match(fs.readFileSync(path.join(temp, 'service-commands'), 'utf8'), /restart/);
|
||||
const flush = spawn(process.execPath, [path.join(__dirname, '../agent/agent.js'), 'flush-usage'], { env:{ ...process.env,
|
||||
NG_CONFIG_DIR:dir, NG_XRAY_BIN:path.join(bin, 'xray'), NG_CONTROLLER:`http://127.0.0.1:${controller.address().port}`,
|
||||
NG_CONFIG_DIR:dir, NG_SINGBOX_BIN:path.join(bin, 'singbox'), NG_SINGBOX_STATS_BIN:path.join(bin, 'stats'),
|
||||
NG_SINGBOX_CONFIG:path.join(temp, 'sing-box.json'),
|
||||
NG_CONTROLLER:`http://127.0.0.1:${controller.address().port}`,
|
||||
NG_AGENT_KEY:'test-agent-key', PATH:`${bin}:${process.env.PATH}` }, stdio:'ignore' });
|
||||
assert.equal(await new Promise((resolve) => flush.on('exit', resolve)), 0);
|
||||
assert.equal(seen[1].path, '/api/agent/usage');
|
||||
fs.writeFileSync(path.join(bin, 'xray'), '#!/bin/sh\nif [ "$1" = api ]; then printf \'{"stat":[{"name":"inbound>>>old-in>>>traffic>>>uplink","value":"invalid"}]}\'; fi\n', { mode:0o755 });
|
||||
const badScript = `require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).applyResource({resource:${JSON.stringify({ id:'third-resource', meta:{ kind:'direct', metricsTag:'third-in' }, inbounds:[], outbounds:[], routingRules:[] })}}).catch(error=>{console.error(error);process.exitCode=1})`;
|
||||
fs.writeFileSync(path.join(bin, 'stats'), '#!/bin/sh\nprintf \'{"stat":[{"name":"inbound>>>old-in>>>traffic>>>uplink","value":"invalid"}]}\'\n', { mode:0o755 });
|
||||
const badScript = `require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).applyResource({resource:${JSON.stringify({ id:'third-resource', engine:'sing-box', meta:{ kind:'node', nodeId:'nod_1', metricsTag:'third-in' }, inbounds:[], outbounds:[], routingRules:[] })}}).catch(error=>{console.error(error);process.exitCode=1})`;
|
||||
const broken = spawn(process.execPath, ['-e', badScript], { env:{ ...process.env,
|
||||
NG_CONFIG_DIR:dir, NG_XRAY_BIN:path.join(bin, 'xray'), NG_CONTROLLER:`http://127.0.0.1:${controller.address().port}`,
|
||||
NG_CONFIG_DIR:dir, NG_SINGBOX_BIN:path.join(bin, 'singbox'), NG_SINGBOX_STATS_BIN:path.join(bin, 'stats'),
|
||||
NG_SINGBOX_CONFIG:path.join(temp, 'sing-box.json'),
|
||||
NG_CONTROLLER:`http://127.0.0.1:${controller.address().port}`,
|
||||
NG_AGENT_KEY:'test-agent-key', PATH:`${bin}:${process.env.PATH}` }, stdio:'ignore' });
|
||||
assert.equal(await new Promise((resolve) => broken.on('exit', resolve)), 1);
|
||||
assert.equal(fs.existsSync(path.join(dir, 'resources', 'third-resource.json')), false);
|
||||
@@ -248,42 +203,46 @@ test('adding an Xray resource submits old ingress counters before restarting the
|
||||
}
|
||||
});
|
||||
|
||||
test('sing-box config isolates AnyTLS from Xray and meters each entry once', () => {
|
||||
const { combinedConfig, combinedSingBoxConfig } = require('../agent/agent');
|
||||
const entry = { id:'entry', engine:'sing-box', meta:{ kind:'direct', customerId:'customer-a', metricsTag:'any-in' },
|
||||
test('sing-box config carries AnyTLS entries and meters each entry once', () => {
|
||||
const { combinedSingBoxConfig } = require('../agent/agent');
|
||||
const entry = { id:'entry', engine:'sing-box', meta:{ kind:'node', nodeId:'nod_1', metricsTag:'any-in' },
|
||||
inbounds:[{ type:'anytls', tag:'any-in', listen:'0.0.0.0', listen_port:23000,
|
||||
users:[{ name:'ng:customer-a', password:'secret' }], tls:{ enabled:true, server_name:'entry.example.com',
|
||||
certificate_path:'/etc/nexusgate/tls/entry.example.com/fullchain.pem', key_path:'/etc/nexusgate/tls/entry.example.com/privkey.pem' } }],
|
||||
outbounds:[{ type:'direct', tag:'any-direct' }], routingRules:[{ inbound:['any-in'], action:'route', outbound:'any-direct' }] };
|
||||
const xray = combinedConfig([entry]);
|
||||
assert.equal(xray.inbounds.length, 1);
|
||||
const sing = combinedSingBoxConfig([entry]);
|
||||
assert.equal(sing.inbounds[0].tag, 'any-in');
|
||||
assert.deepEqual(sing.experimental.v2ray_api.stats.inbounds, ['any-in']);
|
||||
// 按用户统计需要显式列出用户名
|
||||
assert.deepEqual(sing.experimental.v2ray_api.stats.users, ['ng:customer-a']);
|
||||
assert.equal(sing.route.rules[0].outbound, 'any-direct');
|
||||
assert.deepEqual(parseUsageStats(JSON.stringify({ stat:[
|
||||
{ name:'inbound>>>any-in>>>traffic>>>uplink', value:'10' },
|
||||
{ name:'inbound>>>any-in>>>traffic>>>downlink', value:'90' } ] }), [entry]),
|
||||
[{ resourceId:'entry', uplink:10, downlink:90 }]);
|
||||
{ name:'inbound>>>any-in>>>traffic>>>downlink', value:'90' },
|
||||
{ name:'user>>>ng:customer-a>>>traffic>>>uplink', value:'5' } ] })),
|
||||
{ inbounds:[{ tag:'any-in', uplink:10, downlink:90 }], users:[{ name:'ng:customer-a', uplink:5, downlink:0 }] });
|
||||
});
|
||||
|
||||
test('Agent queries sing-box counters with its own helper and never resets them', () => {
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nexusgate-singbox-usage-'));
|
||||
try {
|
||||
const dir = path.join(temp, 'xray');
|
||||
const dir = path.join(temp, 'agent');
|
||||
fs.mkdirSync(path.join(dir, 'resources'), { recursive:true });
|
||||
fs.writeFileSync(path.join(dir, 'resources', 'anytls.json'), JSON.stringify({
|
||||
id:'anytls', engine:'sing-box', meta:{ kind:'direct', metricsTag:'any-in' }
|
||||
id:'anytls', engine:'sing-box', meta:{ kind:'node', nodeId:'nod_1', metricsTag:'any-in' }
|
||||
}));
|
||||
const helper = path.join(temp, 'stats');
|
||||
const argumentsFile = path.join(temp, 'args');
|
||||
fs.writeFileSync(helper, `#!/bin/sh\nprintf '%s\\n' "$@" > ${JSON.stringify(argumentsFile)}\nprintf '{"stat":[{"name":"inbound>>>any-in>>>traffic>>>uplink","value":"1024"},{"name":"inbound>>>any-in>>>traffic>>>downlink","value":"4096"}]}'\n`, { mode:0o755 });
|
||||
fs.writeFileSync(helper, `#!/bin/sh\nprintf '%s\\n' "$@" > ${JSON.stringify(argumentsFile)}\nprintf '{"stat":[{"name":"inbound>>>any-in>>>traffic>>>uplink","value":"1024"},{"name":"inbound>>>any-in>>>traffic>>>downlink","value":"4096"},{"name":"user>>>ng:cus-a>>>traffic>>>uplink","value":"7"}]}'\n`, { mode:0o755 });
|
||||
const script = `process.stdout.write(JSON.stringify(require(${JSON.stringify(path.join(__dirname, '../agent/agent.js'))}).queryUsage()))`;
|
||||
const run = require('node:child_process').spawnSync(process.execPath, ['-e', script], {
|
||||
env:{ ...process.env, NG_CONFIG_DIR:dir, NG_SINGBOX_STATS_BIN:helper, NG_XRAY_BIN:'/nonexistent/xray' }, encoding:'utf8'
|
||||
env:{ ...process.env, NG_CONFIG_DIR:dir, NG_SINGBOX_STATS_BIN:helper }, encoding:'utf8'
|
||||
});
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
assert.deepEqual(JSON.parse(run.stdout).samples, [{ resourceId:'anytls', uplink:1024, downlink:4096, epoch:'sing-box:unknown' }]);
|
||||
assert.deepEqual(JSON.parse(run.stdout).samples, [
|
||||
{ kind:'inbound', id:'nod_1', uplink:1024, downlink:4096, epoch:'sing-box:unknown' },
|
||||
{ kind:'user', id:'cus-a', uplink:7, downlink:0, epoch:'sing-box:unknown' },
|
||||
]);
|
||||
assert.deepEqual(fs.readFileSync(argumentsFile, 'utf8').trim().split('\n'), ['--server=127.0.0.1:10086']);
|
||||
} finally { fs.rmSync(temp, { recursive:true, force:true }); }
|
||||
});
|
||||
@@ -294,7 +253,7 @@ test('AnyTLS IP observations require matching authenticated session and preserve
|
||||
const dir = path.join(temp, 'config');
|
||||
fs.mkdirSync(path.join(dir, 'resources'), { recursive:true });
|
||||
fs.writeFileSync(path.join(dir, 'resources', 'entry.json'), JSON.stringify({ id:'entry', engine:'sing-box',
|
||||
meta:{ kind:'relay', metricsTag:'any-in', customerId:'customer-a' } }));
|
||||
meta:{ kind:'node', nodeId:'nod_1', metricsTag:'any-in' } }));
|
||||
const file = path.join(temp, 'sing-box.log');
|
||||
const source = '+0000 2026-09-23 18:00:00 INFO [31200 0ms] inbound/anytls[any-in]: inbound connection from 198.51.100.12:50000';
|
||||
const authenticated = '+0000 2026-09-23 18:00:01 INFO [31200 1ms] inbound/anytls[any-in]: [ng:customer-a] inbound connection to example.com:443';
|
||||
|
||||
+188
-150
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user