'use strict'; 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 }, { id: 'amazon', label: 'Amazon', serverName: 'www.amazon.com', destPort: 443 }, { id: 'apple', label: 'Apple', serverName: 'www.apple.com', destPort: 443 }, { id: 'intel', label: 'Intel', serverName: 'www.intel.com', destPort: 443 }, { id: 'amd', label: 'AMD', serverName: 'www.amd.com', destPort: 443 } ]); const PROFILE_CATALOG = Object.freeze([ { 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: '兼容旧环境的转发传输。' }, { id: 'shadowsocks-aes256-gcm', role: 'exit-transport', name: 'SS · AES-256-GCM', status: 'stable', deployable: true, capabilities: ['encrypted'], description: '兼容旧环境的转发传输。' }, { id: 'vless-tcp', role: 'exit-transport', name: 'VLESS · TCP', status: 'beta', deployable: true, capabilities: [], description: '适合可信内网或已有加密隧道,不建议裸露公网。' }, { id: 'socks5-auth', role: 'exit-transport', name: 'SOCKS5 · 用户密码', status: 'beta', deployable: true, capabilities: [], description: '不加密,仅用于可信网络或外层隧道。' } ]); const SHADOWSOCKS_METHODS = Object.freeze({ 'shadowsocks-2022-aes128': { method: '2022-blake3-aes-128-gcm', keyBytes: 16 }, 'shadowsocks-2022-aes256': { method: '2022-blake3-aes-256-gcm', keyBytes: 32 }, 'shadowsocks-aes128-gcm': { method: 'aes-128-gcm', keyBytes: 24 }, 'shadowsocks-aes256-gcm': { method: 'aes-256-gcm', keyBytes: 32 } }); function b64(bytes) { return crypto.randomBytes(bytes).toString('base64'); } function shortId() { return crypto.randomBytes(8).toString('hex'); } 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:: // 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(cipher ? cipher.keyBytes : 24), shortId: shortId(), }; } // 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 ((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; } // 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') { 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), }, }, }; } // VLESS WS / WS+TLS if (protocol === 'vless-ws' || protocol === 'vless-ws-tls') { if (protocol === 'vless-ws-tls' && !tlsDomain) throw new Error('TLS 节点缺少有效证书域名'); 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; } // 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 })), }; } // 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 }) { ensureNodeSecrets(node); 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) { 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, ssServerKey }) { const label = encodeURIComponent(name); const publicAddress = serverAddress(relayServer, networkMode); const host = uriHost(publicAddress); if (protocol === 'hysteria2') { const query = new URLSearchParams({ sni: tlsDomain, alpn: 'h3' }); return `hysteria2://${encodeURIComponent(credentials.relayPassword)}@${host}:${relayPort}?${query}#${label}`; } if (protocol === 'anytls') { const query = new URLSearchParams({ sni: tlsDomain }); return `anytls://${encodeURIComponent(credentials.relayPassword)}@${host}:${relayPort}?${query}#${label}`; } if (protocol === 'vless-reality-vision' || protocol === 'vless-reality') { if (!publicKey) return null; const query = new URLSearchParams({ encryption: 'none', security: 'reality', sni: reality.serverName, fp: 'chrome', pbk: publicKey, sid: credentials.shortId, type: 'tcp', headerType: 'none' }); if (protocol === 'vless-reality-vision') query.set('flow', 'xtls-rprx-vision'); return `vless://${credentials.clientId}@${host}:${relayPort}?${query}#${label}`; } if (protocol === 'vless-ws' || protocol === 'vless-ws-tls') { const query = new URLSearchParams({ encryption: 'none', security: protocol === 'vless-ws-tls' ? 'tls' : 'none', type: 'ws', path: credentials.wsPath }); if (protocol === 'vless-ws-tls') query.set('sni', tlsDomain); return `vless://${credentials.clientId}@${host}:${relayPort}?${query}#${label}`; } if (SHADOWSOCKS_METHODS[protocol]) { 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 // ":" 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}`; return null; } function profile(role, protocol) { return PROFILE_CATALOG.find((item) => item.role === role && item.id === protocol); } function validateEntryProtocol(protocol) { const item = profile('relay-ingress', protocol); if (!item || !item.deployable) { const error = new Error(`Unsupported relay ingress: ${protocol}`); error.statusCode = 400; throw error; } } function validateProtocolPair(relayProtocol, exitProtocol) { validateEntryProtocol(relayProtocol); const exit = profile('exit-transport', exitProtocol); if (!exit || !exit.deployable) { const error = new Error(`Unsupported exit transport: ${exitProtocol}`); error.statusCode = 400; throw error; } } function isRealityProtocol(protocol) { return ['vless-reality-vision', 'vless-reality'].includes(protocol); } module.exports = { PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, nodeEmail, newNodeClientCredentials, buildNodeResource, buildNodeExitResource, ensureNodeSecrets, buildClientUri, validateEntryProtocol, validateProtocolPair, isRealityProtocol, resolveBindAddress };