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:
+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