From 2df229473ad75eadc2cc4c17ddf32f886acd5e49 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Wed, 15 Apr 2026 10:28:16 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Clash/SSR=20?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E7=94=9F=E6=88=90=E5=99=A8=E7=9A=84=E5=A4=9A?= =?UTF-8?q?=E4=B8=AA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 按 server:port 过滤 disabled 节点,使节点开关对 Clash/SSR 生效 - 静态节点使用自定义名称(而非 URI 内嵌名),且置于列表最前 - Clash 配置注入自定义规则到 rules: 段顶部 - Clash/SSR 端点 Content-Disposition filename 改为 IPLC.MAX Co-Authored-By: Claude Sonnet 4.6 --- server/src/index.ts | 4 +- server/src/services/clashGenerator.ts | 206 ++++++++++++++++++++++---- server/src/services/ssrGenerator.ts | 70 ++++++++- 3 files changed, 239 insertions(+), 41 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 2158d68..7c0cada 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -51,7 +51,7 @@ app.get('/clash/:token', (req, res) => { const config = generateClashConfig(); res.set({ 'Content-Type': 'text/plain; charset=utf-8', - 'Content-Disposition': 'attachment; filename=clash.yaml', + 'Content-Disposition': 'attachment; filename=IPLC.MAX.yaml', }); res.send(config); }); @@ -62,7 +62,7 @@ app.get('/ssr/:token', (req, res) => { const config = generateSSRConfig(); res.set({ 'Content-Type': 'text/plain; charset=utf-8', - 'Content-Disposition': 'attachment; filename=proxy.txt', + 'Content-Disposition': 'attachment; filename=IPLC.MAX.txt', }); res.send(config); }); diff --git a/server/src/services/clashGenerator.ts b/server/src/services/clashGenerator.ts index 4c9f681..f9e2912 100644 --- a/server/src/services/clashGenerator.ts +++ b/server/src/services/clashGenerator.ts @@ -1,6 +1,10 @@ import db from '../db.js'; import { uriToClashLine } from '../parsers/toClash.js'; +function escYaml(s: string): string { + return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + export function generateClashConfig(): string { const sub = db.prepare( 'SELECT raw_config_clash FROM subscriptions WHERE enabled = 1 AND raw_config_clash IS NOT NULL ORDER BY id LIMIT 1' @@ -10,29 +14,175 @@ export function generateClashConfig(): string { return '# No Clash config available. Add a subscription with a Clash URL and fetch it.'; } + // Get disabled fetched nodes for filtering (by server:port) + const disabledNodes = db.prepare( + 'SELECT server, port FROM fetched_nodes WHERE enabled = 0' + ).all() as { server: string; port: number }[]; + const disabledSet = new Set(disabledNodes.map(n => `${n.server}:${n.port}`)); + const staticNodes = db.prepare( 'SELECT uri, name FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id' - ).all() as any[]; + ).all() as { uri: string; name: string }[]; - if (staticNodes.length === 0) { - return sub.raw_config_clash; + const userRules = db.prepare( + 'SELECT type, value, action, comment FROM rules WHERE enabled = 1 ORDER BY sort_order, id' + ).all() as { type: string; value: string; action: string; comment: string | null }[]; + + let config = sub.raw_config_clash; + + // Filter out disabled fetched nodes + if (disabledSet.size > 0) { + config = filterDisabledProxies(config, disabledSet); } - const clashLines = staticNodes - .map((n: any) => uriToClashLine(n.uri)) - .filter((l): l is string => l !== null); + // Inject static nodes with custom names + if (staticNodes.length > 0) { + const clashLines = staticNodes + .map(n => withCustomName(uriToClashLine(n.uri), n.name)) + .filter((l): l is string => l !== null); + const staticNames = staticNodes.map(n => n.name); - const staticNames = staticNodes.map((n: any) => n.name as string); - - if (clashLines.length === 0) { - return sub.raw_config_clash; + if (clashLines.length > 0) { + config = injectProxies(config, clashLines); + config = injectProxyGroupNames(config, staticNames); + } + } + + // Inject user rules + if (userRules.length > 0) { + config = injectClashRules(config, userRules); } - let config = injectProxies(sub.raw_config_clash, clashLines); - config = injectProxyGroupNames(config, staticNames); return config; } +/** Replace the name field in a Clash flow-style proxy line with a custom name. */ +function withCustomName(line: string | null, name: string): string | null { + if (!line) return null; + return line.replace(/name: "[^"]*"/, `name: "${escYaml(name)}"`); +} + +/** + * Remove proxies whose server:port matches a disabled node. + * Also removes those proxy names from all proxy-group proxies lists. + */ +function filterDisabledProxies(config: string, disabledSet: Set): string { + const lines = config.split('\n'); + const result: string[] = []; + const removedNames = new Set(); + let inProxies = false; + + for (const line of lines) { + const trimmed = line.trim(); + + if (/^proxies:/.test(line)) { + inProxies = true; + result.push(line); + continue; + } + if (/^[a-zA-Z]/.test(line) && !line.startsWith(' ') && !line.startsWith('-')) { + inProxies = false; + } + + if (inProxies && trimmed.startsWith('-')) { + const serverMatch = trimmed.match(/server:\s*([^\s,}]+)/); + const portMatch = trimmed.match(/port:\s*(\d+)/); + if (serverMatch && portMatch) { + const key = `${serverMatch[1].replace(/,+$/, '')}:${portMatch[1].replace(/,+$/, '')}`; + if (disabledSet.has(key)) { + const nameMatch = trimmed.match(/name:\s*"([^"]*)"/); + if (nameMatch) removedNames.add(nameMatch[1]); + continue; // skip this proxy + } + } + } + + result.push(line); + } + + if (removedNames.size > 0) { + return removeNamesFromProxyGroups(result.join('\n'), removedNames); + } + + return result.join('\n'); +} + +/** Remove specific proxy names from all proxy-group proxies lists. */ +function removeNamesFromProxyGroups(config: string, names: Set): string { + // Handle inline array format: proxies: [name1, name2, ...] + config = config.replace(/proxies:\s*\[([^\]]*)\]/g, (_, inner: string) => { + const items = inner.split(',').map(s => s.trim()).filter(s => s && !names.has(s)); + return `proxies: [${items.join(', ')}]`; + }); + + // Handle block list format: + // proxies: + // - SomeName + const lines = config.split('\n'); + const result: string[] = []; + let inProxyGroups = false; + let inProxiesList = false; + + for (const line of lines) { + const trimmed = line.trim(); + + if (/^proxy-groups:/.test(line)) { + inProxyGroups = true; + inProxiesList = false; + result.push(line); + continue; + } + if (/^[a-zA-Z]/.test(line) && !line.startsWith(' ') && !line.startsWith('-') && !/^proxy-groups/.test(line)) { + inProxyGroups = false; + inProxiesList = false; + } + + if (inProxyGroups) { + if (/^\s+-\s+name:/.test(line)) { + inProxiesList = false; + } + if (/^\s+proxies:\s*$/.test(line)) { + inProxiesList = true; + result.push(line); + continue; + } + if (inProxiesList && /^\s+-\s+/.test(line)) { + const name = trimmed.replace(/^-\s+/, ''); + if (names.has(name)) continue; + } + } + + result.push(line); + } + + return result.join('\n'); +} + +/** Inject user rules at the top of the rules: section. */ +function injectClashRules( + config: string, + rules: { type: string; value: string; action: string; comment: string | null }[] +): string { + const ruleLines = rules.map(r => { + const line = ` - ${r.type},${r.value},${r.action}`; + return r.comment ? `${line} # ${r.comment}` : line; + }); + + const lines = config.split('\n'); + const result: string[] = []; + + for (const line of lines) { + result.push(line); + if (/^rules:/.test(line.trim())) { + result.push(' # --- 自定义规则 ---'); + for (const rl of ruleLines) result.push(rl); + result.push(' # --- 订阅规则 ---'); + } + } + + return result.join('\n'); +} + /** * Inject static node YAML lines into the proxies: section. * Inserts immediately after the `proxies:` key line. @@ -48,7 +198,6 @@ function injectProxies(config: string, clashLines: string[]): string { if (!injected && trimmed === 'proxies:') { result.push(lines[i]); - // Inject right after the `proxies:` key for (const cl of clashLines) result.push(cl); injected = true; continue; @@ -126,40 +275,35 @@ function injectProxyGroupNames(config: string, names: string[]): string { } if (inSelectGroup) { - // Handle inline array: proxies: [node1, node2, ...] + // Handle inline array: proxies: [node1, node2, ...] → prepend static names if (/^\s+proxies:\s*\[/.test(line)) { - // Append names before the closing ] + const openBracket = line.indexOf('['); const closeBracket = line.lastIndexOf(']'); - if (closeBracket !== -1) { - const before = line.slice(0, closeBracket); - const after = line.slice(closeBracket); - result.push(before + ', ' + names.join(', ') + after); + if (openBracket !== -1 && closeBracket !== -1) { + const prefix = line.slice(0, openBracket + 1); + const existing = line.slice(openBracket + 1, closeBracket).trim(); + const sep = existing ? ', ' : ''; + result.push(prefix + names.join(', ') + sep + existing + ']'); } else { result.push(line); } continue; } - // Handle block sequence: ` proxies:` followed by ` - name` items + // Handle block sequence: ` proxies:` → inject static names before existing items if (/^\s+proxies:\s*$/.test(line)) { inProxiesSublist = true; result.push(line); + // Peek at next line to determine indentation + const nextLine = lines[i + 1]; + const indentMatch = nextLine?.match(/^(\s+)-\s/); + const indent = indentMatch?.[1] ?? ' '; + for (const name of names) result.push(`${indent}- ${name}`); continue; } if (inProxiesSublist) { - // Check if next line is still a proxies list item - const nextLine = lines[i + 1]; - const isLastItem = !nextLine || !/^\s{4,}-\s/.test(nextLine); - result.push(line); - - if (isLastItem && /^\s{4,}-\s/.test(line)) { - // Inject names after last item - const indent = line.match(/^(\s+)/)?.[1] || ' '; - for (const name of names) result.push(`${indent}- ${name}`); - inProxiesSublist = false; - } continue; } } diff --git a/server/src/services/ssrGenerator.ts b/server/src/services/ssrGenerator.ts index db801a6..2a218a7 100644 --- a/server/src/services/ssrGenerator.ts +++ b/server/src/services/ssrGenerator.ts @@ -9,18 +9,72 @@ export function generateSSRConfig(): string { return ''; } + // Get disabled fetched nodes for filtering (by server:port) + const disabledNodes = db.prepare( + 'SELECT server, port FROM fetched_nodes WHERE enabled = 0' + ).all() as { server: string; port: number }[]; + const disabledSet = new Set(disabledNodes.map(n => `${n.server}:${n.port}`)); + const staticNodes = db.prepare( - 'SELECT uri FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id' - ).all() as any[]; + 'SELECT uri, name FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id' + ).all() as { uri: string; name: string }[]; // Decode base64 content → URI list const decoded = Buffer.from(sub.raw_config_ssr.trim(), 'base64').toString(); - const lines = decoded.split(/\r?\n/).map((l: string) => l.trim()).filter(Boolean); + const rawLines = decoded.split(/\r?\n/).map((l: string) => l.trim()).filter(Boolean); - // Append static node URIs - for (const node of staticNodes) { - if (node.uri) lines.push(node.uri); - } + // Filter out disabled fetched nodes by server:port matching + const filteredLines = disabledSet.size > 0 + ? rawLines.filter((line: string) => { + const sp = extractServerPort(line); + return !sp || !disabledSet.has(`${sp.server}:${sp.port}`); + }) + : rawLines; - return Buffer.from(lines.join('\n')).toString('base64'); + // Prepend static node URIs with custom names (static nodes go first) + const staticLines = staticNodes + .filter(n => n.uri) + .map(n => setUriName(n.uri, n.name)); + filteredLines.unshift(...staticLines); + + return Buffer.from(filteredLines.join('\n')).toString('base64'); +} + +/** Extract server and port from a proxy URI. Returns null if unparseable. */ +function extractServerPort(uri: string): { server: string; port: number } | null { + try { + if (uri.startsWith('ss://') || uri.startsWith('trojan://')) { + const url = new URL(uri); + return { server: url.hostname, port: parseInt(url.port, 10) }; + } + if (uri.startsWith('vmess://')) { + const json = JSON.parse(Buffer.from(uri.replace('vmess://', ''), 'base64').toString()); + return { server: json.add, port: parseInt(json.port, 10) }; + } + if (uri.startsWith('ssr://')) { + // SSR: ssr://base64(host:port:protocol:method:obfs:base64pass/?params) + const inner = Buffer.from(uri.replace('ssr://', ''), 'base64').toString(); + const [main] = inner.split('/?'); + const parts = main.split(':'); + return { server: parts[0], port: parseInt(parts[1], 10) }; + } + } catch {} + return null; +} + +/** Re-encode a proxy URI with a custom display name. */ +function setUriName(uri: string, name: string): string { + try { + if (uri.startsWith('vmess://')) { + const json = JSON.parse(Buffer.from(uri.replace('vmess://', ''), 'base64').toString()); + json.ps = name; + return 'vmess://' + Buffer.from(JSON.stringify(json)).toString('base64'); + } + // ss://, trojan://, ssr:// use #fragment for the display name + const hashIdx = uri.indexOf('#'); + const base = hashIdx >= 0 ? uri.slice(0, hashIdx) : uri; + return base + '#' + encodeURIComponent(name); + } catch { + return uri; + } }