Files
sub2proxy/sub2proxy.js
T

1199 lines
37 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 === '/sub/clash/latest' && req.method === 'GET') {
const filePath = path.join(outDir, 'CatMata-sub.yaml');
if (fs.existsSync(filePath)) {
res.writeHead(200, {
'Content-Type': 'text/yaml; charset=utf-8',
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*'
});
res.end(fs.readFileSync(filePath));
} else {
const history = getHistoryList();
if (history.length > 0 && history[0].yamlFile) {
const histPath = path.join(outDir, 'history', history[0].yamlFile);
if (fs.existsSync(histPath)) {
res.writeHead(200, {
'Content-Type': 'text/yaml; charset=utf-8',
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*'
});
res.end(fs.readFileSync(histPath));
return;
}
}
res.writeHead(404);
res.end('Latest Clash profile not found');
}
return;
}
if (pathname === '/sub/singbox/latest' && req.method === 'GET') {
const filePath = path.join(outDir, 'CatMata-sub.json');
if (fs.existsSync(filePath)) {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*'
});
res.end(fs.readFileSync(filePath));
} else {
const history = getHistoryList();
if (history.length > 0 && history[0].jsonFile) {
const histPath = path.join(outDir, 'history', history[0].jsonFile);
if (fs.existsSync(histPath)) {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*'
});
res.end(fs.readFileSync(histPath));
return;
}
}
res.writeHead(404);
res.end('Latest Sing-box profile not found');
}
return;
}
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();
}