feat: support NAS imports and proxy names

This commit is contained in:
jocayn
2026-06-13 16:19:52 +08:00
parent ec6b3cb93f
commit b231e78498
6 changed files with 212 additions and 47 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.gitignore
node_modules
out
subs.json
npm-debug.log
.DS_Store
+12 -12
View File
@@ -1,24 +1,24 @@
FROM node:22-alpine FROM node:20-alpine
WORKDIR /app WORKDIR /app
# 复制依赖配置并安装 ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=3000 \
AUTO_OPEN=0
COPY package*.json ./ COPY package*.json ./
RUN npm install --production RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit=dev; fi
# 复制其余的项目代码
COPY sub2proxy.js ./ COPY sub2proxy.js ./
COPY public/ ./public/ COPY public ./public
COPY templates/ ./templates/ COPY templates ./templates
# 创建持久化存储的目录与文件 RUN mkdir -p out && printf '[]\n' > subs.json
RUN mkdir -p out
# 暴露 3000 端口
EXPOSE 3000 EXPOSE 3000
# 环境变量:让服务不自动在后台执行浏览器拉起命令 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
ENV HEADLESS=true 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"] CMD ["node", "sub2proxy.js"]
+33
View File
@@ -34,3 +34,36 @@ node sub2proxy.js
node sub2proxy.js --cli node sub2proxy.js --cli
``` ```
配置会自动输出到 `out/CatMata-sub.yaml``out/CatMata-sub.json`,并归档于 `out/history` 配置会自动输出到 `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 会把它理解为设备自己,而不是飞牛服务器。
+10 -9
View File
@@ -2,15 +2,16 @@ services:
sub2proxy: sub2proxy:
build: . build: .
container_name: sub2proxy container_name: sub2proxy
restart: unless-stopped
ports: ports:
- "3000:3000" - "3000:3000"
volumes:
# 映射订阅配置文件,防止容器重启后订阅丢失
- ./subs.json:/app/subs.json
# 映射模板文件夹,方便在 NAS 目录下随时修改或添加自定义配置模板
- ./templates:/app/templates
# 映射输出文件夹,生成的文件和历史记录将同步保存在 NAS 主机目录中
- ./out:/app/out
restart: unless-stopped
environment: environment:
- HEADLESS=true 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
+79 -19
View File
@@ -710,7 +710,8 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th> (Tag)</th> <th>解析名 (Tag)</th>
<th>代理名</th>
<th>协议 (Type)</th> <th>协议 (Type)</th>
<th>服务器 (Server)</th> <th>服务器 (Server)</th>
<th>端口 (Port)</th> <th>端口 (Port)</th>
@@ -719,7 +720,7 @@
</thead> </thead>
<tbody id="proxies-list"> <tbody id="proxies-list">
<tr> <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> </td>
</tr> </tr>
@@ -857,14 +858,28 @@
let subscriptions = []; let subscriptions = [];
let proxies = []; // In-memory editable proxies let proxies = []; // In-memory editable proxies
let originalDns = { clash: '', singbox: '' }; let originalDns = { clash: '', singbox: '' };
let appConfig = { publicBaseUrl: '' };
// On Load // On Load
window.addEventListener('DOMContentLoaded', () => { window.addEventListener('DOMContentLoaded', async () => {
lucide.createIcons(); lucide.createIcons();
await loadAppConfig();
loadSubscriptions(); loadSubscriptions();
loadDnsTemplates(); 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 // Logging helper
function log(message, type = 'info') { function log(message, type = 'info') {
const logs = document.getElementById('console-logs'); const logs = document.getElementById('console-logs');
@@ -1094,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() { function renderProxiesTable() {
const list = document.getElementById('proxies-list'); const list = document.getElementById('proxies-list');
const counter = document.getElementById('proxies-counter'); const counter = document.getElementById('proxies-counter');
@@ -1104,7 +1137,7 @@
if (proxies.length === 0) { if (proxies.length === 0) {
list.innerHTML = ` list.innerHTML = `
<tr> <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> </td>
</tr> </tr>
@@ -1114,11 +1147,17 @@
proxies.forEach((node, idx) => { proxies.forEach((node, idx) => {
const tr = document.createElement('tr'); 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 = ` tr.innerHTML = `
<td style="font-weight: 500;">${node.name || node.tag}</td> <td style="font-weight: 500;" title="${escapeHtml(parsedName)}">${escapeHtml(parsedName)}</td>
<td><span class="node-proto proto-${(node.type || '').toLowerCase()}">${node.type}</span></td> <td title="${escapeHtml(proxyName)}">${escapeHtml(proxyName)}</td>
<td style="font-family: var(--font-mono); font-size: 0.85rem;">${node.server}</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;">${node.port || node.server_port}</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> <td>
<div style="display: flex; gap: 0.5rem;"> <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})"> <button class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="openNodeDrawer(${idx})">
@@ -1136,7 +1175,7 @@
} }
function deleteProxy(idx) { function deleteProxy(idx) {
const nodeName = proxies[idx].name || proxies[idx].tag; const nodeName = getParsedName(proxies[idx]);
if (confirm(`确定要删除代理节点 "${nodeName}" 吗?\n(注:这仅会从当前页面内存中删除。如需应用更改,请在删除后点击右上角“保存并生成配置”按钮重新生成配置文件)`)) { if (confirm(`确定要删除代理节点 "${nodeName}" 吗?\n(注:这仅会从当前页面内存中删除。如需应用更改,请在删除后点击右上角“保存并生成配置”按钮重新生成配置文件)`)) {
log(`在内存中删除代理节点: ${nodeName}`); log(`在内存中删除代理节点: ${nodeName}`);
proxies.splice(idx, 1); proxies.splice(idx, 1);
@@ -1155,17 +1194,18 @@
const container = document.getElementById('node-edit-form'); const container = document.getElementById('node-edit-form');
const overlay = document.getElementById('node-drawer-overlay'); const overlay = document.getElementById('node-drawer-overlay');
title.textContent = `修改节点: ${node.name || node.tag}`; title.textContent = `修改节点: ${getParsedName(node)}`;
container.innerHTML = ''; container.innerHTML = '';
const commonFields = [ 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: 'server', label: '服务器 IP/域名', val: node.server, type: 'text' },
{ name: 'port', label: '端口', val: node.port || node.server_port, type: 'number' } { name: 'port', label: '端口', val: node.port || node.server_port, type: 'number' }
]; ];
commonFields.forEach(f => { 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(); const t = (node.type || '').toLowerCase();
@@ -1206,12 +1246,13 @@
overlay.classList.add('active'); overlay.classList.add('active');
} }
function createFormField(name, label, value, type) { function createFormField(name, label, value, type, options = {}) {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'form-group'; div.className = 'form-group';
const readonlyAttr = options.readonly ? ' readonly aria-readonly="true"' : '';
div.innerHTML = ` div.innerHTML = `
<label for="edit-${name}">${label}</label> <label for="edit-${escapeHtml(name)}">${escapeHtml(label)}</label>
<input type="${type}" id="edit-${name}" class="form-control" value="${value !== undefined ? value : ''}"> <input type="${escapeHtml(type)}" id="edit-${escapeHtml(name)}" class="form-control" value="${escapeHtml(value !== undefined ? value : '')}"${readonlyAttr}>
`; `;
return div; return div;
} }
@@ -1402,6 +1443,20 @@
} }
} }
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) { function renderHistoryList(list) {
const tbody = document.getElementById('history-list'); const tbody = document.getElementById('history-list');
tbody.innerHTML = ''; tbody.innerHTML = '';
@@ -1418,14 +1473,19 @@
} }
const origin = window.location.origin; const origin = window.location.origin;
const clientOrigin = getClientImportOrigin();
list.forEach(item => { list.forEach(item => {
const yamlDownloadUrl = `${origin}/api/download?file=${item.yamlFile}`; const yamlFileParam = encodeURIComponent(item.yamlFile);
const jsonDownloadUrl = `${origin}/api/download?file=${item.jsonFile}`; 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 // Construct client URI schemes
const clashImportUrl = `clash://install-config?url=${encodeURIComponent(yamlDownloadUrl)}&name=${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(jsonDownloadUrl)}#${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'); const tr = document.createElement('tr');
tr.innerHTML = ` tr.innerHTML = `
+71 -7
View File
@@ -314,6 +314,7 @@ function parseSingboxNodes(content) {
const usedNames = new Set(); const usedNames = new Set();
function renameNode(node, subName, isClash = true) { function renameNode(node, subName, isClash = true) {
const originalName = isClash ? (node.name || node.tag || '') : (node.tag || node.name || '');
const typeStr = (node.type || '').toLowerCase(); const typeStr = (node.type || '').toLowerCase();
const protoClean = getProtoName(typeStr); const protoClean = getProtoName(typeStr);
const baseName = `${subName}-${protoClean}`; const baseName = `${subName}-${protoClean}`;
@@ -326,6 +327,7 @@ function renameNode(node, subName, isClash = true) {
} }
usedNames.add(newName.toLowerCase()); usedNames.add(newName.toLowerCase());
node.proxyName = originalName || newName;
if (isClash) { if (isClash) {
node.name = newName; node.name = newName;
@@ -380,6 +382,12 @@ function detectTemplates() {
return { yamlTpl, jsonTpl }; return { yamlTpl, jsonTpl };
} }
function stripUiOnlyNodeFields(node) {
const cleanNode = JSON.parse(JSON.stringify(node));
delete cleanNode.proxyName;
return cleanNode;
}
function replaceNodeRefs(data, oldNodesSet, fallbackNode) { function replaceNodeRefs(data, oldNodesSet, fallbackNode) {
if (data && typeof data === 'object') { if (data && typeof data === 'object') {
if (Array.isArray(data)) { if (Array.isArray(data)) {
@@ -403,7 +411,10 @@ function replaceNodeRefs(data, oldNodesSet, fallbackNode) {
// Configuration paths // Configuration paths
const subsFile = 'subs.json'; const subsFile = 'subs.json';
const outDir = 'out'; 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 // Central configuration generation function
function generateConfigs(rawClashNodes, dns = null) { function generateConfigs(rawClashNodes, dns = null) {
@@ -411,10 +422,11 @@ function generateConfigs(rawClashNodes, dns = null) {
if (!fs.existsSync(outDir)) { if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true }); fs.mkdirSync(outDir, { recursive: true });
} }
const clashNodes = rawClashNodes.map(stripUiOnlyNodeFields);
// 1. Process Clash Config // 1. Process Clash Config
const clashConfig = yaml.load(fs.readFileSync(yamlTpl, 'utf-8')) || {}; const clashConfig = yaml.load(fs.readFileSync(yamlTpl, 'utf-8')) || {};
clashConfig.proxies = rawClashNodes; clashConfig.proxies = clashNodes;
if (dns && dns.clash) { if (dns && dns.clash) {
clashConfig.dns = yaml.load(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 groupNames = new Set((clashConfig['proxy-groups'] || []).map(g => g.name));
const specialNames = new Set(['DIRECT', 'REJECT', 'PASS', 'COMPATIBLE']); 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'] || [])) { for (const group of (clashConfig['proxy-groups'] || [])) {
const oldProxies = group.proxies || []; const oldProxies = group.proxies || [];
@@ -438,7 +450,7 @@ function generateConfigs(rawClashNodes, dns = null) {
// 2. Process Sing-box Config // 2. Process Sing-box Config
const sbConfig = JSON.parse(fs.readFileSync(jsonTpl, 'utf-8')) || {}; 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)); const nodeCopy = JSON.parse(JSON.stringify(n));
return clashToSingbox(nodeCopy); return clashToSingbox(nodeCopy);
}); });
@@ -571,6 +583,20 @@ function startServer() {
return; 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 // API: GET /api/subs
if (pathname === '/api/subs' && req.method === 'GET') { if (pathname === '/api/subs' && req.method === 'GET') {
if (fs.existsSync(subsFile)) { if (fs.existsSync(subsFile)) {
@@ -653,6 +679,7 @@ function startServer() {
renameNode(sbNode, subName, false); renameNode(sbNode, subName, false);
const cNode = singboxToClash(sbNode); const cNode = singboxToClash(sbNode);
cNode.proxyName = sbNode.proxyName || cNode.name;
allClashNodes.push(cNode); allClashNodes.push(cNode);
} }
} }
@@ -733,6 +760,39 @@ function startServer() {
return; 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 // API: POST /api/history/delete
if (pathname === '/api/history/delete' && req.method === 'POST') { if (pathname === '/api/history/delete' && req.method === 'POST') {
try { try {
@@ -791,10 +851,14 @@ function startServer() {
res.end('Not Found'); 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(`CatMata Sub2Proxy 控制面板已启动!`);
console.log(`本地访问地址: http://localhost:${PORT}`); console.log(`访问地址: http://${displayHost}:${PORT}`);
if (process.env.HEADLESS !== 'true') { if (PUBLIC_BASE_URL) {
console.log(`客户端导入地址基准: ${PUBLIC_BASE_URL}`);
}
if (AUTO_OPEN) {
console.log(`正在自动打开默认浏览器...`); console.log(`正在自动打开默认浏览器...`);
openBrowser(`http://localhost:${PORT}`); openBrowser(`http://localhost:${PORT}`);
} }