'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: 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'); });