feat: fix clash
This commit is contained in:
@@ -20,7 +20,14 @@
|
||||
"Bash(docker run:*)",
|
||||
"Bash(docker stop:*)",
|
||||
"Bash(docker compose:*)",
|
||||
"Bash(ssh-keyscan 118.195.187.179)"
|
||||
"Bash(ssh-keyscan 118.195.187.179)",
|
||||
"Bash(sqlite3 *)",
|
||||
"Bash(echo \"EXIT=$?\")",
|
||||
"Bash(open -a OrbStack)",
|
||||
"Bash(docker info *)",
|
||||
"Bash(docker save *)",
|
||||
"Bash(gzip)",
|
||||
"Bash(docker buildx *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -9,7 +9,8 @@
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"express": "^4.21.0",
|
||||
"tsx": "^4.19.0"
|
||||
"tsx": "^4.19.0",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
@@ -18,6 +19,8 @@
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": ["better-sqlite3"]
|
||||
"onlyBuiltDependencies": [
|
||||
"better-sqlite3"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
1257
server/pnpm-lock.yaml
generated
Normal file
1257
server/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
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(/,+$/, '')}`;
|
||||
const filteredProxies = proxies.filter(p => {
|
||||
if (!p || typeof p !== 'object') return true;
|
||||
const key = `${p.server}:${p.port}`;
|
||||
if (disabledSet.has(key)) {
|
||||
const nameMatch = trimmed.match(/name:\s*"([^"]*)"/);
|
||||
if (nameMatch) removedNames.add(nameMatch[1]);
|
||||
continue; // skip this proxy
|
||||
if (typeof p.name === 'string') removedNames.add(p.name);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
if (inProxyGroups) {
|
||||
if (/^\s+-\s+name:/.test(line)) {
|
||||
inProxiesList = false;
|
||||
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];
|
||||
}
|
||||
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;
|
||||
|
||||
group.proxies = list;
|
||||
}
|
||||
}
|
||||
|
||||
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}`;
|
||||
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 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(' # --- 订阅规则 ---');
|
||||
}
|
||||
const existing: string[] = Array.isArray(doc.rules)
|
||||
? doc.rules.filter((r: unknown): r is string => typeof r === 'string')
|
||||
: [];
|
||||
doc.rules = [...ruleLines, ...existing];
|
||||
}
|
||||
|
||||
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');
|
||||
return YAML.stringify(doc, { lineWidth: 0 });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user