feat: support NAS imports and proxy names
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
out
|
||||
subs.json
|
||||
npm-debug.log
|
||||
.DS_Store
|
||||
+12
-12
@@ -1,24 +1,24 @@
|
||||
FROM node:22-alpine
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制依赖配置并安装
|
||||
ENV NODE_ENV=production \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=3000 \
|
||||
AUTO_OPEN=0
|
||||
|
||||
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 public/ ./public/
|
||||
COPY templates/ ./templates/
|
||||
COPY public ./public
|
||||
COPY templates ./templates
|
||||
|
||||
# 创建持久化存储的目录与文件
|
||||
RUN mkdir -p out
|
||||
RUN mkdir -p out && printf '[]\n' > subs.json
|
||||
|
||||
# 暴露 3000 端口
|
||||
EXPOSE 3000
|
||||
|
||||
# 环境变量:让服务不自动在后台执行浏览器拉起命令
|
||||
ENV HEADLESS=true
|
||||
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"]
|
||||
|
||||
@@ -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 会把它理解为设备自己,而不是飞牛服务器。
|
||||
|
||||
+10
-9
@@ -2,15 +2,16 @@ services:
|
||||
sub2proxy:
|
||||
build: .
|
||||
container_name: sub2proxy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
# 映射订阅配置文件,防止容器重启后订阅丢失
|
||||
- ./subs.json:/app/subs.json
|
||||
# 映射模板文件夹,方便在 NAS 目录下随时修改或添加自定义配置模板
|
||||
- ./templates:/app/templates
|
||||
# 映射输出文件夹,生成的文件和历史记录将同步保存在 NAS 主机目录中
|
||||
- ./out:/app/out
|
||||
restart: unless-stopped
|
||||
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
@@ -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>
|
||||
@@ -857,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');
|
||||
@@ -1094,6 +1109,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, ch => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
})[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');
|
||||
@@ -1104,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>
|
||||
@@ -1114,11 +1147,17 @@
|
||||
|
||||
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>
|
||||
<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})">
|
||||
@@ -1136,7 +1175,7 @@
|
||||
}
|
||||
|
||||
function deleteProxy(idx) {
|
||||
const nodeName = proxies[idx].name || proxies[idx].tag;
|
||||
const nodeName = getParsedName(proxies[idx]);
|
||||
if (confirm(`确定要删除代理节点 "${nodeName}" 吗?\n(注:这仅会从当前页面内存中删除。如需应用更改,请在删除后点击右上角“保存并生成配置”按钮重新生成配置文件)`)) {
|
||||
log(`在内存中删除代理节点: ${nodeName}`);
|
||||
proxies.splice(idx, 1);
|
||||
@@ -1155,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();
|
||||
@@ -1206,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;
|
||||
}
|
||||
@@ -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) {
|
||||
const tbody = document.getElementById('history-list');
|
||||
tbody.innerHTML = '';
|
||||
@@ -1418,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 = `
|
||||
|
||||
+71
-7
@@ -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,6 +760,39 @@ 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 {
|
||||
@@ -791,10 +851,14 @@ function startServer() {
|
||||
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}`);
|
||||
if (process.env.HEADLESS !== 'true') {
|
||||
console.log(`访问地址: http://${displayHost}:${PORT}`);
|
||||
if (PUBLIC_BASE_URL) {
|
||||
console.log(`客户端导入地址基准: ${PUBLIC_BASE_URL}`);
|
||||
}
|
||||
if (AUTO_OPEN) {
|
||||
console.log(`正在自动打开默认浏览器...`);
|
||||
openBrowser(`http://localhost:${PORT}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user