fix: 修复 Clash/SSR 订阅生成器的多个问题
- 按 server:port 过滤 disabled 节点,使节点开关对 Clash/SSR 生效 - 静态节点使用自定义名称(而非 URI 内嵌名),且置于列表最前 - Clash 配置注入自定义规则到 rules: 段顶部 - Clash/SSR 端点 Content-Disposition filename 改为 IPLC.MAX Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -51,7 +51,7 @@ app.get('/clash/:token', (req, res) => {
|
|||||||
const config = generateClashConfig();
|
const config = generateClashConfig();
|
||||||
res.set({
|
res.set({
|
||||||
'Content-Type': 'text/plain; charset=utf-8',
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
'Content-Disposition': 'attachment; filename=clash.yaml',
|
'Content-Disposition': 'attachment; filename=IPLC.MAX.yaml',
|
||||||
});
|
});
|
||||||
res.send(config);
|
res.send(config);
|
||||||
});
|
});
|
||||||
@@ -62,7 +62,7 @@ app.get('/ssr/:token', (req, res) => {
|
|||||||
const config = generateSSRConfig();
|
const config = generateSSRConfig();
|
||||||
res.set({
|
res.set({
|
||||||
'Content-Type': 'text/plain; charset=utf-8',
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
'Content-Disposition': 'attachment; filename=proxy.txt',
|
'Content-Disposition': 'attachment; filename=IPLC.MAX.txt',
|
||||||
});
|
});
|
||||||
res.send(config);
|
res.send(config);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import db from '../db.js';
|
import db from '../db.js';
|
||||||
import { uriToClashLine } from '../parsers/toClash.js';
|
import { uriToClashLine } from '../parsers/toClash.js';
|
||||||
|
|
||||||
|
function escYaml(s: string): string {
|
||||||
|
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
export function generateClashConfig(): string {
|
export function generateClashConfig(): string {
|
||||||
const sub = db.prepare(
|
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'
|
'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.';
|
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(
|
const staticNodes = db.prepare(
|
||||||
'SELECT uri, name FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
|
'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) {
|
const userRules = db.prepare(
|
||||||
return sub.raw_config_clash;
|
'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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inject static nodes with custom names
|
||||||
|
if (staticNodes.length > 0) {
|
||||||
const clashLines = staticNodes
|
const clashLines = staticNodes
|
||||||
.map((n: any) => uriToClashLine(n.uri))
|
.map(n => withCustomName(uriToClashLine(n.uri), n.name))
|
||||||
.filter((l): l is string => l !== null);
|
.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) {
|
||||||
|
config = injectProxies(config, clashLines);
|
||||||
if (clashLines.length === 0) {
|
config = injectProxyGroupNames(config, staticNames);
|
||||||
return sub.raw_config_clash;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
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>): string {
|
||||||
|
const lines = config.split('\n');
|
||||||
|
const result: string[] = [];
|
||||||
|
const removedNames = new Set<string>();
|
||||||
|
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>): 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.
|
* Inject static node YAML lines into the proxies: section.
|
||||||
* Inserts immediately after the `proxies:` key line.
|
* Inserts immediately after the `proxies:` key line.
|
||||||
@@ -48,7 +198,6 @@ function injectProxies(config: string, clashLines: string[]): string {
|
|||||||
|
|
||||||
if (!injected && trimmed === 'proxies:') {
|
if (!injected && trimmed === 'proxies:') {
|
||||||
result.push(lines[i]);
|
result.push(lines[i]);
|
||||||
// Inject right after the `proxies:` key
|
|
||||||
for (const cl of clashLines) result.push(cl);
|
for (const cl of clashLines) result.push(cl);
|
||||||
injected = true;
|
injected = true;
|
||||||
continue;
|
continue;
|
||||||
@@ -126,40 +275,35 @@ function injectProxyGroupNames(config: string, names: string[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (inSelectGroup) {
|
if (inSelectGroup) {
|
||||||
// Handle inline array: proxies: [node1, node2, ...]
|
// Handle inline array: proxies: [node1, node2, ...] → prepend static names
|
||||||
if (/^\s+proxies:\s*\[/.test(line)) {
|
if (/^\s+proxies:\s*\[/.test(line)) {
|
||||||
// Append names before the closing ]
|
const openBracket = line.indexOf('[');
|
||||||
const closeBracket = line.lastIndexOf(']');
|
const closeBracket = line.lastIndexOf(']');
|
||||||
if (closeBracket !== -1) {
|
if (openBracket !== -1 && closeBracket !== -1) {
|
||||||
const before = line.slice(0, closeBracket);
|
const prefix = line.slice(0, openBracket + 1);
|
||||||
const after = line.slice(closeBracket);
|
const existing = line.slice(openBracket + 1, closeBracket).trim();
|
||||||
result.push(before + ', ' + names.join(', ') + after);
|
const sep = existing ? ', ' : '';
|
||||||
|
result.push(prefix + names.join(', ') + sep + existing + ']');
|
||||||
} else {
|
} else {
|
||||||
result.push(line);
|
result.push(line);
|
||||||
}
|
}
|
||||||
continue;
|
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)) {
|
if (/^\s+proxies:\s*$/.test(line)) {
|
||||||
inProxiesSublist = true;
|
inProxiesSublist = true;
|
||||||
result.push(line);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inProxiesSublist) {
|
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);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,18 +9,72 @@ export function generateSSRConfig(): string {
|
|||||||
return '';
|
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(
|
const staticNodes = db.prepare(
|
||||||
'SELECT uri FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
|
'SELECT uri, name FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
|
||||||
).all() as any[];
|
).all() as { uri: string; name: string }[];
|
||||||
|
|
||||||
// Decode base64 content → URI list
|
// Decode base64 content → URI list
|
||||||
const decoded = Buffer.from(sub.raw_config_ssr.trim(), 'base64').toString();
|
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
|
// Filter out disabled fetched nodes by server:port matching
|
||||||
for (const node of staticNodes) {
|
const filteredLines = disabledSet.size > 0
|
||||||
if (node.uri) lines.push(node.uri);
|
? rawLines.filter((line: string) => {
|
||||||
|
const sp = extractServerPort(line);
|
||||||
|
return !sp || !disabledSet.has(`${sp.server}:${sp.port}`);
|
||||||
|
})
|
||||||
|
: rawLines;
|
||||||
|
|
||||||
|
// 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
return Buffer.from(lines.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user