diff --git a/.codex/skills/vnt-operations/SKILL.md b/.codex/skills/vnt-operations/SKILL.md new file mode 100644 index 00000000..a3c6a777 --- /dev/null +++ b/.codex/skills/vnt-operations/SKILL.md @@ -0,0 +1,41 @@ +--- +name: vnt-operations +description: "Operate VNT 2 clients and self-hosted VNTS servers: run packaged programs, author client TOML configuration, call authenticated client or server Web APIs, and deploy VNTS with Linux systemd. Use for VNT runtime, configuration, API automation, troubleshooting, or server deployment tasks; do not use for unrelated Rust development." +--- + +# VNT Operations + +Use the packaged VNT programs and their supported APIs. Treat the checked-in source as the authority when a local build differs from this skill. + +## Route the task + +- For `vnt2_cli`, `vnt2_ctrl`, `vnt2_web`, the desktop client, or client TOML/CLI parameters, read [references/client-usage.md](references/client-usage.md). +- For the `vnt2_web` or desktop Web-access API, read [references/client-web-api.md](references/client-web-api.md). +- For building, installing, configuring, upgrading, or troubleshooting the self-hosted server in `D:\rust\vnts`, read [references/server-deployment.md](references/server-deployment.md). +- For the VNTS administrative API, read [references/server-web-api.md](references/server-web-api.md). +- For repeatable HTTP calls, use [scripts/vnt_api.py](scripts/vnt_api.py). Run `python scripts/vnt_api.py --help` and the relevant subcommand help before first use. + +Read only the references required by the current request. If behavior appears version-dependent, check the current binary's `--help`/`--conf-example` or the source paths named in the relevant reference before acting. + +## Acquire API access + +Client and server authentication are separate and their tokens are not interchangeable. + +For a client Web API task, if access data is missing, ask for either: + +- the complete access URL printed by `vnt2_web`, such as `http://host:19099/?token=...`; or +- the API base URL and Web access token separately. + +Parse the `token` query parameter, remove it from subsequent request URLs, and send it only as `Authorization: Bearer `. Probe `/api/version` and `/api/runtime` before relying on the rest of the API. + +For a VNTS administrative API task, if access data is missing, ask for the management base URL, username, and password. Log in at `/api/login`, then use the returned JWT as the Bearer token. A client Web token does not authenticate to VNTS. + +Never echo secrets, include them in summaries, commit them, or persist them in the skill/repository. Prefer the helper's stdin or environment-variable inputs over command-line secret arguments. Redact credentials from errors. On `401`, refresh or request credentials once; if the retry also fails, stop and report the authentication failure. + +## Respect operation scope + +Read-only inspection may proceed when it is relevant. Start, stop, restart, save, or update only when the user's request authorizes that mutation. Before a delete that was not already explicit, identify the exact instance, configuration, network, device, or peer server and obtain confirmation. + +After a mutation, read back the affected resource or status. For asynchronous client startup, poll `/api/start/status` until `Running` or `Stopped`, report the terminal state, and include useful logs without credentials. + +For remote deployment, establish the target host, architecture, SSH access method, public name/address, and intended open ports before changing the host. Do not assume access to a production server merely because the local `D:\rust\vnts` source is available. diff --git a/.codex/skills/vnt-operations/agents/openai.yaml b/.codex/skills/vnt-operations/agents/openai.yaml new file mode 100644 index 00000000..00baa85f --- /dev/null +++ b/.codex/skills/vnt-operations/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "VNT Operations" + short_description: "运行、配置和通过 API 控制 VNT 客户端,并部署 VNTS 服务端" + default_prompt: "Use $vnt-operations to configure and operate this VNT client or self-hosted VNTS server." +policy: + allow_implicit_invocation: true diff --git a/.codex/skills/vnt-operations/references/client-usage.md b/.codex/skills/vnt-operations/references/client-usage.md new file mode 100644 index 00000000..1c1c839c --- /dev/null +++ b/.codex/skills/vnt-operations/references/client-usage.md @@ -0,0 +1,147 @@ +# VNT 客户端程序与配置 + +## 源码依据与版本核对 + +当前客户端源码位于 `D:\rust\vnt`。关键来源: + +- `README.md`:用户入口和安全说明。 +- `Cargo.toml`:程序名称与 feature。 +- `src/args_config.rs`:CLI 参数、TOML 字段、默认值和覆盖规则。 +- `src/main_cli.rs`、`src/main_ctrl.rs`、`src/main_web.rs`:三个程序的运行行为。 +- `.github/workflows/rust.yml`:发行包的实际内容。 + +发行 zip 包含同一目标平台的 `vnt2_cli`、`vnt2_ctrl`、`vnt2_web`(Windows 带 `.exe`)。桌面安装包是独立的 Tauri 客户端。命令示例在 Windows 上为程序名补 `.exe`;Unix 首次解压后执行 `chmod +x vnt2_*`。 + +在给出最终命令前优先运行: + +```text +vnt2_cli --version +vnt2_cli --help +vnt2_web --help +vnt2_ctrl --help +``` + +`vnt2_cli --conf-example` 会在当前工作目录写入 `example_config.toml`,仅在用户允许创建该文件时运行。 + +## 选择程序 + +| 程序 | 用途 | 关键行为 | +| --- | --- | --- | +| VNT 桌面客户端 | Windows 普通用户 | 图形界面;可按需启用进程内 Web 访问。 | +| `vnt2_web` | 浏览器管理、NAS、无桌面主机、多实例 | 默认监听 `127.0.0.1:19099`;配置存于工作目录的 `vnt_config/`;通过 Bearer token 保护 API。 | +| `vnt2_cli` | 单实例、服务化、脚本化 | 直接传 CLI 参数或用 `--conf` 读取 TOML;TUN/TAP 通常需要管理员/root。 | +| `vnt2_ctrl` | 查询后台 `vnt2_cli` | 支持 `info`、`ips`、`clients`/`list`、`route`,用 `--port` 连接非默认控制端口。 | + +最小客户端示例: + +```text +vnt2_cli --network-code my-network --server quic://vpn.example.com:29872 --password "shared-network-password" +``` + +兼容参数 `-k` 也可设置网络编号;新命令优先写清晰的 `--network-code`。同一虚拟网络中的客户端必须使用相同的服务端、网络编号和网络密码。 + +Web 管理示例: + +```text +vnt2_web --addr 127.0.0.1:19099 --token "at-least-16-characters" +``` + +也可通过 `VNT_WEB_TOKEN` 提供 token。未指定时程序生成随机 token,并在日志中输出带 `?token=` 的访问链接。只有明确需要远程访问时才监听非回环地址;远程访问应配合防火墙或 HTTPS 反向代理。 + +控制后台 CLI: + +```text +vnt2_ctrl info +vnt2_ctrl clients +vnt2_ctrl route +vnt2_ctrl --port 11234 info +``` + +`ctrl_port = 0` 会禁用控制服务。 + +## CLI 与配置文件合并 + +`vnt2_cli --conf path/to/client.toml` 可与 CLI 参数同时使用: + +- `Option` 类型参数由显式 CLI 值覆盖文件值。 +- 可重复的列表参数在 CLI 非空时覆盖文件列表,否则使用文件列表。 +- 布尔开关通常为 CLI `true` 与文件值做启用合并;CLI 不提供通用的“反向关闭文件中 true”能力。 +- `network_code` 必填;`server` 在正常组网中也应设置。 +- 已删除 `no_tun`;必须迁移到 `device_mode = "no"`、`"tun"` 或 `"tap"`。 + +不要仅凭本文猜测边界值;使用当前二进制的 `--help` 和源码中的 `FileConfig`/`Args` 核对。 + +## 推荐 TOML 基线 + +```toml +config_name = "office-node" +network_code = "my-network" +server = ["quic://vpn.example.com:29872"] + +# 同一虚拟网络必须一致;开启后节点间使用端到端加密。 +password = "replace-with-a-strong-shared-password" + +device_mode = "tun" +device_name = "office-node" + +# 自签名服务端建议使用启动日志给出的 SHA-256 指纹。 +cert_mode = "finger:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +no_punch = false +no_broadcast = false +rtx = false +compress = false +fec = false +auto_sync_subnet = false +no_nat = false +allow_mapping = false +allow_ikev2 = false +``` + +不要把示例秘密原样投入生产。`config_name` 是 Web 配置格式支持的展示名;CLI 的 `FileConfig` 会忽略未知字段失败与否应按当前版本验证,给纯 CLI 配置时可省略它。 + +## 参数速查 + +### 服务端与直连 + +- `server = ["quic://host:29872", "tcp://host:29872", "wss://host:29872"]` 可配置多个服务端以容灾。 +- `dynamic://domain` 从 DNS TXT 解析;`dynamic://https://...` 接口返回按换行分隔的服务端地址。 +- `peer_address` 可重复,接受 `ip:port`、`tcp://ip:port`、`udp://ip:port`。无协议时同时尝试 TCP/UDP,端口必须是对端 `tunnel_port`。 +- `no_punch = true` 关闭自动打洞,但显式 `peer_address` 仍可直连。 +- `turn = ["目标IP或CIDR,中转虚拟IP"]`。中转填写网关虚拟 IP 时强制服务器中继;命中目标不参与 P2P 打洞。 +- `punch_model = ["目标IP或CIDR,IPv4Udp,IPv4Tcp"]` 限制打洞方式。模式为 `IPv4Tcp`、`IPv4Udp`、`IPv6Tcp`、`IPv6Udp`;双方使用允许集合的交集。 + +### 设备与安全 + +- `device_mode = "tun"`:三层虚拟网卡,默认模式。 +- `device_mode = "tap"`:二层 Ethernet;Windows 需管理员权限并预装 TAP-Windows `tap0901`。 +- `device_mode = "no"`:不创建网卡,只提供流量出口和端口映射,通常不需管理员权限。 +- Windows TUN 使用随程序提取的 `wintun.dll`;Linux/macOS 使用系统 TUN/TAP 能力。 +- `device_id` 同一服务端和网络内不得冲突;缺省时使用机器标识。`device_name` 缺省取 hostname。 +- `tunnel_port` 固定 P2P 端口;同机多实例不能使用同一显式端口。 +- `outbound_interface` 绑定服务端通信、打洞及转发流量使用的出口网卡。 +- `cert_mode = "skip"` 跳过服务端证书验证(默认但不推荐公网生产);`standard` 使用系统根证书;`finger:<64位hex>` 绑定 SHA-256 指纹。 +- `password` 是节点间端到端加密密码,不是 Web token,也不是服务端管理密码。 +- `allow_ikev2 = true` 信任服务端注入的 IKEv2 明文 IPv4 流量;仅在确实要与 IKEv2 客户端互通时开启。 + +### 网络转发与质量 + +- `input = ["源网段CIDR,目标虚拟IP"]` 将指定网段流量导向出口节点。 +- `output = ["真实CIDR"]` 声明本机允许转发的真实网段。 +- `subnet_mapping = ["映射CIDR,真实CIDR"]` 做等掩码映射;真实范围必须被 `output` 覆盖。 +- `auto_sync_subnet = true` 自动应用在线节点上报的出口子网。 +- `no_nat = true` 关闭内置子网 NAT,此时必须另行配置系统转发/NAT。 +- `port_mapping = ["tcp://0.0.0.0:81-10.26.0.2-192.168.1.10:80"]` 表示本地监听、目标虚拟节点和最终目标。 +- 作为远端端口映射出口的节点必须设置 `allow_mapping = true`。 +- `no_broadcast = true` 关闭本机发出的 IPv4 广播和组播转发;ARP、其他二层广播及单播不受影响。 +- `rtx` 启用 QUIC 优化通道;`compress` 启用 LZ4;`fec` 用额外带宽换取丢包恢复。根据链路实测启用,不要默认全部开启。 +- `mtu` 只在明确诊断到 MTU/分片问题时调整。 + +## 多实例和验证 + +Web 端允许多个配置实例,但会拒绝以下冲突: + +- 相同服务端范围、相同 `network_code` 且相同 `device_id`;双方都未指定 `device_id` 也可能因机器标识相同而冲突。 +- 两个实例显式设置相同 `tunnel_port`。 + +验证顺序:确认程序版本;确认服务器、网络编号和密码一致;确认实例进入 `Running`;查看分配的虚拟 IP 和节点列表;最后用虚拟 IP `ping` 或真实业务流量验证。ICMP 失败也可能只是主机防火墙阻止 ping。 diff --git a/.codex/skills/vnt-operations/references/client-web-api.md b/.codex/skills/vnt-operations/references/client-web-api.md new file mode 100644 index 00000000..c9c3fd20 --- /dev/null +++ b/.codex/skills/vnt-operations/references/client-web-api.md @@ -0,0 +1,141 @@ +# VNT 客户端 Web API + +## 连接与鉴权 + +本接口由独立 `vnt2_web` 或桌面端启用的 Web 访问提供。独立程序默认地址是 `http://127.0.0.1:19099`。 + +若用户未提供访问数据,询问完整访问链接(通常包含 `?token=...`),或分别询问 API 根地址和 token。完整链接的查询参数只用于提取 token,API 请求必须改为: + +```text +Authorization: Bearer +``` + +不要继续把 token 放在请求 URL 中。先请求: + +```text +GET /api/version +GET /api/runtime +``` + +`runtime` 当前可能返回 `standalone_web` 或 `desktop_web`。所有业务响应采用: + +```json +{"code": 0, "msg": "success", "data": {}} +``` + +HTTP 2xx 不等于业务成功;只有 `code == 0` 才成功。`401` 表示 Web token 不正确或已变化,只重新获取/重试一次。 + +通用辅助脚本示例(从 stdin 输入带 token 的完整链接): + +```text +python scripts/vnt_api.py client --access-url-stdin GET /api/version +python scripts/vnt_api.py client --access-url-stdin GET /api/instances +``` + +脚本路径相对于本 skill 目录。若调用环境不方便重复输入,可将链接放入临时进程环境变量并用 `--access-url-env`;不要写入仓库文件。 + +## 接口目录 + +| 方法 | 路径 | 用途 | 输入 | +| --- | --- | --- | --- | +| GET | `/api/version` | 程序版本 | 无 | +| GET | `/api/runtime` | 运行形态 | 无 | +| GET | `/api/instances` | 所有实例 | 无 | +| GET | `/api/start/status?file_name=...` | 启动状态和日志 | 配置文件名 | +| GET | `/api/info?file_name=...` | 实例综合状态 | 配置文件名 | +| GET | `/api/peers?file_name=...` | 节点列表 | 配置文件名 | +| GET | `/api/routes?file_name=...` | 路由列表 | 配置文件名 | +| POST | `/api/start` | 启动实例 | `{"file_name":"x.toml"}` | +| POST | `/api/stop` | 停止实例/中止启动 | `{"file_name":"x.toml"}` | +| POST | `/api/restart` | 停止后重新启动 | `{"file_name":"x.toml"}` | +| DELETE | `/api/instance?file_name=...` | 移除已停止实例卡片 | 配置文件名 | +| GET | `/api/config/list` | 配置摘要列表 | 无 | +| GET | `/api/config?file_name=...` | 读取 TOML 原文 | 配置文件名 | +| POST | `/api/config` | 新建或覆盖配置 | `{"file_name":"x.toml","config":"..."}` | +| DELETE | `/api/config?file_name=...` | 删除未占用的配置文件 | 配置文件名 | + +查询参数必须进行 URL 编码。文件名不能为空,不能包含 `..`、`/` 或 `\\`。保存配置时无扩展名会补 `.toml`,其他扩展名会被拒绝;省略或传空 `file_name` 时服务端生成时间戳文件名。 + +## 典型工作流 + +### 查看状态 + +1. `GET /api/instances`,取得准确 `file_name` 和 `status`。 +2. 针对实例读取 `/api/info`、`/api/peers` 或 `/api/routes`。 +3. 不要把展示名 `config_name` 当作 `file_name`。 + +实例摘要: + +```json +{ + "file_name": "office.toml", + "config_name": "Office", + "status": "Running" +} +``` + +状态值由当前版本序列化,主要关注 `Starting`、`Running`、`Stopped`。 + +### 保存配置 + +先读取当前配置并保留用户未要求改变的字段。请求体中的 `config` 是 TOML 字符串,不是嵌套 JSON 对象: + +```json +{ + "file_name": "office.toml", + "config": "config_name = \"Office\"\nnetwork_code = \"team-a\"\nserver = [\"quic://vpn.example.com:29872\"]\ndevice_mode = \"tun\"\n" +} +``` + +服务端会解析 TOML 并拒绝旧的 `no_tun = true`。写入成功后再 `GET /api/config` 比对。覆盖正在运行实例的配置不会自动重启;由用户决定是否调用 restart。 + +脚本示例: + +```text +python scripts/vnt_api.py client --access-url-stdin POST /api/config --json-file office-request.json +``` + +`office-request.json` 只能是用户允许的临时文件,且不得放入秘密 token。若无需落盘,可使用 `--json`。 + +### 启动与轮询 + +调用: + +```json +{"file_name":"office.toml"} +``` + +发送到 `/api/start` 后,每 0.5–2 秒请求 `/api/start/status?file_name=office.toml`,但避免无限轮询。响应 data: + +```json +{ + "status": "Starting", + "logs": ["..."] +} +``` + +到 `Running` 即成功;到 `Stopped` 即失败或已停止,报告相关日志。设置合理总超时;服务端不可达时启动任务可能持续重试,用户要求取消时调用 `/api/stop`。 + +### 停止、重启和清理 + +- `POST /api/stop` 会中止尚在注册重试中的启动任务,并停止运行实例。 +- `POST /api/restart` 最多等待约 5 秒停止,再尝试启动;之后仍需轮询状态。 +- `DELETE /api/instance` 只移除 `Stopped` 实例的内存条目,不删除 TOML。 +- `DELETE /api/config` 删除 TOML,若配置仍被实例占用会失败。 + +先停止、确认 `Stopped`、必要时移除实例条目,最后才删除配置文件。删除不是“修复卡片”的首选;启动失败残留应优先用 `/api/instance` 清理。 + +## 返回数据要点 + +`/api/info` 包含虚拟 IP、前缀、网关、设备 ID、服务器状态、NAT、公网地址、在线/离线/直连数量,以及 FEC、压缩、加密、RTX 和配置是否变化等信息。 + +`/api/peers` 返回各虚拟节点的设备信息、在线状态、连接路径和流量信息。`/api/routes` 按目标虚拟 IP 返回一个或多个路由,每条路由包含地址、协议、metric、RTT 和丢包率。以实际响应为准,不要依赖未使用字段的固定顺序。 + +## 错误处理 + +- HTTP `401`:token 无效;重新询问/刷新一次。 +- `code != 0`:展示已脱敏的 `msg`,不要继续执行依赖该步骤的写操作。 +- `Config file not found`:重新读取配置列表,确认 `file_name`。 +- `实例不存在`:重新读取实例列表,不要假设配置也不存在。 +- `此配置已被使用,不能删除`:先停止并确认状态,不要强制绕过。 +- 网络超时:先做一次只读健康探测;不要循环重放 POST/DELETE。 diff --git a/.codex/skills/vnt-operations/references/server-deployment.md b/.codex/skills/vnt-operations/references/server-deployment.md new file mode 100644 index 00000000..b40ed679 --- /dev/null +++ b/.codex/skills/vnt-operations/references/server-deployment.md @@ -0,0 +1,214 @@ +# VNTS 自建服务端部署 + +## 源码依据 + +本机服务端源码在 `D:\rust\vnts`,与客户端项目分开。关键来源: + +- `README.md`:功能和前端构建说明。 +- `src/main.rs`:启动参数、配置加载、服务组合。 +- `src/utils/config.rs`:配置字段、默认值和校验。 +- `src/http/web_server.rs`:管理端和热更新行为。 +- `.github/workflows/rust.yml`:发行二进制命名与目标架构。 + +部署前核对当前版本: + +```text +vnts2 --version +vnts2 --help +vnts2 --conf-example +``` + +`--conf-example` 只打印示例;无参数首次启动会在当前工作目录创建 `config.toml`、TLS 证书及运行目录。生产环境应先准备配置再启动服务。 + +## 获取二进制 + +优先使用与目标架构匹配的 GitHub Release 单文件二进制,命名形如 `vnts2-x86_64-unknown-linux-musl-v2.0.3`。下载后校验发布页提供的完整性信息并设置执行权限。 + +从源码构建时,先构建会被嵌入二进制的 Web 前端,再构建 Rust: + +```text +cd D:\rust\vnts\frontend +pnpm install --frozen-lockfile +pnpm build +cd D:\rust\vnts +cargo build --release +``` + +本机原生输出为 `target/release/vnts2`(Windows 为 `.exe`)。面向 Linux 部署时不要把 Windows 构建产物复制到服务器;使用 Linux 构建机、CI 的目标产物或配置正确的交叉编译目标。 + +## 生产目录布局 + +使用以下固定布局: + +```text +/opt/vnts/vnts2 只读程序 +/var/lib/vnts/config.toml 配置 +/var/lib/vnts/network_control.db +/var/lib/vnts/cert.pem +/var/lib/vnts/key.pem +/var/lib/vnts/ikev2-*.pem 可选 +/var/lib/vnts/logs/ +``` + +程序的数据库、默认 TLS 证书和日志都相对于工作目录;因此 systemd 的 `WorkingDirectory` 必须固定为 `/var/lib/vnts`。管理 API 会修改 `config.toml` 中的白名单和 IKEv2 配置,因此该文件需由服务用户写入。 + +创建专用账号和目录的典型命令: + +```text +sudo useradd --system --home /var/lib/vnts --shell /usr/sbin/nologin vnts +sudo install -d -o vnts -g vnts -m 0750 /var/lib/vnts +sudo install -d -o root -g root -m 0755 /opt/vnts +sudo install -o root -g root -m 0755 ./vnts2 /opt/vnts/vnts2 +sudo install -o vnts -g vnts -m 0640 ./config.toml /var/lib/vnts/config.toml +``` + +在执行前根据目标发行版确认 `nologin` 路径。升级时不要覆盖数据库或配置目录。 + +## 配置基线 + +```toml +# TCP-TLS 与 WSS 相同地址时由同一监听器自动识别协议。 +tcp_bind = "0.0.0.0:29872" +quic_bind = "0.0.0.0:29872" +ws_bind = "0.0.0.0:29872" + +network = "10.26.0.0/24" +white_list = ["my-network"] +lease_duration = 86400 +persistence = true + +# 管理端优先只监听本机,由 HTTPS 反向代理或 SSH 隧道访问。 +web_bind = "127.0.0.1:29871" +username = "replace-admin" +password = "replace-with-a-long-random-password" + +# 留空时在 WorkingDirectory 自动生成自签名证书。 +# cert = "/var/lib/vnts/cert.pem" +# key = "/var/lib/vnts/key.pem" + +peer_servers = [] + +[custom_nets] +# special-network = "10.27.0.0/24" +``` + +配置含义: + +- `tcp_bind`、`quic_bind`、`ws_bind` 省略某项即不启用对应传输;三项均缺失会启动失败。 +- `network` 是默认虚拟网段。`custom_nets` 可为指定网络编号设置独立网段。 +- `white_list = []` 允许任意网络编号;非空时只允许列表中的编号。网络编号不能为空、不能有首尾空白且最多 32 字节。 +- `lease_duration` 单位秒。 +- `persistence = true` 使用工作目录中的 `network_control.db` 保存网络、设备及互联服务端状态。 +- `web_bind` 省略即禁用管理端。绝不在公网保留默认 `admin/admin`。 +- `cert` 和 `key` 必须一起设置;省略时生成 `cert.pem`/`key.pem`。服务启动日志输出 `Fingerprint: <64位hex>`,客户端可配置 `cert_mode = "finger:"`。 + +服务端互联的所有节点必须使用相同的非默认 `server_token`: + +```toml +server_quic_bind = "0.0.0.0:29873" +peer_servers = ["server-b.example.com:29873"] +server_token = "replace-with-a-long-shared-secret" +``` + +若配置了 peer 或启用了 Web 管理,内部 peer manager 也会建立;不要依赖源码默认的 `default_token` 进行生产互联。 + +## systemd + +写入 `/etc/systemd/system/vnts.service`: + +```ini +[Unit] +Description=VNT self-hosted server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=vnts +Group=vnts +WorkingDirectory=/var/lib/vnts +ExecStart=/opt/vnts/vnts2 --conf /var/lib/vnts/config.toml +Restart=on-failure +RestartSec=3s +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +ReadWritePaths=/var/lib/vnts + +[Install] +WantedBy=multi-user.target +``` + +加载并检查: + +```text +sudo systemctl daemon-reload +sudo systemctl enable --now vnts +sudo systemctl status vnts --no-pager +sudo journalctl -u vnts -n 100 --no-pager +``` + +未启用 IKEv2 时不要授予额外 capability。启用标准 UDP 500/4500 时,为非 root 服务在 `[Service]` 增加: + +```ini +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +``` + +然后 `daemon-reload` 并重启。若发行版或安全策略仍拒绝绑定,先读 journal 和审计日志,不要直接改成 root 长期运行。 + +## 防火墙与暴露面 + +按实际启用项开放: + +| 端口 | 协议 | 用途 | +| --- | --- | --- | +| 29872 | TCP | VNT TCP-TLS / WSS | +| 29872 | UDP | VNT QUIC | +| 29871 | TCP | Web 管理;优先不向公网开放 | +| 自定义,例 29873 | UDP | 服务端互联 QUIC | +| 500 | UDP | 可选 IKEv2 IKE | +| 4500 | UDP | 可选 IKEv2 NAT-T/ESP 数据面 | + +只开放配置中真实绑定的端口。公网管理建议让 `web_bind` 监听回环地址,经 HTTPS 反向代理、VPN 或 SSH 隧道访问;如果直接监听私网地址,也应按管理源 IP 限制防火墙。 + +## IKEv2(可选) + +优先在 Web 管理端“系统设置”配置,再在网络详情中创建设备。配置基线: + +```toml +[ikev2] +enabled = true +ike_bind = "0.0.0.0:500" +natt_bind = "0.0.0.0:4500" +server_address = "vpn.example.com" +remote_id = "vpn.example.com" +dns = ["1.1.1.1"] +# cert/key 同时省略时自动创建本地 CA 和匹配 remote_id 的服务证书。 +``` + +`server_address` 是接入说明交给客户端的实际连接地址,启用服务时必填,接受域名、IPv4 或 IPv6 地址;不要填写 URL、端口或带首尾空白的值。`remote_id` 是服务端 IKE 身份并必须匹配服务器证书 SAN,只接受域名或 IPv4 地址。两者可以不同,但 Android 原生客户端通常以服务器地址作为远程 ID,此时可能无法通过服务器身份验证;需要兼容 Android 时应让两者相同。两个监听地址不能相同。 + +IKEv2 设备使用独立用户名/密码,用户名同时是设备 ID。原生客户端接入时: + +- Android 使用 `IKEv2/IPSec MSCHAPv2`,把受管 CA 安装为“VPN 和应用的 CA 证书”并在“IPSec CA 证书”中明确选择,不能选择“不验证服务器”;“IPSec 标识符”填写该设备 ID。 +- Windows 安装并信任 CA 后,需将 IKE/ESP 加密算法设置为 `GCMAES256`,DH 组设置为 Group14。 +- macOS/iOS 的服务器填写 `server_address`,远程 ID 填写 `remote_id`,本地 ID 留空。 + +普通 VNT 客户端只有设置 `allow_ikev2 = true` 才接受该流量;该路径不受 VNT 节点间密码的端到端加密保护。 + +## 验证 + +1. `systemctl is-active vnts` 必须为 active。 +2. 日志应显示版本、证书指纹、启用的监听器和 `HTTP Server running`(若启用管理端)。 +3. 用 `ss -lntup` 对照配置检查端口,不要仅凭进程状态判断。 +4. 从管理入口登录并读取网络列表。 +5. 客户端使用 `quic://公网名或IP:29872` 连接,网络编号必须满足白名单。 +6. 读取客户端实例状态、分配 IP、节点列表和路由,再进行虚拟 IP/业务连通性测试。 + +## 备份、升级与回滚 + +备份至少包含 `/var/lib/vnts/config.toml`、`network_control.db`、TLS 证书/私钥和所有 `ikev2-*.pem`。私钥备份必须加密并限制访问。日志通常不作为恢复必需项。 + +升级流程:记录当前版本和二进制校验值;备份数据;安装为临时新文件;停止服务;原子替换 `/opt/vnts/vnts2`;启动并执行上述验证。保留上一版二进制,失败时停止服务、恢复旧二进制;仅当新版本修改了数据格式且验证失败时,按发布说明决定是否恢复数据库备份,不要在运行中覆盖 SQLite 文件。 diff --git a/.codex/skills/vnt-operations/references/server-web-api.md b/.codex/skills/vnt-operations/references/server-web-api.md new file mode 100644 index 00000000..69d3bf91 --- /dev/null +++ b/.codex/skills/vnt-operations/references/server-web-api.md @@ -0,0 +1,240 @@ +# VNTS 管理 Web API + +## 登录与会话 + +VNTS 管理 API 与客户端 `vnt2_web` API 完全独立。若用户未提供,询问: + +- 管理端根地址,例如 `https://admin.example.com` 或 `http://127.0.0.1:29871`; +- `config.toml` 中的管理用户名和密码。 + +登录: + +```http +POST /api/login +Content-Type: application/json + +{"username":"...","password":"..."} +``` + +成功响应的 `data.token` 是 JWT,有效期 24 小时;服务器重启会生成新的签名密钥,因此旧 JWT 也会失效。后续请求发送: + +```text +Authorization: Bearer +``` + +成功响应约定: + +```json +{"code": 200, "msg": "success", "data": {}} +``` + +只有 `code == 200` 才是业务成功。HTTP `401` 后允许重新登录一次;再次失败则停止。不要把 VNTS 用户密码、JWT、IKEv2 设备密码或 CA 私钥写入仓库、命令输出总结或日志。 + +辅助脚本从 stdin 读取密码并自动登录: + +```text +python scripts/vnt_api.py server --base-url http://127.0.0.1:29871 --username admin --password-stdin GET /api/networks +``` + +也可使用 `--token-stdin` 传已有 JWT。脚本路径相对于本 skill 目录。 + +## 枚举与公共字段 + +- `network_type`:`Public` 或 `Private`。 +- `ip_type`:`Dynamic`、`Static` 或 `Fixed`。 +- `client_type`:`VNT` 或 `IKEV2`。 +- 设备状态主要为 `Online`、`Remote` 或当前版本增加的其他字符串。 + +路径段和查询参数必须 URL 编码。任何修改后都重新 GET 对应资源确认结果。 + +## 网络 + +### 列表 + +```text +GET /api/network_codes +GET /api/networks +``` + +网络对象包括 `network_code`、`gateway`、`netmask`、`net`、`lease_duration`、`source`、`network_type`、`all_count` 和 `online_count`。 + +### 创建 + +```http +POST /api/networks + +{ + "network_code": "team-a", + "gateway": "10.26.10.1", + "netmask": 24, + "lease_duration": 86400, + "network_type": "Private" +} +``` + +`netmask` 最大为 30。`lease_duration` 和 `network_type` 可省略,后者默认 `Public`。 + +### 更新与删除 + +```text +PUT /api/networks/{network_code} +DELETE /api/networks/{network_code} +``` + +更新请求: + +```json +{ + "gateway": "10.26.10.1", + "netmask": 24, + "lease_duration": 86400, + "network_type": "Private" +} +``` + +删除前读取网络及其设备,向用户明确将删除的网络编号。不要根据展示顺序选择目标。 + +## 设备 + +### 列表 + +```text +GET /api/devices?code={network_code} +``` + +设备对象包括设备 ID/名称/版本、客户端类型、配置和当前 IP、IP 类型、状态、时间、延迟、所在服务器、上报子网及收发字节。 + +### 创建 + +```http +POST /api/devices + +{ + "network_code": "team-a", + "device_id": "node-01", + "ip": "10.26.10.2", + "ip_type": "Fixed", + "client_type": "VNT" +} +``` + +`ip_type` 省略时默认 `Dynamic`。创建 IKEv2 设备时使用 `"client_type":"IKEV2"` 并提供强随机 `ikev2_password`;该密码属于敏感信息。 + +### 更新与删除 + +```text +PUT /api/devices/{device_id} +DELETE /api/devices?code={network_code}&device_id={device_id} +``` + +更新请求: + +```json +{ + "network_code": "team-a", + "ip": "10.26.10.2", + "ip_type": "Fixed", + "ikev2_password": null +} +``` + +设备 ID 只在路径中,网络编号仍在请求体。删除前同时确认网络编号和设备 ID。 + +### IKEv2 接入信息 + +```text +GET /api/networks/{network_code}/devices/{device_id}/ikev2-access +``` + +响应包括服务配置、网络网段、用户名和密码。密码只用于当前任务,不要在最终回复原样重复;若必须交付给用户,使用用户指定的安全通道。 + +## 网络编号白名单 + +```text +GET /api/settings/network-whitelist +PUT /api/settings/network-whitelist +``` + +更新体: + +```json +{"network_codes":["team-a","team-b"]} +``` + +服务端会校验、去重、排序并持久化到当前 `config.toml`,随后立即替换运行时白名单。空数组表示不限制网络编号。更新前保留当前列表,避免因遗漏意外拒绝现有网络。 + +## 服务端互联 + +```text +GET /api/peer_servers +POST /api/peer_servers +DELETE /api/peer_servers/{server_addr} +``` + +添加体: + +```json +{"server_addr":"server-b.example.com:29873"} +``` + +列表将 outbound/inbound 分开,条目包含地址、延迟、连接状态和方向。互联需要在配置中启用 peer manager,并在各服务器使用相同 `server_token`。删除地址路径必须 URL 编码。 + +## IKEv2 服务设置 + +```text +GET /api/settings/ikev2 +PUT /api/settings/ikev2 +``` + +更新体必须给出完整设置而非局部 patch: + +```json +{ + "enabled": true, + "ike_bind": "0.0.0.0:500", + "natt_bind": "0.0.0.0:4500", + "server_address": "vpn.example.com", + "remote_id": "vpn.example.com", + "dns": ["1.1.1.1"], + "cert": null, + "key": null +} +``` + +先 GET、仅修改用户要求的字段、再 PUT 完整对象。`server_address` 是客户端实际连接地址,启用时必填,接受域名、IPv4 或 IPv6;`remote_id` 是服务器身份并必须匹配证书 SAN,只接受域名或 IPv4。`cert`/`key` 必须同时设置或同时为空;为空时可生成受管 CA 和服务证书。 + +响应包含 `configured`、`enabled`、`runtime_active`、`ike_bind`、`natt_bind`、`server_address`、`remote_id`、`dns`、证书状态、`ca_download_available`、`server_certificate_download_available` 及 `runtime_error`。保存会启动、停止或热加载服务,失败时服务端尝试回滚配置和受管证书;仍需读回并检查 `runtime_active/runtime_error`。 + +下载 CA: + +```text +GET /api/ikev2/ca-certificate?format=der +GET /api/ikev2/ca-certificate?format=pem +``` + +使用辅助脚本的 `--output` 保存二进制/文本证书,不要尝试按 JSON 解析: + +```text +python scripts/vnt_api.py server --base-url http://127.0.0.1:29871 --token-stdin GET "/api/ikev2/ca-certificate?format=der" --output vnt-ikev2-ca.cer +``` + +下载服务器叶证书: + +```text +GET /api/ikev2/server-certificate?format=der +GET /api/ikev2/server-certificate?format=pem +``` + +`der` 和 `pem` 都只返回证书链中的第一张叶证书。该下载主要用于诊断或必须直接信任叶证书的客户端;正常接入应安装并信任签发服务器证书的 CA。使用辅助脚本时同样通过 `--output` 保存响应,例如: + +```text +python scripts/vnt_api.py server --base-url http://127.0.0.1:29871 --token-stdin GET "/api/ikev2/server-certificate?format=der" --output vnt-ikev2-server.cer +``` + +## 失败处理 + +- `401`:重新登录一次;服务端可能刚重启或 JWT 已过期。 +- `code != 200`:报告脱敏的 `msg`,停止依赖该步骤的后续写操作。 +- 网络/设备不存在:重新读取列表并核对 URL 编码,不自动创建替代对象。 +- 写配置失败或 IKEv2 回滚失败:立即读取设置、检查 journal 和 `runtime_error`;不要连续重放 PUT。 +- 超时:用一个 GET 健康检查区分管理端不可达与写操作响应丢失,然后读回资源判定是否已生效,避免盲目重试非幂等 POST。 diff --git a/.codex/skills/vnt-operations/scripts/vnt_api.py b/.codex/skills/vnt-operations/scripts/vnt_api.py new file mode 100644 index 00000000..ee59cc76 --- /dev/null +++ b/.codex/skills/vnt-operations/scripts/vnt_api.py @@ -0,0 +1,409 @@ +#!/usr/bin/env python3 +"""Authenticated HTTP client for VNT client and VNTS administration APIs.""" + +import argparse +import getpass +import json +import os +import re +import ssl +import sys +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Tuple +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qs, urlsplit, urlunsplit +from urllib.request import ( + HTTPRedirectHandler, + HTTPSHandler, + Request, + build_opener, +) + + +class ApiError(Exception): + """An expected input, transport, or API error safe to show after redaction.""" + + +class NoRedirectHandler(HTTPRedirectHandler): + """Do not forward Authorization headers across redirects.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401 + return None + + +def fail(message: str) -> None: + print("error: {}".format(message), file=sys.stderr) + raise SystemExit(1) + + +def redact(text: str, secrets: Sequence[Optional[str]]) -> str: + result = text + for secret in secrets: + if secret: + result = result.replace(secret, "") + return result + + +def read_stdin_secret(label: str) -> str: + if sys.stdin.isatty(): + value = getpass.getpass("{}: ".format(label)) + else: + value = sys.stdin.readline().rstrip("\r\n") + if not value: + raise ApiError("{} is empty".format(label)) + return value + + +def read_env(name: str, label: str) -> str: + value = os.environ.get(name, "") + if not value: + raise ApiError("environment variable {} does not contain {}".format(name, label)) + return value + + +def validate_base_url(raw: str) -> str: + parsed = urlsplit(raw.strip()) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise ApiError("base URL must be an absolute http(s) URL") + if parsed.username is not None or parsed.password is not None: + raise ApiError("credentials in URL userinfo are not supported") + path = parsed.path.rstrip("/") + return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")) + + +def parse_access_url(raw: str) -> Tuple[str, Optional[str]]: + parsed = urlsplit(raw.strip()) + base_url = validate_base_url(raw) + values = parse_qs(parsed.query, keep_blank_values=True).get("token", []) + token = values[-1] if values else None + if token == "": + token = None + return base_url, token + + +def build_endpoint(base_url: str, path: str) -> str: + request_path = path.strip() + if not request_path.startswith("/"): + raise ApiError("API path must start with '/'") + parsed_path = urlsplit(request_path) + if parsed_path.scheme or parsed_path.netloc or parsed_path.fragment: + raise ApiError("API path must be relative to the supplied API host") + return "{}{}".format(base_url.rstrip("/"), request_path) + + +def ssl_context(ca_file: Optional[str]) -> ssl.SSLContext: + try: + return ssl.create_default_context(cafile=ca_file) + except (OSError, ssl.SSLError) as exc: + raise ApiError("cannot load CA file: {}".format(exc)) + + +def decode_error_body(raw: bytes) -> str: + if not raw: + return "" + text = raw.decode("utf-8", errors="replace") + try: + payload = json.loads(text) + if isinstance(payload, dict) and payload.get("msg"): + return str(payload["msg"]) + except json.JSONDecodeError: + pass + compact = " ".join(text.split()) + return compact[:500] + + +def exchange( + url: str, + method: str, + token: Optional[str], + body: Optional[bytes], + timeout: float, + ca_file: Optional[str], + secrets: Sequence[Optional[str]], +) -> Tuple[bytes, Dict[str, str], int]: + headers = { + "Accept": "application/json, application/octet-stream;q=0.9, */*;q=0.8", + "User-Agent": "vnt-operations/1", + } + if token: + headers["Authorization"] = "Bearer {}".format(token) + if body is not None: + headers["Content-Type"] = "application/json" + + request = Request(url=url, data=body, headers=headers, method=method) + opener = build_opener(NoRedirectHandler(), HTTPSHandler(context=ssl_context(ca_file))) + try: + with opener.open(request, timeout=timeout) as response: + return response.read(), dict(response.headers.items()), response.status + except HTTPError as exc: + detail = decode_error_body(exc.read()) + message = "HTTP {}".format(exc.code) + if 300 <= exc.code < 400: + message += " redirect refused to protect credentials" + elif detail: + message += ": {}".format(detail) + raise ApiError(redact(message, secrets)) + except URLError as exc: + raise ApiError(redact("request failed: {}".format(exc.reason), secrets)) + except TimeoutError: + raise ApiError("request timed out") + except OSError as exc: + raise ApiError(redact("request failed: {}".format(exc), secrets)) + + +def parse_envelope( + raw: bytes, + expected_code: int, + label: str, + secrets: Sequence[Optional[str]], +) -> Dict[str, object]: + try: + text = raw.decode("utf-8") + payload = json.loads(text) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ApiError("{} returned invalid JSON: {}".format(label, exc)) + if not isinstance(payload, dict): + raise ApiError("{} returned a non-object JSON response".format(label)) + if payload.get("code") != expected_code: + message = str(payload.get("msg") or "unknown API error") + raise ApiError(redact("{} business error: {}".format(label, message), secrets)) + return payload + + +def load_json_body(args: argparse.Namespace) -> Optional[bytes]: + raw = None + if args.json_body is not None: + raw = args.json_body + elif args.json_file is not None: + path = Path(args.json_file) + if str(path) == "-": + raise ApiError("--json-file '-' is not supported because stdin may carry credentials") + try: + raw = path.read_text(encoding="utf-8") + except OSError as exc: + raise ApiError("cannot read JSON file: {}".format(exc)) + if raw is None: + return None + try: + value = json.loads(raw) + except json.JSONDecodeError as exc: + raise ApiError("invalid JSON request body: {}".format(exc)) + return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + + +def write_output(path_value: str, raw: bytes, force: bool) -> None: + path = Path(path_value) + if path.exists() and not force: + raise ApiError("output file already exists; pass --force to replace it") + if not path.parent.exists(): + raise ApiError("output directory does not exist: {}".format(path.parent)) + try: + path.write_bytes(raw) + except OSError as exc: + raise ApiError("cannot write output file: {}".format(exc)) + print("wrote {} bytes to {}".format(len(raw), path)) + + +def show_json(payload: Dict[str, object]) -> None: + print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)) + + +def request_api( + args: argparse.Namespace, + base_url: str, + token: str, + expected_code: int, + label: str, + secrets: Sequence[Optional[str]], +) -> None: + method = args.method.upper() + if not re.fullmatch(r"[A-Z]+", method): + raise ApiError("HTTP method must contain letters only") + if args.timeout <= 0: + raise ApiError("--timeout must be greater than zero") + + body = load_json_body(args) + url = build_endpoint(base_url, args.path) + raw, headers, _status = exchange( + url, method, token, body, args.timeout, args.ca_file, secrets + ) + if args.output: + content_type = next( + (value for key, value in headers.items() if key.lower() == "content-type"), "" + ) + if "json" in content_type.lower(): + parse_envelope(raw, expected_code, label, secrets) + write_output(args.output, raw, args.force) + return + payload = parse_envelope(raw, expected_code, label, secrets) + show_json(payload) + + +def optional_token(args: argparse.Namespace) -> Optional[str]: + if args.token is not None: + return args.token + if args.token_stdin: + return read_stdin_secret("Bearer token") + if args.token_env: + return read_env(args.token_env, "Bearer token") + return None + + +def resolve_client(args: argparse.Namespace) -> Tuple[str, str, List[Optional[str]]]: + access_url = None + if args.access_url is not None: + access_url = args.access_url + elif args.access_url_stdin: + access_url = read_stdin_secret("VNT Web access URL") + elif args.access_url_env: + access_url = read_env(args.access_url_env, "VNT Web access URL") + + explicit_token = optional_token(args) + if access_url is not None: + base_url, url_token = parse_access_url(access_url) + if explicit_token and url_token and explicit_token != url_token: + raise ApiError("access URL token and explicit token do not match") + token = explicit_token or url_token + if not token: + raise ApiError("access URL has no token; provide one with a token option") + return base_url, token, [token, access_url] + + if args.base_url is None: + raise ApiError("provide an access URL or --base-url") + if not explicit_token: + raise ApiError("--base-url requires a token option") + return validate_base_url(args.base_url), explicit_token, [explicit_token] + + +def server_login( + args: argparse.Namespace, base_url: str +) -> Tuple[str, List[Optional[str]]]: + token = optional_token(args) + if token: + return token, [token] + if not args.username: + raise ApiError("provide --username when no existing JWT is supplied") + + if args.password_stdin: + password = read_stdin_secret("VNTS password") + elif args.password_env: + password = read_env(args.password_env, "VNTS password") + elif sys.stdin.isatty(): + password = getpass.getpass("VNTS password: ") + if not password: + raise ApiError("VNTS password is empty") + else: + raise ApiError("provide --password-stdin or --password-env for login") + + secrets: List[Optional[str]] = [password] + login_body = json.dumps( + {"username": args.username, "password": password}, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + login_url = build_endpoint(base_url, "/api/login") + raw, _headers, _status = exchange( + login_url, + "POST", + None, + login_body, + args.timeout, + args.ca_file, + secrets, + ) + payload = parse_envelope(raw, 200, "VNTS login", secrets) + data = payload.get("data") + if not isinstance(data, dict) or not isinstance(data.get("token"), str): + raise ApiError("VNTS login response has no token") + token = data["token"] + secrets.append(token) + return token, secrets + + +def add_request_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument("method", help="HTTP method, for example GET, POST, PUT, or DELETE") + parser.add_argument("path", help="API path beginning with /, including an optional query") + body = parser.add_mutually_exclusive_group() + body.add_argument("--json", dest="json_body", help="inline JSON request body") + body.add_argument("--json-file", help="UTF-8 file containing the JSON request body") + parser.add_argument("--output", help="write raw response bytes to this file") + parser.add_argument( + "--force", action="store_true", help="allow --output to replace an existing file" + ) + parser.add_argument("--timeout", type=float, default=15.0, help="request timeout in seconds") + parser.add_argument("--ca-file", help="custom PEM CA bundle for HTTPS verification") + + +def add_token_arguments(parser: argparse.ArgumentParser) -> None: + token = parser.add_mutually_exclusive_group() + token.add_argument("--token", help="Bearer token (stdin/environment is safer)") + token.add_argument("--token-stdin", action="store_true", help="read Bearer token from stdin") + token.add_argument( + "--token-env", + action="store_const", + const="VNT_API_TOKEN", + help="read Bearer token from VNT_API_TOKEN", + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Call VNT client and VNTS server Web APIs without third-party packages." + ) + subparsers = parser.add_subparsers(dest="mode", required=True) + + client = subparsers.add_parser("client", help="call a vnt2_web/desktop Web-access API") + source = client.add_mutually_exclusive_group(required=True) + source.add_argument("--access-url", help="full access URL, optionally containing ?token=") + source.add_argument( + "--access-url-stdin", action="store_true", help="read the full access URL from stdin" + ) + source.add_argument( + "--access-url-env", + action="store_const", + const="VNT_WEB_ACCESS_URL", + help="read access URL from VNT_WEB_ACCESS_URL", + ) + source.add_argument("--base-url", help="API base URL without a token query") + add_token_arguments(client) + add_request_arguments(client) + + server = subparsers.add_parser("server", help="call the VNTS administrative API") + server.add_argument("--base-url", required=True, help="VNTS management base URL") + add_token_arguments(server) + server.add_argument("--username", help="management username used when no JWT is supplied") + password = server.add_mutually_exclusive_group() + password.add_argument( + "--password-stdin", action="store_true", help="read management password from stdin" + ) + password.add_argument( + "--password-env", + action="store_const", + const="VNTS_PASSWORD", + help="read password from VNTS_PASSWORD", + ) + add_request_arguments(server) + return parser + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + if args.force and not args.output: + raise ApiError("--force requires --output") + if args.mode == "client": + base_url, token, secrets = resolve_client(args) + request_api(args, base_url, token, 0, "VNT client API", secrets) + else: + base_url = validate_base_url(args.base_url) + token, secrets = server_login(args, base_url) + request_api(args, base_url, token, 200, "VNTS API", secrets) + return 0 + except ApiError as exc: + fail(str(exc)) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 46fa3a9f..759dfdf2 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -45,6 +45,10 @@ jobs: rust: name: ${{ matrix.name }} Rust checks + env: + # The frontend job builds and validates the UI separately. Rust jobs run in + # clean checkouts without pnpm or the ignored vnt-web/static artifacts. + VNT_WEB_SKIP_UI_BUILD: "1" strategy: fail-fast: false matrix: diff --git a/Cargo.lock b/Cargo.lock index f30cfbf8..5cf56d8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6145,7 +6145,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vnt-core" -version = "2.0.6" +version = "2.0.7" dependencies = [ "anyhow", "bytes", @@ -6195,7 +6195,7 @@ dependencies = [ [[package]] name = "vnt-desktop" -version = "2.0.6" +version = "2.0.7" dependencies = [ "anyhow", "log", @@ -6218,7 +6218,7 @@ dependencies = [ [[package]] name = "vnt-ipc" -version = "2.0.6" +version = "2.0.7" dependencies = [ "anyhow", "cli-table", @@ -6236,7 +6236,7 @@ dependencies = [ [[package]] name = "vnt-jni" -version = "2.0.6" +version = "2.0.7" dependencies = [ "anyhow", "hostname", @@ -6253,7 +6253,7 @@ dependencies = [ [[package]] name = "vnt-web" -version = "2.0.6" +version = "2.0.7" dependencies = [ "anyhow", "axum", @@ -6277,7 +6277,7 @@ dependencies = [ [[package]] name = "vnt2" -version = "2.0.6" +version = "2.0.7" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 5625f527..f480ac11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vnt2" -version = "2.0.6" +version = "2.0.7" edition = "2024" license = "Apache-2.0" diff --git a/README.md b/README.md index a24c2c37..37237120 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,12 @@ P2P 打洞;中转节点已有直连路由时,数据包会优先发给中转节点继续转发。中转 IP 也可以填写当前虚拟网络的网关 IP,此时强制通过服务器中继。 +可重复使用 `--punch-model <目标IP或CIDR,打洞方式...>`(配置文件中为 +`punch_model = ["10.26.0.2,IPv4Udp", "10.26.1.0/24,IPv4Tcp,IPv4Udp"]`) +限制指定目标可使用的打洞方式。可选方式为 `IPv4Tcp`、`IPv4Udp`、`IPv6Tcp`、 +`IPv6Udp`;双方实际采用各自允许集合的交集。重叠规则按最长前缀匹配,同一目标的 +多条规则会合并;未命中规则时默认允许全部方式。 + IPv4 广播和组播默认开启。可使用 `--no-broadcast`(配置文件中为 `no_broadcast = true`)停止转发本机发出的 IPv4 广播和组播;ARP、非 IPv4 二层广播 以及单播流量不受影响。 @@ -72,6 +78,7 @@ IPv4 广播和组播默认开启。可使用 `--no-broadcast`(配置文件中 ## 安全说明 - **建议设置组网密码**。设置密码后(命令行 `-p` / `--password`,或配置文件中的 `password`),节点之间的数据采用端到端加密(ChaCha20-Poly1305),**服务端仅负责转发密文,无法解密通信内容**。即使使用公共服务端,通信内容也不会泄露给服务端。 +- 如需与服务端接入的 IKEv2/IPsec 客户端通信,使用 `--allow-ikev2`(配置文件中为 `allow_ikev2 = true`)。该功能会信任已认证服务端注入的 IKEv2 明文 IPv4 数据,并让发往 IKEv2 类型设备的流量固定走服务端;默认关闭,且该路径不受 VNT 节点间密码的端到端加密保护。 - 同一虚拟网络内的所有设备必须使用**相同的密码**,否则无法互相通信。 - 未设置密码时,节点间数据不加密,经过服务端中继的流量理论上可被服务端查看,请仅在可信网络环境下省略密码。 - 此外,客户端与服务端之间的连接本身支持 tcp-tls、quic、wss 等加密传输协议,并可绑定服务端证书,防止伪造服务端攻击。 diff --git a/src/args_config.rs b/src/args_config.rs index b3f6d867..14e8ce47 100644 --- a/src/args_config.rs +++ b/src/args_config.rs @@ -4,7 +4,7 @@ use ipnet::Ipv4Net; use serde::{Deserialize, Serialize}; use std::net::Ipv4Addr; use std::path::{Path, PathBuf}; -use vnt_core::context::config::{Config, DeviceMode, PeerAddress, TurnRule}; +use vnt_core::context::config::{Config, DeviceMode, PeerAddress, PunchRule, TurnRule}; use vnt_core::nat::{NetInput, SubnetMapping}; use vnt_core::tls::verifier::CertValidationMode; use vnt_core::tunnel_core::server::transport::config::ProtocolAddress; @@ -16,10 +16,12 @@ pub struct FileConfig { pub server: Option>, pub peer_address: Option>, pub turn: Option>, + pub punch_model: Option>, pub network_code: Option, pub ip: Option, pub no_punch: Option, pub no_broadcast: Option, + pub allow_ikev2: Option, pub rtx: Option, pub compress: Option, pub fec: Option, @@ -97,6 +99,18 @@ impl FileConfig { }) .collect() } + pub fn to_punch_model(&self) -> anyhow::Result> { + self.punch_model + .as_deref() + .unwrap_or_default() + .iter() + .map(|value| { + value + .parse::() + .map_err(|error| anyhow!("invalid punch model rule '{}': {}", value, error)) + }) + .collect() + } pub fn to_port_mapping(&self) -> anyhow::Result> { if let Some(port_mapping_raw) = &self.port_mapping { let mut port_mapping = Vec::with_capacity(port_mapping_raw.len()); @@ -125,6 +139,9 @@ pub struct Args { /// 指定目标 IP/网段的优先中转节点,可重复指定,格式为 target,turn_ip #[clap(long)] pub turn: Vec, + /// 指定目标 IP/网段允许的打洞方式,可重复指定,格式为 target,mode[,mode...] + #[clap(long)] + pub punch_model: Vec, /// 网络编号,相同编号的会组同一个局域网 #[clap(short, long)] pub network_code: Option, @@ -169,6 +186,9 @@ pub struct Args { /// 关闭虚拟网络内的 IPv4 广播和组播转发 #[clap(long)] pub no_broadcast: bool, + /// 允许与 IKEv2/IPsec 客户端通信,并信任服务端注入的 IKEv2 明文 IPv4 包 + #[clap(long)] + pub allow_ikev2: bool, /// 服务端证书验证 #[clap(long)] pub cert_mode: Option, @@ -259,6 +279,11 @@ fn build_from_args_and_file(args: Args, file: FileConfig) -> anyhow::Result<(Con } else { args.turn }; + let punch_model = if args.punch_model.is_empty() { + file.to_punch_model()? + } else { + args.punch_model + }; let port_mapping = if args.port_mapping.is_empty() { file.to_port_mapping()? } else { @@ -313,10 +338,12 @@ fn build_from_args_and_file(args: Args, file: FileConfig) -> anyhow::Result<(Con server_addr, peer_address, turn, + punch_model, network_code, ip: args.ip.or(file.ip), no_punch: args.no_punch || file.no_punch.unwrap_or(false), no_broadcast: args.no_broadcast || file.no_broadcast.unwrap_or(false), + allow_ikev2: args.allow_ikev2 || file.allow_ikev2.unwrap_or(false), rtx: args.rtx || file.rtx.unwrap_or(false), compress: args.compress || file.compress.unwrap_or(false), fec: args.fec || file.fec.unwrap_or(false), @@ -361,12 +388,14 @@ fn build_from_args_only(args: Args) -> anyhow::Result<(Config, CtrlConfig)> { server_addr: args.server, peer_address: args.peer_address, turn: args.turn, + punch_model: args.punch_model, network_code: args .network_code .ok_or_else(|| anyhow!("network_code is required"))?, ip: args.ip, no_punch: args.no_punch, no_broadcast: args.no_broadcast, + allow_ikev2: args.allow_ikev2, rtx: args.rtx, input: args.input, subnet_mapping: args.subnet_mapping, @@ -406,6 +435,7 @@ fn build_from_file_only(file: FileConfig) -> anyhow::Result<(Config, CtrlConfig) let server_addr = file.to_server_addr()?; let peer_address = file.to_peer_address()?; let turn = file.to_turn()?; + let punch_model = file.to_punch_model()?; let port_mapping = file.to_port_mapping()?; let cert_mode = file @@ -435,12 +465,14 @@ fn build_from_file_only(file: FileConfig) -> anyhow::Result<(Config, CtrlConfig) server_addr, peer_address, turn, + punch_model, network_code: file .network_code .ok_or_else(|| anyhow!("network_code is required"))?, ip: file.ip, no_punch: file.no_punch.unwrap_or(false), no_broadcast: file.no_broadcast.unwrap_or(false), + allow_ikev2: file.allow_ikev2.unwrap_or(false), rtx: file.rtx.unwrap_or(false), input: file.input.unwrap_or_default(), subnet_mapping: file.subnet_mapping.unwrap_or_default(), @@ -503,6 +535,10 @@ server = ["quic://1.2.3.4:29872"] # 命中目标不参与 P2P 打洞 # turn = ["10.26.0.0/24,10.26.0.2", "10.26.1.9,10.26.0.3"] +# 指定目标虚拟 IP 或网段允许的 P2P 打洞方式,同一目标的多条规则会合并 +# 可选模式:IPv4Tcp、IPv4Udp、IPv6Tcp、IPv6Udp +# punch_model = ["10.26.0.2,IPv4Udp", "10.26.1.0/24,IPv4Tcp,IPv4Udp"] + # ===简单使用以下参数可以不动=== # 自定义虚拟 IP (可选) @@ -520,6 +556,9 @@ server = ["quic://1.2.3.4:29872"] # 是否关闭 IPv4 广播和组播转发 (默认 false,即开启) # no_broadcast = false +# 是否允许与 IKEv2 客户端通信,并信任服务端注入的 IKEv2 明文 IPv4 包 +# allow_ikev2 = false + # 是否启用 LZ4 压缩 (默认 false,设置为true时开启) # compress = false @@ -748,6 +787,37 @@ mod tests { assert_eq!(config.turn[0].to_string(), "10.26.0.0/16,10.26.0.2"); } + #[test] + fn test_punch_model_cli_and_file_precedence() { + let file: FileConfig = toml::from_str( + "punch_model = [\"10.26.0.0/16,IPv4Udp\"]\nnetwork_code = \"test-net\"\nserver = [\"quic://127.0.0.1:29872\"]", + ) + .unwrap(); + let args = Args::try_parse_from([ + "vnt", + "-s", + "quic://127.0.0.1:29872", + "-n", + "test-net", + "--punch-model", + "10.26.1.9,IPv4Tcp,IPv6Udp", + ]) + .unwrap(); + let (config, _) = build_config_from_args_and_file(Some(args), Some(file)).unwrap(); + assert_eq!(config.punch_model.len(), 1); + assert_eq!( + config.punch_model[0].to_string(), + "10.26.1.9,IPv4Tcp,IPv6Udp" + ); + + let file: FileConfig = toml::from_str( + "punch_model = [\"10.26.0.0/16,IPv4Udp\"]\nnetwork_code = \"test-net\"\nserver = [\"quic://127.0.0.1:29872\"]", + ) + .unwrap(); + let (config, _) = build_config_from_args_and_file(None, Some(file)).unwrap(); + assert_eq!(config.punch_model[0].to_string(), "10.26.0.0/16,IPv4Udp"); + } + #[test] fn test_subnet_mapping_cli_and_file_precedence() { let file: FileConfig = toml::from_str( @@ -836,4 +906,26 @@ mod tests { let (config, _) = build_config_from_args_and_file(Some(args), Some(file)).unwrap(); assert_eq!(config.device_mode, DeviceMode::Tap); } + + #[test] + fn allow_ikev2_is_opt_in_for_cli_and_toml() { + let args = Args::try_parse_from([ + "vnt", + "-s", + "quic://127.0.0.1:29872", + "-n", + "test-net", + "--allow-ikev2", + ]) + .unwrap(); + let (config, _) = build_from_args_only(args).unwrap(); + assert!(config.allow_ikev2); + + let file: FileConfig = toml::from_str( + "server = [\"quic://127.0.0.1:29872\"]\nnetwork_code = \"test\"\nallow_ikev2 = true", + ) + .unwrap(); + let (config, _) = build_config_from_args_and_file(None, Some(file)).unwrap(); + assert!(config.allow_ikev2); + } } diff --git a/vnt-core/Cargo.toml b/vnt-core/Cargo.toml index eaf33612..44a0ae89 100644 --- a/vnt-core/Cargo.toml +++ b/vnt-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vnt-core" -version = "2.0.6" +version = "2.0.7" edition = "2024" license = "Apache-2.0" diff --git a/vnt-core/proto/client.proto b/vnt-core/proto/client.proto index f5d95158..be20eac0 100644 --- a/vnt-core/proto/client.proto +++ b/vnt-core/proto/client.proto @@ -59,4 +59,6 @@ message NatInfo { message PunchInfo{ NatInfo nat_info = 1; -} \ No newline at end of file + // Bit 0..3: IPv4Tcp, IPv4Udp, IPv6Tcp, IPv6Udp. Zero means legacy/all. + uint32 punch_model = 2; +} diff --git a/vnt-core/proto/control_message.proto b/vnt-core/proto/control_message.proto index 90be3f47..b0f75610 100644 --- a/vnt-core/proto/control_message.proto +++ b/vnt-core/proto/control_message.proto @@ -7,6 +7,11 @@ enum RegistrationMode { PRE_REGISTER = 1; } +enum ClientType { + VNT = 0; + IKEV2 = 1; +} + message RegRequestMsg { string network_code = 1; string device_id = 2; @@ -18,6 +23,7 @@ message RegRequestMsg { fixed32 server_id = 8; RegistrationMode registration_mode = 9; repeated Ipv4Subnet advertised_subnets = 10; + bool allow_ikev2 = 11; } @@ -92,6 +98,7 @@ message SelectiveBroadcast { message ClientSimpleInfo{ fixed32 ip = 1; bool online = 2; + ClientType client_type = 3; } message ClientSimpleInfoList{ diff --git a/vnt-core/proto/rpc.proto b/vnt-core/proto/rpc.proto index e6077e70..ebd30256 100644 --- a/vnt-core/proto/rpc.proto +++ b/vnt-core/proto/rpc.proto @@ -2,6 +2,11 @@ syntax = "proto3"; package protocol.rpc; +enum ClientType { + VNT = 0; + IKEV2 = 1; +} + message RpcMessageRequest{ uint64 id = 1; oneof rpc_req_payload{ @@ -30,8 +35,9 @@ message ClientInfo{ bool online = 5; int64 last_connected_time = 6; string id = 7; + ClientType client_type = 8; } message ClientListResponse{ repeated ClientInfo list = 1; -} \ No newline at end of file +} diff --git a/vnt-core/src/context/config.rs b/vnt-core/src/context/config.rs index dc3c173e..4170e7f3 100644 --- a/vnt-core/src/context/config.rs +++ b/vnt-core/src/context/config.rs @@ -5,6 +5,7 @@ use crate::tls::verifier::CertValidationMode; use crate::tunnel_core::server::transport::config::{ConnectRegConfig, ProtocolAddress}; use anyhow::bail; use ipnet::Ipv4Net; +use rustp2p_core::punch::{PunchPolicy, PunchPolicySet}; use rustp2p_core::route_table::Protocol; use rustp2p_core::socket::LocalInterface; use std::collections::HashSet; @@ -18,6 +19,114 @@ pub const MAX_NAME_LEN: usize = 128; pub const MAX_VERSION_LEN: usize = 32; pub const MAX_MTU: u16 = 1500; +const PUNCH_POLICIES: [PunchPolicy; 4] = [ + PunchPolicy::IPv4Tcp, + PunchPolicy::IPv4Udp, + PunchPolicy::IPv6Tcp, + PunchPolicy::IPv6Udp, +]; + +#[derive(Debug, Clone)] +pub struct PunchRule { + target: Ipv4Net, + policies: PunchPolicySet, +} + +impl PunchRule { + pub fn target(&self) -> Ipv4Net { + self.target + } + + pub fn policies(&self) -> PunchPolicySet { + self.policies.clone() + } + + pub fn matches(&self, ip: &Ipv4Addr) -> bool { + self.target.contains(ip) + } + + fn merge(&mut self, other: &Self) { + for policy in PUNCH_POLICIES { + if other.policies.is_match(policy) { + self.policies.or(policy); + } + } + } +} + +impl FromStr for PunchRule { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + let parts = value.split(',').map(str::trim).collect::>(); + if parts.len() < 2 || parts.iter().any(|part| part.is_empty()) { + bail!("invalid punch model rule '{value}', expected target_ip_or_cidr,mode[,mode...]") + } + let target = if parts[0].contains('/') { + parts[0] + .parse::() + .map_err(|error| anyhow::anyhow!("invalid punch target '{}': {error}", parts[0]))? + } else { + let ip = parts[0] + .parse::() + .map_err(|error| anyhow::anyhow!("invalid punch target '{}': {error}", parts[0]))?; + Ipv4Net::new(ip, 32)? + }; + let mut policies = PunchPolicySet::empty(); + for value in &parts[1..] { + policies.or(parse_punch_policy(value)?); + } + Ok(Self { target, policies }) + } +} + +impl Display for PunchRule { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if self.target.prefix_len() == 32 { + write!(f, "{}", self.target.addr())?; + } else { + write!(f, "{}", self.target)?; + } + for policy in PUNCH_POLICIES { + if self.policies.is_match(policy) { + write!(f, ",{}", punch_policy_name(policy))?; + } + } + Ok(()) + } +} + +fn parse_punch_policy(value: &str) -> anyhow::Result { + let normalized = value.trim().to_ascii_lowercase().replace(['-', '_'], ""); + match normalized.as_str() { + "ipv4tcp" => Ok(PunchPolicy::IPv4Tcp), + "ipv4udp" => Ok(PunchPolicy::IPv4Udp), + "ipv6tcp" => Ok(PunchPolicy::IPv6Tcp), + "ipv6udp" => Ok(PunchPolicy::IPv6Udp), + _ => bail!( + "invalid punch mode '{value}', expected one of: IPv4Tcp, IPv4Udp, IPv6Tcp, IPv6Udp" + ), + } +} + +fn punch_policy_name(policy: PunchPolicy) -> &'static str { + match policy { + PunchPolicy::IPv4Tcp => "IPv4Tcp", + PunchPolicy::IPv4Udp => "IPv4Udp", + PunchPolicy::IPv6Tcp => "IPv6Tcp", + PunchPolicy::IPv6Udp => "IPv6Udp", + } +} + +pub fn punch_model_for(rules: &[PunchRule], target: &Ipv4Addr) -> PunchPolicySet { + rules + .iter() + .filter(|rule| rule.matches(target)) + .max_by_key(|rule| rule.target.prefix_len()) + .map(PunchRule::policies) + .unwrap_or_else(PunchPolicySet::all) +} + #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub struct TurnRule { target: Ipv4Net, @@ -231,6 +340,7 @@ pub struct Config { pub server_addr: Vec, pub peer_address: Vec, pub turn: Vec, + pub punch_model: Vec, pub cert_mode: CertValidationMode, pub network_code: String, pub device_id: String, @@ -242,6 +352,8 @@ pub struct Config { pub password: Option, pub no_punch: bool, pub no_broadcast: bool, + /// 允许与由服务端终结的 IKEv2/IPsec 客户端互通。 + pub allow_ikev2: bool, pub compress: bool, pub rtx: bool, pub fec: bool, @@ -265,6 +377,15 @@ impl Config { self.check_turn_rules()?; let mut seen = HashSet::new(); self.turn.retain(|rule| seen.insert(rule.clone())); + let mut merged = Vec::::new(); + for rule in self.punch_model.drain(..) { + if let Some(existing) = merged.iter_mut().find(|item| item.target == rule.target) { + existing.merge(&rule); + } else { + merged.push(rule); + } + } + self.punch_model = merged; crate::nat::subnet_mapping::normalize_and_validate(&mut self.subnet_mapping, &self.output)?; Ok(()) } @@ -294,6 +415,9 @@ impl Config { if self.server_addr.is_empty() { bail!("服务器地址不能为空"); } + if self.allow_ikev2 && self.device_mode == DeviceMode::No { + bail!("allow_ikev2 requires device_mode = tun or tap"); + } if self.server_addr.len() > 1 { let mut set = HashSet::new(); @@ -357,6 +481,7 @@ impl Config { &self.output, &self.subnet_mapping, )), + allow_ikev2: self.allow_ikev2, default_interface, } } @@ -429,6 +554,53 @@ mod tests { } } + #[test] + fn punch_rule_parses_aliases_and_rejects_invalid_values() { + let host = "10.26.0.2,ipv4-udp".parse::().unwrap(); + assert_eq!(host.target().prefix_len(), 32); + assert!(host.policies().is_match(PunchPolicy::IPv4Udp)); + assert_eq!(host.to_string(), "10.26.0.2,IPv4Udp"); + + let cidr = "10.26.1.0/24,IPv4Tcp,ipv6_udp" + .parse::() + .unwrap(); + assert!(cidr.policies().is_match(PunchPolicy::IPv4Tcp)); + assert!(cidr.policies().is_match(PunchPolicy::IPv6Udp)); + assert_eq!(cidr.to_string(), "10.26.1.0/24,IPv4Tcp,IPv6Udp"); + + for value in ["10.26.0.2", "10.26.0.2,", "bad,IPv4Udp", "10.26.0.2,quic"] { + assert!(value.parse::().is_err(), "{value} must fail"); + } + } + + #[test] + fn punch_rules_merge_and_use_longest_prefix() { + let mut config = Config { + punch_model: vec![ + "10.26.0.0/16,IPv4Tcp".parse().unwrap(), + "10.26.1.0/24,IPv4Udp".parse().unwrap(), + "10.26.1.0/24,IPv6Tcp,IPv4Udp".parse().unwrap(), + ], + ..Default::default() + }; + config.normalize().unwrap(); + assert_eq!(config.punch_model.len(), 2); + + let narrow = punch_model_for(&config.punch_model, &Ipv4Addr::new(10, 26, 1, 9)); + assert!(narrow.is_match(PunchPolicy::IPv4Udp)); + assert!(narrow.is_match(PunchPolicy::IPv6Tcp)); + assert!(!narrow.is_match(PunchPolicy::IPv4Tcp)); + + let broad = punch_model_for(&config.punch_model, &Ipv4Addr::new(10, 26, 2, 9)); + assert!(broad.is_match(PunchPolicy::IPv4Tcp)); + assert!(!broad.is_match(PunchPolicy::IPv4Udp)); + + let unmatched = punch_model_for(&config.punch_model, &Ipv4Addr::new(10, 27, 0, 1)); + for policy in PUNCH_POLICIES { + assert!(unmatched.is_match(policy)); + } + } + #[test] fn turn_rule_parses_ip_and_cidr_and_uses_longest_prefix() { let host = "10.26.1.9,10.26.0.3".parse::().unwrap(); diff --git a/vnt-core/src/context/mod.rs b/vnt-core/src/context/mod.rs index 5b13a284..78fa8474 100644 --- a/vnt-core/src/context/mod.rs +++ b/vnt-core/src/context/mod.rs @@ -1,9 +1,9 @@ -use crate::context::config::Config; +use crate::context::config::{Config, punch_model_for}; use crate::context::nat::{MyNatInfo, PunchBackoff}; use crate::nat::SubnetExternalRoute; use crate::protocol::client_message::PunchInfo; use crate::protocol::control_message::{ - ClientSimpleInfo, ClientSimpleInfoList, SubnetSyncResponse, + ClientSimpleInfo, ClientSimpleInfoList, ClientType, SubnetSyncResponse, }; use crate::tunnel_core::p2p::route_table::RouteTable; use crate::tunnel_core::server::transport::config::ProtocolAddress; @@ -423,7 +423,7 @@ impl ServerInfoCollection { ( v.client_map .iter() - .filter(|(_, v)| v.online) + .filter(|(_, v)| v.online && v.client_type == ClientType::Vnt) .map(|(k, _)| *k) .collect(), v.rtt.unwrap_or(500), @@ -496,13 +496,18 @@ impl ServerInfoCollection { self.client_simple_list .read() .iter() - .filter(|v| v.online) + .filter(|v| v.online && v.client_type == ClientType::Vnt) .map(|c| c.ip) .collect() } pub fn client_ips(&self) -> Vec { self.client_simple_list.read().clone() } + pub fn is_ikev2_client(&self, ip: &Ipv4Addr) -> bool { + self.client_simple_list.read().iter().any(|client| { + client.ip == *ip && client.online && client.client_type == ClientType::Ikev2 + }) + } pub fn data_version(&self, server_id: u32) -> u64 { self.server_node_map .read() @@ -578,7 +583,7 @@ impl ServerInfoCollection { self_ip: Ipv4Addr, client_simple_list: ClientSimpleInfoList, now: i64, - ) { + ) -> Vec { let mut guard = self.server_node_map.write(); let server_node = guard.entry(server_id).or_default(); if now > client_simple_list.time { @@ -604,6 +609,9 @@ impl ServerInfoCollection { if x.online { v.online = true; } + if x.client_type == ClientType::Ikev2 { + v.client_type = ClientType::Ikev2; + } } else { client_simple_map.insert(x.ip, x.clone()); } @@ -611,7 +619,21 @@ impl ServerInfoCollection { } let mut guard = self.client_simple_list.write(); - *guard = client_simple_map.into_values().collect() + let previous_online: HashMap = + guard.iter().map(|info| (info.ip, info.online)).collect(); + let mut changed = Vec::new(); + for (ip, info) in &client_simple_map { + if previous_online.get(ip).copied().unwrap_or(false) != info.online { + changed.push(*ip); + } + } + for (ip, was_online) in previous_online { + if was_online && !client_simple_map.contains_key(&ip) { + changed.push(ip); + } + } + *guard = client_simple_map.into_values().collect(); + changed } pub fn set_server_connected(&self, server_id: u32, val: bool) -> bool { let mut mutex_guard = self.server_node_map.write(); @@ -800,6 +822,69 @@ mod subnet_sync_tests { ); } } + +#[cfg(test)] +mod client_status_tests { + use super::ServerInfoCollection; + use crate::protocol::control_message::{ClientSimpleInfo, ClientSimpleInfoList, ClientType}; + use crate::tunnel_core::server::transport::config::ProtocolAddress; + use std::net::Ipv4Addr; + + fn update(servers: &ServerInfoCollection, peer: Ipv4Addr, online: bool) -> Vec { + servers.update_client_simple_list( + 0, + Ipv4Addr::new(10, 26, 0, 1), + ClientSimpleInfoList { + data_version: 1, + list: vec![ClientSimpleInfo { + ip: peer, + online, + client_type: ClientType::Vnt, + }], + is_all: true, + time: 0, + }, + 0, + ) + } + + #[test] + fn online_transitions_are_reported_once() { + let servers = ServerInfoCollection::default(); + servers.update_server(vec![(0, ProtocolAddress::default())]); + let peer = Ipv4Addr::new(10, 26, 0, 2); + + assert_eq!(update(&servers, peer, true), vec![peer]); + assert!(update(&servers, peer, true).is_empty()); + assert_eq!(update(&servers, peer, false), vec![peer]); + assert!(update(&servers, peer, false).is_empty()); + assert_eq!(update(&servers, peer, true), vec![peer]); + } + + #[test] + fn disappearing_from_full_snapshot_is_one_offline_transition() { + let servers = ServerInfoCollection::default(); + servers.update_server(vec![(0, ProtocolAddress::default())]); + let peer = Ipv4Addr::new(10, 26, 0, 2); + assert_eq!(update(&servers, peer, true), vec![peer]); + + let empty_snapshot = || ClientSimpleInfoList { + data_version: 2, + list: Vec::new(), + is_all: true, + time: 0, + }; + assert_eq!( + servers.update_client_simple_list(0, Ipv4Addr::new(10, 26, 0, 1), empty_snapshot(), 0,), + vec![peer] + ); + assert!( + servers + .update_client_simple_list(0, Ipv4Addr::new(10, 26, 0, 1), empty_snapshot(), 0,) + .is_empty() + ); + } +} #[derive(Copy, Clone, Debug)] pub struct NetworkAddr { pub gateway: Ipv4Addr, @@ -870,9 +955,16 @@ impl AppState { } } impl AppState { - pub fn get_punch_info(&self) -> Option { + pub fn get_punch_info(&self, target: Ipv4Addr) -> Option { + let punch_model = self + .config + .lock() + .as_ref() + .map(|config| punch_model_for(&config.punch_model, &target)) + .unwrap_or_else(rustp2p_core::punch::PunchPolicySet::all); self.nat_info.get().map(|info| PunchInfo { nat_info: self.filter_ip(info), + punch_model, }) } pub fn get_nat_info(&self) -> Option { diff --git a/vnt-core/src/context/nat.rs b/vnt-core/src/context/nat.rs index 6a96bff4..efac29ae 100644 --- a/vnt-core/src/context/nat.rs +++ b/vnt-core/src/context/nat.rs @@ -136,11 +136,14 @@ fn mapping_addr(addr: SocketAddr) -> Option<(Ipv4Addr, u16)> { #[derive(Copy, Clone, Debug)] pub struct PunchState { - /// 打洞交互累计次数,退避时长按「BASE × count」线性增长,封顶 MAX_BACKOFF。 + /// 连续失败的打洞轮次,成功或节点上下线时清零。 pub count: u32, /// 该目标下次允许打洞的时刻,到点之前不应再次打洞。 /// 用单调时钟 Instant,不受系统时间调整影响。 pub backoff_until: Instant, + /// 该目标下次允许实际启动底层打洞的时刻。 + /// 与主动协商退避分离,避免双方同时发起时互相阻塞协商响应。 + pub punch_cooldown_until: Instant, } #[derive(Clone, Default)] @@ -149,26 +152,85 @@ pub struct PunchBackoff { } impl PunchBackoff { - const MAX_BACKOFF: Duration = Duration::from_secs(3_600); // 常规退避上限 1h + const PUNCH_COOLDOWN: Duration = Duration::from_secs(5); const NAT_CHANGE_CAP: Duration = Duration::from_secs(600); // NAT 变化后最多等待 10 分钟 - const BASE: Duration = Duration::from_secs(3); + const RETRY_BACKOFF: [Duration; 7] = [ + Duration::from_secs(5), + Duration::from_secs(10), + Duration::from_secs(30), + Duration::from_secs(60), + Duration::from_secs(300), + Duration::from_secs(900), + Duration::from_secs(3_600), + ]; - /// 记录一次打洞交互:退避时长 = BASE × 累计次数,封顶 MAX_BACKOFF。 - pub fn record(&self, ip: Ipv4Addr) { + #[cfg(test)] + fn record(&self, ip: Ipv4Addr) { let now = Instant::now(); let mut map = self.inner.write(); let entry = map.entry(ip).or_insert(PunchState { count: 0, backoff_until: now, + punch_cooldown_until: now, }); - entry.count += 1; - entry.backoff_until = now + (Self::BASE * entry.count).min(Self::MAX_BACKOFF); + Self::record_entry(entry, now); + } + + /// 原子地检查并占用一轮打洞机会。 + /// + /// 返回 `true` 时已经立即更新退避期,即使后续协商包丢失, + /// 也不会在下一调度周期中无限重发。 + pub fn try_begin(&self, ip: Ipv4Addr) -> bool { + let now = Instant::now(); + let mut map = self.inner.write(); + let entry = map.entry(ip).or_insert(PunchState { + count: 0, + backoff_until: now, + punch_cooldown_until: now, + }); + if now < entry.backoff_until || now < entry.punch_cooldown_until { + return false; + } + Self::record_entry(entry, now); + true + } + + /// 原子地占用一次实际打洞机会。 + /// + /// 该冷却与主动协商退避相互独立:本机刚发送 PunchStart1 时, + /// 仍允许响应对端同时发送的 PunchStart1;但随后同一目标产生的 + /// PunchStart2 不会再次启动底层打洞。 + pub fn try_begin_punch(&self, ip: Ipv4Addr) -> bool { + let now = Instant::now(); + let mut map = self.inner.write(); + let entry = map.entry(ip).or_insert(PunchState { + count: 0, + backoff_until: now, + punch_cooldown_until: now, + }); + if now < entry.punch_cooldown_until { + return false; + } + entry.punch_cooldown_until = now + Self::PUNCH_COOLDOWN; + true + } + + fn record_entry(entry: &mut PunchState, now: Instant) { + entry.count = entry.count.saturating_add(1); + let index = (entry.count.saturating_sub(1) as usize).min(Self::RETRY_BACKOFF.len() - 1); + entry.backoff_until = now + Self::RETRY_BACKOFF[index]; } pub fn should_punch(&self, ip: Ipv4Addr) -> bool { + let now = Instant::now(); let map = self.inner.read(); map.get(&ip) - .is_none_or(|state| Instant::now() >= state.backoff_until) + .is_none_or(|state| now >= state.backoff_until && now >= state.punch_cooldown_until) + } + + /// 直连建立成功或节点在线状态变化后,忘记该节点的历史失败。 + pub fn reset(&self, ip: Ipv4Addr) { + self.inner.write().remove(&ip); } /// 对端 NAT 变化:不删除退避记录,把该目标的退避截止时刻压缩到 @@ -295,6 +357,83 @@ mod tests { assert!(backoff.should_punch(ip)); } + #[test] + fn try_begin_atomically_reserves_attempt_and_advances_schedule() { + let backoff = PunchBackoff::default(); + let ip = Ipv4Addr::new(10, 26, 0, 2); + + assert!(backoff.try_begin(ip)); + assert!(!backoff.try_begin(ip), "同一退避期只能占用一次"); + + backoff.inner.write().get_mut(&ip).unwrap().backoff_until = + Instant::now() - Duration::from_secs(1); + let now = Instant::now(); + assert!(backoff.try_begin(ip)); + let state = backoff.inner.read()[&ip]; + assert_eq!(state.count, 2); + assert!(state.backoff_until - now >= Duration::from_secs(9)); + } + + #[test] + fn actual_punch_has_a_shared_per_target_cooldown() { + let backoff = PunchBackoff::default(); + let clone = backoff.clone(); + let first_ip = Ipv4Addr::new(10, 26, 0, 2); + let second_ip = Ipv4Addr::new(10, 26, 0, 3); + + assert!(backoff.try_begin_punch(first_ip)); + assert!( + !clone.try_begin_punch(first_ip), + "克隆必须共享同一目标的实际打洞冷却" + ); + assert!( + clone.try_begin_punch(second_ip), + "不同目标的实际打洞冷却必须相互独立" + ); + + backoff + .inner + .write() + .get_mut(&first_ip) + .unwrap() + .punch_cooldown_until = Instant::now() - Duration::from_secs(1); + assert!(backoff.try_begin_punch(first_ip)); + } + + #[test] + fn outbound_reservation_allows_one_simultaneous_inbound_punch() { + let backoff = PunchBackoff::default(); + let ip = Ipv4Addr::new(10, 26, 0, 2); + + assert!(backoff.try_begin(ip), "主动协商应成功占用重试机会"); + assert!( + backoff.try_begin_punch(ip), + "主动协商不应阻止响应对端同时发起的打洞" + ); + assert!( + !backoff.try_begin_punch(ip), + "同一轮后续 PunchStart2 不应重复启动底层打洞" + ); + assert!( + !backoff.should_punch(ip), + "实际打洞冷却期间不应再次主动协商" + ); + } + + #[test] + fn reset_forgets_previous_failures() { + let backoff = PunchBackoff::default(); + let ip = Ipv4Addr::new(10, 26, 0, 2); + assert!(backoff.try_begin(ip)); + assert!(!backoff.should_punch(ip)); + + backoff.reset(ip); + + assert!(backoff.should_punch(ip)); + assert!(backoff.try_begin(ip)); + assert_eq!(backoff.inner.read()[&ip].count, 1); + } + #[test] fn record_after_expiry_keeps_growth() { let backoff = PunchBackoff::default(); diff --git a/vnt-core/src/core/mod.rs b/vnt-core/src/core/mod.rs index 05eac990..c441f023 100644 --- a/vnt-core/src/core/mod.rs +++ b/vnt-core/src/core/mod.rs @@ -49,7 +49,9 @@ struct RegistrationContext { enhanced_inbound: EnhancedInbound, fec_decoder: FecDecoder, turn: std::sync::Arc>, + punch_model: std::sync::Arc>, auto_sync_subnet: bool, + allow_ikev2: bool, } pub struct NetworkManager { @@ -86,6 +88,7 @@ impl NetworkManager { config.normalize()?; config.check()?; let turn = std::sync::Arc::new(config.turn.clone()); + let punch_model = std::sync::Arc::new(config.punch_model.clone()); let outbound_interface_name = config .outbound_interface .as_deref() @@ -160,6 +163,7 @@ impl NetworkManager { app_state.punch_backoff.clone(), puncher, packet_crypto.clone(), + punch_model.clone(), ); let subnet_external_route = app_state.subnet_route.clone(); subnet_external_route.set_route_table(config.input.clone()); @@ -194,7 +198,8 @@ impl NetworkManager { subnet_packet_mapper.clone(), fec_encoder, ) - .with_no_broadcast(config.no_broadcast); + .with_no_broadcast(config.no_broadcast) + .with_allow_ikev2(config.allow_ikev2); let port_mapping_manager = PortMappingManager::new( config.device_mode == DeviceMode::No, config.allow_port_mapping, @@ -274,6 +279,7 @@ impl NetworkManager { fec_decoder: fec_decoder.clone(), turn: turn.clone(), basic_outbound, + punch_backoff: app_state.punch_backoff.clone(), }); p2p_task.start(handler); } @@ -288,7 +294,9 @@ impl NetworkManager { enhanced_inbound, fec_decoder, turn, + punch_model, auto_sync_subnet: config.auto_sync_subnet, + allow_ikev2: config.allow_ikev2, }); app_state.set_config(config.clone()); @@ -397,12 +405,14 @@ impl NetworkManager { peer_map: app_state.peer_map.clone(), punch_backoff: app_state.punch_backoff.clone(), puncher: ctx.puncher.clone(), + punch_model: ctx.punch_model.clone(), packet_crypto: ctx.packet_crypto.clone(), packet_compression: ctx.packet_compression.clone(), enhanced_inbound: ctx.enhanced_inbound.clone(), fec_decoder: ctx.fec_decoder.clone(), turn: ctx.turn.clone(), auto_sync_subnet: ctx.auto_sync_subnet, + allow_ikev2: ctx.allow_ikev2, }); turn_manager.data_handle_task_connected(task_group, handler_config); } diff --git a/vnt-core/src/enhanced_tunnel/outbound.rs b/vnt-core/src/enhanced_tunnel/outbound.rs index 6c25e3c1..343e30b0 100644 --- a/vnt-core/src/enhanced_tunnel/outbound.rs +++ b/vnt-core/src/enhanced_tunnel/outbound.rs @@ -2,6 +2,7 @@ use crate::context::SharedNetworkAddr; use crate::enhanced_tunnel::quic_over::quic_outbound::EnhancedQuicOutbound; use crate::ethernet::{ MacTable, build_arp_reply, is_broadcast_or_multicast, mac_from_ip, parse_arp_ipv4, parse_frame, + strip_ipv4, }; use crate::nat::SubnetMappingTable; use crate::nat::subnet_packet::SubnetPacketMapper; @@ -86,9 +87,25 @@ impl EnhancedOutbound { if frame.ethertype == EtherTypes::Arp && let Some(arp) = parse_arp_ipv4(data.as_ref()) && arp.operation == ArpOperations::Request - && arp.target_ip == net.gateway + && (arp.target_ip == net.gateway + || self.hybrid_outbound.is_ikev2_client(&arp.target_ip)) { - return Ok(build_arp_reply(data.as_ref(), net.gateway)); + return Ok(build_arp_reply(data.as_ref(), arp.target_ip)); + } + + if frame.ethertype == EtherTypes::Ipv4 { + let Some(ipv4) = Ipv4Packet::new(&data[frame.payload_offset..]) else { + return Ok(None); + }; + let dest = ipv4.get_destination(); + if self.hybrid_outbound.is_ikev2_client(&dest) { + if let Some(ip) = strip_ipv4(data) { + self.hybrid_outbound + .ikev2_relay_outbound(net, ip, dest) + .await?; + } + return Ok(None); + } } // Only frames explicitly addressed to our proxy-ARP gateway enter the @@ -143,6 +160,12 @@ impl EnhancedOutbound { // 发送到网关 return self.hybrid_outbound.ipv4_gateway_outbound(net, data).await; } + if self.hybrid_outbound.is_ikev2_client(&dest) { + return self + .hybrid_outbound + .ikev2_relay_outbound(net, data, dest) + .await; + } if dest.is_multicast() || dest == net.broadcast || dest.is_broadcast() { // 广播或组播 if self.hybrid_outbound.no_broadcast() { diff --git a/vnt-core/src/nat/subnet_packet.rs b/vnt-core/src/nat/subnet_packet.rs index de4abf75..8f05079f 100644 --- a/vnt-core/src/nat/subnet_packet.rs +++ b/vnt-core/src/nat/subnet_packet.rs @@ -404,11 +404,11 @@ fn internet_checksum(data: &[u8]) -> u16 { } fn add_words(sum: &mut u32, data: &[u8]) { - let mut chunks = data.chunks_exact(2); - for word in &mut chunks { - *sum += u16::from_be_bytes([word[0], word[1]]) as u32; + let (words, remainder) = data.as_chunks::<2>(); + for &word in words { + *sum += u16::from_be_bytes(word) as u32; } - if let Some(last) = chunks.remainder().first() { + if let Some(last) = remainder.first() { *sum += (*last as u32) << 8; } } diff --git a/vnt-core/src/protocol/client_message.rs b/vnt-core/src/protocol/client_message.rs index cd82938b..c6b1024c 100644 --- a/vnt-core/src/protocol/client_message.rs +++ b/vnt-core/src/protocol/client_message.rs @@ -5,6 +5,7 @@ mod proto { use anyhow::bail; use bytes::BytesMut; use prost::Message; +use rustp2p_core::punch::{PunchPolicy, PunchPolicySet}; use std::net::{Ipv4Addr, Ipv6Addr}; use crate::protocol::ProtoToBytesMut; @@ -79,6 +80,7 @@ pub fn decode_nat_info(msg: proto::NatInfo) -> anyhow::Result BytesMut { let message = proto::PunchInfo { nat_info: Some(encode_nat_info(&self.nat_info)), + punch_model: encode_punch_model(&self.punch_model), }; message.encode_bytes_mut() } } + +const IPV4_TCP: u32 = 1 << 0; +const IPV4_UDP: u32 = 1 << 1; +const IPV6_TCP: u32 = 1 << 2; +const IPV6_UDP: u32 = 1 << 3; + +fn encode_punch_model(model: &PunchPolicySet) -> u32 { + let mut bits = 0; + for (policy, bit) in [ + (PunchPolicy::IPv4Tcp, IPV4_TCP), + (PunchPolicy::IPv4Udp, IPV4_UDP), + (PunchPolicy::IPv6Tcp, IPV6_TCP), + (PunchPolicy::IPv6Udp, IPV6_UDP), + ] { + if model.is_match(policy) { + bits |= bit; + } + } + bits +} + +fn decode_punch_model(bits: u32) -> PunchPolicySet { + if bits == 0 { + return PunchPolicySet::all(); + } + let mut model = PunchPolicySet::empty(); + for (policy, bit) in [ + (PunchPolicy::IPv4Tcp, IPV4_TCP), + (PunchPolicy::IPv4Udp, IPV4_UDP), + (PunchPolicy::IPv6Tcp, IPV6_TCP), + (PunchPolicy::IPv6Udp, IPV6_UDP), + ] { + if bits & bit != 0 { + model.or(policy); + } + } + model +} + +#[cfg(test)] +mod tests { + use super::*; + + fn encoded_punch_info(punch_model: u32) -> Vec { + proto::PunchInfo { + nat_info: Some(proto::NatInfo { + nat_type: proto::NatType::Cone.into(), + public_ips: Vec::new(), + public_udp_ports: Vec::new(), + public_port_range: 0, + local_ipv4s: Vec::new(), + ipv6: None, + local_udp_ports: Vec::new(), + local_tcp_port: 0, + public_tcp_port: 0, + }), + punch_model, + } + .encode_to_vec() + } + + #[test] + fn punch_model_bits_round_trip() { + for bits in 1..=(IPV4_TCP | IPV4_UDP | IPV6_TCP | IPV6_UDP) { + let decoded = PunchInfo::from_slice(&encoded_punch_info(bits)).unwrap(); + assert_eq!(encode_punch_model(&decoded.punch_model), bits); + + let encoded = decoded.encode(); + let wire = proto::PunchInfo::decode(encoded.as_ref()).unwrap(); + assert_eq!(wire.punch_model, bits); + } + } + + #[test] + fn legacy_zero_means_all_and_unknown_bits_do_not_open_modes() { + let legacy = PunchInfo::from_slice(&encoded_punch_info(0)) + .unwrap() + .punch_model; + for policy in [ + PunchPolicy::IPv4Tcp, + PunchPolicy::IPv4Udp, + PunchPolicy::IPv6Tcp, + PunchPolicy::IPv6Udp, + ] { + assert!(legacy.is_match(policy)); + } + + let unknown_only = PunchInfo::from_slice(&encoded_punch_info(1 << 20)) + .unwrap() + .punch_model; + for policy in [ + PunchPolicy::IPv4Tcp, + PunchPolicy::IPv4Udp, + PunchPolicy::IPv6Tcp, + PunchPolicy::IPv6Udp, + ] { + assert!(!unknown_only.is_match(policy)); + } + } +} diff --git a/vnt-core/src/protocol/control_message.rs b/vnt-core/src/protocol/control_message.rs index 7b000fd9..243a6400 100644 --- a/vnt-core/src/protocol/control_message.rs +++ b/vnt-core/src/protocol/control_message.rs @@ -12,6 +12,8 @@ mod proto { include!(concat!(env!("OUT_DIR"), "/protocol.control_message.rs")); } +pub use proto::ClientType; + #[derive(Debug, Clone, Copy, Eq, PartialEq, Default)] pub enum RegistrationMode { #[default] @@ -47,6 +49,7 @@ pub(crate) struct RegRequestMsg { pub server_id: u32, pub registration_mode: RegistrationMode, pub advertised_subnets: Vec, + pub allow_ikev2: bool, } impl RegRequestMsg { // pub fn check(&self) -> anyhow::Result<()> { @@ -117,6 +120,7 @@ impl RegRequestMsg { .into_iter() .map(ipv4_subnet_to_proto) .collect(), + allow_ikev2: self.allow_ikev2, } } } @@ -333,18 +337,21 @@ impl SelectiveBroadcast { pub struct ClientSimpleInfo { pub ip: Ipv4Addr, pub online: bool, + pub client_type: ClientType, } impl ClientSimpleInfo { pub fn from(msg: proto::ClientSimpleInfo) -> anyhow::Result { Ok(Self { ip: msg.ip.into(), online: msg.online, + client_type: msg.client_type(), }) } pub fn to(self) -> proto::ClientSimpleInfo { proto::ClientSimpleInfo { ip: self.ip.into(), online: self.online, + client_type: self.client_type as i32, } } } @@ -406,6 +413,7 @@ mod tests { server_id: 0, registration_mode: RegistrationMode::Normal, advertised_subnets: vec![advertised], + allow_ikev2: false, }) .encode(); let request = proto::RequestMessage::decode(encoded.as_ref()).unwrap(); @@ -443,4 +451,41 @@ mod tests { assert_eq!(snapshot.snapshot_hash, vec![1, 2, 3]); assert_eq!(snapshot.nodes[0].subnets, vec![advertised]); } + + #[test] + fn ikev2_capability_and_client_type_round_trip() { + let encoded = RequestMessage::Reg(RegRequestMsg { + network_code: "test".to_string(), + device_id: "device".to_string(), + ip: None, + name: "node".to_string(), + version: "1".to_string(), + key_sign: None, + ip_variable: true, + server_id: 0, + registration_mode: RegistrationMode::Normal, + advertised_subnets: Vec::new(), + allow_ikev2: true, + }) + .encode(); + let request = proto::RequestMessage::decode(encoded.as_ref()).unwrap(); + let RequestPayload::Reg(request) = request.request_payload.unwrap() else { + panic!("expected registration request"); + }; + assert!(request.allow_ikev2); + + let list = proto::ClientSimpleInfoList { + data_version: 1, + list: vec![proto::ClientSimpleInfo { + ip: Ipv4Addr::new(10, 26, 0, 8).into(), + online: true, + client_type: proto::ClientType::Ikev2 as i32, + }], + is_all: true, + time: 0, + } + .encode_to_vec(); + let decoded = ClientSimpleInfoList::from_slice(&list).unwrap(); + assert_eq!(decoded.list[0].client_type, ClientType::Ikev2); + } } diff --git a/vnt-core/src/protocol/ip_packet_protocol.rs b/vnt-core/src/protocol/ip_packet_protocol.rs index 67433c5d..b6615339 100644 --- a/vnt-core/src/protocol/ip_packet_protocol.rs +++ b/vnt-core/src/protocol/ip_packet_protocol.rs @@ -117,6 +117,7 @@ pub enum MsgType { FastReg = 22, SubnetSyncReq = 23, SubnetSyncRes = 24, + Ikev2Relay = 25, } impl From for u8 { fn from(val: MsgType) -> Self { @@ -159,6 +160,7 @@ impl TryFrom for MsgType { 22 => MsgType::FastReg, 23 => MsgType::SubnetSyncReq, 24 => MsgType::SubnetSyncRes, + 25 => MsgType::Ikev2Relay, _ => { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -363,6 +365,7 @@ mod tests { MsgType::FastReg, MsgType::SubnetSyncReq, MsgType::SubnetSyncRes, + MsgType::Ikev2Relay, ]; for msg_type in all { let byte = u8::from(msg_type); @@ -374,7 +377,7 @@ mod tests { } // 未分配的取值必须报错 assert!(MsgType::try_from(0u8).is_err()); - assert!(MsgType::try_from(25u8).is_err()); + assert!(MsgType::try_from(26u8).is_err()); } #[test] diff --git a/vnt-core/src/tunnel_core/outbound.rs b/vnt-core/src/tunnel_core/outbound.rs index b502e8b4..702a374b 100644 --- a/vnt-core/src/tunnel_core/outbound.rs +++ b/vnt-core/src/tunnel_core/outbound.rs @@ -115,6 +115,16 @@ impl BasicOutbound { .await } + pub async fn send_server_raw( + &self, + dest: Ipv4Addr, + packet: NetPacket, + ) -> anyhow::Result<()> { + self.server_outbound + .send_raw(dest, packet.into_bytes()) + .await + } + /// 广播发送 pub async fn send_raw_broadcast( &self, @@ -218,6 +228,7 @@ pub(crate) struct HybridOutbound { subnet_packet_mapper: SubnetPacketMapper, fec_encoder: Option, no_broadcast: bool, + allow_ikev2: bool, } impl HybridOutbound { #[allow(clippy::too_many_arguments)] @@ -243,6 +254,7 @@ impl HybridOutbound { subnet_packet_mapper, fec_encoder, no_broadcast: false, + allow_ikev2: false, } } @@ -250,6 +262,50 @@ impl HybridOutbound { self.no_broadcast = no_broadcast; self } + pub fn with_allow_ikev2(mut self, allow_ikev2: bool) -> Self { + self.allow_ikev2 = allow_ikev2; + self + } + pub fn is_ikev2_client(&self, ip: &Ipv4Addr) -> bool { + self.allow_ikev2 && self.server_info.is_ikev2_client(ip) + } + pub async fn ikev2_relay_outbound( + &self, + net: NetworkAddr, + mut data: TransmissionBytes, + dest: Ipv4Addr, + ) -> anyhow::Result<()> { + if !self.is_ikev2_client(&dest) { + return Ok(()); + } + let Some(ipv4) = Ipv4Packet::new(data.as_ref()) else { + return Ok(()); + }; + let header_length = ipv4.get_header_length() as usize * 4; + let total_length = ipv4.get_total_length() as usize; + if header_length < Ipv4Packet::minimum_packet_size() + || total_length < header_length + || total_length > data.len() + || ipv4.get_source() != net.ip + || ipv4.get_destination() != dest + { + return Ok(()); + } + if total_length < data.len() { + let trailing = data.len() - total_length; + data.shrink_end(trailing); + } + let len = data.len() as u64; + data.retreat_head(HEAD_LENGTH)?; + let mut packet = NetPacket::new(data)?; + packet.set_msg_type(MsgType::Ikev2Relay); + packet.set_src_id(net.ip.into()); + packet.set_dest_id(dest.into()); + packet.set_ttl(5); + self.basic_outbound.send_server_raw(dest, packet).await?; + self.traffic_stats.record_tx(dest, len); + Ok(()) + } pub async fn outbound_raw( &self, dest: Ipv4Addr, @@ -357,6 +413,12 @@ impl HybridOutbound { data: TransmissionBytes, mut dest: Ipv4Addr, ) -> anyhow::Result<()> { + if self.is_ikev2_client(&dest) { + let Some(ip) = crate::ethernet::strip_ipv4(data) else { + return Ok(()); + }; + return self.ikev2_relay_outbound(net, ip, dest).await; + } if dest == net.gateway { let Some(ip) = crate::ethernet::strip_ipv4(data) else { return Ok(()); diff --git a/vnt-core/src/tunnel_core/p2p/inbound.rs b/vnt-core/src/tunnel_core/p2p/inbound.rs index 79fbf43b..60d22f8b 100644 --- a/vnt-core/src/tunnel_core/p2p/inbound.rs +++ b/vnt-core/src/tunnel_core/p2p/inbound.rs @@ -1,5 +1,6 @@ use crate::compression::PacketCompression; use crate::context::config::{TurnRule, allow_punch}; +use crate::context::nat::PunchBackoff; use crate::context::{NetworkAddr, NetworkRoute, PacketLossStats}; use crate::crypto::PacketCrypto; use crate::enhanced_tunnel::inbound::EnhancedInbound; @@ -59,6 +60,7 @@ pub(crate) struct P2pInboundConfig { pub fec_decoder: FecDecoder, pub turn: Arc>, pub basic_outbound: BasicOutbound, + pub punch_backoff: PunchBackoff, } #[derive(Clone)] @@ -72,6 +74,7 @@ pub(crate) struct P2pInboundHandler { fec_decoder: FecDecoder, turn: Arc>, basic_outbound: BasicOutbound, + punch_backoff: PunchBackoff, } impl P2pInboundHandler { @@ -86,6 +89,7 @@ impl P2pInboundHandler { fec_decoder: config.fec_decoder, turn: config.turn, basic_outbound: config.basic_outbound, + punch_backoff: config.punch_backoff, } } fn network_contains(&self, ip: &Ipv4Addr) -> bool { @@ -283,7 +287,9 @@ impl P2pInboundHandler { ctx.src_ip, ctx.dest_ip ); - self.route_table.add_owner_route(ctx.src_ip, route_key); + if self.route_table.add_owner_route(ctx.src_ip, route_key) { + self.punch_backoff.reset(ctx.src_ip); + } let mut packet = build_handshake_response( MsgType::PunchRes, net.ip, @@ -317,7 +323,9 @@ impl P2pInboundHandler { ctx.src_ip, ctx.dest_ip ); - self.route_table.add_owner_route(ctx.src_ip, route_key); + if self.route_table.add_owner_route(ctx.src_ip, route_key) { + self.punch_backoff.reset(ctx.src_ip); + } } MsgType::DirectConnectReq => { if !valid_punch_source(net, ctx.src_ip) { @@ -332,7 +340,9 @@ impl P2pInboundHandler { ctx.src_ip, net.ip ); - self.route_table.add_owner_route(ctx.src_ip, route_key); + if self.route_table.add_owner_route(ctx.src_ip, route_key) { + self.punch_backoff.reset(ctx.src_ip); + } let mut packet = build_handshake_response( MsgType::DirectConnectRes, net.ip, @@ -355,7 +365,9 @@ impl P2pInboundHandler { ctx.src_ip, ctx.dest_ip ); - self.route_table.add_owner_route(ctx.src_ip, route_key); + if self.route_table.add_owner_route(ctx.src_ip, route_key) { + self.punch_backoff.reset(ctx.src_ip); + } } MsgType::PingTurn => {} MsgType::PongTurn => {} diff --git a/vnt-core/src/tunnel_core/p2p/route_table.rs b/vnt-core/src/tunnel_core/p2p/route_table.rs index d3e0de25..35bbfb8d 100644 --- a/vnt-core/src/tunnel_core/p2p/route_table.rs +++ b/vnt-core/src/tunnel_core/p2p/route_table.rs @@ -1,4 +1,5 @@ use parking_lot::{Mutex, RwLock}; +use rustp2p_core::punch::{PunchPolicy, PunchPolicySet}; use rustp2p_core::route_table::{DEFAULT_RTT, Protocol, RouteKey}; use std::collections::HashMap; use std::net::{Ipv4Addr, SocketAddr}; @@ -153,14 +154,30 @@ impl RouteTable { self.inner.get_by_id(id).is_some() } - /// 判断是否需要打洞(没有路由或只有中继路由) - pub fn need_punch(&self, id: &Ipv4Addr) -> bool { + /// 返回配置允许、但当前尚未建立直连路由的打洞类型。 + pub fn missing_punch_policies( + &self, + id: &Ipv4Addr, + configured: &PunchPolicySet, + ) -> PunchPolicySet { let guard = self.inner.route_table.read(); - let Some(list) = guard.get(id) else { - return true; - }; - // 如果没有直连路由(metric=1),则需要打洞 - !list.iter().any(|r| r.is_direct()) + let routes = guard.get(id).map(Vec::as_slice).unwrap_or_default(); + let mut missing = PunchPolicySet::empty(); + for policy in [ + PunchPolicy::IPv4Tcp, + PunchPolicy::IPv4Udp, + PunchPolicy::IPv6Tcp, + PunchPolicy::IPv6Udp, + ] { + if configured.is_match(policy) + && !routes + .iter() + .any(|route| direct_route_matches_policy(route, policy)) + { + missing.or(policy); + } + } + missing } /// 获取直连路由数量(用于判断是否直连) @@ -185,10 +202,12 @@ impl RouteTable { } /// 添加 owner 路由(打洞请求响应时调用) - pub fn add_owner_route(&self, id: Ipv4Addr, key: RouteKey) { - if self.inner.add_owner_route(id, key) { + pub fn add_owner_route(&self, id: Ipv4Addr, key: RouteKey) -> bool { + let first_direct = self.inner.add_owner_route(id, key); + if first_direct { self.inner.first_direct_route_notify.notify_one(); } + first_direct } /// 添加路由(心跳时调用,用于更新路由时间和添加跨节点转发路由) @@ -356,6 +375,23 @@ fn best_direct_route(routes: &[Route]) -> Option { .copied() } +fn direct_route_matches_policy(route: &Route, policy: PunchPolicy) -> bool { + if !route.is_direct() { + return false; + } + matches!( + ( + route.route_key().protocol(), + route.route_key().peer_addr(), + policy + ), + (Protocol::TCP, SocketAddr::V4(_), PunchPolicy::IPv4Tcp) + | (Protocol::UDP, SocketAddr::V4(_), PunchPolicy::IPv4Udp) + | (Protocol::TCP, SocketAddr::V6(_), PunchPolicy::IPv6Tcp) + | (Protocol::UDP, SocketAddr::V6(_), PunchPolicy::IPv6Udp) + ) +} + #[cfg(test)] mod tests { use super::*; @@ -477,4 +513,55 @@ mod tests { assert!(relay.score() > direct.score()); assert_eq!(best_direct_route(&[relay, direct]).unwrap().metric(), 1); } + + #[test] + fn missing_punch_policies_only_returns_configured_routes_without_direct_match() { + let table = RouteTable::new(); + let target = Ipv4Addr::new(10, 0, 0, 2); + let ipv4_tcp = RouteKey::new( + Protocol::TCP, + "127.0.0.1:2000".parse().unwrap(), + "127.0.0.1:3000".parse().unwrap(), + ); + let ipv4_udp = RouteKey::new( + Protocol::UDP, + "127.0.0.1:2001".parse().unwrap(), + "127.0.0.1:3001".parse().unwrap(), + ); + let mut configured = PunchPolicySet::empty(); + configured.or(PunchPolicy::IPv4Tcp); + configured.or(PunchPolicy::IPv4Udp); + + table.add_owner_route(target, ipv4_tcp); + table.add_relay_route(target, Route::from_default_rt(ipv4_udp, 2)); + + let missing = table.missing_punch_policies(&target, &configured); + assert!(!missing.is_match(PunchPolicy::IPv4Tcp)); + assert!(missing.is_match(PunchPolicy::IPv4Udp)); + assert!(!missing.is_match(PunchPolicy::IPv6Tcp)); + assert!(!missing.is_match(PunchPolicy::IPv6Udp)); + + table.add_owner_route(target, ipv4_udp); + let missing = table.missing_punch_policies(&target, &configured); + assert!(!missing.is_match(PunchPolicy::IPv4Tcp)); + assert!(!missing.is_match(PunchPolicy::IPv4Udp)); + } + + #[test] + fn missing_punch_policies_distinguishes_ip_family_and_protocol() { + let table = RouteTable::new(); + let target = Ipv4Addr::new(10, 0, 0, 2); + let ipv6_udp = RouteKey::new( + Protocol::UDP, + "[::1]:2000".parse().unwrap(), + "[::1]:3000".parse().unwrap(), + ); + table.add_owner_route(target, ipv6_udp); + + let missing = table.missing_punch_policies(&target, &PunchPolicySet::all()); + assert!(missing.is_match(PunchPolicy::IPv4Tcp)); + assert!(missing.is_match(PunchPolicy::IPv4Udp)); + assert!(missing.is_match(PunchPolicy::IPv6Tcp)); + assert!(!missing.is_match(PunchPolicy::IPv6Udp)); + } } diff --git a/vnt-core/src/tunnel_core/p2p/transport/punch.rs b/vnt-core/src/tunnel_core/p2p/transport/punch.rs index f049c246..f1867c33 100644 --- a/vnt-core/src/tunnel_core/p2p/transport/punch.rs +++ b/vnt-core/src/tunnel_core/p2p/transport/punch.rs @@ -1,4 +1,4 @@ -use crate::context::config::{TurnRule, allow_punch}; +use crate::context::config::{PunchRule, TurnRule, allow_punch, punch_model_for}; use crate::context::nat::PunchBackoff; use crate::context::{ServerInfoCollection, SharedNetworkAddr}; use crate::crypto::PacketCrypto; @@ -10,10 +10,38 @@ use crate::tunnel_core::server::outbound::ServerOutbound; use anyhow::bail; use log::error; use rand::seq::SliceRandom; -use rustp2p_core::punch::{PunchModel, Puncher}; +use rustp2p_core::punch::{PunchModel, PunchPolicy, PunchPolicySet, Puncher}; use std::net::Ipv4Addr; use std::sync::Arc; use std::time::Duration; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; + +const MAX_CONCURRENT_PUNCHES: usize = 4; +const PUNCH_POLICIES: [PunchPolicy; 4] = [ + PunchPolicy::IPv4Tcp, + PunchPolicy::IPv4Udp, + PunchPolicy::IPv6Tcp, + PunchPolicy::IPv6Udp, +]; + +#[derive(Clone)] +struct PunchLimiter { + semaphore: Arc, +} + +impl Default for PunchLimiter { + fn default() -> Self { + Self { + semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_PUNCHES)), + } + } +} + +impl PunchLimiter { + fn try_acquire(&self) -> Option { + self.semaphore.clone().try_acquire_owned().ok() + } +} pub struct PunchTaskContext { pub network: SharedNetworkAddr, @@ -23,7 +51,11 @@ pub struct PunchTaskContext { pub turn: Arc>, } -pub type PunchInfoGetter = std::sync::Arc Option + Send + Sync>; +pub type PunchInfoGetter = std::sync::Arc Option + Send + Sync>; + +fn is_other_peer(src_ip: Ipv4Addr, dest_ip: Ipv4Addr) -> bool { + dest_ip != src_ip +} pub async fn punch_task( tunnel_to_server: ServerOutbound, @@ -35,38 +67,49 @@ pub async fn punch_task( let Some(src_ip) = ctx.network.ip() else { continue; }; - let Some(punch_info) = (ctx.punch_info_getter)() else { + if !ctx.server_info.is_any_server_connected(None) { continue; - }; + } let mut list = ctx.server_info.client_online_ips(); + list.retain(|dest_ip| { + is_other_peer(src_ip, *dest_ip) + && allow_punch(&ctx.turn, dest_ip) + && ctx.punch_backoff.should_punch(*dest_ip) + }); + let mut list = list + .into_iter() + .filter_map(|dest_ip| { + let mut punch_info = (ctx.punch_info_getter)(dest_ip)?; + let missing = route_table.missing_punch_policies(&dest_ip, &punch_info.punch_model); + if !has_any_punch_policy(&missing) { + return None; + } + punch_info.punch_model = missing; + Some((dest_ip, punch_info)) + }) + .collect::>(); list.shuffle(&mut rand::rng()); list.truncate(5); - for dest_ip in list { - if dest_ip <= src_ip { + for (dest_ip, punch_info) in list { + // should_punch() 只用于候选过滤;最终必须原子占用, + // 防止并发调度或重复报文同时启动同一目标。 + if !ctx.punch_backoff.try_begin(dest_ip) { continue; } - if !allow_punch(&ctx.turn, &dest_ip) { - continue; - } - if ctx.server_info.is_any_server_connected(None) && route_table.need_punch(&dest_ip) { - if !ctx.punch_backoff.should_punch(dest_ip) { - continue; - } - log::info!("punching {dest_ip}"); - - let data = punch_info.encode(); - let mut net_packet = NetPacket::new(TransmissionBytes::zeroed_size( - HEAD_LENGTH + data.len(), - tunnel_to_server.encrypt_reserve(), - ))?; - net_packet.set_msg_type(MsgType::PunchStart1); - net_packet.set_ttl(2); - net_packet.set_src_id(src_ip.into()); - net_packet.set_dest_id(dest_ip.into()); - net_packet.set_payload(data.as_ref())?; - if let Err(e) = tunnel_to_server.send(dest_ip, net_packet).await { - error!("punch send error {:?}", e); - } + log::info!("punching {dest_ip}"); + + let data = punch_info.encode(); + let mut net_packet = NetPacket::new(TransmissionBytes::zeroed_size( + HEAD_LENGTH + data.len(), + tunnel_to_server.encrypt_reserve(), + ))?; + net_packet.set_msg_type(MsgType::PunchStart1); + net_packet.set_ttl(2); + net_packet.set_src_id(src_ip.into()); + net_packet.set_dest_id(dest_ip.into()); + net_packet.set_payload(data.as_ref())?; + if let Err(e) = tunnel_to_server.send(dest_ip, net_packet).await { + error!("punch send error {:?}", e); } } } @@ -77,6 +120,8 @@ pub struct NatPuncher { punch_backoff: PunchBackoff, puncher: Option, packet_crypto: PacketCrypto, + limiter: PunchLimiter, + punch_rules: Arc>, } impl NatPuncher { @@ -85,28 +130,53 @@ impl NatPuncher { punch_backoff: PunchBackoff, puncher: Option, packet_crypto: PacketCrypto, + punch_rules: Arc>, ) -> Self { Self { network, punch_backoff, puncher, packet_crypto, + limiter: PunchLimiter::default(), + punch_rules, } } - pub fn punch(&self, dest_ip: Ipv4Addr, punch_info: PunchInfo) -> anyhow::Result { - if self.puncher.is_none() { - return Ok(false); - } - if !self.punch_backoff.should_punch(dest_ip) { - return Ok(false); + pub fn punch( + &self, + dest_ip: Ipv4Addr, + punch_info: PunchInfo, + ) -> anyhow::Result> { + let Some(puncher) = self.puncher.clone() else { + return Ok(None); + }; + let Some((punch_model, effective_policies)) = + self.effective_punch_model(dest_ip, &punch_info) + else { + log::debug!("skip punch to {dest_ip}: punch model intersection is empty"); + return Ok(None); + }; + let Some(permit) = self.limiter.try_acquire() else { + log::debug!("skip punch to {dest_ip}: concurrent punch limit reached"); + return Ok(None); + }; + if !self.punch_backoff.try_begin_punch(dest_ip) { + log::debug!("skip punch to {dest_ip}: punched within the last 5 seconds"); + return Ok(None); } - self.punch_uncheck_delay(dest_ip, punch_info, Some(Duration::from_millis(50)))?; - Ok(true) + self.spawn_punch( + puncher, + dest_ip, + punch_info, + punch_model, + Some(Duration::from_millis(50)), + permit, + )?; + Ok(Some(effective_policies)) } pub fn punch_uncheck(&self, dest_ip: Ipv4Addr, punch_info: PunchInfo) -> anyhow::Result<()> { self.punch_uncheck_delay(dest_ip, punch_info, None) } - pub fn punch_uncheck_delay( + fn punch_uncheck_delay( &self, dest_ip: Ipv4Addr, punch_info: PunchInfo, @@ -115,15 +185,55 @@ impl NatPuncher { let Some(puncher) = self.puncher.clone() else { return Ok(()); }; + let Some((punch_model, _)) = self.effective_punch_model(dest_ip, &punch_info) else { + log::debug!("skip punch to {dest_ip}: punch model intersection is empty"); + return Ok(()); + }; + let Some(permit) = self.limiter.try_acquire() else { + log::debug!("skip punch to {dest_ip}: concurrent punch limit reached"); + return Ok(()); + }; + if !self.punch_backoff.try_begin_punch(dest_ip) { + log::debug!("skip punch to {dest_ip}: punched within the last 5 seconds"); + return Ok(()); + } + self.spawn_punch(puncher, dest_ip, punch_info, punch_model, time, permit) + } + fn effective_punch_model( + &self, + dest_ip: Ipv4Addr, + punch_info: &PunchInfo, + ) -> Option<(PunchModel, PunchPolicySet)> { + effective_punch_model(&self.punch_rules, dest_ip, punch_info.punch_model.clone()) + } + fn spawn_punch( + &self, + puncher: Puncher, + dest_ip: Ipv4Addr, + punch_info: PunchInfo, + punch_model: PunchModel, + time: Option, + permit: OwnedSemaphorePermit, + ) -> anyhow::Result<()> { let Some(src_ip) = self.network.ip() else { bail!("not ip"); }; let packet_crypto = self.packet_crypto.clone(); tokio::spawn(async move { + let _permit = permit; if let Some(time) = time { tokio::time::sleep(time).await; } - if let Err(e) = punch_now(puncher, src_ip, dest_ip, punch_info, packet_crypto).await { + if let Err(e) = punch_now( + puncher, + src_ip, + dest_ip, + punch_info, + punch_model, + packet_crypto, + ) + .await + { log::warn!("punch send error {:?}", e); } }); @@ -135,6 +245,7 @@ async fn punch_now( src_ip: Ipv4Addr, dest_ip: Ipv4Addr, nat_info: PunchInfo, + punch_model: PunchModel, packet_crypto: PacketCrypto, ) -> anyhow::Result<()> { let mut packet = NetPacket::new(TransmissionBytes::zeroed_size( @@ -148,9 +259,94 @@ async fn punch_now( packet.set_payload(&crate::utils::time::now_ts_ms().to_be_bytes())?; packet_crypto.encrypt_in_place(&mut packet)?; let buf = packet.into_buffer().into_bytes().freeze(); - let punch_info = rustp2p_core::punch::PunchInfo::new(PunchModel::all(), nat_info.nat_info); + let punch_info = rustp2p_core::punch::PunchInfo::new(punch_model, nat_info.nat_info); puncher .punch_now(Some(buf.clone()), buf, punch_info) .await?; Ok(()) } + +fn effective_punch_model( + rules: &[PunchRule], + dest_ip: Ipv4Addr, + peer_model: PunchPolicySet, +) -> Option<(PunchModel, PunchPolicySet)> { + let local_model = punch_model_for(rules, &dest_ip); + let effective_policies = intersect_punch_policies(&local_model, &peer_model); + has_any_punch_policy(&effective_policies) + .then(|| (local_model & peer_model, effective_policies)) +} + +fn intersect_punch_policies(left: &PunchPolicySet, right: &PunchPolicySet) -> PunchPolicySet { + let mut intersection = PunchPolicySet::empty(); + for policy in PUNCH_POLICIES { + if left.is_match(policy) && right.is_match(policy) { + intersection.or(policy); + } + } + intersection +} + +fn has_any_punch_policy(policies: &PunchPolicySet) -> bool { + PUNCH_POLICIES + .into_iter() + .any(|policy| policies.is_match(policy)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn both_ip_directions_are_candidates_but_self_is_not() { + let src = Ipv4Addr::new(10, 26, 0, 2); + + assert!(is_other_peer(src, Ipv4Addr::new(10, 26, 0, 1))); + assert!(is_other_peer(src, Ipv4Addr::new(10, 26, 0, 3))); + assert!(!is_other_peer(src, src)); + } + + #[test] + fn punch_limiter_is_shared_and_releases_capacity() { + let limiter = PunchLimiter::default(); + let clone = limiter.clone(); + let mut permits = Vec::new(); + + for _ in 0..MAX_CONCURRENT_PUNCHES { + permits.push(limiter.try_acquire().expect("前四个任务应获得许可")); + } + assert!( + clone.try_acquire().is_none(), + "克隆必须共享同一个全局并发上限" + ); + + permits.pop(); + + assert!( + clone.try_acquire().is_some(), + "任务结束释放许可后应能立即开始下一轮" + ); + } + + #[test] + fn punch_model_uses_local_and_peer_intersection() { + let rules = vec!["10.26.0.2,IPv4Tcp,IPv6Udp".parse::().unwrap()]; + let mut peer = PunchPolicySet::empty(); + peer.or(PunchPolicy::IPv4Tcp); + peer.or(PunchPolicy::IPv4Udp); + + let (effective, effective_policies) = + effective_punch_model(&rules, Ipv4Addr::new(10, 26, 0, 2), peer) + .expect("IPv4Tcp is allowed by both peers"); + assert!(effective.is_match(PunchPolicy::IPv4Tcp)); + assert!(!effective.is_match(PunchPolicy::IPv4Udp)); + assert!(!effective.is_match(PunchPolicy::IPv6Udp)); + assert!(effective_policies.is_match(PunchPolicy::IPv4Tcp)); + assert!(!effective_policies.is_match(PunchPolicy::IPv4Udp)); + assert!(!effective_policies.is_match(PunchPolicy::IPv6Udp)); + + let mut incompatible = PunchPolicySet::empty(); + incompatible.or(PunchPolicy::IPv6Tcp); + assert!(effective_punch_model(&rules, Ipv4Addr::new(10, 26, 0, 2), incompatible).is_none()); + } +} diff --git a/vnt-core/src/tunnel_core/p2p/transport/task.rs b/vnt-core/src/tunnel_core/p2p/transport/task.rs index e14fcdf0..971f3c73 100644 --- a/vnt-core/src/tunnel_core/p2p/transport/task.rs +++ b/vnt-core/src/tunnel_core/p2p/transport/task.rs @@ -82,7 +82,7 @@ pub async fn init_tunnel( network: app_state.network.clone(), server_info: app_state.server_info_collection.clone(), punch_backoff: app_state.punch_backoff.clone(), - punch_info_getter: Arc::new(move || app_state_for_punch.get_punch_info()), + punch_info_getter: Arc::new(move |target| app_state_for_punch.get_punch_info(target)), turn: config.turn.clone(), }; task_group.spawn(punch_task(tunnel_to_server, route_table.clone(), punch_ctx)); diff --git a/vnt-core/src/tunnel_core/server/connection_manager.rs b/vnt-core/src/tunnel_core/server/connection_manager.rs index 1c89e743..2be595cc 100644 --- a/vnt-core/src/tunnel_core/server/connection_manager.rs +++ b/vnt-core/src/tunnel_core/server/connection_manager.rs @@ -1,5 +1,5 @@ use crate::compression::PacketCompression; -use crate::context::config::{Config, TurnRule}; +use crate::context::config::{Config, PunchRule, TurnRule}; use crate::context::nat::{MyNatInfo, PunchBackoff}; use crate::context::{AppState, NetworkRoute, PeerInfoMap, ServerInfoCollection}; use crate::crypto::PacketCrypto; @@ -34,12 +34,14 @@ pub struct InboundHandlerConfig { pub peer_map: PeerInfoMap, pub punch_backoff: PunchBackoff, pub puncher: NatPuncher, + pub punch_model: Arc>, pub packet_crypto: PacketCrypto, pub packet_compression: PacketCompression, pub enhanced_inbound: EnhancedInbound, pub fec_decoder: FecDecoder, pub turn: Arc>, pub auto_sync_subnet: bool, + pub allow_ikev2: bool, } pub struct ServerTurnManager { diff --git a/vnt-core/src/tunnel_core/server/inbound.rs b/vnt-core/src/tunnel_core/server/inbound.rs index 5bd7d070..3bc5b4a2 100644 --- a/vnt-core/src/tunnel_core/server/inbound.rs +++ b/vnt-core/src/tunnel_core/server/inbound.rs @@ -1,5 +1,5 @@ use crate::compression::PacketCompression; -use crate::context::config::{DeviceMode, TurnRule, allow_punch}; +use crate::context::config::{DeviceMode, PunchRule, TurnRule, allow_punch, punch_model_for}; use crate::context::nat::{MyNatInfo, PunchBackoff}; use crate::context::{ NetworkAddr, NetworkRoute, PeerInfoMap, ServerInfoCollection, SharedNetworkAddr, @@ -355,12 +355,14 @@ pub(crate) struct ServerTurnInboundHandler { peer_map: PeerInfoMap, punch_backoff: PunchBackoff, puncher: NatPuncher, + punch_model: Arc>, packet_crypto: PacketCrypto, packet_compression: PacketCompression, enhanced_inbound: EnhancedInbound, fec_decoder: FecDecoder, turn: Arc>, auto_sync_subnet: bool, + allow_ikev2: bool, } impl ServerTurnInboundHandler { pub fn new( @@ -377,12 +379,14 @@ impl ServerTurnInboundHandler { peer_map: config.peer_map, punch_backoff: config.punch_backoff, puncher: config.puncher, + punch_model: config.punch_model, packet_crypto: config.packet_crypto, packet_compression: config.packet_compression, enhanced_inbound: config.enhanced_inbound, fec_decoder: config.fec_decoder, turn: config.turn, auto_sync_subnet: config.auto_sync_subnet, + allow_ikev2: config.allow_ikev2, } } fn network_contains(&self, ip: &Ipv4Addr) -> bool { @@ -395,9 +399,10 @@ impl ServerTurnInboundHandler { info.local_ipv4s.retain(|ip| !self.network_contains(ip)); info } - fn get_punch_info(&self) -> Option { + fn get_punch_info(&self, target: Ipv4Addr) -> Option { self.nat_info.get().map(|info| PunchInfo { nat_info: self.filter_ip(info), + punch_model: punch_model_for(&self.punch_model, &target), }) } fn update_peer_nat_info(&self, ip: Ipv4Addr, nat_info: NatInfo) { @@ -421,6 +426,24 @@ impl ServerTurnInboundHandler { let mut net_packet = self.packet_compression.decompress(net_packet)?; match msg_type { + MsgType::Ikev2Relay if self.allow_ikev2 => { + let Some(ipv4) = Ipv4Packet::new(net_packet.payload()) else { + return Ok(()); + }; + let header_length = ipv4.get_header_length() as usize * 4; + if ipv4.get_version() != 4 + || header_length < Ipv4Packet::minimum_packet_size() + || ipv4.get_total_length() as usize != net_packet.payload().len() + || ipv4.get_source() != src + || ipv4.get_destination() != network_addr.ip + || Ipv4Addr::from(net_packet.dest_id()) != network_addr.ip + { + return Ok(()); + } + self.enhanced_inbound + .inbound(&network_addr, MsgType::Turn, src, net_packet) + .await?; + } MsgType::Turn => { // 只允许icmp EchoReply let Some(ipv4) = Ipv4Packet::new(net_packet.payload()) else { @@ -462,12 +485,15 @@ impl ServerTurnInboundHandler { } MsgType::PushClientIps => { let list = ClientSimpleInfoList::from_slice(net_packet.payload())?; - self.server_info.update_client_simple_list( + let changed = self.server_info.update_client_simple_list( self.server_id, network_addr.ip, list, now, ); + for ip in changed { + self.punch_backoff.reset(ip); + } } MsgType::RpcRes => { // 设置rpc响应 @@ -612,15 +638,17 @@ impl ServerTurnInboundHandler { } // 对方发起打洞 let peer_punch_info = PunchInfo::from_slice(net_packet.payload())?; - let Some(self_punch_info) = self.get_punch_info() else { + let Some(mut self_punch_info) = self.get_punch_info(src) else { return Ok(()); }; log::info!( "对方主动发起打洞 对方nat信息={peer_punch_info:?},自己nat信息={self_punch_info:?} {src}->{dest}" ); self.update_peer_nat_info(src, peer_punch_info.nat_info.clone()); - let rs = self.puncher.punch(src, peer_punch_info)?; - if rs { + let effective_policies = self.puncher.punch(src, peer_punch_info)?; + if let Some(effective_policies) = effective_policies { + // 回传双方都支持的请求策略,确保发起端也只打当前缺失的路由类型。 + self_punch_info.punch_model = effective_policies; let bytes_mut = self_punch_info.encode(); let mut net_packet = NetPacket::new(TransmissionBytes::zeroed_size( HEAD_LENGTH + bytes_mut.len(), @@ -642,7 +670,6 @@ impl ServerTurnInboundHandler { log::debug!("ignore configured turn target PunchStart2 from {src}"); return Ok(()); } - self.punch_backoff.record(src); // 对方回复开始打洞 let peer_punch_info = PunchInfo::from_slice(net_packet.payload())?; self.update_peer_nat_info(src, peer_punch_info.nat_info.clone()); diff --git a/vnt-core/src/tunnel_core/server/transport/config.rs b/vnt-core/src/tunnel_core/server/transport/config.rs index e834abe0..c805debe 100644 --- a/vnt-core/src/tunnel_core/server/transport/config.rs +++ b/vnt-core/src/tunnel_core/server/transport/config.rs @@ -39,6 +39,7 @@ pub(crate) struct ConnectRegConfig { pub key_sign: Option, pub ip_variable: bool, pub advertised_subnets: Arc>, + pub allow_ikev2: bool, pub default_interface: Option, } #[derive(Debug, Clone)] @@ -131,6 +132,7 @@ impl ConnectRegConfig { server_id, registration_mode, advertised_subnets: self.advertised_subnets.as_ref().clone(), + allow_ikev2: self.allow_ikev2, } } /// 解析出全部候选服务器地址:动态地址(DNS TXT 记录或 http(s) 接口返回的 @@ -263,6 +265,7 @@ mod tests { key_sign: None, ip_variable: true, advertised_subnets: Arc::new(Vec::new()), + allow_ikev2: false, default_interface: None, }; diff --git a/vnt-desktop/package.json b/vnt-desktop/package.json index 5f58e4c7..6605dbfe 100644 --- a/vnt-desktop/package.json +++ b/vnt-desktop/package.json @@ -2,7 +2,7 @@ "name": "vnt-desktop", "private": true, "license": "Apache-2.0", - "version": "2.0.6", + "version": "2.0.7", "type": "module", "scripts": { "dev": "vite", diff --git a/vnt-desktop/src-tauri/Cargo.toml b/vnt-desktop/src-tauri/Cargo.toml index 5b2a7140..7fac4e9a 100644 --- a/vnt-desktop/src-tauri/Cargo.toml +++ b/vnt-desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vnt-desktop" -version = "2.0.6" +version = "2.0.7" description = "VNT virtual network desktop client" edition = "2024" license = "Apache-2.0" diff --git a/vnt-desktop/src-tauri/tauri.conf.json b/vnt-desktop/src-tauri/tauri.conf.json index cd3cb55a..d9d081b4 100644 --- a/vnt-desktop/src-tauri/tauri.conf.json +++ b/vnt-desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "VNT Desktop", - "version": "2.0.6", + "version": "2.0.7", "identifier": "com.vnt.desktop", "build": { "beforeDevCommand": "pnpm prepare:sidecar:dev && pnpm dev", diff --git a/vnt-ipc/Cargo.toml b/vnt-ipc/Cargo.toml index eee1ff8c..2397b6af 100644 --- a/vnt-ipc/Cargo.toml +++ b/vnt-ipc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vnt-ipc" -version = "2.0.6" +version = "2.0.7" edition = "2024" license = "Apache-2.0" diff --git a/vnt-ipc/proto/local_ipc.proto b/vnt-ipc/proto/local_ipc.proto index 266e7306..67ec1620 100644 --- a/vnt-ipc/proto/local_ipc.proto +++ b/vnt-ipc/proto/local_ipc.proto @@ -56,6 +56,7 @@ message ServerInfo{ message ClientInfoList{ repeated ClientInfoItem items = 1; + bool allow_ikev2 = 2; } message ClientInfoItem{ @@ -68,6 +69,7 @@ message ClientInfoItem{ optional uint32 rtt = 7; bool key_equal = 8; optional PacketLoss packet_loss = 9; + string client_type = 10; } message PacketLoss{ @@ -100,4 +102,4 @@ message ClientIpItem{ bool online = 2; bool is_direct = 3; optional uint32 rtt = 4; -} \ No newline at end of file +} diff --git a/vnt-ipc/src/client.rs b/vnt-ipc/src/client.rs index 5b062383..d705ac20 100644 --- a/vnt-ipc/src/client.rs +++ b/vnt-ipc/src/client.rs @@ -191,13 +191,15 @@ fn print_client_ip_list(list: ClientIpList) -> anyhow::Result<()> { fn print_client_list(list: ClientInfoList) -> anyhow::Result<()> { println!("\n--- Client List ({}) ---", list.items.len()); + let show_ikev2_warning = has_unreachable_ikev2_client(&list); + let table = list .items .iter() .map(|item| { let key_equal: bool = item.key_equal; let mut ip_str = Ipv4Addr::from(item.ip).to_string(); - if !key_equal { + if item.client_type != "IKEV2" && !key_equal { ip_str.push_str("(Key Mismatch)"); } let loss_str = item @@ -209,6 +211,7 @@ fn print_client_list(list: ClientInfoList) -> anyhow::Result<()> { vec![ ip_str.cell(), item.name.clone().cell(), + item.client_type.clone().cell(), item.version.clone().cell(), item.online.to_string().cell(), item.is_direct.to_string().cell(), @@ -221,6 +224,7 @@ fn print_client_list(list: ClientInfoList) -> anyhow::Result<()> { .title(vec![ "IP".cell().bold(true), "Name".cell().bold(true), + "Type".cell().bold(true), "Version".cell().bold(true), "Online".cell().bold(true), "P2P".cell().bold(true), @@ -230,10 +234,29 @@ fn print_client_list(list: ClientInfoList) -> anyhow::Result<()> { ]); print_stdout(table)?; + if show_ikev2_warning { + println!( + "\n{}", + style( + "WARNING: Online IKEv2 clients detected, but allow_ikev2 / --allow-ikev2 is not enabled. These clients are unreachable." + ) + .yellow() + .bright() + .bold() + ); + } println!("\n"); Ok(()) } +fn has_unreachable_ikev2_client(list: &ClientInfoList) -> bool { + !list.allow_ikev2 + && list + .items + .iter() + .any(|item| item.online && item.client_type == "IKEV2") +} + pub fn print_route_list(route_list: ClientRouteList) -> anyhow::Result<()> { let total_routes: usize = route_list .items @@ -287,3 +310,40 @@ pub fn ts_to_string(ts_secs: i64) -> String { let format = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"); dt_local.format(&format).unwrap_or_default() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::ClientInfoItem; + + fn ikev2_client(online: bool) -> ClientInfoItem { + ClientInfoItem { + online, + client_type: "IKEV2".to_string(), + ..Default::default() + } + } + + #[test] + fn warns_for_online_ikev2_client_when_disabled() { + let list = ClientInfoList { + items: vec![ikev2_client(true)], + allow_ikev2: false, + }; + assert!(has_unreachable_ikev2_client(&list)); + } + + #[test] + fn does_not_warn_when_ikev2_is_allowed_or_offline() { + let allowed = ClientInfoList { + items: vec![ikev2_client(true)], + allow_ikev2: true, + }; + let offline = ClientInfoList { + items: vec![ikev2_client(false)], + allow_ikev2: false, + }; + assert!(!has_unreachable_ikev2_client(&allowed)); + assert!(!has_unreachable_ikev2_client(&offline)); + } +} diff --git a/vnt-ipc/src/server.rs b/vnt-ipc/src/server.rs index 806ee3a0..bc81f4f0 100644 --- a/vnt-ipc/src/server.rs +++ b/vnt-ipc/src/server.rs @@ -59,7 +59,9 @@ async fn handle_connection(stream: TcpStream, vnt_api: VntApi) -> anyhow::Result } IpcCmd::ClientList(_) => { let client_list = vnt_api.server_rpc().client_list().await?; - let key_sign = vnt_api.get_config().and_then(|config| config.key_sign()); + let config = vnt_api.get_config(); + let key_sign = config.as_ref().and_then(|config| config.key_sign()); + let allow_ikev2 = config.as_ref().is_some_and(|config| config.allow_ikev2); let items = client_list .list .into_iter() @@ -85,10 +87,12 @@ async fn handle_connection(stream: TcpStream, vnt_api: VntApi) -> anyhow::Result rtt, key_equal: key_sign == v.key_sign, packet_loss, + client_type: if v.client_type == 1 { "IKEV2" } else { "VNT" } + .to_string(), } }) .collect(); - ResponsePayload::ClientList(ClientInfoList { items }) + ResponsePayload::ClientList(ClientInfoList { items, allow_ikev2 }) } IpcCmd::AllRoute(_) => { let route_list = all_route(&vnt_api); diff --git a/vnt-jni/Cargo.toml b/vnt-jni/Cargo.toml index 927a4299..694546e4 100644 --- a/vnt-jni/Cargo.toml +++ b/vnt-jni/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vnt-jni" -version = "2.0.6" +version = "2.0.7" edition = "2024" license = "Apache-2.0" diff --git a/vnt-jni/java_example/com/vnt/VntConfig.java b/vnt-jni/java_example/com/vnt/VntConfig.java index bcca0499..d02fc9a9 100644 --- a/vnt-jni/java_example/com/vnt/VntConfig.java +++ b/vnt-jni/java_example/com/vnt/VntConfig.java @@ -15,6 +15,7 @@ public class VntConfig { private final List servers; private final List peerAddresses; private final List turnRules; + private final List punchModelRules; private final List inputRoutes; private final List subnetMappings; private final List outputRoutes; @@ -29,6 +30,7 @@ public class VntConfig { private final String certMode; private final boolean noPunch; private final boolean noBroadcast; + private final boolean allowIkev2; private final boolean compress; private final boolean rtx; private final boolean fec; @@ -44,6 +46,7 @@ private VntConfig(Builder builder) { this.servers = builder.servers; this.peerAddresses = builder.peerAddresses; this.turnRules = builder.turnRules; + this.punchModelRules = builder.punchModelRules; this.inputRoutes = builder.inputRoutes; this.subnetMappings = builder.subnetMappings; this.outputRoutes = builder.outputRoutes; @@ -58,6 +61,7 @@ private VntConfig(Builder builder) { this.certMode = builder.certMode; this.noPunch = builder.noPunch; this.noBroadcast = builder.noBroadcast; + this.allowIkev2 = builder.allowIkev2; this.compress = builder.compress; this.rtx = builder.rtx; this.fec = builder.fec; @@ -99,6 +103,7 @@ String toJson() { } json.put("turn", turnArray); } + putStringArray(json, "punch_model", punchModelRules); putStringArray(json, "input", inputRoutes); putStringArray(json, "subnet_mapping", subnetMappings); @@ -117,6 +122,7 @@ String toJson() { // 布尔值 json.put("no_punch", noPunch); json.put("no_broadcast", noBroadcast); + json.put("allow_ikev2", allowIkev2); json.put("compress", compress); json.put("rtx", rtx); json.put("fec", fec); @@ -167,6 +173,7 @@ public static class Builder { private List servers = new ArrayList<>(); private List peerAddresses = new ArrayList<>(); private List turnRules = new ArrayList<>(); + private List punchModelRules = new ArrayList<>(); private List inputRoutes = new ArrayList<>(); private List subnetMappings = new ArrayList<>(); private List outputRoutes = new ArrayList<>(); @@ -181,6 +188,7 @@ public static class Builder { private String certMode; private boolean noPunch = false; private boolean noBroadcast = false; + private boolean allowIkev2 = false; private boolean compress = false; private boolean rtx = false; private boolean fec = false; @@ -219,6 +227,15 @@ public Builder addTurnRule(String turnRule) { return this; } + /** + * 添加目标节点打洞方式规则(可选)。 + * @param rule 格式:目标虚拟IP或CIDR,IPv4Tcp[,IPv4Udp,IPv6Tcp,IPv6Udp] + */ + public Builder addPunchModelRule(String rule) { + this.punchModelRules.add(rule); + return this; + } + /** 添加点对网入口路由,格式:映射CIDR,出口节点虚拟IP。 */ public Builder addInputRoute(String route) { this.inputRoutes.add(route); @@ -325,6 +342,14 @@ public Builder setNoBroadcast(boolean noBroadcast) { return this; } + /** + * 允许与服务端接入的 IKEv2/IPsec 客户端通信(默认false) + */ + public Builder setAllowIkev2(boolean allowIkev2) { + this.allowIkev2 = allowIkev2; + return this; + } + /** * 启用压缩(默认false) */ diff --git a/vnt-jni/src/lib.rs b/vnt-jni/src/lib.rs index 9d51cd7f..f51966e0 100644 --- a/vnt-jni/src/lib.rs +++ b/vnt-jni/src/lib.rs @@ -12,7 +12,7 @@ use std::os::fd::{FromRawFd, OwnedFd}; use std::sync::Arc; use tokio::runtime::Runtime; use vnt_core::api::VntApi; -use vnt_core::context::config::{Config, DeviceMode, PeerAddress, TurnRule}; +use vnt_core::context::config::{Config, DeviceMode, PeerAddress, PunchRule, TurnRule}; use vnt_core::core::{NetworkManager, RegisterResponse}; use vnt_core::nat::{NetInput, SubnetMapping}; use vnt_core::port_mapping::PortMapping; @@ -565,7 +565,7 @@ pub extern "system" fn Java_com_vnt_VntApi_nativeGetClientList<'local>( let local_clients: HashMap<_, _> = api .client_ips() .into_iter() - .map(|client| (client.ip, client.online)) + .map(|client| (client.ip, (client.online, client.client_type))) .collect(); let server_clients: HashMap<_, _> = runtime .block_on(api.server_rpc().client_list()) @@ -601,7 +601,7 @@ pub extern "system" fn Java_com_vnt_VntApi_nativeGetClientList<'local>( .map(|route| route.route_key().protocol().to_string()); let route_metric = route.as_ref().map(|route| route.metric()); let rtt = route.as_ref().map(|route| route.rtt()); - let online = local_clients.get(&ip).copied().unwrap_or(false) + let online = local_clients.get(&ip).map(|value| value.0).unwrap_or(false) || server_client.map(|client| client.online).unwrap_or(false) || has_route; let packet_loss = api.packet_loss_info(&ip).map(|info| { @@ -621,13 +621,24 @@ pub extern "system" fn Java_com_vnt_VntApi_nativeGetClientList<'local>( "ip": ip.to_string(), "name": server_client.map(|client| client.name.as_str()).unwrap_or(""), "version": server_client.map(|client| client.version.as_str()).unwrap_or(""), + "client_type": server_client + .map(|client| if client.client_type == 1 { "IKEV2" } else { "VNT" }) + .or_else(|| local_clients.get(&ip).map(|value| match value.1 { + vnt_core::protocol::control_message::ClientType::Ikev2 => "IKEV2", + vnt_core::protocol::control_message::ClientType::Vnt => "VNT", + })) + .unwrap_or("VNT"), "online": online, "direct": direct, "route_protocol": route_protocol, "route_metric": route_metric, "rtt": rtt, "key_equal": server_client - .map(|client| encryption_state(local_key.as_deref(), client.key_sign.as_deref())) + .map(|client| if client.client_type == 1 { + 0 + } else { + encryption_state(local_key.as_deref(), client.key_sign.as_deref()) + }) .unwrap_or(0), "packet_loss": packet_loss, "traffic": traffic, @@ -1027,6 +1038,8 @@ fn parse_config_from_json(json_str: &str) -> anyhow::Result { peer_address: Vec, #[serde(default)] turn: Vec, + #[serde(default)] + punch_model: Vec, network_code: String, #[serde(default)] device_id: Option, @@ -1047,6 +1060,8 @@ fn parse_config_from_json(json_str: &str) -> anyhow::Result { #[serde(default)] no_broadcast: bool, #[serde(default)] + allow_ikev2: bool, + #[serde(default)] compress: bool, #[serde(default)] rtx: bool, @@ -1111,6 +1126,16 @@ fn parse_config_from_json(json_str: &str) -> anyhow::Result { }) .collect::>()?; + let punch_model: Vec = cfg + .punch_model + .iter() + .map(|value| { + value + .parse() + .map_err(|error| anyhow::anyhow!("invalid punch_model rule '{}': {}", value, error)) + }) + .collect::>()?; + let port_mapping: Vec = cfg .port_mapping .iter() @@ -1158,10 +1183,12 @@ fn parse_config_from_json(json_str: &str) -> anyhow::Result { server_addr: server_addrs, peer_address, turn, + punch_model, network_code: cfg.network_code, ip: cfg.ip, no_punch: cfg.no_punch, no_broadcast: cfg.no_broadcast, + allow_ikev2: cfg.allow_ikev2, rtx: cfg.rtx, compress: cfg.compress, device_id, @@ -1477,6 +1504,24 @@ mod tests { assert_eq!(config.turn[1].to_string(), "10.26.1.9,10.26.0.3"); } + #[test] + fn parses_punch_model_rules_from_json() { + let config = parse_config_from_json( + r#"{ + "server":["quic://127.0.0.1:29872"], + "network_code":"test-net", + "punch_model":["10.26.0.2,IPv4Udp","10.26.1.0/24,IPv4Tcp,IPv6Udp"] + }"#, + ) + .unwrap(); + assert_eq!(config.punch_model.len(), 2); + assert_eq!(config.punch_model[0].to_string(), "10.26.0.2,IPv4Udp"); + assert_eq!( + config.punch_model[1].to_string(), + "10.26.1.0/24,IPv4Tcp,IPv6Udp" + ); + } + #[test] fn parses_exit_subnet_mapping_from_json() { let mut config = parse_config_from_json( diff --git a/vnt-web/Cargo.toml b/vnt-web/Cargo.toml index ea8170c4..31dbff69 100644 --- a/vnt-web/Cargo.toml +++ b/vnt-web/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vnt-web" -version = "2.0.6" +version = "2.0.7" edition = "2024" license = "Apache-2.0" diff --git a/vnt-web/src/service_http.rs b/vnt-web/src/service_http.rs index 3c5d62d7..e5d9e29d 100644 --- a/vnt-web/src/service_http.rs +++ b/vnt-web/src/service_http.rs @@ -28,7 +28,9 @@ use tokio_util::sync::CancellationToken; use tower::ServiceExt; use tower_http::cors::{Any, CorsLayer}; use vnt_core::api::VntApi; -use vnt_core::context::config::{Config as CoreConfig, DeviceMode, PeerAddress, TurnRule}; +use vnt_core::context::config::{ + Config as CoreConfig, DeviceMode, PeerAddress, PunchRule, TurnRule, +}; use vnt_core::core::{DEFAULT_MTU, NetworkManager, RegisterResponse}; use vnt_core::nat::{NetInput, SubnetMapping}; use vnt_core::port_mapping::PortMapping; @@ -257,6 +259,8 @@ pub struct StartConfig { pub peer_address: Vec, #[serde(default)] pub turn: Vec, + #[serde(default)] + pub punch_model: Vec, pub cert_mode: Option, pub network_code: String, pub device_id: Option, @@ -270,6 +274,8 @@ pub struct StartConfig { #[serde(default)] pub no_broadcast: bool, #[serde(default)] + pub allow_ikev2: bool, + #[serde(default)] pub compress: bool, #[serde(default)] pub rtx: bool, @@ -353,6 +359,7 @@ struct HttpAppInfo { compress: Option, encrypt: Option, rtx: Option, + allow_ikev2: bool, input: Vec, output: Vec, automatic_input: Vec, @@ -375,6 +382,7 @@ struct HttpClientItem { online: bool, route: Option, version: String, + client_type: String, last_connected_time: i64, key_equal: i32, nat_info: Option, @@ -1239,6 +1247,7 @@ async fn get_info( compress: config.as_ref().map(|v| v.compress), encrypt: config.as_ref().map(|v| v.password.is_some()), rtx: config.as_ref().map(|v| v.rtx), + allow_ikev2: config.as_ref().is_some_and(|v| v.allow_ikev2), input: config.as_ref().map(|v| v.input.clone()).unwrap_or_default(), output: config .as_ref() @@ -1420,6 +1429,16 @@ fn convert_config(cfg: StartConfig) -> anyhow::Result { }) .collect::>()?; + let punch_model: Vec = cfg + .punch_model + .iter() + .map(|value| { + value + .parse() + .map_err(|error| anyhow!("invalid punch_model rule '{}': {}", value, error)) + }) + .collect::>()?; + let port_mapping: Vec = cfg .port_mapping .iter() @@ -1464,10 +1483,12 @@ fn convert_config(cfg: StartConfig) -> anyhow::Result { server_addr: server_addrs, peer_address, turn, + punch_model, network_code: cfg.network_code, ip: cfg.ip, no_punch: cfg.no_punch, no_broadcast: cfg.no_broadcast, + allow_ikev2: cfg.allow_ikev2, rtx: cfg.rtx, compress: cfg.compress, device_id, @@ -1596,6 +1617,11 @@ async fn get_peers( online: v.online || has_route, route, version: String::new(), + client_type: match v.client_type { + vnt_core::protocol::control_message::ClientType::Ikev2 => "IKEV2", + vnt_core::protocol::control_message::ClientType::Vnt => "VNT", + } + .to_string(), last_connected_time: 0, key_equal: 0, nat_info: build_nat_info(&ip), @@ -1613,6 +1639,7 @@ async fn get_peers( let route = build_route(&ip); // 如果有路由,说明设备在线(可以直接通信) let has_route = route.is_some(); + let client_type = if v.client_type == 1 { "IKEV2" } else { "VNT" }; merged.insert( ip, HttpClientItem { @@ -1621,8 +1648,13 @@ async fn get_peers( online: v.online || has_route, route, version: v.version, + client_type: client_type.to_string(), last_connected_time: v.last_connected_time, - key_equal: calc_key_equal(&v.key_sign), + key_equal: if v.client_type == 1 { + 0 + } else { + calc_key_equal(&v.key_sign) + }, nat_info: build_nat_info(&ip), packet_loss: build_packet_loss(&ip), traffic: build_traffic(&ip), @@ -1797,6 +1829,7 @@ mod tests { server: Vec::new(), peer_address: Vec::new(), turn: Vec::new(), + punch_model: Vec::new(), cert_mode: None, network_code: "test".to_string(), device_id: Some("device-a".to_string()), @@ -1807,6 +1840,7 @@ mod tests { password: None, no_punch: false, no_broadcast: false, + allow_ikev2: false, compress: false, rtx: false, fec: false, @@ -1874,6 +1908,22 @@ network_code = "test" assert_eq!(core.turn[1].to_string(), "10.26.1.9,10.26.0.3"); } + #[test] + fn test_convert_config_keeps_punch_model_rules() { + let mut config = new_test_config(); + config.punch_model = vec![ + "10.26.0.2,IPv4Udp".to_string(), + "10.26.1.0/24,IPv4Tcp,IPv6Udp".to_string(), + ]; + let core = convert_config(config).unwrap(); + assert_eq!(core.punch_model.len(), 2); + assert_eq!(core.punch_model[0].to_string(), "10.26.0.2,IPv4Udp"); + assert_eq!( + core.punch_model[1].to_string(), + "10.26.1.0/24,IPv4Tcp,IPv6Udp" + ); + } + #[test] fn test_convert_config_keeps_exit_subnet_mapping() { let mut config = new_test_config(); diff --git a/vnt-web/ui/package.json b/vnt-web/ui/package.json index a395034d..6932a599 100644 --- a/vnt-web/ui/package.json +++ b/vnt-web/ui/package.json @@ -2,7 +2,7 @@ "name": "vnt-web-ui", "private": true, "license": "Apache-2.0", - "version": "2.0.6", + "version": "2.0.7", "type": "module", "scripts": { "dev": "vite", diff --git a/vnt-web/ui/src/utils/configHelp.js b/vnt-web/ui/src/utils/configHelp.js index f58a62ad..d5cd4fb9 100644 --- a/vnt-web/ui/src/utils/configHelp.js +++ b/vnt-web/ui/src/utils/configHelp.js @@ -38,6 +38,14 @@ export const configHelp = { format: "目标虚拟IP或CIDR,中转节点虚拟IP", example: "10.26.0.0/24,10.26.0.2", }, + punch_model: { + param: "punch_model", + summary: "按目标虚拟 IP 或网段限制允许使用的 P2P 打洞方式。", + usage: "双方会交换各自规则,实际只尝试双方允许集合的交集。重叠规则按最长前缀匹配,相同目标的多条规则会合并;未命中时允许全部方式。", + format: "目标虚拟IP或CIDR,IPv4Tcp|IPv4Udp|IPv6Tcp|IPv6Udp(可填写多种,以逗号分隔)", + example: "10.26.1.0/24,IPv4Tcp,IPv4Udp", + notes: ["模式名称不区分大小写,也支持 ipv4-tcp 等连字符写法。", "指定中转规则和禁用打洞仍具有更高优先级。"], + }, ip: { param: "ip", summary: "请求一个固定的本机虚拟 IPv4 地址。", @@ -89,6 +97,13 @@ export const configHelp = { usage: "可减少发现协议和广播风暴产生的流量;依赖局域网发现、组播服务或某些游戏联机时不要开启。", format: "开关;默认关闭,即允许转发。", }, + allow_ikev2: { + param: "allow_ikev2 / --allow-ikev2", + summary: "允许本节点与接入同一虚拟网络的 IKEv2/IPsec 客户端互通。", + usage: "开启后,本节点信任已认证 VNT 服务端注入的 IKEv2 明文 IPv4 包,并把发往 IKEv2 类型地址的流量固定交给服务端中继。", + format: "开关;默认关闭。", + notes: ["此路径不使用 VNT 节点间 password 加密,应只连接受信任的服务端。"], + }, password: { param: "password", summary: "对虚拟网络中的节点间数据进行加密。", diff --git a/vnt-web/ui/src/utils/networkQr.js b/vnt-web/ui/src/utils/networkQr.js index 35430322..c7e5e4a4 100644 --- a/vnt-web/ui/src/utils/networkQr.js +++ b/vnt-web/ui/src/utils/networkQr.js @@ -19,5 +19,6 @@ export const buildNetworkQrPayload = (form) => { server: servers, mtu, password: String(form.password || ""), + ...(form.allow_ikev2 ? { allow_ikev2: true } : {}), }; }; diff --git a/vnt-web/ui/src/utils/toml.js b/vnt-web/ui/src/utils/toml.js index cdd7b42e..983c3032 100644 --- a/vnt-web/ui/src/utils/toml.js +++ b/vnt-web/ui/src/utils/toml.js @@ -6,6 +6,7 @@ export const emptyFormData = () => ({ server: [""], peer_address: [], turn: [], + punch_model: [], ip: "", mtu: null, rtx: false, @@ -13,6 +14,7 @@ export const emptyFormData = () => ({ compress: false, no_punch: false, no_broadcast: false, + allow_ikev2: false, input: [], subnet_mapping: [], output: [], @@ -67,6 +69,12 @@ export const parseTomlToForm = (toml) => { const items = match[1].match(/"([^"]*)"/g); if (items) data.turn = items.map((s) => s.replace(/"/g, "")); } + } else if (trimmed.match(/^punch_model\s*=/)) { + const match = trimmed.match(/punch_model\s*=\s*\[(.*)\]/); + if (match) { + const items = match[1].match(/"([^"]*)"/g); + if (items) data.punch_model = items.map((s) => s.replace(/"/g, "")); + } } else if (trimmed.includes("ip =")) { const match = trimmed.match(/ip\s*=\s*"([^"]*)"/); if (match) data.ip = match[1]; @@ -83,6 +91,8 @@ export const parseTomlToForm = (toml) => { data.no_punch = trimmed.includes("true"); } else if (trimmed.match(/^no_broadcast\s*=/)) { data.no_broadcast = trimmed.includes("true"); + } else if (trimmed.match(/^allow_ikev2\s*=/)) { + data.allow_ikev2 = trimmed.includes("true"); } else if (trimmed.startsWith("input")) { const match = trimmed.match(/input\s*=\s*\[(.*)\]/); if (match) { @@ -201,6 +211,12 @@ export const formToToml = (formData) => { toml += `turn = [${turnRules.map((s) => `"${s}"`).join(", ")}]\n`; } + const punchModelRules = formData.punch_model.filter((s) => s.trim()); + if (punchModelRules.length > 0) { + toml += "\n# 按目标虚拟 IP 或网段限制 P2P 打洞方式;双方实际使用允许集合的交集\n"; + toml += `punch_model = [${punchModelRules.map((s) => `"${s}"`).join(", ")}]\n`; + } + if (formData.ip) { toml += "\n# 自定义虚拟 IP (可选)\n"; toml += `ip = "${formData.ip}"\n`; @@ -228,6 +244,11 @@ export const formToToml = (formData) => { toml += "no_broadcast = true\n"; } + if (formData.allow_ikev2) { + toml += "\n# 允许与 IKEv2 客户端通信,并信任服务端注入的数据\n"; + toml += "allow_ikev2 = true\n"; + } + if (formData.compress) { toml += "\n# 是否启用 LZ4 压缩 (默认 false)\n"; toml += "compress = true\n"; @@ -358,6 +379,9 @@ server = ["quic://1.2.3.4:29872"] # 命中目标不参与 P2P 打洞 # turn = ["10.26.0.0/24,10.26.0.2", "10.26.1.9,10.26.0.3"] +# 按目标虚拟 IP 或网段限制 P2P 打洞方式;可选 IPv4Tcp、IPv4Udp、IPv6Tcp、IPv6Udp +# punch_model = ["10.26.0.2,IPv4Udp", "10.26.1.0/24,IPv4Tcp,IPv4Udp"] + # ===简单使用以下参数可以不动=== # 自定义虚拟 IP (可选) @@ -377,6 +401,9 @@ server = ["quic://1.2.3.4:29872"] # 是否关闭 IPv4 广播和组播转发 (默认 false,即开启) # no_broadcast = false +# 是否允许与 IKEv2 客户端通信,并信任服务端注入的 IKEv2 明文 IPv4 包 +# allow_ikev2 = false + # 是否启用 LZ4 压缩 (默认 false,设置为true时开启) # compress = false diff --git a/vnt-web/ui/src/views/AboutView.vue b/vnt-web/ui/src/views/AboutView.vue index 0fdc291d..73498971 100644 --- a/vnt-web/ui/src/views/AboutView.vue +++ b/vnt-web/ui/src/views/AboutView.vue @@ -19,7 +19,7 @@ const message = ref(""); const downloaded = ref(0); const contentLength = ref(0); -const currentVersion = computed(() => app.version || "2.0.6"); +const currentVersion = computed(() => app.version || "2.0.7"); const progress = computed(() => { if (!contentLength.value) return 0; return Math.min(100, Math.round((downloaded.value / contentLength.value) * 100)); diff --git a/vnt-web/ui/src/views/ConfigEditor.vue b/vnt-web/ui/src/views/ConfigEditor.vue index acf8a32f..34740522 100644 --- a/vnt-web/ui/src/views/ConfigEditor.vue +++ b/vnt-web/ui/src/views/ConfigEditor.vue @@ -39,6 +39,42 @@ const deviceModeOptions = [ ]; const isWindows = /Windows/i.test(globalThis.navigator?.userAgent || ""); +// 分区折叠状态:子网路由、端口映射、STUN 属于高级配置,默认折叠 +const DEFAULT_SECTIONS = { + basic: true, + connect: true, + network: true, + transport: true, + security: true, + subnet: false, + portmap: false, + device: true, + stun: false, +}; +const sectionExpanded = ref({ ...DEFAULT_SECTIONS }); +const toggleSection = (key) => { + sectionExpanded.value[key] = !sectionExpanded.value[key]; +}; +// 打开编辑器时重置折叠状态;已有数据的分区自动展开,避免配置被藏起来 +const resetSections = (data) => { + sectionExpanded.value = { ...DEFAULT_SECTIONS }; + if ( + data.input.length || + data.output.length || + data.subnet_mapping.length || + data.auto_sync_subnet || + data.no_nat + ) { + sectionExpanded.value.subnet = true; + } + if (data.port_mapping.length || data.allow_mapping) { + sectionExpanded.value.portmap = true; + } + if (data.udp_stun.length || data.tcp_stun.length) { + sectionExpanded.value.stun = true; + } +}; + // 打开时加载内容 watch( () => props.show, @@ -57,6 +93,7 @@ watch( originalToml.value = data; // 保存原始TOML isParsingToml.value = true; formData.value = parseTomlToForm(data); + resetSections(formData.value); nextTick(() => { isParsingToml.value = false; }); @@ -68,6 +105,7 @@ watch( // 新建配置,初始化表单 originalToml.value = ""; formData.value = emptyFormData(); + resetSections(formData.value); editorContent.value = NEW_CONFIG_TEMPLATE; } }, @@ -163,7 +201,9 @@ const removeBtnClass = "shrink-0 rounded-lg bg-red-50 px-3 py-2 text-red-500 transition-colors hover:bg-red-100 dark:bg-red-900/20 dark:text-red-500 dark:hover:bg-red-900/40"; const addBtnClass = "flex w-full items-center justify-center gap-1 rounded-lg bg-slate-100 px-3 py-2 text-sm text-slate-600 transition-colors hover:bg-slate-200 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"; -const sectionTitleClass = "text-md mb-4 flex items-center font-bold text-slate-900 dark:text-white"; +const sectionTitleClass = "text-md flex items-center font-bold text-slate-900 dark:text-white"; +const sectionChevronClass = (expanded) => + `h-5 w-5 shrink-0 text-slate-400 transition-transform duration-200 ${expanded ? "rotate-180" : ""}`;