1141 lines
35 KiB
JavaScript
1141 lines
35 KiB
JavaScript
#!/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();
|
|
}
|