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:
+236
-167
@@ -2,6 +2,7 @@
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const net = require('node:net');
|
||||
const dns = require('node:dns/promises');
|
||||
|
||||
const REALITY_PRESETS = Object.freeze([
|
||||
{ id: 'tesla', label: 'Tesla', serverName: 'www.tesla.com', destPort: 443 },
|
||||
@@ -12,18 +13,17 @@ const REALITY_PRESETS = Object.freeze([
|
||||
]);
|
||||
|
||||
const PROFILE_CATALOG = Object.freeze([
|
||||
{ id: 'vless-reality-vision', role: 'relay-ingress', name: 'VLESS · Reality · Vision', status: 'stable', deployable: true, capabilities: ['reality'], description: '推荐公网入口,无需在节点机部署证书。' },
|
||||
{ id: 'vless-reality', role: 'relay-ingress', name: 'VLESS · Reality', status: 'stable', deployable: true, capabilities: ['reality'], description: '不启用 Vision 流控的 Reality 入口。' },
|
||||
{ id: 'vless-ws', role: 'relay-ingress', name: 'VLESS · WebSocket', status: 'beta', deployable: true, capabilities: ['websocket'], description: '无 TLS 的兼容模式,建议仅在可信或外层 TLS 环境使用。' },
|
||||
{ id: 'vmess-ws', role: 'relay-ingress', name: 'VMess · WebSocket', status: 'beta', deployable: true, capabilities: ['websocket'], description: '兼容旧客户端,建议仅在外层 TLS 环境使用。' },
|
||||
{ id: 'shadowsocks-2022-aes128', role: 'relay-ingress', name: 'SS 2022 · AES-128', status: 'stable', deployable: true, capabilities: [], description: '轻量、推荐的 AEAD 2022 入口。' },
|
||||
{ id: 'shadowsocks-2022-aes256', role: 'relay-ingress', name: 'SS 2022 · AES-256', status: 'stable', deployable: true, capabilities: [], description: '32 字节密钥的 AEAD 2022 入口。' },
|
||||
{ id: 'shadowsocks-aes128-gcm', role: 'relay-ingress', name: 'SS · AES-128-GCM', status: 'stable', deployable: true, capabilities: [], description: '兼容主流旧版 Shadowsocks 客户端。' },
|
||||
{ id: 'shadowsocks-aes256-gcm', role: 'relay-ingress', name: 'SS · AES-256-GCM', status: 'stable', deployable: true, capabilities: [], description: '兼容主流旧版 Shadowsocks 客户端。' },
|
||||
{ id: 'socks5-auth', role: 'relay-ingress', name: 'SOCKS5 · 用户密码', status: 'stable', deployable: true, capabilities: [], description: '适合受控网络或程序级代理。' },
|
||||
{ id: 'vless-ws-tls', role: 'relay-ingress', name: 'VLESS · WS · TLS', status: 'beta', deployable: true, capabilities: ['tls-domain'], description: '需为每台入口设备填写 TLS 域名,并在 Agent 上安装对应证书。' },
|
||||
{ id: 'hysteria2', role: 'relay-ingress', name: 'Hysteria 2 · UDP', status: 'beta', deployable: true, capabilities: ['udp', 'tls-domain'], description: '由新版 Xray 承载;入口须安装有效节点证书并放行所选 UDP 端口。' },
|
||||
{ id: 'anytls', role: 'relay-ingress', name: 'AnyTLS · TLS', status: 'beta', deployable: true, capabilities: ['tls-domain', 'sing-box'], description: '由 sing-box 独立承载,需入口设备的有效节点证书;支持直连或转发到现有出口。' },
|
||||
{ id: 'vless-reality-vision', role: 'relay-ingress', name: 'VLESS · Reality · Vision', status: 'stable', deployable: true, capabilities: ['sing-box', 'reality'], description: '推荐公网入口,无需在节点机部署证书。' },
|
||||
{ id: 'vless-reality', role: 'relay-ingress', name: 'VLESS · Reality', status: 'stable', deployable: true, capabilities: ['sing-box', 'reality'], description: '不启用 Vision 流控的 Reality 入口。' },
|
||||
{ id: 'vless-ws', role: 'relay-ingress', name: 'VLESS · WebSocket', status: 'beta', deployable: true, capabilities: ['sing-box', 'websocket'], description: '无 TLS 的兼容模式,建议仅在可信或外层 TLS 环境使用。' },
|
||||
{ id: 'shadowsocks-2022-aes128', role: 'relay-ingress', name: 'SS 2022 · AES-128', status: 'stable', deployable: true, capabilities: ['sing-box', ], description: '轻量、推荐的 AEAD 2022 入口。' },
|
||||
{ id: 'shadowsocks-2022-aes256', role: 'relay-ingress', name: 'SS 2022 · AES-256', status: 'stable', deployable: true, capabilities: ['sing-box', ], description: '32 字节密钥的 AEAD 2022 入口。' },
|
||||
{ id: 'shadowsocks-aes128-gcm', role: 'relay-ingress', name: 'SS · AES-128-GCM', status: 'stable', deployable: true, capabilities: ['sing-box', ], description: '兼容主流旧版 Shadowsocks 客户端。' },
|
||||
{ id: 'shadowsocks-aes256-gcm', role: 'relay-ingress', name: 'SS · AES-256-GCM', status: 'stable', deployable: true, capabilities: ['sing-box', ], description: '兼容主流旧版 Shadowsocks 客户端。' },
|
||||
{ id: 'socks5-auth', role: 'relay-ingress', name: 'SOCKS5 · 用户密码', status: 'stable', deployable: true, capabilities: ['sing-box', ], description: '适合受控网络或程序级代理。' },
|
||||
{ id: 'vless-ws-tls', role: 'relay-ingress', name: 'VLESS · WS · TLS', status: 'beta', deployable: true, capabilities: ['sing-box', 'tls-domain'], description: '需为每台入口设备填写 TLS 域名,并在 Agent 上安装对应证书。' },
|
||||
{ id: 'hysteria2', role: 'relay-ingress', name: 'Hysteria 2 · UDP', status: 'beta', deployable: true, capabilities: ['sing-box', 'udp', 'tls-domain'], description: '由 sing-box 承载;入口须安装有效节点证书并放行所选 UDP 端口。' },
|
||||
{ id: 'anytls', role: 'relay-ingress', name: 'AnyTLS · TLS', status: 'beta', deployable: true, capabilities: ['sing-box', 'tls-domain'], description: '由 sing-box 独立承载,需入口设备的有效节点证书;支持直连或转发到现有出口。' },
|
||||
{ id: 'shadowsocks-2022-aes128', role: 'exit-transport', name: 'SS 2022 · AES-128', status: 'stable', deployable: true, capabilities: ['encrypted'], description: '推荐的转发到出口传输。' },
|
||||
{ id: 'shadowsocks-2022-aes256', role: 'exit-transport', name: 'SS 2022 · AES-256', status: 'stable', deployable: true, capabilities: ['encrypted'], description: '增强密钥长度的转发传输。' },
|
||||
{ id: 'shadowsocks-aes128-gcm', role: 'exit-transport', name: 'SS · AES-128-GCM', status: 'stable', deployable: true, capabilities: ['encrypted'], description: '兼容旧环境的转发传输。' },
|
||||
@@ -42,176 +42,240 @@ const SHADOWSOCKS_METHODS = Object.freeze({
|
||||
function b64(bytes) { return crypto.randomBytes(bytes).toString('base64'); }
|
||||
function shortId() { return crypto.randomBytes(8).toString('hex'); }
|
||||
|
||||
function newCredentialSet(relayProtocol, exitProtocol) {
|
||||
const relayCipher = SHADOWSOCKS_METHODS[relayProtocol];
|
||||
const exitCipher = SHADOWSOCKS_METHODS[exitProtocol];
|
||||
function listenAddress(networkMode, server, bindAddress) {
|
||||
// bindAddress 是控制面按所选网络栈解析好的字面 IP(登记的域名已在下发前解析),优先使用。
|
||||
if (bindAddress) return bindAddress;
|
||||
// 只绑定所选网络栈对应的具体 IP,避免通配符地址跨栈泄漏。
|
||||
// ipv6 绑定 `::` 时 Linux 默认是双栈 socket(bindv6only=0),IPv4 也能连进来。
|
||||
if (networkMode === 'ipv6') return (server && server.publicAddressV6) || '::';
|
||||
// dual 单个 inbound 只能填一个 listen 地址,`::` 即双栈语义;缺地址时回退通配符。
|
||||
if (networkMode === 'dual') return '::';
|
||||
return (server && server.publicAddress) || '0.0.0.0';
|
||||
}
|
||||
|
||||
// 把服务器登记的地址解析成字面 IP,供 inbound listen 绑定。
|
||||
// v4/v6 各自登记了单栈域名时,按所选网络栈只取对应记录(A / AAAA),避免绑到错误的栈。
|
||||
async function resolveBindAddress(networkMode, server) {
|
||||
if (networkMode === 'dual') return null;
|
||||
const raw = networkMode === 'ipv6' ? server?.publicAddressV6 : server?.publicAddress;
|
||||
if (!raw) return null;
|
||||
const text = String(raw).trim();
|
||||
const family = networkMode === 'ipv6' ? 6 : 4;
|
||||
if (net.isIP(text)) return text;
|
||||
try {
|
||||
const { address } = await dns.lookup(text, { family });
|
||||
return address;
|
||||
} catch (err) {
|
||||
const error = new Error(`绑定地址 DNS 解析失败:${text} 没有 ${family === 6 ? 'AAAA' : 'A'} 记录(${err.code || err.message}),请检查该服务器登记的地址`);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Shared nodes: one inbound, many clients (3x-ui style) ----------
|
||||
|
||||
// 统计与日志里的用户身份统一为 ng:<customerId>:
|
||||
// VLESS/HY2/SS 用 email,SOCKS 用用户名,AnyTLS 用 sing-box 用户名。
|
||||
function nodeEmail(customerId) { return `ng:${customerId}`; }
|
||||
|
||||
function newNodeClientCredentials(protocol) {
|
||||
const cipher = SHADOWSOCKS_METHODS[protocol];
|
||||
return {
|
||||
clientId: crypto.randomUUID(),
|
||||
relayPassword: b64(relayCipher ? relayCipher.keyBytes : 24),
|
||||
exitPassword: b64(exitCipher ? exitCipher.keyBytes : 24),
|
||||
socksUser: `ng_${crypto.randomBytes(5).toString('hex')}`,
|
||||
relayPassword: b64(cipher ? cipher.keyBytes : 24),
|
||||
shortId: shortId(),
|
||||
wsPath: `/${crypto.randomBytes(8).toString('hex')}`
|
||||
};
|
||||
}
|
||||
|
||||
function listenAddress(networkMode) { return networkMode === 'ipv6' || networkMode === 'dual' ? '::' : '0.0.0.0'; }
|
||||
|
||||
function inboundBase(tag, port, protocol, settings, streamSettings, networkMode = 'ipv4') {
|
||||
return {
|
||||
tag, listen: listenAddress(networkMode), port, protocol, settings,
|
||||
sniffing: { enabled: true, destOverride: ['http', 'tls', 'quic'], routeOnly: true },
|
||||
...(streamSettings ? { streamSettings } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function shadowsocksInbound(tag, port, protocol, password, networkMode) {
|
||||
const cipher = SHADOWSOCKS_METHODS[protocol];
|
||||
if (!cipher) return null;
|
||||
return inboundBase(tag, port, 'shadowsocks', { method: cipher.method, password, network: 'tcp,udp' }, null, networkMode);
|
||||
}
|
||||
|
||||
function socksInbound(tag, port, credentials, password, networkMode) {
|
||||
return inboundBase(tag, port, 'socks', {
|
||||
auth: 'password', accounts: [{ user: credentials.socksUser, pass: password }], udp: true, ip: '127.0.0.1', userLevel: 0
|
||||
}, null, networkMode);
|
||||
}
|
||||
|
||||
function buildExitResource({ resourceId, tagPrefix, port, protocol, credentials, customer, networkMode }) {
|
||||
let inbound = shadowsocksInbound(`${tagPrefix}-in`, port, protocol, credentials.exitPassword, networkMode);
|
||||
if (protocol === 'socks5-auth') inbound = socksInbound(`${tagPrefix}-in`, port, credentials, credentials.exitPassword, networkMode);
|
||||
if (protocol === 'vless-tcp') {
|
||||
inbound = inboundBase(`${tagPrefix}-in`, port, 'vless', {
|
||||
clients: [{ id: credentials.clientId, email: `ng:${customer.id}` }], decryption: 'none'
|
||||
}, { network: 'raw', security: 'none' }, networkMode);
|
||||
// Fill in node-level secrets that are missing for the node's current
|
||||
// protocol/topology. Called on node create and on node edit.
|
||||
function ensureNodeSecrets(node) {
|
||||
node.wsPath ||= `/${crypto.randomBytes(9).toString('hex')}`;
|
||||
const cipher = SHADOWSOCKS_METHODS[node.protocol];
|
||||
if (cipher && !node.ssServerKey) node.ssServerKey = b64(cipher.keyBytes);
|
||||
if (isRealityProtocol(node.protocol)) {
|
||||
node.reality ||= { keyId: `reality-${node.id}` };
|
||||
node.reality.serverName = node.realityServerName || 'www.tesla.com';
|
||||
node.reality.destPort = Number(node.realityDestPort || 443);
|
||||
}
|
||||
if (!inbound) throw new Error(`Unsupported exit transport: ${protocol}`);
|
||||
const directTag = `${tagPrefix}-direct`;
|
||||
return {
|
||||
id: resourceId, meta: { kind: 'exit', customerId: customer.id, metricsTag: inbound.tag }, inbounds: [inbound],
|
||||
outbounds: [{ tag: directTag, protocol: 'freedom', settings: {} }],
|
||||
routingRules: [{ type: 'field', inboundTag: [inbound.tag], outboundTag: directTag }]
|
||||
};
|
||||
if ((node.topology || 'forward') !== 'direct' && !node.exitCredentials) {
|
||||
const exitCipher = SHADOWSOCKS_METHODS[node.exitProtocol];
|
||||
node.exitCredentials = {
|
||||
clientId: crypto.randomUUID(),
|
||||
password: b64(exitCipher ? exitCipher.keyBytes : 24),
|
||||
socksUser: `ng_${crypto.randomBytes(5).toString('hex')}`,
|
||||
};
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function buildRelayInbound({ tag, port, protocol, credentials, customer, reality, networkMode, tlsDomain }) {
|
||||
const email = `ng:${customer.id}`;
|
||||
if (protocol === 'hysteria2') {
|
||||
if (!tlsDomain) throw new Error('Hysteria 2 入口缺少有效证书域名');
|
||||
return inboundBase(tag, port, 'hysteria', {
|
||||
// Xray 26.3.27 reads `clients`; newer cores read `users`.
|
||||
// Keep both until all supported cores accept the same spelling.
|
||||
version: 2,
|
||||
clients: [{ auth: credentials.relayPassword, level: 0, email }],
|
||||
users: [{ auth: credentials.relayPassword, level: 0, email }]
|
||||
}, {
|
||||
// Older Xray reads `network`; newer cores accept `method`.
|
||||
network: 'hysteria', method: 'hysteria', security: 'tls', hysteriaSettings: { version: 2 },
|
||||
tlsSettings: { serverName: tlsDomain, alpn: ['h3'], certificates: [{
|
||||
certificateFile: `/etc/nexusgate/tls/${tlsDomain}/fullchain.pem`,
|
||||
keyFile: `/etc/nexusgate/tls/${tlsDomain}/privkey.pem`
|
||||
}] }
|
||||
}, networkMode);
|
||||
}
|
||||
// sing-box 全协议 inbound 构建器
|
||||
function buildSingBoxInbound({ tag, node, nodeClients, server, bindAddress }) {
|
||||
const protocol = node.protocol;
|
||||
const port = node.port;
|
||||
const networkMode = node.networkMode || 'ipv4';
|
||||
const listen = listenAddress(networkMode, server, bindAddress);
|
||||
const tlsDomain = server.tlsDomain;
|
||||
|
||||
// VLESS Reality (Vision / 普通)
|
||||
if (protocol === 'vless-reality-vision' || protocol === 'vless-reality') {
|
||||
const client = { id: credentials.clientId, email };
|
||||
if (protocol === 'vless-reality-vision') client.flow = 'xtls-rprx-vision';
|
||||
return inboundBase(tag, port, 'vless', { clients: [client], decryption: 'none' }, {
|
||||
network: 'raw', security: 'reality',
|
||||
realitySettings: {
|
||||
show: false, target: `${reality.serverName}:${reality.destPort}`, xver: 0,
|
||||
serverNames: [reality.serverName], privateKey: `\${REALITY_PRIVATE:${reality.keyId}}`, shortIds: [credentials.shortId]
|
||||
}
|
||||
}, networkMode);
|
||||
if (!node.reality) throw new Error('Reality 节点缺少密钥配置');
|
||||
const users = nodeClients.map((nc) => ({
|
||||
name: nodeEmail(nc.customerId),
|
||||
uuid: nc.credentials.clientId,
|
||||
...(protocol === 'vless-reality-vision' ? { flow: 'xtls-rprx-vision' } : {}),
|
||||
}));
|
||||
return {
|
||||
type: 'vless', tag, listen, listen_port: port, users,
|
||||
tls: {
|
||||
enabled: true, server_name: node.reality.serverName,
|
||||
reality: {
|
||||
enabled: true,
|
||||
handshake: { server: node.reality.serverName, server_port: node.reality.destPort || 443 },
|
||||
private_key: `\${REALITY_PRIVATE:${node.reality.keyId}}`,
|
||||
short_id: nodeClients.map((nc) => nc.credentials.shortId),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (SHADOWSOCKS_METHODS[protocol]) return shadowsocksInbound(tag, port, protocol, credentials.relayPassword, networkMode);
|
||||
if (protocol === 'socks5-auth') return socksInbound(tag, port, credentials, credentials.relayPassword, networkMode);
|
||||
|
||||
// VLESS WS / WS+TLS
|
||||
if (protocol === 'vless-ws' || protocol === 'vless-ws-tls') {
|
||||
if (protocol === 'vless-ws-tls' && !tlsDomain) throw new Error('TLS 节点缺少有效证书域名');
|
||||
return inboundBase(tag, port, 'vless', { clients: [{ id: credentials.clientId, email }], decryption: 'none' }, {
|
||||
network: 'ws', security: protocol === 'vless-ws-tls' ? 'tls' : 'none', wsSettings: { path: credentials.wsPath },
|
||||
...(protocol === 'vless-ws-tls' ? { tlsSettings: { serverName: tlsDomain,
|
||||
certificates: [{ certificateFile: `/etc/nexusgate/tls/${tlsDomain}/fullchain.pem`, keyFile: `/etc/nexusgate/tls/${tlsDomain}/privkey.pem` }] } } : {})
|
||||
}, networkMode);
|
||||
const users = nodeClients.map((nc) => ({ name: nodeEmail(nc.customerId), uuid: nc.credentials.clientId }));
|
||||
const inbound = {
|
||||
type: 'vless', tag, listen, listen_port: port, users,
|
||||
transport: { type: 'ws', path: node.wsPath || '/' },
|
||||
};
|
||||
if (protocol === 'vless-ws-tls') {
|
||||
inbound.tls = {
|
||||
enabled: true, server_name: tlsDomain,
|
||||
certificate_path: `/etc/nexusgate/tls/${tlsDomain}/fullchain.pem`,
|
||||
key_path: `/etc/nexusgate/tls/${tlsDomain}/privkey.pem`,
|
||||
};
|
||||
}
|
||||
return inbound;
|
||||
}
|
||||
if (protocol === 'vmess-ws') {
|
||||
return inboundBase(tag, port, 'vmess', { clients: [{ id: credentials.clientId, email }] }, {
|
||||
network: 'ws', security: 'none', wsSettings: { path: credentials.wsPath }
|
||||
}, networkMode);
|
||||
|
||||
// Shadowsocks (2022 全系 + 旧版)
|
||||
if (SHADOWSOCKS_METHODS[protocol]) {
|
||||
return {
|
||||
type: 'shadowsocks', tag, listen, listen_port: port,
|
||||
method: SHADOWSOCKS_METHODS[protocol].method,
|
||||
password: node.ssServerKey,
|
||||
users: nodeClients.map((nc) => ({ name: nodeEmail(nc.customerId), password: nc.credentials.relayPassword })),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unsupported relay ingress: ${protocol}`);
|
||||
|
||||
// SOCKS5
|
||||
if (protocol === 'socks5-auth') {
|
||||
return {
|
||||
type: 'socks', tag, listen, listen_port: port,
|
||||
users: nodeClients.map((nc) => ({ username: nodeEmail(nc.customerId), password: nc.credentials.relayPassword })),
|
||||
};
|
||||
}
|
||||
|
||||
// Hysteria2
|
||||
if (protocol === 'hysteria2') {
|
||||
if (!tlsDomain) throw new Error('Hysteria 2 入口缺少有效证书域名');
|
||||
return {
|
||||
type: 'hysteria2', tag, listen, listen_port: port,
|
||||
users: nodeClients.map((nc) => ({ name: nodeEmail(nc.customerId), password: nc.credentials.relayPassword })),
|
||||
tls: {
|
||||
enabled: true, server_name: tlsDomain, alpn: ['h3'],
|
||||
certificate_path: `/etc/nexusgate/tls/${tlsDomain}/fullchain.pem`,
|
||||
key_path: `/etc/nexusgate/tls/${tlsDomain}/privkey.pem`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// AnyTLS 走专用构建器
|
||||
if (protocol === 'anytls') {
|
||||
if (!tlsDomain) throw new Error('AnyTLS 入口缺少有效证书域名');
|
||||
return {
|
||||
type: 'anytls', tag, listen, listen_port: port,
|
||||
users: nodeClients.map((nc) => ({ name: nodeEmail(nc.customerId), password: nc.credentials.relayPassword })),
|
||||
tls: {
|
||||
enabled: true, server_name: tlsDomain,
|
||||
certificate_path: `/etc/nexusgate/tls/${tlsDomain}/fullchain.pem`,
|
||||
key_path: `/etc/nexusgate/tls/${tlsDomain}/privkey.pem`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`sing-box 不支持的协议: ${protocol}`);
|
||||
}
|
||||
|
||||
function buildSingBoxNodeResource({ resourceId, tagPrefix, node, nodeClients, server, bindAddress, exitServer }) {
|
||||
const tag = `${tagPrefix}-in`;
|
||||
const forward = (node.topology || 'forward') === 'forward';
|
||||
const outboundTag = `${tagPrefix}-${forward ? 'exit' : 'direct'}`;
|
||||
const networkMode = node.networkMode || 'ipv4';
|
||||
let outbound;
|
||||
if (!forward) {
|
||||
outbound = { type: 'direct', tag: outboundTag };
|
||||
} else {
|
||||
const base = { tag: outboundTag, server: serverAddress(exitServer, networkMode), server_port: node.exitPort };
|
||||
const creds = node.exitCredentials;
|
||||
const cipher = SHADOWSOCKS_METHODS[node.exitProtocol];
|
||||
if (cipher) outbound = { type: 'shadowsocks', ...base, method: cipher.method, password: creds.password };
|
||||
else if (node.exitProtocol === 'socks5-auth') outbound = { type: 'socks', ...base, version: '5', username: creds.socksUser, password: creds.password };
|
||||
else if (node.exitProtocol === 'vless-tcp') outbound = { type: 'vless', ...base, uuid: creds.clientId };
|
||||
else throw new Error(`Unsupported exit transport: ${node.exitProtocol}`);
|
||||
}
|
||||
return {
|
||||
id: resourceId, engine: 'sing-box',
|
||||
meta: { kind: 'node', nodeId: node.id, metricsTag: tag, protocol: node.protocol },
|
||||
inbounds: [buildSingBoxInbound({ tag, node, nodeClients, server, bindAddress })],
|
||||
outbounds: [outbound],
|
||||
routingRules: [{ inbound: [tag], action: 'route', outbound: outboundTag }],
|
||||
};
|
||||
}
|
||||
|
||||
function buildNodeResource({ resourceId, tagPrefix, node, nodeClients, server, bindAddress, exitServer }) {
|
||||
// 全部走 sing-box,不再生成 Xray 配置
|
||||
return buildSingBoxNodeResource({ resourceId, tagPrefix, node, nodeClients, server, bindAddress, exitServer });
|
||||
}
|
||||
|
||||
function buildNodeExitResource({ resourceId, tagPrefix, node, server, bindAddress }) {
|
||||
const tag = `${tagPrefix}-exit-in`;
|
||||
const directTag = `${tagPrefix}-exit-direct`;
|
||||
const protocol = node.exitProtocol;
|
||||
const creds = node.exitCredentials;
|
||||
const networkMode = node.networkMode || 'ipv4';
|
||||
const listen = listenAddress(networkMode, server, bindAddress);
|
||||
const cipher = SHADOWSOCKS_METHODS[protocol];
|
||||
let inbound;
|
||||
if (cipher) {
|
||||
inbound = { type: 'shadowsocks', tag, listen, listen_port: node.exitPort,
|
||||
method: cipher.method, password: creds.password };
|
||||
} else if (protocol === 'vless-tcp') {
|
||||
inbound = { type: 'vless', tag, listen, listen_port: node.exitPort,
|
||||
users: [{ uuid: creds.clientId }] };
|
||||
} else if (protocol === 'socks5-auth') {
|
||||
inbound = { type: 'socks', tag, listen, listen_port: node.exitPort,
|
||||
users: [{ username: creds.socksUser, password: creds.password }] };
|
||||
} else {
|
||||
throw new Error(`Unsupported exit transport: ${protocol}`);
|
||||
}
|
||||
return {
|
||||
id: resourceId, engine: 'sing-box',
|
||||
meta: { kind: 'node-exit', nodeId: node.id, metricsTag: tag },
|
||||
inbounds: [inbound],
|
||||
outbounds: [{ type: 'direct', tag: directTag }],
|
||||
routingRules: [{ inbound: [tag], action: 'route', outbound: directTag }],
|
||||
};
|
||||
}
|
||||
|
||||
function serverAddress(server, networkMode) {
|
||||
return networkMode === 'ipv6' && server.publicAddressV6 ? server.publicAddressV6 : server.publicAddress;
|
||||
}
|
||||
|
||||
function buildExitOutbound(tag, protocol, exitServer, exitPort, credentials, networkMode) {
|
||||
const address = serverAddress(exitServer, networkMode);
|
||||
const cipher = SHADOWSOCKS_METHODS[protocol];
|
||||
if (cipher) return { tag, protocol: 'shadowsocks', settings: { servers: [{ address, port: exitPort, method: cipher.method, password: credentials.exitPassword }] } };
|
||||
if (protocol === 'socks5-auth') return { tag, protocol: 'socks', settings: { servers: [{ address, port: exitPort, users: [{ user: credentials.socksUser, pass: credentials.exitPassword }] }] } };
|
||||
if (protocol === 'vless-tcp') {
|
||||
return {
|
||||
tag, protocol: 'vless', settings: { vnext: [{ address, port: exitPort, users: [{ id: credentials.clientId, encryption: 'none' }] }] },
|
||||
streamSettings: { network: 'raw', security: 'none' }
|
||||
};
|
||||
}
|
||||
throw new Error(`Unsupported exit transport: ${protocol}`);
|
||||
}
|
||||
|
||||
function singBoxOutbound(tag, protocol, exitServer, exitPort, credentials, networkMode) {
|
||||
const base = { tag, server: serverAddress(exitServer, networkMode), server_port: exitPort };
|
||||
const cipher = SHADOWSOCKS_METHODS[protocol];
|
||||
if (cipher) return { type: 'shadowsocks', ...base, method: cipher.method, password: credentials.exitPassword };
|
||||
if (protocol === 'socks5-auth') return { type: 'socks', ...base, version: '5', username: credentials.socksUser, password: credentials.exitPassword };
|
||||
if (protocol === 'vless-tcp') return { type: 'vless', ...base, uuid: credentials.clientId };
|
||||
throw new Error(`Unsupported exit transport: ${protocol}`);
|
||||
}
|
||||
|
||||
function anytlsResource({ resourceId, tagPrefix, port, credentials, customer, networkMode, tlsDomain, exitProtocol, exitServer, exitPort }) {
|
||||
if (!tlsDomain) throw new Error('AnyTLS 入口缺少有效证书域名');
|
||||
const tag = `${tagPrefix}-in`;
|
||||
const outboundTag = `${tagPrefix}-${exitServer ? 'exit' : 'direct'}`;
|
||||
return {
|
||||
id: resourceId, engine: 'sing-box',
|
||||
meta: { kind: exitServer ? 'relay' : 'direct', customerId: customer.id, metricsTag: tag, protocol: 'anytls' },
|
||||
inbounds: [{ type: 'anytls', tag, listen: listenAddress(networkMode), listen_port: port,
|
||||
users: [{ name: `ng:${customer.id}`, password: credentials.relayPassword }],
|
||||
tls: { enabled: true, server_name: tlsDomain,
|
||||
certificate_path: `/etc/nexusgate/tls/${tlsDomain}/fullchain.pem`,
|
||||
key_path: `/etc/nexusgate/tls/${tlsDomain}/privkey.pem` } }],
|
||||
outbounds: [exitServer ? singBoxOutbound(outboundTag, exitProtocol, exitServer, exitPort, credentials, networkMode)
|
||||
: { type: 'direct', tag: outboundTag }],
|
||||
routingRules: [{ inbound: [tag], action: 'route', outbound: outboundTag }]
|
||||
};
|
||||
}
|
||||
|
||||
function buildRelayResource({ resourceId, tagPrefix, port, protocol, exitProtocol, exitServer, exitPort, credentials, customer, reality, networkMode, tlsDomain }) {
|
||||
if (protocol === 'anytls') return anytlsResource({ resourceId, tagPrefix, port, credentials, customer, networkMode, tlsDomain, exitProtocol, exitServer, exitPort });
|
||||
const inbound = buildRelayInbound({ tag: `${tagPrefix}-in`, port, protocol, credentials, customer, reality, networkMode, tlsDomain });
|
||||
const outboundTag = `${tagPrefix}-exit`;
|
||||
return {
|
||||
id: resourceId, meta: { kind: 'relay', customerId: customer.id, metricsTag: inbound.tag, protocol },
|
||||
inbounds: [inbound], outbounds: [buildExitOutbound(outboundTag, exitProtocol, exitServer, exitPort, credentials, networkMode)],
|
||||
routingRules: [{ type: 'field', inboundTag: [inbound.tag], outboundTag }]
|
||||
};
|
||||
}
|
||||
|
||||
function buildDirectResource({ resourceId, tagPrefix, port, protocol, credentials, customer, reality, networkMode, tlsDomain }) {
|
||||
if (protocol === 'anytls') return anytlsResource({ resourceId, tagPrefix, port, credentials, customer, networkMode, tlsDomain });
|
||||
const inbound = buildRelayInbound({ tag: `${tagPrefix}-in`, port, protocol, credentials, customer, reality, networkMode, tlsDomain });
|
||||
const outboundTag = `${tagPrefix}-direct`;
|
||||
return {
|
||||
id: resourceId, meta: { kind: 'direct', customerId: customer.id, metricsTag: inbound.tag, protocol },
|
||||
inbounds: [inbound], outbounds: [{ tag: outboundTag, protocol: 'freedom', settings: {} }],
|
||||
routingRules: [{ type: 'field', inboundTag: [inbound.tag], outboundTag }]
|
||||
};
|
||||
if (!server) throw new Error('Missing server for address selection');
|
||||
if (networkMode === 'ipv6') return server.publicAddressV6;
|
||||
return server.publicAddress;
|
||||
}
|
||||
|
||||
function uriHost(address) { return net.isIP(String(address)) === 6 ? `[${address}]` : address; }
|
||||
|
||||
function buildClientUri({ protocol, relayServer, relayPort, credentials, reality, publicKey, name, networkMode, tlsDomain }) {
|
||||
function buildClientUri({ protocol, relayServer, relayPort, credentials, reality, publicKey, name, networkMode, tlsDomain, ssServerKey }) {
|
||||
const label = encodeURIComponent(name);
|
||||
const publicAddress = serverAddress(relayServer, networkMode);
|
||||
const host = uriHost(publicAddress);
|
||||
@@ -235,14 +299,18 @@ function buildClientUri({ protocol, relayServer, relayPort, credentials, reality
|
||||
return `vless://${credentials.clientId}@${host}:${relayPort}?${query}#${label}`;
|
||||
}
|
||||
if (SHADOWSOCKS_METHODS[protocol]) {
|
||||
const user = Buffer.from(`${SHADOWSOCKS_METHODS[protocol].method}:${credentials.relayPassword}`).toString('base64url');
|
||||
const method = SHADOWSOCKS_METHODS[protocol].method;
|
||||
// SS2022 multi-user needs the EIH identity header: sing-box derives the
|
||||
// identity subkey from the node's main PSK, so the client password must be
|
||||
// "<server PSK>:<user PSK>" or the server rejects with "invalid request".
|
||||
// Classic AEAD methods identify users by trying keys: user password alone.
|
||||
const password = method.startsWith('2022-blake3-') && ssServerKey
|
||||
? `${ssServerKey}:${credentials.relayPassword}`
|
||||
: credentials.relayPassword;
|
||||
const user = Buffer.from(`${method}:${password}`).toString('base64url');
|
||||
return `ss://${user}@${host}:${relayPort}#${label}`;
|
||||
}
|
||||
if (protocol === 'socks5-auth') return `socks5://${encodeURIComponent(credentials.socksUser)}:${encodeURIComponent(credentials.relayPassword)}@${host}:${relayPort}#${label}`;
|
||||
if (protocol === 'vmess-ws') {
|
||||
const payload = { v: '2', ps: name, add: publicAddress, port: String(relayPort), id: credentials.clientId, aid: '0', scy: 'auto', net: 'ws', type: 'none', host: '', path: credentials.wsPath, tls: '' };
|
||||
return `vmess://${Buffer.from(JSON.stringify(payload)).toString('base64')}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -259,6 +327,7 @@ function validateProtocolPair(relayProtocol, exitProtocol) {
|
||||
function isRealityProtocol(protocol) { return ['vless-reality-vision', 'vless-reality'].includes(protocol); }
|
||||
|
||||
module.exports = {
|
||||
PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, newCredentialSet, buildExitResource,
|
||||
buildRelayResource, buildDirectResource, buildClientUri, validateEntryProtocol, validateProtocolPair, isRealityProtocol
|
||||
PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, nodeEmail, newNodeClientCredentials,
|
||||
buildNodeResource, buildNodeExitResource, ensureNodeSecrets, buildClientUri, validateEntryProtocol, validateProtocolPair, isRealityProtocol,
|
||||
resolveBindAddress
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user