- 移除 VMess 协议 - VLESS/SS2022 按用户流量统计(AnyTLS 日志解析,SS 节点级) - sing-box v2ray_api stats 支持 - 内存优化:面板 128M,agent 64M,sing-box 64M
203 lines
12 KiB
JavaScript
203 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const {
|
|
newNodeClientCredentials, ensureNodeSecrets, nodeEmail,
|
|
buildNodeResource, buildNodeExitResource, buildClientUri,
|
|
validateEntryProtocol, validateProtocolPair, resolveBindAddress,
|
|
} = require('../lib/protocols');
|
|
|
|
const entryServer = { id: 'srv_entry', publicAddress: 'entry.example.com', tlsDomain: 'node.example.com' };
|
|
const exitServer = { id: 'srv_exit', publicAddress: 'exit.example.com' };
|
|
|
|
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', () => {
|
|
assert.throws(() => validateProtocolPair('nonexistent', 'shadowsocks-2022-aes128'), /Unsupported relay ingress/);
|
|
assert.doesNotThrow(() => validateProtocolPair('anytls', 'vless-tcp'));
|
|
});
|
|
|
|
test('AnyTLS node runs in sing-box with per-user names and a VLESS exit', () => {
|
|
const { makeClash, makeSingBox, makeSurge } = require('../lib/subscriptions');
|
|
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: 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);
|
|
});
|
|
|
|
test('Hysteria 2 node carries multiple clients and emits importable credentials', () => {
|
|
const { makeClash, makeSingBox } = require('../lib/subscriptions');
|
|
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"/);
|
|
});
|
|
|
|
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 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 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(() => 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');
|
|
});
|