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:
+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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user