Files

380 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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:<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(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
// "<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}`;
return null;
}
// ---------- Panel-hosted Reality keys ----------
// Reality keypairs live in the panel database (data.keyStore) so that a node
// OS reinstall redeploys the SAME pair and client subscriptions never change.
// Encoding is exactly the agent-side format: Node JWK export of an x25519
// pair, whose base64url no-padding `d`/`x` values match
// `sing-box generate reality-keypair` output byte for byte.
function generateRealityKeyPair() {
const kp = crypto.generateKeyPairSync('x25519');
const priv = kp.privateKey.export({ format: 'jwk' });
const pub = kp.publicKey.export({ format: 'jwk' });
if (!priv.d || !pub.x) throw new Error('X25519 密钥生成失败');
return { privateKey: priv.d, publicKey: pub.x };
}
function realityKeyIdForNode(node) {
return (node.reality && node.reality.keyId) || `reality-${node.id}`;
}
// Returns the stored keypair for a Reality node, generating and storing one
// on first use. Non-Reality nodes return null.
function ensureRealityKey(data, node) {
if (!isRealityProtocol(node.protocol)) return null;
ensureNodeSecrets(node);
const keyId = realityKeyIdForNode(node);
data.keyStore ||= {};
let entry = data.keyStore[keyId];
if (!entry || !entry.privateKey || !entry.publicKey) {
entry = { ...generateRealityKeyPair(), updatedAt: new Date().toISOString() };
data.keyStore[keyId] = entry;
}
return entry;
}
// Payload fragment for apply_resource jobs: { "<keyId>": { privateKey, publicKey } }.
function keyMaterialForNode(data, node) {
const entry = ensureRealityKey(data, node);
if (!entry) return null;
return { [realityKeyIdForNode(node)]: { privateKey: entry.privateKey, publicKey: entry.publicKey } };
}
function dropRealityKey(data, node) {
if (data.keyStore) delete data.keyStore[realityKeyIdForNode(node)];
}
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, generateRealityKeyPair, realityKeyIdForNode, ensureRealityKey, keyMaterialForNode, dropRealityKey
};