From 9f9207a66e99af5c3d9e056a76f218fb438f6311 Mon Sep 17 00:00:00 2001 From: kanomahoro1224 <3497593286@qq.com> Date: Sat, 3 Oct 2026 15:41:57 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=99=A8=E3=80=8C=E5=8F=91?= =?UTF-8?q?=E7=BB=99=E6=9C=8B=E5=8F=8B=E3=80=8D=E4=B8=8D=E5=86=8D=E6=8A=8A?= =?UTF-8?q?=E4=BB=A3=E7=90=86=20TUN=20=E5=BD=93=E5=85=AC=E7=BD=91=20IP?= =?UTF-8?q?=EF=BC=9B=E5=88=A0=E6=8E=89=E6=B0=B8=E4=B8=8D=E7=94=9F=E6=95=88?= =?UTF-8?q?=E7=9A=84=20^tun\d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 198.18.0.0/15 是 RFC 2544 的基准测试段,Clash / Mihomo 这类代理软件拿它当本地 TUN 的 fake-ip 池 —— 那是本机自己的地址,冒充「公网 IP」发给朋友只会让人连不上。同时按网卡名 补上 Radmin VPN / Hamachi:它们是点对点地址,正是同组好友互连要用的,仍留在 vpn 一类。 另外删掉 VIRTUAL_IF 里的 `^tun\d`:分类顺序是 VPN_IF → VIRTUAL_IF,而 VPN_IF 里的 `tun\d` 是子串匹配,凡能被 `^tun\d` 命中的名字都已经被它命中,这条永远轮不到。同一个 判断写在两处、其中一处是死代码,只会让后来者以为顺序无所谓(review 里就是这么指出的)。 测试从 test/windows-bundle.test.js 移到独立的 test/doctor.test.js —— 这本来就是 tools/doctor.mjs 的纯函数,不该挂在打包测试里。另补两条钉住分类顺序的用例:一条防止 `^tun\d` 被抄回 VIRTUAL_IF,一条记录 macOS 的 utunN 因 `tun\d` 子串匹配而归 vpn 的现状 (那是仓库原有行为,本次未改;把它写下来,免得有人误以为 VIRTUAL_IF 里的 `utun` 在起作用)。 --- test/doctor.test.js | 70 +++++++++++++++++++++++++++++++++++++++++++++ tools/doctor.mjs | 16 ++++++++--- 2 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 test/doctor.test.js diff --git a/test/doctor.test.js b/test/doctor.test.js new file mode 100644 index 00000000..b2069303 --- /dev/null +++ b/test/doctor.test.js @@ -0,0 +1,70 @@ +// test/doctor.test.js — tools/doctor.mjs 的地址分类(纯函数,不起服务器)。 +// 启动器「发给朋友」那一行就是按这里的 kind 打的: +// * 代理软件的 TUN 适配器(Clash / Mihomo 把 198.18.0.0/15 当 fake-ip 池)和 Hyper-V / WSL / Docker +// 这类网卡,从别的电脑访问不到,冒充「公网 IP」发给朋友只会让人连不上 —— 归 virtual; +// * 点对点 VPN(Tailscale / ZeroTier / WireGuard / Radmin VPN / Hamachi)正相反,那正是同组好友 +// 互连用的地址 —— 归 vpn,要留着。 +// 分类顺序是 linklocal → 198.18/15 → VPN_IF → VIRTUAL_IF → 私网 → public,所以同一个名字只由 +// 最先命中的规则决定;下面的测试把这套顺序钉住,避免有人把规则随手复制到两处而其中一处永远不生效。 + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { classifyAddresses, KIND_LABEL } from '../tools/doctor.mjs'; + +/** @param {string} name @param {string} address */ +const iface = (name, address) => [name, [{ family: 'IPv4', address, internal: false }]]; + +describe('启动器「发给朋友」的地址清单(tools/doctor.mjs)', () => { + test('只列真正能连的地址:真正局域网在前,虚拟网卡/代理 TUN 不要冒充公网 IP', () => { + const list = classifyAddresses(Object.fromEntries([ + iface('Mihomo', '198.18.0.1'), // Clash/Mihomo TUN 的 fake-ip 池(RFC 2544) + iface('vEthernet (Default Switch)', '172.25.112.1'), + iface('以太网 5', '192.168.1.7'), + iface('Radmin VPN', '26.200.79.213'), + iface('Tailscale', '100.64.0.9'), + ])); + const kind = (ip) => list.find((a) => a.address === ip)?.kind; + assert.equal(kind('198.18.0.1'), 'virtual', '代理 TUN 不能当成公网 IP 发给朋友'); + assert.equal(kind('172.25.112.1'), 'virtual', 'Hyper-V 网卡'); + assert.equal(kind('26.200.79.213'), 'vpn', 'Radmin VPN 是点对点地址'); + assert.equal(kind('100.64.0.9'), 'vpn', 'CGNAT(Tailscale)'); + assert.equal(kind('192.168.1.7'), 'lan'); + assert.equal(list[0].address, '192.168.1.7', '局域网地址排最前'); + + // launch.mjs 就是按这三种 kind 打印「发给朋友」的 + const shareable = list.filter((a) => ['lan', 'vpn', 'public'].includes(a.kind)).map((a) => a.address); + assert.deepEqual(shareable, ['192.168.1.7', '26.200.79.213', '100.64.0.9']); + assert.ok(!shareable.includes('198.18.0.1')); + assert.ok(KIND_LABEL.virtual, '虚拟网卡也有标签可读'); + }); + + test('198.18.0.0/15 整体都算虚拟(不只是 .0.1)', () => { + const list = classifyAddresses(Object.fromEntries([iface('Clash', '198.19.255.254'), iface('x', '198.20.0.1')])); + assert.equal(list.find((a) => a.address === '198.19.255.254').kind, 'virtual'); + assert.equal(list.find((a) => a.address === '198.20.0.1').kind, 'public', '198.20 不在 /15 里'); + }); + + test('tun/tap/wg 这类点对点网卡一律归 vpn —— 虚拟网卡规则不许抢在 VPN 规则前面', () => { + const list = classifyAddresses(Object.fromEntries([ + iface('tun0', '10.7.0.3'), + iface('tap0', '10.7.0.5'), + iface('wg0', '10.7.0.6'), + iface('vEthernet (Default Switch)', '10.7.0.8'), + ])); + const kind = (ip) => list.find((a) => a.address === ip)?.kind; + assert.equal(kind('10.7.0.3'), 'vpn', 'tun0 是点对点 VPN;若这里变成 virtual,说明有人又把 ^tun 抄回了 VIRTUAL_IF'); + assert.equal(kind('10.7.0.5'), 'vpn'); + assert.equal(kind('10.7.0.6'), 'vpn'); + assert.equal(kind('10.7.0.8'), 'virtual', '只有虚拟网卡规则命中的名字才算 virtual'); + }); + + test('VPN 规则是子串匹配:macOS 的 utunN 也归 vpn(记录现状,不是理想解)', () => { + // VPN_IF 的 `tun\d` 没有边界,macOS 的 utunN 里含 "tunN",于是 utunN 一律先被 VPN 规则命中; + // VIRTUAL_IF 里那条 `utun` 只在名字不含「tun+数字」时才轮得到。这两条钉住现状: + // 改分类顺序或给正则加边界都会让这里先红,提醒改动者想清楚 macOS 的 utun 该怎么算。 + const list = classifyAddresses(Object.fromEntries([iface('utun4', '10.7.0.4'), iface('utun', '10.7.0.9')])); + const kind = (ip) => list.find((a) => a.address === ip)?.kind; + assert.equal(kind('10.7.0.4'), 'vpn', 'utun4 含 "tun4" → VPN_IF 先命中'); + assert.equal(kind('10.7.0.9'), 'virtual', '名字里没有「tun+数字」,才轮到 VIRTUAL_IF 的 utun'); + }); +}); diff --git a/tools/doctor.mjs b/tools/doctor.mjs index 677a9d69..bd658024 100755 --- a/tools/doctor.mjs +++ b/tools/doctor.mjs @@ -24,16 +24,21 @@ import { // LAN addresses (also used by scripts/launch.mjs) // --------------------------------------------------------------------------------------------------- -const VIRTUAL_IF = /(vethernet|virtualbox|vmware|vmnet|docker|^br-|^veth|wsl|hyper-v|vboxnet|bridge\d|utun|awdl|llw|parallels|loopback)/i; -const VPN_IF = /(tailscale|zerotier|^zt|wireguard|^wg\d|tun\d|tap)/i; +// 名字里带这些的网卡不对局域网开放:虚拟机 / 容器 / WSL / 代理软件的 TUN 适配器(Mihomo、Clash)。 +// 这里不再列 `^tun\d`:下面 VPN_IF 的 `tun\d` 先判定,任何能被 `^tun\d` 命中的名字也一定被它命中, +// 于是 tun0/tap0 一律归为 vpn(那正是同组好友互连用的地址)。写在两处只会让人以为顺序无关。 +const VIRTUAL_IF = /(vethernet|virtualbox|vmware|vmnet|docker|^br-|^veth|wsl|hyper-v|vboxnet|bridge\d|utun|awdl|llw|parallels|loopback|mihomo|clash|sing-?box)/i; +// 点对点 VPN:这些地址就是同组好友互相访问用的(Tailscale / ZeroTier / WireGuard / Radmin VPN / Hamachi)。 +const VPN_IF = /(tailscale|zerotier|^zt|wireguard|^wg\d|tun\d|tap|radmin|hamachi)/i; function ipv4ToInt(ip) { return ip.split('.').reduce((n, x) => (n << 8) + Number(x), 0) >>> 0; } function inCidr(ip, base, bits) { const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0; return (ipv4ToInt(ip) & mask) === (ipv4ToInt(base) & mask); } /** * Classify every non-internal IPv4 address: 'lan' (RFC 1918, what friends at home use), 'vpn' (Tailscale / ZeroTier / - * CGNAT 100.64/10), 'virtual' (Hyper-V, WSL, Docker, VirtualBox … — usually not reachable from other machines), - * 'public' (a public address directly on this machine), 'linklocal' (169.254 — no DHCP, useless). + * Radmin / Hamachi / CGNAT 100.64/10), 'virtual' (Hyper-V, WSL, Docker, VirtualBox, a Clash/Mihomo TUN adapter … — + * not reachable from other machines, sharing them only confuses people), 'public' (a public address directly on this + * machine), 'linklocal' (169.254 — no DHCP, useless). * @returns {{ name: string, address: string, kind: string }[]} best first */ export function classifyAddresses(ifaces = os.networkInterfaces()) { @@ -44,6 +49,9 @@ export function classifyAddresses(ifaces = os.networkInterfaces()) { const ip = a.address; let kind; if (inCidr(ip, '169.254.0.0', 16)) kind = 'linklocal'; + // 198.18.0.0/15 是 RFC 2544 的基准测试段:代理软件(Clash / Mihomo 的 fake-ip 池)拿它做本地 TUN 地址, + // 绝对不是能发给朋友的「公网 IP」。 + else if (inCidr(ip, '198.18.0.0', 15)) kind = 'virtual'; else if (VPN_IF.test(name) || inCidr(ip, '100.64.0.0', 10)) kind = 'vpn'; else if (VIRTUAL_IF.test(name)) kind = 'virtual'; else if (inCidr(ip, '10.0.0.0', 8) || inCidr(ip, '172.16.0.0', 12) || inCidr(ip, '192.168.0.0', 16)) kind = 'lan';