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:
Muse
2026-10-19 22:23:40 +08:00
parent 1678f766ac
commit 084afeb8f4
35 changed files with 2142 additions and 1749 deletions
+38 -29
View File
@@ -1,36 +1,47 @@
'use strict';
// Client URLs are only issued for confirmed, running entry resources.
function entries(data, customerId) {
const active = data.deployments.filter((item) => item.customerId === customerId && !item.archived && item.status === 'active' &&
['relay', 'direct'].includes(item.role) && typeof item.clientUri === 'string' && item.clientUri.length > 0 &&
(item.role === 'direct' || data.deployments.some((exit) => exit.id === item.exitDeploymentId && exit.status === 'active' && !exit.archived)));
const seen = new Map();
return active.map((item) => {
const chainName = data.chains?.find((chain) => chain.id === item.chainId)?.name;
if (!chainName) return item;
// Use the current route name even for links generated by older Agents.
// Multiple entries under one route still need distinct names in Clash.
const index = (seen.get(chainName) || 0) + 1;
seen.set(chainName, index);
const name = index === 1 ? chainName : `${chainName} (${index})`;
if (item.clientUri.startsWith('vmess://')) {
const payload = JSON.parse(Buffer.from(item.clientUri.slice(8), 'base64').toString('utf8'));
return { ...item, clientUri:`vmess://${Buffer.from(JSON.stringify({ ...payload, ps:name })).toString('base64')}` };
}
const separator = item.clientUri.lastIndexOf('#');
return { ...item, clientUri:`${item.clientUri.slice(0, separator < 0 ? undefined : separator)}#${encodeURIComponent(name)}` };
const { buildClientUri, nodeEmail } = require('./protocols');
// Client URLs are only issued for confirmed, running nodes. Each link is built
// on the fly from the node's current config and the customer's own credentials
// for that node (3x-ui style: shared inbound, per-client identity).
function nodeClientUri(data, nodeClient) {
const node = data.nodes.find((item) => item.id === nodeClient.nodeId);
if (!node || node.status !== 'active') return null;
const server = data.servers.find((item) => item.id === node.serverId);
if (!server) return null;
return buildClientUri({
protocol: node.protocol, relayServer: server, relayPort: node.port,
credentials: { ...nodeClient.credentials, wsPath: node.wsPath, socksUser: nodeEmail(nodeClient.customerId) },
reality: node.reality, publicKey: node.artifacts && node.artifacts.realityPublicKey,
name: node.name, networkMode: node.networkMode || 'ipv4', tlsDomain: server.tlsDomain,
ssServerKey: node.ssServerKey,
});
}
function entries(data, customerId) {
const seen = new Map();
const items = [];
for (const nodeClient of data.nodeClients.filter((nc) => nc.customerId === customerId && nc.status === 'active')) {
const uri = nodeClientUri(data, nodeClient);
if (!uri) continue;
const node = data.nodes.find((item) => item.id === nodeClient.nodeId);
const nodeName = node ? node.name : nodeClient.nodeId;
// Use the current node name even for links generated by older Agents.
const index = (seen.get(nodeName) || 0) + 1;
seen.set(nodeName, index);
const name = index === 1 ? nodeName : `${nodeName} (${index})`;
const separator = uri.lastIndexOf('#');
const clientUri = `${uri.slice(0, separator < 0 ? undefined : separator)}#${encodeURIComponent(name)}`;
items.push({ nodeId: nodeClient.nodeId, clientUri });
}
return items;
}
function q(value) { return JSON.stringify(String(value)); }
function clashProxy(item) {
const uri = item.clientUri;
if (uri.startsWith('vmess://')) {
const value = JSON.parse(Buffer.from(uri.slice(8), 'base64').toString('utf8'));
return { name:value.ps, type:'vmess', server:value.add, port:Number(value.port), uuid:value.id, alterId:0, cipher:'auto', network:'ws', 'ws-opts':{ path:value.path } };
}
const name = decodeURIComponent(uri.slice(uri.lastIndexOf('#') + 1));
const url = new URL(uri);
const base = { name, server:url.hostname.replace(/^\[|\]$/g, ''), port:Number(url.port) };
@@ -104,14 +115,13 @@ function makeClash(items, smart = false) {
function makeSurge(items) {
// Surge does not accept VLESS/Reality policies. Omit unsupported nodes rather
// than handing clients an importable profile that cannot establish a tunnel.
const proxies = items.map(clashProxy).filter((item) => ['ss','socks5','vmess'].includes(item.type));
const proxies = items.map(clashProxy).filter((item) => ['ss','socks5'].includes(item.type));
if (!proxies.length) return null;
const safe = (value) => String(value).replace(/[\r\n,=]/g, '_');
const policies = proxies.map((proxy) => {
const prefix = `${safe(proxy.name)} = `;
if (proxy.type === 'ss') return `${prefix}ss, ${proxy.server}, ${proxy.port}, encrypt-method=${proxy.cipher}, password=${safe(proxy.password)}`;
if (proxy.type === 'socks5') return `${prefix}socks5, ${proxy.server}, ${proxy.port}, ${safe(proxy.username)}, ${safe(proxy.password)}`;
return `${prefix}vmess, ${proxy.server}, ${proxy.port}, username=${proxy.uuid}, vmess-aead=true, ws=true, ws-path=${safe(proxy['ws-opts'].path)}`;
return `${prefix}socks5, ${proxy.server}, ${proxy.port}, ${safe(proxy.username)}, ${safe(proxy.password)}`;
});
const names = proxies.map((proxy) => safe(proxy.name)).join(', ');
return `[General]\nloglevel = notify\n[Proxy]\n${policies.join('\n')}\n[Proxy Group]\n节点选择 = select, ${names}, DIRECT\n自动选择 = url-test, ${names}, url=http://www.gstatic.com/generate_204, interval=600\n[Rule]\nGEOIP,CN,DIRECT\nFINAL,节点选择\n`;
@@ -124,7 +134,6 @@ function makeSingBox(items) {
if (proxy.type === 'hysteria2') return { type:'hysteria2', ...base, password:proxy.password, tls:{ enabled:true, server_name:proxy.sni, alpn:['h3'] } };
if (proxy.type === 'anytls') return { type:'anytls', ...base, password:proxy.password, tls:{ enabled:true, server_name:proxy.sni } };
if (proxy.type === 'socks5') return { type:'socks', ...base, version:'5', username:proxy.username, password:proxy.password };
if (proxy.type === 'vmess') return { type:'vmess', ...base, uuid:proxy.uuid, security:'auto', alter_id:0, transport:{ type:'ws', path:proxy['ws-opts'].path } };
const outbound = { type:'vless', ...base, uuid:proxy.uuid };
if (proxy.flow) outbound.flow = proxy.flow;
if (proxy.network === 'ws') outbound.transport = { type:'ws', path:proxy['ws-opts'].path };
@@ -151,4 +160,4 @@ function formatSubscription(data, customer, format, userAgent = '') {
return undefined;
}
module.exports = { entries, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription };
module.exports = { entries, nodeClientUri, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription };