|
| 1 | +// createRuleMatcher.ts |
| 2 | + |
| 3 | +/* 固定 bypass 域名(根域与任意层级子域名都直连) */ |
| 4 | +export const BYPASS_DOMAINS = [ |
| 5 | + "localhost", |
| 6 | + ".localhost", |
| 7 | + ".local", |
| 8 | + ".localdomain", |
| 9 | + ".home.arpa", |
| 10 | + "host.docker.internal", |
| 11 | + "gateway.docker.internal", |
| 12 | +] as const; |
| 13 | + |
| 14 | +/* 固定 bypass IPv4 段(直连) */ |
| 15 | +export const BYPASS_IP_CIDRS = [ |
| 16 | + "127.0.0.0/8", |
| 17 | + "10.0.0.0/8", |
| 18 | + "172.16.0.0/12", |
| 19 | + "192.168.0.0/16", |
| 20 | + "169.254.0.0/16", |
| 21 | + "100.64.0.0/10", |
| 22 | + "224.0.0.0/4", |
| 23 | + "0.0.0.0/8", |
| 24 | + "255.255.255.255/32", |
| 25 | +] as const; |
| 26 | + |
| 27 | +export type FilterRule = { |
| 28 | + DOMAIN: string[]; |
| 29 | + IP: string[]; |
| 30 | +}; |
| 31 | + |
| 32 | +type CidrTuple = { base: number; maskBits: number }; |
| 33 | + |
| 34 | +/** 统一为“以 . 开头的后缀”并去重:["example.com",".foo"] -> [".example.com",".foo"] */ |
| 35 | +function normalizeSuffixesUnique(list: string[] | undefined): string[] { |
| 36 | + const arr = (list ?? []) |
| 37 | + .map((s) => s.trim().toLowerCase()) |
| 38 | + .filter(Boolean) |
| 39 | + .map((s) => (s.startsWith(".") ? s : "." + s)); |
| 40 | + return Array.from(new Set(arr)); |
| 41 | +} |
| 42 | + |
| 43 | +/** "a.b.c.d" -> uint32(0..2^32-1);非法返回 null */ |
| 44 | +function ipv4ToInt(ipStr: string): number | null { |
| 45 | + const m = ipStr.match( |
| 46 | + /^(25[0-5]|2[0-4]\d|1?\d?\d)\.(25[0-5]|2[0-4]\d|1?\d?\d)\.(25[0-5]|2[0-4]\d|1?\d?\d)\.(25[0-5]|2[0-4]\d|1?\d?\d)$/ |
| 47 | + ); |
| 48 | + if (!m) return null; |
| 49 | + const a = Number(m[1]), |
| 50 | + b = Number(m[2]), |
| 51 | + c = Number(m[3]), |
| 52 | + d = Number(m[4]); |
| 53 | + return (((a << 24) >>> 0) + (b << 16) + (c << 8) + d) >>> 0; |
| 54 | +} |
| 55 | + |
| 56 | +function intToIpv4(n: number): string { |
| 57 | + return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff].join( |
| 58 | + "." |
| 59 | + ); |
| 60 | +} |
| 61 | + |
| 62 | +/** 掩码位数 -> 掩码位图(uint32) */ |
| 63 | +function maskToBits(maskBits: number): number { |
| 64 | + return maskBits === 0 ? 0 : (~((1 << (32 - maskBits)) - 1)) >>> 0; |
| 65 | +} |
| 66 | + |
| 67 | +/** 掩码位数 -> 点分掩码 */ |
| 68 | +function maskBitsToDottedMask(bits: number): string { |
| 69 | + if (bits <= 0) return "0.0.0.0"; |
| 70 | + if (bits >= 32) return "255.255.255.255"; |
| 71 | + const mask = (0xffffffff << (32 - bits)) >>> 0; |
| 72 | + return [ |
| 73 | + (mask >>> 24) & 0xff, |
| 74 | + (mask >>> 16) & 0xff, |
| 75 | + (mask >>> 8) & 0xff, |
| 76 | + mask & 0xff, |
| 77 | + ].join("."); |
| 78 | +} |
| 79 | + |
| 80 | +/** 解析 "x.y.z.w/n" 或 "x.y.z.w" 为 { base, maskBits }(base 已按掩码归一化);非法返回 null */ |
| 81 | +function toCidrTuple(s: string): CidrTuple | null { |
| 82 | + const parts = s.split("/"); |
| 83 | + if (parts.length === 1) { |
| 84 | + const ip = ipv4ToInt(parts[0].trim()); |
| 85 | + if (ip === null) return null; |
| 86 | + return { base: ip, maskBits: 32 }; |
| 87 | + } |
| 88 | + if (parts.length === 2) { |
| 89 | + const ip = ipv4ToInt(parts[0].trim()); |
| 90 | + const mask = Number(parts[1]); |
| 91 | + if (ip === null || !Number.isInteger(mask) || mask < 0 || mask > 32) return null; |
| 92 | + const base = (ip & maskToBits(mask)) >>> 0; // 归一化网络地址 |
| 93 | + return { base, maskBits: mask }; |
| 94 | + } |
| 95 | + return null; |
| 96 | +} |
| 97 | + |
| 98 | +/** ip 是否落在 base/mask 中 */ |
| 99 | +function inCidr(ip: number, base: number, maskBits: number): boolean { |
| 100 | + const mask = maskToBits(maskBits); |
| 101 | + return (ip & mask) === (base & mask); |
| 102 | +} |
| 103 | + |
| 104 | +/** host 是否匹配 ".example.com"(含根域名相等) */ |
| 105 | +function domainMatches(hostLower: string, suffixDotLower: string): boolean { |
| 106 | + const plain = suffixDotLower.slice(1); |
| 107 | + return hostLower === plain || hostLower.endsWith(suffixDotLower); |
| 108 | +} |
| 109 | + |
| 110 | +export function createRuleMatcher(rules: FilterRule) { |
| 111 | + /* 原始规则副本(便于导出/调试) */ |
| 112 | + const rawRules: FilterRule = { |
| 113 | + DOMAIN: [...(rules?.DOMAIN ?? [])], |
| 114 | + IP: [...(rules?.IP ?? [])], |
| 115 | + }; |
| 116 | + |
| 117 | + /* 编译用户域名/网段 */ |
| 118 | + const domainSuffixes = normalizeSuffixesUnique(rawRules.DOMAIN); |
| 119 | + |
| 120 | + const ipCidrs: CidrTuple[] = (rawRules.IP ?? []) |
| 121 | + .map((s) => s.trim()) |
| 122 | + .filter(Boolean) |
| 123 | + .map(toCidrTuple) |
| 124 | + .filter((x): x is CidrTuple => !!x); |
| 125 | + |
| 126 | + /* 编译固定 bypass */ |
| 127 | + const bypassDomainSuffixes = normalizeSuffixesUnique([...BYPASS_DOMAINS]); |
| 128 | + const bypassIpCidrs: CidrTuple[] = [...BYPASS_IP_CIDRS] |
| 129 | + .map(toCidrTuple) |
| 130 | + .filter((x): x is CidrTuple => !!x); |
| 131 | + |
| 132 | + function match(host: string): boolean { |
| 133 | + const h = host.trim(); |
| 134 | + const ip = ipv4ToInt(h); |
| 135 | + |
| 136 | + // 0) 固定 bypass 优先:命中则“排除在代理之外” |
| 137 | + if (ip !== null) { |
| 138 | + for (const { base, maskBits } of bypassIpCidrs) { |
| 139 | + if (inCidr(ip, base, maskBits)) return false; |
| 140 | + } |
| 141 | + } else { |
| 142 | + const lh = h.toLowerCase(); |
| 143 | + for (const suf of bypassDomainSuffixes) { |
| 144 | + if (domainMatches(lh, suf)) return false; |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + // 1) 用户 IP 规则 |
| 149 | + if (ip !== null) { |
| 150 | + for (const { base, maskBits } of ipCidrs) { |
| 151 | + if (inCidr(ip, base, maskBits)) return true; |
| 152 | + } |
| 153 | + return false; |
| 154 | + } |
| 155 | + |
| 156 | + // 2) 用户 DOMAIN 规则 |
| 157 | + const lh = h.toLowerCase(); |
| 158 | + for (const suf of domainSuffixes) { |
| 159 | + if (domainMatches(lh, suf)) return true; |
| 160 | + } |
| 161 | + return false; |
| 162 | + } |
| 163 | + |
| 164 | + /** |
| 165 | + * 生成 PAC 文本(bypass → DIRECT, 用户规则 → proxy, 默认 DIRECT) |
| 166 | + * @param proxy 例:"PROXY 127.0.0.1:1080; DIRECT" |
| 167 | + * @param opts.embedMeta 是否在 PAC 顶部注释中嵌入 raw/compiled 元数据 |
| 168 | + */ |
| 169 | + function pac( |
| 170 | + proxy = "SOCKS5 127.0.0.1:3002; SOCKS 127.0.0.1:3002; PROXY 127.0.0.1:3002; DIRECT", |
| 171 | + opts?: { embedMeta?: boolean } |
| 172 | + ): string { |
| 173 | + |
| 174 | + |
| 175 | + // 直连(bypass)域名条件 |
| 176 | + const bypassDomainChecks = bypassDomainSuffixes.map((suf) => { |
| 177 | + const plain = suf.slice(1); |
| 178 | + return `(host === "${plain}" || dnsDomainIs(host, "${suf}"))`; |
| 179 | + }); |
| 180 | + |
| 181 | + // 直连(bypass)IP 条件(网络地址 + 掩码) |
| 182 | + const bypassIpChecks = bypassIpCidrs.map((c) => { |
| 183 | + const net = intToIpv4(c.base & maskToBits(c.maskBits)); |
| 184 | + const mask = maskBitsToDottedMask(c.maskBits); |
| 185 | + return `isInNet(host, "${net}", "${mask}")`; |
| 186 | + }); |
| 187 | + |
| 188 | + // 用户域名条件 |
| 189 | + const userDomainChecks = domainSuffixes.map((suf) => { |
| 190 | + const plain = suf.slice(1); |
| 191 | + return `(host === "${plain}" || dnsDomainIs(host, "${suf}"))`; |
| 192 | + }); |
| 193 | + |
| 194 | + // 用户 IP 条件(去掉与 bypass 完全相同的条目以免重复) |
| 195 | + const bypassSet = new Set( |
| 196 | + bypassIpCidrs.map( |
| 197 | + (c) => `${intToIpv4(c.base & maskToBits(c.maskBits))}/${c.maskBits}` |
| 198 | + ) |
| 199 | + ); |
| 200 | + const userIpChecks = ipCidrs |
| 201 | + .filter( |
| 202 | + (c) => |
| 203 | + !bypassSet.has( |
| 204 | + `${intToIpv4(c.base & maskToBits(c.maskBits))}/${c.maskBits}` |
| 205 | + ) |
| 206 | + ) |
| 207 | + .map((c) => { |
| 208 | + const net = intToIpv4(c.base & maskToBits(c.maskBits)); |
| 209 | + const mask = maskBitsToDottedMask(c.maskBits); |
| 210 | + return `isInNet(host, "${net}", "${mask}")`; |
| 211 | + }); |
| 212 | + |
| 213 | + const meta = |
| 214 | + opts?.embedMeta |
| 215 | + ? `/* createRuleMatcher meta |
| 216 | +generatedAt: ${new Date().toISOString()} |
| 217 | +rawRules: ${JSON.stringify(rawRules)} |
| 218 | +compiled: ${JSON.stringify({ |
| 219 | + domainSuffixes, |
| 220 | + ipCidrs: ipCidrs.map((c) => ({ |
| 221 | + network: intToIpv4(c.base & maskToBits(c.maskBits)), |
| 222 | + maskBits: c.maskBits, |
| 223 | + })), |
| 224 | + bypassDomainSuffixes, |
| 225 | + bypassIpCidrs: BYPASS_IP_CIDRS, |
| 226 | +})} |
| 227 | +*/\n` |
| 228 | + : ""; |
| 229 | + |
| 230 | + const pac = `${meta}function FindProxyForURL(url, host) { |
| 231 | + if (!host) return "DIRECT"; |
| 232 | + // 仅聚焦 IPv4;IPv6 字面量直接直连 |
| 233 | + if (host.indexOf(":") !== -1) return "DIRECT"; |
| 234 | + // 标准化域名大小写 |
| 235 | + host = host.toLowerCase(); |
| 236 | +
|
| 237 | + // 固定 bypass:先直连 |
| 238 | + ${bypassDomainChecks.length ? `if (${bypassDomainChecks.join(" ||\n ")}) return "DIRECT";` : ""} |
| 239 | + ${bypassIpChecks.length ? `if (${bypassIpChecks.join(" ||\n ")}) return "DIRECT";` : ""} |
| 240 | +
|
| 241 | + // 用户规则:命中则直连;其余全部走代理 |
| 242 | + ${userDomainChecks.length ? `if (${userDomainChecks.join(" ||\n ")}) return "DIRECT";` : ""} |
| 243 | + ${userIpChecks.length ? `if (${userIpChecks.join(" ||\n ")}) return "DIRECT";` : ""} |
| 244 | +
|
| 245 | + return "${proxy}"; |
| 246 | +} |
| 247 | +`; |
| 248 | + return pac; |
| 249 | + } |
| 250 | + |
| 251 | + /** 导出原始规则(浅拷贝) */ |
| 252 | + function exportRules(): FilterRule { |
| 253 | + return { DOMAIN: [...rawRules.DOMAIN], IP: [...rawRules.IP] }; |
| 254 | + } |
| 255 | + |
| 256 | + /** 导出编译视图,便于核对或日志打印 */ |
| 257 | + function compiled() { |
| 258 | + return { |
| 259 | + DOMAIN_SUFFIXES: [...domainSuffixes], |
| 260 | + IP_CIDRS: ipCidrs.map((c) => ({ |
| 261 | + network: intToIpv4(c.base & maskToBits(c.maskBits)), |
| 262 | + maskBits: c.maskBits, |
| 263 | + })), |
| 264 | + BYPASS_DOMAINS: [...bypassDomainSuffixes], |
| 265 | + BYPASS_IP_CIDRS: [...BYPASS_IP_CIDRS], |
| 266 | + }; |
| 267 | + } |
| 268 | + |
| 269 | + /** 便捷调试 dump(美化 JSON) */ |
| 270 | + function dump(): string { |
| 271 | + return JSON.stringify({ rules: exportRules(), compiled: compiled() }, null, 2); |
| 272 | + } |
| 273 | + |
| 274 | + return { match, pac, rules, compiled, dump }; |
| 275 | +} |
| 276 | + |
| 277 | +/* ------------------------------------------- |
| 278 | +使用示例: |
| 279 | +
|
| 280 | +const matcher = createRuleMatcher({ |
| 281 | + DOMAIN: ["example.com", ".internal.corp"], |
| 282 | + IP: ["8.8.8.8", "203.0.113.0/24"], |
| 283 | +}); |
| 284 | +
|
| 285 | +// 运行时匹配 |
| 286 | +matcher.match("api.example.com"); // true |
| 287 | +matcher.match("localhost"); // false(bypass) |
| 288 | +matcher.match("192.168.1.10"); // false(bypass) |
| 289 | +matcher.match("8.8.8.8"); // true |
| 290 | +
|
| 291 | +// 生成 PAC(可供 /pac 路由返回) |
| 292 | +const pacText = matcher.pac("PROXY 127.0.0.1:1080; DIRECT", { embedMeta: true }); |
| 293 | +
|
| 294 | +------------------------------------------- */ |
0 commit comments