#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const http = require('http'); const { execSync, exec } = require('child_process'); // Auto-install js-yaml if missing try { require('js-yaml'); } catch (e) { console.log('js-yaml module not found. Installing it via npm...'); try { execSync('npm install js-yaml', { stdio: 'inherit' }); } catch (err) { console.error('Failed to install js-yaml:', err.message); process.exit(1); } } const yaml = require('js-yaml'); function getProtoName(typeStr) { const t = typeStr.toLowerCase(); if (t === 'hysteria2') return 'hy2'; if (t === 'ss' || t === 'shadowsocks') return 'ss'; return t; } function parseSpeed(speedStr) { if (!speedStr) return null; const m = String(speedStr).match(/(\d+)/); return m ? parseInt(m[1], 10) : null; } function clashToSingbox(node) { const sb = {}; const t = (node.type || '').toLowerCase(); sb.tag = node.name; sb.server = node.server; sb.server_port = parseInt(node.port || 0, 10); if (t === 'hysteria2') { sb.type = 'hysteria2'; sb.password = node.password; const up = parseSpeed(node.up); if (up !== null) sb.up_mbps = up; const down = parseSpeed(node.down); if (down !== null) sb.down_mbps = down; const tls = { enabled: true }; if (node.sni) tls.server_name = node.sni; if (node.hasOwnProperty('skip-cert-verify')) tls.insecure = !!node['skip-cert-verify']; if (node.alpn) tls.alpn = node.alpn; sb.tls = tls; } else if (t === 'vless') { sb.type = 'vless'; sb.uuid = node.uuid; sb.flow = node.flow || ''; sb.network = node.network || 'tcp'; if (node['packet-encoding']) sb.packet_encoding = node['packet-encoding']; const tls = { enabled: false }; if (node.tls) { tls.enabled = true; if (node.servername) tls.server_name = node.servername; if (node.hasOwnProperty('skip-cert-verify')) tls.insecure = !!node['skip-cert-verify']; if (node.alpn) tls.alpn = node.alpn; if (node['reality-opts'] || (node.opts && node.opts.reality)) { const ropts = node['reality-opts'] || {}; tls.reality = { enabled: true, public_key: ropts['public-key'] || '', short_id: ropts['short-id'] || '' }; } if (node['client-fingerprint']) { tls.utls = { enabled: true, fingerprint: node['client-fingerprint'] }; } } sb.tls = tls; } else if (t === 'tuic') { sb.type = 'tuic'; sb.uuid = node.uuid; sb.password = node.password; if (node['congestion-controller']) sb.congestion_control = node['congestion-controller']; if (node['udp-relay-mode']) sb.udp_relay_mode = node['udp-relay-mode']; if (node['reduce-rtt']) sb.zero_rtt_handshake = !!node['reduce-rtt']; const tls = { enabled: true }; if (node.hasOwnProperty('disable-sni')) tls.disable_sni = !!node['disable-sni']; if (node.hasOwnProperty('skip-cert-verify')) tls.insecure = !!node['skip-cert-verify']; if (node.alpn) tls.alpn = node.alpn; sb.tls = tls; } else if (t === 'ss' || t === 'shadowsocks') { sb.type = 'shadowsocks'; sb.method = node.cipher; sb.password = node.password; } else if (t === 'trojan') { sb.type = 'trojan'; sb.password = node.password; const tls = { enabled: true }; if (node.sni) tls.server_name = node.sni; if (node.hasOwnProperty('skip-cert-verify')) tls.insecure = !!node['skip-cert-verify']; if (node.alpn) tls.alpn = node.alpn; sb.tls = tls; } else if (t === 'vmess') { sb.type = 'vmess'; sb.uuid = node.uuid; sb.security = node.cipher || 'auto'; sb.alter_id = parseInt(node.alterId || 0, 10); const tls = { enabled: false }; if (node.tls) { tls.enabled = true; if (node.servername) tls.server_name = node.servername; if (node.hasOwnProperty('skip-cert-verify')) tls.insecure = !!node['skip-cert-verify']; } sb.tls = tls; if (node.network === 'ws') { const ws_opts = node['ws-opts'] || {}; sb.transport = { type: 'ws', path: ws_opts.path || '/', headers: ws_opts.headers || {} }; } } else { sb.type = t; for (const k in node) { if (!['name', 'port', 'type', 'server'].includes(k)) { sb[k] = node[k]; } } } return sb; } function singboxToClash(node) { const c = {}; const t = (node.type || '').toLowerCase(); c.name = node.tag; c.server = node.server; c.port = parseInt(node.server_port || 0, 10); c.udp = true; if (t === 'hysteria2') { c.type = 'hysteria2'; c.password = node.password; if (node.up_mbps) c.up = `${node.up_mbps} Mbps`; if (node.down_mbps) c.down = `${node.down_mbps} Mbps`; const tls = node.tls || {}; if (tls.enabled) { if (tls.server_name) c.sni = tls.server_name; if (tls.hasOwnProperty('insecure')) c['skip-cert-verify'] = !!tls.insecure; if (tls.alpn) c.alpn = tls.alpn; } } else if (t === 'vless') { c.type = 'vless'; c.uuid = node.uuid; c.flow = node.flow || ''; c.network = node.network || 'tcp'; if (node.packet_encoding) c['packet-encoding'] = node.packet_encoding; const tls = node.tls || {}; if (tls.enabled) { c.tls = true; if (tls.server_name) c.servername = tls.server_name; if (tls.hasOwnProperty('insecure')) c['skip-cert-verify'] = !!tls.insecure; if (tls.alpn) c.alpn = tls.alpn; const reality = tls.reality || {}; if (reality.enabled) { c['reality-opts'] = { 'public-key': reality.public_key || '', 'short-id': reality.short_id || '' }; } const utls = tls.utls || {}; if (utls.enabled && utls.fingerprint) { c['client-fingerprint'] = utls.fingerprint; } } else { c.tls = false; } } else if (t === 'tuic') { c.type = 'tuic'; c.version = 5; c.uuid = node.uuid; c.password = node.password; if (node.congestion_control) c['congestion-controller'] = node.congestion_control; if (node.udp_relay_mode) c['udp-relay-mode'] = node.udp_relay_mode; if (node.hasOwnProperty('zero_rtt_handshake')) c['reduce-rtt'] = !!node.zero_rtt_handshake; const tls = node.tls || {}; if (tls.enabled) { if (tls.hasOwnProperty('disable_sni')) c['disable-sni'] = !!tls.disable_sni; if (tls.hasOwnProperty('insecure')) c['skip-cert-verify'] = !!tls.insecure; if (tls.alpn) c.alpn = tls.alpn; } } else if (t === 'shadowsocks') { c.type = 'ss'; c.cipher = node.method; c.password = node.password; } else if (t === 'trojan') { c.type = 'trojan'; c.password = node.password; const tls = node.tls || {}; if (tls.enabled) { if (tls.server_name) c.sni = tls.server_name; if (tls.hasOwnProperty('insecure')) c['skip-cert-verify'] = !!tls.insecure; if (tls.alpn) c.alpn = tls.alpn; } } else if (t === 'vmess') { c.type = 'vmess'; c.uuid = node.uuid; c.cipher = node.security || 'auto'; c.alterId = parseInt(node.alter_id || 0, 10); const tls = node.tls || {}; if (tls.enabled) { c.tls = true; if (tls.server_name) c.servername = tls.server_name; if (tls.hasOwnProperty('insecure')) c['skip-cert-verify'] = !!tls.insecure; } else { c.tls = false; } const trans = node.transport || {}; if (trans.type === 'ws') { c.network = 'ws'; c['ws-opts'] = { path: trans.path || '/', headers: trans.headers || {} }; } } else { c.type = t; for (const k in node) { if (!['tag', 'server_port', 'type', 'server'].includes(k)) { c[k] = node[k]; } } } return c; } async function loadSubscriptionContent(link) { if (link.startsWith('http://') || link.startsWith('https://')) { try { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), 15000); const res = await fetch(link, { headers: { 'User-Agent': 'clash-meta; sing-box; Mozilla/5.0' }, signal: controller.signal }); clearTimeout(id); if (!res.ok) { throw new Error(`HTTP error! status: ${res.status}`); } return await res.text(); } catch (e) { console.error(`Error fetching ${link}:`, e.message); return null; } } else { try { return fs.readFileSync(link, 'utf-8'); } catch (e) { console.error(`Error reading local file ${link}:`, e.message); return null; } } } function parseClashNodes(content) { try { const data = yaml.load(content); if (data && typeof data === 'object' && Array.isArray(data.proxies)) { return data.proxies; } } catch (e) { console.error(`Error parsing Clash YAML:`, e.message); } return []; } function parseSingboxNodes(content) { try { const data = JSON.parse(content); if (data && typeof data === 'object') { const outbounds = data.outbounds || []; return outbounds.filter(o => o && o.type && !['selector', 'urltest', 'direct', 'block', 'dns'].includes(o.type) ); } } catch (e) { console.error(`Error parsing Sing-box JSON:`, e.message); } return []; } const usedNames = new Set(); function renameNode(node, subName, isClash = true) { const typeStr = (node.type || '').toLowerCase(); const protoClean = getProtoName(typeStr); const baseName = `${subName}-${protoClean}`; let newName = baseName; let counter = 1; while (usedNames.has(newName.toLowerCase())) { newName = `${baseName}-${counter}`; counter++; } usedNames.add(newName.toLowerCase()); if (isClash) { node.name = newName; } else { node.tag = newName; } return newName; } function detectTemplates() { let yamlTpl = 'CatMata.optimized.yaml'; if (!fs.existsSync(yamlTpl)) { const yamls = fs.readdirSync('.').filter(f => f.endsWith('.yaml') || f.endsWith('.yml')); const matched = yamls.find(f => f.includes('CatMata')); if (matched) yamlTpl = matched; } let jsonTpl = 'CatMata.json'; if (!fs.existsSync(jsonTpl)) { if (fs.existsSync('CatMata-tun.json')) { jsonTpl = 'CatMata-tun.json'; } else { const jsons = fs.readdirSync('.').filter(f => f.endsWith('.json') && f !== 'subs.json'); const matched = jsons.find(f => f.includes('CatMata')); if (matched) jsonTpl = matched; } } return { yamlTpl, jsonTpl }; } function replaceNodeRefs(data, oldNodesSet, fallbackNode) { if (data && typeof data === 'object') { if (Array.isArray(data)) { for (const item of data) { replaceNodeRefs(item, oldNodesSet, fallbackNode); } } else { for (const k in data) { if ((k === 'detour' || k === 'download_detour') && typeof data[k] === 'string') { if (oldNodesSet.has(data[k])) { data[k] = fallbackNode; } } else { replaceNodeRefs(data[k], oldNodesSet, fallbackNode); } } } } } // Configuration paths const subsFile = 'subs.json'; const outDir = 'out'; const PORT = 3000; // Central configuration generation function function generateConfigs(rawClashNodes, dns = null) { const { yamlTpl, jsonTpl } = detectTemplates(); if (!fs.existsSync(outDir)) { fs.mkdirSync(outDir, { recursive: true }); } // 1. Process Clash Config const clashConfig = yaml.load(fs.readFileSync(yamlTpl, 'utf-8')) || {}; clashConfig.proxies = rawClashNodes; if (dns && dns.clash) { clashConfig.dns = yaml.load(dns.clash); } const groupNames = new Set((clashConfig['proxy-groups'] || []).map(g => g.name)); const specialNames = new Set(['DIRECT', 'REJECT', 'PASS', 'COMPATIBLE']); const newNodeNames = rawClashNodes.map(n => n.name); for (const group of (clashConfig['proxy-groups'] || [])) { const oldProxies = group.proxies || []; const nonNodeItems = oldProxies.filter(p => groupNames.has(p) || specialNames.has(p)); const hasNodes = oldProxies.some(p => !groupNames.has(p) && !specialNames.has(p)); if (hasNodes || nonNodeItems.length === 0) { group.proxies = [...nonNodeItems, ...newNodeNames]; } } const outYamlPath = path.join(outDir, 'CatMata-sub.yaml'); fs.writeFileSync(outYamlPath, yaml.dump(clashConfig, { noRefs: true, sortKeys: false }), 'utf-8'); // 2. Process Sing-box Config const sbConfig = JSON.parse(fs.readFileSync(jsonTpl, 'utf-8')) || {}; const allSingboxNodes = rawClashNodes.map(n => { const nodeCopy = JSON.parse(JSON.stringify(n)); return clashToSingbox(nodeCopy); }); if (dns && dns.singbox) { sbConfig.dns = JSON.parse(dns.singbox); } const oldNodesSet = new Set(); const preservedOutbounds = []; for (const o of (sbConfig.outbounds || [])) { if (['selector', 'urltest', 'direct', 'block', 'dns'].includes(o.type)) { preservedOutbounds.push(o); } else { oldNodesSet.add(o.tag); } } const groupTags = new Set(preservedOutbounds.filter(o => ['selector', 'urltest'].includes(o.type)).map(o => o.tag)); const specialTags = new Set(['direct', 'block', 'dns']); const newNodeTags = allSingboxNodes.map(n => n.tag); for (const o of preservedOutbounds) { if (['selector', 'urltest'].includes(o.type)) { const oldList = o.outbounds || []; const nonNodeTags = oldList.filter(t => groupTags.has(t) || specialTags.has(t)); const hasNodes = oldList.some(t => !groupTags.has(t) && !specialTags.has(t)); if (hasNodes || nonNodeTags.length === 0) { o.outbounds = [...nonNodeTags, ...newNodeTags]; } if (o.default && oldNodesSet.has(o.default)) { o.default = newNodeTags[0]; } } } replaceNodeRefs(sbConfig, oldNodesSet, newNodeTags[0]); sbConfig.outbounds = [...preservedOutbounds, ...allSingboxNodes]; const outJsonPath = path.join(outDir, 'CatMata-sub.json'); fs.writeFileSync(outJsonPath, JSON.stringify(sbConfig, null, 2), 'utf-8'); // Archive in history const historyDir = path.join(outDir, 'history'); if (!fs.existsSync(historyDir)) { fs.mkdirSync(historyDir, { recursive: true }); } const now = new Date(); const pad = n => String(n).padStart(2, '0'); const timestampFile = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; const timeStr = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; const histYamlName = `CatMata-sub-${timestampFile}.yaml`; const histJsonName = `CatMata-sub-${timestampFile}.json`; fs.writeFileSync(path.join(historyDir, histYamlName), fs.readFileSync(outYamlPath)); fs.writeFileSync(path.join(historyDir, histJsonName), fs.readFileSync(outJsonPath)); const historyJsonPath = path.join(historyDir, 'history.json'); let historyList = []; if (fs.existsSync(historyJsonPath)) { try { historyList = JSON.parse(fs.readFileSync(historyJsonPath, 'utf-8')); } catch (e) { console.error("Failed to parse history.json, resetting:", e.message); } } historyList.unshift({ timestamp: now.toISOString(), timeStr: timeStr, yamlFile: histYamlName, jsonFile: histJsonName, nodesCount: rawClashNodes.length }); fs.writeFileSync(historyJsonPath, JSON.stringify(historyList, null, 2), 'utf-8'); return { yamlPath: outYamlPath, jsonPath: outJsonPath }; } function openBrowser(url) { let cmd; if (process.platform === 'win32') { cmd = `start "" "${url}"`; } else if (process.platform === 'darwin') { cmd = `open "${url}"`; } else { cmd = `xdg-open "${url}"`; } exec(cmd, err => { if (err) console.error('Failed to open browser:', err); }); } function readJsonBody(req) { return new Promise((resolve, reject) => { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', () => { try { resolve(JSON.parse(body || '{}')); } catch (e) { reject(e); } }); req.on('error', err => reject(err)); }); } // HTTP Server setup function startServer() { const server = http.createServer(async (req, res) => { const parsedUrl = new URL(req.url, `http://${req.headers.host}`); const pathname = parsedUrl.pathname; // Static Index file if (pathname === '/' || pathname === '/index.html') { const htmlPath = path.join(__dirname, 'public', 'index.html'); if (fs.existsSync(htmlPath)) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(fs.readFileSync(htmlPath)); } else { res.writeHead(404); res.end('index.html not found'); } return; } // API: GET /api/subs if (pathname === '/api/subs' && req.method === 'GET') { if (fs.existsSync(subsFile)) { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(fs.readFileSync(subsFile)); } else { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify([])); } return; } // API: POST /api/subs if (pathname === '/api/subs' && req.method === 'POST') { try { const payload = await readJsonBody(req); fs.writeFileSync(subsFile, JSON.stringify(payload, null, 2), 'utf-8'); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true })); } catch (e) { res.writeHead(500); res.end(e.message); } return; } // API: GET /api/dns if (pathname === '/api/dns' && req.method === 'GET') { try { const { yamlTpl, jsonTpl } = detectTemplates(); const clashConfig = yaml.load(fs.readFileSync(yamlTpl, 'utf-8')) || {}; const sbConfig = JSON.parse(fs.readFileSync(jsonTpl, 'utf-8')) || {}; const clashDnsStr = yaml.dump(clashConfig.dns || {}, { noRefs: true, sortKeys: false }); const sbDnsStr = JSON.stringify(sbConfig.dns || {}, null, 2); res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ clash: clashDnsStr, singbox: sbDnsStr })); } catch (e) { res.writeHead(500); res.end(e.message); } return; } // API: POST /api/fetch (Fetches and converts in-memory) if (pathname === '/api/fetch' && req.method === 'POST') { try { if (!fs.existsSync(subsFile)) { res.writeHead(400); res.end('Subscription config subs.json is missing'); return; } const subs = JSON.parse(fs.readFileSync(subsFile, 'utf-8')); const allClashNodes = []; usedNames.clear(); for (const sub of subs) { const subName = sub.name || 'Sub'; const subLink = sub.link; const subType = (sub.type || 'clash').toLowerCase(); if (!subLink) continue; const content = await loadSubscriptionContent(subLink); if (!content) continue; if (subType === 'clash') { const nodes = parseClashNodes(content); for (const node of nodes) { const cNode = JSON.parse(JSON.stringify(node)); renameNode(cNode, subName, true); allClashNodes.push(cNode); } } else if (subType === 'singbox') { const nodes = parseSingboxNodes(content); for (const node of nodes) { const sbNode = JSON.parse(JSON.stringify(node)); renameNode(sbNode, subName, false); const cNode = singboxToClash(sbNode); allClashNodes.push(cNode); } } } res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ proxies: allClashNodes })); } catch (e) { res.writeHead(500); res.end(e.message); } return; } // API: POST /api/generate (Generates and writes configurations) if (pathname === '/api/generate' && req.method === 'POST') { try { const { proxies: rawClashNodes, dns } = await readJsonBody(req); if (!rawClashNodes || !Array.isArray(rawClashNodes) || rawClashNodes.length === 0) { res.writeHead(400); res.end('No proxies supplied'); return; } generateConfigs(rawClashNodes, dns); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true })); } catch (e) { res.writeHead(500); res.end(e.message); } return; } // API: GET /api/history if (pathname === '/api/history' && req.method === 'GET') { const historyJsonPath = path.join(outDir, 'history', 'history.json'); if (fs.existsSync(historyJsonPath)) { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(fs.readFileSync(historyJsonPath)); } else { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify([])); } return; } // API: GET /api/download if (pathname === '/api/download' && req.method === 'GET') { const fileName = parsedUrl.searchParams.get('file'); if (!fileName) { res.writeHead(400); res.end('Missing file parameter'); return; } // Security check: prevent directory traversal const safeRegex = /^CatMata-sub-\d{8}-\d{6}\.(yaml|json)$/; if (!safeRegex.test(fileName)) { res.writeHead(400); res.end('Invalid file name'); return; } const filePath = path.join(outDir, 'history', fileName); if (fs.existsSync(filePath)) { const isYaml = fileName.endsWith('.yaml'); res.writeHead(200, { 'Content-Type': isYaml ? 'application/x-yaml' : 'application/json; charset=utf-8', 'Content-Disposition': `attachment; filename="${fileName}"` }); res.end(fs.readFileSync(filePath)); } else { res.writeHead(404); res.end('File not found'); } return; } res.writeHead(404); res.end('Not Found'); }); server.listen(PORT, () => { console.log(`CatMata Sub2Proxy 控制面板已启动!`); console.log(`本地访问地址: http://localhost:${PORT}`); console.log(`正在自动打开默认浏览器...`); openBrowser(`http://localhost:${PORT}`); }); } // Legacy CLI Mode fallback async function runCli() { const detected = detectTemplates(); const yamlTpl = detected.yamlTpl; const jsonTpl = detected.jsonTpl; console.log(`CLI 编译模式:`); console.log(`Clash 模板: ${yamlTpl}`); console.log(`Sing-box 模板: ${jsonTpl}`); if (!fs.existsSync(subsFile)) { console.error(`Error: subs.json not found.`); process.exit(1); } const subs = JSON.parse(fs.readFileSync(subsFile, 'utf-8')); const allClashNodes = []; usedNames.clear(); for (const sub of subs) { const subName = sub.name || 'Sub'; const subLink = sub.link; const subType = (sub.type || 'clash').toLowerCase(); if (!subLink) continue; const content = await loadSubscriptionContent(subLink); if (!content) continue; if (subType === 'clash') { const nodes = parseClashNodes(content); for (const node of nodes) { const cNode = JSON.parse(JSON.stringify(node)); const newName = renameNode(cNode, subName, true); allClashNodes.push(cNode); } } else if (subType === 'singbox') { const nodes = parseSingboxNodes(content); for (const node of nodes) { const sbNode = JSON.parse(JSON.stringify(node)); const newName = renameNode(sbNode, subName, false); const cNode = singboxToClash(sbNode); allClashNodes.push(cNode); } } } if (allClashNodes.length === 0) { console.error("未拉取到任何代理节点。"); process.exit(1); } try { const result = generateConfigs(allClashNodes); console.log(`生成 Clash 配置成功: ${result.yamlPath}`); console.log(`生成 Sing-box 配置成功: ${result.jsonPath}`); } catch (e) { console.error("生成配置失败:", e.message); } } // Start Main Routine const args = process.argv.slice(2); if (args.includes('--cli') || args.includes('-c')) { runCli(); } else { startServer(); }