Files
proxy-station/server/src/services/generators/clash.ts
YANG JIANKUAN a5d980a5e9 feat: 添加 WireGuard 设备管理并完善客户端分流
实现独立加密设备身份、网关 agent、持久生命周期任务和 Surge/FlClash 专属订阅凭证。

完善 Shadowrocket 规则转换与 Clash AI DNS,增加 Surge FFSB 适配、规则快照、运维工具及验收文档。
2026-11-02 22:44:23 +08:00

259 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import YAML from 'yaml';
import { clientSupports, normalizeSsCipher, splitRuleExpression, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
import type { ProfileIR } from '../ir.js';
import { clashAiDns } from './clash-ai-dns.js';
import { WG_POLICY } from './wireguard.js';
export function clashProxy(n: NodeDto): Record<string, unknown> {
const base: Record<string, unknown> = {
name: n.name, server: n.server, port: n.port,
// SIP003 的 WS 插件只转发 TCP,不能把 UDP 发往 nginx 的同端口。
udp: !(n.protocol === 'shadowsocks' && n.network === 'ws'),
};
switch (n.protocol) {
case 'vmess': {
const cipher = n.security === 'chacha20-ietf-poly1305' ? 'chacha20-poly1305' : n.security || 'auto';
Object.assign(base, { type: 'vmess', uuid: n.uuid, alterId: n.alterId, cipher });
if (n.tls) {
base.tls = true;
if (n.sni) base.servername = n.sni;
if (n.skipCertVerify) base['skip-cert-verify'] = true;
}
if (n.network === 'ws') {
base.network = 'ws';
base['ws-opts'] = {
path: n.wsPath,
...(n.wsHost ? { headers: { Host: n.wsHost } } : {}),
};
}
break;
}
case 'trojan': {
Object.assign(base, { type: 'trojan', password: n.password, sni: n.sni || n.server });
if (n.network === 'ws') {
base.network = 'ws';
base['ws-opts'] = {
path: n.wsPath,
...(n.wsHost ? { headers: { Host: n.wsHost } } : {}),
};
}
if (n.skipCertVerify) base['skip-cert-verify'] = true;
break;
}
case 'shadowsocks': {
Object.assign(base, { type: 'ss', cipher: normalizeSsCipher(n.method || ''), password: n.password });
if (n.network === 'ws') {
// SS over WS+TLS 在 Clash 侧唯一可行方案是 v2ray-plugin
base.plugin = 'v2ray-plugin';
base['plugin-opts'] = {
mode: 'websocket',
tls: n.tls,
host: n.wsHost || n.sni || n.server,
path: n.wsPath,
...(n.skipCertVerify ? { 'skip-cert-verify': true } : {}),
};
}
break;
}
case 'hysteria2': {
Object.assign(base, { type: 'hysteria2', password: n.password });
if (n.sni) base.sni = n.sni;
if (n.obfsType === 'salamander' && n.obfsPassword) {
base.obfs = 'salamander';
base['obfs-password'] = n.obfsPassword;
}
if (n.upMbps) base.up = `${n.upMbps} Mbps`;
if (n.downMbps) base.down = `${n.downMbps} Mbps`;
if (n.skipCertVerify) base['skip-cert-verify'] = true;
break;
}
}
return base;
}
/** Surge 独有策略 → mihomo 等价策略 */
function mapPolicy(policy: string): string {
if (policy === 'REJECT-NO-DROP' || policy === 'REJECT-TINYGIF') return 'REJECT';
return policy;
}
/** 逐个转换逻辑子规则;任何不可表达的子条件使整条规则跳过,避免扩大匹配范围。 */
function mapLogicValue(value: string): string | null {
value = value.trim();
if (!value.startsWith('(') || !value.endsWith(')')) return null;
const children = splitRuleExpression(value.slice(1, -1));
if (!children?.length) return null;
const mapped: string[] = [];
for (const child of children) {
if (!child.startsWith('(') || !child.endsWith(')')) return null;
const parts = splitRuleExpression(child.slice(1, -1));
if (!parts || parts.length < 2) return null;
let [type, payload] = parts;
if (['AND', 'OR', 'NOT'].includes(type)) {
const nested = mapLogicValue(payload);
if (!nested) return null;
payload = nested;
} else if (type === 'PROTOCOL') {
if (payload !== 'TCP' && payload !== 'UDP') return null;
type = 'NETWORK';
} else {
const types: Record<string, string> = {
DOMAIN: 'DOMAIN', 'DOMAIN-SUFFIX': 'DOMAIN-SUFFIX', 'DOMAIN-KEYWORD': 'DOMAIN-KEYWORD',
'DOMAIN-WILDCARD': 'DOMAIN-WILDCARD', 'IP-CIDR': 'IP-CIDR', 'IP-CIDR6': 'IP-CIDR6',
'IP-ASN': 'IP-ASN', GEOIP: 'GEOIP', 'PROCESS-NAME': 'PROCESS-NAME',
'SRC-IP': 'SRC-IP-CIDR', 'DEST-PORT': 'DST-PORT', 'SRC-PORT': 'SRC-PORT',
};
if (!types[type] || /["'\\]/.test(payload)) return null;
type = types[type];
}
if (parts.slice(2).some((p) => p !== 'no-resolve')) return null;
mapped.push(`(${[type, payload, ...parts.slice(2)].join(',')})`);
}
return `(${mapped.join(',')})`;
}
const CLASH_UNSUPPORTED_RULE_TYPES = new Set(['URL-REGEX', 'USER-AGENT']);
export interface ClashOutput {
content: string;
excluded: ExcludedNode[];
skippedRules: string[];
}
export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }): ClashOutput {
if (ir.wireguard && [...ir.nodes, ...ir.groups].some(n => n.name === WG_POLICY)) {
throw new Error('WireGuard 组网策略名称与现有节点或组冲突');
}
const excluded: ExcludedNode[] = [];
const usable = ir.nodes.filter((n) => {
const verdict = clientSupports('clash', n);
if (!verdict.ok) excluded.push({ name: n.name, reason: verdict.reason! });
return verdict.ok;
});
const usableNames = new Set(usable.map((n) => n.name));
const proxies = usable.map(clashProxy);
if (ir.wireguard) {
const wg = ir.wireguard;
proxies.push({ name: WG_POLICY, type: 'wireguard', ip: wg.address, 'private-key': wg.privateKey,
mtu: wg.mtu, udp: true, 'persistent-keepalive': wg.keepalive,
peers: [{ server: wg.server, port: wg.port, 'public-key': wg.serverPublicKey,
...(wg.presharedKey ? { 'pre-shared-key': wg.presharedKey } : {}), 'allowed-ips': [wg.subnet] }] });
}
const proxyGroups = ir.groups.map((g) => {
const group: Record<string, unknown> = { name: g.name, type: g.type };
const members = g.members
.filter((m) => m.kind !== 'node' || usableNames.has(m.name))
.map((m) => m.name);
if (g.filterRegex) {
group['include-all-proxies'] = true;
if (ir.wireguard) group['exclude-filter'] = `^${WG_POLICY}$`;
group.filter = g.filterRegex;
if (members.length) group.proxies = [...new Set(members)];
} else if (g.includeAllNodes) {
group['include-all-proxies'] = true;
if (ir.wireguard) group['exclude-filter'] = `^${WG_POLICY}$`;
if (members.length) group.proxies = [...new Set(members)];
} else {
group.proxies = members.length ? members : ['DIRECT'];
}
if (g.type !== 'select') {
group.url = g.testUrl || 'http://cp.cloudflare.com/generate_204';
group.interval = g.interval ?? 300;
if (g.type === 'url-test' && g.tolerance !== null) group.tolerance = g.tolerance;
}
return group;
});
// rule-providers:从启用规则引用的规则集生成,名称唯一化
const providerNameByRulesetId = new Map<string, string>();
const providers: Record<string, Record<string, unknown>> = {};
const takenNames = new Set<string>();
const usedRulesets = ir.rules.flatMap((r) => (r.ruleset ? [r.ruleset] : []));
for (const rs of usedRulesets) {
if (providerNameByRulesetId.has(rs.id)) continue;
let name = rs.name;
for (let i = 2; takenNames.has(name); i++) name = `${rs.name}-${i}`;
takenNames.add(name);
providerNameByRulesetId.set(rs.id, name);
const url = rs.needsConvert || !rs.clashUrl ? `${opts.convertBaseUrl}/ruleset/${rs.id}.yaml` : rs.clashUrl;
providers[name] = {
type: 'http',
url,
behavior: rs.clashBehavior,
format: rs.needsConvert || !rs.clashUrl ? 'yaml' : rs.clashFormat === 'yaml' ? 'yaml' : 'text',
interval: 86400,
};
}
const skippedRules: string[] = [];
const ruleLines: string[] = [];
if (ir.wireguard) ruleLines.push(`IP-CIDR,${ir.wireguard.subnet},${WG_POLICY},no-resolve`);
for (const r of ir.rules) {
if (r.type === 'FINAL') {
ruleLines.push(`MATCH,${mapPolicy(r.policy)}`);
continue;
}
if (CLASH_UNSUPPORTED_RULE_TYPES.has(r.type)) {
skippedRules.push(`${r.type},${r.value},${r.policy}`);
continue;
}
// Surge 的应用协议识别不能直接作为 mihomo 规则类型输出。
if (r.type === 'PROTOCOL') {
if (r.value === 'TCP' || r.value === 'UDP') {
ruleLines.push(`NETWORK,${r.value},${mapPolicy(r.policy)}`);
} else {
skippedRules.push(`${r.type},${r.value},${r.policy}`);
}
continue;
}
const policy = mapPolicy(r.policy);
if (r.ruleset) {
ruleLines.push(['RULE-SET', providerNameByRulesetId.get(r.ruleset.id), policy, ...r.params.filter((p) => p === 'no-resolve')].join(','));
continue;
}
// Surge 内置规则集(非 URL):LAN → 私网 GEOIP;SYSTEM 无对应物,跳过
if (r.type === 'RULE-SET' || r.type === 'DOMAIN-SET') {
if (r.value === 'LAN') {
ruleLines.push(`GEOIP,private,${policy},no-resolve`);
} else {
skippedRules.push(`${r.type},${r.value},${r.policy}`);
}
continue;
}
if (r.type === 'AND' || r.type === 'OR' || r.type === 'NOT') {
const value = mapLogicValue(r.value || '');
if (value) ruleLines.push([r.type, value, policy, ...r.params.filter((p) => p === 'no-resolve')].join(','));
else skippedRules.push(`${r.type},${r.value},${r.policy}`);
continue;
}
const params = r.params.filter((p) => p === 'no-resolve');
ruleLines.push([r.type, r.value, policy, ...params].join(','));
}
const defaultNameservers = ['https://doh.pub/dns-query', 'https://dns.alidns.com/dns-query'];
const aiDns = clashAiDns(ir, providers, providerNameByRulesetId, defaultNameservers);
const doc = {
'mixed-port': 7890,
'allow-lan': false,
mode: 'rule',
'log-level': 'info',
'unified-delay': true,
ipv6: false,
dns: {
enable: true,
listen: '0.0.0.0:1053',
'enhanced-mode': 'fake-ip',
nameserver: defaultNameservers,
...aiDns,
},
proxies,
'proxy-groups': proxyGroups,
'rule-providers': providers,
rules: ruleLines,
};
return { content: YAML.stringify(doc, { lineWidth: 0 }), excluded, skippedRules };
}