816 lines
25 KiB
JavaScript
816 lines
25 KiB
JavaScript
#!/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() {
|
|
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 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();
|
|
}
|