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 { const base: Record = { 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 = { 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 = { 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(); const providers: Record> = {}; const takenNames = new Set(); 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 }; }