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:
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user