215 lines
12 KiB
JavaScript
215 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
const { buildClientUri, nodeEmail } = require('./protocols');
|
|
const { dedupKey, withName, nameFromUri } = require('./extsub');
|
|
|
|
function nodeSortIndex(data, nodeId) {
|
|
const node = data.nodes.find((item) => item.id === nodeId);
|
|
return node && Number.isFinite(node.sortIndex) ? node.sortIndex : Number.MAX_SAFE_INTEGER;
|
|
}
|
|
|
|
// 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 = [];
|
|
// Own nodes follow the panel-wide custom order (sortIndex); the sort is
|
|
// stable, so equal/unset indexes keep the binding creation order.
|
|
const bindings = data.nodeClients.filter((nc) => nc.customerId === customerId && nc.status === 'active');
|
|
bindings.sort((a, b) => nodeSortIndex(data, a.nodeId) - nodeSortIndex(data, b.nodeId));
|
|
for (const nodeClient of bindings) {
|
|
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;
|
|
}
|
|
|
|
// Full subscription item list: own nodes (sortIndex order), then manual
|
|
// links (list order), then external sources (source order, cached order).
|
|
// Global dedup by scheme+host+port+credential fingerprint, own first.
|
|
// Name collisions across segments get the " (n)" suffix treatment.
|
|
function aggregateEntries(data, customer) {
|
|
const items = entries(data, customer.id);
|
|
const usedKeys = new Set();
|
|
const usedNames = new Set();
|
|
for (const item of items) {
|
|
const key = dedupKey(item.clientUri);
|
|
if (key) usedKeys.add(key);
|
|
usedNames.add(nameFromUri(item.clientUri));
|
|
}
|
|
const append = (uri, rawName) => {
|
|
const key = dedupKey(uri);
|
|
if (key && usedKeys.has(key)) return;
|
|
let name = rawName || '未命名节点';
|
|
if (usedNames.has(name)) {
|
|
let index = 2;
|
|
while (usedNames.has(`${name} (${index})`)) index += 1;
|
|
name = `${name} (${index})`;
|
|
}
|
|
if (key) usedKeys.add(key);
|
|
usedNames.add(name);
|
|
items.push({ nodeId: '', clientUri: withName(uri, name) });
|
|
};
|
|
(customer.manualLinks || []).forEach((link, index) => {
|
|
if (!link.enabled) return;
|
|
append(link.uri, link.remark || nameFromUri(link.uri) || `手动节点 ${index + 1}`);
|
|
});
|
|
for (const source of customer.externalSources || []) {
|
|
if (!source.enabled) continue;
|
|
const cache = (data.subSourceCache || {})[source.id];
|
|
if (!cache || !Array.isArray(cache.items)) continue;
|
|
for (const cached of cache.items) {
|
|
append(cached.uri, `${source.remark ? `[${source.remark}] ` : ''}${cached.name || '未命名节点'}`);
|
|
}
|
|
}
|
|
return items;
|
|
}
|
|
|
|
function q(value) { return JSON.stringify(String(value)); }
|
|
|
|
function clashProxy(item) {
|
|
const uri = item.clientUri;
|
|
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) };
|
|
if (url.protocol === 'hysteria2:') return { ...base, type:'hysteria2', password:decodeURIComponent(url.username), sni:url.searchParams.get('sni'), alpn:['h3'] };
|
|
if (url.protocol === 'anytls:') return { ...base, type:'anytls', password:decodeURIComponent(url.username), sni:url.searchParams.get('sni'), udp:true };
|
|
if (url.protocol === 'vless:') {
|
|
const security = url.searchParams.get('security');
|
|
const proxy = { ...base, type:'vless', uuid:decodeURIComponent(url.username), tls:['reality','tls'].includes(security), network:url.searchParams.get('type') === 'ws' ? 'ws' : 'tcp' };
|
|
if (proxy.network === 'ws') proxy['ws-opts'] = { path:url.searchParams.get('path') || '/' };
|
|
if (proxy.tls) {
|
|
proxy.servername = url.searchParams.get('sni');
|
|
if (security === 'reality') {
|
|
proxy['client-fingerprint'] = url.searchParams.get('fp') || 'chrome';
|
|
proxy['reality-opts'] = { 'public-key':url.searchParams.get('pbk'), 'short-id':url.searchParams.get('sid') };
|
|
}
|
|
if (url.searchParams.has('flow')) proxy.flow = url.searchParams.get('flow');
|
|
}
|
|
return proxy;
|
|
}
|
|
if (url.protocol === 'ss:') {
|
|
const decoded = Buffer.from(url.username, 'base64url').toString('utf8');
|
|
const separator = decoded.indexOf(':');
|
|
if (separator < 0) throw new Error('Invalid Shadowsocks URI');
|
|
return { ...base, type:'ss', cipher:decoded.slice(0, separator), password:decoded.slice(separator + 1) };
|
|
}
|
|
if (url.protocol === 'socks5:') return { ...base, type:'socks5', username:decodeURIComponent(url.username), password:decodeURIComponent(url.password) };
|
|
throw new Error('Unsupported client URI');
|
|
}
|
|
|
|
function yamlValue(value, indent = 0) {
|
|
// JSON flow sequences are valid YAML and avoid an incorrectly indented
|
|
// scalar when a proxy has ALPN or other list-valued options.
|
|
if (Array.isArray(value)) return JSON.stringify(value);
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
return Object.entries(value).map(([key, child]) => `${' '.repeat(indent)}${key}: ${child && typeof child === 'object' && !Array.isArray(child) ? `\n${yamlValue(child, indent + 2)}` : yamlValue(child)}`).join('\n');
|
|
}
|
|
if (typeof value === 'boolean' || typeof value === 'number') return String(value);
|
|
return q(value);
|
|
}
|
|
|
|
function makeClash(items, smart = false) {
|
|
const reserved = smart ? ['节点选择','自动选择','流媒体','AI 服务','Meta 服务','DIRECT','REJECT']
|
|
: ['节点选择','DIRECT','REJECT'];
|
|
const used = new Set(reserved);
|
|
const proxies = items.map(clashProxy).map((proxy) => {
|
|
let name = proxy.name;
|
|
if (used.has(name)) {
|
|
let index = 1;
|
|
do { name = `${proxy.name} (节点${index === 1 ? '' : ` ${index}`})`; index += 1; } while (used.has(name));
|
|
}
|
|
used.add(name);
|
|
return { ...proxy, name };
|
|
});
|
|
const names = proxies.map((item) => item.name);
|
|
const yaml = proxies.map((proxy) => yamlValue(proxy, 4).replace(/^ /, ' - ')).join('\n');
|
|
const group = (name, type, members) => ` - name: ${q(name)}\n type: ${type}\n${type === 'url-test' ? ' url: "https://www.gstatic.com/generate_204"\n interval: 300\n tolerance: 80\n' : ''} proxies:\n${members.map((item) => ` - ${q(item)}`).join('\n')}\n`;
|
|
const groups = smart
|
|
? group('节点选择','select',['自动选择',...names,'DIRECT']) + group('自动选择','url-test',names) + group('流媒体','select',['节点选择','自动选择',...names]) + group('AI 服务','select',['节点选择','自动选择',...names]) + group('Meta 服务','select',['节点选择','自动选择',...names])
|
|
: group('节点选择','select',names);
|
|
const rules = smart ? [
|
|
'GEOSITE,category-ads-all,REJECT', 'GEOSITE,private,DIRECT',
|
|
'GEOSITE,openai,AI 服务', 'GEOSITE,netflix,流媒体',
|
|
'GEOSITE,facebook,Meta 服务', 'DOMAIN-SUFFIX,fbcdn.net,Meta 服务',
|
|
'DOMAIN-SUFFIX,instagram.com,Meta 服务', 'DOMAIN-SUFFIX,whatsapp.net,Meta 服务',
|
|
'GEOSITE,geolocation-!cn,节点选择', 'GEOSITE,cn,DIRECT',
|
|
'GEOIP,CN,DIRECT', 'MATCH,节点选择'
|
|
] : ['MATCH,节点选择'];
|
|
return `mixed-port: 7890\nallow-lan: false\nmode: rule\nproxies:\n${yaml}\nproxy-groups:\n${groups}rules:\n${rules.map((rule) => ` - ${q(rule)}`).join('\n')}\n`;
|
|
}
|
|
|
|
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'].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)}`;
|
|
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`;
|
|
}
|
|
|
|
function makeSingBox(items) {
|
|
const proxies = items.map(clashProxy).map((proxy, index) => {
|
|
const base = { tag:`node-${index + 1}`, server:proxy.server, server_port:proxy.port };
|
|
if (proxy.type === 'ss') return { type:'shadowsocks', ...base, method:proxy.cipher, password:proxy.password };
|
|
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 };
|
|
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 };
|
|
if (proxy.tls) outbound.tls = { enabled:true, server_name:proxy.servername,
|
|
...(proxy['reality-opts'] ? { reality:{ enabled:true, public_key:proxy['reality-opts']['public-key'], short_id:proxy['reality-opts']['short-id'] } } : {}) };
|
|
return outbound;
|
|
});
|
|
return JSON.stringify({
|
|
log:{ level:'warn' }, inbounds:[{ type:'mixed', tag:'local', listen:'127.0.0.1', listen_port:2080 }],
|
|
outbounds:[{ type:'selector', tag:'proxy', outbounds:proxies.map((item) => item.tag) }, ...proxies, { type:'direct', tag:'direct' }],
|
|
route:{ final:'proxy' }
|
|
}, null, 2) + '\n';
|
|
}
|
|
|
|
function formatSubscription(data, customer, format, userAgent = '') {
|
|
const items = aggregateEntries(data, customer);
|
|
if (!items.length) return null;
|
|
const selected = format === 'auto' ? (/clash|mihomo/i.test(userAgent) ? 'clash' : 'base64') : format;
|
|
if (selected === 'raw') return { body:items.map((item) => item.clientUri).join('\n') + '\n', contentType:'text/plain; charset=utf-8' };
|
|
if (selected === 'base64') return { body:Buffer.from(items.map((item) => item.clientUri).join('\n') + '\n').toString('base64'), contentType:'text/plain; charset=utf-8' };
|
|
if (selected === 'clash' || selected === 'clash-smart') return { body:makeClash(items, selected === 'clash-smart'), contentType:'text/yaml; charset=utf-8' };
|
|
if (selected === 'singbox') return { body:makeSingBox(items), contentType:'application/json; charset=utf-8' };
|
|
if (selected === 'surge') { const body = makeSurge(items); return body ? { body, contentType:'text/plain; charset=utf-8' } : null; }
|
|
return undefined;
|
|
}
|
|
|
|
module.exports = { entries, aggregateEntries, nodeClientUri, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription };
|