feat: init proj
This commit is contained in:
20
server/package.json
Normal file
20
server/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "sub-router-server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"express": "^4.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
60
server/src/db.ts
Normal file
60
server/src/db.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
|
||||
const DB_PATH = path.join(__dirname, '..', 'data', 'sub-router.db');
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
raw_config TEXT,
|
||||
last_fetch TEXT,
|
||||
node_count INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fetched_nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subscription_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
server TEXT,
|
||||
port INTEGER,
|
||||
surge_line TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS static_nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
uri TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
surge_line TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
action TEXT NOT NULL DEFAULT 'PROXY',
|
||||
comment TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
export default db;
|
||||
100
server/src/index.ts
Normal file
100
server/src/index.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import './db.js'; // Initialize database
|
||||
import subscriptionsRouter from './routes/subscriptions.js';
|
||||
import nodesRouter from './routes/nodes.js';
|
||||
import rulesRouter from './routes/rules.js';
|
||||
import surgeRouter from './routes/surge.js';
|
||||
import db from './db.js';
|
||||
import { generateSurgeConfig } from './services/generator.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT || '3456', 10);
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Surge endpoint (no auth, before everything)
|
||||
app.get('/surge', (req, res) => {
|
||||
const host = req.headers.host || 'localhost:3456';
|
||||
const protocol = req.secure ? 'https' : 'http';
|
||||
const hostUrl = `${protocol}://${host}/surge`;
|
||||
const config = generateSurgeConfig(hostUrl);
|
||||
res.set({
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename=IPLC.MAX.conf',
|
||||
});
|
||||
res.send(config);
|
||||
});
|
||||
|
||||
// Auth routes (no auth required)
|
||||
app.post('/api/auth/login', (req, res) => {
|
||||
const { password } = req.body;
|
||||
const configRow = db.prepare("SELECT value FROM config WHERE key = 'password'").get() as any;
|
||||
|
||||
if (!configRow?.value) {
|
||||
db.prepare("INSERT OR REPLACE INTO config (key, value) VALUES ('password', ?)").run(password);
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
|
||||
if (password === configRow.value) {
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
|
||||
res.status(401).json({ error: 'wrong password' });
|
||||
});
|
||||
|
||||
app.get('/api/auth/status', (_req, res) => {
|
||||
const configRow = db.prepare("SELECT value FROM config WHERE key = 'password'").get() as any;
|
||||
res.json({ hasPassword: !!configRow?.value });
|
||||
});
|
||||
|
||||
// Auth middleware for other /api routes
|
||||
app.use('/api', (req, res, next) => {
|
||||
const configRow = db.prepare("SELECT value FROM config WHERE key = 'password'").get() as any;
|
||||
if (!configRow?.value) return next();
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || authHeader !== `Bearer ${configRow.value}`) {
|
||||
return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use('/api/subscriptions', subscriptionsRouter);
|
||||
app.use('/api/nodes', nodesRouter);
|
||||
app.use('/api/rules', rulesRouter);
|
||||
app.use('/api/config', surgeRouter);
|
||||
|
||||
// Stats endpoint
|
||||
app.get('/api/stats', (_req, res) => {
|
||||
const subs = db.prepare('SELECT COUNT(*) as count FROM subscriptions WHERE enabled = 1').get() as any;
|
||||
const fetchedEnabled = db.prepare('SELECT COUNT(*) as count FROM fetched_nodes WHERE enabled = 1').get() as any;
|
||||
const fetchedTotal = db.prepare('SELECT COUNT(*) as count FROM fetched_nodes').get() as any;
|
||||
const staticEnabled = db.prepare('SELECT COUNT(*) as count FROM static_nodes WHERE enabled = 1').get() as any;
|
||||
const staticTotal = db.prepare('SELECT COUNT(*) as count FROM static_nodes').get() as any;
|
||||
const rulesCount = db.prepare('SELECT COUNT(*) as count FROM rules WHERE enabled = 1').get() as any;
|
||||
|
||||
res.json({
|
||||
subscriptions: subs.count,
|
||||
nodes: {
|
||||
fetched: { enabled: fetchedEnabled.count, total: fetchedTotal.count },
|
||||
static: { enabled: staticEnabled.count, total: staticTotal.count },
|
||||
},
|
||||
rules: rulesCount.count,
|
||||
});
|
||||
});
|
||||
|
||||
// Serve static frontend files
|
||||
const webDist = path.join(__dirname, '..', '..', 'web', 'dist');
|
||||
app.use(express.static(webDist));
|
||||
app.get('*', (req, res, next) => {
|
||||
if (req.path.startsWith('/api') || req.path === '/surge') return next();
|
||||
res.sendFile(path.join(webDist, 'index.html'));
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Sub Router running at http://127.0.0.1:${PORT}`);
|
||||
console.log(`Surge subscription: http://127.0.0.1:${PORT}/surge`);
|
||||
console.log(`Admin panel: http://127.0.0.1:${PORT}`);
|
||||
});
|
||||
82
server/src/parsers/index.ts
Normal file
82
server/src/parsers/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { parseSS, type ParsedNode } from './ss.js';
|
||||
import { parseVMess } from './vmess.js';
|
||||
import { parseTrojan } from './trojan.js';
|
||||
|
||||
export type { ParsedNode };
|
||||
|
||||
export function parseNodeUri(uri: string): ParsedNode | null {
|
||||
try {
|
||||
if (uri.startsWith('ss://')) return parseSS(uri);
|
||||
if (uri.startsWith('vmess://')) return parseVMess(uri);
|
||||
if (uri.startsWith('trojan://')) return parseTrojan(uri);
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSubscriptionContent(content: string): ParsedNode[] {
|
||||
// Try base64 decode first (common subscription format)
|
||||
let text = content;
|
||||
try {
|
||||
const decoded = Buffer.from(content.trim(), 'base64').toString();
|
||||
if (decoded.includes('://')) {
|
||||
text = decoded;
|
||||
}
|
||||
} catch {
|
||||
// Not base64, use as-is
|
||||
}
|
||||
|
||||
// If it's a Surge config (contains sections), extract proxy lines
|
||||
if (text.includes('[Proxy]') || text.includes('[General]')) {
|
||||
return parseSurgeConfig(text);
|
||||
}
|
||||
|
||||
// Otherwise treat as URI list
|
||||
const lines = text.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
|
||||
const nodes: ParsedNode[] = [];
|
||||
for (const line of lines) {
|
||||
const node = parseNodeUri(line);
|
||||
if (node) nodes.push(node);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function parseSurgeConfig(config: string): ParsedNode[] {
|
||||
const lines = config.split('\n');
|
||||
const nodes: ParsedNode[] = [];
|
||||
let inProxySection = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
inProxySection = trimmed === '[Proxy]';
|
||||
continue;
|
||||
}
|
||||
if (!inProxySection) continue;
|
||||
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) continue;
|
||||
|
||||
// Parse "Name = type, server, port, ..."
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx === -1) continue;
|
||||
|
||||
const name = trimmed.slice(0, eqIdx).trim();
|
||||
const rest = trimmed.slice(eqIdx + 1).trim();
|
||||
const parts = rest.split(',').map(p => p.trim());
|
||||
const type = parts[0] || '';
|
||||
const server = parts[1] || '';
|
||||
const port = parseInt(parts[2] || '0', 10);
|
||||
|
||||
if (name && type && server) {
|
||||
nodes.push({
|
||||
name,
|
||||
type,
|
||||
server,
|
||||
port,
|
||||
surgeLine: trimmed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
24
server/src/parsers/ss.ts
Normal file
24
server/src/parsers/ss.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface ParsedNode {
|
||||
name: string;
|
||||
type: string;
|
||||
server: string;
|
||||
port: number;
|
||||
surgeLine: string;
|
||||
}
|
||||
|
||||
export function parseSS(uri: string): ParsedNode {
|
||||
const url = new URL(uri);
|
||||
const name = decodeURIComponent(url.hash.slice(1));
|
||||
const server = url.hostname;
|
||||
const port = parseInt(url.port, 10);
|
||||
|
||||
const decoded = Buffer.from(url.username, 'base64').toString();
|
||||
const firstColon = decoded.indexOf(':');
|
||||
const method = decoded.slice(0, firstColon);
|
||||
const password = decoded.slice(firstColon + 1);
|
||||
|
||||
// SS 2022 has built-in encryption, Surge does not support SS over TLS
|
||||
const surgeLine = `${name} = ss, ${server}, ${port}, encrypt-method=${method}, password=${password}`;
|
||||
|
||||
return { name, type: 'ss', server, port, surgeLine };
|
||||
}
|
||||
15
server/src/parsers/trojan.ts
Normal file
15
server/src/parsers/trojan.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ParsedNode } from './ss.js';
|
||||
|
||||
export function parseTrojan(uri: string): ParsedNode {
|
||||
const url = new URL(uri);
|
||||
const name = decodeURIComponent(url.hash.slice(1));
|
||||
const server = url.hostname;
|
||||
const port = parseInt(url.port, 10);
|
||||
const password = url.username;
|
||||
const params = url.searchParams;
|
||||
const sni = params.get('sni') || server;
|
||||
|
||||
const surgeLine = `${name} = trojan, ${server}, ${port}, password=${password}, tls=true, sni=${sni}, skip-cert-verify=false`;
|
||||
|
||||
return { name, type: 'trojan', server, port, surgeLine };
|
||||
}
|
||||
27
server/src/parsers/vmess.ts
Normal file
27
server/src/parsers/vmess.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ParsedNode } from './ss.js';
|
||||
|
||||
export function parseVMess(uri: string): ParsedNode {
|
||||
const b64 = uri.replace('vmess://', '');
|
||||
const json = JSON.parse(Buffer.from(b64, 'base64').toString());
|
||||
|
||||
const name = json.ps || 'VMess';
|
||||
const server = json.add;
|
||||
const port = parseInt(json.port, 10);
|
||||
const uuid = json.id;
|
||||
|
||||
let line = `${name} = vmess, ${server}, ${port}, username=${uuid}, vmess-aead=true`;
|
||||
|
||||
if (json.tls === 'tls') {
|
||||
line += ', tls=true';
|
||||
if (json.sni) line += `, sni=${json.sni}`;
|
||||
line += ', skip-cert-verify=false';
|
||||
}
|
||||
|
||||
if (json.net === 'ws') {
|
||||
line += ', ws=true';
|
||||
if (json.path) line += `, ws-path=${json.path}`;
|
||||
if (json.host) line += `, ws-headers=Host:${json.host}`;
|
||||
}
|
||||
|
||||
return { name, type: 'vmess', server, port, surgeLine: line };
|
||||
}
|
||||
98
server/src/routes/nodes.ts
Normal file
98
server/src/routes/nodes.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db.js';
|
||||
import { parseNodeUri } from '../parsers/index.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** Replace the node name prefix in a surge_line like "OldName = ss, ..." → "NewName = ss, ..." */
|
||||
function renameSurgeLine(surgeLine: string, oldName: string, newName: string): string {
|
||||
if (surgeLine.startsWith(oldName + ' = ')) {
|
||||
return newName + surgeLine.slice(oldName.length);
|
||||
}
|
||||
return surgeLine;
|
||||
}
|
||||
|
||||
// --- Fetched nodes ---
|
||||
|
||||
// PUT /api/nodes/fetched/batch — MUST be before /fetched/:id
|
||||
router.put('/fetched/batch', (req, res) => {
|
||||
const { ids, enabled } = req.body;
|
||||
if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids must be array' });
|
||||
const stmt = db.prepare('UPDATE fetched_nodes SET enabled = ? WHERE id = ?');
|
||||
const batch = db.transaction(() => {
|
||||
for (const id of ids) {
|
||||
stmt.run(enabled ? 1 : 0, id);
|
||||
}
|
||||
});
|
||||
batch();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// PUT /api/nodes/fetched/:id - toggle enabled
|
||||
router.put('/fetched/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { enabled } = req.body;
|
||||
db.prepare('UPDATE fetched_nodes SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- Static nodes ---
|
||||
|
||||
// GET /api/nodes/static
|
||||
router.get('/static', (_req, res) => {
|
||||
const rows = db.prepare('SELECT * FROM static_nodes ORDER BY sort_order, id').all();
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// POST /api/nodes/static
|
||||
router.post('/static', (req, res) => {
|
||||
const { uri, name: customName } = req.body;
|
||||
if (!uri) return res.status(400).json({ error: 'uri is required' });
|
||||
|
||||
const node = parseNodeUri(uri);
|
||||
if (!node) return res.status(400).json({ error: 'unsupported or invalid URI' });
|
||||
|
||||
// Allow custom name override
|
||||
const finalName = customName?.trim() || node.name;
|
||||
const surgeLine = renameSurgeLine(node.surgeLine, node.name, finalName);
|
||||
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM static_nodes').get() as any;
|
||||
const sortOrder = (maxOrder?.m ?? -1) + 1;
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO static_nodes (name, uri, type, surge_line, sort_order) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(finalName, uri, node.type, surgeLine, sortOrder);
|
||||
|
||||
res.json({ id: result.lastInsertRowid, name: finalName, type: node.type, server: node.server, port: node.port, surgeLine });
|
||||
});
|
||||
|
||||
// PUT /api/nodes/static/:id
|
||||
router.put('/static/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { name, enabled, sort_order } = req.body;
|
||||
const node = db.prepare('SELECT * FROM static_nodes WHERE id = ?').get(id) as any;
|
||||
if (!node) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const newName = name ?? node.name;
|
||||
const newEnabled = enabled ?? node.enabled;
|
||||
const newSortOrder = sort_order ?? node.sort_order;
|
||||
|
||||
// If name changed, update surge_line too
|
||||
let newSurgeLine = node.surge_line;
|
||||
if (name && name !== node.name) {
|
||||
newSurgeLine = renameSurgeLine(node.surge_line, node.name, name);
|
||||
}
|
||||
|
||||
db.prepare('UPDATE static_nodes SET name = ?, surge_line = ?, enabled = ?, sort_order = ? WHERE id = ?')
|
||||
.run(newName, newSurgeLine, newEnabled, newSortOrder, id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE /api/nodes/static/:id
|
||||
router.delete('/static/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
db.prepare('DELETE FROM static_nodes WHERE id = ?').run(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
68
server/src/routes/rules.ts
Normal file
68
server/src/routes/rules.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/rules
|
||||
router.get('/', (_req, res) => {
|
||||
const rows = db.prepare('SELECT * FROM rules ORDER BY sort_order, id').all();
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// POST /api/rules
|
||||
router.post('/', (req, res) => {
|
||||
const { type, value, action, comment } = req.body;
|
||||
if (!type || !value) return res.status(400).json({ error: 'type and value are required' });
|
||||
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM rules').get() as any;
|
||||
const sortOrder = (maxOrder?.m ?? -1) + 1;
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO rules (type, value, action, comment, sort_order) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(type, value, action || 'PROXY', comment || null, sortOrder);
|
||||
|
||||
res.json({ id: result.lastInsertRowid });
|
||||
});
|
||||
|
||||
// PUT /api/rules/reorder — MUST be before /:id
|
||||
router.put('/reorder', (req, res) => {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids must be array' });
|
||||
|
||||
const stmt = db.prepare('UPDATE rules SET sort_order = ? WHERE id = ?');
|
||||
const reorder = db.transaction(() => {
|
||||
ids.forEach((id: number, index: number) => {
|
||||
stmt.run(index, id);
|
||||
});
|
||||
});
|
||||
reorder();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// PUT /api/rules/:id
|
||||
router.put('/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { type, value, action, comment, enabled } = req.body;
|
||||
const rule = db.prepare('SELECT * FROM rules WHERE id = ?').get(id) as any;
|
||||
if (!rule) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
db.prepare('UPDATE rules SET type = ?, value = ?, action = ?, comment = ?, enabled = ? WHERE id = ?')
|
||||
.run(
|
||||
type ?? rule.type,
|
||||
value ?? rule.value,
|
||||
action ?? rule.action,
|
||||
comment ?? rule.comment,
|
||||
enabled ?? rule.enabled,
|
||||
id
|
||||
);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE /api/rules/:id
|
||||
router.delete('/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
db.prepare('DELETE FROM rules WHERE id = ?').run(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
88
server/src/routes/subscriptions.ts
Normal file
88
server/src/routes/subscriptions.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db.js';
|
||||
import { parseSubscriptionContent } from '../parsers/index.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/subscriptions
|
||||
router.get('/', (_req, res) => {
|
||||
const rows = db.prepare('SELECT * FROM subscriptions ORDER BY id').all();
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// POST /api/subscriptions
|
||||
router.post('/', (req, res) => {
|
||||
const { name, url } = req.body;
|
||||
if (!name || !url) {
|
||||
return res.status(400).json({ error: 'name and url are required' });
|
||||
}
|
||||
const result = db.prepare('INSERT INTO subscriptions (name, url) VALUES (?, ?)').run(name, url);
|
||||
res.json({ id: result.lastInsertRowid });
|
||||
});
|
||||
|
||||
// PUT /api/subscriptions/:id
|
||||
router.put('/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { name, url, enabled } = req.body;
|
||||
const sub = db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(id);
|
||||
if (!sub) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
db.prepare('UPDATE subscriptions SET name = ?, url = ?, enabled = ? WHERE id = ?')
|
||||
.run(name ?? (sub as any).name, url ?? (sub as any).url, enabled ?? (sub as any).enabled, id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE /api/subscriptions/:id
|
||||
router.delete('/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
db.prepare('DELETE FROM subscriptions WHERE id = ?').run(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/subscriptions/:id/fetch - trigger fetch and parse
|
||||
router.post('/:id/fetch', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const sub = db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(id) as any;
|
||||
if (!sub) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
try {
|
||||
const response = await fetch(sub.url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const rawConfig = await response.text();
|
||||
|
||||
// Save existing enabled states by node name
|
||||
const existingNodes = db.prepare('SELECT name, enabled FROM fetched_nodes WHERE subscription_id = ?').all(id) as any[];
|
||||
const enabledMap = new Map(existingNodes.map((n: any) => [n.name, n.enabled]));
|
||||
|
||||
// Parse nodes
|
||||
const nodes = parseSubscriptionContent(rawConfig);
|
||||
|
||||
// Replace nodes in transaction
|
||||
const replace = db.transaction(() => {
|
||||
db.prepare('DELETE FROM fetched_nodes WHERE subscription_id = ?').run(id);
|
||||
const insert = db.prepare(
|
||||
'INSERT INTO fetched_nodes (subscription_id, name, type, server, port, surge_line, enabled) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
for (const node of nodes) {
|
||||
const enabled = enabledMap.get(node.name) ?? 1;
|
||||
insert.run(id, node.name, node.type, node.server, node.port, node.surgeLine, enabled);
|
||||
}
|
||||
db.prepare('UPDATE subscriptions SET raw_config = ?, last_fetch = ?, node_count = ? WHERE id = ?')
|
||||
.run(rawConfig, new Date().toISOString(), nodes.length, id);
|
||||
});
|
||||
replace();
|
||||
|
||||
res.json({ nodeCount: nodes.length });
|
||||
} catch (err: any) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/subscriptions/:id/nodes
|
||||
router.get('/:id/nodes', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const nodes = db.prepare('SELECT * FROM fetched_nodes WHERE subscription_id = ? ORDER BY id').all(id);
|
||||
res.json(nodes);
|
||||
});
|
||||
|
||||
export default router;
|
||||
31
server/src/routes/surge.ts
Normal file
31
server/src/routes/surge.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Router } from 'express';
|
||||
import { generateSurgeConfig } from '../services/generator.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /surge - Surge client subscription endpoint (no auth required)
|
||||
router.get('/', (req, res) => {
|
||||
const host = req.headers.host || 'localhost:3456';
|
||||
const protocol = req.secure ? 'https' : 'http';
|
||||
const hostUrl = `${protocol}://${host}/surge`;
|
||||
|
||||
const config = generateSurgeConfig(hostUrl);
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename=sub-router.conf',
|
||||
});
|
||||
res.send(config);
|
||||
});
|
||||
|
||||
// GET /api/config/preview - preview generated config
|
||||
router.get('/preview', (req, res) => {
|
||||
const host = req.headers.host || 'localhost:3456';
|
||||
const protocol = req.secure ? 'https' : 'http';
|
||||
const hostUrl = `${protocol}://${host}/surge`;
|
||||
|
||||
const config = generateSurgeConfig(hostUrl);
|
||||
res.json({ config });
|
||||
});
|
||||
|
||||
export default router;
|
||||
167
server/src/services/generator.ts
Normal file
167
server/src/services/generator.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import db from '../db.js';
|
||||
|
||||
export function generateSurgeConfig(hostUrl: string): string {
|
||||
// Get first enabled subscription's raw_config as base template
|
||||
const sub = db.prepare(
|
||||
'SELECT raw_config FROM subscriptions WHERE enabled = 1 AND raw_config IS NOT NULL ORDER BY id LIMIT 1'
|
||||
).get() as any;
|
||||
|
||||
if (!sub?.raw_config) {
|
||||
return '# No subscription config available. Add and fetch a subscription first.';
|
||||
}
|
||||
|
||||
// Collect enabled fetched nodes
|
||||
const fetchedNodes = db.prepare(
|
||||
'SELECT surge_line FROM fetched_nodes WHERE enabled = 1 ORDER BY subscription_id, id'
|
||||
).all() as any[];
|
||||
|
||||
// Collect enabled static nodes (these go FIRST)
|
||||
const staticNodes = db.prepare(
|
||||
'SELECT surge_line FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
|
||||
).all() as any[];
|
||||
|
||||
// Collect enabled rules
|
||||
const userRules = db.prepare(
|
||||
'SELECT type, value, action, comment FROM rules WHERE enabled = 1 ORDER BY sort_order, id'
|
||||
).all() as any[];
|
||||
|
||||
// Static nodes first, then fetched nodes
|
||||
const staticLines = staticNodes.map((n: any) => n.surge_line);
|
||||
const fetchedLines = fetchedNodes.map((n: any) => n.surge_line);
|
||||
const allNodeLines = [...staticLines, ...fetchedLines];
|
||||
const allNodeNames = allNodeLines.map((l: string) => l.split(' = ')[0].trim());
|
||||
|
||||
// Build rule lines
|
||||
const ruleLines = userRules.map((r: any) => {
|
||||
const line = `${r.type},${r.value},${r.action}`;
|
||||
return r.comment ? `${line} // ${r.comment}` : line;
|
||||
});
|
||||
|
||||
let config = sub.raw_config;
|
||||
|
||||
// Replace [Proxy] section with only enabled nodes
|
||||
config = rebuildProxySection(config, staticLines, fetchedLines);
|
||||
|
||||
// Rebuild [Proxy Group] select groups with only enabled node names
|
||||
config = rebuildProxyGroup(config, allNodeNames);
|
||||
|
||||
// Inject user rules at the beginning of [Rule] section
|
||||
if (ruleLines.length > 0) {
|
||||
config = injectRules(config, ruleLines);
|
||||
}
|
||||
|
||||
// Rewrite MANAGED-CONFIG URL
|
||||
config = config.replace(
|
||||
/^#!MANAGED-CONFIG\s+\S+/m,
|
||||
`#!MANAGED-CONFIG ${hostUrl}`
|
||||
);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the entire [Proxy] section content with only enabled nodes.
|
||||
* Static nodes go first, then fetched nodes.
|
||||
*/
|
||||
function rebuildProxySection(config: string, staticLines: string[], fetchedLines: string[]): string {
|
||||
const lines = config.split('\n');
|
||||
const result: string[] = [];
|
||||
let inProxySection = false;
|
||||
let proxyHeaderEmitted = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
if (inProxySection && !proxyHeaderEmitted) {
|
||||
// Emit our rebuilt proxy content before leaving the section
|
||||
emitProxyContent(result, staticLines, fetchedLines);
|
||||
proxyHeaderEmitted = true;
|
||||
}
|
||||
inProxySection = trimmed === '[Proxy]';
|
||||
result.push(line);
|
||||
if (inProxySection) {
|
||||
// Emit all enabled nodes right after [Proxy] header
|
||||
emitProxyContent(result, staticLines, fetchedLines);
|
||||
proxyHeaderEmitted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inProxySection) {
|
||||
// Skip original proxy lines (we replaced them)
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(line);
|
||||
}
|
||||
|
||||
// If [Proxy] was the last section
|
||||
if (inProxySection && !proxyHeaderEmitted) {
|
||||
emitProxyContent(result, staticLines, fetchedLines);
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
function emitProxyContent(result: string[], staticLines: string[], fetchedLines: string[]) {
|
||||
if (staticLines.length > 0) {
|
||||
result.push('# --- 自定义节点 ---');
|
||||
staticLines.forEach(l => result.push(l));
|
||||
result.push('');
|
||||
}
|
||||
if (fetchedLines.length > 0) {
|
||||
result.push('# --- 订阅节点 ---');
|
||||
fetchedLines.forEach(l => result.push(l));
|
||||
result.push('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild [Proxy Group] select groups to contain only the enabled node names.
|
||||
*/
|
||||
function rebuildProxyGroup(config: string, allNodeNames: string[]): string {
|
||||
if (allNodeNames.length === 0) return config;
|
||||
|
||||
const lines = config.split('\n');
|
||||
const result: string[] = [];
|
||||
let inProxyGroupSection = false;
|
||||
let handled = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
inProxyGroupSection = trimmed === '[Proxy Group]';
|
||||
}
|
||||
|
||||
if (inProxyGroupSection && !handled && trimmed.includes('= select,')) {
|
||||
// Rebuild: keep group name and "= select," prefix, replace node list
|
||||
const eqSelect = trimmed.indexOf('= select,');
|
||||
const prefix = trimmed.slice(0, eqSelect + '= select,'.length);
|
||||
result.push(prefix + ' ' + allNodeNames.join(', '));
|
||||
handled = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(line);
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
function injectRules(config: string, ruleLines: string[]): string {
|
||||
const lines = config.split('\n');
|
||||
const result: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
result.push(line);
|
||||
if (line.trim() === '[Rule]') {
|
||||
result.push('# --- 自定义规则 ---');
|
||||
ruleLines.forEach(r => result.push(r));
|
||||
result.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
14
server/tsconfig.json
Normal file
14
server/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user