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

206
pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,206 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
concurrently:
specifier: ^9.1.0
version: 9.2.4
packages:
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
concurrently@9.2.4:
resolution: {integrity: sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==}
engines: {node: '>=18'}
hasBin: true
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
shell-quote@1.9.0:
resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==}
engines: {node: '>= 0.4'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
supports-color@8.1.1:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
snapshots:
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
cliui@8.0.1:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
concurrently@9.2.4:
dependencies:
chalk: 4.1.2
rxjs: 7.8.2
shell-quote: 1.9.0
supports-color: 8.1.1
tree-kill: 1.2.2
yargs: 17.7.2
emoji-regex@8.0.0: {}
escalade@3.2.0: {}
get-caller-file@2.0.5: {}
has-flag@4.0.0: {}
is-fullwidth-code-point@3.0.0: {}
require-directory@2.1.1: {}
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
shell-quote@1.9.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
supports-color@8.1.1:
dependencies:
has-flag: 4.0.0
tree-kill@1.2.2: {}
tslib@2.8.1: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
y18n@5.0.8: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1

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]') {
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');
}

1134
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import Subscriptions from './components/Subscriptions';
import StaticNodes from './components/StaticNodes';
import NodeSelector from './components/NodeSelector';
import Rules from './components/Rules';
import CustomConfig from './components/CustomConfig';
import Output from './components/Output';
import Guests from './components/Guests';
import { auth, setToken } from './api';
@@ -147,6 +148,7 @@ export default function App() {
'static-nodes': <StaticNodes />,
'node-selector': <NodeSelector />,
rules: <Rules />,
'custom-config': <CustomConfig />,
output: <Output />,
guests: <Guests />,
};

View File

@@ -124,6 +124,11 @@ export const config = {
preview: () => request<{ config: string }>('/config/preview'),
getSurgeToken: () => request<{ token: string }>('/config/surge-token'),
regenerateSurgeToken: () => request<{ token: string }>('/config/surge-token', { method: 'POST' }),
getCustom: () => request<{ content: string }>('/config/custom'),
saveCustom: (content: string) => request<{ ok: boolean }>('/config/custom', {
method: 'PUT',
body: JSON.stringify({ content }),
}),
};
// Guest links

View File

@@ -0,0 +1,116 @@
import { useState, useEffect } from 'react';
import { config as configApi } from '../api';
const PLACEHOLDER = `[Proxy]
EN5-DIRECT = direct, interface = en5, allow-other-interface = false, dns-follow-interface = true
[Rule]
# 目标应用固定走 en5
PROCESS-NAME,/Applications/YourApp.app/,EN5-DIRECT
# 其他所有流量按照 macOS 系统默认路由出网
FINAL,DIRECT`;
export default function CustomConfig() {
const [content, setContent] = useState('');
const [saved, setSaved] = useState('');
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
useEffect(() => {
configApi.getCustom().then(data => {
setContent(data.content);
setSaved(data.content);
}).catch(() => {});
}, []);
const dirty = content !== saved;
const handleSave = async () => {
setSaving(true);
setMessage('');
setError('');
try {
await configApi.saveCustom(content);
setSaved(content);
setMessage('已保存,将合并到 Surge 输出配置');
setTimeout(() => setMessage(''), 3000);
} catch (err: any) {
setError(err.message || '保存失败');
} finally {
setSaving(false);
}
};
return (
<div>
<h2 style={styles.title}></h2>
<p style={styles.subtitle}>
Surge [Proxy][Rule] Surge
</p>
<div style={styles.hints}>
<div>· [Proxy] [Proxy] </div>
<div>· [Rule] [Rule] </div>
<div>· [Host]</div>
<div>· </div>
</div>
<textarea
value={content}
onChange={e => setContent(e.target.value)}
placeholder={PLACEHOLDER}
spellCheck={false}
style={styles.textarea}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 12 }}>
<button className="primary" onClick={handleSave} disabled={saving || !dirty}>
{saving ? '保存中...' : '保存'}
</button>
{dirty && <span style={{ fontSize: 12, color: 'var(--warning, #e0af68)' }}></span>}
{message && <span style={{ fontSize: 12, color: 'var(--success)' }}>{message}</span>}
{error && <span style={{ fontSize: 12, color: 'var(--danger)' }}>{error}</span>}
</div>
</div>
);
}
const styles = {
title: {
fontFamily: 'var(--font-mono)' as const,
fontSize: 16,
fontWeight: 600 as const,
color: 'var(--text-primary)',
marginBottom: 4,
},
subtitle: {
fontSize: 12,
color: 'var(--text-secondary)',
marginBottom: 12,
},
hints: {
background: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: '10px 14px',
fontSize: 11,
lineHeight: 1.8,
color: 'var(--text-muted)',
marginBottom: 16,
},
textarea: {
width: '100%',
minHeight: 'calc(100vh - 400px)',
background: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: 16,
fontFamily: 'var(--font-mono)' as const,
fontSize: 12,
lineHeight: 1.6,
color: 'var(--text-primary)',
resize: 'vertical' as const,
},
};

View File

@@ -1,13 +1,14 @@
import { useState, useEffect } from 'react';
import { stats as statsApi } from '../api';
type Panel = 'subscriptions' | 'static-nodes' | 'node-selector' | 'rules' | 'output' | 'guests';
type Panel = 'subscriptions' | 'static-nodes' | 'node-selector' | 'rules' | 'custom-config' | 'output' | 'guests';
const NAV_ITEMS: { key: Panel; label: string; icon: string }[] = [
{ key: 'subscriptions', label: '订阅', icon: '⟐' },
{ key: 'static-nodes', label: '节点', icon: '◈' },
{ key: 'node-selector', label: '选择', icon: '☰' },
{ key: 'rules', label: '规则', icon: '⧖' },
{ key: 'custom-config', label: '自定义', icon: '✎' },
{ key: 'output', label: '输出', icon: '▸' },
{ key: 'guests', label: '游客', icon: '◐' },
];