feat: init proj

This commit is contained in:
2026-03-31 13:11:54 +08:00
commit 8f75ea24d6
38 changed files with 6826 additions and 0 deletions

20
server/package.json Normal file
View 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
View 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
View 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}`);
});

View 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
View 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 };
}

View 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 };
}

View 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 };
}

View 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;

View 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;

View 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;

View 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;

View 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
View 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"]
}