Compare commits

...

2 Commits

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 15:45:24 +08:00
11 changed files with 1630 additions and 26 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

@@ -6,6 +6,12 @@ export interface ParsedNode {
surgeLine: string; surgeLine: string;
} }
// Surge 使用与标准 Shadowsocks 不同的加密方式命名,需做别名映射
const ENCRYPT_METHOD_ALIASES: Record<string, string> = {
'chacha20-poly1305': 'chacha20-ietf-poly1305',
'xchacha20-poly1305': 'xchacha20-ietf-poly1305',
};
export function parseSS(uri: string): ParsedNode { export function parseSS(uri: string): ParsedNode {
const url = new URL(uri); const url = new URL(uri);
const name = decodeURIComponent(url.hash.slice(1)); const name = decodeURIComponent(url.hash.slice(1));
@@ -14,11 +20,18 @@ export function parseSS(uri: string): ParsedNode {
const decoded = Buffer.from(url.username, 'base64').toString(); const decoded = Buffer.from(url.username, 'base64').toString();
const firstColon = decoded.indexOf(':'); const firstColon = decoded.indexOf(':');
const method = decoded.slice(0, firstColon); const rawMethod = decoded.slice(0, firstColon);
const method = ENCRYPT_METHOD_ALIASES[rawMethod] ?? rawMethod;
const password = decoded.slice(firstColon + 1); const password = decoded.slice(firstColon + 1);
// SS 2022 has built-in encryption, Surge does not support SS over TLS // SS 2022 has built-in encryption, Surge does not support SS over TLS
const surgeLine = `${name} = ss, ${server}, ${port}, encrypt-method=${method}, password=${password}`; const surgeLine = `${name} = ss, ${server}, ${port}, encrypt-method=${method}, password=${password}`;
return { name, type: 'ss', server, port, surgeLine }; // Surge's `ss` proxy type has no WebSocket/TLS transport support at all (unlike
// trojan/vmess/vless) — this Xray-style ss+ws+tls link can never work in Surge,
// so it's tagged 'ss-ws' and excluded from Surge output (see generator.ts).
// Clash/SSR generators re-parse the raw uri independently and are unaffected.
const type = url.searchParams.get('type') === 'ws' ? 'ss-ws' : 'ss';
return { name, type, server, port, surgeLine };
} }

View File

@@ -1,4 +1,5 @@
import type { ParsedNode } from './ss.js'; import type { ParsedNode } from './ss.js';
import { normalizeWsPath } from './util.js';
export function parseTrojan(uri: string): ParsedNode { export function parseTrojan(uri: string): ParsedNode {
const url = new URL(uri); const url = new URL(uri);
@@ -9,7 +10,13 @@ export function parseTrojan(uri: string): ParsedNode {
const params = url.searchParams; const params = url.searchParams;
const sni = params.get('sni') || server; const sni = params.get('sni') || server;
const surgeLine = `${name} = trojan, ${server}, ${port}, password=${password}, tls=true, sni=${sni}, skip-cert-verify=false`; let surgeLine = `${name} = trojan, ${server}, ${port}, password=${password}, tls=true, sni=${sni}, skip-cert-verify=false`;
if (params.get('type') === 'ws') {
const path = params.get('path') || '/';
const host = params.get('host') || sni;
surgeLine += `, ws=true, ws-path=${normalizeWsPath(path)}, ws-headers=Host:${host}`;
}
return { name, type: 'trojan', server, port, surgeLine }; return { name, type: 'trojan', server, port, surgeLine };
} }

View File

@@ -18,6 +18,22 @@ router.post('/surge-token', (_req, res) => {
res.json({ token }); 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 // GET /api/config/preview - preview generated config
router.get('/preview', (req, res) => { router.get('/preview', (req, res) => {
const host = req.headers.host || 'localhost:3456'; const host = req.headers.host || 'localhost:3456';

View File

@@ -14,11 +14,12 @@ function renameSurgeLine(surgeLine: string, oldName: string, newName: string): s
return surgeLine; return surgeLine;
} }
const rows = db.prepare('SELECT id, name, uri, surge_line FROM static_nodes').all() as Array<{ const rows = db.prepare('SELECT id, name, uri, surge_line, type FROM static_nodes').all() as Array<{
id: number; id: number;
name: string; name: string;
uri: string; uri: string;
surge_line: string; surge_line: string;
type: string;
}>; }>;
const update = db.prepare('UPDATE static_nodes SET surge_line = ?, type = ? WHERE id = ?'); const update = db.prepare('UPDATE static_nodes SET surge_line = ?, type = ? WHERE id = ?');
@@ -31,7 +32,7 @@ for (const row of rows) {
continue; continue;
} }
const newLine = renameSurgeLine(node.surgeLine, node.name, row.name); const newLine = renameSurgeLine(node.surgeLine, node.name, row.name);
if (newLine !== row.surge_line) { if (newLine !== row.surge_line || node.type !== row.type) {
update.run(newLine, node.type, row.id); update.run(newLine, node.type, row.id);
changed++; changed++;
console.log(`[updated] id=${row.id} "${row.name}"`); console.log(`[updated] id=${row.id} "${row.name}"`);

View File

@@ -15,18 +15,21 @@ export function generateSurgeConfig(hostUrl: string, whitelist?: Set<string>): s
return '# No subscription config available. Add and fetch a subscription first.'; return '# No subscription config available. Add and fetch a subscription first.';
} }
// Collect fetched nodes (exclude vless Surge doesn't support it; tuic uses `tuic-v5`) // Exclude vless and ss-ws (Surge doesn't support either transport)
const UNSUPPORTED = "type NOT IN ('vless', 'ss-ws')";
// Collect fetched nodes (tuic uses `tuic-v5`)
const fetchedNodes = db.prepare( const fetchedNodes = db.prepare(
whitelist whitelist
? "SELECT surge_line FROM fetched_nodes WHERE type != 'vless' ORDER BY subscription_id, sort_order, id" ? `SELECT surge_line FROM fetched_nodes WHERE ${UNSUPPORTED} ORDER BY subscription_id, sort_order, id`
: "SELECT surge_line FROM fetched_nodes WHERE enabled = 1 AND type != 'vless' ORDER BY subscription_id, sort_order, id" : `SELECT surge_line FROM fetched_nodes WHERE enabled = 1 AND ${UNSUPPORTED} ORDER BY subscription_id, sort_order, id`
).all() as any[]; ).all() as any[];
// Collect static nodes (these go FIRST, exclude vless) // Collect static nodes (these go FIRST)
const staticNodes = db.prepare( const staticNodes = db.prepare(
whitelist whitelist
? "SELECT surge_line FROM static_nodes WHERE type != 'vless' ORDER BY sort_order, id" ? `SELECT surge_line FROM static_nodes WHERE ${UNSUPPORTED} ORDER BY sort_order, id`
: "SELECT surge_line FROM static_nodes WHERE enabled = 1 AND type != 'vless' ORDER BY sort_order, id" : `SELECT surge_line FROM static_nodes WHERE enabled = 1 AND ${UNSUPPORTED} ORDER BY sort_order, id`
).all() as any[]; ).all() as any[];
// Collect enabled rules // Collect enabled rules
@@ -34,6 +37,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' 'SELECT type, value, action, comment FROM rules WHERE enabled = 1 ORDER BY sort_order, id'
).all() as any[]; ).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 // Static nodes first, then fetched nodes
const lineName = (l: string) => l.split(' = ')[0].trim(); const lineName = (l: string) => l.split(' = ')[0].trim();
const keep = (l: string) => !whitelist || whitelist.has(lineName(l)); const keep = (l: string) => !whitelist || whitelist.has(lineName(l));
@@ -51,14 +61,22 @@ export function generateSurgeConfig(hostUrl: string, whitelist?: Set<string>): s
let config = sub.raw_config; let config = sub.raw_config;
// Replace [Proxy] section with only enabled nodes // 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 // Rebuild [Proxy Group] select groups with only enabled node names
config = rebuildProxyGroup(config, allNodeNames); config = rebuildProxyGroup(config, allNodeNames);
// Inject user rules at the beginning of [Rule] section // Inject user rules at the beginning of [Rule] section
if (ruleLines.length > 0) { // (custom config rules go first — they take highest priority)
config = injectRules(config, ruleLines); 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 // Rewrite MANAGED-CONFIG URL
@@ -71,10 +89,34 @@ export function generateSurgeConfig(hostUrl: string, whitelist?: Set<string>): s
} }
/** /**
* Replace the entire [Proxy] section content with only enabled nodes. * Parse a custom config snippet into section name → lines.
* Static nodes go first, then fetched nodes. * 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 lines = config.split('\n');
const result: string[] = []; const result: string[] = [];
let inProxySection = false; let inProxySection = false;
@@ -86,14 +128,14 @@ function rebuildProxySection(config: string, staticLines: string[], fetchedLines
if (trimmed.startsWith('[') && trimmed.endsWith(']')) { if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
if (inProxySection && !proxyHeaderEmitted) { if (inProxySection && !proxyHeaderEmitted) {
// Emit our rebuilt proxy content before leaving the section // Emit our rebuilt proxy content before leaving the section
emitProxyContent(result, staticLines, fetchedLines); emitProxyContent(result, staticLines, fetchedLines, customLines);
proxyHeaderEmitted = true; proxyHeaderEmitted = true;
} }
inProxySection = trimmed === '[Proxy]'; inProxySection = trimmed === '[Proxy]';
result.push(line); result.push(line);
if (inProxySection) { if (inProxySection) {
// Emit all enabled nodes right after [Proxy] header // Emit all enabled nodes right after [Proxy] header
emitProxyContent(result, staticLines, fetchedLines); emitProxyContent(result, staticLines, fetchedLines, customLines);
proxyHeaderEmitted = true; proxyHeaderEmitted = true;
} }
continue; continue;
@@ -109,13 +151,13 @@ function rebuildProxySection(config: string, staticLines: string[], fetchedLines
// If [Proxy] was the last section // If [Proxy] was the last section
if (inProxySection && !proxyHeaderEmitted) { if (inProxySection && !proxyHeaderEmitted) {
emitProxyContent(result, staticLines, fetchedLines); emitProxyContent(result, staticLines, fetchedLines, customLines);
} }
return result.join('\n'); 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) { if (staticLines.length > 0) {
result.push('# --- 自定义节点 ---'); result.push('# --- 自定义节点 ---');
staticLines.forEach(l => result.push(l)); staticLines.forEach(l => result.push(l));
@@ -126,6 +168,11 @@ function emitProxyContent(result: string[], staticLines: string[], fetchedLines:
fetchedLines.forEach(l => result.push(l)); fetchedLines.forEach(l => result.push(l));
result.push(''); result.push('');
} }
if (customLines.length > 0) {
result.push('# --- 自定义配置 ---');
customLines.forEach(l => result.push(l));
result.push('');
}
} }
/** /**
@@ -161,18 +208,74 @@ function rebuildProxyGroup(config: string, allNodeNames: string[]): string {
return result.join('\n'); 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 lines = config.split('\n');
const result: string[] = []; const result: string[] = [];
for (const line of lines) { for (const line of lines) {
result.push(line); result.push(line);
if (line.trim() === '[Rule]') { if (line.trim() === '[Rule]') {
result.push('# --- 自定义规则 ---'); if (customRuleLines.length > 0) {
ruleLines.forEach(r => result.push(r)); result.push('# --- 自定义配置规则 ---');
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'); 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 StaticNodes from './components/StaticNodes';
import NodeSelector from './components/NodeSelector'; import NodeSelector from './components/NodeSelector';
import Rules from './components/Rules'; import Rules from './components/Rules';
import CustomConfig from './components/CustomConfig';
import Output from './components/Output'; import Output from './components/Output';
import Guests from './components/Guests'; import Guests from './components/Guests';
import { auth, setToken } from './api'; import { auth, setToken } from './api';
@@ -147,6 +148,7 @@ export default function App() {
'static-nodes': <StaticNodes />, 'static-nodes': <StaticNodes />,
'node-selector': <NodeSelector />, 'node-selector': <NodeSelector />,
rules: <Rules />, rules: <Rules />,
'custom-config': <CustomConfig />,
output: <Output />, output: <Output />,
guests: <Guests />, guests: <Guests />,
}; };

View File

@@ -124,6 +124,11 @@ export const config = {
preview: () => request<{ config: string }>('/config/preview'), preview: () => request<{ config: string }>('/config/preview'),
getSurgeToken: () => request<{ token: string }>('/config/surge-token'), getSurgeToken: () => request<{ token: string }>('/config/surge-token'),
regenerateSurgeToken: () => request<{ token: string }>('/config/surge-token', { method: 'POST' }), 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 // 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 { useState, useEffect } from 'react';
import { stats as statsApi } from '../api'; 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 }[] = [ const NAV_ITEMS: { key: Panel; label: string; icon: string }[] = [
{ key: 'subscriptions', label: '订阅', icon: '⟐' }, { key: 'subscriptions', label: '订阅', icon: '⟐' },
{ key: 'static-nodes', label: '节点', icon: '◈' }, { key: 'static-nodes', label: '节点', icon: '◈' },
{ key: 'node-selector', label: '选择', icon: '☰' }, { key: 'node-selector', label: '选择', icon: '☰' },
{ key: 'rules', label: '规则', icon: '⧖' }, { key: 'rules', label: '规则', icon: '⧖' },
{ key: 'custom-config', label: '自定义', icon: '✎' },
{ key: 'output', label: '输出', icon: '▸' }, { key: 'output', label: '输出', icon: '▸' },
{ key: 'guests', label: '游客', icon: '◐' }, { key: 'guests', label: '游客', icon: '◐' },
]; ];