'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)}` }; }); } 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) }; 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','vmess'].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)}`; }); 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 }; 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 }; 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 = entries(data, customer.id); 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, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription };