From dbb33952550e1a69ad81b2f11857727fd5b7c74b Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:13:11 +0800 Subject: [PATCH 1/5] feat(windows): add global VPN policy --- CONTEXT.md | 4 + README.md | 4 +- docs/invoke-api.md | 11 +- docs/runtime-resource-policy.md | 2 + docs/tun-platform.md | 3 +- docs/windows-session-runtime.md | 12 +- docs/windows-vpn.md | 17 +- example/windows-uwp/README.md | 25 ++- example/windows-uwp/demo.cpp | 10 +- include/vcore.h | 2 +- readme/README.ru.md | 4 +- readme/README.zh_CN.md | 4 +- scripts/src/vcore_scripts/builds.py | 2 +- scripts/src/vcore_scripts/tun2socks.py | 7 +- scripts/tests/test_scripts.py | 2 +- src/windows/host.rs | 85 +++++++++- src/windows/mod.rs | 4 + src/windows/policy.rs | 205 +++++++++++++++++++++++++ src/windows/profile.rs | 49 +++++- src/windows/session.rs | 10 +- src/windows/vpn.rs | 55 +++++-- 21 files changed, 457 insertions(+), 60 deletions(-) create mode 100644 src/windows/policy.rs diff --git a/CONTEXT.md b/CONTEXT.md index 1c752e0..8bf9f16 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -8,6 +8,10 @@ VCore turns host-captured IP traffic into routed proxy or direct sessions while The packaged AppContainer participant that owns one active Windows tunnel session and exchanges its raw-IP packets with the Windows session runtime. _Avoid_: Plugin, background task when referring to the whole participant, proxy core +**Windows VPN policy**: +The profile-level Always On and destination-bypass choices for a Windows tunnel whose application scope is always global. +_Avoid_: Per-app policy, app selection, traffic filter + **Windows session runtime**: The VCore runtime serving one active Windows tunnel session inside the Windows session host. _Avoid_: Foreground runtime, provider runtime, external core diff --git a/README.md b/README.md index 0888ae6..c2ad86b 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ char *VCoreInvoke(const char *request_json); void VCoreFree(char *response); ``` -Windows packages also use the revision-2 host bridge for profiles, Session Snapshots, and the optional session backend: +Windows packages also use the revision-3 host bridge for the all-app VPN policy, profiles, Session Snapshots, and the optional session backend: ```c char *VCoreWindowsVpnInvoke(const char *request_json); @@ -99,6 +99,8 @@ DNS opaque cache 64 entries / 256 KiB GeoData allocation capacity 8 MiB ``` +Windows advertises a 1,400-byte L3 MTU as required by `StartWithMainTransport`; 1,500 bytes remains the cross-platform parser ceiling. + TCP sessions, ordinary UDP associations, half-open connections, outbound handshakes, and active DNS transports are created on demand. Bounded queues, per-flow buffers, wire/parser limits, timeouts, idle cleanup, and caches provide structural safety. The iOS 35/45 MiB targets are best-effort observations and do not change lifecycle results. ## Documentation diff --git a/docs/invoke-api.md b/docs/invoke-api.md index 665c2ba..577ff58 100644 --- a/docs/invoke-api.md +++ b/docs/invoke-api.md @@ -318,10 +318,10 @@ ProtectFd(fd) -> bool ## Windows 安装包桥接 -`VCoreWindowsVpnInvoke` 使用独立的桥接修订版 2: +`VCoreWindowsVpnInvoke` 使用独立的桥接修订版 3: ```json -{"bridgeVersion":2,"method":"getVpnStatus","payload":{}} +{"bridgeVersion":3,"method":"getVpnStatus","payload":{}} ``` 只接受六个方法: @@ -344,6 +344,11 @@ ProtectFd(fd) -> bool "dnsIpv4Address": "8.8.8.8", "dnsIpv6Address": "2001:4860:4860::8888" }, + "policy": { + "alwaysOn": false, + "allowLocalNetwork": true, + "excludedCidrs": [] + }, "sessionBackend": { "processes": [ { @@ -355,6 +360,8 @@ ProtectFd(fd) -> bool } ``` +`policy` 始终必填。Windows VPN 固定覆盖所有应用;`alwaysOn` 控制 profile capability,实际自动连接仍取决于 Windows 用户设置和 active profile;`allowLocalNetwork` 控制本地子网是否绕过;`excludedCidrs` 是最多 64 个规范 IPv4/IPv6 目标网段。拒绝重复项、host bits、`/0`、禁用 IPv6 时的 IPv6 项,以及包含当前 VPN DNS 地址的项。 + `sessionBackend` 可以省略。存在时包含 `1..=8` 个有序关键进程;每项只有 package installed location 内的规范 `.exe` 相对路径和有界 argv 数组。同一可执行文件可出现多次。第一版不接受 port、UDP、readiness、restart、environment、working directory 或 raw command line;任一进程退出都会使当前 VPN 会话失败关闭。 桥接把 YAML、进程顺序、路径和参数发布为 `vcore-session-v2:` Session Snapshot。参数引用的文件由调用方保持存在且不可变,VCore 不读取或摘要其内容。`getVpnStatus.data.snapshotToken` 返回该完整 Session token。 diff --git a/docs/runtime-resource-policy.md b/docs/runtime-resource-policy.md index b4f3667..e8fbff3 100644 --- a/docs/runtime-resource-policy.md +++ b/docs/runtime-resource-policy.md @@ -27,6 +27,8 @@ TUN 域名提示 256 项(按需) GeoData 分配容量 8 MiB ``` +Windows L3 接口及其 Session Host netstack 使用 1400 MTU;表中的 1500 是跨平台原始包解析上限和其他 TUN 平台的固定 MTU。 + - DNS 响应和普通 UDP 响应使用不同队列,但共享 netstack UDP 入站接收器。 - TUN 域名提示只在 TUN 配置实际包含域名规则时创建,并从空容量按需增长。 - ICMP 响应复用原始包出站队列,不创建独立任务或长期状态。 diff --git a/docs/tun-platform.md b/docs/tun-platform.md index 2cbe60f..1c3d0d1 100644 --- a/docs/tun-platform.md +++ b/docs/tun-platform.md @@ -44,6 +44,7 @@ Windows 使用 `Windows.Networking.Vpn` 回调,不使用文件描述符或适 - Provider 在回调内复制 `VpnPacketBuffer` 字节,不保存系统缓冲区的借用; - 顶层 `ipv6: false` 时,Provider 不向 Windows 分配 IPv6 TUN 地址,也不安装 IPv6 路由或 DNS;`startVpn` 的 IPv6 地址字段仍严格必填并经过验证; +- Windows profile 固定覆盖所有应用;Provider 按 policy 设置本地子网旁路,并把最多 64 条规范目标 CIDR 加入 exclusion routes; - 系统和 Provider 创建的缓冲区都按 WinRT 所有权规则归还; - 回调不等待管道 I/O,入站和出站队列保持有界; - 空到非空的回环唤醒只通知 `Decapsulate` 排空响应队列; @@ -55,7 +56,7 @@ Windows 使用 `Windows.Networking.Vpn` 回调,不使用文件描述符或适 ## MTU 与结构上限 -当前只接受 MTU 1500: +用户 TUN 配置当前只接受 MTU 1500。Windows 因 `StartWithMainTransport` 平台上限对 L3 接口和 Session Host netstack 使用 1400;packet channel 仍保留 1500 字节解析上限: ```text 原始 TUN 包 1,500 字节 diff --git a/docs/windows-session-runtime.md b/docs/windows-session-runtime.md index 8680523..97ad811 100644 --- a/docs/windows-session-runtime.md +++ b/docs/windows-session-runtime.md @@ -42,7 +42,7 @@ Session Host 每次连接新建一个进程,不常驻、不复用运行时, ## 快照与 profile - Windows 只维护一个同包 `VCore` profile。 -- 桥接先用当前解析器验证 TUN 配置、四个网络地址和可选 backend,再发布单文件内容寻址 Session Snapshot: +- 桥接先用当前解析器验证 TUN 配置、四个网络地址、全局 VPN policy 和可选 backend,再发布单文件内容寻址 Session Snapshot: ```text vcore-session-v2:<64 lowercase sha256> @@ -51,17 +51,17 @@ Session Host 每次连接新建一个进程,不常驻、不复用运行时, - Snapshot revision 2 保存完整 VCore YAML(包括代理组及其 `default-selected`),以及可选的有序 `sessionBackend.processes`;每项只有规范 package-relative executable path 和 argv 数组。 - token 覆盖 YAML、进程顺序、路径和参数。参数引用的文件由调用方保持存在且不可变,VCore 不读取或摘要其内容。 -- profile custom configuration 是最大 1 KiB 的严格 JSON,包含修订版 3、规范 Session token、顶层 IPv6 开关和 TUN/DNS 的 IPv4/IPv6 地址。 +- profile custom configuration 是最大 4 KiB 的严格 JSON,包含修订版 4、规范 Session token、顶层 IPv6 开关、TUN/DNS 的 IPv4/IPv6 地址和完整 policy。 - custom configuration 不包含 YAML、backend 描述、Controller secret、PID 或管道路径。 -- `startVpn` 的四个地址始终严格必填并经过验证;顶层 `ipv6: false` 时,Provider 忽略两个 IPv6 地址,只安装 IPv4 地址、路由和 DNS。 -- 活动 profile 只有在 token、IPv6 开关和四个地址完全相同时才幂等;任何变化都必须先 Stop。 +- `startVpn` 的四个地址和 `alwaysOn`、`allowLocalNetwork`、`excludedCidrs` 始终严格必填并经过验证;顶层 `ipv6: false` 时,Provider 忽略两个 IPv6 地址,只安装 IPv4 地址、路由和 DNS,并拒绝 IPv6 exclusion。 +- 活动 profile 只有在 token、IPv6 开关、四个地址和 policy 完全相同时才幂等;任何变化都必须先 Stop。 - 读取 Snapshot 时校验大小、普通文件、reparse point、内容摘要、规范 JSON及每个 executable。 - TUN 地址、Controller 端口/secret 和 Ping 目标属于运行时字段,不写入用户 RAW YAML。 ## 启动顺序 -1. 前台宿主调用 `startVpn(configYaml, networkSettings, sessionBackend?)`。 -2. 桥接验证配置、四个地址和进程描述,发布不可变 Session Snapshot,并把解析后的顶层 IPv6 开关写入 profile configuration。 +1. 前台宿主调用 `startVpn(configYaml, networkSettings, policy, sessionBackend?)`。 +2. 桥接验证配置、四个地址、policy 和进程描述,发布不可变 Session Snapshot,并把解析后的顶层 IPv6 开关和 policy 写入 profile configuration。 3. 桥接写入单一 VPN profile 并调用 `ConnectProfileAsync`;它不启动或持有 Session Host。 4. Windows 激活 AppContainer Provider。 5. Provider 从 profile configuration 取得权威 token,选择物理网络绑定并准备基础资源。 diff --git a/docs/windows-vpn.md b/docs/windows-vpn.md index 239ab22..8150627 100644 --- a/docs/windows-vpn.md +++ b/docs/windows-vpn.md @@ -66,6 +66,8 @@ u16 大端序包长 - Provider 两侧包队列容量均为 256;从空变为非空时只发一次唤醒; - `Decapsulate` 每次排空当前已就绪队列,队列满按包计数。 +`StartWithMainTransport` 按 WinRT 契约使用 1400 MTU 和 1412 最大 frame;Session Host netstack 同样使用 1400 MTU。packet channel 的 1500 上限仍是帧解析的结构边界,不是 Windows L3 接口宣告值。 + 控制消息使用独立管道,避免包背压阻塞启动和停止。 ## 回包唤醒 @@ -90,9 +92,17 @@ IPv6: ::/1, 8000::/1 顶层 `ipv6` 默认为 `true`。设为 `false` 时,Provider 只安装 IPv4 `/1` 路由,只分配 IPv4 TUN 地址,并且只向 Windows 注册 IPv4 DNS;不会安装或分配任何 IPv6 项。 +Windows profile 固定覆盖所有应用,不使用 AppTriggers、traffic filters 或流量身份。每次会话还应用完整 policy: + +- `allowLocalNetwork` 直接传给 `VpnRouteAssignment.SetExcludeLocalSubnets`; +- `excludedCidrs` 按地址族加入 exclusion routes,不修改两条 `/1` inclusion routes; +- `alwaysOn` 写入 profile capability;实际自动连接仍由 Windows 用户设置和 active profile 决定。 + +排除项最多 64 条,必须是规范 network/prefix;重复、host bits、`/0`、禁用 IPv6 时的 IPv6 项和包含 VPN DNS 的项都会在接触 WinRT 前失败关闭。 + 不能把 IPv4 两条 `/1` 合并为 `/0`。Windows 包环境中的验证表明,VPN `/0` 会使按产品要求绑定物理源地址和接口索引的外层 socket 返回 `WSAENETUNREACH`,而两条 `/1` 可以保持物理出口。 -前台宿主在 `startVpn` payload 中提供当前会话的 TUN IPv4/IPv6 和 DNS IPv4/IPv6。四个地址始终严格必填并经过验证;即使顶层 `ipv6: false`,两个 IPv6 值仍保留在桥接契约中,但 Provider 不使用。桥接把地址、顶层 IPv6 开关与 Session token 写入 profile custom configuration,Provider 再按开关传给 `StartWithMainTransport`。这些字段不写入用户 RAW YAML。 +前台宿主在 `startVpn` payload 中提供当前会话的 TUN IPv4/IPv6、DNS IPv4/IPv6 和 policy。四个地址与三个 policy 字段始终严格必填并经过验证;即使顶层 `ipv6: false`,两个 IPv6 地址仍保留在桥接契约中,但 Provider 不使用。桥接把地址、顶层 IPv6 开关、policy 与 Session token 写入 profile custom configuration,Provider 再按开关传给 `StartWithMainTransport`。这些字段不写入用户 RAW YAML。 Provider 为后缀 `.` 安装外部 DNS 地址: @@ -160,9 +170,9 @@ vcore-windows-session-host.exe - Provider 的 `windows.backgroundTasks` extension 显式使用 `windowsApp + appContainer`; - Provider activation class 来自 `vcore.dll`; - 同一 package 只维护一个 `VCore` VPN profile; -- custom configuration 是最大 1 KiB 的严格 JSON,只含修订版 3、Session token、顶层 IPv6 开关和四个网络地址; +- custom configuration 是最大 4 KiB 的严格 JSON,只含修订版 4、Session token、顶层 IPv6 开关、四个网络地址和完整 policy; - Session Snapshot 是 `LocalState/vcore/windows/sessions/.json`,覆盖 YAML、可选进程顺序、路径和参数; -- 活动 Session token、IPv6 开关或网络地址不同时必须先显式 Stop,不能热切换; +- 活动 Session token、IPv6 开关、网络地址或 policy 不同时必须先显式 Stop,不能热切换; - 安装包更新只能在 VPN 已断开时进行,并要求版本递增。 ## 失败关闭 @@ -185,6 +195,7 @@ vcore-windows-session-host.exe - [`IVpnPlugIn`](https://learn.microsoft.com/uwp/api/windows.networking.vpn.ivpnplugin) - [`VpnChannel`](https://learn.microsoft.com/uwp/api/windows.networking.vpn.vpnchannel) +- [`VpnChannel.StartWithMainTransport`](https://learn.microsoft.com/uwp/api/windows.networking.vpn.vpnchannel.startwithmaintransport) - [`VpnChannel.AssociateTransport`](https://learn.microsoft.com/uwp/api/windows.networking.vpn.vpnchannel.associatetransport) - [`VpnPacketBuffer`](https://learn.microsoft.com/uwp/api/windows.networking.vpn.vpnpacketbuffer) - [`VpnManagementAgent`](https://learn.microsoft.com/uwp/api/windows.networking.vpn.vpnmanagementagent) diff --git a/example/windows-uwp/README.md b/example/windows-uwp/README.md index 93d5372..8d73b8b 100644 --- a/example/windows-uwp/README.md +++ b/example/windows-uwp/README.md @@ -26,7 +26,7 @@ vcore-windows-vpn-host.exe + vcore.dll(AppContainer Provider) | 文件 | 用途 | | --- | --- | -| `demo.cpp` | 最小完全信任宿主;读取 YAML,调用 revision-2 Windows bridge | +| `demo.cpp` | 最小完全信任宿主;读取 YAML,调用 revision-3 Windows bridge | | `demo.yaml` | 无真实凭据的生命周期示例;把流量交给 `127.0.0.1:1080` SOCKS5 | | `AppxManifest.xml.in` | 完整最小 MSIX manifest,包括 Provider、Session Host 和受限能力 | | `build.ps1` | 构建三项 VCore 产物、编译 demo、打包、签名并可选安装 | @@ -114,7 +114,7 @@ if (response != nullptr) { 约束: - 请求是 NUL 结尾 UTF-8 JSON,最大 1 MiB; -- revision 固定为 `bridgeVersion: 2`; +- revision 固定为 `bridgeVersion: 3`; - DTO 严格拒绝未知字段; - 返回内存必须由同一份 `vcore.dll` 的 `VCoreFree` 释放; - 桥接命令不能重叠;真实前台应在单进程内串行调用,本命令行 demo 额外用 session-local named mutex 串行化多个 alias 进程; @@ -127,7 +127,7 @@ if (response != nullptr) { | --- | --- | --- | | `getEnvironment` | `{}` | 验证 package identity,返回 PFN 和 LocalState 路径 | | `getVpnStatus` | `{}` | 查询同包唯一 profile | -| `startVpn` | `configYaml` + `networkSettings` + 可选 `sessionBackend` | 发布 Session Snapshot 并连接 | +| `startVpn` | `configYaml` + `networkSettings` + `policy` + 可选 `sessionBackend` | 发布 Session Snapshot、配置全局 VPN policy 并连接 | | `stopVpn` | `{}` | 断开当前 profile | 桥接还提供 `getStartupTaskStatus` 和 `setStartupTaskEnabled`。本 demo 故意不声明 StartupTask,避免登录时启动一个无 UI 的命令行工具;产品需要该能力时,再声明 `xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"`、把 `desktop` 加入 `IgnorableNamespaces`,并在前台 `` 下增加: @@ -148,7 +148,7 @@ if (response != nullptr) { ```json { - "bridgeVersion": 2, + "bridgeVersion": 3, "method": "startVpn", "payload": { "configYaml": "tun:\n enable: true\n...", @@ -157,20 +157,29 @@ if (response != nullptr) { "ipv6Address": "fd00::2", "dnsIpv4Address": "223.5.5.5", "dnsIpv6Address": "2400:3200::1" + }, + "policy": { + "alwaysOn": false, + "allowLocalNetwork": true, + "excludedCidrs": [] } } } ``` -`networkSettings` 是 session settings,不属于用户 RAW YAML: +`networkSettings` 和 `policy` 是 session settings,不属于用户 RAW YAML。Windows VPN 固定覆盖所有应用;policy 只控制 Always On capability、本地子网旁路和目标 CIDR 旁路。 + +`networkSettings` 约束: - 四项都必填并且必须是对应地址族的合法单播地址; - TUN 地址不能与同地址族 DNS 地址相同; - 不能使用 unspecified、loopback、link-local、multicast,IPv4 也不能使用 broadcast; - 配置内容或任一地址变化时,活动会话不会 hot-swap,必须先 `stopVpn`; -- 完全相同的配置和地址重复 `startVpn` 是幂等查询,不创建第二个 Session Host。 +- 完全相同的配置、地址和 policy 重复 `startVpn` 是幂等查询,不创建第二个 Session Host。 + +`policy.excludedCidrs` 最多 64 项,必须是规范 network/prefix;拒绝重复、host bits、`/0`、禁用 IPv6 时的 IPv6 项和 VPN DNS overlap。`alwaysOn` 只声明 profile capability,实际自动连接仍由 Windows 用户设置和 active profile 决定。 -VCore 会校验 YAML、发布 `vcore-session-v2:` 内容寻址 Session Snapshot,并只把 token、解析后的顶层 IPv6 开关和四个地址写入最大 1 KiB 的 profile custom configuration。调用方不要自行创建另一个 `VpnPlugInProfile` 或维护第二份 Snapshot。 +VCore 会校验 YAML、发布 `vcore-session-v2:` 内容寻址 Session Snapshot,并把 token、解析后的顶层 IPv6 开关、四个地址和 policy 写入最大 4 KiB 的 profile custom configuration。调用方不要自行创建另一个 `VpnPlugInProfile` 或维护第二份 Snapshot。 需要让 Session Host 同会话监督 package-local 进程时,可以额外提交 `sessionBackend.processes`;每项只有 `executableRelativePath` 和 `arguments`。第一版不管理端口、UDP、readiness 或进程业务配置,完整契约见 [Windows 会话运行时](../../docs/windows-session-runtime.md)。本 demo 不携带 backend。 @@ -223,7 +232,7 @@ broker 负责 JSON bridge、命令串行化和结果回传。UWP UI 不接触 YA 1. 启动时调用 `getEnvironment`,确认运行在预期 package family; 2. 调用 `getVpnStatus` 恢复系统真实状态; -3. 用户连接时构造当前 YAML 和四个地址,调用 `startVpn`; +3. 用户连接时构造当前 YAML、四个地址和完整 policy,调用 `startVpn`; 4. UI 可以退出,VPN 与 Session Host 继续; 5. UI 重启后再次以 `getVpnStatus` 为权威; 6. 用户断开时调用 `stopVpn` 并等待结果; diff --git a/example/windows-uwp/demo.cpp b/example/windows-uwp/demo.cpp index e83bfdb..aa8cc86 100644 --- a/example/windows-uwp/demo.cpp +++ b/example/windows-uwp/demo.cpp @@ -98,19 +98,19 @@ std::string json_string(std::string_view value) { std::string request(int argc, wchar_t **argv) { if (argc == 2 && std::wstring_view(argv[1]) == L"environment") { - return R"({"bridgeVersion":2,"method":"getEnvironment","payload":{}})"; + return R"({"bridgeVersion":3,"method":"getEnvironment","payload":{}})"; } if (argc == 2 && std::wstring_view(argv[1]) == L"status") { - return R"({"bridgeVersion":2,"method":"getVpnStatus","payload":{}})"; + return R"({"bridgeVersion":3,"method":"getVpnStatus","payload":{}})"; } if (argc == 2 && std::wstring_view(argv[1]) == L"stop") { - return R"({"bridgeVersion":2,"method":"stopVpn","payload":{}})"; + return R"({"bridgeVersion":3,"method":"stopVpn","payload":{}})"; } if (argc == 3 && std::wstring_view(argv[1]) == L"start") { return std::string( - R"({"bridgeVersion":2,"method":"startVpn","payload":{"configYaml":)") + + R"({"bridgeVersion":3,"method":"startVpn","payload":{"configYaml":)") + json_string(read_config(argv[2])) + - R"(,"networkSettings":{"ipv4Address":"192.168.3.1","ipv6Address":"fd00::2","dnsIpv4Address":"223.5.5.5","dnsIpv6Address":"2400:3200::1"}}})"; + R"(,"networkSettings":{"ipv4Address":"192.168.3.1","ipv6Address":"fd00::2","dnsIpv4Address":"223.5.5.5","dnsIpv6Address":"2400:3200::1"},"policy":{"alwaysOn":false,"allowLocalNetwork":true,"excludedCidrs":[]}}})"; } throw std::runtime_error( "usage: vcore-uwp-demo.exe environment|status|stop|start "); diff --git a/include/vcore.h b/include/vcore.h index 500c5c7..3a027f7 100644 --- a/include/vcore.h +++ b/include/vcore.h @@ -14,7 +14,7 @@ extern "C" { char *VCoreInvoke(const char *request_json); /* - * Windows package hosts use a separate revision-2 JSON contract for package + * Windows package hosts use a separate revision-3 JSON contract for package * environment, VPN profile lifecycle, immutable Session Snapshot publication, * optional session backend processes, and StartupTask operations. The response * has the same allocation ownership as VCoreInvoke and must be released with diff --git a/readme/README.ru.md b/readme/README.ru.md index 481cffe..d962eeb 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -40,7 +40,7 @@ char *VCoreInvoke(const char *request_json); void VCoreFree(char *response); ``` -Пакеты Windows также используют host bridge ревизии 2 для profile, Session Snapshot и необязательного session backend: +Пакеты Windows также используют host bridge ревизии 3 для глобальной VPN policy, profile, Session Snapshot и необязательного session backend: ```c char *VCoreWindowsVpnInvoke(const char *request_json); @@ -99,6 +99,8 @@ DNS opaque cache 64 entries / 256 KiB GeoData allocation capacity 8 MiB ``` +Windows объявляет L3 MTU 1 400 байт согласно требованиям `StartWithMainTransport`; 1 500 байт остаётся межплатформенным пределом парсера. + TCP sessions, обычные UDP associations, half-open connections, outbound handshakes и активные DNS transports создаются по запросу. Структурную безопасность обеспечивают bounded queues, buffers на flow, ограничения wire/parser, timeouts, idle cleanup и caches. Цели iOS 35/45 MiB являются best-effort наблюдениями и не меняют результаты жизненного цикла. ## Документация diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 4ea5862..61f1650 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -40,7 +40,7 @@ char *VCoreInvoke(const char *request_json); void VCoreFree(char *response); ``` -Windows 安装包另提供 revision-2 host bridge,负责 profile、Session Snapshot 和可选 session backend: +Windows 安装包另提供 revision-3 host bridge,负责全应用 VPN policy、profile、Session Snapshot 和可选 session backend: ```c char *VCoreWindowsVpnInvoke(const char *request_json); @@ -99,6 +99,8 @@ DNS opaque cache 64 entries / 256 KiB GeoData allocation capacity 8 MiB ``` +Windows 按 `StartWithMainTransport` 要求宣告 1,400 字节 L3 MTU;1,500 字节仍是跨平台解析上限。 + TCP session、普通 UDP association、half-open、outbound handshake 和 active DNS transport 按需创建;bounded queue、每流 buffer、wire/parser size、timeout、idle cleanup 和 cache 继续提供结构安全。iOS 35/45 MiB 仅为 best-effort 优化观测,不改变生命周期结果。 ## 文档 diff --git a/scripts/src/vcore_scripts/builds.py b/scripts/src/vcore_scripts/builds.py index 269cf96..65a99d0 100644 --- a/scripts/src/vcore_scripts/builds.py +++ b/scripts/src/vcore_scripts/builds.py @@ -416,7 +416,7 @@ def build_windows() -> None: json.dumps( { "formatVersion": 1, - "windowsPackageIntegrationRevision": 2, + "windowsPackageIntegrationRevision": 3, "architecture": architecture, "buildIdentity": EXPECTED_IDENTITY.decode("ascii"), "artifacts": digests, diff --git a/scripts/src/vcore_scripts/tun2socks.py b/scripts/src/vcore_scripts/tun2socks.py index 8ad7747..461820c 100644 --- a/scripts/src/vcore_scripts/tun2socks.py +++ b/scripts/src/vcore_scripts/tun2socks.py @@ -89,7 +89,7 @@ def invoke_bridge( command = temp / "bridge.cmd" request.write_text( json.dumps( - {"bridgeVersion": 2, "method": method, "payload": payload}, + {"bridgeVersion": 3, "method": method, "payload": payload}, separators=(",", ":"), ), encoding="utf-8", @@ -406,6 +406,11 @@ def run_demo(source_config: Path | None = None) -> None: "dnsIpv4Address": DNS_IPV4, "dnsIpv6Address": DNS_IPV6, }, + "policy": { + "alwaysOn": False, + "allowLocalNetwork": True, + "excludedCidrs": [], + }, }, ) if state["status"] != "connected": diff --git a/scripts/tests/test_scripts.py b/scripts/tests/test_scripts.py index 2e58a45..ae05420 100644 --- a/scripts/tests/test_scripts.py +++ b/scripts/tests/test_scripts.py @@ -152,7 +152,7 @@ def test_windows_release_build_uses_production_features_and_checks_identity(self }, "buildIdentity": EXPECTED_IDENTITY.decode("ascii"), "formatVersion": 1, - "windowsPackageIntegrationRevision": 2, + "windowsPackageIntegrationRevision": 3, }, ) diff --git a/src/windows/host.rs b/src/windows/host.rs index 4b70fbd..57a3de0 100644 --- a/src/windows/host.rs +++ b/src/windows/host.rs @@ -25,12 +25,13 @@ use windows::{ use super::{ managed_processes::SessionBackend, + policy::WindowsVpnPolicy, profile::{WindowsNetworkSettings, WindowsProfileConfiguration}, snapshot::SessionReference, }; use crate::config::Config; -const BRIDGE_VERSION: u32 = 2; +const BRIDGE_VERSION: u32 = 3; const MAX_REQUEST_BYTES: usize = 1024 * 1024; const MAX_ERROR_BYTES: usize = 4096; const PROFILE_NAME: &str = "VCore"; @@ -54,6 +55,7 @@ struct EmptyPayload {} struct StartPayload { config_yaml: String, network_settings: WindowsNetworkSettings, + policy: WindowsVpnPolicy, #[serde(default, deserialize_with = "deserialize_session_backend")] session_backend: Option, } @@ -176,6 +178,7 @@ fn start_vpn(payload: StartPayload) -> Result { let StartPayload { config_yaml, network_settings, + policy, session_backend, } = payload; let config = Config::parse_yaml(config_yaml.as_bytes()) @@ -183,6 +186,13 @@ fn start_vpn(payload: StartPayload) -> Result { if !config.tun.enable { return Err("Windows VPN configuration must enable TUN".to_owned()); } + policy + .validate_for( + config.ipv6, + network_settings.dns_ipv4_address(), + network_settings.dns_ipv6_address(), + ) + .map_err(str::to_owned)?; let environment = package_environment()?; let snapshot = SessionReference::publish( @@ -193,8 +203,9 @@ fn start_vpn(payload: StartPayload) -> Result { ) .map_err(display_error)?; let profile_configuration = - WindowsProfileConfiguration::new(&snapshot, config.ipv6, network_settings); + WindowsProfileConfiguration::new(&snapshot, config.ipv6, network_settings, policy); let profile_configuration_json = profile_configuration.to_json().map_err(display_error)?; + let always_on = profile_configuration.policy().always_on(); let token = snapshot.token(); let agent = VpnManagementAgent::new().map_err(display_error)?; let existing = find_profile(&agent, &environment.family_name)?; @@ -207,7 +218,9 @@ fn start_vpn(payload: StartPayload) -> Result { .CustomConfiguration() .map_err(display_error)? .to_string(); - if current == profile_configuration_json { + if current == profile_configuration_json + && existing.profile.AlwaysOn().map_err(display_error)? == always_on + { return profile_status_data(Some(existing)); } return Err("Windows VPN is connected with different session settings".to_owned()); @@ -236,6 +249,7 @@ fn start_vpn(payload: StartPayload) -> Result { &profile, &environment.family_name, &profile_configuration_json, + always_on, )?; let status = if existing.is_some() { agent @@ -402,11 +416,13 @@ fn configure_profile( profile: &VpnPlugInProfile, family_name: &str, profile_configuration: &str, + always_on: bool, ) -> Result<(), String> { profile .SetProfileName(&PROFILE_NAME.into()) .and_then(|()| profile.SetVpnPluginPackageFamilyName(&family_name.into())) .and_then(|()| profile.SetCustomConfiguration(&profile_configuration.into())) + .and_then(|()| profile.SetAlwaysOn(always_on)) .map_err(display_error)?; let servers = profile.ServerUris().map_err(display_error)?; servers.Clear().map_err(display_error)?; @@ -513,10 +529,18 @@ mod tests { } } + fn default_policy() -> Value { + json!({ + "alwaysOn": false, + "allowLocalNetwork": true, + "excludedCidrs": [] + }) + } + #[test] - fn rejects_unknown_bridge_versions_before_touching_winrt() { + fn rejects_obsolete_bridge_versions_before_touching_winrt() { assert_eq!( - invoke(r#"{"bridgeVersion":3,"method":"getEnvironment","payload":{}}"#), + invoke(r#"{"bridgeVersion":2,"method":"getEnvironment","payload":{}}"#), json!({ "success": false, "data": null, @@ -534,7 +558,8 @@ mod tests { "ipv6Address": "fd00:8::2", "dnsIpv4Address": "223.5.5.5", "dnsIpv6Address": "2400:3200::1" - } + }, + "policy": default_policy() })) .unwrap(); assert_eq!( @@ -548,7 +573,8 @@ mod tests { assert!( decode_payload::(json!({ - "configYaml": "tun:\n enable: true\n" + "configYaml": "tun:\n enable: true\n", + "policy": default_policy() })) .is_err() ); @@ -560,8 +586,38 @@ mod tests { "ipv6Address": "fd00:8::2", "dnsIpv4Address": "192.168.8.1", "dnsIpv6Address": "2400:3200::1" + }, + "policy": default_policy() + })) + .is_err() + ); + } + + #[test] + fn start_payload_requires_global_vpn_policy() { + let network_settings = json!({ + "ipv4Address": "192.168.8.1", + "ipv6Address": "fd00:8::2", + "dnsIpv4Address": "223.5.5.5", + "dnsIpv6Address": "2400:3200::1" + }); + assert!( + decode_payload::(json!({ + "configYaml": "tun:\n enable: true\n", + "networkSettings": network_settings.clone(), + "policy": { + "alwaysOn": false, + "allowLocalNetwork": true, + "excludedCidrs": [] } })) + .is_ok() + ); + assert!( + decode_payload::(json!({ + "configYaml": "tun:\n enable: true\n", + "networkSettings": network_settings + })) .is_err() ); } @@ -577,6 +633,7 @@ mod tests { let payload: StartPayload = decode_payload(json!({ "configYaml": "tun:\n enable: true\n", "networkSettings": network_settings.clone(), + "policy": default_policy(), "sessionBackend": { "processes": [{ "executableRelativePath": "bin\\proxy.exe", @@ -590,6 +647,7 @@ mod tests { decode_payload::(json!({ "configYaml": "tun:\n enable: true\n", "networkSettings": network_settings.clone(), + "policy": default_policy(), "sessionBackend": null })) .is_err() @@ -600,6 +658,7 @@ mod tests { decode_payload::(json!({ "configYaml": "tun:\n enable: true\n", "networkSettings": network_settings.clone(), + "policy": default_policy(), "sessionBackend": { "processes": [{ "executableRelativePath": "bin\\proxy.exe", @@ -617,7 +676,7 @@ mod tests { #[test] fn rejects_unknown_methods_without_exposing_profile_crud() { assert_eq!( - invoke(r#"{"bridgeVersion":2,"method":"deleteProfile","payload":{}}"#), + invoke(r#"{"bridgeVersion":3,"method":"deleteProfile","payload":{}}"#), json!({ "success": false, "data": null, @@ -626,6 +685,16 @@ mod tests { ); } + #[test] + fn profile_configuration_applies_always_on_capability() { + let _winrt = WinRtGuard::enter().unwrap(); + let profile = VpnPlugInProfile::new().unwrap(); + + configure_profile(&profile, "example.family", "{}", true).unwrap(); + + assert!(profile.AlwaysOn().unwrap()); + } + #[test] fn plugin_profile_skips_only_no_interface() { use windows::core::Error as WindowsError; diff --git a/src/windows/mod.rs b/src/windows/mod.rs index 6395041..2fa087f 100644 --- a/src/windows/mod.rs +++ b/src/windows/mod.rs @@ -2,8 +2,12 @@ pub(crate) mod host; pub(crate) mod log; pub(crate) mod managed_processes; pub(crate) mod packet_channel; +pub(crate) mod policy; pub(crate) mod profile; #[doc(hidden)] pub mod session; pub(crate) mod snapshot; pub(crate) mod vpn; + +// StartWithMainTransport requires an interface MTU no greater than 1400. +pub(crate) const WINDOWS_VPN_MTU: usize = 1400; diff --git a/src/windows/policy.rs b/src/windows/policy.rs new file mode 100644 index 0000000..96c2fc1 --- /dev/null +++ b/src/windows/policy.rs @@ -0,0 +1,205 @@ +use std::{ + fmt, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + str::FromStr, +}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; + +const MAX_EXCLUDED_CIDRS: usize = 64; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct WindowsVpnPolicy { + always_on: bool, + allow_local_network: bool, + #[serde(deserialize_with = "deserialize_excluded_cidrs")] + excluded_cidrs: Vec, +} + +impl WindowsVpnPolicy { + pub(crate) fn always_on(&self) -> bool { + self.always_on + } + + pub(crate) fn allow_local_network(&self) -> bool { + self.allow_local_network + } + + pub(crate) fn excluded_cidrs(&self) -> &[WindowsVpnCidr] { + &self.excluded_cidrs + } + + pub(crate) fn validate_for( + &self, + ipv6: bool, + dns_ipv4: Ipv4Addr, + dns_ipv6: Ipv6Addr, + ) -> Result<(), &'static str> { + if self.excluded_cidrs.iter().any(|cidr| { + (!ipv6 && cidr.network.is_ipv6()) + || cidr.contains(IpAddr::V4(dns_ipv4)) + || cidr.contains(IpAddr::V6(dns_ipv6)) + }) { + return Err("Windows VPN exclusion conflicts with network settings"); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct WindowsVpnCidr { + network: IpAddr, + prefix_len: u8, +} + +impl WindowsVpnCidr { + pub(crate) fn network(&self) -> IpAddr { + self.network + } + + pub(crate) fn prefix_len(&self) -> u8 { + self.prefix_len + } + + fn contains(&self, address: IpAddr) -> bool { + match (self.network, address) { + (IpAddr::V4(network), IpAddr::V4(address)) => { + let mask = u32::MAX << (32 - u32::from(self.prefix_len)); + u32::from(address) & mask == u32::from(network) + } + (IpAddr::V6(network), IpAddr::V6(address)) => { + let mask = u128::MAX << (128 - u32::from(self.prefix_len)); + u128::from(address) & mask == u128::from(network) + } + _ => false, + } + } +} + +impl FromStr for WindowsVpnCidr { + type Err = &'static str; + + fn from_str(value: &str) -> Result { + let (address, prefix) = value + .split_once('/') + .filter(|(_, prefix)| !prefix.contains('/')) + .ok_or("invalid Windows VPN exclusion CIDR")?; + let address = address + .parse::() + .map_err(|_| "invalid Windows VPN exclusion CIDR")?; + let prefix_len = prefix + .parse::() + .map_err(|_| "invalid Windows VPN exclusion CIDR")?; + let network = match address { + IpAddr::V4(address) if (1..=32).contains(&prefix_len) => { + let mask = u32::MAX << (32 - u32::from(prefix_len)); + IpAddr::V4(Ipv4Addr::from(u32::from(address) & mask)) + } + IpAddr::V6(address) if (1..=128).contains(&prefix_len) => { + let mask = u128::MAX << (128 - u32::from(prefix_len)); + IpAddr::V6(Ipv6Addr::from(u128::from(address) & mask)) + } + _ => return Err("invalid Windows VPN exclusion CIDR"), + }; + if address != network { + return Err("invalid Windows VPN exclusion CIDR"); + } + Ok(Self { + network, + prefix_len, + }) + } +} + +impl fmt::Display for WindowsVpnCidr { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}/{}", self.network, self.prefix_len) + } +} + +impl Serialize for WindowsVpnCidr { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for WindowsVpnCidr { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + String::deserialize(deserializer)? + .parse() + .map_err(D::Error::custom) + } +} + +fn deserialize_excluded_cidrs<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let mut cidrs = Vec::::deserialize(deserializer)?; + cidrs.sort_unstable(); + if cidrs.len() > MAX_EXCLUDED_CIDRS || cidrs.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(D::Error::custom("invalid Windows VPN exclusion CIDRs")); + } + Ok(cidrs) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn policy_rejects_unsafe_excluded_cidrs() { + let policy = |excluded_cidrs| { + serde_json::from_value::(json!({ + "alwaysOn": false, + "allowLocalNetwork": true, + "excludedCidrs": excluded_cidrs + })) + }; + + for invalid in [ + json!(["192.0.2.0/24", "192.0.2.0/24"]), + json!(["192.0.2.1/24"]), + json!(["0.0.0.0/0"]), + json!(["2001:db8::/129"]), + ] { + assert!(policy(invalid).is_err()); + } + assert!( + policy(json!( + (0..65) + .map(|index| format!("192.0.2.{index}/32")) + .collect::>() + )) + .is_err() + ); + } + + #[test] + fn policy_canonicalizes_excluded_cidrs() { + let policy: WindowsVpnPolicy = serde_json::from_value(json!({ + "alwaysOn": true, + "allowLocalNetwork": false, + "excludedCidrs": ["2001:0DB8:0:0::/64", "192.0.2.0/24"] + })) + .unwrap(); + + assert_eq!( + serde_json::to_value(policy).unwrap(), + json!({ + "alwaysOn": true, + "allowLocalNetwork": false, + "excludedCidrs": ["192.0.2.0/24", "2001:db8::/64"] + }) + ); + } +} diff --git a/src/windows/profile.rs b/src/windows/profile.rs index 2282cbf..8ea79fa 100644 --- a/src/windows/profile.rs +++ b/src/windows/profile.rs @@ -6,10 +6,10 @@ use windows::{ core::{Error, Result}, }; -use super::snapshot::SessionReference; +use super::{policy::WindowsVpnPolicy, snapshot::SessionReference}; -const PROFILE_CONFIGURATION_VERSION: u32 = 3; -const MAX_PROFILE_CONFIGURATION_BYTES: usize = 1024; +const PROFILE_CONFIGURATION_VERSION: u32 = 4; +const MAX_PROFILE_CONFIGURATION_BYTES: usize = 4096; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] @@ -84,6 +84,7 @@ pub(crate) struct WindowsProfileConfiguration { snapshot_token: String, ipv6: bool, network_settings: WindowsNetworkSettings, + policy: WindowsVpnPolicy, } impl WindowsProfileConfiguration { @@ -91,12 +92,14 @@ impl WindowsProfileConfiguration { snapshot: &SessionReference, ipv6: bool, network_settings: WindowsNetworkSettings, + policy: WindowsVpnPolicy, ) -> Self { Self { version: PROFILE_CONFIGURATION_VERSION, snapshot_token: snapshot.token(), ipv6, network_settings, + policy, } } @@ -108,6 +111,14 @@ impl WindowsProfileConfiguration { serde_json::from_str(value).map_err(|_| invalid_profile_configuration())?; if configuration.version != PROFILE_CONFIGURATION_VERSION || SessionReference::parse(&configuration.snapshot_token).is_err() + || configuration + .policy + .validate_for( + configuration.ipv6, + configuration.network_settings.dns_ipv4_address, + configuration.network_settings.dns_ipv6_address, + ) + .is_err() { return Err(invalid_profile_configuration()); } @@ -133,6 +144,10 @@ impl WindowsProfileConfiguration { pub(crate) fn network_settings(&self) -> &WindowsNetworkSettings { &self.network_settings } + + pub(crate) fn policy(&self) -> &WindowsVpnPolicy { + &self.policy + } } fn invalid_ipv4(address: Ipv4Addr) -> bool { @@ -161,7 +176,7 @@ mod tests { fn valid_json() -> String { let digest = "0123456789abcdef".repeat(4); format!( - r#"{{"version":3,"snapshotToken":"vcore-session-v2:{digest}","ipv6":true,"networkSettings":{{"ipv4Address":"192.168.8.1","ipv6Address":"fd00:8::2","dnsIpv4Address":"223.5.5.5","dnsIpv6Address":"2400:3200::1"}}}}"# + r#"{{"version":4,"snapshotToken":"vcore-session-v2:{digest}","ipv6":true,"networkSettings":{{"ipv4Address":"192.168.8.1","ipv6Address":"fd00:8::2","dnsIpv4Address":"223.5.5.5","dnsIpv6Address":"2400:3200::1"}},"policy":{{"alwaysOn":false,"allowLocalNetwork":true,"excludedCidrs":[]}}}}"# ) } @@ -197,6 +212,9 @@ mod tests { .to_string(), "2400:3200::1" ); + assert!(!configuration.policy().always_on()); + assert!(configuration.policy().allow_local_network()); + assert!(configuration.policy().excluded_cidrs().is_empty()); assert_eq!( serde_json::from_str::(&configuration.to_json().unwrap()).unwrap(), serde_json::from_str::(&json).unwrap() @@ -207,7 +225,7 @@ mod tests { fn profile_configuration_rejects_unknown_or_unsafe_network_settings() { let valid = valid_json(); for invalid in [ - valid.replace(r#""version":3"#, r#""version":2"#), + valid.replace(r#""version":4"#, r#""version":3"#), valid.replace(r#","ipv6":true"#, ""), valid.replace(r#""ipv6":true"#, r#""ipv6":"true""#), valid.replace(r#""snapshotToken""#, r#""unknown":true,"snapshotToken""#), @@ -233,6 +251,27 @@ mod tests { ); } + #[test] + fn profile_configuration_rejects_policy_conflicts() { + let json = valid_json(); + assert!( + WindowsProfileConfiguration::parse(&json.replace( + r#""excludedCidrs":[]"#, + r#""excludedCidrs":["223.5.5.5/32"]"# + )) + .is_err() + ); + assert!( + WindowsProfileConfiguration::parse( + &json.replace(r#""ipv6":true"#, r#""ipv6":false"#).replace( + r#""excludedCidrs":[]"#, + r#""excludedCidrs":["2001:db8::/64"]"#, + ), + ) + .is_err() + ); + } + #[test] fn profile_configuration_round_trips_disabled_ipv6() { let json = valid_json().replace(r#""ipv6":true"#, r#""ipv6":false"#); diff --git a/src/windows/session.rs b/src/windows/session.rs index b736674..4b1a5fd 100644 --- a/src/windows/session.rs +++ b/src/windows/session.rs @@ -23,7 +23,7 @@ use windows::{ }; use super::{ - log, + WINDOWS_VPN_MTU, log, managed_processes::ManagedProcessSet, packet_channel::{ ControlMessage, DATA_PIPE_READ_BUFFER_BYTES, MAX_PACKET_BATCH_PACKETS, PROTOCOL_VERSION, @@ -273,9 +273,11 @@ async fn start_vcore( Duration::from_secs(24 * 60 * 60), ) .map_err(io::Error::other)?; - let prepared = - PreparedCore::prepare_config(config, geodata, &SystemResolver, ResourceLimits::default()) - .await?; + let limits = ResourceLimits { + tun_max_datagram_size: WINDOWS_VPN_MTU, + ..ResourceLimits::default() + }; + let prepared = PreparedCore::prepare_config(config, geodata, &SystemResolver, limits).await?; let wake = Arc::new(Notify::new()); let observed = Arc::clone(&wake); diff --git a/src/windows/vpn.rs b/src/windows/vpn.rs index 5b9d2b1..2348acb 100644 --- a/src/windows/vpn.rs +++ b/src/windows/vpn.rs @@ -3,7 +3,7 @@ use std::{ io, mem::size_of, - net::{Ipv4Addr, Ipv6Addr}, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, num::NonZeroU32, panic::{AssertUnwindSafe, catch_unwind}, path::PathBuf, @@ -58,17 +58,19 @@ use windows_collections::IVectorView; use windows_core::{AgileReference, IUnknownImpl as _}; use super::{ - log, + WINDOWS_VPN_MTU, log, packet_channel::{ AddressBindingV4, AddressBindingV6, PacketCounters, PhysicalBinding, ProviderPacketSession, remove_rendezvous, }, + policy::WindowsVpnPolicy, profile::{WindowsNetworkSettings, WindowsProfileConfiguration}, }; use crate::platform::{TunIo, WindowsPacketAdapter, WindowsPacketStats}; const CLASS_NAME: &str = "VCore.VpnBackgroundTask"; const PACKET_QUEUE_CAPACITY: usize = 256; +const WINDOWS_VPN_MAX_FRAME_SIZE: u32 = WINDOWS_VPN_MTU as u32 + 12; const FAIL_CLOSED_IDLE: u8 = 0; const FAIL_CLOSED_STOPPING: u8 = 1; const FAIL_CLOSED_CANCELLED: u8 = 2; @@ -589,7 +591,7 @@ impl VpnProvider { let output = back_transport.OutputStream()?; let ipv6 = profile.ipv6_enabled(); - let routes = vpn_routes(ipv6)?; + let routes = vpn_routes(ipv6, profile.policy())?; let (assigned_ipv4, assigned_ipv6) = vpn_client_addresses(profile.network_settings(), ipv6)?; let dns = vpn_dns_assignment(profile.network_settings(), ipv6)?; @@ -643,8 +645,8 @@ impl VpnProvider { None::<&VpnInterfaceId>, &routes, &dns, - 1500, - 1512, + WINDOWS_VPN_MTU as u32, + WINDOWS_VPN_MAX_FRAME_SIZE, false, &transport, )?; @@ -933,9 +935,9 @@ extern "system" fn DllGetActivationFactory( .unwrap_or(E_FAIL) } -fn vpn_routes(ipv6: bool) -> Result { +fn vpn_routes(ipv6: bool, policy: &WindowsVpnPolicy) -> Result { let routes = VpnRouteAssignment::new()?; - routes.SetExcludeLocalSubnets(true)?; + routes.SetExcludeLocalSubnets(policy.allow_local_network())?; for network in ["0.0.0.0", "128.0.0.0"] { routes .Ipv4InclusionRoutes()? @@ -954,6 +956,16 @@ fn vpn_routes(ipv6: bool) -> Result { )?)?; } } + for cidr in policy.excluded_cidrs() { + let route = VpnRoute::CreateVpnRoute( + &HostName::CreateHostName(&cidr.network().to_string().into())?, + cidr.prefix_len(), + )?; + match cidr.network() { + IpAddr::V4(_) => routes.Ipv4ExclusionRoutes()?.Append(&route)?, + IpAddr::V6(_) => routes.Ipv6ExclusionRoutes()?.Append(&route)?, + } + } Ok(routes) } @@ -1117,11 +1129,11 @@ mod tests { let _winrt = WinRtGuard::enter(); let digest = "0123456789abcdef".repeat(4); let profile = WindowsProfileConfiguration::parse(&format!( - r#"{{"version":3,"snapshotToken":"vcore-session-v2:{digest}","ipv6":true,"networkSettings":{{"ipv4Address":"192.168.8.1","ipv6Address":"fd00:8::2","dnsIpv4Address":"223.5.5.5","dnsIpv6Address":"2400:3200::1"}}}}"# + r#"{{"version":4,"snapshotToken":"vcore-session-v2:{digest}","ipv6":true,"networkSettings":{{"ipv4Address":"192.168.8.1","ipv6Address":"fd00:8::2","dnsIpv4Address":"223.5.5.5","dnsIpv6Address":"2400:3200::1"}},"policy":{{"alwaysOn":false,"allowLocalNetwork":true,"excludedCidrs":[]}}}}"# )) .unwrap(); - let routes = vpn_routes(profile.ipv6_enabled()).unwrap(); + let routes = vpn_routes(profile.ipv6_enabled(), profile.policy()).unwrap(); assert_eq!(routes.Ipv4InclusionRoutes().unwrap().Size().unwrap(), 2); assert_eq!(routes.Ipv6InclusionRoutes().unwrap().Size().unwrap(), 2); @@ -1143,16 +1155,37 @@ mod tests { ); } + #[test] + fn provider_routes_apply_global_vpn_policy() { + let _winrt = WinRtGuard::enter(); + let digest = "0123456789abcdef".repeat(4); + let profile = WindowsProfileConfiguration::parse(&format!( + r#"{{"version":4,"snapshotToken":"vcore-session-v2:{digest}","ipv6":true,"networkSettings":{{"ipv4Address":"192.168.8.1","ipv6Address":"fd00:8::2","dnsIpv4Address":"223.5.5.5","dnsIpv6Address":"2400:3200::1"}},"policy":{{"alwaysOn":true,"allowLocalNetwork":false,"excludedCidrs":["192.0.2.0/24","2001:db8::/64"]}}}}"# + )) + .unwrap(); + + let routes = vpn_routes(profile.ipv6_enabled(), profile.policy()).unwrap(); + assert!(!routes.ExcludeLocalSubnets().unwrap()); + assert_eq!(routes.Ipv4InclusionRoutes().unwrap().Size().unwrap(), 2); + assert_eq!(routes.Ipv6InclusionRoutes().unwrap().Size().unwrap(), 2); + let ipv4 = routes.Ipv4ExclusionRoutes().unwrap().GetAt(0).unwrap(); + assert_eq!(ipv4.Address().unwrap().DisplayName().unwrap(), "192.0.2.0"); + assert_eq!(ipv4.PrefixSize().unwrap(), 24); + let ipv6 = routes.Ipv6ExclusionRoutes().unwrap().GetAt(0).unwrap(); + assert_eq!(ipv6.Address().unwrap().DisplayName().unwrap(), "2001:db8::"); + assert_eq!(ipv6.PrefixSize().unwrap(), 64); + } + #[test] fn provider_omits_ipv6_assignments_when_disabled() { let _winrt = WinRtGuard::enter(); let digest = "0123456789abcdef".repeat(4); let profile = WindowsProfileConfiguration::parse(&format!( - r#"{{"version":3,"snapshotToken":"vcore-session-v2:{digest}","ipv6":false,"networkSettings":{{"ipv4Address":"192.168.8.1","ipv6Address":"fd00:8::2","dnsIpv4Address":"223.5.5.5","dnsIpv6Address":"2400:3200::1"}}}}"# + r#"{{"version":4,"snapshotToken":"vcore-session-v2:{digest}","ipv6":false,"networkSettings":{{"ipv4Address":"192.168.8.1","ipv6Address":"fd00:8::2","dnsIpv4Address":"223.5.5.5","dnsIpv6Address":"2400:3200::1"}},"policy":{{"alwaysOn":false,"allowLocalNetwork":true,"excludedCidrs":[]}}}}"# )) .unwrap(); - let routes = vpn_routes(profile.ipv6_enabled()).unwrap(); + let routes = vpn_routes(profile.ipv6_enabled(), profile.policy()).unwrap(); assert_eq!(routes.Ipv4InclusionRoutes().unwrap().Size().unwrap(), 2); assert_eq!(routes.Ipv6InclusionRoutes().unwrap().Size().unwrap(), 0); From 8942611ece14e05296d819b0d4224e21b94f6d83 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:10:14 +0800 Subject: [PATCH 2/5] fix(windows): honor IPv4-only VPN routes --- docs/acceptance.md | 18 +++- docs/tun-platform.md | 2 +- docs/windows-vpn.md | 15 +-- src/windows/vpn.rs | 228 +++++++++++++++++++++++++++++++++++-------- 4 files changed, 216 insertions(+), 47 deletions(-) diff --git a/docs/acceptance.md b/docs/acceptance.md index 68807bf..fbd4d4e 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -119,11 +119,27 @@ vcore-uwp-demo.exe stop - Provider/Session Host 退出、管道错误和网络变化时的失败关闭; - 快速重连、持续压力、零队列丢弃和显式 Stop 清理。 +### 全局 VPN policy 与 IPv4-only clean gate(2026-09-01) + +当前 Windows 11 ARM64 build 26200.9278 主机重启后,先确认 package-owned route、interface 和进程均为零,再用同一源树构建、开发签名并安装单 Application MSIX。实际结果: + +- `ipv6: false` 和 `ipv6: true` 都完成稳定 `start → status → TCP traffic → stop → status`;前者运行时有一个 VPN interface,后者有两个,Provider 和 Session Host 都各激活一个; +- 两种地址模式都观察到 Provider ingress/egress,显式 Stop 后 Session Host、route 和 interface 均归零; +- `ipv6: false` 向 `StartWithMainTransport` 传 null IPv6 client-address 参数后通过;传空集合的对照包稳定返回 `0x8007000E`,且不启动 Session Host; +- 主 transport 绑定当前物理地址后,目标 exclusion route 选择物理出口,而未排除的控制流量仍进入 VPN;回环绑定的对照包会使 exclusion traffic 超时; +- `allowLocalNetwork: true` 时实机局域网 peer 选择物理出口;设为 `false` 后,两个更具体的物理子网 inclusion route 使同一 peer 进入 VPN,并完成外网双向流量; +- profile 的 Always On capability 在连接时读回为启用,显式 Stop 后保持断开;随后用默认 policy 重建 profile 时读回为禁用; +- 已有 profile/Snapshot 在前台启动命令退出、Provider/Session Host 均不存在时,由系统 profile connect 冷启动两个 native 进程并完成双向流量; +- 类型错误的 policy 在 Provider 激活前被拒绝;运行中强制终止 Session Host 后 Provider 失败关闭,profile 变为 disconnected,route/interface 清零; +- 每个 gate 结束时都确认无 Session Host、route 或 VPN interface 残留。Provider 的已停止 AppContainer 外壳可能暂留,验收脚本在各 case 之间显式结束它。 + +该记录证明当前机器上的 IPv4 物理出口和 Windows 分配的双栈 VPN interface;主机没有物理 IPv6/default gateway,因此真实 IPv6 exclusion 仍未执行。 + Windows 路由必须保留两条 `/1`。在安装包环境中,单条 VPN `/0` 会使按产品要求绑定物理源地址和接口的外层 socket 返回 `WSAENETUNREACH`;两条 `/1` 不会产生该问题。 以下项目由发布开发者在对应机器或服务中验证,不阻塞上述本机可行性结论: -- 单 Application test-signed MSIX 安装; +- production-signed MSIX、WACK 与 Store 安装路径; - Windows 10 20H2; - 原生 x64 Windows; - 真实物理 IPv6; diff --git a/docs/tun-platform.md b/docs/tun-platform.md index 1c3d0d1..0c96f5c 100644 --- a/docs/tun-platform.md +++ b/docs/tun-platform.md @@ -43,7 +43,7 @@ VpnChannel callback Windows 使用 `Windows.Networking.Vpn` 回调,不使用文件描述符或适配器 ring: - Provider 在回调内复制 `VpnPacketBuffer` 字节,不保存系统缓冲区的借用; -- 顶层 `ipv6: false` 时,Provider 不向 Windows 分配 IPv6 TUN 地址,也不安装 IPv6 路由或 DNS;`startVpn` 的 IPv6 地址字段仍严格必填并经过验证; +- 顶层 `ipv6: false` 时,Provider 向 `StartWithMainTransport` 传 null IPv6 client-address 参数,不分配 IPv6 TUN 地址,也不安装 IPv6 路由或 DNS;`startVpn` 的 IPv6 地址字段仍严格必填并经过验证; - Windows profile 固定覆盖所有应用;Provider 按 policy 设置本地子网旁路,并把最多 64 条规范目标 CIDR 加入 exclusion routes; - 系统和 Provider 创建的缓冲区都按 WinRT 所有权规则归还; - 回调不等待管道 I/O,入站和出站队列保持有界; diff --git a/docs/windows-vpn.md b/docs/windows-vpn.md index 8150627..6dca62f 100644 --- a/docs/windows-vpn.md +++ b/docs/windows-vpn.md @@ -72,14 +72,14 @@ u16 大端序包长 ## 回包唤醒 -`VpnChannel` 要求 Provider 关联受管理传输。Provider 在同一 AppContainer 内建立一对回环 `DatagramSocket`: +`VpnChannel` 要求 Provider 关联受管理传输。Provider 在同一 AppContainer 内建立一对绑定当前物理地址的本地 `DatagramSocket`,并把主 socket 交给 `AssociateTransport` 和 `StartWithMainTransport`: -1. 出站队列从空变为非空时发送一个哑数据报; +1. 出站队列从空变为非空时,配对 socket 向主 socket 发送一个哑数据报; 2. Windows 触发 `Decapsulate`; 3. 回调消费哑数据报并排空已就绪的原始包; 4. 队列持续非空时不重复唤醒。 -哑数据报只用于调度,不承载业务包。 +哑数据报只用于调度,不承载业务包。主 socket 不能绑定回环地址;Windows 必须从它识别物理接口,才能把 local/CIDR exclusion route 落到物理出口。 ## 路由与 DNS @@ -90,11 +90,12 @@ IPv4: 0.0.0.0/1, 128.0.0.0/1 IPv6: ::/1, 8000::/1 ``` -顶层 `ipv6` 默认为 `true`。设为 `false` 时,Provider 只安装 IPv4 `/1` 路由,只分配 IPv4 TUN 地址,并且只向 Windows 注册 IPv4 DNS;不会安装或分配任何 IPv6 项。 +顶层 `ipv6` 默认为 `true`。设为 `false` 时,Provider 只安装 IPv4 `/1` 路由,只分配 IPv4 TUN 地址,并且只向 Windows 注册 IPv4 DNS;不会安装或分配任何 IPv6 项。传给 `StartWithMainTransport` 的 IPv6 client-address 参数必须是 null,而不是空集合;Windows 11 ARM64 对空集合返回 `0x8007000E`。 Windows profile 固定覆盖所有应用,不使用 AppTriggers、traffic filters 或流量身份。每次会话还应用完整 policy: -- `allowLocalNetwork` 直接传给 `VpnRouteAssignment.SetExcludeLocalSubnets`; +- `allowLocalNetwork: true` 设置 `VpnRouteAssignment.SetExcludeLocalSubnets(true)`; +- `allowLocalNetwork: false` 清除该标志,并用物理子网的两个更具体子前缀覆盖本地 on-link route,避免 `/1` inclusion route 因优先级较低而旁路 VPN; - `excludedCidrs` 按地址族加入 exclusion routes,不修改两条 `/1` inclusion routes; - `alwaysOn` 写入 profile capability;实际自动连接仍由 Windows 用户设置和 active profile 决定。 @@ -117,7 +118,7 @@ Provider 在安装路由前选择不可变的: - 适配器 GUID; - 网络 profile 和 network identity; -- 每个可用地址族的源 IP; +- 每个可用地址族的源 IP 和 on-link prefix; - 对应的非零接口索引。 Session Host 的每个非回环出站 socket 必须同时应用: @@ -132,7 +133,7 @@ Session Host 的每个非回环出站 socket 必须同时应用: ## 网络变化 -Provider 是物理网络状态的唯一权威,并订阅 `NetworkStatusChanged`。事件到达后等待 2 秒,再复验适配器 GUID、地址和 network identity;任一项变化就停止当前 VPN。 +Provider 是物理网络状态的唯一权威,并订阅 `NetworkStatusChanged`。事件到达后等待 2 秒,再复验适配器 GUID、地址、on-link prefix 和 network identity;任一项变化就停止当前 VPN。 当前实现不迁移现有 socket、不重选接口,也不回退到未绑定 socket。Session Host 不自行更新物理绑定。 diff --git a/src/windows/vpn.rs b/src/windows/vpn.rs index 2348acb..99325fd 100644 --- a/src/windows/vpn.rs +++ b/src/windows/vpn.rs @@ -378,6 +378,8 @@ struct PhysicalNetwork { identity: NetworkIdentity, ipv4: Option, ipv6: Option, + ipv4_prefix: Option, + ipv6_prefix: Option, ipv4_index: Option, ipv6_index: Option, } @@ -393,12 +395,18 @@ impl PhysicalNetwork { for index in 0..host_names.Size()? { let host = host_names.GetAt(index)?; - let Ok(adapter) = host.IPInformation().and_then(|info| info.NetworkAdapter()) else { + let Ok(information) = host.IPInformation() else { + continue; + }; + let Ok(adapter) = information.NetworkAdapter() else { continue; }; if adapter.NetworkAdapterId()? != adapter_id { continue; } + let Ok(prefix) = information.PrefixLength().and_then(|prefix| prefix.Value()) else { + continue; + }; match host.Type()? { HostNameType::Ipv4 => { let Ok(address) = host.CanonicalName()?.to_string().parse::() else { @@ -407,8 +415,9 @@ impl PhysicalNetwork { if !address.is_loopback() && !address.is_link_local() && !address.is_unspecified() + && prefix <= 32 { - ipv4.push(address); + ipv4.push((address, prefix)); } } HostNameType::Ipv6 => { @@ -419,8 +428,9 @@ impl PhysicalNetwork { && !address.is_unicast_link_local() && !address.is_unspecified() && !address.is_multicast() + && prefix <= 128 { - ipv6.push(address); + ipv6.push((address, prefix)); } } _ => {} @@ -439,6 +449,10 @@ impl PhysicalNetwork { } let ipv4 = ipv4.into_iter().next(); let ipv6 = ipv6.into_iter().next(); + let ipv4_prefix = ipv4.map(|(_, prefix)| prefix); + let ipv6_prefix = ipv6.map(|(_, prefix)| prefix); + let ipv4 = ipv4.map(|(address, _)| address); + let ipv6 = ipv6.map(|(address, _)| address); let indices = adapter_interface_indices(adapter_id)?; let ipv4_index = match ipv4 { Some(_) => Some(indices.ipv4.ok_or_else(|| { @@ -457,6 +471,8 @@ impl PhysicalNetwork { identity, ipv4, ipv6, + ipv4_prefix, + ipv6_prefix, ipv4_index, ipv6_index, }) @@ -490,19 +506,33 @@ impl PhysicalNetwork { let mut ipv6_found = self.ipv6.is_none(); for index in 0..host_names.Size()? { let host = host_names.GetAt(index)?; - let Ok(adapter) = host.IPInformation().and_then(|info| info.NetworkAdapter()) else { + let Ok(information) = host.IPInformation() else { + continue; + }; + let Ok(adapter) = information.NetworkAdapter() else { continue; }; if adapter.NetworkAdapterId()? != self.adapter_id { continue; } + let Ok(prefix) = information.PrefixLength().and_then(|prefix| prefix.Value()) else { + continue; + }; let canonical = host.CanonicalName()?.to_string(); - ipv4_found |= self.ipv4.is_some_and(|expected| { - canonical.parse::().is_ok_and(|ip| ip == expected) - }); - ipv6_found |= self.ipv6.is_some_and(|expected| { - canonical.parse::().is_ok_and(|ip| ip == expected) - }); + ipv4_found |= + self.ipv4 + .zip(self.ipv4_prefix) + .is_some_and(|(expected, expected_prefix)| { + prefix == expected_prefix + && canonical.parse::().is_ok_and(|ip| ip == expected) + }); + ipv6_found |= + self.ipv6 + .zip(self.ipv6_prefix) + .is_some_and(|(expected, expected_prefix)| { + prefix == expected_prefix + && canonical.parse::().is_ok_and(|ip| ip == expected) + }); } if !ipv4_found || !ipv6_found { return Ok(false); @@ -572,26 +602,31 @@ impl VpnProvider { let token = profile.snapshot_token().to_owned(); let physical = PhysicalNetwork::current()?; - let localhost = HostName::CreateHostName(&"127.0.0.1".into())?; + let transport_address = physical + .ipv4 + .map(|address| address.to_string()) + .or_else(|| physical.ipv6.map(|address| address.to_string())) + .ok_or_else(|| Error::new(E_FAIL, "physical network has no transport address"))?; + let transport_host = HostName::CreateHostName(&transport_address.into())?; let transport = DatagramSocket::new()?; let back_transport = DatagramSocket::new()?; channel.AssociateTransport(&transport, None::<&IInspectable>)?; transport - .BindEndpointAsync(&localhost, &HSTRING::new())? + .BindEndpointAsync(&transport_host, &HSTRING::new())? .join()?; back_transport - .BindEndpointAsync(&localhost, &HSTRING::new())? + .BindEndpointAsync(&transport_host, &HSTRING::new())? .join()?; transport - .ConnectAsync(&localhost, &back_transport.Information()?.LocalPort()?)? + .ConnectAsync(&transport_host, &back_transport.Information()?.LocalPort()?)? .join()?; back_transport - .ConnectAsync(&localhost, &transport.Information()?.LocalPort()?)? + .ConnectAsync(&transport_host, &transport.Information()?.LocalPort()?)? .join()?; let output = back_transport.OutputStream()?; let ipv6 = profile.ipv6_enabled(); - let routes = vpn_routes(ipv6, profile.policy())?; + let routes = vpn_routes(ipv6, profile.policy(), &physical)?; let (assigned_ipv4, assigned_ipv6) = vpn_client_addresses(profile.network_settings(), ipv6)?; let dns = vpn_dns_assignment(profile.network_settings(), ipv6)?; @@ -641,7 +676,7 @@ impl VpnProvider { channel.StartWithMainTransport( &assigned_ipv4, - &assigned_ipv6, + assigned_ipv6.as_ref(), None::<&VpnInterfaceId>, &routes, &dns, @@ -935,7 +970,11 @@ extern "system" fn DllGetActivationFactory( .unwrap_or(E_FAIL) } -fn vpn_routes(ipv6: bool, policy: &WindowsVpnPolicy) -> Result { +fn vpn_routes( + ipv6: bool, + policy: &WindowsVpnPolicy, + physical: &PhysicalNetwork, +) -> Result { let routes = VpnRouteAssignment::new()?; routes.SetExcludeLocalSubnets(policy.allow_local_network())?; for network in ["0.0.0.0", "128.0.0.0"] { @@ -956,6 +995,36 @@ fn vpn_routes(ipv6: bool, policy: &WindowsVpnPolicy) -> Result Result Result<(IVectorView, IVectorView)> { - Ok(( - IVectorView::from(vec![Some(HostName::CreateHostName( - &settings.ipv4_address().to_string().into(), - )?)]), - IVectorView::from(if ipv6 { - vec![Some(HostName::CreateHostName( - &settings.ipv6_address().to_string().into(), - )?)] - } else { - Vec::new() - }), - )) +) -> Result<(IVectorView, Option>)> { + let ipv4 = IVectorView::from(vec![Some(HostName::CreateHostName( + &settings.ipv4_address().to_string().into(), + )?)]); + let ipv6 = if ipv6 { + Some(IVectorView::from(vec![Some(HostName::CreateHostName( + &settings.ipv6_address().to_string().into(), + )?)])) + } else { + None + }; + Ok((ipv4, ipv6)) } fn vpn_dns_assignment( @@ -1091,6 +1159,25 @@ mod tests { } } + fn physical_network( + ipv4: Option<(Ipv4Addr, u8)>, + ipv6: Option<(Ipv6Addr, u8)>, + ) -> PhysicalNetwork { + PhysicalNetwork { + adapter_id: GUID::from_u128(0), + identity: NetworkIdentity { + profile_name: String::new(), + network_names: Vec::new(), + }, + ipv4: ipv4.map(|(address, _)| address), + ipv6: ipv6.map(|(address, _)| address), + ipv4_prefix: ipv4.map(|(_, prefix)| prefix), + ipv6_prefix: ipv6.map(|(_, prefix)| prefix), + ipv4_index: None, + ipv6_index: None, + } + } + #[test] fn adapter_name_matches_winrt_network_adapter_id() { let id = GUID::from_u128(0xc2afe445_9ed9_423d_8c29_6b2cd49691d2); @@ -1133,14 +1220,22 @@ mod tests { )) .unwrap(); - let routes = vpn_routes(profile.ipv6_enabled(), profile.policy()).unwrap(); + let routes = vpn_routes( + profile.ipv6_enabled(), + profile.policy(), + &physical_network(None, None), + ) + .unwrap(); assert_eq!(routes.Ipv4InclusionRoutes().unwrap().Size().unwrap(), 2); assert_eq!(routes.Ipv6InclusionRoutes().unwrap().Size().unwrap(), 2); let (ipv4, ipv6) = vpn_client_addresses(profile.network_settings(), profile.ipv6_enabled()).unwrap(); assert_eq!(ipv4.GetAt(0).unwrap().DisplayName().unwrap(), "192.168.8.1"); - assert_eq!(ipv6.GetAt(0).unwrap().DisplayName().unwrap(), "fd00:8::2"); + assert_eq!( + ipv6.unwrap().GetAt(0).unwrap().DisplayName().unwrap(), + "fd00:8::2" + ); let dns = vpn_dns_assignment(profile.network_settings(), profile.ipv6_enabled()).unwrap(); let info = dns.DomainNameList().unwrap().GetAt(0).unwrap(); @@ -1164,10 +1259,62 @@ mod tests { )) .unwrap(); - let routes = vpn_routes(profile.ipv6_enabled(), profile.policy()).unwrap(); + let routes = vpn_routes( + profile.ipv6_enabled(), + profile.policy(), + &physical_network( + Some((Ipv4Addr::new(198, 51, 100, 129), 24)), + Some(("2001:db8:1::2".parse().unwrap(), 64)), + ), + ) + .unwrap(); assert!(!routes.ExcludeLocalSubnets().unwrap()); - assert_eq!(routes.Ipv4InclusionRoutes().unwrap().Size().unwrap(), 2); - assert_eq!(routes.Ipv6InclusionRoutes().unwrap().Size().unwrap(), 2); + let ipv4_inclusions = routes.Ipv4InclusionRoutes().unwrap(); + assert_eq!(ipv4_inclusions.Size().unwrap(), 4); + assert_eq!( + ipv4_inclusions + .GetAt(2) + .unwrap() + .Address() + .unwrap() + .DisplayName() + .unwrap(), + "198.51.100.0" + ); + assert_eq!(ipv4_inclusions.GetAt(2).unwrap().PrefixSize().unwrap(), 25); + assert_eq!( + ipv4_inclusions + .GetAt(3) + .unwrap() + .Address() + .unwrap() + .DisplayName() + .unwrap(), + "198.51.100.128" + ); + let ipv6_inclusions = routes.Ipv6InclusionRoutes().unwrap(); + assert_eq!(ipv6_inclusions.Size().unwrap(), 4); + assert_eq!( + ipv6_inclusions + .GetAt(2) + .unwrap() + .Address() + .unwrap() + .DisplayName() + .unwrap(), + "2001:db8:1::" + ); + assert_eq!(ipv6_inclusions.GetAt(2).unwrap().PrefixSize().unwrap(), 65); + assert_eq!( + ipv6_inclusions + .GetAt(3) + .unwrap() + .Address() + .unwrap() + .DisplayName() + .unwrap(), + "2001:db8:1:0:8000::" + ); let ipv4 = routes.Ipv4ExclusionRoutes().unwrap().GetAt(0).unwrap(); assert_eq!(ipv4.Address().unwrap().DisplayName().unwrap(), "192.0.2.0"); assert_eq!(ipv4.PrefixSize().unwrap(), 24); @@ -1185,14 +1332,19 @@ mod tests { )) .unwrap(); - let routes = vpn_routes(profile.ipv6_enabled(), profile.policy()).unwrap(); + let routes = vpn_routes( + profile.ipv6_enabled(), + profile.policy(), + &physical_network(None, None), + ) + .unwrap(); assert_eq!(routes.Ipv4InclusionRoutes().unwrap().Size().unwrap(), 2); assert_eq!(routes.Ipv6InclusionRoutes().unwrap().Size().unwrap(), 0); let (ipv4, ipv6) = vpn_client_addresses(profile.network_settings(), profile.ipv6_enabled()).unwrap(); assert_eq!(ipv4.Size().unwrap(), 1); - assert_eq!(ipv6.Size().unwrap(), 0); + assert!(ipv6.is_none()); let dns = vpn_dns_assignment(profile.network_settings(), profile.ipv6_enabled()).unwrap(); let servers = dns From e37002085a2d838366076965ec928fddb53b3575 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:21:40 +0800 Subject: [PATCH 3/5] docs: isolate public repository context --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 1ac348c..0839b1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ Read the relevant document completely before changing that area: 6. Reuse the existing raw-IP netstack and outbound graph. Add no second Windows proxy core, Wintun path, per-protocol socket factory, or fake fd layer. 7. Keep optional protocol/platform code feature- and target-gated. Default non-TUN builds must continue to compile. 8. When copying or modifying third-party source, record the upstream project and preserve all applicable license terms. Audit linked dependencies against the resolved release graph. Do not describe independent rewrites, protocol interoperability, or architectural references as derived source without evidence; Credits provide context and attribution, not a substitute for release license review. -9. Keep code, documentation, package identifiers, examples, and signing inputs host-neutral; application-specific names and credentials belong in downstream integrations. +9. Keep every public surface—code, documentation, tests, examples, commits, issues, pull requests, reviews, CI output, and releases—limited to VCore and public dependencies. Keep private downstream repository or product identities, links, implementation details, status, artifacts, and roadmaps outside this repository and its GitHub surfaces. Before publishing, search the staged diff and proposed GitHub text for downstream identifiers. # Validation From ac72335206b8df9dffcace9b1332bd47d655d277 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:41:45 +0800 Subject: [PATCH 4/5] fix(windows): preserve VPN route policy precedence --- docs/acceptance.md | 6 +- docs/tun-platform.md | 2 +- docs/windows-vpn.md | 10 +- src/windows/policy.rs | 93 ++++++--- src/windows/vpn.rs | 433 +++++++++++++++++++++++++++++++----------- 5 files changed, 392 insertions(+), 152 deletions(-) diff --git a/docs/acceptance.md b/docs/acceptance.md index fbd4d4e..948d324 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -24,7 +24,7 @@ - 规则、GeoData、HTTP/TLS/QUIC 嗅探; - ICMPv4/ICMPv6 Echo、校验和、分片、MTU 和队列满; - Apple/Android TUN 帧格式、文件描述符副本和关闭所有权; -- Windows 单 Application manifest、Provider/Session Host token 绑定、控制/数据协议、Session Snapshot v2、会合记录、包队列、批量写入、物理网络绑定和 Job Object 多进程监督; +- Windows 单 Application manifest、Provider/Session Host token 绑定、控制/数据协议、Session Snapshot v2、会合记录、包队列、批量写入、物理网络绑定、全部 on-link prefix 去重、显式排除优先的本地路由规划和 Job Object 多进程监督; - Controller 鉴权、速率和累计流量语义,以及代理组查询、实时选择和有界请求处理。 常用命令: @@ -127,12 +127,14 @@ vcore-uwp-demo.exe stop - 两种地址模式都观察到 Provider ingress/egress,显式 Stop 后 Session Host、route 和 interface 均归零; - `ipv6: false` 向 `StartWithMainTransport` 传 null IPv6 client-address 参数后通过;传空集合的对照包稳定返回 `0x8007000E`,且不启动 Session Host; - 主 transport 绑定当前物理地址后,目标 exclusion route 选择物理出口,而未排除的控制流量仍进入 VPN;回环绑定的对照包会使 exclusion traffic 超时; -- `allowLocalNetwork: true` 时实机局域网 peer 选择物理出口;设为 `false` 后,两个更具体的物理子网 inclusion route 使同一 peer 进入 VPN,并完成外网双向流量; +- `allowLocalNetwork: true` 时实机局域网 peer 选择物理出口;设为 `false` 后,Provider 为当前适配器全部去重的 on-link prefixes 安装更具体的 inclusion routes,路由总数由 6 增至 10,同一 peer 选择 `/25` VPN route,并完成外网双向流量; - profile 的 Always On capability 在连接时读回为启用,显式 Stop 后保持断开;随后用默认 policy 重建 profile 时读回为禁用; - 已有 profile/Snapshot 在前台启动命令退出、Provider/Session Host 均不存在时,由系统 profile connect 冷启动两个 native 进程并完成双向流量; - 类型错误的 policy 在 Provider 激活前被拒绝;运行中强制终止 Session Host 后 Provider 失败关闭,profile 变为 disconnected,route/interface 清零; - 每个 gate 结束时都确认无 Session Host、route 或 VPN interface 残留。Provider 的已停止 AppContainer 外壳可能暂留,验收脚本在各 case 之间显式结束它。 +路由规划修复后,同机开发签名包再次通过 IPv4-only/dual-stack lifecycle 与流量、LAN bypass、IPv4 exclusion/control、Always On、cold profile、失败关闭和零残留门禁。自动化同时覆盖多地址、多 IPv4/IPv6 on-link prefix、重复子网和显式 exclusion 与生成子前缀重叠;实机只探测到一个局域网 peer,未逐一验证每个 on-link prefix。 + 该记录证明当前机器上的 IPv4 物理出口和 Windows 分配的双栈 VPN interface;主机没有物理 IPv6/default gateway,因此真实 IPv6 exclusion 仍未执行。 Windows 路由必须保留两条 `/1`。在安装包环境中,单条 VPN `/0` 会使按产品要求绑定物理源地址和接口的外层 socket 返回 `WSAENETUNREACH`;两条 `/1` 不会产生该问题。 diff --git a/docs/tun-platform.md b/docs/tun-platform.md index 0c96f5c..f731f02 100644 --- a/docs/tun-platform.md +++ b/docs/tun-platform.md @@ -71,7 +71,7 @@ TCP 会话、普通 UDP 关联、半开连接和出站握手不设固定业务 ## 物理出口 - Android:每个出站 TCP/UDP socket 在 connect 前调用宿主 protect;失败则当前连接失败关闭。 -- Windows:Provider 为当前会话选择不可变的物理网络绑定,并把每个地址族的源 IP 和接口索引交给 Session Host。普通出站 socket 必须同时绑定源地址和 WinSock 接口索引。 +- Windows:Provider 为当前会话选择不可变的物理网络绑定;每个地址族只选择一个源 IP 和接口索引交给 Session Host,同时保留物理适配器全部去重的 on-link prefixes 用于 VPN 路由。普通出站 socket 必须同时绑定源地址和 WinSock 接口索引。 - Windows 只有配置中显式使用 `127.0.0.0/8` 范围内的 IPv4 字面量或 `::1` 的本地出站可以跳过物理绑定;物理代理服务器的域名解析到任何回环地址都会失败关闭。 - 物理适配器、地址或网络身份变化后,Provider 等待 2 秒消抖并停止会话,不迁移 socket 或自动回退。 diff --git a/docs/windows-vpn.md b/docs/windows-vpn.md index 6dca62f..d6f83af 100644 --- a/docs/windows-vpn.md +++ b/docs/windows-vpn.md @@ -95,8 +95,8 @@ IPv6: ::/1, 8000::/1 Windows profile 固定覆盖所有应用,不使用 AppTriggers、traffic filters 或流量身份。每次会话还应用完整 policy: - `allowLocalNetwork: true` 设置 `VpnRouteAssignment.SetExcludeLocalSubnets(true)`; -- `allowLocalNetwork: false` 清除该标志,并用物理子网的两个更具体子前缀覆盖本地 on-link route,避免 `/1` inclusion route 因优先级较低而旁路 VPN; -- `excludedCidrs` 按地址族加入 exclusion routes,不修改两条 `/1` inclusion routes; +- `allowLocalNetwork: false` 清除该标志,并为物理适配器的每个去重 on-link prefix 生成更具体的 inclusion routes,避免 `/1` inclusion route 因优先级较低而旁路 VPN; +- `excludedCidrs` 按地址族加入 exclusion routes,不修改两条 `/1` inclusion routes;生成本地 inclusion routes 前先减去全部显式排除范围,确保排除项不会被更具体的本地 inclusion 覆盖; - `alwaysOn` 写入 profile capability;实际自动连接仍由 Windows 用户设置和 active profile 决定。 排除项最多 64 条,必须是规范 network/prefix;重复、host bits、`/0`、禁用 IPv6 时的 IPv6 项和包含 VPN DNS 的项都会在接触 WinRT 前失败关闭。 @@ -118,8 +118,8 @@ Provider 在安装路由前选择不可变的: - 适配器 GUID; - 网络 profile 和 network identity; -- 每个可用地址族的源 IP 和 on-link prefix; -- 对应的非零接口索引。 +- 每个可用地址族选定的一个源 IP 和对应非零接口索引; +- 物理适配器上全部去重的 on-link prefixes。 Session Host 的每个非回环出站 socket 必须同时应用: @@ -133,7 +133,7 @@ Session Host 的每个非回环出站 socket 必须同时应用: ## 网络变化 -Provider 是物理网络状态的唯一权威,并订阅 `NetworkStatusChanged`。事件到达后等待 2 秒,再复验适配器 GUID、地址、on-link prefix 和 network identity;任一项变化就停止当前 VPN。 +Provider 是物理网络状态的唯一权威,并订阅 `NetworkStatusChanged`。事件到达后等待 2 秒,再复验适配器 GUID、选定源地址、全部去重 on-link prefixes 和 network identity;任一项变化就停止当前 VPN。 当前实现不迁移现有 socket、不重选接口,也不回退到未绑定 socket。Session Host 不自行更新物理绑定。 diff --git a/src/windows/policy.rs b/src/windows/policy.rs index 96c2fc1..32b5619 100644 --- a/src/windows/policy.rs +++ b/src/windows/policy.rs @@ -54,6 +54,32 @@ pub(crate) struct WindowsVpnCidr { } impl WindowsVpnCidr { + pub(crate) fn from_address(address: IpAddr, prefix_len: u8) -> Option { + let network = match address { + IpAddr::V4(address) if prefix_len <= 32 => { + let mask = if prefix_len == 0 { + 0 + } else { + u32::MAX << (32 - u32::from(prefix_len)) + }; + IpAddr::V4(Ipv4Addr::from(u32::from(address) & mask)) + } + IpAddr::V6(address) if prefix_len <= 128 => { + let mask = if prefix_len == 0 { + 0 + } else { + u128::MAX << (128 - u32::from(prefix_len)) + }; + IpAddr::V6(Ipv6Addr::from(u128::from(address) & mask)) + } + _ => return None, + }; + Some(Self { + network, + prefix_len, + }) + } + pub(crate) fn network(&self) -> IpAddr { self.network } @@ -62,18 +88,40 @@ impl WindowsVpnCidr { self.prefix_len } - fn contains(&self, address: IpAddr) -> bool { - match (self.network, address) { - (IpAddr::V4(network), IpAddr::V4(address)) => { - let mask = u32::MAX << (32 - u32::from(self.prefix_len)); - u32::from(address) & mask == u32::from(network) - } - (IpAddr::V6(network), IpAddr::V6(address)) => { - let mask = u128::MAX << (128 - u32::from(self.prefix_len)); - u128::from(address) & mask == u128::from(network) - } - _ => false, - } + pub(crate) fn contains(&self, address: IpAddr) -> bool { + Self::from_address(address, self.prefix_len) + .is_some_and(|cidr| cidr.network == self.network) + } + + pub(crate) fn contains_cidr(&self, other: &Self) -> bool { + self.prefix_len <= other.prefix_len && self.contains(other.network) + } + + pub(crate) fn overlaps(&self, other: &Self) -> bool { + self.contains_cidr(other) || other.contains_cidr(self) + } + + pub(crate) fn children(&self) -> Option<[Self; 2]> { + let child_prefix = self.prefix_len.checked_add(1)?; + let second = match self.network { + IpAddr::V4(network) if child_prefix <= 32 => IpAddr::V4(Ipv4Addr::from( + u32::from(network) | (1 << (32 - u32::from(child_prefix))), + )), + IpAddr::V6(network) if child_prefix <= 128 => IpAddr::V6(Ipv6Addr::from( + u128::from(network) | (1 << (128 - u32::from(child_prefix))), + )), + _ => return None, + }; + Some([ + Self { + network: self.network, + prefix_len: child_prefix, + }, + Self { + network: second, + prefix_len: child_prefix, + }, + ]) } } @@ -91,24 +139,13 @@ impl FromStr for WindowsVpnCidr { let prefix_len = prefix .parse::() .map_err(|_| "invalid Windows VPN exclusion CIDR")?; - let network = match address { - IpAddr::V4(address) if (1..=32).contains(&prefix_len) => { - let mask = u32::MAX << (32 - u32::from(prefix_len)); - IpAddr::V4(Ipv4Addr::from(u32::from(address) & mask)) - } - IpAddr::V6(address) if (1..=128).contains(&prefix_len) => { - let mask = u128::MAX << (128 - u32::from(prefix_len)); - IpAddr::V6(Ipv6Addr::from(u128::from(address) & mask)) - } - _ => return Err("invalid Windows VPN exclusion CIDR"), - }; - if address != network { + let cidr = Self::from_address(address, prefix_len) + .filter(|_| prefix_len != 0) + .ok_or("invalid Windows VPN exclusion CIDR")?; + if address != cidr.network { return Err("invalid Windows VPN exclusion CIDR"); } - Ok(Self { - network, - prefix_len, - }) + Ok(cidr) } } diff --git a/src/windows/vpn.rs b/src/windows/vpn.rs index 99325fd..53bf48f 100644 --- a/src/windows/vpn.rs +++ b/src/windows/vpn.rs @@ -63,7 +63,7 @@ use super::{ AddressBindingV4, AddressBindingV6, PacketCounters, PhysicalBinding, ProviderPacketSession, remove_rendezvous, }, - policy::WindowsVpnPolicy, + policy::{WindowsVpnCidr, WindowsVpnPolicy}, profile::{WindowsNetworkSettings, WindowsProfileConfiguration}, }; use crate::platform::{TunIo, WindowsPacketAdapter, WindowsPacketStats}; @@ -372,6 +372,77 @@ fn network_identity(profile: &ConnectionProfile) -> Result { }) } +#[derive(Debug)] +struct AdapterAddresses { + ipv4: Vec<(Ipv4Addr, u8)>, + ipv6: Vec<(Ipv6Addr, u8)>, +} + +fn adapter_addresses(adapter_id: GUID) -> Result { + let host_names = NetworkInformation::GetHostNames()?; + let mut ipv4 = Vec::new(); + let mut ipv6 = Vec::new(); + + for index in 0..host_names.Size()? { + let host = host_names.GetAt(index)?; + let Ok(information) = host.IPInformation() else { + continue; + }; + let Ok(adapter) = information.NetworkAdapter() else { + continue; + }; + if adapter.NetworkAdapterId()? != adapter_id { + continue; + } + let Ok(prefix) = information.PrefixLength().and_then(|prefix| prefix.Value()) else { + continue; + }; + match host.Type()? { + HostNameType::Ipv4 => { + let Ok(address) = host.CanonicalName()?.to_string().parse::() else { + continue; + }; + if !address.is_loopback() + && !address.is_link_local() + && !address.is_unspecified() + && prefix <= 32 + { + ipv4.push((address, prefix)); + } + } + HostNameType::Ipv6 => { + let Ok(address) = host.CanonicalName()?.to_string().parse::() else { + continue; + }; + if !address.is_loopback() + && !address.is_unicast_link_local() + && !address.is_unspecified() + && !address.is_multicast() + && prefix <= 128 + { + ipv6.push((address, prefix)); + } + } + _ => {} + } + } + + ipv4.sort_unstable(); + ipv4.dedup(); + ipv6.sort_unstable(); + ipv6.dedup(); + Ok(AdapterAddresses { ipv4, ipv6 }) +} + +fn physical_subnets(addresses: impl Iterator) -> Vec { + let mut subnets = addresses + .filter_map(|(address, prefix)| WindowsVpnCidr::from_address(address, prefix)) + .collect::>(); + subnets.sort_unstable(); + subnets.dedup(); + subnets +} + #[derive(Debug, Clone)] struct PhysicalNetwork { adapter_id: GUID, @@ -380,6 +451,8 @@ struct PhysicalNetwork { ipv6: Option, ipv4_prefix: Option, ipv6_prefix: Option, + ipv4_subnets: Vec, + ipv6_subnets: Vec, ipv4_index: Option, ipv6_index: Option, } @@ -389,66 +462,27 @@ impl PhysicalNetwork { let profile = NetworkInformation::GetInternetConnectionProfile()?; let adapter_id = profile.NetworkAdapter()?.NetworkAdapterId()?; let identity = network_identity(&profile)?; - let host_names = NetworkInformation::GetHostNames()?; - let mut ipv4 = Vec::new(); - let mut ipv6 = Vec::new(); - - for index in 0..host_names.Size()? { - let host = host_names.GetAt(index)?; - let Ok(information) = host.IPInformation() else { - continue; - }; - let Ok(adapter) = information.NetworkAdapter() else { - continue; - }; - if adapter.NetworkAdapterId()? != adapter_id { - continue; - } - let Ok(prefix) = information.PrefixLength().and_then(|prefix| prefix.Value()) else { - continue; - }; - match host.Type()? { - HostNameType::Ipv4 => { - let Ok(address) = host.CanonicalName()?.to_string().parse::() else { - continue; - }; - if !address.is_loopback() - && !address.is_link_local() - && !address.is_unspecified() - && prefix <= 32 - { - ipv4.push((address, prefix)); - } - } - HostNameType::Ipv6 => { - let Ok(address) = host.CanonicalName()?.to_string().parse::() else { - continue; - }; - if !address.is_loopback() - && !address.is_unicast_link_local() - && !address.is_unspecified() - && !address.is_multicast() - && prefix <= 128 - { - ipv6.push((address, prefix)); - } - } - _ => {} - } - } - - ipv4.sort_unstable(); - ipv4.dedup(); - ipv6.sort_unstable(); - ipv6.dedup(); - if ipv4.is_empty() && ipv6.is_empty() { + let addresses = adapter_addresses(adapter_id)?; + if addresses.ipv4.is_empty() && addresses.ipv6.is_empty() { return Err(Error::new( E_FAIL, "physical network has no usable IP address", )); } - let ipv4 = ipv4.into_iter().next(); - let ipv6 = ipv6.into_iter().next(); + let ipv4_subnets = physical_subnets( + addresses + .ipv4 + .iter() + .map(|&(address, prefix)| (IpAddr::V4(address), prefix)), + ); + let ipv6_subnets = physical_subnets( + addresses + .ipv6 + .iter() + .map(|&(address, prefix)| (IpAddr::V6(address), prefix)), + ); + let ipv4 = addresses.ipv4.first().copied(); + let ipv6 = addresses.ipv6.first().copied(); let ipv4_prefix = ipv4.map(|(_, prefix)| prefix); let ipv6_prefix = ipv6.map(|(_, prefix)| prefix); let ipv4 = ipv4.map(|(address, _)| address); @@ -473,6 +507,8 @@ impl PhysicalNetwork { ipv6, ipv4_prefix, ipv6_prefix, + ipv4_subnets, + ipv6_subnets, ipv4_index, ipv6_index, }) @@ -501,40 +537,32 @@ impl PhysicalNetwork { } fn is_available(&self) -> Result { - let host_names = NetworkInformation::GetHostNames()?; - let mut ipv4_found = self.ipv4.is_none(); - let mut ipv6_found = self.ipv6.is_none(); - for index in 0..host_names.Size()? { - let host = host_names.GetAt(index)?; - let Ok(information) = host.IPInformation() else { - continue; - }; - let Ok(adapter) = information.NetworkAdapter() else { - continue; - }; - if adapter.NetworkAdapterId()? != self.adapter_id { - continue; - } - let Ok(prefix) = information.PrefixLength().and_then(|prefix| prefix.Value()) else { - continue; - }; - let canonical = host.CanonicalName()?.to_string(); - ipv4_found |= - self.ipv4 - .zip(self.ipv4_prefix) - .is_some_and(|(expected, expected_prefix)| { - prefix == expected_prefix - && canonical.parse::().is_ok_and(|ip| ip == expected) - }); - ipv6_found |= - self.ipv6 - .zip(self.ipv6_prefix) - .is_some_and(|(expected, expected_prefix)| { - prefix == expected_prefix - && canonical.parse::().is_ok_and(|ip| ip == expected) - }); - } - if !ipv4_found || !ipv6_found { + let addresses = adapter_addresses(self.adapter_id)?; + let ipv4_found = self + .ipv4 + .zip(self.ipv4_prefix) + .is_none_or(|expected| addresses.ipv4.contains(&expected)); + let ipv6_found = self + .ipv6 + .zip(self.ipv6_prefix) + .is_none_or(|expected| addresses.ipv6.contains(&expected)); + let ipv4_subnets = physical_subnets( + addresses + .ipv4 + .iter() + .map(|&(address, prefix)| (IpAddr::V4(address), prefix)), + ); + let ipv6_subnets = physical_subnets( + addresses + .ipv6 + .iter() + .map(|&(address, prefix)| (IpAddr::V6(address), prefix)), + ); + if !ipv4_found + || !ipv6_found + || ipv4_subnets != self.ipv4_subnets + || ipv6_subnets != self.ipv6_subnets + { return Ok(false); } @@ -970,6 +998,49 @@ extern "system" fn DllGetActivationFactory( .unwrap_or(E_FAIL) } +fn subtract_exclusion( + route: WindowsVpnCidr, + exclusion: &WindowsVpnCidr, + remaining: &mut Vec, +) { + if !route.overlaps(exclusion) { + remaining.push(route); + } else if !exclusion.contains_cidr(&route) + && let Some(children) = route.children() + { + for child in children { + subtract_exclusion(child, exclusion, remaining); + } + } +} + +fn local_inclusion_routes( + ipv6: bool, + policy: &WindowsVpnPolicy, + physical: &PhysicalNetwork, +) -> Vec { + let mut routes = Vec::new(); + for subnet in physical + .ipv4_subnets + .iter() + .chain(physical.ipv6_subnets.iter().filter(|_| ipv6)) + { + if let Some(children) = subnet.children() { + routes.extend(children); + } + } + for exclusion in policy.excluded_cidrs() { + let mut remaining = Vec::new(); + for route in routes.drain(..) { + subtract_exclusion(route, exclusion, &mut remaining); + } + routes = remaining; + } + routes.sort_unstable(); + routes.dedup(); + routes +} + fn vpn_routes( ipv6: bool, policy: &WindowsVpnPolicy, @@ -996,32 +1067,14 @@ fn vpn_routes( } } if !policy.allow_local_network() { - if let (Some(address), Some(prefix @ 1..=31)) = (physical.ipv4, physical.ipv4_prefix) { - let child_prefix = prefix + 1; - let network = u32::from(address) & (u32::MAX << (32 - u32::from(prefix))); - let second = network | (1 << (32 - u32::from(child_prefix))); - for network in [network, second].map(Ipv4Addr::from) { - routes - .Ipv4InclusionRoutes()? - .Append(&VpnRoute::CreateVpnRoute( - &HostName::CreateHostName(&network.to_string().into())?, - child_prefix, - )?)?; - } - } - if ipv6 - && let (Some(address), Some(prefix @ 1..=127)) = (physical.ipv6, physical.ipv6_prefix) - { - let child_prefix = prefix + 1; - let network = u128::from(address) & (u128::MAX << (128 - u32::from(prefix))); - let second = network | (1 << (128 - u32::from(child_prefix))); - for network in [network, second].map(Ipv6Addr::from) { - routes - .Ipv6InclusionRoutes()? - .Append(&VpnRoute::CreateVpnRoute( - &HostName::CreateHostName(&network.to_string().into())?, - child_prefix, - )?)?; + for cidr in local_inclusion_routes(ipv6, policy, physical) { + let route = VpnRoute::CreateVpnRoute( + &HostName::CreateHostName(&cidr.network().to_string().into())?, + cidr.prefix_len(), + )?; + match cidr.network() { + IpAddr::V4(_) => routes.Ipv4InclusionRoutes()?.Append(&route)?, + IpAddr::V6(_) => routes.Ipv6InclusionRoutes()?.Append(&route)?, } } } @@ -1173,11 +1226,35 @@ mod tests { ipv6: ipv6.map(|(address, _)| address), ipv4_prefix: ipv4.map(|(_, prefix)| prefix), ipv6_prefix: ipv6.map(|(_, prefix)| prefix), + ipv4_subnets: physical_subnets( + ipv4.into_iter() + .map(|(address, prefix)| (IpAddr::V4(address), prefix)), + ), + ipv6_subnets: physical_subnets( + ipv6.into_iter() + .map(|(address, prefix)| (IpAddr::V6(address), prefix)), + ), ipv4_index: None, ipv6_index: None, } } + fn physical_network_with_subnets( + ipv4: &[(Ipv4Addr, u8)], + ipv6: &[(Ipv6Addr, u8)], + ) -> PhysicalNetwork { + let mut physical = physical_network(ipv4.first().copied(), ipv6.first().copied()); + physical.ipv4_subnets = physical_subnets( + ipv4.iter() + .map(|&(address, prefix)| (IpAddr::V4(address), prefix)), + ); + physical.ipv6_subnets = physical_subnets( + ipv6.iter() + .map(|&(address, prefix)| (IpAddr::V6(address), prefix)), + ); + physical + } + #[test] fn adapter_name_matches_winrt_network_adapter_id() { let id = GUID::from_u128(0xc2afe445_9ed9_423d_8c29_6b2cd49691d2); @@ -1211,6 +1288,31 @@ mod tests { assert_ne!(first, other); } + #[test] + fn physical_subnets_normalize_and_deduplicate_addresses() { + let subnets = physical_subnets( + [ + (IpAddr::V4(Ipv4Addr::new(198, 51, 100, 129)), 24), + (IpAddr::V4(Ipv4Addr::new(198, 51, 100, 200)), 24), + (IpAddr::V4(Ipv4Addr::new(198, 51, 100, 200)), 25), + (IpAddr::V6("2001:db8:1::2".parse().unwrap()), 64), + (IpAddr::V6("2001:db8:1::3".parse().unwrap()), 64), + (IpAddr::V6("2001:db8:2::2".parse().unwrap()), 64), + ] + .into_iter(), + ); + + assert_eq!( + subnets.iter().map(ToString::to_string).collect::>(), + [ + "198.51.100.0/24", + "198.51.100.128/25", + "2001:db8:1::/64", + "2001:db8:2::/64", + ] + ); + } + #[test] fn provider_assignments_use_profile_addresses() { let _winrt = WinRtGuard::enter(); @@ -1323,6 +1425,105 @@ mod tests { assert_eq!(ipv6.PrefixSize().unwrap(), 64); } + #[test] + fn provider_routes_cover_every_physical_subnet() { + let _winrt = WinRtGuard::enter(); + let digest = "0123456789abcdef".repeat(4); + let profile = WindowsProfileConfiguration::parse(&format!( + r#"{{"version":4,"snapshotToken":"vcore-session-v2:{digest}","ipv6":true,"networkSettings":{{"ipv4Address":"192.168.8.1","ipv6Address":"fd00:8::2","dnsIpv4Address":"223.5.5.5","dnsIpv6Address":"2400:3200::1"}},"policy":{{"alwaysOn":false,"allowLocalNetwork":false,"excludedCidrs":[]}}}}"# + )) + .unwrap(); + let physical = physical_network_with_subnets( + &[ + (Ipv4Addr::new(198, 51, 100, 129), 24), + (Ipv4Addr::new(198, 51, 100, 200), 24), + (Ipv4Addr::new(203, 0, 113, 129), 24), + ], + &[ + ("2001:db8:1::2".parse().unwrap(), 64), + ("2001:db8:1::3".parse().unwrap(), 64), + ("2001:db8:2::2".parse().unwrap(), 64), + ], + ); + + let routes = vpn_routes(profile.ipv6_enabled(), profile.policy(), &physical).unwrap(); + let ipv4 = routes.Ipv4InclusionRoutes().unwrap(); + assert_eq!(ipv4.Size().unwrap(), 6); + assert_eq!( + ipv4.GetAt(4) + .unwrap() + .Address() + .unwrap() + .DisplayName() + .unwrap(), + "203.0.113.0" + ); + let ipv6 = routes.Ipv6InclusionRoutes().unwrap(); + assert_eq!(ipv6.Size().unwrap(), 6); + assert_eq!( + ipv6.GetAt(4) + .unwrap() + .Address() + .unwrap() + .DisplayName() + .unwrap(), + "2001:db8:2::" + ); + } + + #[test] + fn provider_local_routes_subtract_explicit_exclusions() { + let _winrt = WinRtGuard::enter(); + let digest = "0123456789abcdef".repeat(4); + let profile = WindowsProfileConfiguration::parse(&format!( + r#"{{"version":4,"snapshotToken":"vcore-session-v2:{digest}","ipv6":true,"networkSettings":{{"ipv4Address":"192.168.8.1","ipv6Address":"fd00:8::2","dnsIpv4Address":"223.5.5.5","dnsIpv6Address":"2400:3200::1"}},"policy":{{"alwaysOn":false,"allowLocalNetwork":false,"excludedCidrs":["198.51.100.0/25","198.51.100.192/26","2001:db8:1::/65","2001:db8:1:0:c000::/66"]}}}}"# + )) + .unwrap(); + let physical = physical_network( + Some((Ipv4Addr::new(198, 51, 100, 129), 24)), + Some(("2001:db8:1::2".parse().unwrap(), 64)), + ); + + let local = local_inclusion_routes(profile.ipv6_enabled(), profile.policy(), &physical); + assert_eq!( + local.iter().map(ToString::to_string).collect::>(), + ["198.51.100.128/26", "2001:db8:1:0:8000::/66"] + ); + assert!(local.iter().all(|route| { + profile + .policy() + .excluded_cidrs() + .iter() + .all(|excluded| !route.overlaps(excluded)) + })); + + let routes = vpn_routes(profile.ipv6_enabled(), profile.policy(), &physical).unwrap(); + let ipv4 = routes.Ipv4InclusionRoutes().unwrap(); + assert_eq!(ipv4.Size().unwrap(), 3); + assert_eq!( + ipv4.GetAt(2) + .unwrap() + .Address() + .unwrap() + .DisplayName() + .unwrap(), + "198.51.100.128" + ); + assert_eq!(ipv4.GetAt(2).unwrap().PrefixSize().unwrap(), 26); + let ipv6 = routes.Ipv6InclusionRoutes().unwrap(); + assert_eq!(ipv6.Size().unwrap(), 3); + assert_eq!( + ipv6.GetAt(2) + .unwrap() + .Address() + .unwrap() + .DisplayName() + .unwrap(), + "2001:db8:1:0:8000::" + ); + assert_eq!(ipv6.GetAt(2).unwrap().PrefixSize().unwrap(), 66); + } + #[test] fn provider_omits_ipv6_assignments_when_disabled() { let _winrt = WinRtGuard::enter(); From 080029868f054cec652dcf6a8f9e5902e5ae201d Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:07:02 +0800 Subject: [PATCH 5/5] fix(windows): align route and UDP MTU policy --- README.md | 2 +- docs/acceptance.md | 12 +- docs/invoke-api.md | 4 +- docs/runtime-resource-policy.md | 6 +- docs/tun-icmp-dns.md | 2 +- docs/tun-platform.md | 6 +- docs/windows-session-runtime.md | 5 +- docs/windows-vpn.md | 8 +- src/session.rs | 28 ++- src/tun_runtime.rs | 49 +++-- src/windows/vpn.rs | 362 ++++++++++++++++++++------------ 11 files changed, 320 insertions(+), 164 deletions(-) diff --git a/README.md b/README.md index c2ad86b..a354788 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ DNS opaque cache 64 entries / 256 KiB GeoData allocation capacity 8 MiB ``` -Windows advertises a 1,400-byte L3 MTU as required by `StartWithMainTransport`; 1,500 bytes remains the cross-platform parser ceiling. +Windows advertises a 1,400-byte L3 MTU as required by `StartWithMainTransport` and caps TUN UDP responses at 1,352 bytes; 1,500 bytes remain the cross-platform parser ceiling. TCP sessions, ordinary UDP associations, half-open connections, outbound handshakes, and active DNS transports are created on demand. Bounded queues, per-flow buffers, wire/parser limits, timeouts, idle cleanup, and caches provide structural safety. The iOS 35/45 MiB targets are best-effort observations and do not change lifecycle results. diff --git a/docs/acceptance.md b/docs/acceptance.md index 948d324..7612b2f 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -24,7 +24,7 @@ - 规则、GeoData、HTTP/TLS/QUIC 嗅探; - ICMPv4/ICMPv6 Echo、校验和、分片、MTU 和队列满; - Apple/Android TUN 帧格式、文件描述符副本和关闭所有权; -- Windows 单 Application manifest、Provider/Session Host token 绑定、控制/数据协议、Session Snapshot v2、会合记录、包队列、批量写入、物理网络绑定、全部 on-link prefix 去重、显式排除优先的本地路由规划和 Job Object 多进程监督; +- Windows 单 Application manifest、Provider/Session Host token 绑定、控制/数据协议、Session Snapshot v2、会合记录、包队列、批量写入、物理网络绑定、link-local 与普通 on-link prefix 的独立收集/去重、显式排除优先的本地路由规划、按有效 MTU 计算的 UDP/DNS 响应上限和 Job Object 多进程监督; - Controller 鉴权、速率和累计流量语义,以及代理组查询、实时选择和有界请求处理。 常用命令: @@ -135,7 +135,15 @@ vcore-uwp-demo.exe stop 路由规划修复后,同机开发签名包再次通过 IPv4-only/dual-stack lifecycle 与流量、LAN bypass、IPv4 exclusion/control、Always On、cold profile、失败关闭和零残留门禁。自动化同时覆盖多地址、多 IPv4/IPv6 on-link prefix、重复子网和显式 exclusion 与生成子前缀重叠;实机只探测到一个局域网 peer,未逐一验证每个 on-link prefix。 -该记录证明当前机器上的 IPv4 物理出口和 Windows 分配的双栈 VPN interface;主机没有物理 IPv6/default gateway,因此真实 IPv6 exclusion 仍未执行。 +PR 审查修复后的当前源树再次构建 ARM64 release artifacts,打入同一单 Application 开发签名 MSIX,并通过 `Add-AppxPackage` 安装。使用 bridge `start/status/stop`、`curl`、有接收超时的 UDP probe、`ping.exe`、`Clear-DnsClientCache` 和 `Resolve-DnsName` 实际执行: + +- `ipv6: false` 与 `ipv6: true` 都通过 TCP、UDP、ICMPv4 和清缓存 hostname 请求;dual-stack 还通过 ICMPv6; +- 两种地址模式在 `dns.enable: true` 和 `false` 下都通过清缓存 hostname 请求; +- 物理 IPv6 link-local 只进入 on-link prefix 库,不成为 Session Host socket 源地址;`allowLocalNetwork: false` 的 dual-stack case 实际观察到两条对应的更具体 inclusion routes,并保持 TCP、UDP、ICMPv4/ICMPv6 和 hostname 流量; +- LAN allow/block 与 IPv4 exclusion/control 再次通过;每个 case 均观察到 Provider 和 Session Host 激活、稳定 connected、显式 Stop、disconnected,以及 Provider 外壳清理后的零进程、零 route、零 VPN interface; +- 1400 MTU 对应的 1352 字节 TUN/XUDP 与 DNS UDP 响应上限由当前自动化覆盖;本次实包 probe 验证正常大小 UDP 数据面,没有把超限数据报写成成功传输。 + +该记录证明当前机器上的 IPv4 物理出口、物理 IPv6 link-local route inventory 和 Windows 分配的双栈 VPN interface;主机没有物理 IPv6/default gateway,因此真实 IPv6 exclusion 仍未执行。 Windows 路由必须保留两条 `/1`。在安装包环境中,单条 VPN `/0` 会使按产品要求绑定物理源地址和接口的外层 socket 返回 `WSAENETUNREACH`;两条 `/1` 不会产生该问题。 diff --git a/docs/invoke-api.md b/docs/invoke-api.md index 577ff58..530c3f9 100644 --- a/docs/invoke-api.md +++ b/docs/invoke-api.md @@ -372,7 +372,7 @@ ProtectFd(fd) -> bool - 所有 JSON DTO 拒绝未知字段。 - 配置、错误和日志按 UTF-8 字节计数并受固定上限约束。 -- TUN 原始数据包最大 1,500 字节;最终代理 UDP 负载最大 1,452 字节。 -- 嵌套 UDP 协议可以增加有界帧头,但解封装后的最终负载仍受 1,452 字节限制。 +- TUN 原始数据包解析最大 1,500 字节;最终代理 UDP 响应按有效 MTU 减去 48 字节限制,其他 TUN 平台最大 1,452 字节,Windows 最大 1,352 字节。 +- 嵌套 UDP 协议可以增加有界帧头,但解封装后的最终负载仍受该平台响应上限限制。 - 节点和代理组定义名共享大小写敏感的严格 UTF-8 命名空间;`DIRECT`、`REJECT` 和 `RULES` 不能用作定义名。 - Secret、password、UUID、REALITY key、short ID、目标地址和完整配置不得进入日志。 diff --git a/docs/runtime-resource-policy.md b/docs/runtime-resource-policy.md index e8fbff3..b9deaec 100644 --- a/docs/runtime-resource-policy.md +++ b/docs/runtime-resource-policy.md @@ -15,7 +15,7 @@ ```text 原始包 / MTU 1,500 字节 -最终代理 UDP 负载 1,452 字节 +最终代理 UDP 负载 1,452 字节(Windows 1,352) 包队列 256 普通事件 / UDP 响应 128 DNS 入站 / DNS 响应 128 / 128 @@ -27,7 +27,7 @@ TUN 域名提示 256 项(按需) GeoData 分配容量 8 MiB ``` -Windows L3 接口及其 Session Host netstack 使用 1400 MTU;表中的 1500 是跨平台原始包解析上限和其他 TUN 平台的固定 MTU。 +Windows L3 接口及其 Session Host netstack 使用 1400 MTU,因此按 IPv6 UDP 头保守计算的响应负载上限是 1352;表中的 1500 是跨平台原始包解析上限和其他 TUN 平台的固定 MTU。 - DNS 响应和普通 UDP 响应使用不同队列,但共享 netstack UDP 入站接收器。 - TUN 域名提示只在 TUN 配置实际包含域名规则时创建,并从空容量按需增长。 @@ -54,7 +54,7 @@ Windows L3 接口及其 Session Host netstack 使用 1400 MTU;表中的 1500 - 只有成功入队的请求或响应刷新活动时间; - 父运行时停止时删除、取消并等待全部子任务。 -嵌套代理协议可以增加有界帧头,但最终解封装负载仍不得超过调用方给出的上限;TUN 最终负载上限为 1,452 字节。 +嵌套代理协议可以增加有界帧头,但最终解封装负载仍不得超过调用方按有效 MTU 给出的上限;其他 TUN 平台为 1,452 字节,Windows 为 1,352 字节。 ## DNS diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index c946cdf..5855a52 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -151,7 +151,7 @@ Controller 切换代理组后,只在后续需要创建新 DNS transport 时使 | DNS 响应 | 128 | | 普通 UDP 响应 | 128 | -DNS 和普通 UDP 响应使用不同队列,但共享 netstack UDP 入站接收器。队列满时只丢当前请求或响应,不阻塞全局 UDP 循环。TUN UDP 响应受 1,452 字节负载上限约束。 +DNS 和普通 UDP 响应使用不同队列,但共享 netstack UDP 入站接收器。队列满时只丢当前请求或响应,不阻塞全局 UDP 循环。TUN UDP 响应按有效 MTU 减去 48 字节保守限制;其他平台为 1,452 字节,Windows 为 1,352 字节。 运行时停止会取消 open、send、receive、retry 和 response-send,释放全部传输并等待已跟踪任务结束。停止返回后不得再向 TUN 回包。 diff --git a/docs/tun-platform.md b/docs/tun-platform.md index f731f02..e413d47 100644 --- a/docs/tun-platform.md +++ b/docs/tun-platform.md @@ -60,7 +60,7 @@ Windows 使用 `Windows.Networking.Vpn` 回调,不使用文件描述符或适 ```text 原始 TUN 包 1,500 字节 -最终代理 UDP 负载 1,452 字节 +最终代理 UDP 负载 1,452 字节(Windows 1,352) 包队列 256 普通事件 / UDP 响应 128 DNS 入站 / DNS 响应 128 / 128 @@ -71,8 +71,8 @@ TCP 会话、普通 UDP 关联、半开连接和出站握手不设固定业务 ## 物理出口 - Android:每个出站 TCP/UDP socket 在 connect 前调用宿主 protect;失败则当前连接失败关闭。 -- Windows:Provider 为当前会话选择不可变的物理网络绑定;每个地址族只选择一个源 IP 和接口索引交给 Session Host,同时保留物理适配器全部去重的 on-link prefixes 用于 VPN 路由。普通出站 socket 必须同时绑定源地址和 WinSock 接口索引。 +- Windows:Provider 为当前会话选择不可变的物理网络绑定;每个地址族只从非 link-local 地址中选择一个源 IP 和接口索引交给 Session Host,同时独立保留物理适配器全部去重的 on-link prefixes(包括 link-local)用于 VPN 路由。普通出站 socket 必须同时绑定源地址和 WinSock 接口索引。 - Windows 只有配置中显式使用 `127.0.0.0/8` 范围内的 IPv4 字面量或 `::1` 的本地出站可以跳过物理绑定;物理代理服务器的域名解析到任何回环地址都会失败关闭。 -- 物理适配器、地址或网络身份变化后,Provider 等待 2 秒消抖并停止会话,不迁移 socket 或自动回退。 +- 物理适配器、选定源地址、全部 on-link prefixes 或网络身份变化后,Provider 等待 2 秒消抖并停止会话,不迁移 socket 或自动回退。 主机测试只能证明帧、所有权、队列和生命周期逻辑;平台实测范围见 [验收矩阵](acceptance.md)。 diff --git a/docs/windows-session-runtime.md b/docs/windows-session-runtime.md index 97ad811..1bfb5fa 100644 --- a/docs/windows-session-runtime.md +++ b/docs/windows-session-runtime.md @@ -164,11 +164,12 @@ Provider 选择并持有: - 适配器 GUID; - profile 和 network identity; -- 可用地址族的源 IP 和非零接口索引。 +- 可用地址族从非 link-local 地址中选出的源 IP 和非零接口索引; +- 独立于源地址选择保留的全部去重 on-link prefixes,包括 link-local。 Session Host 只消费这份不可变绑定。每个非回环 socket 必须同时设置源地址和接口选项。只有配置中显式使用 `127.0.0.0/8` 范围内的 IPv4 字面量或 `::1` 的本地出站可以跳过;物理代理服务器的域名在准备阶段解析到任何回环地址都会失败关闭。地址族缺失、bind 失败或 setsockopt 失败时,当前连接失败关闭。 -Provider 订阅网络变化,等待 2 秒消抖后复验适配器、地址和 identity。任一变化就停止会话,不迁移 socket、不重选网卡、不自动回退。 +Provider 订阅网络变化,等待 2 秒消抖后复验适配器、选定源地址、全部 on-link prefixes 和 identity。任一变化就停止会话,不迁移 socket、不重选网卡、不自动回退。 ## Windows session backend diff --git a/docs/windows-vpn.md b/docs/windows-vpn.md index d6f83af..1fb8d76 100644 --- a/docs/windows-vpn.md +++ b/docs/windows-vpn.md @@ -66,7 +66,7 @@ u16 大端序包长 - Provider 两侧包队列容量均为 256;从空变为非空时只发一次唤醒; - `Decapsulate` 每次排空当前已就绪队列,队列满按包计数。 -`StartWithMainTransport` 按 WinRT 契约使用 1400 MTU 和 1412 最大 frame;Session Host netstack 同样使用 1400 MTU。packet channel 的 1500 上限仍是帧解析的结构边界,不是 Windows L3 接口宣告值。 +`StartWithMainTransport` 按 WinRT 契约使用 1400 MTU 和 1412 最大 frame;Session Host netstack 同样使用 1400 MTU,并把 TUN/XUDP 与 DNS UDP 响应负载保守限制为 MTU 减 48,即 1352 字节。packet channel 的 1500 上限仍是帧解析的结构边界,不是 Windows L3 接口宣告值。 控制消息使用独立管道,避免包背压阻塞启动和停止。 @@ -95,7 +95,7 @@ IPv6: ::/1, 8000::/1 Windows profile 固定覆盖所有应用,不使用 AppTriggers、traffic filters 或流量身份。每次会话还应用完整 policy: - `allowLocalNetwork: true` 设置 `VpnRouteAssignment.SetExcludeLocalSubnets(true)`; -- `allowLocalNetwork: false` 清除该标志,并为物理适配器的每个去重 on-link prefix 生成更具体的 inclusion routes,避免 `/1` inclusion route 因优先级较低而旁路 VPN; +- `allowLocalNetwork: false` 清除该标志,并为物理适配器的每个去重 on-link prefix(包括 link-local)生成更具体的 inclusion routes,避免 `/1` inclusion route 因优先级较低而旁路 VPN; - `excludedCidrs` 按地址族加入 exclusion routes,不修改两条 `/1` inclusion routes;生成本地 inclusion routes 前先减去全部显式排除范围,确保排除项不会被更具体的本地 inclusion 覆盖; - `alwaysOn` 写入 profile capability;实际自动连接仍由 Windows 用户设置和 active profile 决定。 @@ -118,8 +118,8 @@ Provider 在安装路由前选择不可变的: - 适配器 GUID; - 网络 profile 和 network identity; -- 每个可用地址族选定的一个源 IP 和对应非零接口索引; -- 物理适配器上全部去重的 on-link prefixes。 +- 每个可用地址族从非 link-local 地址中选定的一个源 IP 和对应非零接口索引; +- 独立于源地址选择保留的、物理适配器上全部去重的 on-link prefixes,包括 link-local。 Session Host 的每个非回环出站 socket 必须同时应用: diff --git a/src/session.rs b/src/session.rs index bf5e870..19054c8 100644 --- a/src/session.rs +++ b/src/session.rs @@ -2,7 +2,9 @@ use std::{fmt, net::SocketAddr, sync::Arc}; use bytes::Bytes; -const TUN_XUDP_MAX_RESPONSE_PAYLOAD_SIZE: u16 = 1_452; +const DEFAULT_TUN_MTU: usize = 1_500; +const IPV6_UDP_OVERHEAD: usize = 48; +const TUN_XUDP_MAX_RESPONSE_PAYLOAD_SIZE: u16 = (DEFAULT_TUN_MTU - IPV6_UDP_OVERHEAD) as u16; const INTERNAL_DNS_XUDP_MAX_RESPONSE_PAYLOAD_SIZE: u16 = 4_096; const XUDP_MAX_RESPONSE_PAYLOAD_SIZE: u16 = u16::MAX; @@ -130,8 +132,9 @@ pub struct DatagramSession { impl DatagramSession { /// Builds a UDP association with an inbound-specific XUDP response limit. /// - /// A TUN response must fit the conservative IPv6 UDP payload budget for a - /// 1500-byte MTU. Internal DNS is capped at the DNS wire-message ceiling, + /// The generic TUN default uses the conservative IPv6 UDP payload budget + /// for a 1500-byte MTU. TUN runtimes with a lower effective MTU use + /// [`Self::for_tun`]. Internal DNS is capped at the DNS wire-message ceiling, /// while proxy inbounds retain the full XUDP wire payload range. #[must_use] pub const fn new(inbound: InboundKind, source: SocketAddr) -> Self { @@ -149,6 +152,21 @@ impl DatagramSession { } } + /// Builds a TUN UDP association whose response budget follows its effective MTU. + #[must_use] + pub(crate) const fn for_tun(source: SocketAddr, mtu: usize) -> Self { + let payload_size = mtu.saturating_sub(IPV6_UDP_OVERHEAD); + Self { + inbound: InboundKind::Tun, + source, + max_response_payload_size: if payload_size > u16::MAX as usize { + u16::MAX + } else { + payload_size as u16 + }, + } + } + #[must_use] #[cfg_attr(not(feature = "outbound-vless"), allow(dead_code))] pub(crate) const fn max_response_payload_size(&self) -> u16 { @@ -199,6 +217,10 @@ mod tests { DatagramSession::new(InboundKind::Tun, source).max_response_payload_size(), TUN_XUDP_MAX_RESPONSE_PAYLOAD_SIZE ); + assert_eq!( + DatagramSession::for_tun(source, 1_400).max_response_payload_size(), + 1_352 + ); assert_eq!( DatagramSession::new(InboundKind::Http, source).max_response_payload_size(), XUDP_MAX_RESPONSE_PAYLOAD_SIZE diff --git a/src/tun_runtime.rs b/src/tun_runtime.rs index 3f7ddbe..b35539e 100644 --- a/src/tun_runtime.rs +++ b/src/tun_runtime.rs @@ -64,6 +64,10 @@ const TUN_NETSTACK_STATS_FINAL_EVENT: &str = "tun_netstack_stats_final"; static NEXT_DIAGNOSTIC_SESSION_ID: AtomicU64 = AtomicU64::new(1); +fn effective_tun_mtu(limits: ResourceLimits) -> usize { + TUN_MTU.min(limits.tun_max_datagram_size) +} + fn tun_udp_ingress_queue_capacity(limits: ResourceLimits, dns_enabled: bool) -> usize { if dns_enabled { limits.tun_dns_ingress_queue_capacity @@ -78,7 +82,7 @@ fn tun_netstack_config( fake_icmp_echo: bool, ) -> NetStackConfig { NetStackConfig { - mtu: TUN_MTU.min(limits.tun_max_datagram_size), + mtu: effective_tun_mtu(limits), packet_queue: limits.packet_queue_capacity, tcp_accept_queue: limits.event_queue_capacity, udp_queue: tun_udp_ingress_queue_capacity(limits, dns_enabled), @@ -757,6 +761,7 @@ async fn udp_loop( resource_stats: RuntimeResourceStats, cancellation: CancellationToken, ) -> io::Result<()> { + let tun_mtu = effective_tun_mtu(limits); let association_queue = limits .event_queue_capacity .clamp(1, UDP_ASSOCIATION_QUEUE_MAX); @@ -845,6 +850,7 @@ async fn udp_loop( permit, datagram, dns_responses_tx.clone(), + tun_mtu, resource_stats.clone(), cancellation.clone(), )); @@ -876,6 +882,7 @@ async fn udp_loop( resource_stats: resource_stats.clone(), association_clock: association_clock.clone(), last_activity, + tun_mtu, sniffer: sniffer.clone(), cancellation: child_cancellation, }, @@ -983,6 +990,7 @@ async fn run_tun_dns_query( permit: DnsQueryPermit, request: UdpDatagram, responses: mpsc::Sender, + tun_mtu: usize, resource_stats: RuntimeResourceStats, cancellation: CancellationToken, ) { @@ -997,7 +1005,7 @@ async fn run_tun_dns_query( } }, }; - if let Some(response) = complete_tun_dns_response(&request, response) { + if let Some(response) = complete_tun_dns_response(&request, response, tun_mtu) { try_queue_tun_dns_response(&responses, response, Some(permit), &resource_stats); } } @@ -1049,11 +1057,13 @@ fn try_queue_tun_udp_response( } } -fn complete_tun_dns_response(request: &UdpDatagram, response: Vec) -> Option { +fn complete_tun_dns_response( + request: &UdpDatagram, + response: Vec, + tun_mtu: usize, +) -> Option { if response.len() - > usize::from( - DatagramSession::new(InboundKind::Tun, request.source).max_response_payload_size(), - ) + > usize::from(DatagramSession::for_tun(request.source, tun_mtu).max_response_payload_size()) { tracing::debug!( response_bytes = response.len(), @@ -1559,6 +1569,7 @@ struct UdpAssociationTaskContext { resource_stats: RuntimeResourceStats, association_clock: AssociationClock, last_activity: Arc, + tun_mtu: usize, sniffer: Option>, cancellation: CancellationToken, } @@ -1655,7 +1666,7 @@ where { let association_id = context.association_id; let source = context.source; - let session = DatagramSession::new(InboundKind::Tun, source); + let session = DatagramSession::for_tun(source, context.tun_mtu); let opened = tokio::select! { biased; () = context.cancellation.cancelled() => return Ok(()), @@ -2500,6 +2511,7 @@ mod tests { resource_stats: RuntimeResourceStats::new("tun_runtime_quic_test"), association_clock: AssociationClock::realtime(), last_activity: Arc::new(AtomicU64::new(0)), + tun_mtu: TUN_MTU, sniffer: Some(test_sniffer( &[], &[], @@ -2601,6 +2613,7 @@ mod tests { resource_stats: RuntimeResourceStats::new("tun_runtime_quic_fairness_test"), association_clock: AssociationClock::realtime(), last_activity: Arc::new(AtomicU64::new(0)), + tun_mtu: TUN_MTU, sniffer: Some(test_sniffer( &[], &[], @@ -3384,7 +3397,7 @@ mod tests { let limits = ResourceLimits { packet_queue_capacity: 8, event_queue_capacity: 4, - tun_max_datagram_size: TUN_MTU, + tun_max_datagram_size: 1_400, ..ResourceLimits::default() }; let runtime = TunRuntime::new( @@ -3444,7 +3457,7 @@ mod tests { assert_eq!(dispatcher.udp_sessions.lock().unwrap().len(), 1); assert_eq!( dispatcher.udp_sessions.lock().unwrap()[0], - DatagramSession::new(InboundKind::Tun, udp_source) + DatagramSession::for_tun(udp_source, 1_400) ); let tcp_source: SocketAddr = "192.0.2.11:13000".parse().unwrap(); @@ -3717,6 +3730,7 @@ mod tests { permit, request, responses.clone(), + TUN_MTU, resource_stats.clone(), CancellationToken::new(), ) @@ -3745,6 +3759,7 @@ mod tests { permit, request, responses, + TUN_MTU, resource_stats.clone(), CancellationToken::new(), ) @@ -3931,9 +3946,11 @@ mod tests { let requested_server: SocketAddr = "198.51.100.20:53".parse().unwrap(); let query = build_query(0x3456, "example.com", QueryType::A).unwrap(); let request = UdpDatagram::new(source, requested_server, query.clone()); + let tun_mtu = 1_400; let ceiling = - usize::from(DatagramSession::new(InboundKind::Tun, source).max_response_payload_size()); - assert!(complete_tun_dns_response(&request, vec![0_u8; ceiling + 1]).is_none()); + usize::from(DatagramSession::for_tun(source, tun_mtu).max_response_payload_size()); + assert!(complete_tun_dns_response(&request, vec![0_u8; ceiling], tun_mtu).is_some()); + assert!(complete_tun_dns_response(&request, vec![0_u8; ceiling + 1], tun_mtu).is_none()); } #[test] @@ -3943,9 +3960,12 @@ mod tests { let query = build_query(0x4567, "example.com", QueryType::Aaaa).unwrap(); let classified = classify_query(&query).unwrap(); let request = UdpDatagram::new(source, requested_server, query); - let response = - complete_tun_dns_response(&request, synthesize_empty_response(&classified, 0).unwrap()) - .unwrap(); + let response = complete_tun_dns_response( + &request, + synthesize_empty_response(&classified, 0).unwrap(), + TUN_MTU, + ) + .unwrap(); assert_eq!(response.source, requested_server); assert_eq!(response.destination, source); let parsed = crate::dns::parse_response(&response.payload).unwrap(); @@ -4105,6 +4125,7 @@ mod tests { resource_stats: RuntimeResourceStats::new("tun_runtime_test"), association_clock: AssociationClock::realtime(), last_activity: Arc::new(AtomicU64::new(0)), + tun_mtu: TUN_MTU, sniffer: None, cancellation: child, }, diff --git a/src/windows/vpn.rs b/src/windows/vpn.rs index 53bf48f..b421b89 100644 --- a/src/windows/vpn.rs +++ b/src/windows/vpn.rs @@ -24,7 +24,7 @@ use windows::{ }, Networking::{ Connectivity::{ConnectionProfile, NetworkInformation, NetworkStatusChangedEventHandler}, - HostName, HostNameType, + HostName, Sockets::DatagramSocket, Vpn::{ IVpnPlugIn, IVpnPlugIn_Impl, VpnChannel, VpnDomainNameAssignment, VpnDomainNameInfo, @@ -43,7 +43,10 @@ use windows::{ NetworkManagement::IpHelper::{ GET_ADAPTERS_ADDRESSES_FLAGS, GetAdaptersAddresses, IP_ADAPTER_ADDRESSES_LH, }, - Networking::WinSock::AF_UNSPEC, + Networking::WinSock::{ + AF_INET, AF_INET6, AF_UNSPEC, IpDadStatePreferred, SOCKADDR_IN, SOCKADDR_IN6, + SOCKET_ADDRESS, + }, System::WinRT::{ IActivationFactory, IActivationFactory_Impl, IBufferByteAccess, RO_INIT_MULTITHREADED, RoInitialize, RoUninitialize, @@ -280,19 +283,56 @@ impl Drop for FailClosedStop { } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] struct InterfaceIndices { ipv4: Option, ipv6: Option, } +#[derive(Debug)] +struct AdapterProperties { + indices: InterfaceIndices, + addresses: AdapterAddresses, +} + fn adapter_name_matches(name: &str, adapter_id: GUID) -> bool { name.trim() .trim_matches(|character| character == '{' || character == '}') .eq_ignore_ascii_case(&format!("{adapter_id:?}")) } -fn adapter_interface_indices(adapter_id: GUID) -> Result { +fn socket_address_ip(address: SOCKET_ADDRESS) -> Option { + if address.lpSockaddr.is_null() { + return None; + } + // SAFETY: callers pass a `SOCKET_ADDRESS` returned inside the live adapter buffer. + let family = unsafe { (*address.lpSockaddr).sa_family }; + match family { + AF_INET if address.iSockaddrLength >= size_of::() as i32 => { + // SAFETY: the family and recorded length identify a complete `SOCKADDR_IN`. + let address = unsafe { &*address.lpSockaddr.cast::() }; + // SAFETY: `S_un_b` is the byte view of the network-order IPv4 address. + let octets = unsafe { address.sin_addr.S_un.S_un_b }; + Some(IpAddr::V4(Ipv4Addr::new( + octets.s_b1, + octets.s_b2, + octets.s_b3, + octets.s_b4, + ))) + } + AF_INET6 if address.iSockaddrLength >= size_of::() as i32 => { + // SAFETY: the family and recorded length identify a complete `SOCKADDR_IN6`. + let address = unsafe { &*address.lpSockaddr.cast::() }; + // SAFETY: `Byte` is the octet view of the IPv6 address. + Some(IpAddr::V6(Ipv6Addr::from(unsafe { + address.sin6_addr.u.Byte + }))) + } + _ => None, + } +} + +fn adapter_properties(adapter_id: GUID) -> Result { let mut storage = vec![0_usize; (15 * 1024_usize).div_ceil(size_of::())]; for _ in 0..3 { let mut byte_count = u32::try_from(storage.len() * size_of::()) @@ -332,11 +372,27 @@ fn adapter_interface_indices(adapter_id: GUID) -> Result { let name = unsafe { adapter.AdapterName.to_string() } .map_err(|_| Error::new(E_FAIL, "network adapter name is not UTF-8"))?; if adapter_name_matches(&name, adapter_id) { + let mut addresses = AdapterAddresses::default(); + let mut current = adapter.FirstUnicastAddress; + while !current.is_null() { + // SAFETY: successful `GetAdaptersAddresses` linked this entry in `storage`. + let unicast = unsafe { &*current }; + if unicast.DadState == IpDadStatePreferred + && let Some(address) = socket_address_ip(unicast.Address) + { + addresses.record(address, unicast.OnLinkPrefixLength); + } + current = unicast.Next; + } + addresses.normalize(); // SAFETY: `Anonymous` is the documented Length/IfIndex view of this union. let ipv4 = NonZeroU32::new(unsafe { adapter.Anonymous1.Anonymous.IfIndex }); - return Ok(InterfaceIndices { - ipv4, - ipv6: NonZeroU32::new(adapter.Ipv6IfIndex), + return Ok(AdapterProperties { + indices: InterfaceIndices { + ipv4, + ipv6: NonZeroU32::new(adapter.Ipv6IfIndex), + }, + addresses, }); } } @@ -372,75 +428,55 @@ fn network_identity(profile: &ConnectionProfile) -> Result { }) } -#[derive(Debug)] +#[derive(Debug, Default)] struct AdapterAddresses { ipv4: Vec<(Ipv4Addr, u8)>, ipv6: Vec<(Ipv6Addr, u8)>, + ipv4_subnets: Vec, + ipv6_subnets: Vec, } -fn adapter_addresses(adapter_id: GUID) -> Result { - let host_names = NetworkInformation::GetHostNames()?; - let mut ipv4 = Vec::new(); - let mut ipv6 = Vec::new(); - - for index in 0..host_names.Size()? { - let host = host_names.GetAt(index)?; - let Ok(information) = host.IPInformation() else { - continue; - }; - let Ok(adapter) = information.NetworkAdapter() else { - continue; - }; - if adapter.NetworkAdapterId()? != adapter_id { - continue; - } - let Ok(prefix) = information.PrefixLength().and_then(|prefix| prefix.Value()) else { - continue; +impl AdapterAddresses { + fn record(&mut self, address: IpAddr, prefix: u8) { + let Some(subnet) = WindowsVpnCidr::from_address(address, prefix) else { + return; }; - match host.Type()? { - HostNameType::Ipv4 => { - let Ok(address) = host.CanonicalName()?.to_string().parse::() else { - continue; - }; + match address { + IpAddr::V4(address) if !address.is_loopback() - && !address.is_link_local() && !address.is_unspecified() - && prefix <= 32 - { - ipv4.push((address, prefix)); + && !address.is_multicast() + && !address.is_broadcast() => + { + self.ipv4_subnets.push(subnet); + if !address.is_link_local() { + self.ipv4.push((address, prefix)); } } - HostNameType::Ipv6 => { - let Ok(address) = host.CanonicalName()?.to_string().parse::() else { - continue; - }; + IpAddr::V6(address) if !address.is_loopback() - && !address.is_unicast_link_local() && !address.is_unspecified() - && !address.is_multicast() - && prefix <= 128 - { - ipv6.push((address, prefix)); + && !address.is_multicast() => + { + self.ipv6_subnets.push(subnet); + if !address.is_unicast_link_local() { + self.ipv6.push((address, prefix)); } } _ => {} } } - ipv4.sort_unstable(); - ipv4.dedup(); - ipv6.sort_unstable(); - ipv6.dedup(); - Ok(AdapterAddresses { ipv4, ipv6 }) -} - -fn physical_subnets(addresses: impl Iterator) -> Vec { - let mut subnets = addresses - .filter_map(|(address, prefix)| WindowsVpnCidr::from_address(address, prefix)) - .collect::>(); - subnets.sort_unstable(); - subnets.dedup(); - subnets + fn normalize(&mut self) { + self.ipv4.sort_unstable(); + self.ipv4.dedup(); + self.ipv6.sort_unstable(); + self.ipv6.dedup(); + self.ipv4_subnets.sort_unstable(); + self.ipv4_subnets.dedup(); + self.ipv6_subnets.sort_unstable(); + self.ipv6_subnets.dedup(); + } } #[derive(Debug, Clone)] @@ -462,32 +498,21 @@ impl PhysicalNetwork { let profile = NetworkInformation::GetInternetConnectionProfile()?; let adapter_id = profile.NetworkAdapter()?.NetworkAdapterId()?; let identity = network_identity(&profile)?; - let addresses = adapter_addresses(adapter_id)?; + let properties = adapter_properties(adapter_id)?; + let addresses = properties.addresses; if addresses.ipv4.is_empty() && addresses.ipv6.is_empty() { return Err(Error::new( E_FAIL, "physical network has no usable IP address", )); } - let ipv4_subnets = physical_subnets( - addresses - .ipv4 - .iter() - .map(|&(address, prefix)| (IpAddr::V4(address), prefix)), - ); - let ipv6_subnets = physical_subnets( - addresses - .ipv6 - .iter() - .map(|&(address, prefix)| (IpAddr::V6(address), prefix)), - ); let ipv4 = addresses.ipv4.first().copied(); let ipv6 = addresses.ipv6.first().copied(); let ipv4_prefix = ipv4.map(|(_, prefix)| prefix); let ipv6_prefix = ipv6.map(|(_, prefix)| prefix); let ipv4 = ipv4.map(|(address, _)| address); let ipv6 = ipv6.map(|(address, _)| address); - let indices = adapter_interface_indices(adapter_id)?; + let indices = properties.indices; let ipv4_index = match ipv4 { Some(_) => Some(indices.ipv4.ok_or_else(|| { Error::new(E_FAIL, "physical network has no IPv4 interface index") @@ -507,8 +532,8 @@ impl PhysicalNetwork { ipv6, ipv4_prefix, ipv6_prefix, - ipv4_subnets, - ipv6_subnets, + ipv4_subnets: addresses.ipv4_subnets, + ipv6_subnets: addresses.ipv6_subnets, ipv4_index, ipv6_index, }) @@ -537,7 +562,8 @@ impl PhysicalNetwork { } fn is_available(&self) -> Result { - let addresses = adapter_addresses(self.adapter_id)?; + let properties = adapter_properties(self.adapter_id)?; + let addresses = properties.addresses; let ipv4_found = self .ipv4 .zip(self.ipv4_prefix) @@ -546,22 +572,12 @@ impl PhysicalNetwork { .ipv6 .zip(self.ipv6_prefix) .is_none_or(|expected| addresses.ipv6.contains(&expected)); - let ipv4_subnets = physical_subnets( - addresses - .ipv4 - .iter() - .map(|&(address, prefix)| (IpAddr::V4(address), prefix)), - ); - let ipv6_subnets = physical_subnets( - addresses - .ipv6 - .iter() - .map(|&(address, prefix)| (IpAddr::V6(address), prefix)), - ); if !ipv4_found || !ipv6_found - || ipv4_subnets != self.ipv4_subnets - || ipv6_subnets != self.ipv6_subnets + || (self.ipv4.is_some() && properties.indices.ipv4 != self.ipv4_index) + || (self.ipv6.is_some() && properties.indices.ipv6 != self.ipv6_index) + || addresses.ipv4_subnets != self.ipv4_subnets + || addresses.ipv6_subnets != self.ipv6_subnets { return Ok(false); } @@ -1216,6 +1232,26 @@ mod tests { ipv4: Option<(Ipv4Addr, u8)>, ipv6: Option<(Ipv6Addr, u8)>, ) -> PhysicalNetwork { + physical_network_with_subnets( + &ipv4.into_iter().collect::>(), + &ipv6.into_iter().collect::>(), + ) + } + + fn physical_network_with_subnets( + ipv4: &[(Ipv4Addr, u8)], + ipv6: &[(Ipv6Addr, u8)], + ) -> PhysicalNetwork { + let mut addresses = AdapterAddresses::default(); + for &(address, prefix) in ipv4 { + addresses.record(IpAddr::V4(address), prefix); + } + for &(address, prefix) in ipv6 { + addresses.record(IpAddr::V6(address), prefix); + } + addresses.normalize(); + let ipv4 = addresses.ipv4.first().copied(); + let ipv6 = addresses.ipv6.first().copied(); PhysicalNetwork { adapter_id: GUID::from_u128(0), identity: NetworkIdentity { @@ -1226,35 +1262,13 @@ mod tests { ipv6: ipv6.map(|(address, _)| address), ipv4_prefix: ipv4.map(|(_, prefix)| prefix), ipv6_prefix: ipv6.map(|(_, prefix)| prefix), - ipv4_subnets: physical_subnets( - ipv4.into_iter() - .map(|(address, prefix)| (IpAddr::V4(address), prefix)), - ), - ipv6_subnets: physical_subnets( - ipv6.into_iter() - .map(|(address, prefix)| (IpAddr::V6(address), prefix)), - ), + ipv4_subnets: addresses.ipv4_subnets, + ipv6_subnets: addresses.ipv6_subnets, ipv4_index: None, ipv6_index: None, } } - fn physical_network_with_subnets( - ipv4: &[(Ipv4Addr, u8)], - ipv6: &[(Ipv6Addr, u8)], - ) -> PhysicalNetwork { - let mut physical = physical_network(ipv4.first().copied(), ipv6.first().copied()); - physical.ipv4_subnets = physical_subnets( - ipv4.iter() - .map(|&(address, prefix)| (IpAddr::V4(address), prefix)), - ); - physical.ipv6_subnets = physical_subnets( - ipv6.iter() - .map(|&(address, prefix)| (IpAddr::V6(address), prefix)), - ); - physical - } - #[test] fn adapter_name_matches_winrt_network_adapter_id() { let id = GUID::from_u128(0xc2afe445_9ed9_423d_8c29_6b2cd49691d2); @@ -1289,21 +1303,27 @@ mod tests { } #[test] - fn physical_subnets_normalize_and_deduplicate_addresses() { - let subnets = physical_subnets( - [ - (IpAddr::V4(Ipv4Addr::new(198, 51, 100, 129)), 24), - (IpAddr::V4(Ipv4Addr::new(198, 51, 100, 200)), 24), - (IpAddr::V4(Ipv4Addr::new(198, 51, 100, 200)), 25), - (IpAddr::V6("2001:db8:1::2".parse().unwrap()), 64), - (IpAddr::V6("2001:db8:1::3".parse().unwrap()), 64), - (IpAddr::V6("2001:db8:2::2".parse().unwrap()), 64), - ] - .into_iter(), - ); + fn adapter_subnets_normalize_and_deduplicate_addresses() { + let mut addresses = AdapterAddresses::default(); + for (address, prefix) in [ + (IpAddr::V4(Ipv4Addr::new(198, 51, 100, 129)), 24), + (IpAddr::V4(Ipv4Addr::new(198, 51, 100, 200)), 24), + (IpAddr::V4(Ipv4Addr::new(198, 51, 100, 200)), 25), + (IpAddr::V6("2001:db8:1::2".parse().unwrap()), 64), + (IpAddr::V6("2001:db8:1::3".parse().unwrap()), 64), + (IpAddr::V6("2001:db8:2::2".parse().unwrap()), 64), + ] { + addresses.record(address, prefix); + } + addresses.normalize(); assert_eq!( - subnets.iter().map(ToString::to_string).collect::>(), + addresses + .ipv4_subnets + .iter() + .chain(&addresses.ipv6_subnets) + .map(ToString::to_string) + .collect::>(), [ "198.51.100.0/24", "198.51.100.128/25", @@ -1313,6 +1333,70 @@ mod tests { ); } + #[test] + fn socket_addresses_preserve_network_order() { + let mut ipv4 = SOCKADDR_IN::default(); + ipv4.sin_family = AF_INET; + ipv4.sin_addr.S_un.S_un_b = windows::Win32::Networking::WinSock::IN_ADDR_0_0 { + s_b1: 198, + s_b2: 51, + s_b3: 100, + s_b4: 2, + }; + assert_eq!( + socket_address_ip(SOCKET_ADDRESS { + lpSockaddr: std::ptr::from_mut(&mut ipv4).cast(), + iSockaddrLength: size_of::() as i32, + }), + Some(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 2))) + ); + + let expected: Ipv6Addr = "fe80::2".parse().unwrap(); + let mut ipv6 = SOCKADDR_IN6::default(); + ipv6.sin6_family = AF_INET6; + ipv6.sin6_addr.u.Byte = expected.octets(); + assert_eq!( + socket_address_ip(SOCKET_ADDRESS { + lpSockaddr: std::ptr::from_mut(&mut ipv6).cast(), + iSockaddrLength: size_of::() as i32, + }), + Some(IpAddr::V6(expected)) + ); + } + + #[test] + fn adapter_network_retains_link_local_subnets_but_not_sources() { + let mut addresses = AdapterAddresses::default(); + for (address, prefix) in [ + (IpAddr::V4(Ipv4Addr::new(169, 254, 10, 2)), 16), + (IpAddr::V4(Ipv4Addr::new(198, 51, 100, 2)), 24), + (IpAddr::V6("fe80::2".parse().unwrap()), 64), + (IpAddr::V6("2001:db8::2".parse().unwrap()), 64), + ] { + addresses.record(address, prefix); + } + addresses.normalize(); + + assert_eq!(addresses.ipv4, vec![(Ipv4Addr::new(198, 51, 100, 2), 24)]); + assert_eq!(addresses.ipv6, vec![("2001:db8::2".parse().unwrap(), 64)]); + assert_eq!( + addresses + .ipv4_subnets + .iter() + .map(ToString::to_string) + .collect::>(), + ["169.254.0.0/16", "198.51.100.0/24"] + ); + assert_eq!( + addresses + .ipv6_subnets + .iter() + .map(ToString::to_string) + .collect::>(), + ["2001:db8::/64", "fe80::/64"] + ); + } + #[test] fn provider_assignments_use_profile_addresses() { let _winrt = WinRtGuard::enter(); @@ -1435,11 +1519,13 @@ mod tests { .unwrap(); let physical = physical_network_with_subnets( &[ + (Ipv4Addr::new(169, 254, 10, 2), 16), (Ipv4Addr::new(198, 51, 100, 129), 24), (Ipv4Addr::new(198, 51, 100, 200), 24), (Ipv4Addr::new(203, 0, 113, 129), 24), ], &[ + ("fe80::2".parse().unwrap(), 64), ("2001:db8:1::2".parse().unwrap(), 64), ("2001:db8:1::3".parse().unwrap(), 64), ("2001:db8:2::2".parse().unwrap(), 64), @@ -1448,9 +1534,18 @@ mod tests { let routes = vpn_routes(profile.ipv6_enabled(), profile.policy(), &physical).unwrap(); let ipv4 = routes.Ipv4InclusionRoutes().unwrap(); - assert_eq!(ipv4.Size().unwrap(), 6); + assert_eq!(ipv4.Size().unwrap(), 8); + assert_eq!( + ipv4.GetAt(2) + .unwrap() + .Address() + .unwrap() + .DisplayName() + .unwrap(), + "169.254.0.0" + ); assert_eq!( - ipv4.GetAt(4) + ipv4.GetAt(6) .unwrap() .Address() .unwrap() @@ -1459,7 +1554,7 @@ mod tests { "203.0.113.0" ); let ipv6 = routes.Ipv6InclusionRoutes().unwrap(); - assert_eq!(ipv6.Size().unwrap(), 6); + assert_eq!(ipv6.Size().unwrap(), 8); assert_eq!( ipv6.GetAt(4) .unwrap() @@ -1469,6 +1564,15 @@ mod tests { .unwrap(), "2001:db8:2::" ); + assert_eq!( + ipv6.GetAt(6) + .unwrap() + .Address() + .unwrap() + .DisplayName() + .unwrap(), + "fe80::" + ); } #[test]