#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const http = require('http'); const crypto = require('crypto'); 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 originalName = isClash ? (node.name || node.tag || '') : (node.tag || node.name || ''); 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()); node.proxyName = originalName || newName; if (isClash) { node.name = newName; } else { node.tag = newName; } return newName; } function detectTemplates() { const tplDir = 'templates'; let yamlTpl = path.join(tplDir, 'CatMata.optimized.yaml'); let jsonTpl = path.join(tplDir, 'CatMata.json'); if (!fs.existsSync(yamlTpl)) { if (fs.existsSync(tplDir)) { const yamls = fs.readdirSync(tplDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml')); const matched = yamls.find(f => f.includes('CatMata')); if (matched) yamlTpl = path.join(tplDir, matched); } else { 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; } } } if (!fs.existsSync(jsonTpl)) { if (fs.existsSync(tplDir)) { if (fs.existsSync(path.join(tplDir, 'CatMata-tun.json'))) { jsonTpl = path.join(tplDir, 'CatMata-tun.json'); } else { const jsons = fs.readdirSync(tplDir).filter(f => f.endsWith('.json')); const matched = jsons.find(f => f.includes('CatMata')); if (matched) jsonTpl = path.join(tplDir, matched); } } else { 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 stripUiOnlyNodeFields(node) { const cleanNode = JSON.parse(JSON.stringify(node)); delete cleanNode.proxyName; return cleanNode; } 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 = Number.parseInt(process.env.PORT || '3000', 10); const HOST = process.env.HOST || '0.0.0.0'; const PUBLIC_BASE_URL = String(process.env.PUBLIC_BASE_URL || '').trim().replace(/\/+$/, ''); const AUTO_OPEN = !['0', 'false', 'no', 'off'].includes(String(process.env.AUTO_OPEN || '1').toLowerCase()); const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin'; const VIEWER_PASSWORD = process.env.VIEWER_PASSWORD || 'viewer'; const SESSION_SECRET = process.env.SESSION_SECRET || ADMIN_PASSWORD || 'sub2proxy-session-secret'; const LINK_TOKEN_SECRET = process.env.LINK_TOKEN_SECRET || SESSION_SECRET; const SESSION_COOKIE = 'sub2proxy_session'; const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; const sessions = new Map(); // Central configuration generation function function generateConfigs(rawClashNodes, dns = null) { const { yamlTpl, jsonTpl } = detectTemplates(); if (!fs.existsSync(outDir)) { fs.mkdirSync(outDir, { recursive: true }); } const clashNodes = rawClashNodes.map(stripUiOnlyNodeFields); // 1. Process Clash Config const clashConfig = yaml.load(fs.readFileSync(yamlTpl, 'utf-8')) || {}; clashConfig.proxies = clashNodes; 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 = clashNodes.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 = clashNodes.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)); }); } function sendJson(res, statusCode, payload) { res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(payload)); } function serveHtml(res, fileName) { const htmlPath = path.join(__dirname, 'public', fileName); 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(`${fileName} not found`); } } function redirect(res, location) { res.writeHead(302, { Location: location }); res.end(); } function parseCookies(req) { const header = req.headers.cookie || ''; const cookies = {}; header.split(';').forEach(part => { const index = part.indexOf('='); if (index === -1) return; const key = part.slice(0, index).trim(); const value = part.slice(index + 1).trim(); if (key) { try { cookies[key] = decodeURIComponent(value); } catch (e) { cookies[key] = value; } } }); return cookies; } function setSessionCookie(res, token) { const maxAge = Math.floor(SESSION_TTL_MS / 1000); res.setHeader('Set-Cookie', `${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`); } function clearSessionCookie(res) { res.setHeader('Set-Cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`); } function safeCompare(a, b) { const left = Buffer.from(String(a || '')); const right = Buffer.from(String(b || '')); if (left.length !== right.length) return false; return crypto.timingSafeEqual(left, right); } function authenticatePassword(password, requestedRole = 'admin') { if (requestedRole === 'viewer' && safeCompare(password, VIEWER_PASSWORD)) { return 'viewer'; } if (safeCompare(password, ADMIN_PASSWORD)) { return 'admin'; } return null; } function createSession(role) { const token = crypto.randomBytes(32).toString('hex'); sessions.set(token, { role, expiresAt: Date.now() + SESSION_TTL_MS }); return token; } function getSession(req) { const token = parseCookies(req)[SESSION_COOKIE]; if (!token) return null; const session = sessions.get(token); if (!session) return null; if (session.expiresAt < Date.now()) { sessions.delete(token); return null; } session.expiresAt = Date.now() + SESSION_TTL_MS; return session; } function hasRole(req, roles) { const session = getSession(req); if (!session) return false; if (roles.includes(session.role)) return true; return roles.includes('viewer') && session.role === 'admin'; } function requireRole(req, res, roles) { if (hasRole(req, roles)) return true; sendJson(res, 401, { error: 'Unauthorized' }); return false; } function getHistoryList() { const historyJsonPath = path.join(outDir, 'history', 'history.json'); if (!fs.existsSync(historyJsonPath)) return []; try { return JSON.parse(fs.readFileSync(historyJsonPath, 'utf-8')); } catch (e) { console.error("Failed to parse history.json:", e.message); return []; } } function signFileName(fileName) { return crypto.createHmac('sha256', LINK_TOKEN_SECRET).update(fileName).digest('hex'); } function withFileTokens(historyList) { return historyList.map(item => ({ ...item, yamlToken: signFileName(item.yamlFile), jsonToken: signFileName(item.jsonFile) })); } function hasFileAccess(req, fileName, token) { if (hasRole(req, ['admin', 'viewer'])) return true; return Boolean(token) && safeCompare(token, signFileName(fileName)); } // 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; if (pathname === '/login' || pathname === '/login.html') { serveHtml(res, 'login.html'); return; } if (pathname === '/viewer' || pathname === '/viewer.html') { redirect(res, '/sub'); return; } if (pathname === '/sub' || pathname === '/sub/') { serveHtml(res, 'viewer.html'); return; } if (pathname === '/' || pathname === '/index.html') { redirect(res, '/config'); return; } // Static Index file if (pathname === '/config' || pathname === '/config/') { if (!hasRole(req, ['admin'])) { redirect(res, '/login.html'); return; } serveHtml(res, 'index.html'); return; } // API: GET /api/health if (pathname === '/api/health' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ ok: true })); return; } // API: GET /api/session if (pathname === '/api/session' && req.method === 'GET') { const session = getSession(req); sendJson(res, 200, { authenticated: Boolean(session), role: session ? session.role : null }); return; } // API: POST /api/login if (pathname === '/api/login' && req.method === 'POST') { try { const { password, role } = await readJsonBody(req); const nextRole = authenticatePassword(password, role); if (!nextRole) { sendJson(res, 401, { error: '密码不正确' }); return; } const token = createSession(nextRole); setSessionCookie(res, token); sendJson(res, 200, { success: true, role: nextRole, redirectTo: nextRole === 'admin' ? '/config' : '/sub' }); } catch (e) { sendJson(res, 400, { error: e.message }); } return; } // API: POST /api/logout if (pathname === '/api/logout' && req.method === 'POST') { clearSessionCookie(res); sendJson(res, 200, { success: true }); return; } // API: GET /api/app-config if (pathname === '/api/app-config' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ publicBaseUrl: PUBLIC_BASE_URL })); return; } // API: GET /api/subs if (pathname === '/api/subs' && req.method === 'GET') { if (!requireRole(req, res, ['admin'])) return; 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') { if (!requireRole(req, res, ['admin'])) return; 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') { if (!requireRole(req, res, ['admin'])) return; 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') { if (!requireRole(req, res, ['admin'])) return; 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); cNode.proxyName = sbNode.proxyName || cNode.name; 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') { if (!requireRole(req, res, ['admin'])) return; 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') { if (!requireRole(req, res, ['admin', 'viewer'])) return; sendJson(res, 200, withFileTokens(getHistoryList())); return; } // API: GET /api/download if (pathname === '/api/download' && req.method === 'GET') { const fileName = parsedUrl.searchParams.get('file'); const token = parsedUrl.searchParams.get('token'); 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; } if (!hasFileAccess(req, fileName, token)) { res.writeHead(401); res.end('Unauthorized'); 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; } // API: GET /api/profile // Remote import clients should receive the profile body directly, not as a browser attachment. if (pathname === '/api/profile' && req.method === 'GET') { const fileName = parsedUrl.searchParams.get('file'); const token = parsedUrl.searchParams.get('token'); if (!fileName) { res.writeHead(400); res.end('Missing file parameter'); return; } const safeRegex = /^CatMata-sub-\d{8}-\d{6}\.(yaml|json)$/; if (!safeRegex.test(fileName)) { res.writeHead(400); res.end('Invalid file name'); return; } if (!hasFileAccess(req, fileName, token)) { res.writeHead(401); res.end('Unauthorized'); return; } const filePath = path.join(outDir, 'history', fileName); if (fs.existsSync(filePath)) { const isYaml = fileName.endsWith('.yaml'); res.writeHead(200, { 'Content-Type': isYaml ? 'text/yaml; charset=utf-8' : 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': '*' }); res.end(fs.readFileSync(filePath)); } else { res.writeHead(404); res.end('File not found'); } return; } // API: POST /api/history/delete if (pathname === '/api/history/delete' && req.method === 'POST') { if (!requireRole(req, res, ['admin'])) return; try { const { yamlFile, jsonFile } = await readJsonBody(req); if (!yamlFile || !jsonFile) { res.writeHead(400); res.end('Missing yamlFile or jsonFile parameter'); return; } // Security check: prevent directory traversal const safeRegex = /^CatMata-sub-\d{8}-\d{6}\.(yaml|json)$/; if (!safeRegex.test(yamlFile) || !safeRegex.test(jsonFile)) { res.writeHead(400); res.end('Invalid file name'); return; } const historyDir = path.join(outDir, 'history'); const yamlPath = path.join(historyDir, yamlFile); const jsonPath = path.join(historyDir, jsonFile); // Delete physical files if (fs.existsSync(yamlPath)) { fs.unlinkSync(yamlPath); } if (fs.existsSync(jsonPath)) { fs.unlinkSync(jsonPath); } // Update history.json 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 = historyList.filter(item => item.yamlFile !== yamlFile || item.jsonFile !== jsonFile); fs.writeFileSync(historyJsonPath, JSON.stringify(historyList, 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; } res.writeHead(404); res.end('Not Found'); }); server.listen(PORT, HOST, () => { const displayHost = HOST === '0.0.0.0' || HOST === '::' ? 'localhost' : HOST; console.log(`CatMata Sub2Proxy 控制面板已启动!`); console.log(`访问地址: http://${displayHost}:${PORT}`); if (PUBLIC_BASE_URL) { console.log(`客户端导入地址基准: ${PUBLIC_BASE_URL}`); } if (AUTO_OPEN) { 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(); }