feat: 新增自定义配置合并到 Surge 输出

管理面板新增「自定义」面板,可按 Surge 格式书写 [Proxy]/[Rule]/
[General] 等配置片段,生成时合并进最终输出:[Proxy] 行追加到节点
之后,[Rule] 行注入到规则最顶部(优先级最高),其他段注入对应段
顶部且同名 key 覆盖模板取值(如 doh-server)。仅管理员链接生效,
游客链接不包含自定义内容。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 15:45:24 +08:00
parent 1eb0359b8f
commit d708240acf
8 changed files with 1595 additions and 15 deletions

View File

@@ -18,6 +18,22 @@ router.post('/surge-token', (_req, res) => {
res.json({ token });
});
// GET /api/config/custom - get custom config snippet
router.get('/custom', (_req, res) => {
const row = db.prepare("SELECT value FROM config WHERE key = 'custom_config'").get() as any;
res.json({ content: row?.value || '' });
});
// PUT /api/config/custom - save custom config snippet
router.put('/custom', (req, res) => {
const { content } = req.body;
if (typeof content !== 'string') {
return res.status(400).json({ error: 'content must be a string' });
}
db.prepare("INSERT OR REPLACE INTO config (key, value) VALUES ('custom_config', ?)").run(content);
res.json({ ok: true });
});
// GET /api/config/preview - preview generated config
router.get('/preview', (req, res) => {
const host = req.headers.host || 'localhost:3456';

View File

@@ -34,6 +34,13 @@ export function generateSurgeConfig(hostUrl: string, whitelist?: Set<string>): s
'SELECT type, value, action, comment FROM rules WHERE enabled = 1 ORDER BY sort_order, id'
).all() as any[];
// Custom config sections ([Proxy]/[Rule]/... snippets), admin links only —
// guest links must not receive admin-machine specifics like local interfaces
const customRow = whitelist
? null
: db.prepare("SELECT value FROM config WHERE key = 'custom_config'").get() as any;
const customSections = parseCustomSections(customRow?.value || '');
// Static nodes first, then fetched nodes
const lineName = (l: string) => l.split(' = ')[0].trim();
const keep = (l: string) => !whitelist || whitelist.has(lineName(l));
@@ -51,14 +58,22 @@ export function generateSurgeConfig(hostUrl: string, whitelist?: Set<string>): s
let config = sub.raw_config;
// Replace [Proxy] section with only enabled nodes
config = rebuildProxySection(config, staticLines, fetchedLines);
config = rebuildProxySection(config, staticLines, fetchedLines, customSections.get('Proxy') || []);
// 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);
// (custom config rules go first — they take highest priority)
const customRuleLines = customSections.get('Rule') || [];
if (ruleLines.length > 0 || customRuleLines.length > 0) {
config = injectRules(config, ruleLines, customRuleLines);
}
// Merge remaining custom sections (anything other than [Proxy]/[Rule])
for (const [section, lines] of customSections) {
if (section === 'Proxy' || section === 'Rule') continue;
config = injectIntoSection(config, section, lines);
}
// Rewrite MANAGED-CONFIG URL
@@ -71,10 +86,34 @@ export function generateSurgeConfig(hostUrl: string, whitelist?: Set<string>): s
}
/**
* Replace the entire [Proxy] section content with only enabled nodes.
* Static nodes go first, then fetched nodes.
* Parse a custom config snippet into section name → lines.
* Lines before any [Section] header are ignored; blank lines are dropped
* (comments are kept so users can annotate their rules).
*/
function rebuildProxySection(config: string, staticLines: string[], fetchedLines: string[]): string {
export function parseCustomSections(text: string): Map<string, string[]> {
const sections = new Map<string, string[]>();
let current: string[] | null = null;
for (const raw of text.split('\n')) {
const trimmed = raw.trim();
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
const name = trimmed.slice(1, -1).trim();
current = sections.get(name) || [];
sections.set(name, current);
continue;
}
if (!trimmed || !current) continue;
current.push(trimmed);
}
return sections;
}
/**
* Replace the entire [Proxy] section content with only enabled nodes.
* Static nodes go first, then fetched nodes, then custom config proxies.
*/
function rebuildProxySection(config: string, staticLines: string[], fetchedLines: string[], customLines: string[]): string {
const lines = config.split('\n');
const result: string[] = [];
let inProxySection = false;
@@ -86,14 +125,14 @@ function rebuildProxySection(config: string, staticLines: string[], fetchedLines
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
if (inProxySection && !proxyHeaderEmitted) {
// Emit our rebuilt proxy content before leaving the section
emitProxyContent(result, staticLines, fetchedLines);
emitProxyContent(result, staticLines, fetchedLines, customLines);
proxyHeaderEmitted = true;
}
inProxySection = trimmed === '[Proxy]';
result.push(line);
if (inProxySection) {
// Emit all enabled nodes right after [Proxy] header
emitProxyContent(result, staticLines, fetchedLines);
emitProxyContent(result, staticLines, fetchedLines, customLines);
proxyHeaderEmitted = true;
}
continue;
@@ -109,13 +148,13 @@ function rebuildProxySection(config: string, staticLines: string[], fetchedLines
// If [Proxy] was the last section
if (inProxySection && !proxyHeaderEmitted) {
emitProxyContent(result, staticLines, fetchedLines);
emitProxyContent(result, staticLines, fetchedLines, customLines);
}
return result.join('\n');
}
function emitProxyContent(result: string[], staticLines: string[], fetchedLines: string[]) {
function emitProxyContent(result: string[], staticLines: string[], fetchedLines: string[], customLines: string[]) {
if (staticLines.length > 0) {
result.push('# --- 自定义节点 ---');
staticLines.forEach(l => result.push(l));
@@ -126,6 +165,11 @@ function emitProxyContent(result: string[], staticLines: string[], fetchedLines:
fetchedLines.forEach(l => result.push(l));
result.push('');
}
if (customLines.length > 0) {
result.push('# --- 自定义配置 ---');
customLines.forEach(l => result.push(l));
result.push('');
}
}
/**
@@ -161,18 +205,74 @@ function rebuildProxyGroup(config: string, allNodeNames: string[]): string {
return result.join('\n');
}
function injectRules(config: string, ruleLines: string[]): string {
function injectRules(config: string, ruleLines: string[], customRuleLines: 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('');
if (customRuleLines.length > 0) {
result.push('# --- 自定义配置规则 ---');
customRuleLines.forEach(r => result.push(r));
result.push('');
}
if (ruleLines.length > 0) {
result.push('# --- 自定义规则 ---');
ruleLines.forEach(r => result.push(r));
result.push('');
}
}
}
return result.join('\n');
}
/**
* Inject custom lines at the top of an arbitrary [Section]; appends the
* section at the end of the config if the template doesn't have it.
* A custom `key = value` line overrides (removes) same-key lines the
* template already has in that section, e.g. doh-server in [General].
*/
function injectIntoSection(config: string, section: string, customLines: string[]): string {
if (customLines.length === 0) return config;
const header = `[${section}]`;
const keyOf = (l: string) => l.includes('=') && !l.startsWith('#') ? l.split('=')[0].trim() : null;
const overrideKeys = new Set(customLines.map(keyOf).filter(Boolean));
const lines = config.split('\n');
const result: string[] = [];
let found = false;
let inSection = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
inSection = trimmed === header;
result.push(line);
if (inSection && !found) {
found = true;
result.push('# --- 自定义配置 ---');
customLines.forEach(l => result.push(l));
result.push('');
}
continue;
}
if (inSection) {
const key = keyOf(trimmed);
if (key && overrideKeys.has(key)) continue;
}
result.push(line);
}
if (!found) {
result.push('', header, '# --- 自定义配置 ---');
customLines.forEach(l => result.push(l));
}
return result.join('\n');
}