feat: fix clash

This commit is contained in:
2026-04-17 22:19:12 +08:00
parent e268ed6d10
commit c3d8e1a961
7 changed files with 1368 additions and 320 deletions

View File

@@ -1,9 +1,17 @@
/**
* Convert proxy URIs (ss://, vmess://, trojan://) to Clash YAML flow-style proxy lines.
* Returns a string like ` - {name: "NodeName", type: ss, server: ..., port: ..., ...}`
* Convert proxy URIs (ss://, vmess://, trojan://) to Clash proxy objects.
* Returns a JS object suitable for inclusion in a Clash YAML `proxies:` array,
* or null if the URI is unsupported/invalid.
*/
export function uriToClashLine(uri: string): string | null {
export interface ClashProxy {
name: string;
type: string;
server: string;
port: number;
[key: string]: unknown;
}
export function uriToClashProxy(uri: string): ClashProxy | null {
try {
if (uri.startsWith('ss://')) return ssToClash(uri);
if (uri.startsWith('vmess://')) return vmessToClash(uri);
@@ -14,66 +22,65 @@ export function uriToClashLine(uri: string): string | null {
}
}
/** Escape a string value for use inside double-quoted YAML flow style */
function esc(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function ssToClash(uri: string): string | null {
function ssToClash(uri: string): ClashProxy | null {
const url = new URL(uri);
const name = decodeURIComponent(url.hash.slice(1));
const server = url.hostname;
const port = url.port;
const port = parseInt(url.port, 10);
// userinfo is base64(method:password)
const decoded = Buffer.from(url.username, 'base64').toString();
const firstColon = decoded.indexOf(':');
if (firstColon === -1) return null;
const cipher = decoded.slice(0, firstColon);
const password = decoded.slice(firstColon + 1);
return ` - {name: "${esc(name)}", type: ss, server: ${server}, port: ${port}, cipher: ${cipher}, password: "${esc(password)}"}`;
return { name, type: 'ss', server, port, cipher, password };
}
function vmessToClash(uri: string): string | null {
function vmessToClash(uri: string): ClashProxy | null {
const b64 = uri.replace('vmess://', '');
const json = JSON.parse(Buffer.from(b64, 'base64').toString());
const name = json.ps || 'VMess';
const server = json.add;
const port = json.port;
const port = parseInt(json.port, 10);
const uuid = json.id;
if (!server || !port || !uuid) return null;
let line = ` - {name: "${esc(name)}", type: vmess, server: ${server}, port: ${port}, uuid: ${uuid}, alterId: 0, cipher: auto`;
const proxy: ClashProxy = {
name,
type: 'vmess',
server,
port,
uuid,
alterId: 0,
cipher: 'auto',
};
if (json.tls === 'tls') {
line += ', tls: true';
if (json.sni) line += `, sni: ${json.sni}`;
proxy.tls = true;
if (json.sni) proxy.servername = json.sni;
}
if (json.net === 'ws') {
line += ', network: ws';
const wsOpts: string[] = [];
if (json.path) wsOpts.push(`path: "${esc(json.path)}"`);
if (json.host) wsOpts.push(`headers: {Host: ${json.host}}`);
if (wsOpts.length > 0) {
line += `, ws-opts: {${wsOpts.join(', ')}}`;
}
proxy.network = 'ws';
const wsOpts: Record<string, unknown> = {};
if (json.path) wsOpts.path = json.path;
if (json.host) wsOpts.headers = { Host: json.host };
if (Object.keys(wsOpts).length > 0) proxy['ws-opts'] = wsOpts;
}
line += '}';
return line;
return proxy;
}
function trojanToClash(uri: string): string | null {
function trojanToClash(uri: string): ClashProxy | null {
const url = new URL(uri);
const name = decodeURIComponent(url.hash.slice(1));
const server = url.hostname;
const port = url.port;
const port = parseInt(url.port, 10);
const password = url.username;
const sni = url.searchParams.get('sni') || server;
return ` - {name: "${esc(name)}", type: trojan, server: ${server}, port: ${port}, password: "${esc(password)}", sni: ${sni}}`;
return { name, type: 'trojan', server, port, password, sni };
}

View File

@@ -1,9 +1,6 @@
import YAML from 'yaml';
import db from '../db.js';
import { uriToClashLine } from '../parsers/toClash.js';
function escYaml(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
import { uriToClashProxy, type ClashProxy } from '../parsers/toClash.js';
export function generateClashConfig(): string {
const sub = db.prepare(
@@ -14,13 +11,20 @@ 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)
let doc: any;
try {
doc = YAML.parse(sub.raw_config_clash);
} catch (e: any) {
return `# Failed to parse upstream Clash YAML: ${e.message}`;
}
if (!doc || typeof doc !== 'object') return sub.raw_config_clash;
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 staticRows = db.prepare(
'SELECT uri, name FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
).all() as { uri: string; name: string }[];
@@ -28,288 +32,58 @@ export function generateClashConfig(): string {
'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;
const proxies: ClashProxy[] = Array.isArray(doc.proxies) ? doc.proxies : [];
// 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
.map(n => withCustomName(uriToClashLine(n.uri), n.name))
.filter((l): l is string => l !== null);
const staticNames = staticNodes.map(n => n.name);
if (clashLines.length > 0) {
config = injectProxies(config, clashLines);
config = injectProxyGroupNames(config, staticNames);
}
}
// Inject user rules
if (userRules.length > 0) {
config = injectClashRules(config, userRules);
}
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;
const filteredProxies = proxies.filter(p => {
if (!p || typeof p !== 'object') return true;
const key = `${p.server}:${p.port}`;
if (disabledSet.has(key)) {
if (typeof p.name === 'string') removedNames.add(p.name);
return false;
}
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(', ')}]`;
return true;
});
// 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);
const staticProxies: ClashProxy[] = [];
const staticNames: string[] = [];
for (const row of staticRows) {
const proxy = uriToClashProxy(row.uri);
if (!proxy) continue;
proxy.name = row.name;
staticProxies.push(proxy);
staticNames.push(row.name);
}
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.
* If no `proxies:` key exists, inserts a new proxies block before `proxy-groups:`.
*/
function injectProxies(config: string, clashLines: string[]): string {
const lines = config.split('\n');
const result: string[] = [];
let injected = false;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (!injected && trimmed === 'proxies:') {
result.push(lines[i]);
for (const cl of clashLines) result.push(cl);
injected = true;
continue;
}
// If proxies: never appeared, inject before proxy-groups:
if (!injected && trimmed === 'proxy-groups:') {
result.push('proxies:');
for (const cl of clashLines) result.push(cl);
result.push('');
injected = true;
}
result.push(lines[i]);
}
// Fallback: append at end if neither key was found
if (!injected) {
result.push('');
result.push('proxies:');
for (const cl of clashLines) result.push(cl);
}
return result.join('\n');
}
/**
* Inject static node names into every `type: select` proxy-group's proxies sub-list.
* Handles both block sequence (` - name`) and inline array (`proxies: [a, b]`) formats.
*/
function injectProxyGroupNames(config: string, names: string[]): string {
const lines = config.split('\n');
const result: string[] = [];
let inProxyGroups = false;
let inSelectGroup = false;
let inProxiesSublist = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Detect top-level section changes
if (/^[a-zA-Z]/.test(line) && line.includes(':')) {
const key = line.split(':')[0].trim();
if (key === 'proxy-groups') {
inProxyGroups = true;
inSelectGroup = false;
inProxiesSublist = false;
} else if (!line.startsWith(' ') && !line.startsWith('-')) {
inProxyGroups = false;
inSelectGroup = false;
inProxiesSublist = false;
}
}
if (!inProxyGroups) {
result.push(line);
continue;
}
// New group entry: reset state
if (/^\s*-\s+name:/.test(line)) {
inSelectGroup = false;
inProxiesSublist = false;
result.push(line);
continue;
}
// Detect `type: select` within a group
if (trimmed === 'type: select') {
inSelectGroup = true;
result.push(line);
continue;
}
if (inSelectGroup) {
// Handle inline array: proxies: [node1, node2, ...] → prepend static names
if (/^\s+proxies:\s*\[/.test(line)) {
const openBracket = line.indexOf('[');
const closeBracket = line.lastIndexOf(']');
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:` → 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) {
result.push(line);
continue;
}
}
result.push(line);
}
return result.join('\n');
doc.proxies = [...staticProxies, ...filteredProxies];
if (Array.isArray(doc['proxy-groups'])) {
for (const group of doc['proxy-groups']) {
if (!group || typeof group !== 'object') continue;
if (!Array.isArray(group.proxies)) continue;
let list: string[] = group.proxies.filter(
(n: unknown): n is string => typeof n === 'string' && !removedNames.has(n)
);
if (group.type === 'select' && staticNames.length > 0) {
list = [...staticNames, ...list];
}
group.proxies = list;
}
}
if (userRules.length > 0) {
const ruleLines = userRules.map(r => {
const line = `${r.type},${r.value},${r.action}`;
return r.comment ? `${line} # ${r.comment}` : line;
});
const existing: string[] = Array.isArray(doc.rules)
? doc.rules.filter((r: unknown): r is string => typeof r === 'string')
: [];
doc.rules = [...ruleLines, ...existing];
}
return YAML.stringify(doc, { lineWidth: 0 });
}

View File

@@ -10,14 +10,14 @@ export function generateSurgeConfig(hostUrl: string): string {
return '# No subscription config available. Add and fetch a subscription first.';
}
// Collect enabled fetched nodes
// Collect enabled fetched nodes (exclude vless — Surge doesn't support it)
const fetchedNodes = db.prepare(
'SELECT surge_line FROM fetched_nodes WHERE enabled = 1 ORDER BY subscription_id, id'
'SELECT surge_line FROM fetched_nodes WHERE enabled = 1 AND type != \'vless\' ORDER BY subscription_id, id'
).all() as any[];
// Collect enabled static nodes (these go FIRST)
// Collect enabled static nodes (these go FIRST, exclude vless)
const staticNodes = db.prepare(
'SELECT surge_line FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
'SELECT surge_line FROM static_nodes WHERE enabled = 1 AND type != \'vless\' ORDER BY sort_order, id'
).all() as any[];
// Collect enabled rules