diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index abbafbb..bd71800 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,14 +57,14 @@ jobs: $fv = (Get-Item .\WarpGameBoost.exe).VersionInfo "file version $($fv.FileVersion), product $($fv.ProductName) $($fv.ProductVersion)" if (-not $fv.FileVersion.StartsWith($env:VERSION_NUM)) { throw "file version $($fv.FileVersion)" } - - name: Self-test -v6only with system DNS (the runner has no IPv6, so IPv4 entries) + - name: Self-test -exit ipv6 with system DNS (the runner has no IPv6, so IPv4 entries) shell: pwsh run: | - .\WarpGameBoost.exe -selftest -v6only -dns system -diag=false -count 80 - if ($LASTEXITCODE -ne 0) { throw "selftest -v6only failed" } + .\WarpGameBoost.exe -selftest -exit ipv6 -dns system -diag=false -count 80 + if ($LASTEXITCODE -ne 0) { throw "selftest -exit ipv6 failed" } Start-Sleep 2 if (Get-NetAdapter -Name WarpGameBoost -ErrorAction SilentlyContinue) { throw "adapter left behind" } - - name: Self-test with defaults (diagnosis, both exits, malware-blocking DNS) + - name: Self-test with defaults (diagnosis, IPv4-only exit, malware-blocking DNS) shell: pwsh run: | .\WarpGameBoost.exe -selftest -count 80 @@ -77,17 +77,23 @@ jobs: function Fail($m) { Get-Content out.txt -Encoding utf8; Stop-Process -Id $p.Id -Force; throw $m } foreach ($i in 1..40) { Start-Sleep 2; if ((Get-Content out.txt -Raw -Encoding utf8 -ErrorAction SilentlyContinue) -match '加速已开启|失败') { break } } Get-Content out.txt -Encoding utf8 - $t4 = curl.exe -4 -s -m 10 https://www.cloudflare.com/cdn-cgi/trace - $t6 = curl.exe -6 -s -m 10 https://www.cloudflare.com/cdn-cgi/trace - "IPv4 exit: $($t4 | Select-String 'ip=|warp=')" - "IPv6 exit: $($t6 | Select-String 'ip=|warp=')" + $t4 = curl.exe -s -m 10 https://www.cloudflare.com/cdn-cgi/trace + "default exit: $($t4 | Select-String 'ip=|warp=')" if (-not ($t4 -match 'warp=on')) { Fail "IPv4 exit is not WARP" } - if (-not ($t6 -match 'warp=on')) { Fail "IPv6 exit is not WARP" } + if (-not ($t4 -match 'ip=\d+\.')) { Fail "exit is not IPv4" } + $sw = [Diagnostics.Stopwatch]::StartNew() + $t6 = curl.exe -s -m 10 -w '%{errormsg}' "https://[2606:4700:4700::1111]/cdn-cgi/trace" + "IPv6 literal: $t6 ($($sw.ElapsedMilliseconds) ms)" + if ($t6 -match 'warp=') { Fail "IPv6 not blocked" } + if ($sw.ElapsedMilliseconds -gt 6000) { Fail "IPv6 is dropped, not refused" } + $addrs = [Net.Dns]::GetHostAddresses('www.cloudflare.com') -join ' ' + "getaddrinfo: $addrs" + if ($addrs -match ':') { Fail "Windows still hands out IPv6 addresses" } # fec0:0:0:ffff::1-3 are the placeholders Windows shows on IPv6 # interfaces without DNS servers. $dns = (Get-DnsClientServerAddress -InterfaceAlias WarpGameBoost).ServerAddresses | Where-Object { $_ -notlike 'fec0:0:0:ffff::*' } "adapter DNS servers: [$($dns -join ', ')]" - if (($dns -join ',') -ne '1.1.1.2,1.0.0.2,2606:4700:4700::1112,2606:4700:4700::1002') { Fail "unexpected adapter DNS: $dns" } + if (($dns -join ',') -ne '1.1.1.2,1.0.0.2') { Fail "unexpected adapter DNS: $dns" } # The system resolver (no -Server) must be the malware-blocking one: # Cloudflare's test name resolves to 0.0.0.0 there and normally elsewhere. Clear-DnsClientCache @@ -96,9 +102,8 @@ jobs: "malware.testcategory.com -> $blocked; www.blizzard.com -> $normal" if ($blocked -ne '0.0.0.0') { Fail "malware name not blocked" } if (-not $normal) { Fail "name resolution broken" } - $addrs = [Net.Dns]::GetHostAddresses('www.cloudflare.com') -join ' ' - "getaddrinfo: $addrs" - if ($addrs -notmatch ':') { Fail "Windows does not hand out IPv6 addresses" } + $iwr = (Invoke-WebRequest https://github.com -UseBasicParsing -TimeoutSec 15).StatusCode + "Invoke-WebRequest github.com: $iwr" Stop-Process -Id $p.Id -Force Start-Sleep 3 if (Get-NetAdapter -Name WarpGameBoost -ErrorAction SilentlyContinue) { throw "adapter left behind after kill" } @@ -109,6 +114,25 @@ jobs: $t = (Invoke-WebRequest https://www.cloudflare.com/cdn-cgi/trace -UseBasicParsing -TimeoutSec 10).Content if ($t -notmatch 'warp=off') { throw "network not restored: $t" } "network restored: warp=off" + exit 0 # curl's expected failure above would otherwise fail the step + - name: Disconnects a running official WARP client first + shell: pwsh + run: | + Invoke-WebRequest https://1111-releases.cloudflareclient.com/windows/Cloudflare_WARP_Release-x64.msi -OutFile warp.msi + Start-Process msiexec -ArgumentList '/i','warp.msi','/qn','/norestart' -Wait + $cli = "$env:ProgramFiles\Cloudflare\Cloudflare WARP\warp-cli.exe" + foreach ($i in 1..30) { Start-Sleep 2; $st = & $cli --accept-tos status 2>&1 | Out-String; if ($st -notmatch 'Unable to connect|daemon') { break } } + & $cli --accept-tos registration new + & $cli --accept-tos connect + foreach ($i in 1..30) { Start-Sleep 2; $st = & $cli --accept-tos status | Out-String; if ($st -match 'Connected' -and $st -notmatch 'Disconnected') { break } } + "official client before: $($st.Trim())" + if ($st -notmatch 'Connected' -or $st -match 'Disconnected') { throw "could not connect the official client for the test" } + .\WarpGameBoost.exe -selftest -diag=false -count 80 | Tee-Object -Variable out + if ($LASTEXITCODE -ne 0) { throw "selftest with the official client connected failed" } + if (-not ($out -match '已断开 VPN:Cloudflare WARP')) { throw "official client was not reported as disconnected" } + $st = & $cli --accept-tos status | Out-String + "official client after: $($st.Trim())" + if ($st -notmatch 'Disconnected') { throw "official client still connected" } - name: Package shell: pwsh run: | diff --git a/README.md b/README.md index 7c5ac43..4e412c7 100644 --- a/README.md +++ b/README.md @@ -2,28 +2,32 @@ **Windows 一键 Cloudflare WARP 游戏加速:自动挑出最稳的入口,游戏的 UDP 流量也走加速,关掉窗口就恢复原网络。** -## 默认模式:阻止恶意软件 DNS + IPv4/IPv6 双栈 +## 默认模式:阻止恶意软件 DNS + 仅 IPv4 出口 -从 v0.7.0 起,双击运行就是这个模式,不用加任何参数: +从 v0.8.0 起,双击运行就是这个模式,不用加任何参数: | | 默认 | | :--- | :--- | +| 打开时 | **先自动断开其他 VPN**(官方 WARP 客户端、Windows 自带 VPN、WireGuard),避免隧道套隧道 | | 入口(连 WARP) | **IPv4 和 IPv6 入口一起优选**,选综合最稳的那个;本机没有 IPv6 时只用 IPv4 入口 | -| 出口(访问网站、游戏) | **IPv4 和 IPv6 都走 WARP**:只有 IPv4 的游戏(如守望先锋)照常能玩,支持 IPv6 的网站走 IPv6 | -| DNS | **Cloudflare 阻止恶意软件**(`1.1.1.2`、`1.0.0.2`、`2606:4700:4700::1112`、`2606:4700:4700::1002`),经隧道查询 | +| 出口(访问网站、游戏) | **仅 IPv4**,经 WARP;IPv6 禁用,程序只会拿到 IPv4 地址 | +| DNS | **Cloudflare 阻止恶意软件**(`1.1.1.2`、`1.0.0.2`),经隧道查询 | | 加密设备到 Cloudflare 的连接 | 是 | | 隐藏网站看到的原始公网 IP | 通常可以 | | 提供匿名性 / 绕过所有封锁 | 不保证 | **阻止恶意软件**:Cloudflare 已知的恶意软件、钓鱼网站的域名会被解析成 `0.0.0.0`,直接打不开;其他网站不受影响,不过滤成人内容和广告。想确认生效,开着程序访问 Cloudflare 的测试地址 `malware.testcategory.com`,应该打不开。 +**自动断开的 VPN 不会在程序退出后自动重连**,需要时自己再连上。其他 VPN(OpenVPN、Tailscale 出口节点等)无法自动断开,程序会提示你手动断开。 + ### 其他模式 | 参数 | 效果 | | :--- | :--- | -| `-dns system` | **仅流量模式**:虚拟网卡不设 DNS,保持系统设置。路由器 DNS(如 `192.168.1.1`)的查询留在局域网;公网 DNS(如 `8.8.8.8`)的查询经隧道发出 | +| `-exit dual` | 出口 IPv4 和 IPv6 都走 WARP(DNS 同时配 `2606:4700:4700::1112`、`::1002`) | +| `-exit ipv6` | 出口只走 IPv6,IPv4 全部禁用并立即拒绝。**只有 IPv4 的游戏(如守望先锋)会连不上** | +| `-dns system` | **仅流量模式**:虚拟网卡不设 DNS,保持系统设置 | | `-ipv4=false` | 只用 IPv6 入口(本机没有 IPv6 时无效,仍用 IPv4) | -| `-v6only` | 出口只走 IPv6,IPv4 全部禁用并立即拒绝。**只有 IPv4 的游戏(如守望先锋)会连不上**,不适合打游戏 | 想固定用某个模式:右键 exe →“创建快捷方式”→ 右键快捷方式 →“属性”→ 在“目标”最后加一个空格和参数,以后双击这个快捷方式。 @@ -47,19 +51,14 @@ ### 网络诊断(默认开启) -每次启动先花约 20 秒测三段,告诉你**卡在哪一段、该怎么办**,再开始加速: +每次启动先花约 20 秒测两段,告诉你**卡在哪一段、该怎么办**,再开始加速: | 测哪一段 | 怎么测 | 不稳说明什么 | | :--- | :--- | :--- | | 电脑 → 路由器 | ping 默认网关 | 家里的 Wi-Fi/网线/路由器有问题。**WARP 帮不上**,程序会给出具体做法(插网线、5GHz、关定位、关网卡节能、开 SQM) | | → 运营商 | 先 traceroute 找到第一个公网路由器,再 ping 它 | 运营商接入有问题,WARP 也要经过这一段,**帮不上** | -| → 暴雪(美东) | 对 `overwatch.blizzard.com`(AWS 弗吉尼亚)做 TLS 握手计时 | 前两段正常而这段不稳 = 运营商到暴雪的线路问题,**这种情况 WARP 可能有帮助** | - -连上 WARP 后还会**经 WARP 再测一次暴雪**,和直连并排对比,直接告诉你开加速更稳还是直连更好。 -说明: -- 测的是暴雪美东的网站服务器,不是对局服务器(对局服务器地址不公开),所以延迟数字只代表到暴雪美东的线路,和游戏里显示的不完全一样。 -- 测暴雪用 TLS 握手而不是普通 TCP 连接:WARP 会在自己的边缘直接完成 TCP 握手,那样测出来只有几毫秒,是假的。 +- 连的是**手机热点**(iPhone `172.20.10.1`、安卓 `192.168.43.1`)时会直接提示:手机网络本身延迟高、抖动大,打游戏尽量用家里宽带。 - 有些路由器不回应 ping,会显示“没有回应”,这不算故障。 - 不想每次都等,加 `-diag=false` 跳过。 @@ -80,7 +79,7 @@ - **当前入口失效**(连续 15 秒不通,或 3 分钟没有握手):自动重新优选并切换。 - **网络变了**(Wi-Fi 重连、插上网线、电脑睡眠后唤醒):隧道自动改走新的网卡。 - **重复打开**:提示“已经在运行”,不会开第二个。 -- **官方 WARP 客户端或其他 VPN 还连着**:提示先断开再运行。两个同时开会变成隧道套隧道,延迟翻倍。 +- **官方 WARP 客户端或其他 VPN 还连着**:自动断开官方 WARP 客户端、Windows 自带 VPN、WireGuard 隧道;断不了的提示手动断开。两个同时开会变成隧道套隧道,延迟翻倍。 - **程序被强制结束或崩溃**:虚拟网卡和路由由系统随之清除,网络自动恢复。 ### 参数 @@ -90,7 +89,7 @@ | `-diag=false` | 跳过启动时的网络诊断 | | `-dns system` | DNS 保持系统设置(仅流量模式);默认 `malware` = Cloudflare 阻止恶意软件 | | `-ipv4=false` | 只用 IPv6 入口(默认 IPv4、IPv6 入口一起优选) | -| `-v6only` | 出口只走 IPv6,禁用 IPv4(守望先锋会连不上) | +| `-exit dual` / `-exit ipv6` | 出口改为 IPv4+IPv6 / 仅 IPv6(默认 `ipv4`,仅 IPv4) | | `-count 200` | 每次优选测试的入口数量 | | `-v` | 显示 WireGuard 调试日志(排查连不上时用) | | `-version` | 显示版本号后退出 | @@ -118,7 +117,7 @@ 2. **隧道**:用 [wireguard-go](https://git.zx2c4.com/wireguard-go) 和 WireGuard 官方的 [Wintun](https://www.wintun.net/) 虚拟网卡建立隧道,MTU 1280。 3. **路由**:加 `0.0.0.0/1`、`128.0.0.0/1`、`::/1`、`8000::/1` 四条路由,盖过默认路由但不修改它;隧道自己的 UDP 包绑定在实体网卡上发出。关闭虚拟网卡,这些路由随之消失。 4. **DNS**:虚拟网卡的接口优先级设为最高(metric 0)并配上 Cloudflare 阻止恶意软件的 DNS,Windows 优先问它,查询经隧道发出。程序退出、虚拟网卡删除后,DNS 自动回到系统原来的设置。 -5. **出口仅 IPv6**(`-v6only`):IPv4 仍然全部进虚拟网卡(不会绕过隧道直连),但在交给 WireGuard 之前就被拦下:TCP 回一个 RST、其他回 ICMP 不可达,程序立刻知道“连不上”并改用 IPv6,而不是干等超时。系统里原本的 IPv4 DNS 服务器例外放行。虚拟网卡另加一条 `::/0` 路由,让没有 IPv6 的网络上 Windows 也把 IPv6 当作可用。 +5. **仅一种出口**(默认仅 IPv4;`-exit ipv6` 仅 IPv6):被禁用的那一种仍然全部进虚拟网卡(不会绕过隧道直连),但在交给 WireGuard 之前就被拦下:TCP 回一个 RST、其他回 ICMP 不可达,程序立刻知道“连不上”并改用另一种,而不是干等超时。系统里原本的 DNS 服务器例外放行。仅 IPv4 时虚拟网卡不配 IPv6 地址,Windows 只给程序 IPv4 地址;仅 IPv6 时虚拟网卡另加一条 `::/0` 路由和 IPv6 DNS,让没有 IPv6 的网络上 Windows 也把 IPv6 当作可用。 ## 文件 @@ -154,6 +153,10 @@ go build -trimpath -ldflags "-s -w" -o WarpGameBoost.exe . 版本号显示在窗口标题和第一行,也可以右键 exe →“属性”→“详细信息”查看。 +- **v0.8.0**: + - 打开时先自动断开官方 WARP 客户端、Windows 自带 VPN、WireGuard 隧道。 + - 出口默认改为仅 IPv4(IPv6 禁用并立即拒绝);`-exit dual`/`-exit ipv6` 取代 `-v6only`。 + - 网络诊断不再测暴雪服务器、不再做直连对比,只测路由器和运营商;识别手机热点。 - **v0.7.1**:检测到官方 WARP 客户端或其他 VPN 正在连接时提示先断开(同时开会隧道套隧道,延迟翻倍)。 - **v0.7.0**:默认改为“阻止恶意软件 DNS(Cloudflare `1.1.1.2`)+ IPv4/IPv6 入口一起优选 + IPv4/IPv6 双出口”,守望先锋不用再加参数。`-v6only` 改为可选(默认关);原来的仅流量模式用 `-dns system`。 - **v0.6.0**:新增网络诊断(默认开启,`-diag=false` 跳过):启动时测“电脑→路由器”“→运营商”“→暴雪美东”三段的延迟、抖动、丢包,指出问题在哪一段并给出做法;`-v6only=false` 时连上后再经 WARP 测一次暴雪,与直连对比,告诉你开不开加速更好。 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e9aeba9..0fb2ac7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1 +1,3 @@ -检测到官方 WARP 客户端或其他 VPN 正在连接时,提示先断开再运行。两个同时开会变成隧道套隧道,延迟翻倍(实际遇到:游戏里 140ms)。 +- **打开时先自动断开其他 VPN**:官方 WARP 客户端、Windows 自带 VPN、WireGuard 隧道。避免隧道套隧道、延迟翻倍。断开的 VPN 不会在退出后自动重连;断不了的(OpenVPN、Tailscale 等)会提示手动断开。 +- **出口默认仅 IPv4**:IPv6 禁用并立即拒绝,程序只会拿到 IPv4 地址。DNS 仍是 Cloudflare 阻止恶意软件(`1.1.1.2`、`1.0.0.2`)。需要 IPv6 出口用 `-exit dual` 或 `-exit ipv6`(取代原来的 `-v6only`)。 +- **网络诊断不再测暴雪服务器**,只测电脑→路由器、→运营商两段;连手机热点时直接提示手机网络延迟本来就高。 diff --git a/diag.go b/diag.go index a62d21b..785bb10 100644 --- a/diag.go +++ b/diag.go @@ -73,10 +73,10 @@ func (st stats) String() string { st.avg.Milliseconds(), st.jitter.Milliseconds(), st.max.Milliseconds(), st.loss*100) } -// verdict turns the three hops into advice. gw is the router, isp the first -// public hop, target a Blizzard server; any may be nil when it could not be -// found or measured. -func verdict(gw, isp, target *series, wifi bool) []string { +// verdict turns the two measured legs into advice. gw is the router, isp the +// first public hop; either may be nil when it could not be found. hotspot is +// set when the router is a phone sharing its mobile connection. +func verdict(gw, isp *series, wifi, hotspot bool) []string { // A hop that never answered is unmeasured, not broken: plenty of // routers ignore pings altogether. measured := func(s *series) bool { return s != nil && len(s.rtts) > 0 } @@ -97,47 +97,26 @@ func verdict(gw, isp, target *series, wifi bool) []string { lines = append(lines, " · 换一根网线或换个路由器口试试;重启路由器") } return append(lines, " · 家里有人在下载、看视频、传文件时最明显:路由器有 SQM/QoS 就打开") - case bad(target, false) && bad(isp, true): + case hotspot: return []string{ - "问题在运营商接入:路由器正常,但出了家门到运营商的第一段就不稳。", - " · WARP 加速的流量也要先经过这一段,帮不上;可以打电话让运营商查线路,或换运营商", - } - case bad(target, false): - return []string{ - "问题在运营商到暴雪的线路:家里和运营商接入都正常,往后才不稳。", - " · 这种情况 WARP 加速可能有帮助:开和不开各打几局,用游戏里的网络图(Ctrl+Shift+N)对比", + "你在用手机热点:手机网络本身就比宽带多几十毫秒,延迟也时高时低,WARP 改变不了。", + " · 打游戏尽量用家里的宽带(网线最好,其次 Wi-Fi)", } - case !measured(target): - return []string{"家里网络和运营商接入正常;暴雪服务器没测到,无法判断后面的线路。"} - default: + case bad(isp, true): return []string{ - "三段都正常,网络本身没有明显问题。", - " · 游戏里仍然卡,可能是游戏服务器或本机(后台下载、同步、杀毒扫描)", - " · 不一定需要加速;下面会经 WARP 再测一次,对比后再决定", + "问题在运营商接入:路由器正常,但出了家门到运营商的第一段就不稳。", + " · WARP 加速的流量也要先经过这一段,帮不上;可以打电话让运营商查线路,或换运营商", } - } -} - -// compareVerdict weighs the direct route against WARP the way a game would: -// loss and jitter first, then latency. -func compareVerdict(direct, warp stats) string { - score := func(st stats) time.Duration { - return st.avg + 2*st.jitter + time.Duration(st.loss*1000)*time.Millisecond - } - d, w := score(direct), score(warp) - switch { - case w+5*time.Millisecond < d: - return "WARP 更稳,建议开着加速打游戏。" - case d+5*time.Millisecond < w: - return "直连更好:WARP 绕远了。建议关掉加速直接玩。" + case !measured(gw) && !measured(isp): + return []string{"路由器和运营商都不回应 ping,没法判断。"} default: - return "两者差不多,开不开都行;以游戏里的网络图(Ctrl+Shift+N)为准。" + return []string{"家里网络和运营商接入都正常。游戏里仍然卡,就开着加速和不开各打几局,用网络图(Ctrl+Shift+N)对比。"} } } -func report(hops []*series, gw, isp, target *series, wifi bool) string { +func report(gw, isp *series, wifi, hotspot bool) string { var b strings.Builder - for _, s := range hops { + for _, s := range []*series{gw, isp} { if s == nil { continue } @@ -148,7 +127,7 @@ func report(hops []*series, gw, isp, target *series, wifi bool) string { fmt.Fprintf(&b, " %-26s %s\n", s.name, s.stats()) } b.WriteString("\n") - for _, l := range verdict(gw, isp, target, wifi) { + for _, l := range verdict(gw, isp, wifi, hotspot) { b.WriteString(l + "\n") } return b.String() diff --git a/diag_test.go b/diag_test.go index 8d768d4..d1ac197 100644 --- a/diag_test.go +++ b/diag_test.go @@ -44,32 +44,23 @@ func TestStats(t *testing.T) { } func TestVerdict(t *testing.T) { - ok, gwBad, ispBad, bliz, blizBad := steady("gw", 2, 60), jittery("gw", 60), lossy("isp", 8, 60, 12), steady("b", 70, 30), lossy("b", 70, 30, 3) + ok, gwBad, ispBad := steady("gw", 2, 60), jittery("gw", 60), lossy("isp", 8, 60, 12) cases := []struct { - gw, isp, target *series - wifi bool - want string + gw, isp *series + wifi, hotspot bool + want string }{ - {gwBad, ok, blizBad, true, "Wi-Fi"}, - {ok, ispBad, blizBad, false, "运营商接入"}, - {ok, ok, blizBad, false, "WARP 加速可能有帮助"}, - {ok, ispBad, bliz, false, "三段都正常"}, // a hop that rate-limits pings alone is not a fault - {ok, nil, nil, false, "没测到"}, - {&series{name: "gw", sent: 60}, ok, bliz, false, "三段都正常"}, // silent router is unmeasured + {gwBad, ok, true, false, "Wi-Fi"}, + {ok, ispBad, false, false, "运营商接入"}, + {ok, ok, true, true, "手机热点"}, + {ok, ok, false, false, "都正常"}, + {ok, nil, false, false, "都正常"}, + {&series{name: "gw", sent: 60}, nil, false, false, "没法判断"}, // silent router is unmeasured } for i, c := range cases { - got := strings.Join(verdict(c.gw, c.isp, c.target, c.wifi), "\n") + got := strings.Join(verdict(c.gw, c.isp, c.wifi, c.hotspot), "\n") if !strings.Contains(got, c.want) { t.Errorf("case %d: want %q in\n%s", i, c.want, got) } } } - -func TestCompareVerdict(t *testing.T) { - good, bad := steady("", 20, 10).stats(), lossy("", 20, 10, 2).stats() - if !strings.Contains(compareVerdict(bad, good), "WARP 更稳") || - !strings.Contains(compareVerdict(good, bad), "直连更好") || - !strings.Contains(compareVerdict(good, good), "差不多") { - t.Fatal("compare verdict") - } -} diff --git a/diag_windows.go b/diag_windows.go index 58fce9f..df0ffc2 100644 --- a/diag_windows.go +++ b/diag_windows.go @@ -1,10 +1,9 @@ package main import ( - "crypto/tls" "fmt" - "net" "net/netip" + "slices" "sync" "time" "unsafe" @@ -20,11 +19,6 @@ var ( icmpSendEcho = iphlpapi.NewProc("IcmpSendEcho") ) -// blizzardHost is Blizzard's Overwatch website, served from AWS in Virginia. -// It is not a match server (those are not published), so its latency is a -// reference for the route towards Blizzard's US East, not the in-game ping. -const blizzardHost = "overwatch.blizzard.com" - type ipOptionInformation struct { TTL, TOS, Flags, OptionsSize uint8 OptionsData uintptr @@ -93,21 +87,25 @@ func defaultGateway4() (netip.Addr, bool) { return netip.Addr{}, false } -// diagnose measures the three legs a game's packets cross — this computer to -// the router, the ISP's first router, and the route on to Blizzard — before -// the tunnel is up, and says which one is unstable. -func diagnose() *series { +// phoneHotspots are the router addresses iPhones and Android phones use when +// sharing their mobile connection. +var phoneHotspots = []netip.Addr{netip.MustParseAddr("172.20.10.1"), netip.MustParseAddr("192.168.43.1")} + +// diagnose measures the two legs a game's packets cross before they leave +// the ISP — this computer to the router, and on to the ISP's first router — +// before the tunnel is up, and says which one is unstable. +func diagnose() { const samples, interval = 60, 250 * time.Millisecond fmt.Printf("网络诊断(约 %d 秒,加 -diag=false 可跳过)…\n", samples*int(interval)/int(time.Second)+3) h, _, _ := icmpCreateFile.Call() if h == uintptr(windows.InvalidHandle) { fmt.Println("无法诊断:ICMP 不可用。") - return nil + return } defer icmpCloseHandle.Call(h) gwAddr, wifi := defaultGateway4() - var gw, isp, target *series + var gw, isp *series var ispAddr netip.Addr if gwAddr.IsValid() && !gwAddr.IsUnspecified() { gw = &series{name: "电脑 → 路由器 " + gwAddr.String()} @@ -121,14 +119,6 @@ func diagnose() *series { break } } - if ips, err := net.LookupIP(blizzardHost); err == nil { - for _, ip := range ips { - if ip.To4() != nil { - target = &series{name: "→ 暴雪(美东)"} - break - } - } - } var wg sync.WaitGroup icmpSeries := func(s *series, dst netip.Addr) { @@ -153,86 +143,9 @@ func diagnose() *series { wg.Add(1) go icmpSeries(isp, ispAddr) } - if target != nil { - wg.Add(1) - go func() { - defer wg.Done() - // Blizzard does not answer pings; a TLS hello times the round - // trip instead. Half the samples, to stay polite. - tick := time.NewTicker(2 * interval) - defer tick.Stop() - for i := 0; i < samples/2; i++ { - target.sample() - <-tick.C - } - }() - } wg.Wait() fmt.Println() - fmt.Print(report([]*series{gw, isp, target}, gw, isp, target, wifi)) - fmt.Println() - return target -} - -// sample times one round trip to Blizzard: from sending a TLS hello to the -// first byte of the answer. A TCP handshake alone would not do, because WARP -// completes handshakes at its own edge, in a few milliseconds, whatever the -// distance to the real server. -func (s *series) sample() { - s.sent++ - c, err := net.DialTimeout("tcp4", blizzardHost+":443", time.Second) - if err != nil { - return - } - defer c.Close() - tc := &timedConn{Conn: c} - c.SetDeadline(time.Now().Add(time.Second)) - tls.Client(tc, &tls.Config{ServerName: blizzardHost}).Handshake() - if !tc.answered.IsZero() { - s.rtts = append(s.rtts, tc.answered.Sub(tc.sent)) - } -} - -// timedConn notes when the first bytes went out and when the first came back. -type timedConn struct { - net.Conn - sent, answered time.Time -} - -func (c *timedConn) Write(b []byte) (int, error) { - if c.sent.IsZero() { - c.sent = time.Now() - } - return c.Conn.Write(b) -} - -func (c *timedConn) Read(b []byte) (int, error) { - n, err := c.Conn.Read(b) - if n > 0 && c.answered.IsZero() { - c.answered = time.Now() - } - return n, err -} - -// compareWarp measures the same Blizzard server through the tunnel and sets -// the result beside the direct measurement from diagnose. -func compareWarp(direct *series) { - if direct == nil || len(direct.rtts) == 0 { - return - } - fmt.Println("对比:经 WARP 再测暴雪(约 8 秒)…") - via := &series{name: "经 WARP"} - for i := 0; i < 16; i++ { - via.sample() - time.Sleep(500 * time.Millisecond) - } - if len(via.rtts) == 0 { - fmt.Println(" 经 WARP 连不上暴雪。") - return - } - d, w := direct.stats(), via.stats() - fmt.Printf(" 直连 %s\n 经 WARP %s\n", d, w) - fmt.Println(" " + compareVerdict(d, w)) + fmt.Print(report(gw, isp, wifi, slices.Contains(phoneHotspots, gwAddr))) fmt.Println() } diff --git a/main_windows.go b/main_windows.go index 1efb337..fe4dd45 100644 --- a/main_windows.go +++ b/main_windows.go @@ -47,7 +47,7 @@ var ( verbose = flag.Bool("v", false, "显示 WireGuard 调试日志") showVer = flag.Bool("version", false, "显示版本号后退出") diag = flag.Bool("diag", true, "启动时先诊断网络(路由器/运营商/暴雪三段)") - v6only = flag.Bool("v6only", false, "出口只走 IPv6,禁用 IPv4(只有 IPv4 的游戏,如守望先锋,会连不上)") + exitMode = flag.String("exit", "ipv4", "出口:ipv4 = 仅 IPv4,IPv6 禁用(默认);ipv6 = 仅 IPv6,IPv4 禁用;dual = 都走 WARP") dnsMode = flag.String("dns", "malware", "DNS:malware = Cloudflare 阻止恶意软件(1.1.1.2);system = 保持系统设置(仅流量)") ) @@ -116,13 +116,22 @@ func run() int { saveAccount(acctPath, acct) } + if *exitMode != "ipv4" && *exitMode != "ipv6" && *exitMode != "dual" { + fmt.Println("-exit 只能是 ipv4、ipv6 或 dual") + return 1 + } if *dnsMode != "malware" && *dnsMode != "system" { fmt.Println("-dns 只能是 malware 或 system") return 1 } + // Another VPN underneath would nest one tunnel in the other and double + // the latency, so disconnect what can be disconnected first. + for _, d := range disconnectVPNs() { + fmt.Println("已断开 VPN:" + d) + } if name := otherVPN(); name != "" { - fmt.Printf("检测到另一个 VPN 正在连接:%s\n", name) - fmt.Println("两个同时开会变成隧道套隧道,延迟翻倍。请先断开它(官方 WARP 客户端点“断开连接”),再运行本程序。") + fmt.Printf("还有一个 VPN 正在连接,无法自动断开:%s\n", name) + fmt.Println("两个同时开会变成隧道套隧道,延迟翻倍。请手动断开它,再运行本程序。") return 1 } physIndex6 = defaultInterface(winipcfg.AddressFamily(windows.AF_INET6), 0) @@ -137,9 +146,8 @@ func run() int { physIndex4 = defaultInterface(winipcfg.AddressFamily(windows.AF_INET), 0) } - var blizzard *series if *diag { - blizzard = diagnose() + diagnose() } results := optimize(acct, "") @@ -176,10 +184,10 @@ func run() int { if *verbose { fmt.Println(time.Now().Format("15:04:05.000"), "开始验证") } - // Verify over the exit that must work: IPv6 with -v6only, else IPv4 (the - // game's). IPv6 is then checked once more, but its absence only warns. + // Verify over the exit that must work (IPv4 unless -exit ipv6), then that + // a blocked family is refused. With -exit dual a missing IPv6 only warns. primary := "tcp4" - if *v6only { + if *exitMode == "ipv6" { primary = "tcp6" } trace := warpTrace(primary, 3) @@ -187,42 +195,47 @@ func run() int { fmt.Println("隧道已建立,但验证失败:", strings.TrimSpace(trace)) return 1 } - exit4, exit6 := traceField(trace, "ip"), "" - if *v6only { - exit4, exit6 = "", exit4 - } else if t6 := warpTrace("tcp6", 2); strings.Contains(t6, "warp=on") { - exit6 = traceField(t6, "ip") - } - if *v6only { + exitIP := traceField(trace, "ip") + exit6 := "" + switch *exitMode { + case "dual": + if t6 := warpTrace("tcp6", 2); strings.Contains(t6, "warp=on") { + exit6 = traceField(t6, "ip") + } + case "ipv4": + if _, ok := tcpPing("[2606:4700:4700::1111]:443"); ok { + fmt.Println("IPv6 仍然可以访问,禁用失败。") + return 1 + } + case "ipv6": if _, ok := tcpPing("1.1.1.1:443"); ok { fmt.Println("IPv4 仍然可以访问,禁用失败。") return 1 } - if !strings.Contains(traceField(trace, "ip"), ":") { + if !strings.Contains(exitIP, ":") { fmt.Println("出口不是 IPv6:", strings.TrimSpace(trace)) return 1 } } fmt.Printf("\n✅ 加速已开启:本机全部网络(包括游戏)经 WARP 入口 %s\n", t.endpoint()) switch { - case *v6only: - fmt.Printf(" 出口:仅 IPv6 %s(IPv4 已禁用,只有 IPv4 的网站和游戏会连不上)\n", exit6) + case *exitMode == "ipv4": + fmt.Printf(" 出口:仅 IPv4 %s(IPv6 已禁用)\n", exitIP) + case *exitMode == "ipv6": + fmt.Printf(" 出口:仅 IPv6 %s(IPv4 已禁用,只有 IPv4 的网站和游戏会连不上)\n", exitIP) case exit6 == "": - fmt.Printf(" 出口:IPv4 %s;IPv6 没有通,只能访问 IPv4\n", exit4) + fmt.Printf(" 出口:IPv4 %s;IPv6 没有通,只能访问 IPv4\n", exitIP) default: - fmt.Printf(" 出口:IPv4 %s,IPv6 %s\n", exit4, exit6) + fmt.Printf(" 出口:IPv4 %s,IPv6 %s\n", exitIP, exit6) } - if *dnsMode == "malware" { + switch { + case *dnsMode == "malware": fmt.Println(" DNS:Cloudflare 阻止恶意软件(1.1.1.2),经隧道查询") - } else if *v6only { + case *exitMode == "ipv6": fmt.Println(" DNS:Cloudflare(2606:4700:4700::1111),经隧道查询") - } else { + default: fmt.Println(" DNS:保持系统设置(仅流量)") } - if !*v6only { - fmt.Println() - compareWarp(blizzard) - } fmt.Println(" 现在可以打开游戏。关闭本窗口或按 Ctrl+C 结束加速;输入 r 回车重新优选。") fmt.Println() if *selftest { @@ -255,14 +268,14 @@ type tunnel struct { mu sync.Mutex ep string - reject *v4Reject // set with -v6only + reject *familyReject // set unless -exit dual } -// refreshDNS keeps the IPv4 DNS servers let through -v6only in step with the -// network Windows is on. +// refreshDNS keeps the system DNS servers let through a blocked family in step +// with the network Windows is on. func (t *tunnel) refreshDNS() { if t.reject != nil { - t.reject.setAllowed(systemDNS4(t.luid)) + t.reject.setAllowed(systemDNS(t.luid, t.reject.block)) } } @@ -289,11 +302,15 @@ func start(acct *Account, results []scored) (*tunnel, error) { level = device.LogLevelVerbose } var dev tun.Device = tdev - if *v6only { - t.reject = &v4Reject{Device: tdev} - t.reject.setAllowed(systemDNS4(t.luid)) + if *exitMode != "dual" { + block := 6 + if *exitMode == "ipv6" { + block = 4 + } + t.reject = &familyReject{Device: tdev, block: block} + t.refreshDNS() if *verbose { - fmt.Println("经隧道放行的 IPv4 DNS:", systemDNS4(t.luid)) + fmt.Printf("经隧道放行的 IPv%d DNS:%v\n", block, systemDNS(t.luid, block)) } dev = t.reject } @@ -349,8 +366,16 @@ func start(acct *Account, results []scored) (*tunnel, error) { // traffic through it. Two half routes win over the default route without // touching it, so closing the adapter restores the network exactly. func (t *tunnel) configure(acct *Account) error { + // With -exit ipv4 the adapter gets no IPv6 address: Windows then asks + // its resolver (the adapter's, queried first) for IPv4 addresses only, + // and programs never try IPv6 at all. IPv6 is still routed here and + // refused, in case another adapter's address gets used. + family := []string{acct.AddrV4, acct.AddrV6} + if *exitMode == "ipv4" { + family = family[:1] + } var addrs []netip.Prefix - for _, s := range []string{acct.AddrV4, acct.AddrV6} { + for _, s := range family { if a, err := netip.ParseAddr(s); err == nil { addrs = append(addrs, netip.PrefixFrom(a, a.BitLen())) } @@ -372,9 +397,10 @@ func (t *tunnel) configure(acct *Account) error { // Windows keeps its own settings (traffic only): a resolver on the local // network stays local, one on the internet is reached through the tunnel. // - // -v6only always needs a resolver on the adapter, IPv6 only: Windows hands - // out IPv6 addresses for a name only when the interface the query went out - // on has IPv6, which on an IPv4-only network is never the real adapter. + // -exit ipv6 always needs a resolver on the adapter, IPv6 only: Windows + // hands out IPv6 addresses for a name only when the interface the query + // went out on has IPv6, which on an IPv4-only network is never the real + // adapter. -exit ipv4 gives the adapter IPv4 resolvers only. v4dns, v6dns := tunnelDNS() if len(v4dns) > 0 { if err := t.luid.SetDNS(windows.AF_INET, v4dns, nil); err != nil { @@ -391,10 +417,13 @@ func (t *tunnel) configure(acct *Account) error { {Destination: netip.MustParsePrefix("128.0.0.0/1"), NextHop: netip.IPv4Unspecified()}, {Destination: netip.MustParsePrefix("::/1"), NextHop: netip.IPv6Unspecified()}, {Destination: netip.MustParsePrefix("8000::/1"), NextHop: netip.IPv6Unspecified()}, + } + if *exitMode != "ipv4" { // Windows only hands out IPv6 addresses for names when some interface // has an IPv6 default route; on an IPv4-only network that has to be - // this one, or nothing would try the tunnel's IPv6. - {Destination: netip.MustParsePrefix("::/0"), NextHop: netip.IPv6Unspecified()}, + // this one, or nothing would try the tunnel's IPv6. With -exit ipv4 + // that is exactly what is wanted, so it is left out. + routes = append(routes, &winipcfg.RouteData{Destination: netip.MustParsePrefix("::/0"), NextHop: netip.IPv6Unspecified()}) } if err := t.luid.SetRoutes(routes); err != nil { return fmt.Errorf("设置路由:%w", err) @@ -557,28 +586,30 @@ var ( // tunnelDNS is what the tunnel adapter's DNS servers should be. func tunnelDNS() (v4, v6 []netip.Addr) { switch { - case *dnsMode == "malware" && *v6only: + case *exitMode == "ipv6" && *dnsMode == "malware": return nil, malwareDNS6 - case *dnsMode == "malware": - return malwareDNS4, malwareDNS6 - case *v6only: + case *exitMode == "ipv6": return nil, plainDNS6 + case *dnsMode == "system": + return nil, nil + case *exitMode == "ipv4": + return malwareDNS4, nil } - return nil, nil + return malwareDNS4, malwareDNS6 } func pingTarget() string { - if *v6only { + if *exitMode == "ipv6" { return "[2606:4700:4700::1111]:443" } return "1.1.1.1:443" } -// systemDNS4 lists the IPv4 DNS servers Windows is using. With -v6only they -// stay reachable through the tunnel, or names would stop resolving on networks -// that hand out a public resolver. -func systemDNS4(skip winipcfg.LUID) []netip.Addr { - adapters, err := winipcfg.GetAdaptersAddresses(windows.AF_INET, winipcfg.GAAFlagDefault) +// systemDNS lists the DNS servers of one family (4 or 6) Windows is using. +// When that family is blocked they stay reachable through the tunnel, or names +// would stop resolving on networks that hand out a public resolver. +func systemDNS(skip winipcfg.LUID, family int) []netip.Addr { + adapters, err := winipcfg.GetAdaptersAddresses(windows.AF_UNSPEC, winipcfg.GAAFlagDefault) if err != nil { return nil } @@ -594,7 +625,7 @@ func systemDNS4(skip winipcfg.LUID) []netip.Addr { continue } a = a.Unmap() - if a.Is4() && !a.IsLoopback() && !a.IsUnspecified() && !seen[a] { + if a.Is4() == (family == 4) && !a.IsLoopback() && !a.IsUnspecified() && !seen[a] { seen[a] = true out = append(out, a) } @@ -670,7 +701,10 @@ func otherVPN() string { } name := ad.FriendlyName() + " " + ad.Description() if strings.Contains(strings.ToLower(strings.ReplaceAll(name, " ", "")), "cloudflarewarp") { - return "Cloudflare WARP 官方客户端" + if defaults[ad.LUID] { + return "Cloudflare WARP 官方客户端" + } + continue // installed but not carrying traffic } tunnelish := ad.IfType == winipcfg.IfTypePropVirtual || ad.IfType == winipcfg.IfTypeTunnel || ad.IfType == winipcfg.IfTypePPP builtin := false // Windows' own IPv6 transition tunnels are not VPNs diff --git a/reject.go b/reject.go index 28f7922..7930190 100644 --- a/reject.go +++ b/reject.go @@ -8,19 +8,28 @@ import ( "golang.zx2c4.com/wireguard/tun" ) -// v4Reject sits between Windows and WireGuard when the exit is IPv6 only. IPv4 -// is still routed into the tunnel so nothing goes around it, but instead of -// vanishing (which leaves programs waiting out their timeouts before trying -// IPv6) each IPv4 packet is answered at once: TCP with a reset, everything else -// with ICMP unreachable. Programs see "refused" and move on to IPv6. -type v4Reject struct { +// familyReject sits between Windows and WireGuard when the exit is one IP +// family only. The other family is still routed into the tunnel so nothing goes +// around it, but instead of vanishing (which leaves programs waiting out their +// timeouts before trying the family that works) each packet is answered at +// once: TCP with a reset, everything else with ICMP unreachable. Programs see +// "refused" and move on. +type familyReject struct { tun.Device - allow atomic.Pointer[[]netip.Addr] // IPv4 destinations let through (the system's DNS servers) + block int // 4 or 6: the family refused + allow atomic.Pointer[[]netip.Addr] // destinations let through anyway (the system's DNS servers) } -func (r *v4Reject) setAllowed(a []netip.Addr) { r.allow.Store(&a) } +func (r *familyReject) setAllowed(a []netip.Addr) { r.allow.Store(&a) } -func (r *v4Reject) allowed(dst netip.Addr) bool { +func (r *familyReject) reject(pkt []byte) (bool, []byte) { + if r.block == 6 { + return rejectV6(pkt, r.allowed) + } + return rejectV4(pkt, r.allowed) +} + +func (r *familyReject) allowed(dst netip.Addr) bool { if p := r.allow.Load(); p != nil { for _, a := range *p { if a == dst { @@ -31,13 +40,13 @@ func (r *v4Reject) allowed(dst netip.Addr) bool { return false } -func (r *v4Reject) Read(bufs [][]byte, sizes []int, offset int) (int, error) { +func (r *familyReject) Read(bufs [][]byte, sizes []int, offset int) (int, error) { for { n, err := r.Device.Read(bufs, sizes, offset) k := 0 for i := 0; i < n; i++ { pkt := bufs[i][offset : offset+sizes[i]] - if drop, reply := rejectV4(pkt, r.allowed); drop { + if drop, reply := r.reject(pkt); drop { if reply != nil { out := make([]byte, offset+len(reply)) copy(out[offset:], reply) @@ -98,6 +107,19 @@ func rejectV4(pkt []byte, allow func(netip.Addr) bool) (drop bool, reply []byte) // tcpReset answers a TCP segment from src to dst with a reset from dst, as a // host with nothing listening would (RFC 9293 3.10.7.1). func tcpReset(seg []byte, src, dst netip.Addr) []byte { + t := tcpResetSegment(seg) + if t == nil { + return nil + } + b := make([]byte, 20+len(t)) + ipHeader(b, 6, dst, src) + copy(b[20:], t) + binary.BigEndian.PutUint16(b[20+16:20+18], transportChecksum(6, dst, src, b[20:])) + return b +} + +// tcpResetSegment builds the reset's TCP header, checksum left at zero. +func tcpResetSegment(seg []byte) []byte { if len(seg) < 20 { return nil } @@ -109,9 +131,7 @@ func tcpReset(seg []byte, src, dst netip.Addr) []byte { if off < 20 || off > len(seg) { return nil } - b := make([]byte, 40) - ipHeader(b, 6, dst, src) - t := b[20:] + t := make([]byte, 20) copy(t[0:2], seg[2:4]) // swap ports copy(t[2:4], seg[0:2]) t[12] = 5 << 4 @@ -129,8 +149,7 @@ func tcpReset(seg []byte, src, dst netip.Addr) []byte { binary.BigEndian.PutUint32(t[8:12], binary.BigEndian.Uint32(seg[4:8])+n) t[13] = 0x14 } - binary.BigEndian.PutUint16(t[16:18], transportChecksum(6, dst, src, t)) - return b + return t } // icmpUnreachable builds ICMP destination unreachable (type 3) from `from` to @@ -179,3 +198,82 @@ func checksum(b []byte, sum uint32) uint16 { } return ^uint16(sum) } + +// rejectV6 is rejectV4 for IPv6: other IPv6 is dropped, TCP answered with a +// reset and the rest with ICMPv6 unreachable. +func rejectV6(pkt []byte, allow func(netip.Addr) bool) (drop bool, reply []byte) { + if len(pkt) < 1 || pkt[0]>>4 != 6 { + return false, nil + } + if len(pkt) < 40 { + return true, nil + } + total := 40 + int(binary.BigEndian.Uint16(pkt[4:6])) + if total > len(pkt) { + return true, nil + } + pkt = pkt[:total] + src := netip.AddrFrom16([16]byte(pkt[8:24])) + dst := netip.AddrFrom16([16]byte(pkt[24:40])) + if allow(dst) { + return false, nil + } + if dst.IsMulticast() || dst.IsLinkLocalUnicast() || src.IsUnspecified() { + return true, nil + } + switch next, body := pkt[6], pkt[40:]; { + case next == 6: + return true, tcpReset6(body, src, dst) + case next == 58 && (len(body) == 0 || body[0] < 128 || body[0] > 129): + return true, nil // never answer ICMPv6 errors or neighbour discovery + case next == 58 && body[0] == 129: + return true, nil // nor echo replies + case next == 17: + return true, icmp6Unreachable(pkt, dst, src, 4) // port unreachable + default: + return true, icmp6Unreachable(pkt, dst, src, 3) // address unreachable + } +} + +func tcpReset6(seg []byte, src, dst netip.Addr) []byte { + t := tcpResetSegment(seg) + if t == nil { + return nil + } + b := make([]byte, 40+len(t)) + ip6Header(b, 6, dst, src) + copy(b[40:], t) + binary.BigEndian.PutUint16(b[40+16:40+18], checksum6(6, dst, src, b[40:])) + return b +} + +func icmp6Unreachable(orig []byte, from, to netip.Addr, code byte) []byte { + quote := orig[:min(len(orig), 1280-48)] + b := make([]byte, 40+8+len(quote)) + ip6Header(b, 58, from, to) + m := b[40:] + m[0], m[1] = 1, code + copy(m[8:], quote) + binary.BigEndian.PutUint16(m[2:4], checksum6(58, from, to, m)) + return b +} + +func ip6Header(b []byte, next byte, src, dst netip.Addr) { + b[0] = 6 << 4 + binary.BigEndian.PutUint16(b[4:6], uint16(len(b)-40)) + b[6] = next + b[7] = 64 + s, d := src.As16(), dst.As16() + copy(b[8:24], s[:]) + copy(b[24:40], d[:]) +} + +func checksum6(next byte, src, dst netip.Addr, body []byte) uint16 { + s, d := src.As16(), dst.As16() + var sum uint32 + for i := 0; i < 16; i += 2 { + sum += uint32(binary.BigEndian.Uint16(s[i:])) + uint32(binary.BigEndian.Uint16(d[i:])) + } + sum += uint32(len(body)>>16) + uint32(len(body)&0xffff) + uint32(next) + return checksum(body, sum) +} diff --git a/reject_test.go b/reject_test.go index 6b48c84..9862b1a 100644 --- a/reject_test.go +++ b/reject_test.go @@ -106,3 +106,57 @@ func TestMulticastAndICMPErrorsSilent(t *testing.T) { t.Error("ping must get host unreachable") } } + +var ( + local6 = netip.MustParseAddr("2606:4700:110:8000::1") + remote6 = netip.MustParseAddr("2606:4700:4700::1111") +) + +func packet6(next byte, dst netip.Addr, payload []byte) []byte { + b := make([]byte, 40+len(payload)) + ip6Header(b, next, local6, dst) + copy(b[40:], payload) + return b +} + +func TestV6TCPSynGetsReset(t *testing.T) { + s := syn(7) + drop, r := rejectV6(packet6(6, remote6, s), allowDNS) + if !drop || len(r) != 60 { + t.Fatalf("drop=%v reply=%d bytes", drop, len(r)) + } + if netip.AddrFrom16([16]byte(r[8:24])) != remote6 || netip.AddrFrom16([16]byte(r[24:40])) != local6 { + t.Error("addresses not swapped") + } + tcp := r[40:] + if tcp[13] != 0x14 || binary.BigEndian.Uint32(tcp[8:12]) != 8 { + t.Errorf("flags %#x ack %d", tcp[13], binary.BigEndian.Uint32(tcp[8:12])) + } + if checksum6(6, remote6, local6, tcp) != 0 { + t.Error("bad TCP checksum") + } +} + +func TestV6UDPAndSilentCases(t *testing.T) { + drop, r := rejectV6(packet6(17, remote6, make([]byte, 16)), allowDNS) + if !drop || r == nil || r[6] != 58 || r[40] != 1 || r[41] != 4 { + t.Fatalf("UDP reply %v", r) + } + if checksum6(58, remote6, local6, r[40:]) != 0 { + t.Error("bad ICMPv6 checksum") + } + ns := make([]byte, 24) + ns[0] = 135 + if drop, r := rejectV6(packet6(58, remote6, ns), allowDNS); !drop || r != nil { + t.Error("neighbour solicitation must be dropped silently") + } + if drop, r := rejectV6(packet6(17, netip.MustParseAddr("ff02::fb"), make([]byte, 8)), allowDNS); !drop || r != nil { + t.Error("multicast must be dropped silently") + } + if drop, _ := rejectV6(packet(6, remote, syn(1)), allowDNS); drop { + t.Error("IPv4 must pass") + } + if drop, _ := rejectV6(packet6(17, netip.MustParseAddr("2606:4700:4700::1112"), make([]byte, 8)), func(a netip.Addr) bool { return a == netip.MustParseAddr("2606:4700:4700::1112") }); drop { + t.Error("allowed DNS must pass") + } +} diff --git a/vpn_windows.go b/vpn_windows.go new file mode 100644 index 0000000..1b5a31d --- /dev/null +++ b/vpn_windows.go @@ -0,0 +1,98 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +// disconnectVPNs disconnects the VPNs this program knows how to: the official +// WARP client, Windows' built-in VPN connections, and WireGuard tunnels. They +// stay disconnected after this program exits; reconnect them by hand. It +// returns what it disconnected. +func disconnectVPNs() []string { + var done []string + if warpCLIConnected() { + runQuiet(warpCLI(), "--accept-tos", "disconnect") + done = append(done, "Cloudflare WARP 官方客户端") + } + for _, name := range windowsVPNs() { + runQuiet("rasdial", name, "/disconnect") + done = append(done, name+"(Windows VPN)") + } + done = append(done, stopWireGuardTunnels()...) + // Give adapters a moment to drop their routes. + for i := 0; len(done) > 0 && i < 20 && otherVPN() != ""; i++ { + time.Sleep(500 * time.Millisecond) + } + return done +} + +func warpCLI() string { + return filepath.Join(os.Getenv("ProgramFiles"), "Cloudflare", "Cloudflare WARP", "warp-cli.exe") +} + +func warpCLIConnected() bool { + if _, err := os.Stat(warpCLI()); err != nil { + return false + } + out := runQuiet(warpCLI(), "--accept-tos", "status") + return strings.Contains(out, "Connected") && !strings.Contains(out, "Disconnected") +} + +// windowsVPNs lists the connected VPN profiles of Windows' own VPN client. +func windowsVPNs() []string { + out := runQuiet("powershell", "-NoProfile", "-NonInteractive", "-Command", + "@(Get-VpnConnection; Get-VpnConnection -AllUserConnection) | Where-Object ConnectionStatus -eq 'Connected' | ForEach-Object Name") + var names []string + for _, l := range strings.Split(out, "\n") { + if l = strings.TrimSpace(l); l != "" { + names = append(names, l) + } + } + return names +} + +// stopWireGuardTunnels stops running WireGuard for Windows tunnels; each one +// is a service named WireGuardTunnel$. +func stopWireGuardTunnels() []string { + m, err := mgr.Connect() + if err != nil { + return nil + } + defer m.Disconnect() + names, _ := m.ListServices() + var done []string + for _, n := range names { + tunnel, ok := strings.CutPrefix(n, "WireGuardTunnel$") + if !ok { + continue + } + s, err := m.OpenService(n) + if err != nil { + continue + } + if st, err := s.Query(); err == nil && st.State == svc.Running { + if _, err := s.Control(svc.Stop); err == nil { + done = append(done, tunnel+"(WireGuard)") + } + } + s.Close() + } + return done +} + +// runQuiet executes a command without a console window and returns its output. +func runQuiet(name string, args ...string) string { + cmd := exec.Command(name, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW} + out, _ := cmd.CombinedOutput() + return string(out) +}