diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c879354..06aabaa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,8 @@ concurrency: cancel-in-progress: false env: + UV_PYTHON_PREFERENCE: only-system + UV_PYTHON_DOWNLOADS: never # Reusable calls inherit the caller's event and workflow context. VOLE_RELEASE_TAG: ${{ github.event_name == 'push' && github.ref_type == 'tag' && github.workflow_ref == format('{0}/.github/workflows/release.yml@{1}', github.repository, github.ref) && github.ref_name || '' }} VOLE_RELEASE_REPOSITORY: ${{ github.repository }} @@ -72,16 +74,14 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} + - uses: actions/setup-python@v7 + with: + python-version: "3.x" + check-latest: true + architecture: ${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }} - uses: astral-sh/setup-uv@v10.2.0 with: version: "0.12" - - name: Install latest stable Python 3 - shell: bash - env: - UV_PYTHON_INSTALL_DIR: ${{ runner.temp }}/python - run: | - uv python install 3 --no-bin --no-registry - echo "UV_PYTHON=$(uv python find --managed-python --no-project 3)" >> "$GITHUB_ENV" - name: Linux native dependencies if: matrix.platform == 'linux' run: | @@ -188,16 +188,14 @@ jobs: with: toolchain: stable targets: ${{ matrix.targets }} + - uses: actions/setup-python@v7 + with: + python-version: "3.x" + check-latest: true + architecture: ${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }} - uses: astral-sh/setup-uv@v10.2.0 with: version: "0.12" - - name: Install latest stable Python 3 - shell: bash - env: - UV_PYTHON_INSTALL_DIR: ${{ runner.temp }}/python - run: | - uv python install 3 --no-bin --no-registry - echo "UV_PYTHON=$(uv python find --managed-python --no-project 3)" >> "$GITHUB_ENV" - name: Locked source run: cargo fetch --locked - name: Linux native dependencies @@ -244,6 +242,19 @@ jobs: if [[ -n "$VOLE_RELEASE_TAG" ]]; then args+=(--tag "$VOLE_RELEASE_TAG"); fi uv run --project scripts --locked python -m vole_scripts.release "${args[@]}" test -f "dist/release/$VOLE_GROUP/$VOLE_ARCHIVE" + - name: Check unpackaged Windows COM lifecycle + if: matrix.platform == 'windows' && matrix.backend == 'uwp' + shell: bash + env: + VOLE_TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + case "$VOLE_TARGET" in + x86_64-pc-windows-msvc) architecture=x64 ;; + aarch64-pc-windows-msvc) architecture=arm64 ;; + *) exit 1 ;; + esac + python scripts/check_windows_com.py --dll "dist/windows/$architecture/uwp/vole.dll" - name: Upload release archive uses: actions/upload-artifact@v7 with: @@ -280,16 +291,13 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} + - uses: actions/setup-python@v7 + with: + python-version: "3.x" + check-latest: true - uses: astral-sh/setup-uv@v10.2.0 with: version: "0.12" - - name: Install latest stable Python 3 - shell: bash - env: - UV_PYTHON_INSTALL_DIR: ${{ runner.temp }}/python - run: | - uv python install 3 --no-bin --no-registry - echo "UV_PYTHON=$(uv python find --managed-python --no-project 3)" >> "$GITHUB_ENV" - name: Cache native Rust dependencies uses: Swatinem/rust-cache@v2 with: @@ -323,16 +331,13 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: "3.x" + check-latest: true - uses: astral-sh/setup-uv@v10.2.0 with: version: "0.12" - - name: Install latest stable Python 3 - shell: bash - env: - UV_PYTHON_INSTALL_DIR: ${{ runner.temp }}/python - run: | - uv python install 3 --no-bin --no-registry - echo "UV_PYTHON=$(uv python find --managed-python --no-project 3)" >> "$GITHUB_ENV" - uses: actions/download-artifact@v8 with: pattern: vole-apple-*-${{ github.sha }} @@ -359,16 +364,13 @@ jobs: with: fetch-depth: 0 persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: "3.x" + check-latest: true - uses: astral-sh/setup-uv@v10.2.0 with: version: "0.12" - - name: Install latest stable Python 3 - shell: bash - env: - UV_PYTHON_INSTALL_DIR: ${{ runner.temp }}/python - run: | - uv python install 3 --no-bin --no-registry - echo "UV_PYTHON=$(uv python find --managed-python --no-project 3)" >> "$GITHUB_ENV" - uses: actions/download-artifact@v8 with: pattern: vole-release-build-*-${{ github.sha }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5d6c985..ddec080 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,6 +12,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +env: + UV_PYTHON_PREFERENCE: only-system + UV_PYTHON_DOWNLOADS: never + jobs: client-contracts: name: ${{ matrix.name }} @@ -33,16 +37,13 @@ jobs: with: toolchain: stable components: rustfmt, clippy + - uses: actions/setup-python@v7 + with: + python-version: "3.x" + check-latest: true - uses: astral-sh/setup-uv@v10.2.0 with: version: "0.12" - - name: Install latest stable Python 3 - shell: bash - env: - UV_PYTHON_INSTALL_DIR: ${{ runner.temp }}/python - run: | - uv python install 3 --no-bin --no-registry - echo "UV_PYTHON=$(uv python find --managed-python --no-project 3)" >> "$GITHUB_ENV" - name: Cache Rust dependencies uses: Swatinem/rust-cache@v2 with: diff --git a/CONTEXT.md b/CONTEXT.md index efde343..f3b6689 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -16,6 +16,10 @@ _Avoid_: Plugin, background task when referring to the whole participant, proxy The desktop raw-IP platform adapter backed by a host-provided Wintun DLL. It connects to the shared Vole TUN runtime without a Windows VPN Provider, package identity or packet channel; the first release leaves system network configuration to the host. _Avoid_: Windows VPN provider, second proxy core, Unix fd emulation +**Windows VPN profile**: +The Windows system VPN profile selected by the host's stable `profileName` within its package family. Start, status and stop use the same exact name; omission selects `Vole`. A custom name does not enable concurrent package sessions or migrate another profile. +_Avoid_: Proxy node, user configuration, Session Snapshot + **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 diff --git a/Cargo.toml b/Cargo.toml index 7f50d95..0fa6d8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -164,6 +164,7 @@ windows = { version = "=0.62.2", optional = true, features = [ "Win32_Networking_WinSock", "Win32_Security", "Win32_Security_Isolation", + "Win32_System_Com", "Win32_System_JobObjects", "Win32_System_RemoteDesktop", "Win32_System_Threading", diff --git a/docs/invoke-api.md b/docs/invoke-api.md index bd9fdd2..2cf865b 100644 --- a/docs/invoke-api.md +++ b/docs/invoke-api.md @@ -19,7 +19,7 @@ void VoleFree(char *response); - 业务运行时线程不能重入 Invoke;Debug 和 Release 构建都立即返回失败 JSON,包括 `version` 等只读请求。 - 请求正文、响应正文、完整配置、UUID、密钥、short ID 和凭据不得写入日志。 - UWP 宿主包含 `vole_windows_uwp.h`(同时引入 `vole.h`),其中声明 `VoleWindowsVpnInvoke`。该头文件和独立的 Windows 安装包桥接接口仅由 `windows-uwp` 构建提供。 -- `VoleWindowsVpnInvoke` 当前在调用线程上初始化 MTA;调用线程必须尚未初始化 COM,或已经是 MTA。STA/ASTA 调用不受支持。 +- `VoleWindowsVpnInvoke` 接受未显式初始化 COM 或已是 MTA 的调用线程;STA/ASTA 调用失败且不改变其 apartment。库配对管理每次调用的初始化,并为静态 WinRT factory 缓存保留一份进程寿命的 MTA 引用;宿主无需额外持有 MTA,短生命周期工作线程可串行重复调用。返回后未显式初始化的线程可能处于 implicit MTA,不遗留逐次显式初始化计数;完整边界见 [COM / WinRT 生命周期](windows-vpn.md#com--winrt-生命周期)。 ## 请求与响应 @@ -345,6 +345,30 @@ Unix 等待 SIGINT/SIGTERM,Windows 等待 Ctrl+C/Ctrl+Break;标准输入先 ## Android protect +Android JNI 类固定为 `io.github.yuandevteam.vole.NativeVole`,与宿主应用的包名独立。 +宿主提供如下 Java 声明(或等价的 Kotlin/JVM 声明),并打包匹配 ABI 的原生库: + +```java +package io.github.yuandevteam.vole; + +public final class NativeVole { + static { + System.loadLibrary("vole"); + } + + private NativeVole() {} + + public static native byte[] nativeInvoke(byte[] request); + public static native boolean nativeRegisterProtectController(Object controller); + public static native boolean nativeUnregisterProtectController(); +} +``` + +仅导出该命名空间下的三个 JNI 方法,不提供其它包名别名。宿主声明必须与原生库一致; +代码压缩和混淆必须保留该类、这些 native 方法以及 controller 的 `boolean protect(int)` +方法名。`nativeInvoke` 的请求与响应均为标准 UTF-8 JSON `byte[]`,由 JVM 管理, +不使用 C ABI 的 `VoleFree` 释放。 + Android TUN 通过 Invoke 之外的运行时本地回调注册: ```text @@ -385,11 +409,37 @@ ProtectFd(fd) -> bool - `getStartupTaskStatus` - `setStartupTaskEnabled` +### VPN profile 名称 + +`startVpn`、`getVpnStatus` 和 `stopVpn` 的 payload 都接受可选的 `profileName`。 +省略时使用 `Vole`;使用自定义名称的宿主必须在这三个方法中始终传入同一个值,包括 +前台重启后的查询。名称不属于业务 YAML、Session Snapshot 或 profile custom configuration, +不改变 Session token 的计算。 + +```json +{"bridgeVersion":3,"method":"getVpnStatus","payload":{"profileName":"Example VPN"}} +``` + +- 名称必须是 1–256 UTF-8 字节的字符串,不能有首尾 Unicode 空白或控制字符(含 NUL)。 + `null`、空串和其它 JSON 类型都拒绝,不 trim、不补默认值;其它系统名称限制仍由 Windows 返回错误。 +- 仅匹配当前 package family 内同名的 VPN profile,区分大小写且不做 Unicode normalization。 + 其它 package 的 profile 不会被接管或修改;Windows 名称冲突不会触发删除、重命名或回退。 +- 同包存在其它名称的活动 profile 时,三个方法均失败,而不是报告已断开或操作那个 profile; + 多个同名匹配同样失败。同包不同名且已断开的 profile 不参与本次操作。 +- 宿主应使用稳定名称。这不是多 profile 管理接口,不支持多会话并行、自动迁移、重命名或删除。 + 更换名称前必须用旧名称显式 `stopVpn` 并确认断开;使用新名称启动不会复用或删除旧名称的 profile。 + 名称选择不会转换旧 Session token 或解除现有 profile 配置校验。 +- `getEnvironment`、`getStartupTaskStatus` 和 `setStartupTaskEnabled` 不接受 `profileName`。 + StartupTask ID 仍固定为 `VoleStartup`,与 VPN profile 名称无关。 + +### 启动 payload + `startVpn` 的 payload 为: ```json { - "configYaml": "tun:\n enable: true\n...", + "profileName": "Example VPN", + "configYaml": "tun:\n enable: true\n mtu: 1400\n...", "networkSettings": { "ipv4Address": "192.168.3.1", "ipv6Address": "fd00::2", @@ -418,7 +468,7 @@ ProtectFd(fd) -> bool 桥接把 YAML、进程顺序、路径和参数发布为 `vole-session-v2:` Session Snapshot。参数引用的文件由调用方保持存在且不可变,Vole 不读取或摘要其内容。`getVpnStatus.data.snapshotToken` 返回该完整 Session token。 -桥接请求最大 1 MiB。它负责安装包身份、单一 VPN profile、不可变 Session Snapshot、连接/断开命令和系统 VPN 状态;Provider 负责激活 Session Host。桥接不公开 profile CRUD、内部文件路径、backend 描述、参数、PID、管道名称或 Snapshot 维护。数据包、Controller 流量查询、代理组查询/切换和业务生命周期不经过该 JSON 桥接。 +桥接请求最大 1 MiB。它负责安装包身份、按名称选择的 VPN profile、不可变 Session Snapshot、连接/断开命令和系统 VPN 状态;Provider 负责激活 Session Host。桥接不公开 profile CRUD、内部文件路径、backend 描述、参数、PID、管道名称或 Snapshot 维护。数据包、Controller 流量查询、代理组查询/切换和业务生命周期不经过该 JSON 桥接。 ## 编码与安全边界 @@ -428,3 +478,5 @@ ProtectFd(fd) -> bool - 嵌套 UDP 协议可以增加有界帧头,但解封装后的最终负载仍受该平台响应上限限制。 - 节点和代理组定义名共享大小写敏感的严格 UTF-8 命名空间;`DIRECT`、`REJECT` 和 `RULES` 不能用作定义名。 - Secret、password、UUID、REALITY key、short ID、目标地址和完整配置不得进入日志。 +- Apple Unified Logging 的 subsystem 固定为 `io.github.yuandevteam.vole`,category 为 + `vole`,不继承宿主应用的 bundle identifier;Console 或 `log stream` 应按该标识筛选。 diff --git a/docs/windows-vpn.md b/docs/windows-vpn.md index 5fe7e68..cf7343c 100644 --- a/docs/windows-vpn.md +++ b/docs/windows-vpn.md @@ -21,7 +21,20 @@ tun-rs 的 Wintun 后端,也不依赖外置 `wintun.dll`。Wintun 的 interrup 未安装的普通桌面进程没有 package identity,Windows VPN 桥接会失败关闭。 -`VoleWindowsVpnInvoke` 当前在调用线程上初始化 MTA。调用线程必须尚未初始化 COM,或已经是 MTA;STA/ASTA 调用不受支持。 +### COM / WinRT 生命周期 + +`VoleWindowsVpnInvoke` 在调用线程上配对执行 `RoInitialize(MTA)` / `RoUninitialize`。 +调用线程可以尚未显式初始化 COM,或已经是 MTA;STA/ASTA 调用失败,不改变调用方的 apartment。 + +桥接首次成功进入 MTA 时,通过 `CoIncrementMTAUsage` 保留一份进程寿命的 MTA 引用, +与 `windows-rs` 的静态 agile factory 缓存同寿命。即使调用线程退出、调用方释放自己的 +MTA,或两次请求之间没有显式 MTA 线程,缓存也不会跨越 MTA 拆卸后被再次使用。 +这是一份固定引用,不随请求增长;不在逐次返回或 DLL/process teardown 时释放, +也不在 loader lock 内执行 COM 清理。获取失败仍配对释放本次初始化,并返回失败。 + +宿主无需额外持有 MTA,可以从不同的短生命周期工作线程串行调用。返回后,原本未显式 +初始化的线程可能属于进程的 implicit MTA;桥接不会遗留该线程的显式初始化计数。 +命令仍不允许重叠,不增加工作队列,也不改变包身份、profile 或会话生命周期。 ## `IVpnPlugIn` 回调 @@ -171,7 +184,7 @@ vole-windows-session-host.exe - Session Host 是 `windows.fullTrustProcess` extension,不显示在应用列表,也不注册 StartupTask 或 URI; - Provider 的 `windows.backgroundTasks` extension 显式使用 `windowsApp + appContainer`; - Provider activation class 来自 `vole.dll`; -- 同一 package 只维护一个 `Vole` VPN profile; +- 宿主为同一 package 使用一个稳定的 VPN profile 名称;`startVpn`、`getVpnStatus`、`stopVpn` 的可选 `profileName` 缺省为 `Vole`,匹配和改名边界见 [Invoke API](invoke-api.md#vpn-profile-名称)。同包仍只允许一个活动会话; - custom configuration 是最大 4 KiB 的严格 JSON,只含修订版 4、Session token、顶层 IPv6 开关、四个网络地址和完整 policy; - Session Snapshot 是 `LocalState/vole/windows/sessions/.json`,revision 2 覆盖完整 YAML、可选进程顺序、路径和参数;读取验证大小、普通文件、reparse point、规范 JSON、摘要和每个 executable。参数引用的文件由宿主保持存在且不可变,Vole 不读取其内容; - 活动 Session token、IPv6 开关、网络地址或 policy 不同时必须先显式 Stop,不能热切换; @@ -233,9 +246,9 @@ Session Host 每次连接新建一个进程,不常驻、不复用运行时, ## 启动顺序 -1. 前台宿主调用 `startVpn(configYaml, networkSettings, policy, sessionBackend?)`。 +1. 前台宿主调用 `startVpn(configYaml, networkSettings, policy, sessionBackend?, profileName?)`,后续查询和停止使用同一 profile 名称。 2. 桥接验证配置、四个地址、policy 和进程描述,发布不可变 Session Snapshot,并把解析后的顶层 IPv6 开关和 policy 写入 profile configuration。 -3. 桥接写入单一 VPN profile 并调用 `ConnectProfileAsync`;它不启动或持有 Session Host。 +3. 桥接按当前 package family 和请求的名称查找并写入 VPN profile,再调用 `ConnectProfileAsync`;它不接管其它 profile,也不启动或持有 Session Host。 4. Windows 激活 AppContainer Provider。 5. Provider 从 profile configuration 取得权威 token,选择物理网络绑定并准备基础资源。 6. Provider 清理陈旧会合记录,通过无参数 `FullTrustProcessLauncher` 激活 Session Host。 @@ -347,3 +360,5 @@ Stopped { version, packetCounters } - [`VpnManagementAgent`](https://learn.microsoft.com/uwp/api/windows.networking.vpn.vpnmanagementagent) - [`FullTrustProcessLauncher`](https://learn.microsoft.com/uwp/api/windows.applicationmodel.fulltrustprocesslauncher) - [Package identity](https://learn.microsoft.com/windows/apps/desktop/modernize/package-identity-overview) +- [`RoInitialize`](https://learn.microsoft.com/windows/win32/api/roapi/nf-roapi-roinitialize) +- [`CoIncrementMTAUsage`](https://learn.microsoft.com/windows/win32/api/combaseapi/nf-combaseapi-coincrementmtausage) diff --git a/example/windows-uwp/README.md b/example/windows-uwp/README.md index 130148e..af643a9 100644 --- a/example/windows-uwp/README.md +++ b/example/windows-uwp/README.md @@ -117,6 +117,7 @@ if (response != nullptr) { - revision 固定为 `bridgeVersion: 3`; - DTO 严格拒绝未知字段; - 返回内存必须由同一份 `vole.dll` 的 `VoleFree` 释放; +- 调用线程可尚未显式初始化 COM 或已经是 MTA,不能是 STA/ASTA。库为静态 WinRT factory 缓存保留一份进程寿命的 MTA 引用,并配对每次调用的初始化;宿主无需自行持有 MTA,可从不同的短生命周期工作线程串行调用,见 [COM 生命周期](../../docs/windows-vpn.md#com--winrt-生命周期); - 桥接命令不能重叠;真实前台应在单进程内串行调用,本命令行 demo 额外用 session-local named mutex 串行化多个 alias 进程; - 调用进程必须具有当前 MSIX package identity;unpackaged EXE 会失败关闭; - 不要从 Provider 回调、Session Host 或 AppContainer UI 重入该接口。 @@ -126,9 +127,14 @@ if (response != nullptr) { | 方法 | payload | 用途 | | --- | --- | --- | | `getEnvironment` | `{}` | 验证 package identity,返回 PFN 和 LocalState 路径 | -| `getVpnStatus` | `{}` | 查询同包唯一 profile | -| `startVpn` | `configYaml` + `networkSettings` + `policy` + 可选 `sessionBackend` | 发布 Session Snapshot、配置全局 VPN policy 并连接 | -| `stopVpn` | `{}` | 断开当前 profile | +| `getVpnStatus` | 可选 `profileName` | 查询同包指定名称的 profile | +| `startVpn` | `configYaml` + `networkSettings` + `policy` + 可选 `sessionBackend` / `profileName` | 发布 Session Snapshot、配置全局 VPN policy 并连接 | +| `stopVpn` | 可选 `profileName` | 断开同包指定名称的 profile | + +本 demo 省略 `profileName`,三个方法都操作默认名称 `Vole`。接入其它宿主时,可以在 +这三个请求的 payload 中统一增加 `"profileName":"Example VPN"`;重启后的状态查询 +也必须使用相同名称。此设置不是用户节点选择,也不会迁移或删除旧名称的 profile。 +完整校验、包隔离和更换名称约束见 [Invoke API](../../docs/invoke-api.md#vpn-profile-名称)。 桥接还提供 `getStartupTaskStatus` 和 `setStartupTaskEnabled`。本 demo 故意不声明 StartupTask,避免登录时启动一个无 UI 的命令行工具;产品需要该能力时,再声明 `xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"`、把 `desktop` 加入 `IgnorableNamespaces`,并在前台 `` 下增加: @@ -199,7 +205,7 @@ Vole 会校验 YAML、发布 `vole-session-v2:` 内容寻址 Session Snapshot, | capabilities | `internetClientServer`、`privateNetworkClientServer`、`runFullTrust`、`networkingVpnProvider` | | minimum desktop OS | `10.0.19042.0` | -可以修改 identity、publisher、版本、主 Application Id/EXE、显示名称、图标和 app execution alias。不要删除 Session Host/Provider extensions 或修改 `Vole.VpnBackgroundTask`。桥接还固定使用 profile 名 `Vole`,可选 StartupTask 固定使用 `VoleStartup`;两者都按 package family 隔离。 +可以修改 identity、publisher、版本、主 Application Id/EXE、显示名称、图标和 app execution alias。不要删除 Session Host/Provider extensions 或修改 `Vole.VpnBackgroundTask`。VPN profile 名称由桥接 payload 的 `profileName` 选择,缺省为 `Vole`;可选 StartupTask 仍固定使用 `VoleStartup`,不随 profile 名称变化。两者都按 package family 隔离。 三项 Vole 文件必须位于 package 根目录: diff --git a/include/vole_windows_uwp.h b/include/vole_windows_uwp.h index b3c72ee..0ff8537 100644 --- a/include/vole_windows_uwp.h +++ b/include/vole_windows_uwp.h @@ -11,10 +11,17 @@ extern "C" { * Only windows-uwp builds export this entry point. 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 + * processes, and StartupTask operations. The startVpn/getVpnStatus/stopVpn + * payloads accept an optional profileName (default: Vole); callers must use + * the same name for all three operations. The response has the same allocation * ownership as VoleInvoke and must be released with VoleFree. The calling * thread must either have no COM apartment initialized or already be - * initialized as MTA; STA and ASTA callers are unsupported. + * initialized as MTA; STA and ASTA callers are unsupported. Each call balances + * its own COM initialization. The library retains one process-lifetime MTA + * reference for cached WinRT factories, so callers need not retain an MTA + * between requests and may use different short-lived threads serially. A + * previously uninitialized thread may belong to the implicit MTA on return; + * no per-call explicit initialization is left on the calling thread. */ #ifdef _WIN32 char *VoleWindowsVpnInvoke(const char *request_json); diff --git a/scripts/README.md b/scripts/README.md index 03d5a52..ea0e196 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -15,6 +15,10 @@ uv run --project scripts --locked vole-scripts build windows --backend uwp CLI 默认构建当前宿主的 Release,Windows 固定 Wintun;Windows FFI 默认 UWP。 脚本输出产物路径,编译失败直接返回失败。 +Windows 的构建和验证只允许宿主原生架构:x64 使用 x64 Python、MSVC 与产物, +ARM64 使用 ARM64 Python、MSVC 与产物,不跨架构编译或通过模拟运行验证。 +Windows 需先安装本架构的 Python 并加入 `PATH`;为上述 `uv run` 增加 `--python python` +可明确使用该解释器。脚本拒绝非原生 Python 和不同架构的 Windows 目标。 | 目标 | 工具链与输出 | | --- | --- | @@ -34,7 +38,12 @@ Android 优先使用 `ANDROID_NDK_HOME`,否则在 `ANDROID_HOME/ndk` 中选择 [Release workflow](../.github/workflows/release.yml) 在 `vX.Y.Z` tag push 时构建并发布, tag 必须匹配 Cargo 版本。PR 使用同一构建矩阵,全部构建成功后汇总十四个归档。 -CLI 构建后执行 `-h/-v/-t` 检查。 +CI 中的 Python 均由 `actions/setup-python` 安装,选择最新稳定 Python 3;uv 仅使用系统解释器, +禁止自动下载 Python,不使用脚本安装或特定解释器发行版请求。 +CLI 构建后执行 `-h/-v/-t` 检查。Windows CLI、Wintun FFI、UWP FFI 分别使用独立的 +x64/ARM64 job,每个 job 的 runner、Python、工具链和验证产物必须同架构; +UWP 的 COM 回归只加载当前 job 构建的 DLL。ARM64 MSVC 使用原生 `vcvarsall arm64`, +不使用 `amd64_arm64` 交叉工具链。 Apple FFI 的六个 Rust 目标分别在独立 job 并行编译并缓存;`FFI Apple` 等待全部目标 成功后合并 macOS 双架构、生成五切片 XCFramework 并打包。 CI 的依赖来源检查见 [TLS 依赖](../docs/tls-dependencies.md)。 diff --git a/scripts/check_windows_com.py b/scripts/check_windows_com.py new file mode 100644 index 0000000..eaf06d3 --- /dev/null +++ b/scripts/check_windows_com.py @@ -0,0 +1,202 @@ +"""Unpackaged DLL regression: COM lifetime only, without VPN or network access.""" + +from __future__ import annotations + +import argparse +import ctypes +import json +import subprocess +import sys +import threading +from pathlib import Path + +CASES = ("uninitialized", "caller-mta", "worker-handoff", "sta-rejection") +CO_E_NOTINITIALIZED = ctypes.c_int32(0x800401F0).value +IMPLICIT_MTA = (0, 1, 1) +EXPLICIT_MTA = (0, 1, 0) +IDENTITY_FAILURE = { + "success": False, + "data": None, + "error": "Windows package identity is required", +} + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def bind(library, name, result_type, argument_types): + function = getattr(library, name) + function.restype = result_type + function.argtypes = argument_types + return function + + +class Bridge: + def __init__(self, dll: Path): + kernel = ctypes.WinDLL("kernel32.dll") + bind(kernel, "SetErrorMode", ctypes.c_uint32, [ctypes.c_uint32])(0x8003) + package_name = bind( + kernel, + "GetCurrentPackageFullName", + ctypes.c_int32, + [ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint16)], + ) + length = ctypes.c_uint32() + require( + package_name(ctypes.byref(length), None) == 15700, + "requires an unpackaged process; refusing to access package data or VPNs", + ) + com = ctypes.WinDLL("combase.dll") + self.initialize = bind(com, "RoInitialize", ctypes.c_int32, [ctypes.c_uint32]) + self.uninitialize = bind(com, "RoUninitialize", None, []) + self.get_apartment = bind( + com, + "CoGetApartmentType", + ctypes.c_int32, + [ctypes.POINTER(ctypes.c_int32), ctypes.POINTER(ctypes.c_int32)], + ) + library = ctypes.CDLL(str(dll)) + self.invoke = bind( + library, "VoleWindowsVpnInvoke", ctypes.c_void_p, [ctypes.c_char_p] + ) + self.free = bind(library, "VoleFree", None, [ctypes.c_void_p]) + self.calls = 0 + + def apartment(self) -> tuple[int, int, int]: + kind = ctypes.c_int32() + qualifier = ctypes.c_int32() + result = self.get_apartment(ctypes.byref(kind), ctypes.byref(qualifier)) + return result, kind.value, qualifier.value + + def query(self, method: str) -> dict: + request = json.dumps({"bridgeVersion": 3, "method": method, "payload": {}}) + pointer = self.invoke(request.encode("utf-8")) + require(pointer is not None, "bridge returned a null response") + try: + response = json.loads(ctypes.string_at(pointer).decode("utf-8")) + finally: + self.free(pointer) + self.calls += 1 + return response + + def repeat(self, count: int = 64, apartment=IMPLICIT_MTA) -> None: + for _ in range(count): + for method in ("getEnvironment", "getVpnStatus"): + require( + self.query(method) == IDENTITY_FAILURE, "expected identity error" + ) + require( + self.apartment() == apartment, + "per-call COM initialization was not balanced or MTA was torn down", + ) + + def reject_sta(self) -> None: + require(self.initialize(0) >= 0, "cannot initialize the test STA") + try: + before = self.apartment() + require(before[0] == 0 and before[1] in (0, 3), "expected STA or main STA") + for method in ("getEnvironment", "getVpnStatus"): + response = self.query(method) + require( + response["success"] is False + and response["data"] is None + and "80010106" in response["error"], + "STA must return RPC_E_CHANGED_MODE without executing the request", + ) + require(self.apartment() == before, "bridge changed the caller's STA") + finally: + self.uninitialize() + + +def run_case(dll: Path, case: str) -> None: + bridge = Bridge(dll) + require( + bridge.apartment()[0] == CO_E_NOTINITIALIZED, + "each regression must start without a preinitialized COM apartment", + ) + if case == "uninitialized": + bridge.repeat() + elif case == "caller-mta": + require(bridge.initialize(1) >= 0, "cannot initialize the test MTA") + try: + bridge.repeat(apartment=EXPLICIT_MTA) + finally: + bridge.uninitialize() + # The caller can release its own reference without invalidating factories. + bridge.repeat() + elif case == "worker-handoff": + # Each short-lived worker exits before the next is created. Neither the + # main thread nor the caller holds an explicit MTA across requests. + for index in range(32): + errors: list[BaseException] = [] + + def worker(first=index == 0, errors=errors): + try: + if first: + require( + bridge.apartment()[0] == CO_E_NOTINITIALIZED, + "first worker must start without COM initialization", + ) + else: + require( + bridge.apartment() == IMPLICIT_MTA, "MTA did not survive" + ) + bridge.repeat(2) + except BaseException as error: + errors.append(error) + + thread = threading.Thread(target=worker, daemon=True) + thread.start() + thread.join(timeout=10) + require(not thread.is_alive(), "worker did not complete within 10 seconds") + if errors: + raise errors[0] + bridge.repeat(2) + elif case == "sta-rejection": + bridge.reject_sta() + require( + bridge.apartment()[0] == CO_E_NOTINITIALIZED, + "a rejected STA call must not retain an MTA or unbalance the caller", + ) + bridge.repeat(2) + bridge.reject_sta() + bridge.repeat(2) + bridge.free(None) + print(f"PASS {case}: {bridge.calls} responses released; no VPN access", flush=True) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dll", required=True, type=Path) + parser.add_argument("--case", choices=CASES, help=argparse.SUPPRESS) + arguments = parser.parse_args() + if sys.platform != "win32": + parser.error( + "requires native Windows and a matching-architecture Python and DLL" + ) + dll = arguments.dll.resolve(strict=True) + if arguments.case: + run_case(dll, arguments.case) + return + # A fresh process per case prevents other tests or initialized factories from + # hiding the first-call/last-MTA-thread bug. Timeouts also bound native hangs. + for case in CASES: + subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve()), + "--dll", + str(dll), + "--case", + case, + ], + check=True, + timeout=30, + ) + print("PASS all four isolated Windows COM lifecycle regressions", flush=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/src/vole_scripts/builds.py b/scripts/src/vole_scripts/builds.py index f1b6b61..1f30556 100644 --- a/scripts/src/vole_scripts/builds.py +++ b/scripts/src/vole_scripts/builds.py @@ -6,6 +6,7 @@ import re import shutil import subprocess +import sysconfig import tempfile from pathlib import Path @@ -452,14 +453,23 @@ def _windows_architecture() -> str: ) as key: processor = str(winreg.QueryValueEx(key, "PROCESSOR_ARCHITECTURE")[0]).lower() try: - return {"amd64": "x64", "arm64": "arm64"}[processor] + architecture = {"amd64": "x64", "arm64": "arm64"}[processor] except KeyError as error: raise RuntimeError( f"unsupported native Windows processor architecture: {processor}" ) from error + python_platform = {"x64": "win-amd64", "arm64": "win-arm64"}[architecture] + if sysconfig.get_platform() != python_platform: + raise RuntimeError( + f"Windows {architecture} builds require native {architecture} Python" + ) + return architecture def _windows_msvc_environment(architecture: str) -> dict[str, str]: + if architecture != _windows_architecture(): + raise ValueError("Windows builds require the native host architecture") + component = "ARM64" if architecture == "arm64" else "x86.x64" program_files = os.environ.get("PROGRAMFILES(X86)") if not program_files: raise RuntimeError("ProgramFiles(x86) is unavailable") @@ -473,7 +483,7 @@ def _windows_msvc_environment(architecture: str) -> dict[str, str]: "-products", "*", "-requires", - "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + f"Microsoft.VisualStudio.Component.VC.Tools.{component}", "-find", r"VC\Auxiliary\Build\vcvarsall.bat", ], @@ -484,7 +494,7 @@ def _windows_msvc_environment(architecture: str) -> dict[str, str]: vcvars = next((line.strip() for line in result.stdout.splitlines() if line), None) if not vcvars: raise RuntimeError("Visual Studio C++ tools were not found") - vc_target = "amd64_arm64" if architecture == "arm64" else "amd64" + vc_target = "arm64" if architecture == "arm64" else "amd64" with tempfile.TemporaryDirectory(prefix="vole-msvc-") as directory: command = Path(directory) / "environment.cmd" command.write_bytes( diff --git a/scripts/tests/test_builds.py b/scripts/tests/test_builds.py index d140efd..49b9466 100644 --- a/scripts/tests/test_builds.py +++ b/scripts/tests/test_builds.py @@ -4,7 +4,7 @@ import tempfile import unittest from pathlib import Path -from unittest.mock import patch +from unittest.mock import MagicMock, patch from vole_scripts import builds @@ -98,6 +98,95 @@ def test_windows_cli_and_ffi_select_their_backends(self): ) self.assertEqual("--bins" in command, backend == "uwp") + def test_windows_requires_native_python(self): + registry = MagicMock() + with patch.dict("sys.modules", {"winreg": registry}): + for processor, architecture, native_python in ( + ("AMD64", "x64", "win-amd64"), + ("ARM64", "arm64", "win-arm64"), + ): + registry.QueryValueEx.return_value = (processor, 0) + for python_platform in ("win-amd64", "win-arm64", "win32"): + with ( + self.subTest(host=processor, python=python_platform), + patch.object( + builds.sysconfig, + "get_platform", + return_value=python_platform, + ), + ): + if python_platform == native_python: + self.assertEqual( + builds._windows_architecture(), architecture + ) + else: + with self.assertRaisesRegex( + RuntimeError, "native .* Python" + ): + builds._windows_architecture() + + def test_windows_cli_rejects_cross_architecture_before_compilation(self): + for host, target in ( + ("arm64", "x86_64-pc-windows-msvc"), + ("x64", "aarch64-pc-windows-msvc"), + ): + with ( + self.subTest(host=host), + patch.object(builds.platform, "system", return_value="Windows"), + patch.object(builds, "_windows_architecture", return_value=host), + patch.object(builds.subprocess, "run") as run, + self.assertRaisesRegex(ValueError, "native host architecture"), + ): + builds.build_cli(target) + run.assert_not_called() + self.assertEqual(self.commands, []) + + def test_windows_msvc_selects_native_host_tools(self): + for architecture, vc_target, component in ( + ("x64", "amd64", "x86.x64"), + ("arm64", "arm64", "ARM64"), + ): + + def configure( + command, + *, + architecture=architecture, + vc_target=vc_target, + component=component, + **kwargs, + ): + if "-find" in command: + self.assertIn( + f"Microsoft.VisualStudio.Component.VC.Tools.{component}", + command, + ) + output = "C:\\VS\\vcvarsall.bat\n" + else: + script = Path(command[-1]).read_text() + self.assertIn( + f'call "C:\\VS\\vcvarsall.bat" {vc_target} >nul', script + ) + output = f"Path=tools\nVSCMD_ARG_HOST_ARCH={architecture}\n" + return builds.subprocess.CompletedProcess(command, 0, stdout=output) + + with ( + self.subTest(architecture=architecture), + patch.dict(os.environ, {"PROGRAMFILES(X86)": str(self.root)}), + patch.object( + builds, "_windows_architecture", return_value=architecture + ), + patch.object(builds.subprocess, "run", side_effect=configure), + patch.object( + builds.shutil, + "which", + side_effect=lambda name, **kw: f"tools/{name}", + ), + ): + env = builds._windows_msvc_environment(architecture) + self.assertEqual(env["VSCMD_ARG_HOST_ARCH"], architecture) + if architecture == "arm64": + self.assertEqual(env["CC_aarch64_pc_windows_msvc"], "tools/clang-cl") + def test_android_uses_matching_api_and_cpp_runtime(self): toolchain = self.root / "ndk/toolchain" for target, abi in ( diff --git a/scripts/tests/test_platform_namespaces.py b/scripts/tests/test_platform_namespaces.py new file mode 100644 index 0000000..fba742d --- /dev/null +++ b/scripts/tests/test_platform_namespaces.py @@ -0,0 +1,34 @@ +"""Guard platform namespace source contracts, not native loading or device behavior.""" + +import re +import unittest +from pathlib import Path + +CORE_DIR = Path(__file__).resolve().parents[2] +NAMESPACE = "io.github.yuandevteam.vole" + + +class PlatformNamespaceTests(unittest.TestCase): + def test_android_exports_only_the_canonical_jni_class(self): + source = (CORE_DIR / "src/ffi/android.rs").read_text(encoding="utf-8") + symbols = re.findall( + r'pub (?:unsafe )?extern "system" fn (Java_\w+)\s*\(', source + ) + prefix = "Java_" + NAMESPACE.replace(".", "_") + "_NativeVole_" + self.assertCountEqual( + symbols, + [ + prefix + "nativeInvoke", + prefix + "nativeRegisterProtectController", + prefix + "nativeUnregisterProtectController", + ], + ) + + def test_apple_logging_uses_the_canonical_identity(self): + source = (CORE_DIR / "src/platform/apple_logging.rs").read_text( + encoding="utf-8" + ) + self.assertRegex( + source, rf'(?m)^const SUBSYSTEM: &str = "{re.escape(NAMESPACE)}";$' + ) + self.assertRegex(source, r'(?m)^const CATEGORY: &str = "vole";$') diff --git a/src/ffi/android.rs b/src/ffi/android.rs index b1cddf9..bc9c592 100644 --- a/src/ffi/android.rs +++ b/src/ffi/android.rs @@ -170,7 +170,7 @@ pub unsafe extern "system" fn JNI_OnUnload(_vm: JavaVm, _reserved: *mut c_void) /// supplementary Unicode characters such as emoji. #[unsafe(no_mangle)] #[allow(non_snake_case)] -pub unsafe extern "system" fn Java_io_github_onexray_vole_NativeVole_nativeInvoke( +pub unsafe extern "system" fn Java_io_github_yuandevteam_vole_NativeVole_nativeInvoke( env: JniEnv, _receiver: JObject, request: JByteArray, @@ -195,11 +195,11 @@ pub unsafe extern "system" fn Java_io_github_onexray_vole_NativeVole_nativeInvok /// Registers a runtime-local controller with method `boolean protect(int fd)`. /// -/// `io.github.onexray.vole` is Vole's stable Android namespace. The dispatcher +/// `io.github.yuandevteam.vole` is Vole's stable Android namespace. The dispatcher /// accepts replacement whenever no instance currently holds the Android TUN lease. #[unsafe(no_mangle)] #[allow(non_snake_case)] -pub unsafe extern "system" fn Java_io_github_onexray_vole_NativeVole_nativeRegisterProtectController( +pub unsafe extern "system" fn Java_io_github_yuandevteam_vole_NativeVole_nativeRegisterProtectController( env: JniEnv, _receiver: JObject, controller: JObject, @@ -217,7 +217,7 @@ pub unsafe extern "system" fn Java_io_github_onexray_vole_NativeVole_nativeRegis /// Unregisters the Android controller while no TUN lease is held. #[unsafe(no_mangle)] #[allow(non_snake_case)] -pub extern "system" fn Java_io_github_onexray_vole_NativeVole_nativeUnregisterProtectController( +pub extern "system" fn Java_io_github_yuandevteam_vole_NativeVole_nativeUnregisterProtectController( _env: JniEnv, _receiver: JObject, ) -> JBoolean { diff --git a/src/invoke/mod.rs b/src/invoke/mod.rs index 68fe33c..4b4a7d3 100644 --- a/src/invoke/mod.rs +++ b/src/invoke/mod.rs @@ -23,7 +23,7 @@ use serde_json::{Value, json}; use tokio::sync::oneshot; use crate::{ - BUILD_IDENTITY, ENGINE, Lifecycle, LifecycleState, ResourceLimits, TunFraming, VoleError, + BUILD_IDENTITY, ENGINE, Lifecycle, LifecycleState, ResourceLimits, VoleError, config::Config, data_dir::DataDirectory, dialer::{Dialer, SocketProtector, SystemResolver}, @@ -38,7 +38,10 @@ use crate::{ target_os = "macos", target_os = "linux" ))] -use crate::platform::{TunFd, TunIo}; +use crate::{ + TunFraming, + platform::{TunFd, TunIo}, +}; #[cfg(any( target_os = "android", diff --git a/src/platform/apple_logging.rs b/src/platform/apple_logging.rs index 925f15c..24c24c0 100644 --- a/src/platform/apple_logging.rs +++ b/src/platform/apple_logging.rs @@ -22,7 +22,7 @@ use tracing::{ subscriber::Interest, }; -const SUBSYSTEM: &str = "io.github.onexray.vole"; +const SUBSYSTEM: &str = "io.github.yuandevteam.vole"; const CATEGORY: &str = "vole"; const MAX_EVENT_BYTES: usize = 2 * 1024; const TRUNCATION_MARKER: &str = "..."; @@ -195,8 +195,8 @@ fn os_log_level(level: &Level) -> OsLogLevel { { // Xcode does not reliably forward Default/Info messages from a Packet // Tunnel Extension unless it is attached as the debug executable. - // Match the existing Swift YGLog transport level while retaining the - // real tracing level in the formatted message. + // Use the Error transport level to keep extension diagnostics visible, + // while retaining the real tracing level in the formatted message. let _ = level; return OsLogLevel::Error; } diff --git a/src/windows/host.rs b/src/windows/host.rs index ae16cc6..c723223 100644 --- a/src/windows/host.rs +++ b/src/windows/host.rs @@ -1,5 +1,6 @@ use std::{ ffi::{CString, c_char}, + marker::PhantomData, panic::{AssertUnwindSafe, catch_unwind}, path::PathBuf, ptr, @@ -18,7 +19,10 @@ use windows::{ Storage::ApplicationData, Win32::{ Foundation::E_NOINTERFACE, - System::WinRT::{RO_INIT_MULTITHREADED, RoInitialize, RoUninitialize}, + System::{ + Com::CoIncrementMTAUsage, + WinRT::{RO_INIT_MULTITHREADED, RoInitialize, RoUninitialize}, + }, }, core::{Interface as _, Result as WindowsResult}, }; @@ -34,9 +38,15 @@ use crate::{config::Config, platform::validate_packet_channel_config}; const BRIDGE_VERSION: u32 = 3; const MAX_REQUEST_BYTES: usize = 1024 * 1024; const MAX_ERROR_BYTES: usize = 4096; -const PROFILE_NAME: &str = "Vole"; +const DEFAULT_PROFILE_NAME: &str = "Vole"; +const MAX_PROFILE_NAME_BYTES: usize = 256; const STARTUP_TASK_ID: &str = "VoleStartup"; static COMMAND: Mutex<()> = Mutex::new(()); +// windows-rs caches agile WinRT factories for process lifetime. Keep one opaque +// MTA usage cookie for the same lifetime, including gaps between caller threads. +// Never decrement it per request or during DLL/process teardown: cached factories +// could still be used, and COM shutdown under the loader lock is unsupported. +static MTA_USAGE: Mutex> = Mutex::new(None); #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -50,9 +60,44 @@ struct BridgeRequest { #[serde(deny_unknown_fields)] struct EmptyPayload {} +/// A host-selected Windows profile name, never a filesystem path or session ID. +struct ProfileName(String); + +impl Default for ProfileName { + fn default() -> Self { + Self(DEFAULT_PROFILE_NAME.to_owned()) + } +} + +impl<'de> Deserialize<'de> for ProfileName { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let name = String::deserialize(deserializer)?; + if name.is_empty() + || name.len() > MAX_PROFILE_NAME_BYTES + || name.trim() != name + || name.chars().any(char::is_control) + { + return Err(serde::de::Error::custom("invalid Windows VPN profile name")); + } + Ok(Self(name)) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProfilePayload { + #[serde(default)] + profile_name: ProfileName, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct StartPayload { + #[serde(default)] + profile_name: ProfileName, config_yaml: String, network_settings: WindowsNetworkSettings, policy: WindowsVpnPolicy, @@ -86,13 +131,23 @@ struct ProfileMatch { status: VpnManagementConnectionStatus, } -struct WinRtGuard; +// The per-call initialization must be released on its original thread. +struct WinRtGuard(PhantomData<*mut ()>); impl WinRtGuard { fn enter() -> Result { - unsafe { RoInitialize(RO_INIT_MULTITHREADED) } - .map(|()| Self) - .map_err(display_error) + // Reject STA/ASTA before acquiring the process-wide MTA reference. + unsafe { RoInitialize(RO_INIT_MULTITHREADED) }.map_err(display_error)?; + let guard = Self(PhantomData); + let mut cookie = MTA_USAGE + .lock() + .map_err(|_| "Windows host MTA lifetime lock is poisoned".to_owned())?; + if cookie.is_none() { + // This reference outlives all per-call guards. On failure the guard + // still balances RoInitialize, and a later call can retry retention. + *cookie = Some(unsafe { CoIncrementMTAUsage() }.map_err(display_error)?.0 as usize); + } + Ok(guard) } } @@ -141,15 +196,9 @@ fn invoke_bytes(request: &[u8]) -> Result { decode_payload::(request.payload)?; get_environment() } - "getVpnStatus" => { - decode_payload::(request.payload)?; - get_vpn_status() - } + "getVpnStatus" => get_vpn_status(decode_payload(request.payload)?), "startVpn" => start_vpn(decode_payload(request.payload)?), - "stopVpn" => { - decode_payload::(request.payload)?; - stop_vpn() - } + "stopVpn" => stop_vpn(decode_payload(request.payload)?), "getStartupTaskStatus" => { decode_payload::(request.payload)?; get_startup_task_status() @@ -167,15 +216,16 @@ fn get_environment() -> Result { })) } -fn get_vpn_status() -> Result { +fn get_vpn_status(payload: ProfilePayload) -> Result { let environment = package_environment()?; let agent = VpnManagementAgent::new().map_err(display_error)?; - let profile = find_profile(&agent, &environment.family_name)?; + let profile = find_profile(&agent, &environment.family_name, &payload.profile_name.0)?; profile_status_data(profile.as_ref()) } fn start_vpn(payload: StartPayload) -> Result { let StartPayload { + profile_name, config_yaml, network_settings, policy, @@ -209,7 +259,7 @@ fn start_vpn(payload: StartPayload) -> Result { 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)?; + let existing = find_profile(&agent, &environment.family_name, &profile_name.0)?; if let Some(existing) = &existing { match existing.status { @@ -249,6 +299,7 @@ fn start_vpn(payload: StartPayload) -> Result { configure_profile( &profile, &environment.family_name, + &profile_name.0, &profile_configuration_json, always_on, )?; @@ -278,10 +329,11 @@ fn start_vpn(payload: StartPayload) -> Result { Ok(json!({"status": "connected", "snapshotToken": token})) } -fn stop_vpn() -> Result { +fn stop_vpn(payload: ProfilePayload) -> Result { let environment = package_environment()?; let agent = VpnManagementAgent::new().map_err(display_error)?; - let Some(profile) = find_profile(&agent, &environment.family_name)? else { + let Some(profile) = find_profile(&agent, &environment.family_name, &payload.profile_name.0)? + else { return Ok(json!({"status": "disconnected", "snapshotToken": null})); }; if profile.status == VpnManagementConnectionStatus::Disconnected { @@ -371,6 +423,7 @@ fn package_environment() -> Result { fn find_profile( agent: &VpnManagementAgent, family_name: &str, + profile_name: &str, ) -> Result, String> { let profiles = agent .GetProfilesAsync() @@ -386,14 +439,17 @@ fn find_profile( continue; } let status = plugin.ConnectionStatus().map_err(display_error)?; - if plugin.ProfileName().map_err(display_error)? != PROFILE_NAME { - if status != VpnManagementConnectionStatus::Disconnected { - return Err("unknown package-owned Windows VPN profile is active".to_owned()); - } + if !matches_profile_name( + profile_name, + &plugin.ProfileName().map_err(display_error)?.to_string(), + status, + )? { continue; } if found.is_some() { - return Err("multiple package-owned Vole VPN profiles exist".to_owned()); + return Err( + "multiple package-owned Windows VPN profiles match the requested name".to_owned(), + ); } found = Some(ProfileMatch { profile: plugin, @@ -403,6 +459,21 @@ fn find_profile( Ok(found) } +/// Only called for profiles whose package family has already been verified. +fn matches_profile_name( + requested: &str, + actual: &str, + status: VpnManagementConnectionStatus, +) -> Result { + if actual == requested { + return Ok(true); + } + if status != VpnManagementConnectionStatus::Disconnected { + return Err("another package-owned Windows VPN profile is active".to_owned()); + } + Ok(false) +} + fn plugin_profile( cast: WindowsResult, ) -> Result, String> { @@ -416,11 +487,12 @@ fn plugin_profile( fn configure_profile( profile: &VpnPlugInProfile, family_name: &str, + profile_name: &str, profile_configuration: &str, always_on: bool, ) -> Result<(), String> { profile - .SetProfileName(&PROFILE_NAME.into()) + .SetProfileName(&profile_name.into()) .and_then(|()| profile.SetVpnPluginPackageFamilyName(&family_name.into())) .and_then(|()| profile.SetCustomConfiguration(&profile_configuration.into())) .and_then(|()| profile.SetAlwaysOn(always_on)) @@ -550,6 +622,124 @@ mod tests { ); } + fn start_payload() -> Value { + json!({ + "configYaml": "tun:\n enable: true\n", + "networkSettings": { + "ipv4Address": "192.168.8.1", + "ipv6Address": "fd00:8::2", + "dnsIpv4Address": "223.5.5.5", + "dnsIpv6Address": "2400:3200::1" + }, + "policy": default_policy() + }) + } + + #[test] + fn profile_payload_defaults_and_custom_names_are_shared_by_lifecycle_commands() { + let status_or_stop: ProfilePayload = decode_payload(json!({})).unwrap(); + let start: StartPayload = decode_payload(start_payload()).unwrap(); + assert_eq!(status_or_stop.profile_name.0, "Vole"); + assert_eq!(start.profile_name.0, "Vole"); + + for name in [ + "Example VPN".to_owned(), + "示例 VPN".to_owned(), + "v".repeat(256), + "名".repeat(85), + ] { + let status_or_stop: ProfilePayload = + decode_payload(json!({"profileName": name})).unwrap(); + let mut input = start_payload(); + input["profileName"] = json!(name); + let start: StartPayload = decode_payload(input).unwrap(); + assert_eq!(status_or_stop.profile_name.0, name); + assert_eq!(start.profile_name.0, name); + } + } + + #[test] + fn profile_payload_rejects_invalid_names_without_echoing_input() { + for name in [ + Value::Null, + json!(false), + json!(123), + json!([]), + json!({}), + json!(""), + json!(" "), + json!(" Example VPN"), + json!("Example VPN\u{3000}"), + json!("Example\nVPN"), + json!("Example\0VPN"), + json!("Example\u{007f}VPN"), + json!("v".repeat(257)), + json!("名".repeat(86)), + ] { + let status_or_stop = decode_payload::(json!({"profileName": name})); + let mut input = start_payload(); + input["profileName"] = name; + let start = decode_payload::(input); + assert_eq!( + status_or_stop.err().as_deref(), + Some("invalid Windows host payload") + ); + assert_eq!(start.err().as_deref(), Some("invalid Windows host payload")); + } + } + + #[test] + fn profile_payload_keeps_unknown_fields_and_unrelated_methods_strict() { + assert!( + decode_payload::(json!({ + "profileName": "Example VPN", "unknown": true + })) + .is_err() + ); + let mut input = start_payload(); + input["profileName"] = json!("Example VPN"); + input["unknown"] = json!(true); + assert!(decode_payload::(input).is_err()); + assert!(decode_payload::(json!({"profileName": "Example VPN"})).is_err()); + assert!( + decode_payload::(json!({ + "enabled": true, "profileName": "Example VPN" + })) + .is_err() + ); + } + + #[test] + fn profile_lookup_matches_names_exactly_and_preserves_the_single_session_guard() { + for status in [ + VpnManagementConnectionStatus::Disconnected, + VpnManagementConnectionStatus::Connecting, + VpnManagementConnectionStatus::Connected, + VpnManagementConnectionStatus::Disconnecting, + ] { + assert!(matches_profile_name("Example VPN", "Example VPN", status).unwrap()); + for other in ["Vole", "example vpn", "Other VPN"] { + let result = matches_profile_name("Example VPN", other, status); + if status == VpnManagementConnectionStatus::Disconnected { + assert!(!result.unwrap()); + } else { + assert_eq!( + result.unwrap_err(), + "another package-owned Windows VPN profile is active" + ); + } + } + } + assert!( + !matches_profile_name( + "Café", + "Cafe\u{0301}", + VpnManagementConnectionStatus::Disconnected + ) + .unwrap() + ); + } + #[test] fn start_payload_accepts_only_strict_external_network_settings() { let payload: StartPayload = decode_payload(json!({ @@ -691,8 +881,14 @@ mod tests { let _winrt = WinRtGuard::enter().unwrap(); let profile = VpnPlugInProfile::new().unwrap(); - configure_profile(&profile, "example.family", "{}", true).unwrap(); + configure_profile(&profile, "example.family", "示例 VPN", "{}", true).unwrap(); + assert_eq!(profile.ProfileName().unwrap(), "示例 VPN"); + assert_eq!( + profile.VpnPluginPackageFamilyName().unwrap(), + "example.family" + ); + assert_eq!(profile.CustomConfiguration().unwrap(), "{}"); assert!(profile.AlwaysOn().unwrap()); } diff --git a/tests/README.md b/tests/README.md index f34c5af..d7112bb 100644 --- a/tests/README.md +++ b/tests/README.md @@ -19,13 +19,39 @@ uv run --project scripts --locked ruff check scripts uv run --project scripts --locked ruff format --check scripts ``` +Windows UWP bridge 的名称准入与匹配使用独立过滤器;下列测试仅处理 JSON、匹配策略 +和未注册的内存 `VpnPlugInProfile` 对象,不创建系统 VPN profile 或启动连接: + +```sh +cargo test --locked --no-default-features --features ffi,windows-uwp --lib windows::host::tests:: +``` + +构建 UWP DLL 后,可在未打包的 Windows 进程中执行真实 C ABI 的 COM 生命周期回归: + +```sh +python scripts/check_windows_com.py --dll dist/windows/arm64/uwp/vole.dll +``` + +此命令只在原生 Windows ARM64 执行。原生 x64 主机使用本机的 Python 和 +`dist/windows/x64/uwp/vole.dll`。宿主、Python 和 DLL 必须同架构;不得通过模拟运行跨架构验证。 +该脚本只需 Python 标准库。CI 的各个 Windows job 通过 `actions/setup-python` 安装本架构的 +Python,直接运行此检查,仅验证各自构建的产物。 +四个独立子进程覆盖未初始化线程连续查询、调用方 MTA 的保留与释放、短线程退出/切换和 STA 拒绝, +同时检查显式初始化计数与 `VoleFree` 响应所有权。测试先用 Win32 确认没有包身份, +仅查询环境/状态并断言包身份错误,不读取 VPN profile、启动连接或访问网络。 +父进程为每个用例设置超时。UWP 发布构建在上传归档前执行这些检查;通过不代表包内验收。 + +在 Windows 上,`windows-uwp` 与 `windows-wintun` 互斥,编译与 Clippy 应明确选择 +所测后端而非 `--all-features`。名称选择仍需已安装包验证,不以对象级测试代替。 + 宿主只执行明确的纯内存过滤器;全目标使用 `--no-run`。 `invoke::tests::` 含监听器用例,不可整体在宿主执行;可执行过滤器以 [Tests workflow](../.github/workflows/test.yml) 为准。 协议独立向量见 [protocols](protocols/README.md),ClientHello 输入见 [fingerprints](fingerprints/README.md)。 脚本测试保留平台文件输出、Windows 后端选择、Android ABI/runtime、Apple 切片、 -真实归档读写。外部编译命令由夹具替代,原生编译由 CI 平台矩阵执行。 +真实归档读写,以及 Android JNI 导出与 Apple 日志命名空间的源码契约检查。 +外部编译命令由夹具替代,原生编译由 CI 平台矩阵执行;源码检查不替代 JNI 加载或设备验证。 CI 的核心回归分别使用 Debug 和 `ci-release`;后者继承 Release 优化并关闭 LTO, 正式产物使用标准 `release`。Quality 负责格式、Clippy、feature 编译和依赖来源检查。