Compare commits

...

3 Commits

Author SHA1 Message Date
jocayn b231e78498 feat: support NAS imports and proxy names 2026-06-13 16:19:52 +08:00
jocayn ec6b3cb93f chore: add Dockerfile and docker-compose.yml for NAS deployment 2026-06-13 15:56:22 +08:00
jocayn 8f28ae3c67 feat: add delete options for proxy nodes and history records 2026-06-13 15:53:09 +08:00
6 changed files with 337 additions and 31 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.gitignore
node_modules
out
subs.json
npm-debug.log
.DS_Store
+24
View File
@@ -0,0 +1,24 @@
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=3000 \
AUTO_OPEN=0
COPY package*.json ./
RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit=dev; fi
COPY sub2proxy.js ./
COPY public ./public
COPY templates ./templates
RUN mkdir -p out && printf '[]\n' > subs.json
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "require('http').get('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
CMD ["node", "sub2proxy.js"]
+33
View File
@@ -34,3 +34,36 @@ node sub2proxy.js
node sub2proxy.js --cli
```
配置会自动输出到 `out/CatMata-sub.yaml``out/CatMata-sub.json`,并归档于 `out/history`
## Docker / 飞牛部署
### 使用 Compose
先确认当前目录下存在 `subs.json`,再运行:
```bash
docker compose up -d --build
```
默认访问地址:
```text
http://飞牛IP:3000
```
需要持久化的数据:
* `subs.json` - 订阅源配置,控制面板修改后会写入这里
* `out/` - 当前输出和历史配置
* `templates/` - Clash / Sing-box 模板,默认只读挂载
### 手机/电脑一键导入
如果手机或电脑通过 `http://飞牛IP:3000` 打开控制面板,导入链接会自动使用这个地址生成,Clash 客户端可以直接从飞牛拉取配置。
如果你通过反向代理、域名或 HTTPS 访问,建议在 `docker-compose.yml` 中设置:
```yaml
PUBLIC_BASE_URL: "https://你的域名"
```
如果使用局域网固定 IP,也可以显式设置:
```yaml
PUBLIC_BASE_URL: "http://192.168.1.10:3000"
```
注意:不要让导入链接使用 `127.0.0.1``localhost`,手机/电脑上的 Clash 会把它理解为设备自己,而不是飞牛服务器。
+17
View File
@@ -0,0 +1,17 @@
services:
sub2proxy:
build: .
container_name: sub2proxy
restart: unless-stopped
ports:
- "3000:3000"
environment:
PORT: "3000"
HOST: "0.0.0.0"
AUTO_OPEN: "0"
# Set this when clients should import through a fixed LAN IP, domain, or reverse proxy.
# PUBLIC_BASE_URL: "http://192.168.1.10:3000"
volumes:
- ./subs.json:/app/subs.json
- ./out:/app/out
- ./templates:/app/templates:ro
+128 -23
View File
@@ -710,7 +710,8 @@
<table>
<thead>
<tr>
<th> (Tag)</th>
<th>解析名 (Tag)</th>
<th>代理名</th>
<th>协议 (Type)</th>
<th>服务器 (Server)</th>
<th>端口 (Port)</th>
@@ -719,7 +720,7 @@
</thead>
<tbody id="proxies-list">
<tr>
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
请先点击右上角 “一键拉取” 按钮解析节点
</td>
</tr>
@@ -765,11 +766,12 @@
<th>节点数量</th>
<th>本地下载</th>
<th>一键导入</th>
<th>操作</th>
</tr>
</thead>
<tbody id="history-list">
<tr>
<td colspan="4" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
暂无生成历史记录,运行生成配置后将在此归档
</td>
</tr>
@@ -856,14 +858,28 @@
let subscriptions = [];
let proxies = []; // In-memory editable proxies
let originalDns = { clash: '', singbox: '' };
let appConfig = { publicBaseUrl: '' };
// On Load
window.addEventListener('DOMContentLoaded', () => {
window.addEventListener('DOMContentLoaded', async () => {
lucide.createIcons();
await loadAppConfig();
loadSubscriptions();
loadDnsTemplates();
});
async function loadAppConfig() {
try {
const res = await fetch('/api/app-config');
if (res.ok) {
const data = await res.json();
appConfig.publicBaseUrl = (data.publicBaseUrl || '').replace(/\/+$/, '');
}
} catch (e) {
console.warn('Failed to load app config:', e);
}
}
// Logging helper
function log(message, type = 'info') {
const logs = document.getElementById('console-logs');
@@ -1093,6 +1109,24 @@
}
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, ch => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[ch]);
}
function getParsedName(node) {
return node.name || node.tag || '';
}
function getProxyName(node) {
return node.proxyName || node.originalName || getParsedName(node);
}
function renderProxiesTable() {
const list = document.getElementById('proxies-list');
const counter = document.getElementById('proxies-counter');
@@ -1103,7 +1137,7 @@
if (proxies.length === 0) {
list.innerHTML = `
<tr>
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
未提取到任何代理节点
</td>
</tr>
@@ -1113,15 +1147,26 @@
proxies.forEach((node, idx) => {
const tr = document.createElement('tr');
const parsedName = getParsedName(node);
const proxyName = getProxyName(node);
const nodeType = node.type || '';
const server = node.server || '';
const port = node.port || node.server_port || '';
tr.innerHTML = `
<td style="font-weight: 500;">${node.name || node.tag}</td>
<td><span class="node-proto proto-${(node.type || '').toLowerCase()}">${node.type}</span></td>
<td style="font-family: var(--font-mono); font-size: 0.85rem;">${node.server}</td>
<td style="font-family: var(--font-mono); font-size: 0.85rem;">${node.port || node.server_port}</td>
<td style="font-weight: 500;" title="${escapeHtml(parsedName)}">${escapeHtml(parsedName)}</td>
<td title="${escapeHtml(proxyName)}">${escapeHtml(proxyName)}</td>
<td><span class="node-proto proto-${escapeHtml(nodeType.toLowerCase())}">${escapeHtml(nodeType)}</span></td>
<td style="font-family: var(--font-mono); font-size: 0.85rem;">${escapeHtml(server)}</td>
<td style="font-family: var(--font-mono); font-size: 0.85rem;">${escapeHtml(port)}</td>
<td>
<button class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="openNodeDrawer(${idx})">
<i data-lucide="sliders" style="width: 12px; height: 12px;"></i>修改参数
</button>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="openNodeDrawer(${idx})">
<i data-lucide="sliders" style="width: 12px; height: 12px;"></i>修改参数
</button>
<button class="btn btn-danger" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="deleteProxy(${idx})">
<i data-lucide="trash-2" style="width: 12px; height: 12px;"></i>删除
</button>
</div>
</td>
`;
list.appendChild(tr);
@@ -1129,6 +1174,16 @@
lucide.createIcons();
}
function deleteProxy(idx) {
const nodeName = getParsedName(proxies[idx]);
if (confirm(`确定要删除代理节点 "${nodeName}" 吗?\n(注:这仅会从当前页面内存中删除。如需应用更改,请在删除后点击右上角“保存并生成配置”按钮重新生成配置文件)`)) {
log(`在内存中删除代理节点: ${nodeName}`);
proxies.splice(idx, 1);
renderProxiesTable();
showToast('代理节点已删除');
}
}
// Modal Drawer for Editing Nodes
let currentEditNodeIndex = null;
@@ -1139,17 +1194,18 @@
const container = document.getElementById('node-edit-form');
const overlay = document.getElementById('node-drawer-overlay');
title.textContent = `修改节点: ${node.name || node.tag}`;
title.textContent = `修改节点: ${getParsedName(node)}`;
container.innerHTML = '';
const commonFields = [
{ name: 'name', label: '节点 Tag/Name', val: node.name || node.tag, type: 'text' },
{ name: 'name', label: '解析名 (Tag)', val: getParsedName(node), type: 'text' },
{ name: 'proxyName', label: '代理名', val: getProxyName(node), type: 'text', readonly: true },
{ name: 'server', label: '服务器 IP/域名', val: node.server, type: 'text' },
{ name: 'port', label: '端口', val: node.port || node.server_port, type: 'number' }
];
commonFields.forEach(f => {
container.appendChild(createFormField(f.name, f.label, f.val, f.type));
container.appendChild(createFormField(f.name, f.label, f.val, f.type, { readonly: f.readonly }));
});
const t = (node.type || '').toLowerCase();
@@ -1190,12 +1246,13 @@
overlay.classList.add('active');
}
function createFormField(name, label, value, type) {
function createFormField(name, label, value, type, options = {}) {
const div = document.createElement('div');
div.className = 'form-group';
const readonlyAttr = options.readonly ? ' readonly aria-readonly="true"' : '';
div.innerHTML = `
<label for="edit-${name}">${label}</label>
<input type="${type}" id="edit-${name}" class="form-control" value="${value !== undefined ? value : ''}">
<label for="edit-${escapeHtml(name)}">${escapeHtml(label)}</label>
<input type="${escapeHtml(type)}" id="edit-${escapeHtml(name)}" class="form-control" value="${escapeHtml(value !== undefined ? value : '')}"${readonlyAttr}>
`;
return div;
}
@@ -1362,6 +1419,44 @@
}
}
async function deleteHistory(yamlFile, jsonFile, timeStr) {
if (confirm(`确定要删除该历史生成记录吗?\n生成时间: ${timeStr}\n(注意:此操作将物理删除对应的配置文件归档且不可逆!)`)) {
log(`正在请求删除历史记录: ${timeStr}`);
try {
const res = await fetch('/api/history/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ yamlFile, jsonFile })
});
if (res.ok) {
log(`成功删除历史记录: ${timeStr}`, 'success');
showToast('历史记录删除成功');
loadHistory();
} else {
const errText = await res.text();
log(`删除历史记录失败: ${errText}`, 'error');
showToast('删除历史记录失败', 'error');
}
} catch (e) {
log(`删除历史记录异常: ${e.message}`, 'error');
}
}
}
function getClientImportOrigin() {
if (appConfig.publicBaseUrl) {
return appConfig.publicBaseUrl;
}
const localHosts = new Set(['localhost', '127.0.0.1', '::1']);
if (!localHosts.has(window.location.hostname)) {
return window.location.origin;
}
const port = window.location.port ? `:${window.location.port}` : '';
return `${window.location.protocol}//127.0.0.1${port}`;
}
function renderHistoryList(list) {
const tbody = document.getElementById('history-list');
tbody.innerHTML = '';
@@ -1369,7 +1464,7 @@
if (!list || list.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="4" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
暂无生成历史记录,运行生成配置后将在此归档
</td>
</tr>
@@ -1378,14 +1473,19 @@
}
const origin = window.location.origin;
const clientOrigin = getClientImportOrigin();
list.forEach(item => {
const yamlDownloadUrl = `${origin}/api/download?file=${item.yamlFile}`;
const jsonDownloadUrl = `${origin}/api/download?file=${item.jsonFile}`;
const yamlFileParam = encodeURIComponent(item.yamlFile);
const jsonFileParam = encodeURIComponent(item.jsonFile);
const yamlDownloadUrl = `${origin}/api/download?file=${yamlFileParam}`;
const jsonDownloadUrl = `${origin}/api/download?file=${jsonFileParam}`;
const yamlImportProfileUrl = `${clientOrigin}/api/profile?file=${yamlFileParam}`;
const jsonImportProfileUrl = `${clientOrigin}/api/profile?file=${jsonFileParam}`;
// Construct client URI schemes
const clashImportUrl = `clash://install-config?url=${encodeURIComponent(yamlDownloadUrl)}&name=${encodeURIComponent('CatMata-' + item.timeStr.replace(/[: ]/g, '-'))}`;
const singboxImportUrl = `sing-box://import-remote-profile?url=${encodeURIComponent(jsonDownloadUrl)}#${encodeURIComponent('CatMata-' + item.timeStr.replace(/[: ]/g, '-'))}`;
const clashImportUrl = `clash://install-config?url=${encodeURIComponent(yamlImportProfileUrl)}&name=${encodeURIComponent('CatMata-' + item.timeStr.replace(/[: ]/g, '-'))}`;
const singboxImportUrl = `sing-box://import-remote-profile?url=${encodeURIComponent(jsonImportProfileUrl)}#${encodeURIComponent('CatMata-' + item.timeStr.replace(/[: ]/g, '-'))}`;
const tr = document.createElement('tr');
tr.innerHTML = `
@@ -1411,6 +1511,11 @@
</a>
</div>
</td>
<td>
<button class="btn btn-danger" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="deleteHistory('${item.yamlFile}', '${item.jsonFile}', '${item.timeStr}')">
<i data-lucide="trash-2" style="width: 12px; height: 12px;"></i>删除
</button>
</td>
`;
tbody.appendChild(tr);
});
+128 -8
View File
@@ -314,6 +314,7 @@ function parseSingboxNodes(content) {
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}`;
@@ -326,6 +327,7 @@ function renameNode(node, subName, isClash = true) {
}
usedNames.add(newName.toLowerCase());
node.proxyName = originalName || newName;
if (isClash) {
node.name = newName;
@@ -380,6 +382,12 @@ function detectTemplates() {
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)) {
@@ -403,7 +411,10 @@ function replaceNodeRefs(data, oldNodesSet, fallbackNode) {
// Configuration paths
const subsFile = 'subs.json';
const outDir = 'out';
const PORT = 3000;
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());
// Central configuration generation function
function generateConfigs(rawClashNodes, dns = null) {
@@ -411,10 +422,11 @@ function generateConfigs(rawClashNodes, dns = null) {
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 = rawClashNodes;
clashConfig.proxies = clashNodes;
if (dns && dns.clash) {
clashConfig.dns = yaml.load(dns.clash);
@@ -422,7 +434,7 @@ function generateConfigs(rawClashNodes, dns = null) {
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);
const newNodeNames = clashNodes.map(n => n.name);
for (const group of (clashConfig['proxy-groups'] || [])) {
const oldProxies = group.proxies || [];
@@ -438,7 +450,7 @@ function generateConfigs(rawClashNodes, dns = null) {
// 2. Process Sing-box Config
const sbConfig = JSON.parse(fs.readFileSync(jsonTpl, 'utf-8')) || {};
const allSingboxNodes = rawClashNodes.map(n => {
const allSingboxNodes = clashNodes.map(n => {
const nodeCopy = JSON.parse(JSON.stringify(n));
return clashToSingbox(nodeCopy);
});
@@ -571,6 +583,20 @@ function startServer() {
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/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 (fs.existsSync(subsFile)) {
@@ -653,6 +679,7 @@ function startServer() {
renameNode(sbNode, subName, false);
const cNode = singboxToClash(sbNode);
cNode.proxyName = sbNode.proxyName || cNode.name;
allClashNodes.push(cNode);
}
}
@@ -733,15 +760,108 @@ function startServer() {
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');
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;
}
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') {
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, () => {
server.listen(PORT, HOST, () => {
const displayHost = HOST === '0.0.0.0' || HOST === '::' ? 'localhost' : HOST;
console.log(`CatMata Sub2Proxy 控制面板已启动!`);
console.log(`本地访问地址: http://localhost:${PORT}`);
console.log(`正在自动打开默认浏览器...`);
openBrowser(`http://localhost:${PORT}`);
console.log(`访问地址: http://${displayHost}:${PORT}`);
if (PUBLIC_BASE_URL) {
console.log(`客户端导入地址基准: ${PUBLIC_BASE_URL}`);
}
if (AUTO_OPEN) {
console.log(`正在自动打开默认浏览器...`);
openBrowser(`http://localhost:${PORT}`);
}
});
}