diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..990e2c1
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,24 @@
+name: Tests
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: windows-2025
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v7
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: ilammy/msvc-dev-cmd@v1
+ with:
+ arch: x64
+ - name: Build default features
+ run: cargo build --locked --all-targets
+ - name: Unit tests
+ run: cargo test --locked --all-features --all-targets
diff --git a/AGENTS.md b/AGENTS.md
index b31b22d..1ac348c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
# Project Overview
-VCore is a standalone Rust proxy core. The current public contract is Invoke API v5 with internal schema revision 12. Runtime configuration uses the strict schema documented in `docs/config.yaml` and is passed inline as `configYaml` / `configYamls`; YAML contains neither `configVersion` nor `default-proxy`. Public lifecycle state is runtime-local and single-instance.
+VCore is a standalone Rust proxy core. The current public contract is Invoke API v5 with internal schema revision 13. Runtime configuration uses the strict schema documented in `docs/config.yaml` and is passed inline as `configYaml` / `configYamls`; YAML contains neither `configVersion` nor `default-proxy`. Public lifecycle state is runtime-local and single-instance.
Apple and Android use host-owned TUN fds through the Unix `rust-tun` adapter. Windows uses `windows-rs` / `Windows.Networking.Vpn`; the packaged ARM64 foreground, AppContainer provider, per-session full-trust runtime, lifecycle, pressure, and packet-channel gates pass on Windows 11. Windows 10, native x64, physical IPv6, WACK, and Store publishing remain release gates. Linux remains unsupported.
@@ -11,9 +11,9 @@ Current source, tests, and the public contract documents under `docs/` define im
Read the relevant document completely before changing that area:
- FFI, lifecycle, Android protect, or config delivery: `docs/invoke-api.md` and `src/ffi/`.
-- YAML, proxy graph, DNS, rules, or sniffer: `docs/config.yaml`, `docs/tun-icmp-dns.md`, and `src/config/`.
+- YAML, proxy graph, proxy groups, DNS, rules, or sniffer: `docs/config.yaml`, `docs/tun-icmp-dns.md`, and `src/config/`.
- AnyTLS: `docs/anytls.md`.
-- TUN traffic metrics: `docs/controller-api.md`.
+- Runtime Controller, proxy-group selection, or TUN traffic metrics: `docs/controller-api.md` and `src/controller.rs`.
- GeoData: `docs/geodata.md`.
- REALITY or the GitHub rustls fork: `docs/reality-wire-protocol.md` and `docs/rustls-reality-release.md`.
- Unix TUN fd ownership or packet I/O: `docs/tun-platform.md`.
@@ -28,6 +28,7 @@ Read the relevant document completely before changing that area:
- `src/tun_runtime.rs` connects platform raw-IP I/O to `vcore-netstack` and dispatches TCP, UDP, DNS, ICMP, and sniffing.
- `src/platform/` contains platform adapters. Keep Windows callback semantics here instead of simulating a Unix fd.
- `src/dialer.rs` is the shared physical TCP/UDP socket seam. Fix socket protection or Windows `(source IP, interface index)` binding once here rather than in each outbound.
+- Static `select` proxy groups are mutable Running Session route targets at the routing `Dispatcher` seam. Keep `dialer-proxy` and `measureDelay` node-only, and do not move group selection into the immutable outbound connector graph.
- `crates/vcore-netstack` is platform-independent raw-IP state and must not depend on WinRT, JNI, Swift, or host UI frameworks.
- Core runtime lifecycle does not infer App, extension, service, or daemon roles and does not implement cross-process state or IPC. The Windows-only host Invoke is the explicit package integration seam for profile/status/Session Snapshot/StartupTask operations and an optional bounded `sessionBackend`; its Session Host owns backend process liveness without interpreting arguments, files, ports, or protocols. Provider runtime state remains process-local.
diff --git a/CONTEXT.md b/CONTEXT.md
index d21066b..1c752e0 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -13,8 +13,8 @@ The VCore runtime serving one active Windows tunnel session inside the Windows s
_Avoid_: Foreground runtime, provider runtime, external core
**Windows session host**:
-The hidden packaged full-trust Application that owns one Windows session runtime and its optional Windows session backend independently of the foreground host.
-_Avoid_: Foreground process, provider host, external-core host
+The packaged full-trust process registered under the product's single Application that owns one Windows session runtime and its optional Windows session backend independently of the foreground host.
+_Avoid_: Helper Application, foreground process, provider host, external-core host
**Windows session backend**:
The optional ordered set of package-local processes whose lifetime is owned by one Windows session host. VCore supervises process liveness but does not interpret their arguments, files, ports, or protocols.
@@ -51,3 +51,19 @@ _Avoid_: Proxy traffic, transport traffic, per-node traffic
**Local SOCKS5 outbound**:
A normal VCore SOCKS5 outbound to a loopback server. The server may be managed outside VCore or happen to run in a Windows session backend; SOCKS5 readiness and protocol health remain outside the backend contract.
_Avoid_: Managed core, child core, URI configuration
+
+**Proxy node**:
+A named, concrete outbound protocol path that can be selected as a route target or referenced by another proxy node through `dialer-proxy`.
+_Avoid_: Proxy when a proxy group or route target is intended, server profile
+
+**Proxy group**:
+A named route target whose current group selection identifies one direct member route target.
+_Avoid_: Folder, provider, subscription
+
+**Route target**:
+A proxy node, proxy group, or reserved built-in target that routing rules, the final `MATCH`, and DNS routes can select.
+_Avoid_: Proxy when the distinction between a node and group matters, dialer
+
+**Group selection**:
+The running-session state identifying the direct member route target currently chosen by a proxy group.
+_Avoid_: Variant, configuration switch, active profile
diff --git a/Cargo.toml b/Cargo.toml
index 14c5db9..f072eca 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -102,7 +102,6 @@ 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/README.md b/README.md
index cdf4170..0888ae6 100644
--- a/README.md
+++ b/README.md
@@ -4,12 +4,13 @@
English · 简体中文 · Русский
-VCore is a standalone, host-agnostic Rust client proxy core. It provides proxy graphs, DNS, routing rules, GeoData, an HTTP listener, a TUN data plane, and traffic statistics through strict YAML configuration and Invoke API v5. The internal configuration schema revision is 12; the revision appears only in the `version` response and `buildIdentity`, not in YAML.
+VCore is a standalone, host-agnostic Rust client proxy core. It provides proxy graphs, static `select` proxy groups, DNS, routing rules, GeoData, an HTTP listener, a TUN data plane, and a loopback Controller through strict YAML configuration and Invoke API v5. The internal configuration schema revision is 13; the revision appears only in the `version` response and `buildIdentity`, not in YAML.
## Features
- Outbounds: VLESS + XHTTP + TLS/REALITY, SOCKS5 CONNECT/UDP ASSOCIATE, AnyTLS TCP/UoT, and DIRECT.
- Proxy chains: `dialer-proxy` forms a directed acyclic graph of arbitrary length. If node A points to B, the physical path is `client -> B -> A -> target`.
+- Proxy groups: static `select` groups keep ordered members, including concrete nodes, nested groups, `DIRECT`, and `REJECT`; their current-session selection can be changed live through the Controller. `dialer-proxy` remains node-only.
- Routing: ordered `DOMAIN`, `DOMAIN-SUFFIX`, `DOMAIN-KEYWORD`, `GEOSITE`, `GEOIP`, `IP-CIDR`, `IP-CIDR6`, `DST-PORT`, `NETWORK`, and final `MATCH` rules.
- DNS: fixed-IP UDP/TCP nameservers, explicit outbounds, ordered policy/failover, typed and opaque caches, singleflight, and TUN UDP/TCP port 53 interception.
- TUN: raw IPv4/IPv6, TCP/UDP, local ICMPv4/ICMPv6 Echo replies, HTTP/TLS/QUIC sniffing, and four per-session traffic counters.
@@ -23,9 +24,10 @@ VCore is a standalone, host-agnostic Rust client proxy core. It provides proxy g
- YAML is limited to 256 KiB and rejects unknown fields, anchors, aliases, custom tags, and obsolete structures.
- The top level must contain at least one proxy and either `port` or an enabled `tun`.
-- Proxy `name` values are case-sensitive and unique; every reference must exist, and the proxy graph must be acyclic.
-- `rules` is required and must end with exactly one `MATCH` targeting a real proxy name.
-- `DIRECT` and `REJECT` are built-in actions; every other action must be a real proxy name.
+- Proxy and group definition names share one exact, case-sensitive namespace. Names are 1–64 UTF-8 bytes, reject surrounding Unicode whitespace, controls, `, # / ? & = % \`, `.` and `..`, and reserve `DIRECT`, `REJECT`, and `RULES`; internal ordinary spaces, CJK, and emoji are allowed.
+- `proxy-groups` accepts only `select`. Member order and duplicates are preserved; an omitted `default-selected` selects the first member, while an explicit value must name a direct member. Proxy chains and nested groups must each be acyclic.
+- `rules` is required and must end with exactly one `MATCH` targeting a configured proxy node or proxy group.
+- `DIRECT` and `REJECT` are built-in actions and group members; every other route target must be a configured proxy node or proxy group. DNS alone also reserves `RULES`.
- Configuration is delivered inline through `configYaml` / `configYamls`; VCore does not read host configuration paths.
- Runtime values such as the Controller, TUN fd, Controller port, and secret are generated by the host and are not stored in user RAW YAML.
@@ -57,14 +59,18 @@ initialize
`instanceId` is a generation token that is never reused by the current runtime. Commands for the same instance fail fast when another command is active; pure `validateConfig` calls may run concurrently. See [`docs/invoke-api.md`](docs/invoke-api.md) for the complete envelope, methods, fd ownership, and Android protect contract.
-TUN traffic is queried through a session-local loopback Controller:
+Runtime state is exposed through a session-local loopback Controller:
```http
GET /traffic
+GET /group
+GET /group/{name}
+GET /proxies/{name}
+PUT /proxies/{name}
Authorization: Bearer
```
-The response is a one-time `up/down/upTotal/downTotal` snapshot, not a continuous stream. See [`docs/controller-api.md`](docs/controller-api.md).
+`GET /traffic` is a one-time `up/down/upTotal/downTotal` TUN snapshot. The group endpoints expose and change the selected direct member of static `select` groups; a successful change affects only new physical TCP, UDP, and DNS transports in the current session. It does not migrate existing connections, UDP associations, DNS state, or pooled TCP transports, and it never performs automatic failover. A Controller that manages groups requires one Bearer secret for all routes and may run without TUN. See [`docs/controller-api.md`](docs/controller-api.md).
## Platforms
@@ -103,7 +109,7 @@ TCP sessions, ordinary UDP associations, half-open connections, outbound handsha
- [AnyTLS outbound](docs/anytls.md)
- [REALITY V1 client protocol](docs/reality-wire-protocol.md)
- [rustls REALITY dependency and release requirements](docs/rustls-reality-release.md)
-- [TUN traffic Controller](docs/controller-api.md)
+- [Runtime Controller](docs/controller-api.md)
- [TUN ICMP and DNS](docs/tun-icmp-dns.md)
- [GeoData rules and assets](docs/geodata.md)
- [TUN platform layer](docs/tun-platform.md)
diff --git a/docs/README.md b/docs/README.md
index 48bc21e..d809a4d 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -4,12 +4,12 @@
## 公共契约
-1. [配置协议](config.yaml):配置修订版 12 的完整 YAML 示例和严格字段边界。
+1. [配置协议](config.yaml):配置修订版 13 的完整 YAML 示例、静态 `select` 代理组和严格字段边界。
2. [Invoke API](invoke-api.md):API v5 的请求格式、生命周期和平台回调。
3. [AnyTLS 出站](anytls.md):TLS、会话复用、填充、TCP/UoT 和清理语义。
4. [REALITY V1 协议](reality-wire-protocol.md):握手、认证、连接状态和失败边界。
-5. [TUN 流量 Controller](controller-api.md):Bearer 鉴权和四字段流量快照。
-6. [DNS 与 ICMP](tun-icmp-dns.md):TUN DNS、缓存、故障转移和本地 Echo Reply。
+5. [运行时 Controller](controller-api.md):Bearer 鉴权、实时代理组选择和四字段 TUN 流量快照。
+6. [DNS 与 ICMP](tun-icmp-dns.md):TUN DNS、代理组出口、缓存、故障转移和本地 Echo Reply。
7. [GeoData](geodata.md):规则、资产更新、匹配器和资源上限。
8. [TUN 平台层](tun-platform.md):文件描述符、原始 IP 包和平台所有权。
diff --git a/docs/acceptance.md b/docs/acceptance.md
index 8a3b3a9..68807bf 100644
--- a/docs/acceptance.md
+++ b/docs/acceptance.md
@@ -18,14 +18,14 @@
当前测试覆盖以下公共边界:
- Invoke API v5 envelope、严格 payload、单实例生命周期、panic 隔离和同步清理;
-- 配置修订版 12、IPv6 总开关、代理图、节点测速配置和未知字段拒绝;
+- 配置修订版 13、IPv6 总开关、代理图、静态 `select` 代理组、节点测速配置和未知字段拒绝;
- VLESS/XHTTP/TLS/REALITY、SOCKS5、AnyTLS 和代理链;
- DNS wire、缓存、singleflight、policy、故障转移和 TCP 复用;
- 规则、GeoData、HTTP/TLS/QUIC 嗅探;
- ICMPv4/ICMPv6 Echo、校验和、分片、MTU 和队列满;
- Apple/Android TUN 帧格式、文件描述符副本和关闭所有权;
-- Windows 控制/数据协议、Session Snapshot v2、会合记录、包队列、批量写入、物理网络绑定和 Job Object 多进程监督;
-- Controller 鉴权、速率和累计流量语义。
+- Windows 单 Application manifest、Provider/Session Host token 绑定、控制/数据协议、Session Snapshot v2、会合记录、包队列、批量写入、物理网络绑定和 Job Object 多进程监督;
+- Controller 鉴权、速率和累计流量语义,以及代理组查询、实时选择和有界请求处理。
常用命令:
@@ -70,6 +70,7 @@ Windows 11 ARM64 开发验收的环境、命令、结果和适用范围保存在
| DNS / rules / GeoData / sniffer | 已覆盖 | 本地 DNS 与代理 fixture | Windows ARM64 已覆盖 |
| ICMPv4 / ICMPv6 Echo | 已覆盖 | 不适用 | Windows ARM64 已覆盖 |
| Controller 四字段流量 | 已覆盖 | HTTP fixture | Windows ARM64 已覆盖 |
+| Controller `select` 代理组控制 | 已覆盖 | HTTP fixture | 物理设备未验证 |
“本地互操作”只证明当前双方在受控配置下可以通信,不代表所有公网服务或配置组合。
@@ -93,7 +94,20 @@ Windows 11 ARM64 开发验收的环境、命令、结果和适用范围保存在
## Windows VPN
-已验证范围限于 Windows 11 ARM64 开发签名安装包:
+单 Application 可行性在当前 Windows 11 ARM64 build 26200.9278 机器的 Developer Mode loose-package spike 上验证。该 spike 以 `042919ab5ead6af719ae68564244966e95003b58` 为基线,并包含后来收敛为 `d9018a2dfb75ab1f55c593023cbaa60951165eb5` 的未提交目标改动;基线 SHA 本身不能复现该结果。实际执行路径为:
+
+```powershell
+Add-AppxPackage -Register \AppxManifest.xml
+vcore-uwp-demo.exe environment
+vcore-uwp-demo.exe status
+vcore-uwp-demo.exe start
+vcore-uwp-demo.exe status
+vcore-uwp-demo.exe stop
+```
+
+观察结果是 manifest 只有一个 Application,Provider 是 AppContainer,Provider 通过无参数 `FullTrustProcessLauncher` 启动具有同一 package identity 的 medium-integrity Session Host,connect/stop 与 rendezvous 清理通过。该记录只证明本机可行性,不作为其他系统、架构、签名包、WACK 或 Store 的验证结论。
+
+此前 Windows 11 ARM64 开发签名包的数据面证据覆盖:
- `Windows.Networking.Vpn` Provider 激活;
- 完全信任前台宿主、AppContainer Provider 和完全信任 Session Host 的进程边界;
@@ -103,13 +117,13 @@ Windows 11 ARM64 开发验收的环境、命令、结果和适用范围保存在
- 非回环 socket 的物理源地址与接口索引绑定;
- Controller、外部回环 SOCKS5 和前台宿主退出后的会话延续;
- Provider/Session Host 退出、管道错误和网络变化时的失败关闭;
-- 快速重连、持续压力、零队列丢弃和显式 Stop 清理;
-- 断开状态下的安装包原位升级和签名校验。
+- 快速重连、持续压力、零队列丢弃和显式 Stop 清理。
Windows 路由必须保留两条 `/1`。在安装包环境中,单条 VPN `/0` 会使按产品要求绑定物理源地址和接口的外层 socket 返回 `WSAENETUNREACH`;两条 `/1` 不会产生该问题。
-尚未验证或完成:
+以下项目由发布开发者在对应机器或服务中验证,不阻塞上述本机可行性结论:
+- 单 Application test-signed MSIX 安装;
- Windows 10 20H2;
- 原生 x64 Windows;
- 真实物理 IPv6;
diff --git a/docs/adr/0003-run-windows-runtime-in-a-full-trust-session-host.md b/docs/adr/0003-run-windows-runtime-in-a-full-trust-session-host.md
index e052264..cebc736 100644
--- a/docs/adr/0003-run-windows-runtime-in-a-full-trust-session-host.md
+++ b/docs/adr/0003-run-windows-runtime-in-a-full-trust-session-host.md
@@ -1,5 +1,5 @@
---
-status: accepted
+status: superseded by ADR-0007
---
# ADR 0003:在完全信任的 Session Host 中运行 Windows 会话
diff --git a/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md b/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md
new file mode 100644
index 0000000..b52000b
--- /dev/null
+++ b/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md
@@ -0,0 +1,11 @@
+---
+status: accepted
+---
+
+# ADR 0005:将 select 代理组建模为会话路由目标
+
+VCore 把首版 `select` 代理组建模为位于不可变代理节点与 `dialer-proxy` 图之上的命名路由目标。组拥有一个有序、非空的直接成员列表,成员可以是代理节点、嵌套 `select` 组、`DIRECT` 或 `REJECT`;省略 `default-selected` 时选择第一项,显式值必须精确命中一个直接成员。普通规则、最终 `MATCH` 和 DNS 路由可以引用组,`dialer-proxy` 与 `measureDelay` 仍然只接受代理节点。
+
+组选择只属于当前 VCore 运行会话,由调用方在下一次配置中重新提供。每次 `connect_tcp` 或 `open_datagram` 进入组时只读取一次选择;成功切换只影响提交后才进入组的新调用,不迁移已有 TCP、不替换已有 UDP transport,也不清理 DNS cache 或已有 DNS TCP connection。已经读取旧选择但仍在建链的调用继续使用旧成员;被选成员失败时原样返回错误,不在组内隐式 failover。
+
+代理组位于 `Dispatcher` seam,具体协议节点及其 `dialer-proxy` graph 继续由 `OutboundConnector` 实现。这个边界避免让运行时选择渗入协议建链,并使“组不能作为 `dialer-proxy`”成为明确、可验证的约束。
diff --git a/docs/adr/0006-control-live-proxy-group-selection-through-controller.md b/docs/adr/0006-control-live-proxy-group-selection-through-controller.md
new file mode 100644
index 0000000..8fd361a
--- /dev/null
+++ b/docs/adr/0006-control-live-proxy-group-selection-through-controller.md
@@ -0,0 +1,11 @@
+---
+status: accepted
+---
+
+# ADR 0006:只通过 Controller 控制代理组的实时选择
+
+VCore 首版只通过 Controller 暴露代理组的实时选择,不新增 Invoke 方法,也不扩展 Windows 控制管道。Controller 采用 mihomo `select` 的核心调用形状:`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}`;PUT body 为 `{"name":"member"}`,成功返回空的 `204`。组状态只承诺 `name`、`type`、`all`、`now`,不宣称兼容完整 mihomo Dashboard API。
+
+Windows 的实际运行时位于独立 Session Host,Apple 与 Android 的运行时位于 Invoke owner 背后;Controller 是现有平台中唯一能统一到达实际运行会话的 Adapter。组路由可以在无 TUN 的本地代理运行时中使用,`/traffic` 仍只在存在 TUN 统计时提供。
+
+配置同时包含代理组和 Controller 时,必须为整个 Controller 配置 Bearer secret。请求使用精确名称和严格、有界的 JSON,任何失败都不得改变选择。没有 Controller 的代理组配置仍然有效,但本次会话中没有公开的实时切换入口;调用方在成功切换后自行持久化下一次启动所需的 `default-selected`,持久化失败不回滚已经成功的运行时选择。
diff --git a/docs/adr/0007-activate-windows-session-host-from-vpn-provider.md b/docs/adr/0007-activate-windows-session-host-from-vpn-provider.md
new file mode 100644
index 0000000..873344e
--- /dev/null
+++ b/docs/adr/0007-activate-windows-session-host-from-vpn-provider.md
@@ -0,0 +1,7 @@
+---
+status: accepted
+---
+
+# Activate the Windows Session Host from the VPN Provider
+
+A package has one Application but keeps separate full-trust foreground, AppContainer Provider, and full-trust Session Host processes. The Provider launches the Session Host through parameterless `FullTrustProcessLauncher`; the rendezvous token remains untrusted until the Provider binds it to the profile token during the existing handshake, preserving Windows 10 support without another protocol or compatibility path.
diff --git a/docs/config.yaml b/docs/config.yaml
index 7b2aeac..76680aa 100644
--- a/docs/config.yaml
+++ b/docs/config.yaml
@@ -1,6 +1,6 @@
-# VCore 配置修订版 12 的完整示例。
+# VCore 配置修订版 13 的完整示例。
#
-# `version.configVersion` 返回的 12 是二进制报告值,不是 YAML 字段。配置只接受
+# `version.configVersion` 返回的 13 是二进制报告值,不是 YAML 字段。配置只接受
# 本文列出的结构;未知字段、anchor、alias 和自定义 tag 都会失败。
#
# 宿主先通过 Invoke API v5 调用 initialize({dataDir}),再通过 configYaml 内联
@@ -14,7 +14,8 @@ ipv6: true
# GeoData 下载配置可省略;出现时三项必须齐全。URL 必须是以域名为主机、无
# userinfo/fragment 的绝对 HTTPS URL,更新周期固定为 24 小时。validateConfig 和
# measureDelay 不下载。公共实例启动后,只有实际规则需要资产且自动更新已启用时
-# 才后台检查。下载使用最终 MATCH 选中的完整代理链,不回退 DIRECT。
+# 才后台检查。下载使用最终 MATCH route target 在创建新物理 transport 时解析到的
+# 当前节点、DIRECT 或 REJECT;代理组不自动故障转移,也不回退 DIRECT。
geox-url:
geoip: https://assets.example.com/geoip.dat
geosite: https://assets.example.com/geosite.dat
@@ -28,9 +29,12 @@ port: 1080
authentication:
- "probe-user:probe-password"
-# 可选的 TUN 流量 Controller。只允许配合 tun.enable: true 使用,地址必须是
-# 回环地址。secret 省略时不鉴权,出现时必须非空。只支持 `GET /traffic` 返回
-# 一次 up/down/upTotal/downTotal 快照;它不属于 Invoke API。
+# 可选的运行时 Controller,地址必须是回环 IP 和显式非零端口。它可服务于启用
+# TUN 的配置或含 proxy-groups 的配置;整个运行配置仍必须包含 port 或启用 TUN。
+# 只要 Controller 管理 proxy-groups,secret 就必填;仅有 TUN 流量接口时可省略。
+# secret 出现时必须为 1–255 UTF-8 字节,并作为全部 Controller 路由的 Bearer token。
+# Controller 提供 GET /traffic、GET /group、GET /group/{name}、
+# GET /proxies/{name} 和 PUT /proxies/{name},不属于 Invoke API。
external-controller: 127.0.0.1:9090
secret: "vcore-runtime-secret"
@@ -54,10 +58,15 @@ sniffer:
QUIC:
ports: [443, 8443]
-# 至少配置一个平铺代理。name 大小写敏感且必须唯一;dialer-proxy 必须引用另一个
-# 实际 name。未知引用、自引用和环都会失败。代理数量和链深没有独立上限,仍受
-# YAML 256 KiB、名称唯一和全图无环约束。物理首跳需要连接本机服务时,server 必须
-# 显式使用 127.0.0.0/8 范围内的 IPv4 字面量或 ::1;域名解析到回环地址会失败关闭。
+# 至少配置一个具体代理节点。节点和代理组定义名共享同一大小写敏感的命名空间,
+# 引用精确匹配且不做 Unicode normalization 或大小写折叠。名称必须为 1–64 UTF-8
+# 字节,首尾不能是任何 Unicode 空白字符,不能含控制字符,也不能包含逗号、井号、
+# 斜杠、问号、`&`、`=`、`%`、反斜杠,或使用 `.`、`..`、`DIRECT`、`REJECT`、
+# `RULES`;内部普通空格、CJK 和 emoji 可用。dialer-proxy 必须引用另一个具体节点,
+# 不能引用代理组。未知引用、自引用和环都会失败。代理数量和链深没有独立上限,
+# 仍受 YAML 256 KiB、名称唯一和
+# 全图无环约束。物理首跳需要连接本机服务时,server 必须显式使用 127.0.0.0/8
+# 范围内的 IPv4 字面量或 ::1;域名解析到回环地址会失败关闭。
#
# VLESS 子集:
# - type 固定 vless,network 固定 xhttp,tls 必须为 true;
@@ -138,38 +147,66 @@ proxies:
sni: anytls.example.com
udp: true
+# 可选的静态 `select` 代理组。字段外形是严格 Mihomo 子集:每组只接受 name、
+# type、proxies 和可选 default-selected,type 只能是 select,proxies 至少一项。
+# 成员可精确引用具体节点、另一个 select 组、DIRECT 或 REJECT;RULES 不是成员。
+# 组声明顺序、成员顺序和重复成员全部保留,嵌套组图必须无环;组数、成员数和嵌套
+# 深度没有独立上限,仍受 256 KiB 配置总大小和 O(V+E) 无环校验约束。
+#
+# default-selected 省略时选中第一项;显式值必须是当前组的直接成员。重复名称按
+# 第一项命中。没有 Controller 时,该初始选择在本次 session 内固定;有 Controller
+# 时可实时切换。选择只存于 VCore 当前 session 内存,VCore 不写回 YAML;宿主若需
+# 跨 session 保留,必须持久化选择并在下次配置中注入 default-selected。
+#
+# 切换只影响之后新建的物理 TCP 连接、UDP association/transport 和 DNS transport;
+# 既有 TCP 连接、UDP association、DNS cache/singleflight、DNS TCP pool 和其中的
+# transport 不迁移、不清空。选中成员失败时原样失败,代理组不自动换成员。
+proxy-groups:
+ - name: fallback-select
+ type: select
+ proxies: [socks-hop, DIRECT]
+
+ - name: main-select
+ type: select
+ proxies: [vless-edge, fallback-select, DIRECT, REJECT, vless-edge]
+ default-selected: vless-edge
+
# 运行时 DNS 可省略;enable: true 时主 nameserver 必须有 1–4 项。ipv6 省略时
# 默认为 true;false 时直接 AAAA 查询返回空结果,域名解析不查询 AAAA。nameserver
# 只接受裸 IP、udp://IP[:port] 或 tcp://IP[:port]。没有 fragment 时固定 DIRECT;
-# fragment 只接受 DIRECT、RULES 或实际代理名。`PROXY`/`#PROXY` 没有默认出口
-# 含义,只有存在同名代理时才有效。nameserver-policy 按顺序匹配
+# fragment 只接受 DIRECT、RULES、实际节点名或代理组名。`PROXY`/`#PROXY` 没有
+# 默认出口含义,只有存在同名 route target 时才有效。DNS route 选中代理组后,
+# 仅新建物理 transport 解析当前成员;cache 与既有 TCP pool 不随切换清空。
+# nameserver-policy 按顺序匹配
# `geosite:[,...]`,命中后只在当前组内顺序故障转移。
dns:
enable: true
ipv6: true
nameserver:
- - "tcp://1.1.1.1:53#vless-edge"
+ - "tcp://1.1.1.1:53#main-select"
nameserver-policy:
"geosite:private,cn,apple":
- "tcp://223.5.5.5:53#DIRECT"
- - "tcp://1.1.1.1:53#vless-edge"
+ - "tcp://1.1.1.1:53#main-select"
# rules 是运行配置必填项,按顺序首条命中。DIRECT 与 REJECT 是内置动作,其他
-# 动作必须精确使用实际代理名。`PROXY` 没有特殊别名。rules 不能为空,必须恰好
-# 以一个指向实际代理的 MATCH 结束;不存在隐式 MATCH 或 DIRECT 回退。
+# 动作必须精确使用实际节点名或代理组名。`PROXY` 没有特殊别名。rules 不能为空,
+# 必须恰好以一个指向实际节点或代理组的 MATCH 结束;不存在隐式 MATCH 或 DIRECT
+# 回退。选中组时使用当前成员,组本身不做 health check、测速或自动 failover。
rules:
- GEOSITE,category-ads-all,REJECT
- GEOSITE,cn,DIRECT
- GEOIP,CN,DIRECT,no-resolve
- DOMAIN-SUFFIX,anytls.example,anytls-edge
- NETWORK,UDP,anytls-edge
- - MATCH,vless-edge
+ - MATCH,main-select
-# measureDelay 使用独立的节点配置:顶层只能包含 proxies。节点字段和代理图校验
-# 与上文一致,但不能出现 port/authentication、tun、sniffer、Controller、DNS、
-# rules 或 GeoData。VCore 从“未被任何 dialer-proxy 引用”的节点推导唯一链头,
-# 且该链必须覆盖全部节点。测速不创建监听器、公共实例、TUN/protect 租约、DNS、
-# 规则引擎或 GeoData 注册。
+# measureDelay 使用独立的 node-only 配置:顶层只能包含 proxies,不能包含
+# proxy-groups。节点字段和代理图校验与上文一致,但不能出现 port/authentication、
+# tun、sniffer、Controller、DNS、rules 或 GeoData;dialer-proxy 同样只能引用具体
+# 节点。VCore 从“未被任何 dialer-proxy 引用”的节点推导唯一链头,且该链必须覆盖
+# 全部节点。测速不创建监听器、公共实例、TUN/protect 租约、DNS、规则引擎、代理组
+# 或 GeoData 注册。
#
# 最小测速示例:
# proxies:
diff --git a/docs/controller-api.md b/docs/controller-api.md
index 838c50f..88a3f03 100644
--- a/docs/controller-api.md
+++ b/docs/controller-api.md
@@ -1,26 +1,25 @@
-# TUN 流量 Controller API
+# 运行时 Controller API
-Controller 只提供当前 TUN 会话的单次 HTTP 流量快照,不提供持续推送、连接明细、配置修改或其他管理接口。
+Controller 是当前 Running Session 的回环 HTTP 接口。它提供一次性 TUN 流量快照,以及静态 `select` 代理组的状态查询和实时选择;它不是 Invoke API,也不承诺完整 Mihomo Dashboard 兼容。
-## 配置
+## 配置与生命周期
```yaml
external-controller: 127.0.0.1:9090
secret: "vcore-runtime-secret"
```
-- 省略 `external-controller` 表示不启动 Controller,此时不能单独配置 `secret`。
-- `external-controller` 只接受回环地址,并且只能与 `tun.enable: true` 一起使用。
-- `secret` 可省略;一旦出现就必须是非空 Bearer token。
-- `measureDelay` 的节点配置不能包含这两个字段。
-- `validateConfig` 只校验字段;`prepare` 不监听端口;`start` 绑定端口,绑定失败则启动失败。
-- `stop` 和 `destroyInstance` 关闭监听器。
+- 省略 `external-controller` 表示不启动 Controller,此时不能单独配置 `secret`。含代理组但没有 Controller 的配置合法,组在本次 session 内保持初始选择。
+- `external-controller` 只接受带显式非零端口的回环 IP 地址。它要求启用 TUN 或至少定义一个 `proxy-groups`;非 TUN 配置仍须通过 `port` 满足运行配置的入站要求。
+- 只要同时配置代理组与 Controller,`secret` 就必填;仅有 TUN 流量接口时可省略。`secret` 出现时必须为 1–255 UTF-8 字节,并保护本文定义的全部路由。
+- `measureDelay` 的 node-only 配置不能包含 Controller 或代理组字段。
+- `validateConfig` 只校验字段;`prepare` 不监听端口;`start` 绑定端口,绑定失败则启动失败。`stop` 和 `destroyInstance` 关闭监听器。
-Controller 与 TUN 会话同生共灭。每次会话启动都会新建统计状态并把全部计数清零。宿主应为每次运行选择专用端口和随机密钥,密钥不得写入日志或错误正文。
+Controller 与公共运行时 session 同生共灭。代理组选择只保存在 VCore 当前 session 的内存中;VCore 不写回配置。宿主如需跨 session 保留选择,必须自行持久化,并在下次 `configYaml` 中提供对应的 `default-selected`。
## Bearer 鉴权
-配置 `secret` 后,请求必须携带:
+配置 `secret` 后,每个请求都必须携带:
```http
Authorization: Bearer vcore-runtime-secret
@@ -28,12 +27,10 @@ Authorization: Bearer vcore-runtime-secret
要求:
-- scheme 必须是 `Bearer`;
-- token 必须与当前会话配置完全一致;
-- 缺失、格式错误或不匹配都返回 `401 Unauthorized`;
-- 比较过程不能通过日志、响应或明显的提前返回时序泄漏 token。
-
-未配置 `secret` 时不要求 `Authorization`。Controller 不使用 HTTP Basic;顶层 `authentication` 只属于可选 HTTP 代理监听器。
+- scheme 必须精确为 `Bearer`,token 必须与当前 session 配置完全一致;
+- 缺失、重复、格式错误或不匹配都返回 `401 Unauthorized`;
+- 比较过程不能通过日志、响应或明显的提前返回时序泄漏 token;
+- 未配置 `secret` 时不要求 `Authorization`;Controller 不使用 HTTP Basic,顶层 `authentication` 只属于 HTTP 代理 listener。
## `GET /traffic`
@@ -52,15 +49,99 @@ Authorization: Bearer vcore-runtime-secret
- `up`:最近一个完整的一秒窗口内,从宿主 TUN 进入 VCore 的原始 IP 包字节数;
- `down`:最近一个完整的一秒窗口内,由 VCore 写回宿主 TUN 的原始 IP 包字节数;
-- `upTotal`:本次 TUN 会话的累计上行字节数;
-- `downTotal`:本次 TUN 会话的累计下行字节数。
+- `upTotal`:本次 TUN session 的累计上行字节数;
+- `downTotal`:本次 TUN session 的累计下行字节数。
Apple utun 的四字节包信息头不计入统计。统计只覆盖跨过 TUN L3 边界的包,包括 TUN DNS 和 ICMP;不重复计算 TLS、XHTTP 或其他代理封装,也不包含 HTTP 代理入站、GeoData 下载、Controller 请求和 `measureDelay`。
-请求不会等待下一次采样,不保持分块响应,不升级 WebSocket,也不为调用方保存“上次读取”状态。首个采样窗口完成前和空闲窗口内,`up`/`down` 为 0;累计值在同一会话内单调不减并采用饱和语义。
+请求不会等待下一次采样,不保持分块响应,不升级 WebSocket,也不为调用方保存“上次读取”状态。首个采样窗口完成前和空闲窗口内,`up`/`down` 为 0;累计值在同一 session 内单调不减并采用饱和语义。没有 TUN 的代理组 Controller 对该路由返回 `404 Not Found`。
+
+## 代理组查询
+
+`GET /group` 按 YAML 声明顺序返回全部代理组:
+
+```json
+{
+ "proxies": [
+ {
+ "name": "main-select",
+ "type": "Selector",
+ "all": ["edge-a", "fallback-select", "DIRECT", "edge-a"],
+ "now": "edge-a"
+ }
+ ]
+}
+```
+
+以下两个请求返回同一个组状态对象:
+
+```http
+GET /group/{name}
+GET /proxies/{name}
+```
+
+- `name` 是组定义名,`type` 固定为 `Selector`;
+- `all` 是配置中的直接成员,严格保留顺序和重复项;
+- `now` 是当前选中的直接成员名。若该成员是嵌套组,`now` 仍返回组名,不展开为最终叶节点;
+- 名称和值大小写敏感并精确匹配,不做大小写折叠或 Unicode normalization。
+
+## `PUT /proxies/{name}`
+
+切换组的当前直接成员:
+
+```http
+PUT /proxies/main-select HTTP/1.1
+Authorization: Bearer vcore-runtime-secret
+Content-Type: application/json
+Content-Length: 26
+
+{"name":"fallback-select"}
+```
+
+成功返回 `204 No Content` 和空正文。`name` 必须是该组 `all` 中的直接成员;重复名称命中第一项。请求不接受传递成员、数组、索引、CAS/version 或其他字段。
+
+请求正文边界:
+
+- `Content-Type` 必须是唯一且不带参数的 `application/json`;
+- 必须有且只有一个十进制 `Content-Length`,不接受 `Transfer-Encoding` 或 chunked;
+- 正文最大 1 KiB,读取 header 和 body 各有 5 秒期限;
+- JSON 必须是 UTF-8 对象并且恰好包含一个字符串字段 `name`;缺失、未知或重复字段、malformed JSON 和尾随 JSON 都会失败;
+- 未知组、未知成员和任何无效请求都不得改变当前选择。
+
+同一组的成功写入是线性化的,最后一个成功提交的请求胜出;不同组独立更新,不提供跨组事务或 CAS。
+
+## 名称与路径
+
+节点和代理组定义名共享一个命名空间。名称必须为 1–64 UTF-8 字节,首尾不能是任何 Unicode 空白字符,不能含控制字符或 `, # / ? & = % \\`,也不能是 `.` 或 `..`。定义名额外保留 `DIRECT`、`REJECT` 和 `RULES`;前两者仍可作为组成员,`RULES` 只作为 DNS sentinel。内部普通空格、CJK 和 emoji 可以使用。
+
+路径中的 `{name}` 必须是单个 segment。Controller 对 percent-encoding 严格解码一次,再执行相同的 UTF-8 和名称校验;无效 `%`、无效 UTF-8、解码后的 `/`、二次编码形式、query 和 fragment 都会失败。客户端应对非 ASCII 或路径保留字节执行一次标准 percent-encoding;`+` 不会被当作空格。
+
+## 选择生效边界
+
+代理组成员列表在配置期固定,只有当前选择可在 Running Session 内改变:
+
+- 新建的物理 TCP 连接使用切换后的当前叶节点;既有 TCP 连接保持原路径;
+- 新建的 UDP association/物理 transport 使用切换后的当前叶节点;既有 association 不迁移;
+- 新建的 DNS transport 使用切换后的当前叶节点;DNS cache、singleflight 状态和已在池中的 TCP transport 不清空、不迁移;
+- 切换不主动断开连接、不刷新缓存、不重启运行时,也不修改 YAML 或下一次 session 的默认值;
+- 选中节点、`DIRECT` 或 `REJECT` 时按原样执行;选中嵌套组时继续解析其当前成员。失败按原样返回,不自动选择其他成员。
+
+## HTTP 状态与协议边界
-## 边界
+| 状态 | 含义 |
+| --- | --- |
+| `200 OK` | 查询成功,返回 `application/json` |
+| `204 No Content` | 选择成功,正文为空 |
+| `400 Bad Request` | 路径、JSON、长度格式或成员无效 |
+| `401 Unauthorized` | Bearer 鉴权失败 |
+| `403 Forbidden` | 请求来源不是回环地址 |
+| `404 Not Found` | 路由、组或当前 session 中的 TUN 流量资源不存在 |
+| `405 Method Not Allowed` | 路由存在但方法不允许 |
+| `408 Request Timeout` | header 或 body 读取超时 |
+| `411 Length Required` | PUT 缺少 `Content-Length` |
+| `413 Payload Too Large` | PUT 正文超过 1 KiB |
+| `415 Unsupported Media Type` | PUT 不是严格 `application/json` |
-Windows 的 TUN 运行时位于独立 Session Host 时,App 通过回环 HTTP 访问 Controller。流量查询不是 `VCoreInvoke` 方法,不携带 `instanceId`,也不占用 Invoke 命令锁。
+第一版不提供 provider/`use`、health check、自动 failover、`url-test`/`fallback`/`load-balance`、delay 测试、连接管理、配置修改、WebSocket 推送、Controller 版本协商或完整 Dashboard response。除 `GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}` 外,其他路径和方法均不属于公共协议。
-当前协议只定义符合鉴权要求的 `GET /traffic`。其他路径和方法均不属于公共协议。
+Windows 的完整运行时和 Controller 位于独立 Session Host。App 通过回环 HTTP 直接访问实际 Running Session;组查询和切换不经过 `VCoreInvoke`、Windows bridge 或 Provider 控制管道,不携带 `instanceId`,也不占用 Invoke 命令锁。
diff --git a/docs/geodata.md b/docs/geodata.md
index 9936de5..eb2d9d5 100644
--- a/docs/geodata.md
+++ b/docs/geodata.md
@@ -10,7 +10,7 @@ rules:
- GEOSITE,cn,DIRECT
- GEOIP,PRIVATE,DIRECT,no-resolve
- GEOIP,CN,DIRECT
- - MATCH,vless-edge
+ - MATCH,main-select
```
语法:
@@ -20,12 +20,12 @@ GEOSITE,,
GEOIP,,[,no-resolve]
```
-- 规则类型和 `code` 不区分 ASCII 大小写,代理目标名称大小写敏感。
-- `target` 只能是 `DIRECT`、`REJECT` 或实际代理名。
+- 规则类型和 `code` 不区分 ASCII 大小写,route target 名称大小写敏感并精确匹配。
+- `target` 只能是 `DIRECT`、`REJECT`、实际代理节点名或静态 `select` 组名。
- `code` 必须匹配 `[A-Za-z0-9][A-Za-z0-9._+!-]{0,63}`,按 ASCII 大小写折叠后判重。
- `GEOSITE` 恰好三段;`GEOIP` 只允许可选且大小写敏感的 `no-resolve`。
- 不支持反选、属性选择器、单条规则多个 code、源地址 GeoIP 或隐式局域网分类。
-- 规则按顺序首条命中,最终 `MATCH` 必须指向实际代理。
+- 规则按顺序首条命中,最终 `MATCH` 必须指向实际代理节点或代理组。
DNS `nameserver-policy` 中的 `geosite:[,...]` 与业务规则共享同一份分类需求和匹配器。
@@ -59,7 +59,7 @@ geo-update-interval: 24
- `geox-url` 只能包含 `geoip` 和 `geosite`;URL 必须是以域名为主机、无 userinfo/fragment 的绝对 HTTPS URL。
- `geo-update-interval` 只接受整数 `24`。
- `validateConfig` 和 `prepare` 不下载。实例启动后,只有自动更新已开启且规则实际需要资产时才运行更新任务。
-- 下载固定使用最终 `MATCH` 选中的完整代理链,不回退 DIRECT 或系统代理。
+- 下载固定使用最终 `MATCH` route target。若它是代理组,每次新建下载物理 transport 时沿嵌套组解析到当时选中的具体节点、`DIRECT` 或 `REJECT`;失败不自动换成员,也不隐式回退 DIRECT 或系统代理。
- 缺失资产立即检查;失败后按 1 分钟、5 分钟、15 分钟、1 小时退避,之后保持 1 小时上限。成功后恢复 24 小时周期。
- ETag、SHA-256、源 URL 和下次检查时间保存在 VCore 管理的状态中。合法 304 只推进调度。
- 下载写入同目录暂存文件,通过大小、hash、wire、需求和资源校验后原子替换。失败保留上一份有效资产。
@@ -111,6 +111,7 @@ geo-update-interval: 24
- `prepare` 注册去重后的需求并读取当时可用的本地快照,不等待更新。
- Manager 只为当前公共实例构建匹配器;停止或销毁实例时释放需求和快照。
- 后台更新通过完整校验后原子发布不可变快照。新流使用新快照,已经完成的选路不回溯。
+- Controller 切换代理组不会重启正在进行的下载或迁移既有连接;只有之后新建的 GeoData 下载物理 transport 使用新选择。
- `measureDelay` 不注册 GeoData、匹配器或更新任务。
## 资源上限
diff --git a/docs/invoke-api.md b/docs/invoke-api.md
index 4e089df..665c2ba 100644
--- a/docs/invoke-api.md
+++ b/docs/invoke-api.md
@@ -1,6 +1,6 @@
# VCore Invoke API
-业务接口版本为 5,配置结构修订版为 12。配置只通过内联的 `configYaml` 或 `configYamls` 传入;每份已加载的 VCore 运行时最多拥有一个公共实例。
+业务接口版本为 5,配置结构修订版为 13。配置只通过内联的 `configYaml` 或 `configYamls` 传入;每份已加载的 VCore 运行时最多拥有一个公共实例。代理组实时选择沿用 Controller,不增加 Invoke method 或版本协商。
## C ABI
@@ -89,7 +89,7 @@ stopped -> preparing -> prepared -> starting -> running
- 同一实例一次只执行一个生命周期命令,重叠命令立即失败。
- `validateConfig` 是可并发的纯校验方法。
- `measureDelay` 使用独立批次和私有工作器,不进入公共实例表。
-- 不支持热重载;切换配置需要 `stop -> prepare -> start`。
+- 不支持配置热重载;切换配置需要 `stop -> prepare -> start`。唯一的运行期路由变更是通过 Controller 修改静态 `select` 组的当前选择,它不修改已 prepare 的配置。
- `stop` 在 stopped 状态幂等;`destroyInstance` 是最终同步清理屏障。
## 方法
@@ -107,8 +107,8 @@ stopped -> preparing -> prepared -> starting -> running
```json
{
"apiVersion": 5,
- "buildIdentity": "VCore;engine=rust;coreVersion=0.1.0;invokeApiVersion=5;configVersion=12",
- "configVersion": 12,
+ "buildIdentity": "VCore;engine=rust;coreVersion=0.1.0;invokeApiVersion=5;configVersion=13",
+ "configVersion": 13,
"engine": "rust",
"version": "0.1.0"
}
@@ -193,7 +193,7 @@ stopped -> preparing -> prepared -> starting -> running
}
```
-完成大小、YAML、结构、引用、代理图和字段组合校验。不创建实例、不解析远端域名、不联网,也不读取 GeoData。资产缺失不影响纯配置校验。
+完成大小、YAML、结构、共享 route-target 命名空间、引用、具体节点 `dialer-proxy` 图、代理组 DAG 和字段组合校验。不创建实例、不解析远端域名、不联网,也不读取 GeoData。资产缺失不影响纯配置校验。
### `prepare`
@@ -210,6 +210,7 @@ stopped -> preparing -> prepared -> starting -> running
- 读取当时可用的本地 GeoData,不启动或等待下载。
- 只为直接访问物理网络的代理根节点执行引导 DNS;代理链上的域名交给下一跳。
+- 校验每个静态 `select` 组的初始选择;省略 `default-selected` 时使用第一项,显式值必须是直接成员。可变的 session 选择状态到 `start` 时才创建。
- 成功进入 prepared;失败释放临时资源并回到 stopped。
- 含 TUN 的配置从 preparing 起持有运行时本地的 TUN/protect 租约,直到停止或销毁。
- Android 只有在实际启用 TUN 时才要求事先注册 protect controller。
@@ -232,6 +233,7 @@ Android 使用 `rawIp`。非 TUN 配置必须省略 `tunFd` 和 `tunFraming`。
- `tunFd` 由宿主借用,宿主必须预先设置 nonblocking。
- VCore 校验后建立带 `CLOEXEC` 的副本,只关闭副本。
- Apple 只接受 `utun`,Android 只接受 `rawIp`。
+- 从 prepared 配置创建本次 session 的代理组选择状态。
- 所有监听器和关键数据面成功后才进入 running。
- GeoData 更新只在启动后按需后台运行,不属于启动关键路径。
- Windows 系统 VPN 使用安装包桥接接口,不使用文件描述符启动参数。
@@ -242,7 +244,7 @@ Android 使用 `rawIp`。非 TUN 配置必须省略 `tunFd` 和 `tunFraming`。
{"apiVersion":5,"method":"stop","instanceId":"1","payload":{}}
```
-同步取消并等待监听器、TUN、netstack、DNS、会话、出站和更新任务,关闭 VCore 持有的文件描述符副本并释放平台回调租约。返回后不得继续产生数据包或调用 protect callback。
+同步取消并等待监听器、Controller、TUN、netstack、DNS、会话、出站和更新任务,关闭 VCore 持有的文件描述符副本并释放平台回调租约。返回后不得继续产生数据包或调用 protect callback;本次 session 的代理组选择随之销毁。
### `getState`
@@ -287,7 +289,7 @@ Android 使用 `rawIp`。非 TUN 配置必须省略 `tunFd` 和 `tunFraming`。
- `configYamls` 接受 1–5 份非空节点配置,`timeout` 为 1–30 秒。
- 同一运行时一次只允许一个测速批次,最多并发五个私有工作器。
-- 节点配置顶层只允许 `proxies`。VCore 推导唯一链头,且该链必须覆盖全部节点。
+- 节点配置顶层只允许 `proxies`,不接受 `proxy-groups`;`dialer-proxy` 也只能引用具体节点。VCore 推导唯一链头,且该链必须覆盖全部节点。
- 工作器只准备出站图并执行 TCP、可选 TLS 和 HTTP/1.1 HEAD;不创建公共实例、监听器、TUN、DNS、规则、嗅探器或 GeoData。
- URL 必须是无 userinfo 和 fragment 的绝对 HTTP/HTTPS URL;HTTPS 使用发布信任根。
- 任意合法 HTTP 状态都表示探测成功;不跟随重定向、不读取正文。
@@ -310,7 +312,9 @@ ProtectFd(fd) -> bool
## Controller
-配置 `external-controller` 后,TUN 运行时提供回环 `GET /traffic`。该查询不是 Invoke 方法,不携带 `instanceId`。完整语义见 [Controller API](controller-api.md)。
+配置 `external-controller` 后,运行时可提供回环 `GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}`。代理组 Controller 可以在非 TUN 的本地 HTTP 配置中运行;此时 `/traffic` 不存在。只要 Controller 管理代理组,`secret` 就必填并保护全部路由。
+
+组成员列表是配置期固定的,选择只存于当前 Running Session。成功切换只影响之后新建的物理 TCP、UDP 和 DNS transport,不迁移既有连接、UDP association、DNS 状态或 TCP pool,也不触发 failover。Controller 查询不携带 `instanceId`,不进入 Invoke 命令锁;完整语义见 [Controller API](controller-api.md)。
## Windows 安装包桥接
@@ -355,7 +359,7 @@ ProtectFd(fd) -> bool
桥接把 YAML、进程顺序、路径和参数发布为 `vcore-session-v2:` Session Snapshot。参数引用的文件由调用方保持存在且不可变,VCore 不读取或摘要其内容。`getVpnStatus.data.snapshotToken` 返回该完整 Session token。
-桥接请求最大 1 MiB。它负责安装包身份、单一 VPN profile、不可变 Session Snapshot、Session Host 激活和系统 VPN 状态;不公开 profile CRUD、内部文件路径、backend 描述、参数、PID、管道名称或 Snapshot 维护。数据包、Controller 查询和业务生命周期不经过该 JSON 桥接。
+桥接请求最大 1 MiB。它负责安装包身份、单一 VPN profile、不可变 Session Snapshot、连接/断开命令和系统 VPN 状态;Provider 负责激活 Session Host。桥接不公开 profile CRUD、内部文件路径、backend 描述、参数、PID、管道名称或 Snapshot 维护。数据包、Controller 流量查询、代理组查询/切换和业务生命周期不经过该 JSON 桥接。
## 编码与安全边界
@@ -363,4 +367,5 @@ ProtectFd(fd) -> bool
- 配置、错误和日志按 UTF-8 字节计数并受固定上限约束。
- TUN 原始数据包最大 1,500 字节;最终代理 UDP 负载最大 1,452 字节。
- 嵌套 UDP 协议可以增加有界帧头,但解封装后的最终负载仍受 1,452 字节限制。
+- 节点和代理组定义名共享大小写敏感的严格 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 d278d2a..b4f3667 100644
--- a/docs/runtime-resource-policy.md
+++ b/docs/runtime-resource-policy.md
@@ -59,7 +59,7 @@ GeoData 分配容量 8 MiB
- 不设置固定的活动请求或活动传输总许可数。
- 相同 key 的 cache miss 使用 singleflight;leader 取消后 follower 重新选举。
- UDP 传输属于单次请求,同一尝试的重发复用当前传输。
-- 显式 TCP nameserver 按 endpoint 和最终出口复用;一条连接同时只处理一个查询。
+- 显式 TCP nameserver 按 endpoint 和配置的 route target 复用;一条连接同时只处理一个查询。
- 活动 TCP 不设固定数量上限;空闲连接总数最多 4,同 key 最多 2,空闲超时 30 秒。
- 查询、单次尝试和 UDP 重发期限分别为 5 秒、3 秒和 1 秒。
- 响应最多扫描 64 条记录,类型化缓存和提示最多保留 16 个唯一 IP。
@@ -67,6 +67,16 @@ GeoData 分配容量 8 MiB
完整语义见 [TUN ICMP 与 DNS](tun-icmp-dns.md)。
+## 代理组与 Controller
+
+- 静态 `select` 组只拥有不可变的有序成员和每组一个可原子替换的当前索引,不拥有协议连接、后台任务、队列、缓存或独立 worker。
+- 组数、每组成员数、重复成员数和嵌套深度没有独立固定上限,统一受 256 KiB YAML 上限约束;配置期使用 O(V+E) 的迭代 DAG 校验,运行时也以迭代方式解析当前叶节点。
+- 同组成功选择是线性化的,不同组独立;选择失败不改变状态,也不创建重试或自动 failover 任务。
+- 切换不扫描或迁移现有 TCP、UDP、DNS transport,不刷新 DNS cache/singleflight 或 TCP pool;资源仍由原所有者按既有 timeout、EOF、取消和 stop 语义回收。
+- Controller 同时最多跟踪 8 个连接任务。请求 header 和 PUT body 各有 5 秒读取期限,PUT body 最大 1 KiB;超时、超限和解析失败只终止当前请求。
+
+完整接口见 [运行时 Controller](controller-api.md)。
+
## GeoData
- GeoSite 与 GeoIP 共享 8 MiB 分配容量。
diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md
index e188cf0..c946cdf 100644
--- a/docs/tun-icmp-dns.md
+++ b/docs/tun-icmp-dns.md
@@ -53,7 +53,7 @@ dns:
enable: true
ipv6: true
nameserver:
- - "tcp://1.1.1.1:53#vless-edge"
+ - "tcp://1.1.1.1:53#main-select"
nameserver-policy:
"geosite:private,cn":
- "tcp://223.5.5.5:53#DIRECT"
@@ -66,10 +66,12 @@ dns:
- `dns.ipv6: false` 不限制 IPv6 nameserver;顶层 `ipv6: false` 会阻止通过 DIRECT(含 `RULES` 选中 DIRECT)的 IPv6 nameserver 在本机物理建链,并继续当前组的故障转移。
- Endpoint 必须是 IP 字面量,不支持 hostname、system 或 DHCP resolver。
- 无 fragment 时固定使用 DIRECT。
-- Fragment 只接受 `DIRECT`、`RULES` 或实际代理名。
+- Fragment 只接受 `DIRECT`、`RULES`、实际代理节点名或静态 `select` 组名。
- `#RULES` 只按 nameserver endpoint 和传输执行业务规则,不把 DNS question 当作选路域名。
- 规则结果为 `REJECT` 时,当前尝试失败并继续当前组下一项。
+代理组成员可以是具体节点、嵌套组、`DIRECT` 或 `REJECT`。DNS exchange 需要新建物理 transport 时解析当时的当前叶节点;选中成员失败不会使代理组自动改选,但 nameserver 列表仍按本节已有故障转移语义继续下一项。
+
### Policy
`nameserver-policy` 是有序映射:
@@ -132,13 +134,15 @@ A/AAAA 缓存最多 256 项,从空容量按需增长。TUN 域名提示存储
显式 TCP nameserver 使用每运行时独立连接池:
-- Key 为 endpoint 和最终出口;
+- Key 为 endpoint 和配置的 route target;
- 一条连接同时只处理一个查询,不做 pipeline;
- 活动连接不设固定业务数量上限;空闲连接总数最多 4、同 key 最多 2、超时 30 秒;
- 完成帧和响应校验后才能归还连接;
- 复用连接遇到 EOF、I/O、reset 或响应不匹配时,在原尝试期限内最多新建一次连接;
- 非法、截断或超限响应不重试、不归还连接。
+Controller 切换代理组后,只在后续需要创建新 DNS transport 时使用新选择。类型化/原始响应 cache、singleflight 状态和已经位于 TCP pool 中的 transport 不清空、不迁移;因此命中 cache 或复用既有 TCP transport 时,不会为切换单独建链。停止 session 才统一释放这些状态。
+
## 队列与生命周期
| 队列 | 容量 |
@@ -157,7 +161,7 @@ DNS 和普通 UDP 响应使用不同队列,但共享 netstack UDP 入站接收
- 真实 ICMP 转发、ICMP error、Traceroute、ICMP 选路规则或 ICMP 测速;
- DoH、DoT、DoQ、hostname/system/DHCP nameserver;
-- 代理组、并行 nameserver 竞速、DNS fallback group、fake IP 和 hosts;
+- 代理组 provider/health check/自动 failover、并行 nameserver 竞速、DNS fallback group、fake IP 和 hosts;
- 用户可配置的缓存容量、超时、重试或并发度;
- 使用 DNS question 执行业务规则,或用 DNS 出口改写后续业务动作。
diff --git a/docs/windows-session-runtime.md b/docs/windows-session-runtime.md
index 5bc38fa..8680523 100644
--- a/docs/windows-session-runtime.md
+++ b/docs/windows-session-runtime.md
@@ -1,6 +1,6 @@
# Windows 会话运行时
-Windows 每个 VPN 会话由一个隐藏的完全信任 Session Host 独占完整 VCore 运行时。AppContainer Provider 只负责 Windows VPN 平台资源、原始包转发和失败关闭;系统中不存在 Provider 内嵌运行时的备用路径。
+Windows package 只有一个主 Application,但每个 VPN 会话仍由独立的完全信任 Session Host 独占完整 VCore 运行时。AppContainer Provider 只负责 Windows VPN 平台资源、原始包转发和失败关闭;系统中不存在 Provider 内嵌运行时的备用路径。
## 参与者与所有权
@@ -8,32 +8,33 @@ Windows 每个 VPN 会话由一个隐藏的完全信任 Session Host 独占完
前台宿主(完全信任)
├─ VCoreInvoke
└─ VCoreWindowsVpnInvoke
- │ 激活
- ▼
+ ├─ Session Snapshot / profile
+ └─ ConnectProfileAsync
+ │ Windows 激活
+ ▼
+vcore-windows-vpn-host.exe + vcore.dll(AppContainer)
+ ├─ VpnChannel / 路由 / DNS / 物理网络
+ ├─ VpnPacketBuffer / 有界回调队列 / 失败关闭
+ └─ FullTrustProcessLauncher
+ │ 无参数激活
+ ▼
vcore-windows-session-host.exe
├─ 校验不可变 Session Snapshot
├─ 可选 Windows session backend / Job Object
├─ PreparedCore / RunningCore
- ├─ DNS / 规则 / GeoData / 嗅探器
+ ├─ DNS / 规则 / GeoData / 嗅探器 / select 代理组
├─ VLESS / SOCKS5 / AnyTLS / DIRECT
- └─ 已鉴权流量 Controller
- ▲
- │ 同包控制管道 + 数据管道
- ▼
-vcore-windows-vpn-host.exe + vcore.dll(AppContainer)
- ├─ VpnChannel 生命周期
- ├─ 路由 / DNS / 物理网络
- ├─ VpnPacketBuffer 所有权
- ├─ 有界回调队列
- └─ 失败关闭
+ └─ 运行时 Controller(TUN 流量 / 代理组选择)
+ ▲
+ └─ 同包控制管道 + 数据管道
```
| 参与者 | 拥有 | 不拥有 |
| --- | --- | --- |
| 前台宿主 | 用户命令、会话记录、UI 状态 | TUN 运行时、包通道、Provider 状态 |
-| Windows 桥接 | Session Snapshot、profile、Session Host 激活与回滚 | 数据包、代理流 |
+| Windows 桥接 | Session Snapshot、profile、连接/断开命令 | 数据包、代理流、Session Host 进程 |
| Session Host | 单次 VCore 运行时、可选 session backend、Controller、GeoData、包客户端 | `VpnChannel`、路由、进程业务配置 |
-| Provider | `VpnChannel`、WinRT 缓冲区、路由、物理绑定、管道服务端、网络监控 | YAML、代理图、Controller、GeoData、backend 描述 |
+| Provider | `VpnChannel`、WinRT 缓冲区、路由、物理绑定、管道服务端、网络监控、Session Host 激活 | YAML、代理图、Controller、GeoData、backend 描述 |
| SOCKS 服务 | 自身监听器、外层 socket 和绕过策略 | VCore 代理图和 Windows profile |
Session Host 每次连接新建一个进程,不常驻、不复用运行时,也不处理 URI 或 StartupTask。
@@ -48,7 +49,7 @@ Session Host 每次连接新建一个进程,不常驻、不复用运行时,
LocalState/vcore/windows/sessions/.json
```
-- Snapshot revision 2 保存完整 VCore YAML,以及可选的有序 `sessionBackend.processes`;每项只有规范 package-relative executable path 和 argv 数组。
+- 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 地址。
- custom configuration 不包含 YAML、backend 描述、Controller secret、PID 或管道路径。
@@ -61,21 +62,21 @@ Session Host 每次连接新建一个进程,不常驻、不复用运行时,
1. 前台宿主调用 `startVpn(configYaml, networkSettings, sessionBackend?)`。
2. 桥接验证配置、四个地址和进程描述,发布不可变 Session Snapshot,并把解析后的顶层 IPv6 开关写入 profile configuration。
-3. 桥接通过 `IApplicationActivationManager` 激活隐藏 Session Host,只传 `--session-token `。
-4. 桥接持有激活返回的精确进程句柄。
-5. 桥接写入单一 VPN profile 并调用 `ConnectProfileAsync`。
-6. Windows 激活 Provider。
-7. Provider 在安装路由前选择物理网络绑定,并创建控制/数据管道服务端;顶层 `ipv6: false` 时不安装 IPv6 地址、路由或 DNS。
-8. Provider 原子发布会合记录。
-9. Session Host 校验命令行令牌和会合记录,构造限定对象路径并连接两条管道。
-10. Session Host 发送 `SessionHello`;Provider 返回 `ProviderHello` 和不可变物理绑定。
-11. Session Host 读取 Snapshot;若存在 backend,则用一个 kill-on-close Job Object 按顺序启动全部进程。
-12. Session Host 准备并启动完整 VCore 运行时和 Controller。
+3. 桥接写入单一 VPN profile 并调用 `ConnectProfileAsync`;它不启动或持有 Session Host。
+4. Windows 激活 AppContainer Provider。
+5. Provider 从 profile configuration 取得权威 token,选择物理网络绑定并准备基础资源。
+6. Provider 清理陈旧会合记录,通过无参数 `FullTrustProcessLauncher` 激活 Session Host。
+7. Session Host 不读取动态命令行参数,等待 Provider 会合记录。
+8. Provider 创建控制/数据管道服务端并原子发布会合记录。
+9. Session Host 严格解析会合记录,把其中的 token 作为候选值,构造限定对象路径并连接两条管道。
+10. Session Host 发送 `SessionHello`;Provider 把候选 token 与 profile token 精确比较后返回 `ProviderHello` 和不可变物理绑定。
+11. Session Host 验证 `ProviderHello` 回传同一 token,之后才读取 Snapshot;若存在 backend,则用一个 kill-on-close Job Object 按顺序启动全部进程。
+12. Session Host 准备并启动完整 VCore 运行时、静态代理组状态和 Controller。
13. Session Host 确认受管进程尚未退出后返回 `RuntimeReady`。
14. Provider 调用 `StartWithMainTransport` 并启动失败关闭监视器。
15. 连接成功后,桥接向前台宿主返回当前系统 VPN 状态。
-任一步失败都必须关闭包通道、终止本次精确 Session Host、收敛为 Disconnected,并只返回有界脱敏错误。
+任一步失败都必须关闭包通道并收敛为 Disconnected,只返回有界脱敏错误;未完成握手的 Session Host 最多等待 15 秒后退出。
## 会合记录
@@ -93,8 +94,8 @@ Session Host 每次连接新建一个进程,不常驻、不复用运行时,
- Provider 是唯一发布者和清理者;
- 使用同目录暂存文件和原子重命名;
-- 只接受规范 AppContainer 相对路径、固定 leaf 和匹配令牌;
-- 非普通文件、reparse point、超限、未知字段和令牌不匹配都会失败关闭;
+- 只接受规范 token、AppContainer 相对路径和固定 leaf;
+- 非普通文件、reparse point、超限或未知字段都会失败关闭;候选 token 与 profile token 的绑定只在双向握手中完成;
- 握手完成后删除,新连接前清理断开状态下的陈旧记录。
## 控制协议
@@ -193,9 +194,13 @@ Provider 订阅网络变化,等待 2 秒消抖后复验适配器、地址和 i
- Controller 由 Session Host 监听完全信任的回环地址;
- `RuntimeReady` 前必须完成绑定,失败则连接失败;
-- 前台宿主持有配置中的端口和 secret,并调用 `GET /traffic`;
+- 前台宿主持有配置中的端口和 secret,并调用 `GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 或 `PUT /proxies/{name}`;
+- 配置代理组与 Controller 时 secret 必填并保护全部路由;仅有 TUN 流量 Controller 时 secret 仍可省略;
+- 代理组选择属于 Session Host 中实际 Running Session 的内存状态,不经过 Windows bridge、Provider 控制管道或 Session Snapshot 写回;
+- 成功切换只影响之后新建的物理 TCP、UDP 和 DNS transport,不迁移既有连接、UDP association、DNS 状态或 TCP pool,也不执行自动 failover;
+- 宿主如需跨 VPN session 保留选择,必须自行持久化,并在下次 `startVpn` 的 YAML 中注入对应 `default-selected`;
- 前台宿主退出后 Controller 和运行时继续,重新启动后恢复查询;
-- Stop 关闭 Controller,下一会话从零计数;
+- Stop 关闭 Controller,销毁本次代理组选择;下一会话重新采用 YAML 初始选择并从零计数;
- 回环 SOCKS5 是普通 VCore 出站;其服务可以由外部宿主管理,也可以恰好运行在 session backend 中,但 VCore 不从 backend 描述推断端口或 readiness;
- 单个 SOCKS 流失败不停止 VPN。
@@ -213,8 +218,9 @@ Provider 订阅网络变化,等待 2 秒消抖后复验适配器、地址和 i
| 显式 Stop | Disconnect -> Stop -> 有界确认 -> channel Stop |
| 本地 SOCKS 流失败且服务进程仍存活 | 只失败当前流 |
| Controller 绑定失败 | 启动失败,profile 不进入 Connected |
+| Controller 切换代理组 | 当前 Session Host 原子更新选择;既有 transport 保持原路径 |
-桥接回滚只终止本次激活返回的精确进程句柄,不按进程名扫描或清理。
+桥接不持有或终止 Session Host 进程;启动失败由 Provider 的 Connect 清理路径收敛,显式 Stop 通过系统 profile 断开。
## 安装包契约
diff --git a/docs/windows-vpn.md b/docs/windows-vpn.md
index d8c2902..239ab22 100644
--- a/docs/windows-vpn.md
+++ b/docs/windows-vpn.md
@@ -9,7 +9,7 @@ Windows 数据面只使用官方 `Windows.Networking.Vpn` 和 `windows-rs`,不
- 分发形式:具有 package identity 的 MSIX。
- 前台:调用 Windows 桥接接口的完全信任宿主。
- Provider:同 package family 的 AppContainer 应用。
-- Session Host:同 package 中隐藏的完全信任应用,每个 VPN 会话一个进程,可选拥有同包 session backend。
+- Session Host:同一主 Application 的完全信任 extension,每个 VPN 会话一个独立进程,可选拥有同包 session backend。
- Windows 依赖:`windows = 0.62.2`。
- Manifest:包含 `networkingVpnProvider` 和 `runFullTrust`,不配置产品级 loopback exemption。
@@ -130,7 +130,7 @@ Provider 是物理网络状态的唯一权威,并订阅 `NetworkStatusChanged`
- Provider 创建 AppContainer 本地控制管道和数据管道;
- `GetAppContainerNamedObjectPath` 提供限定对象路径;
-- `IApplicationActivationManager` 激活隐藏 Session Host;
+- Provider 通过无参数 `FullTrustProcessLauncher` 激活同一 Application 的 Session Host;
- Session Host 使用当前 Windows session ID 构造限定路径并连接;
- 前台宿主退出不停止 Provider 或 Session Host,重新启动后从系统 profile 恢复状态。
@@ -155,8 +155,9 @@ vcore-windows-vpn-host.exe
vcore-windows-session-host.exe
```
-- 三个可执行参与者相互独立;
-- Session Host 不显示在应用列表,也不注册 StartupTask 或 URI;
+- manifest 只有一个主 Application,三个可执行参与者相互独立;
+- Session Host 是 `windows.fullTrustProcess` extension,不显示在应用列表,也不注册 StartupTask 或 URI;
+- Provider 的 `windows.backgroundTasks` extension 显式使用 `windowsApp + appContainer`;
- Provider activation class 来自 `vcore.dll`;
- 同一 package 只维护一个 `VCore` VPN profile;
- custom configuration 是最大 1 KiB 的严格 JSON,只含修订版 3、Session token、顶层 IPv6 开关和四个网络地址;
@@ -176,7 +177,7 @@ vcore-windows-session-host.exe
| 受管进程退出 | 清理同 Job 进程并停止当前 VPN |
| 本地 SOCKS 流失败且服务进程仍存活 | 只失败当前流 |
| 显式 Stop | 有界确认后清理路由、记录、Controller 和会话进程 |
-| 启动失败 | 终止本次精确 Session Host 进程并收敛为 Disconnected |
+| 启动失败 | Provider Connect 失败;未完成握手的 Session Host 最多等待 15 秒后退出 |
当前实测范围和未完成平台门禁见 [验收矩阵](acceptance.md)。
@@ -187,5 +188,5 @@ vcore-windows-session-host.exe
- [`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)
-- [`IApplicationActivationManager`](https://learn.microsoft.com/windows/win32/api/shobjidl_core/nn-shobjidl_core-iapplicationactivationmanager)
+- [`FullTrustProcessLauncher`](https://learn.microsoft.com/uwp/api/windows.applicationmodel.fulltrustprocesslauncher)
- [Package identity](https://learn.microsoft.com/windows/apps/desktop/modernize/package-identity-overview)
diff --git a/example/windows-uwp/AppxManifest.xml.in b/example/windows-uwp/AppxManifest.xml.in
index b729c84..b4f4b2b 100644
--- a/example/windows-uwp/AppxManifest.xml.in
+++ b/example/windows-uwp/AppxManifest.xml.in
@@ -3,9 +3,11 @@
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5"
+ xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
+ xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
- IgnorableNamespaces="uap uap5 desktop4 rescap">
+ IgnorableNamespaces="uap uap5 uap10 desktop desktop4 rescap">
VCore UWP VPN Demo
@@ -19,7 +21,7 @@
-
+
-
+
-
-
-
-
-
-
-
-
-
+
+
+
+
diff --git a/example/windows-uwp/README.md b/example/windows-uwp/README.md
index 5b53153..93d5372 100644
--- a/example/windows-uwp/README.md
+++ b/example/windows-uwp/README.md
@@ -2,7 +2,7 @@
本示例演示如何把 VCore 的 `Windows.Networking.Vpn` Provider、每会话完全信任运行时和一个最小命令行前台打进同一个 MSIX,并通过 `VCoreWindowsVpnInvoke` 创建、连接、查询和停止系统 VPN。
-> 这里的 “UWP” 指 Windows 的 UWP VPN Provider 模型。VCore **不支持纯 AppContainer 前台直接承载完整集成**:profile/snapshot 管理和 Session Host 激活必须由同包的完全信任进程执行。已有纯 UWP UI 时,应增加一个完全信任 broker;不要从 UWP UI 直接调用桥接接口。
+> 这里的 “UWP” 指 Windows 的 UWP VPN Provider 模型。VCore **不支持纯 AppContainer 前台直接承载完整集成**:profile/snapshot 管理必须由同包的完全信任进程执行,Session Host 则由 Provider 激活。已有纯 UWP UI 时,应增加一个完全信任 broker;不要从 UWP UI 直接调用桥接接口。
## 最小架构
@@ -11,13 +11,13 @@ VCoreUwpDemo.exe(完全信任前台)
└─ VCoreWindowsVpnInvoke
├─ 发布不可变配置快照
├─ 创建/更新同包 VPN profile
- └─ 激活 SessionHost
-
-vcore-windows-session-host.exe(每次连接一个完全信任进程)
- └─ 完整 VCore:netstack / DNS / rules / outbounds
+ └─ ConnectProfileAsync
vcore-windows-vpn-host.exe + vcore.dll(AppContainer Provider)
- └─ VpnChannel / routes / DNS assignment / packet buffers / physical network
+ ├─ VpnChannel / routes / DNS assignment / packet buffers / physical network
+ └─ FullTrustProcessLauncher
+ └─ vcore-windows-session-host.exe(每次连接一个完全信任进程)
+ └─ 完整 VCore:netstack / DNS / rules / outbounds
```
前台退出不会停止 VPN。Provider 或 Session Host 退出、管道损坏、非法 frame 或物理网络变化会失败关闭当前 VPN。
@@ -180,17 +180,17 @@ VCore 会校验 YAML、发布 `vcore-session-v2:` 内容寻址 Session Snapshot
| Manifest 项 | 必须值/规则 |
| --- | --- |
-| Session Host Application Id | `SessionHost` |
-| Session Host executable | `vcore-windows-session-host.exe` |
+| Application 数量 | `1` |
+| Session Host extension | `windows.fullTrustProcess` / `vcore-windows-session-host.exe` |
| Provider executable | `vcore-windows-vpn-host.exe` |
-| Provider Application EntryPoint | `VCore.VpnHost.App` |
| Provider background EntryPoint | `VCore.VpnBackgroundTask` |
+| Provider runtime / trust | `windowsApp` / `appContainer` |
| in-process server path | `vcore.dll` |
| activatable class | `VCore.VpnBackgroundTask`,`ThreadingModel="both"` |
| capabilities | `internetClientServer`、`privateNetworkClientServer`、`runFullTrust`、`networkingVpnProvider` |
| minimum desktop OS | `10.0.19042.0` |
-可以修改 identity、publisher、版本、前台 Application Id/EXE、显示名称、图标和 app execution alias。除非同步修改 VCore 源码,否则不要修改 `SessionHost` 和 `VCore.VpnBackgroundTask`。桥接还固定使用 profile 名 `VCore`,可选 StartupTask 固定使用 `VCoreStartup`;两者都按 package family 隔离。
+可以修改 identity、publisher、版本、主 Application Id/EXE、显示名称、图标和 app execution alias。不要删除 Session Host/Provider extensions 或修改 `VCore.VpnBackgroundTask`。桥接还固定使用 profile 名 `VCore`,可选 StartupTask 固定使用 `VCoreStartup`;两者都按 package family 隔离。
三项 VCore 文件必须位于 package 根目录:
diff --git a/readme/README.ru.md b/readme/README.ru.md
index 174ed85..481cffe 100644
--- a/readme/README.ru.md
+++ b/readme/README.ru.md
@@ -4,12 +4,13 @@
English · 简体中文 · Русский
-VCore — независимое клиентское прокси-ядро на Rust, не привязанное к конкретному хост-приложению. Через строгую YAML-конфигурацию и Invoke API v5 оно предоставляет граф прокси, DNS, правила маршрутизации, GeoData, HTTP listener, плоскость данных TUN и статистику трафика. Внутренняя ревизия схемы конфигурации — 12; она присутствует только в ответе `version` и `buildIdentity`, но не записывается в YAML.
+VCore — независимое клиентское прокси-ядро на Rust, не привязанное к конкретному хост-приложению. Через строгую YAML-конфигурацию и Invoke API v5 оно предоставляет граф прокси, статические группы `select`, DNS, правила маршрутизации, GeoData, HTTP listener, плоскость данных TUN и loopback Controller. Внутренняя ревизия схемы конфигурации — 13; она присутствует только в ответе `version` и `buildIdentity`, но не записывается в YAML.
## Возможности
- Исходящие подключения: VLESS + XHTTP + TLS/REALITY, SOCKS5 CONNECT/UDP ASSOCIATE, AnyTLS TCP/UoT и DIRECT.
- Цепочки прокси: `dialer-proxy` образует ориентированный ациклический граф произвольной длины. Если узел A указывает на B, физический путь имеет вид `client -> B -> A -> target`.
+- Группы прокси: статические группы `select` сохраняют порядок участников; участниками могут быть конкретные узлы, вложенные группы, `DIRECT` и `REJECT`. Выбор текущей session можно менять через Controller. `dialer-proxy` по-прежнему принимает только конкретный узел.
- Маршрутизация: последовательно применяются `DOMAIN`, `DOMAIN-SUFFIX`, `DOMAIN-KEYWORD`, `GEOSITE`, `GEOIP`, `IP-CIDR`, `IP-CIDR6`, `DST-PORT`, `NETWORK` и завершающее правило `MATCH`.
- DNS: UDP/TCP nameserver с фиксированным IP, явный outbound, последовательные policy/failover, typed/opaque cache, singleflight и перехват UDP/TCP-порта 53 в TUN.
- TUN: raw IPv4/IPv6, TCP/UDP, локальные ответы ICMPv4/ICMPv6 Echo, HTTP/TLS/QUIC sniffer и четыре счётчика трафика на session.
@@ -23,9 +24,10 @@ VCore — независимое клиентское прокси-ядро на
- Размер YAML ограничен 256 KiB; неизвестные поля, anchors, aliases, пользовательские tags и устаревшие структуры отклоняются.
- На верхнем уровне должен быть хотя бы один proxy, а также `port` или включённый `tun`.
-- Значения proxy `name` чувствительны к регистру и уникальны; все ссылки должны существовать, а граф прокси должен быть ациклическим.
-- `rules` обязателен и должен завершаться ровно одним `MATCH`, указывающим на реальное имя proxy.
-- `DIRECT` и `REJECT` — встроенные actions; любой другой action должен быть реальным именем proxy.
+- Имена proxy и proxy group используют общее пространство имён с точным совпадением и учётом регистра. Имя занимает 1–64 байта UTF-8; запрещены окружающие Unicode-пробелы, управляющие символы, `, # / ? & = % \`, `.` и `..`, а `DIRECT`, `REJECT` и `RULES` зарезервированы. Внутренние обычные пробелы, CJK и emoji разрешены.
+- `proxy-groups` принимает только `select`. Порядок и повторы участников сохраняются; без `default-selected` выбирается первый участник, а явное значение должно быть прямым участником. Цепочки proxy и вложенные группы должны быть ациклическими.
+- `rules` обязателен и должен завершаться ровно одним `MATCH`, указывающим на настроенный proxy node или proxy group.
+- `DIRECT` и `REJECT` — встроенные actions и участники групп; любой другой route target должен быть настроенным proxy node или proxy group. `RULES` зарезервирован только для DNS.
- Конфигурация передаётся inline через `configYaml` / `configYamls`; VCore не читает пути конфигурации хоста.
- Runtime-значения, включая Controller, TUN fd, порт и secret Controller, создаются хостом и не сохраняются в пользовательском RAW YAML.
@@ -57,14 +59,18 @@ initialize
`instanceId` — generation token, который не используется повторно текущим runtime. Команды одного экземпляра завершаются fail-fast, если другая команда уже выполняется; чистые вызовы `validateConfig` могут выполняться параллельно. Полный envelope, методы, владение fd и контракт Android protect описаны в [`docs/invoke-api.md`](../docs/invoke-api.md).
-Трафик TUN запрашивается через session-local loopback Controller:
+Состояние runtime доступно через session-local loopback Controller:
```http
GET /traffic
+GET /group
+GET /group/{name}
+GET /proxies/{name}
+PUT /proxies/{name}
Authorization: Bearer
```
-Ответ представляет собой одноразовый snapshot `up/down/upTotal/downTotal`, а не непрерывный stream. Подробности — в [`docs/controller-api.md`](../docs/controller-api.md).
+`GET /traffic` возвращает одноразовый TUN snapshot `up/down/upTotal/downTotal`. Конечные точки групп читают и изменяют выбранного прямого участника статической группы `select`; успешное изменение влияет только на новые физические TCP-, UDP- и DNS-transports текущей session. Оно не переносит существующие соединения, UDP associations, состояние DNS или pooled TCP transports и не выполняет автоматический failover. Controller, управляющий группами, требует один Bearer secret для всех маршрутов и может работать без TUN. Подробности — в [`docs/controller-api.md`](../docs/controller-api.md).
## Платформы
@@ -103,7 +109,7 @@ TCP sessions, обычные UDP associations, half-open connections, outbound h
- [AnyTLS outbound](../docs/anytls.md)
- [Клиентский протокол REALITY V1](../docs/reality-wire-protocol.md)
- [Зависимость rustls REALITY и требования к выпуску](../docs/rustls-reality-release.md)
-- [Controller трафика TUN](../docs/controller-api.md)
+- [Runtime Controller](../docs/controller-api.md)
- [ICMP и DNS в TUN](../docs/tun-icmp-dns.md)
- [Правила и assets GeoData](../docs/geodata.md)
- [Платформенный слой TUN](../docs/tun-platform.md)
diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md
index 7b3e8ff..4ea5862 100644
--- a/readme/README.zh_CN.md
+++ b/readme/README.zh_CN.md
@@ -4,12 +4,13 @@
English · 简体中文 · Русский
-VCore 是独立且不绑定特定宿主应用的 Rust 客户端代理 core。它通过严格 YAML 配置和 Invoke API v5 提供代理图、DNS、规则、GeoData、HTTP listener、TUN 数据面与流量统计。内部配置 schema revision 为 12;revision 只出现在 `version` 响应和 `buildIdentity` 中,不写入 YAML。
+VCore 是独立且不绑定特定宿主应用的 Rust 客户端代理 core。它通过严格 YAML 配置和 Invoke API v5 提供代理图、静态 `select` 代理组、DNS、规则、GeoData、HTTP listener、TUN 数据面与回环 Controller。内部配置 schema revision 为 13;revision 只出现在 `version` 响应和 `buildIdentity` 中,不写入 YAML。
## 能力
- Outbound:VLESS + XHTTP + TLS/REALITY、SOCKS5 CONNECT/UDP ASSOCIATE、AnyTLS TCP/UoT、DIRECT。
- 代理链:`dialer-proxy` 组成任意长度的有向无环图;节点 A 指向 B 时,物理路径为 `client -> B -> A -> target`。
+- 代理组:静态 `select` 组保留有序成员,可包含具体节点、嵌套组、`DIRECT` 与 `REJECT`;当前 session 的选择可通过 Controller 实时修改。`dialer-proxy` 仍然只能引用具体节点。
- 路由:顺序执行 `DOMAIN`、`DOMAIN-SUFFIX`、`DOMAIN-KEYWORD`、`GEOSITE`、`GEOIP`、`IP-CIDR`、`IP-CIDR6`、`DST-PORT`、`NETWORK` 和最终 `MATCH`。
- DNS:固定 IP 的 UDP/TCP nameserver、显式出口、顺序 policy/failover、typed/opaque cache、singleflight、TUN UDP/TCP 53 劫持。
- TUN:raw IPv4/IPv6、TCP/UDP、ICMPv4/ICMPv6 Echo 本地响应、HTTP/TLS/QUIC sniffer、每 session 四字段流量统计。
@@ -23,9 +24,10 @@ VCore 是独立且不绑定特定宿主应用的 Rust 客户端代理 core。它
- YAML 最大 256 KiB,拒绝未知字段、anchor、alias、自定义 tag 和历史结构。
- 顶层至少包含一个 proxy,以及 `port` 或启用的 `tun`。
-- proxy `name` 大小写敏感且唯一;引用必须存在,代理图必须无环。
-- `rules` 必填,必须恰好以一个指向实际 proxy name 的 `MATCH` 结束。
-- `DIRECT` 和 `REJECT` 是内置 action;其他 action 必须是实际 proxy name。
+- proxy 与 proxy group 定义名共享一个精确且大小写敏感的命名空间。名称为 1–64 UTF-8 字节,拒绝首尾 Unicode 空白、控制字符、`, # / ? & = % \`、`.`、`..`,并保留 `DIRECT`、`REJECT` 与 `RULES`;内部普通空格、CJK 和 emoji 可用。
+- `proxy-groups` 只接受 `select`。成员顺序与重复项均保留;省略 `default-selected` 时选择第一项,显式值必须是直接成员。代理链和嵌套组分别必须无环。
+- `rules` 必填,必须恰好以一个指向已配置 proxy node 或 proxy group 的 `MATCH` 结束。
+- `DIRECT` 和 `REJECT` 是内置 action 与组成员;其他 route target 必须是已配置 proxy node 或 proxy group。`RULES` 只由 DNS 保留。
- 配置通过 `configYaml` / `configYamls` 内联交付;VCore 不读取宿主配置路径。
- Controller、TUN fd、Controller 端口/secret 等运行时值由宿主生成,不进入用户保存的 RAW YAML。
@@ -57,14 +59,18 @@ initialize
`instanceId` 是当前 runtime 内不可复用的 generation token。同实例命令 fail-fast;纯 `validateConfig` 可并发执行。完整 envelope、method、fd 所有权和 Android protect 契约见 [`docs/invoke-api.md`](../docs/invoke-api.md)。
-TUN 流量通过 session-local loopback Controller 查询:
+运行时状态通过 session-local loopback Controller 访问:
```http
GET /traffic
+GET /group
+GET /group/{name}
+GET /proxies/{name}
+PUT /proxies/{name}
Authorization: Bearer
```
-响应为一次 `up/down/upTotal/downTotal` snapshot,不是持续 stream。详见 [`docs/controller-api.md`](../docs/controller-api.md)。
+`GET /traffic` 返回一次 TUN `up/down/upTotal/downTotal` snapshot。代理组端点读取或修改静态 `select` 组的当前直接成员;成功切换只影响当前 session 后续新建的物理 TCP、UDP 与 DNS transport,不迁移既有连接、UDP association、DNS 状态或 TCP 连接池,也不自动故障转移。Controller 管理代理组时,全部路由必须共用一个 Bearer secret,并且可以不启用 TUN。详见 [`docs/controller-api.md`](../docs/controller-api.md)。
## 平台
@@ -103,7 +109,7 @@ TCP session、普通 UDP association、half-open、outbound handshake 和 active
- [AnyTLS 出站](../docs/anytls.md)
- [REALITY V1 客户端协议](../docs/reality-wire-protocol.md)
- [rustls REALITY 依赖与发布要求](../docs/rustls-reality-release.md)
-- [TUN 流量 Controller](../docs/controller-api.md)
+- [运行时 Controller](../docs/controller-api.md)
- [TUN ICMP 与 DNS](../docs/tun-icmp-dns.md)
- [GeoData 规则与资产](../docs/geodata.md)
- [TUN 平台层](../docs/tun-platform.md)
diff --git a/scripts/README.md b/scripts/README.md
index d6632f7..961a7ad 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -19,8 +19,8 @@ uv run --project scripts --locked vcore-scripts build windows
- Apple 命令只能在 macOS 运行,输出 `dist/apple/LibVCore.xcframework`。
- Android 命令在 macOS/Linux 运行,默认输出 `dist/android/{arm64-v8a,x86_64}/libvcore.so`。
-- Windows 命令只能在已安装 Visual Studio C++ 工具的 Windows 运行;命令从系统注册表读取原生 ARM64/x64 处理器架构,通过 `vswhere` 加载对应的 MSVC 环境,并输出 `dist/windows/` 下的 DLL、Provider Host 和 Session Host。
-- 所有构建都使用 `Cargo.lock`,并检查产物内的 Invoke API v5/config revision 12 身份。
+- Windows 命令只能在已安装 Visual Studio C++ 工具的 Windows 运行;命令从系统注册表读取原生 ARM64/x64 处理器架构,通过 `vswhere` 加载对应的 MSVC 环境,验证三项 PE 的 machine type 后输出 `dist/windows/` 下的 DLL、Provider Host、Session Host 和记录 package integration revision、架构及三项 SHA-256 的 `vcore-windows-artifacts.json`。
+- 所有构建都使用 `Cargo.lock`,并检查产物内的 Invoke API v5/config revision 13 身份。
Apple/Android 继续接受现有环境变量:
diff --git a/scripts/src/vcore_scripts/builds.py b/scripts/src/vcore_scripts/builds.py
index 197ed67..269cf96 100644
--- a/scripts/src/vcore_scripts/builds.py
+++ b/scripts/src/vcore_scripts/builds.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import hashlib
+import json
import locale
import mmap
import os
@@ -12,7 +13,7 @@
CORE_DIR = Path(__file__).resolve().parents[3]
EXPECTED_IDENTITY = (
- b"VCore;engine=rust;coreVersion=0.1.0;invokeApiVersion=5;configVersion=12"
+ b"VCore;engine=rust;coreVersion=0.1.0;invokeApiVersion=5;configVersion=13"
)
DEFAULT_FEATURES = "ffi,tun,inbound-http,outbound-vless"
@@ -77,6 +78,25 @@ def _cargo_build(
)
+def _require_windows_architecture(artifact: Path, architecture: str) -> None:
+ with artifact.open("rb") as file:
+ if file.read(2) != b"MZ":
+ raise RuntimeError(f"invalid Windows PE artifact: {artifact}")
+ file.seek(0x3C)
+ offset = file.read(4)
+ if len(offset) != 4:
+ raise RuntimeError(f"invalid Windows PE artifact: {artifact}")
+ file.seek(int.from_bytes(offset, "little"))
+ if file.read(4) != b"PE\0\0":
+ raise RuntimeError(f"invalid Windows PE artifact: {artifact}")
+ machine = file.read(2)
+ if len(machine) != 2:
+ raise RuntimeError(f"invalid Windows PE artifact: {artifact}")
+ expected = {"arm64": 0xAA64, "x64": 0x8664}[architecture]
+ if int.from_bytes(machine, "little") != expected:
+ raise RuntimeError(f"VCore Windows artifact has wrong architecture: {artifact}")
+
+
def _require_identity(artifact: Path, platform_name: str) -> None:
found = False
if artifact.stat().st_size:
@@ -352,6 +372,9 @@ def build_windows() -> None:
if os.name != "nt":
raise RuntimeError("Windows artifacts must be built on Windows")
architecture = _windows_architecture()
+ output = CORE_DIR / "dist" / "windows" / architecture
+ shutil.rmtree(output, ignore_errors=True)
+ output.mkdir(parents=True)
targets = {
"arm64": "aarch64-pc-windows-msvc",
"x64": "x86_64-pc-windows-msvc",
@@ -378,14 +401,29 @@ def build_windows() -> None:
"vcore-windows-vpn-host.exe",
"vcore-windows-session-host.exe",
]
+ for name in artifacts:
+ _require_windows_architecture(release / name, architecture)
_require_identity(release / "vcore.dll", "Windows")
- output = CORE_DIR / "dist" / "windows" / architecture
- shutil.rmtree(output, ignore_errors=True)
- output.mkdir(parents=True)
for name in artifacts:
shutil.copy2(release / name, output / name)
+ digests = {}
for name in artifacts:
artifact = output / name
with artifact.open("rb") as file:
- digest = hashlib.file_digest(file, "sha256").hexdigest()
- print(f"{digest} {artifact}")
+ digests[name] = hashlib.file_digest(file, "sha256").hexdigest()
+ print(f"{digests[name]} {artifact}")
+ (output / "vcore-windows-artifacts.json").write_text(
+ json.dumps(
+ {
+ "formatVersion": 1,
+ "windowsPackageIntegrationRevision": 2,
+ "architecture": architecture,
+ "buildIdentity": EXPECTED_IDENTITY.decode("ascii"),
+ "artifacts": digests,
+ },
+ indent=2,
+ sort_keys=True,
+ )
+ + "\n",
+ encoding="utf-8",
+ )
diff --git a/scripts/tests/test_scripts.py b/scripts/tests/test_scripts.py
index e9819ea..2e58a45 100644
--- a/scripts/tests/test_scripts.py
+++ b/scripts/tests/test_scripts.py
@@ -3,6 +3,7 @@
import json
import tempfile
import unittest
+import xml.etree.ElementTree as ET
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -17,6 +18,16 @@
from vcore_scripts.tun2socks import derive_xray_config
+def _windows_pe(machine: int) -> bytes:
+ contents = bytearray(512)
+ contents[:2] = b"MZ"
+ contents[0x3C:0x40] = (0x80).to_bytes(4, "little")
+ contents[0x80:0x84] = b"PE\0\0"
+ contents[0x84:0x86] = machine.to_bytes(2, "little")
+ contents[0x100 : 0x100 + len(EXPECTED_IDENTITY)] = EXPECTED_IDENTITY
+ return bytes(contents)
+
+
class ScriptTest(unittest.TestCase):
def test_cli_dispatches_windows_build_without_architecture(self):
with patch("vcore_scripts.cli.build_windows") as build:
@@ -39,6 +50,35 @@ def test_windows_architecture_uses_native_processor_registry(self):
key, "PROCESSOR_ARCHITECTURE"
)
+ def test_windows_example_uses_one_application_with_isolated_hosts(self):
+ root = ET.parse(
+ builds.CORE_DIR / "example/windows-uwp/AppxManifest.xml.in"
+ ).getroot()
+ foundation = "http://schemas.microsoft.com/appx/manifest/foundation/windows10"
+ desktop = "http://schemas.microsoft.com/appx/manifest/desktop/windows10"
+ uap10 = "http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
+ applications = root.findall(
+ f"{{{foundation}}}Applications/{{{foundation}}}Application"
+ )
+
+ self.assertEqual(len(applications), 1)
+ self.assertFalse(
+ any("AppListEntry" in element.attrib for element in root.iter())
+ )
+ extensions = applications[0].find(f"{{{foundation}}}Extensions")
+ full_trust = extensions.find(
+ f"{{{desktop}}}Extension[@Category='windows.fullTrustProcess']"
+ )
+ provider = extensions.find(
+ f"{{{foundation}}}Extension[@Category='windows.backgroundTasks']"
+ )
+ self.assertEqual(
+ full_trust.attrib["Executable"], "vcore-windows-session-host.exe"
+ )
+ self.assertEqual(provider.attrib["Executable"], "vcore-windows-vpn-host.exe")
+ self.assertEqual(provider.attrib[f"{{{uap10}}}RuntimeBehavior"], "windowsApp")
+ self.assertEqual(provider.attrib[f"{{{uap10}}}TrustLevel"], "appContainer")
+
def test_android_target_mapping_is_strict(self):
self.assertEqual(
_android_target("aarch64-linux-android", "24"),
@@ -61,12 +101,13 @@ def test_windows_release_build_uses_production_features_and_checks_identity(self
root = Path(directory)
release = root / "target/aarch64-pc-windows-msvc/release"
release.mkdir(parents=True)
- for name in (
+ artifacts = (
"vcore.dll",
"vcore-windows-vpn-host.exe",
"vcore-windows-session-host.exe",
- ):
- (release / name).write_bytes(EXPECTED_IDENTITY)
+ )
+ for name in artifacts:
+ (release / name).write_bytes(_windows_pe(0xAA64))
with (
patch.object(builds, "CORE_DIR", root),
@@ -92,7 +133,43 @@ def test_windows_release_build_uses_production_features_and_checks_identity(self
"--bins",
],
)
- (release / "vcore.dll").write_bytes(b"wrong")
+ manifest = json.loads(
+ (
+ root / "dist/windows/arm64/vcore-windows-artifacts.json"
+ ).read_text()
+ )
+ expected_digest = (
+ "93dd534a99e69369e9dc435101dce44d5b0be4fb43c82abec500e1bb4fb88444"
+ )
+ self.assertEqual(
+ manifest,
+ {
+ "architecture": "arm64",
+ "artifacts": {
+ "vcore-windows-session-host.exe": expected_digest,
+ "vcore-windows-vpn-host.exe": expected_digest,
+ "vcore.dll": expected_digest,
+ },
+ "buildIdentity": EXPECTED_IDENTITY.decode("ascii"),
+ "formatVersion": 1,
+ "windowsPackageIntegrationRevision": 2,
+ },
+ )
+
+ provider = release / "vcore-windows-vpn-host.exe"
+ provider.write_bytes(_windows_pe(0x8664))
+ with self.assertRaisesRegex(RuntimeError, "wrong architecture"):
+ builds.build_windows()
+ self.assertFalse(
+ (root / "dist/windows/arm64/vcore-windows-artifacts.json").exists()
+ )
+
+ provider.write_bytes(_windows_pe(0xAA64))
+ dll = bytearray(_windows_pe(0xAA64))
+ dll[0x100 : 0x100 + len(EXPECTED_IDENTITY)] = bytes(
+ len(EXPECTED_IDENTITY)
+ )
+ (release / "vcore.dll").write_bytes(dll)
with self.assertRaisesRegex(RuntimeError, "incompatible Rust identity"):
builds.build_windows()
diff --git a/src/config/measure.rs b/src/config/measure.rs
index 76b8b39..c684fb1 100644
--- a/src/config/measure.rs
+++ b/src/config/measure.rs
@@ -137,6 +137,7 @@ proxies:
"geo-auto-update: false",
"configVersion: 9",
"default-proxy: proxy",
+ "proxy-groups: []",
] {
let yaml = NODE.replacen("proxies:", &format!("{extra}\nproxies:"), 1);
assert!(
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 8517311..4cc8ab8 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -1,7 +1,7 @@
//! Strict parsing for the current VCore YAML configuration.
use std::{
- collections::HashMap,
+ collections::{HashMap, VecDeque},
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
str::FromStr,
};
@@ -40,7 +40,8 @@ pub const GEO_UPDATE_INTERVAL_HOURS: u64 = 24;
pub struct Config {
pub ipv6: bool,
pub proxies: Vec,
- pub default_proxy: ProxyId,
+ pub proxy_groups: Vec,
+ pub default_route_target: RouteTargetId,
pub geodata_update: Option,
pub external_controller: Option,
pub inbounds: Vec,
@@ -153,7 +154,7 @@ pub enum DnsTransport {
pub enum DnsRoute {
Direct,
Rules,
- Proxy(ProxyId),
+ Route(RouteTargetId),
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -178,7 +179,7 @@ pub enum RuleKind {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuleAction {
- Proxy(ProxyId),
+ Route(RouteTargetId),
Direct,
Reject,
}
@@ -204,6 +205,53 @@ impl ProxyId {
type ProxyIdsByTag = HashMap;
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct ProxyGroupId(usize);
+
+impl ProxyGroupId {
+ #[must_use]
+ pub const fn new(index: usize) -> Option {
+ Some(Self(index))
+ }
+
+ #[must_use]
+ pub const fn index(self) -> usize {
+ self.0
+ }
+
+ fn from_index(index: usize) -> Self {
+ Self(index)
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum RouteTargetId {
+ Proxy(ProxyId),
+ Group(ProxyGroupId),
+}
+
+type RouteTargetsByName = HashMap;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ProxyGroupMemberTarget {
+ Route(RouteTargetId),
+ Direct,
+ Reject,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ProxyGroupMemberConfig {
+ pub name: String,
+ pub target: ProxyGroupMemberTarget,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct SelectProxyGroupConfig {
+ pub name: String,
+ pub members: Vec,
+ pub initial_member: usize,
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IpCidr {
pub network: IpAddr,
@@ -434,11 +482,32 @@ struct RawVCoreConfig {
#[serde(default, deserialize_with = "deserialize_present_option")]
sniffer: Option,
proxies: Vec,
+ #[serde(
+ rename = "proxy-groups",
+ default,
+ deserialize_with = "deserialize_non_null_vec"
+ )]
+ proxy_groups: Vec,
#[serde(default, deserialize_with = "deserialize_present_option")]
dns: Option,
rules: Vec,
}
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct RawProxyGroup {
+ name: String,
+ #[serde(rename = "type")]
+ kind: String,
+ proxies: Vec,
+ #[serde(
+ rename = "default-selected",
+ default,
+ deserialize_with = "deserialize_present_option"
+ )]
+ default_selected: Option,
+}
+
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawGeoDataUrls {
@@ -735,6 +804,15 @@ where
T::deserialize(deserializer).map(Some)
}
+fn deserialize_non_null_vec<'de, D, T>(deserializer: D) -> std::result::Result, D::Error>
+where
+ D: Deserializer<'de>,
+ T: Deserialize<'de>,
+{
+ Option::>::deserialize(deserializer)?
+ .ok_or_else(|| serde::de::Error::custom("sequence must not be null"))
+}
+
impl Config {
pub fn parse_yaml(input: &[u8]) -> Result {
if input.len() > MAX_CONFIG_BYTES {
@@ -784,8 +862,6 @@ impl RawVCoreConfig {
if tun.mtu != default_mtu() {
return invalid("TUN only supports mtu: 1500");
}
- let external_controller =
- normalize_external_controller(self.external_controller, self.secret, tun.enable)?;
if self.port.is_none() && !tun.enable {
return invalid("configuration requires port or tun.enable: true");
}
@@ -797,15 +873,28 @@ impl RawVCoreConfig {
if self.proxies.is_empty() {
return invalid("proxies must contain at least 1 entry");
}
- let (proxies, proxy_ids) = normalize_proxy_graph(self.proxies)?;
- let default_proxy = derive_default_proxy_from_rules(&self.rules, &proxy_ids)?;
+ let (raw_proxy_groups, proxy_group_ids) =
+ normalize_proxy_group_declarations(self.proxy_groups)?;
+ let (proxies, proxy_ids) =
+ normalize_proxy_graph_with_groups(self.proxies, &proxy_group_ids)?;
+ let route_targets = collect_route_targets(&proxy_ids, &proxy_group_ids);
+ let proxy_groups = normalize_proxy_groups(raw_proxy_groups, &route_targets)?;
+ let default_route_target =
+ derive_default_route_target_from_rules(&self.rules, &route_targets)?;
+ let external_controller = normalize_external_controller(
+ self.external_controller,
+ self.secret,
+ tun.enable,
+ !proxy_groups.is_empty(),
+ )?;
let mut dns = self
.dns
- .map_or_else(DnsConfig::disabled, |dns| dns.normalize(&proxy_ids))?;
+ .map_or_else(DnsConfig::disabled, |dns| dns.normalize(&route_targets))?;
dns.ipv6 &= self.ipv6;
- let rules = normalize_rules(self.rules, &proxy_ids)?;
+ let rules = normalize_rules(self.rules, &route_targets)?;
drop(proxy_ids);
+ drop(route_targets);
let mut inbounds = Vec::with_capacity(2);
if let (Some(port), Some((username, password))) = (self.port, http_authentication) {
@@ -828,7 +917,8 @@ impl RawVCoreConfig {
Ok(Config {
ipv6: self.ipv6,
proxies,
- default_proxy,
+ proxy_groups,
+ default_route_target,
geodata_update,
external_controller,
inbounds,
@@ -845,6 +935,7 @@ fn normalize_external_controller(
listen: Option,
secret: Option,
tun_enabled: bool,
+ has_proxy_groups: bool,
) -> Result