diff --git a/.gitignore b/.gitignore index 6a270837..503be48e 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ easytier-gui/src-tauri/*.sys # contrib go.sum +.test-env/ diff --git a/CONTEXT.md b/CONTEXT.md index ec78a17c..88976145 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -103,3 +103,22 @@ The Browser Adapter is an outbound-only EasyTier instance with a smoltcp TCP data plane. The Cloudflare Adapter is an inbound-only relay hosted by one named Durable Object. Their public configuration exposes only capabilities each Host can execute; guest ABI details and serialized TOML remain internal. + +## Web configuration consumers + +Central network management and an external Console are alternative consumers +of `ClientManager`. An external webhook selects Console mode; otherwise the +central service registers devices through an in-process webhook handler and +publishes each device's complete compiled configuration through the existing +Full reconcile API. `ClientManager` owns persistence, offline replay, and +runtime reconciliation without knowing central network business state. + +The central service owns network intent, device registration and bans, and +Gateway runtimes. Central HTTP mutations and direct public configuration writes +share its mutation lock so ownership checks cannot race with enrollment. +Internal Console APIs retain their upstream behavior and central routes and +publication workers are disabled in Console mode. + +Device deletion uses the upstream disconnect semantics: an already in-flight +validation may register the device again. A persisted ban rejects subsequent +validations. Deletion does not introduce device generations or session fences. diff --git a/Cargo.lock b/Cargo.lock index db9dfec8..2ac9903c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2926,16 +2926,20 @@ dependencies = [ "axum-messages", "base64 0.23.1", "chrono", + "cidr", "clap", "dashmap", "easytier", "easytier-core", + "easytier-proto", + "futures", "image", "imageproc", "maxminddb", "mimalloc", "openidconnect", "password-auth", + "prost 0.14.4", "rand 0.8.8", "reqwest 0.13.5", "rust-embed", @@ -2944,6 +2948,7 @@ dependencies = [ "sea-orm-migration", "serde", "serde_json", + "sha2 0.11.0", "sqlx", "subtle", "sys-locale", @@ -2951,12 +2956,14 @@ dependencies = [ "thunk-rs", "tokio", "tokio-util", + "tower", "tower-http 0.7.1", "tower-sessions", "tower-sessions-sqlx-store", "tracing", "url", "uuid", + "x25519-dalek 2.0.1", ] [[package]] diff --git a/docs/central-network-e2e.md b/docs/central-network-e2e.md new file mode 100644 index 00000000..fb5d61d7 --- /dev/null +++ b/docs/central-network-e2e.md @@ -0,0 +1,209 @@ +# 中央网络管理 E2E 验证记录 + +## 范围与执行约定 + +- 被测 PR:`feat/web-central-console-v2`;初始代码提交:后端 `5f66627d`,前端 `57a15acb`。 +- 测试日期:2026-09-30。先建立本清单,再逐项执行和回填结果。 +- 主验证链路:真实浏览器 / HTTP API → 独立 SQLite 数据库 → Web 中央服务 → Docker 内真实 Core → 配置文件 / peer / route / 实际数据包。 +- 数据面测试使用隔离 network namespace 和测试专属进程;不修改现有业务节点。 +- `通过` 必须有实际执行证据;单元测试、mock 页面测试不能冒充真实 E2E。覆盖不完整标记 `部分通过`,环境障碍标记 `阻塞`,缺陷标记 `失败`,未执行保持 `待测`。 +- 首轮只记录缺陷;用户随后批准修复,修复后的专项和受影响链路回归见下文,保留首轮失败证据。 +- 本文记录中央网络相关功能;第三方 OIDC/验证码供应商、其他平台原生 GUI 不纳入本轮完整 E2E 承诺。 + +## 环境与复现入口 + +- 宿主机 Web:`cargo build -p easytier-web --features embed`,Docker `rust` 中 Core/CLI:`cargo build -p easytier --bins`;版本 `2.7.0-57a15acb`。 +- `rust` 容器具备 root、TUN、iproute2、原生 wg、ping、Python。每次数据面测试创建独立 bridge 和四个 network namespace,清理时只删除本次资源。未修改容器全局 IP forwarding。 +- 数据面复现:构建后在 `easytier-web/frontend` 执行 `node tests/central-coverage.mjs`。独立 SQLite、随机端口和进程日志保存在 `.test-env/central-e2e-*`。 +- 真实 baseline 使用原 `central-e2e.test.mjs`,本次临时副本仅跳过重复构建并增加截图,结束已删除。 + +## 用例与结果 + +### 访问与运行模式 + +| ID | 测试点 | 步骤 / 预期断言 | 状态 | 结果与证据 | +|---|---|---|---|---| +| A01 | 登录与会话 | 真实浏览器登录,未登录访问中央接口被拒绝,登出后会话失效 | 通过 | 真实 Chromium 双账号登录;匿名与登出后中央接口 401。security results。 | +| A02 | 控制台元信息 | 用户名、配置服务器协议/端口、Gateway 开关/地址与启动参数一致 | 通过 | 真实 console-info 核对用户名、协议/端口及模式;Gateway启用/关闭的 peer_url 与 relay_data 均对照启动参数。 | +| A03 | 中央与 Console 模式隔离 | 外部 webhook 模式不暴露中央网络路由;原有内部 Full/Patch API 可用 | 通过 | 外部 webhook 模式中央 GET 404/POST 405;真实 Core internal Full/Patch、Web 重启与删除 TOML 通过。 | +| A04 | Gateway 启动参数校验 | 无 Gateway 时禁止创建 Gateway 网络;外部 webhook 与 Gateway 同时配置被拒绝 | 通过 | 无 Gateway 建网 400;webhook+Gateway、监听协议不匹配、非法地址启动 exit 2。 | +| A05 | 跨租户隔离 | 另一租户不能读写网络、成员、凭据、ACL、设备,也不能代理其节点 RPC | 通过 | 双租户网络/成员/配置/ACL/凭据/设备/在线代理 RPC 全部隔离;另一网络使用原凭据不能接入。 | + +### 设备管理 + +| ID | 测试点 | 步骤 / 预期断言 | 状态 | 结果与证据 | +|---|---|---|---|---| +| D01 | 设备登记 | 真实 Core 使用登记命令连接,机器 ID、主机名、版本、在线状态可见 | 通过 | 隔离 netns 内三个真实 Core 登记,API 返回机器 ID、版本和在线信息。 | +| D02 | 设备展示 | 搜索、排序、列表/卡片、详情及设备所属网络正确 | 通过 | 独立真实浏览器:两 Core 的搜索、排序、列表/卡片、详情与所属网络;刷新一致。 | +| D03 | 别名 | 保存、清空、超长拒绝;刷新和服务重启后保持 | 通过 | 别名设置/清空/超长拒绝;真实 Web 重启后 Persisted Alias 仍在。 | +| D04 | 离线与重连 | Core 停止后显示离线但保留记录,重连后恢复在线且不重复登记 | 通过 | 真实停止/重启 Core,离线变更在重连后实际生效;设备不重复。 | +| D05 | 删除设备 | 移除设备及其成员关系、运行配置和引用;不封禁时允许重新登记 | 通过 | 真实删除并移除成员;非封禁删除后同 machine ID 重新登记成功。 | +| D06 | 封禁与解封 | 删除并封禁后拒绝重连,封禁列表记录尝试;解封后可重新登记 | 通过 | 删除封禁后重连尝试被记录;解封后真实 Core 重登记且不恢复旧成员。 | + +### 网络生命周期 + +| ID | 测试点 | 步骤 / 预期断言 | 状态 | 结果与证据 | +|---|---|---|---|---| +| N01 | Gateway 网络创建 | 浏览器创建安全 Gateway 网络,真实成员连接到正确 Gateway | 通过 | 真实浏览器建安全 Gateway 网络;三个 TUN 成员实际 ping 互通。 | +| N02 | Manual 网络 | 指定自建真实 peer,配置下发后成功连接 | 通过 | 真实自建 A 作为 Manual peer;B 下发相同 URL 并实际 ping A。 | +| N03 | PublicServer 网络 | 使用隔离自建公开 peer 验证 URL 下发和连接,不依赖公网公共节点 | 通过 | PublicServer 指向隔离自建 A;Core 编译后 peer URL 一致并实际互通。运行配置通用表示为 Manual,不要求保留 UI 模式名。 | +| N04 | Standalone 网络 | 允许创建并下发,不自动配置外部 peer | 通过 | Standalone 保存后真实 Core peer_urls 清空,不自动连外部 peer。 | +| N05 | 基础设置与密钥 | 修改显示名、网络名、网段和网络密钥;成员运行配置随之更新 | 通过 | 显示名/网络名/密钥旋转实际 Core 配置收敛并恢复 ping;网段相关见 R03。 | +| N06 | 安全模式 | 安全/非安全网络均可组网,切换后重新收敛 | 通过 | 安全→非安全→安全均实际收敛,期间分别验证 ping。 | +| N07 | 模式切换 | Gateway 与其他模式切换,旧 Gateway 退出、新配置生效 | 通过 | 完整模式切换及实际 ping 通过;独立 unmanaged probe 验证切 Standalone 后旧 Gateway 断开且新连接被拒,切回 Gateway 恢复。 | +| N08 | 网络删除 | 在线/离线成员配置最终清除,删除最后一个网络不残留 Gateway | 通过 | 最后网络删除后在线/离线成员实例与 TOML 清除;独立 probe 确认旧 Gateway 断开且新 probe 无法接入,全程 Web 不重启。 | +| N09 | 非法及重复配置 | 空名字、错误 URL/CIDR、重复网络身份被拒绝且不破坏原配置 | 通过 | 修复后 Manual/PublicServer 在空网创建和已有成员更新时均拒绝 bogus://;合法 discovery 协议仍可保存。原缺陷与修复证据见 F01。 | +| N10 | 多网络汇总 | 同一设备加入两个网络,修改/删除其一不影响另一个 | 通过 | 同一 Core 两实例;删除第二网络后第一个实例保留且运行。 | + +### 成员配置 + +| ID | 测试点 | 步骤 / 预期断言 | 状态 | 结果与证据 | +|---|---|---|---|---| +| M01 | 批量添加/移除 | 真实节点加入/离开;重复/不存在/跨租户设备的批量请求不部分提交 | 通过 | 三真实节点添加;重复 ID 409、部分未知/外租户 404,成员与实例均无部分提交;移除另见 D05/N08。 | +| M02 | 主机名与静态 IP | 设置和清空成员主机名、IP,运行节点反映修改;重复/越界 IP 被拒绝 | 通过 | 真实成员主机名/IP 设置与清空、重复/越界拒绝;配置生效后实际 ping 通过。 | +| M03 | 代理子网 | IPv4 子网配置、路由广播及转发一致;当前不支持的 IPv6 输入原子拒绝 | 通过 | IPv4 代理子网真实路由和 ping 通过;IPv6 输入保存前400,数据库及两Core TOML保持。原缺陷见 F02。 | +| M04 | 高级配置 | 读取、保存、重置覆盖配置;运行与持久化一致 | 通过 | 高级 MTU 1300 下发;读取覆盖、清空重置均经真实 API 验证。 | +| M05 | 中央所有权 | 高级配置不能覆盖中央身份、凭据、ACL;直接 REST/RPC 修改中央实例被拒绝 | 通过 | 独立实测伪造/清空 identity、secure_mode、managed_credentials、ACL 均保留中央值且 MTU 生效;真实 TOML/RPC 核对。直接 run/save/delete 与凭据写 RPC 均 409。 | +| M06 | 离线下发 | 设备离线期间修改/删除,重连后恢复最新完整配置 | 通过 | 离线修改主机名 → Web 重启 → Core 重连读到最新值并能 ping;baseline另验证离线删除 TOML。 | +| M07 | 并发操作 | 同时修改不同成员/网络,最终完整配置包含全部成功变更 | 通过 | 同时修改两个真实成员主机名,最终成员配置同时保留两次成功修改。 | +| M08 | 成员编辑竞态 | 迟到的 A 成员配置响应不能污染 B 成员表单和保存目标 | 通过 | route.fetch 取得真实 A 响应后延迟交付;切到 B,迟到响应未覆盖 B,PUT 与持久化目标均正确。 | + +### 凭据及临时节点 + +| ID | 测试点 | 步骤 / 预期断言 | 状态 | 结果与证据 | +|---|---|---|---|---| +| C01 | 凭据生成与展示 | 生成、列表、复制/接入信息、TTL 和复用属性一致,刷新仍可查看 | 通过 | 真实浏览器生成一小时/nonreusable凭据;读取剪贴板核对 secret/CLI/TOML;刷新保持,执行复制命令成功接入。 | +| C02 | 多节点复用 | 两个真实临时 Core 使用同一 reusable 凭据,成员/凭据视图均展示 | 通过 | 真实 baseline 两个独立 Core 使用同一 reusable 凭据;成员和凭据页均展示两个 peer。 | +| C03 | 非复用约束 | 同一 non-reusable 凭据的并发连接受限 | 通过 | 两个真实 Core 共用不可复用凭据,仅一个身份进入管理员路由;停止赢家后另一节点接替。控制连接数量不是路由独占的判断标准。 | +| C04 | 撤销 | 撤销后既有连接断开且不能重新连接,视图清理 | 通过 | 真实 baseline 撤销后两 Core 断开,等待 6 秒仍不能重连,临时节点视图清空。 | +| C05 | 过期 | 短 TTL 凭据到期后连接被清理,不能再次接入 | 通过 | 15 秒 TTL 到期后实际远端路由移除;用同一凭据重启 Core,6.5 秒仍不能重连。 | +| C06 | 临时托管成员 | 临时成员收到专属凭据而非网络密钥,IP/子网与权限一致 | 通过 | 真实 TOML/credential grant 校验;临时 C→B 指定 ACL 允许而 A 被阻断;C 代理子网实际访问按 ACL 区分 A/B。 | +| C07 | 受引用与非法凭据 | 被成员引用的凭据不可单独撤销,非法 TTL/重复 ID 不破坏原状态 | 通过 | 被临时成员引用撤销 409;TTL 0/超一年 400、负值 422、重复 ID 409且原列表保持。 | + +### ACL 策略 + +| ID | 测试点 | 步骤 / 预期断言 | 状态 | 结果与证据 | +|---|---|---|---|---| +| L01 | 编辑流程 | 浏览器新增、编辑、禁用、排序、删除规则,刷新后保持 | 通过 | 真实浏览器 ACL 新建、编辑、禁用、拖拽排序、删除;API 与页面刷新均确认持久化。 | +| L02 | 默认策略 | 真实数据包验证默认 allow/deny,而不只检查保存成功 | 通过 | 实际 ICMP:默认 allow 可达 → deny 阻断 → allow 恢复。 | +| L03 | 成员与组选择 | 真实安全组成员匹配,未授权成员不能借用另一成员权限 | 通过 | 实际安全成员组:指定 A→B 允许,未授权 C→B 被阻断。 | +| L04 | 协议与端口 | 实际 TCP/UDP/ICMP 流量验证允许/拒绝及端口范围 | 通过 | 五个真 echo 端口先确认存活;TCP 18080 通/18082 断;UDP 范围两端 18081、18082 通/18083 断;跨协议和 ICMP 另有实测。 | +| L05 | 子网目标 | 验证成员代理子网规则的编译和真实转发效果 | 通过 | IPv4 代理子网实际 ICMP;仅允许子网时节点 VIP 不可达;临时成员代理权限另独立实测通过。IPv6输入缺陷见 F02。 | +| L06 | 统计 | 产生流量后节点 ACL 统计可查询并对应命中规则 | 通过 | 产生 ping 后真实 ACL RPC stats 返回对应规则 ID、命中包和字节计数。 | +| L07 | 删除引用与非法规则 | 移除成员清理引用;错误选择器/端口被拒绝且旧策略保持 | 通过 | 真实 baseline 移除成员清理 ACL 引用;14 种非法规则全部 400,每次读回旧策略完全相等。 | + +### WireGuard 管理 + +| ID | 测试点 | 步骤 / 预期断言 | 状态 | 结果与证据 | +|---|---|---|---|---| +| W01 | 启用与关闭 | 成员高级配置启用 WireGuard;关闭保留私钥和客户端,再启用恢复 | 通过 | 原生 wg 客户端实际流量:关闭阻断;服务端私钥和客户端保留;重新启用恢复。 | +| W02 | 客户端增删与清空 | 中央成员通过配置保存管理客户端;普通实例热更新同步持久化 | 通过 | 中央完整配置添加/清空;普通 Console 模式 RPC 添加/移除/清空,对照实际 Core、Web SQLite、TOML 与重启恢复。 | +| W03 | 真实 WireGuard 接入 | 原生 wg 客户端完成握手,通过隧道访问真实网络节点 | 通过 | 原生 Linux wg latest-handshakes 非零;通过 Portal 实际 ping 另一真实 Core。 | +| W04 | 地址/权限校验 | 重复/非法地址与未声明组被拒绝,已有有效客户端不受损 | 通过 | 中央配置重复地址/名称、非法IP、网络/广播/节点地址、未知组均400,原配置及流量保持;普通实例验证同前。见 F02。 | +| W05 | 撤销与重启持久化 | 移除客户端后不能访问;保留客户端在节点/服务重启后仍能接入 | 通过 | 原生 wg 清空客户端后实际 ping 被阻断;有效客户端跨 Core 重启可重新握手和访问。 | + +### 运行详情与数据面 + +| ID | 测试点 | 步骤 / 预期断言 | 状态 | 结果与证据 | +|---|---|---|---|---| +| R01 | 节点详情 | 真实节点 peer、route、TOML、版本/错误等展示与 RPC 一致 | 通过 | 真实浏览器 peer/route IP/CIDR、连接计数、版本与 RPC 一致;导出 TOML 与真实 RPC 逐字相同。 | +| R02 | 日志级别 | 读取/设置真实节点日志级别并读回确认 | 通过 | 真实浏览器逐级设置六种日志级别、RPC读回与整页刷新回显均正确;省略字段显示Disabled,中文选项正确。见 F03。 | +| R03 | DHCP 全动态网络 | 两个全 DHCP 节点在无静态 IP 时分配不同地址并能互通 | 通过 | 三个全 DHCP Core 无静态成员,获得三个不同 10.126.126.x 地址并实际互通。 | +| R04 | DHCP 地址保持 | 已有地址节点暂时失去其他 IPv4 广播,不切回默认网段 | 通过 | 另独立验证 A DHCP 从 B 继承非默认10.88.99.1/24;停B、route只剩无IPv4 Gateway后,18秒9次读回均保留原地址。 | +| R05 | Gateway 同端口分流 | 管理连接和普通/Noise 组网连接共用端口,相互不串流 | 通过 | 同一 TCP端口承载三 Core 的配置登记及 Gateway secure/普通组网,切换安全模式后实际 ping 均通过。 | +| R06 | Gateway 中继开关 | 以只经 Gateway 的拓扑确认 relay_data 开关效果 | 通过 | 真实 Gateway 拓扑禁用成员 P2P;relay_data 开→ping通,重启关→ping断,重启开→恢复。 | + +### 恢复与兼容性 + +| ID | 测试点 | 步骤 / 预期断言 | 状态 | 结果与证据 | +|---|---|---|---|---| +| P01 | Web 服务重启 | 网络、成员、凭据、ACL、Gateway 与设备别名恢复,运行配置不丢失 | 通过 | 真实 Web 重启恢复网络/成员/凭据/Gateway/别名及离线新配置;另保持 default deny 的 UDP 范围 ACL 重启,GET 完全相同且实际允许/拒绝均保持。 | +| P02 | Core 重启 | 持久化 TOML 恢复并与最新中央配置收敛 | 通过 | 真实 Core 重启后最新中央配置恢复;WireGuard 保留客户重新接入。 | +| P03 | 普通实例兼容 | 非中央实例已有保存/运行/停止及 WireGuard 操作保持可用 | 通过 | 外部 Console webhook 模式真实普通实例保存/启动/停止/重启/删除及 WG 热更新;核对 Web SQLite 与 Core TOML。 | +| P04 | HTTP 安全随机 | 普通 HTTP 无 randomUUID 时 ACL ID 和网络密钥仍用安全随机源 | 通过 | loopback HTTP 页面手动禁用 randomUUID,观测 getRandomValues;ACL ID 与密钥成功生成,新密钥落入真实 Core TOML。 | +| P05 | 浏览器交互补充 | 导航/刷新、暗色/多语言/移动端及错误恢复;明确区分真实 E2E 与 mock UI 测试 | 通过 | 真实中英/明暗/桌面移动布局、导航刷新及 Web 真停机→Retry→重启重登录恢复;另有16项mock UI补充。 | + +## 执行记录与证据索引 + +首次执行 59 个功能条目:**55 通过、4 失败、0 待测**。当时失败的 N09、M03、W04、R02 归为下述三组发现;不把同一无效配置造成的后续失败重复计数。主数据面基线 22/22 通过不代表整体验收通过;审查补强和定向复测单独列出,未隐藏测试等待条件问题。 + +原始日志、截图、临时密钥/DB 和辅助脚本保留在本机忽略目录 `.test-env/`,不提交测试密钥和数据库。下表路径相对于仓库根目录;辅助报告中保留原 `/tmp` 来源路径,但其内容也已复制归档。 + +| 证据 | 最终执行 | 归档位置 | +|---|---|---| +| 主真实数据面 | 22/22 组通过 | `.test-env/central-e2e-2316549/results.json` 与各进程日志;复现脚本 `easytier-web/frontend/tests/central-coverage.mjs` | +| 审查后加强端口断言的主套件 | 21/22 通过;M02 等待条件修正后定向4/4通过 | `.test-env/central-e2e-2745102/results.json`;`.test-env/central-e2e-2971552/results.json` | +| 原真实 baseline | 1/1 通过,18.24 秒 | `.test-env/central-e2e-evidence-20260930/central-e2e-baseline-evidence/baseline.log`,3 张真实截图 | +| Dashboard mock 补充 | 16/16 通过,33.56 秒 | 同上 `dashboard.log`;此项不计为真实后端验证 | +| 访问/隔离/Console/非法 URL | 14 组通过,N09 失败 | `.test-env/central-e2e-evidence-20260930/central-security-e2e-vM8P3H/` | +| Gateway 在线生命周期 | 3/3 通过,Web PID 全程不变 | `.test-env/central-e2e-evidence-20260930/central-security-e2e-CY4Q6S/` | +| 非法 ACL | 14 个请求全部正确拒绝 | `.test-env/central-e2e-evidence-20260930/central-security-e2e-qw5Fsn/` | +| 凭据生命周期 | 1 综合场景通过,55.39 秒 | `.test-env/central-e2e-evidence-20260930/central-e2e-credential-evidence/` | +| 批次原子性/浏览器凭据复制 | 1 综合场景通过,6.46 秒 | `.test-env/central-e2e-evidence-20260930/central-e2e-batch-evidence/` | +| 同协议端口范围/非默认 ACL 重启 | 4/4 通过 | `.test-env/central-e2e-port-range-2757460/results.json` | +| 中央 override 所有权 | 2/2 通过 | `.test-env/central-e2e-evidence-20260930/central-security-e2e-eOCOeK/` | +| 临时成员真实包权限 | 3/3 通过 | `.test-env/central-e2e-temporary-2280549/results.json` | +| 非默认 DHCP 地址保持 | 1/1 通过,18 秒连续观察 | `.test-env/central-e2e-dhcp-hold-2439398/results.json` | +| 真实 UI/竞态/随机数/故障恢复 | 6/6 通过 | `.test-env/central-e2e-evidence-20260930/central-ui-e2e-YhcgUD/`,16 张截图 | +| 普通实例/WG/IPv6诊断 | 12/12 组通过 | `.test-env/central-e2e-evidence-20260930/central-security-e2e-F7VjL0/` | +| 中央 WG 非法配置 | 3 个非法请求均暴露 F02 | `.test-env/central-e2e-evidence-20260930/central-security-e2e-HvHrqo/` | +| 真实节点详情/日志级别 UI | R01 通过、R02 失败 | `.test-env/central-e2e-evidence-20260930/central-node-ui-e2e-5UwmgY/`,6 张截图 | + +执行过程中修正过测试假设:CIDR 地址不能直接作为 ping 目标;连续配置发布后需等待实例收敛;protobuf JSON enum 返回名称且默认零值省略;Core 通用 peer 配置不保留 PublicServer UI 名称;切到 DHCP 需清除之前固定的成员 IP;Docker 创建的 600 权限 TOML 通过容器读取;不可复用凭据检查可路由身份而非物理连接数量。这些不是产品缺陷。早期数据面记录保留于 `.test-env/central-e2e-audit-*`、`.test-env/central-e2e-2067723/`,主套件完整正向基线使用 `2316549`,端口范围同时由 `2745102` 与独立 `2757460` 实测。`2745102` 的 M02 在地址已上报但路由尚未收敛时立即 ping 失败;脚本改为等待实际可达后,原步骤定向重跑 `2971552` 4/4 通过;IPv6 阻塞证据仍使用早期运行,并由独立 Core 请求复核。 + +## 首轮缺陷及修复状态 + +### F01:不支持的 peer 协议可保存并触发连续失败(已修复) + +- **major / high confidence**;对应 N09。 +- 有效网络已有真实 Core,PATCH 网络为 Manual,`peer_urls=["bogus://127.0.0.1:9999"]`。 +- 实际 HTTP 200,SQLite 网络意图被替换;真实 Core 报 `unsupported core manual connector URL`。18 秒观察内有 292 次实例创建失败重试。 +- 预期保存前拒绝并保持有效配置。根因入口 `easytier-web/src/central_network/compiler.rs:232`,目前仅检查 URL 能否解析。 +- 证据:归档 `central-security-e2e-vM8P3H/results.json` 的 `N09-protocol` / `N09-runtime-evidence`,`central.log:1221` 起。 +- `http://` 和 `https://` 是 Core 支持的 discovery connector,不在非法协议之列。 + +### F02:中央完整配置缺少 Core 可执行性校验(已修复已发现路径) + +- **major / high confidence**;对应 M03、W04。属中央编译/发布边界的局部架构问题,不能把后续每次下发失败作为新 bug。 +- 成员 PATCH `proxy_cidrs=["198.18.240.0/24","2001:db8:240::/64"]` 返回 200,但 Core 的代理网段转换只接受 IPv4;该设备 TOML 保持旧值,后续主机名/网络身份更新也不收敛。独立验证开启 IPv6 同样失败,移除 IPv6 网段成功,排除测试环境关闭 IPv6 的影响。 +- 中央成员完整配置 PUT 分别加入重复 WireGuard IP、`virtual_ip="invalid-ip"`、`groups=["not-declared"]`,都返回 200;真实 Core 分别报 duplicate IP、parse error、unknown ACL group。三轮均从有效基线独立开始并恢复。 +- 普通实例的 WG 客户端 PATCH 对上述非法输入会拒绝,且 Web DB/运行配置保持。因此普通热更新验证不能替代中央 Full 的验证。 +- 证据:`.test-env/central-e2e-audit-1689346/` 的成员快照、DB 与旧 TOML;归档 `central-security-e2e-F7VjL0/results.json`;`central-security-e2e-HvHrqo/results.json` 和 `central-full-validation.log:29` 起。 +- 修复已在中央编译阶段复用 Core 配置转换、可移植配置规范化和 Portal 客户端校验;Core 只开放原有纯校验函数。 + +### F03:日志级别页面回显错误(已修复) + +- **minor / high confidence**;对应 R02;实际设置日志级别能够生效。 +- 六个下拉选项显示翻译函数源码;初始 DISABLED RPC 返回 `{}`,页面误显示 Info;设置 WARNING 后 RPC 返回 `{"level":"WARNING"}`,重开详情显示 Loading。 +- 两处局部问题:`easytier-web/frontend/src/components/NetworkDetail.vue:456-463` 使用函数作为 option label;`easytier-web/frontend/src/modules/api.ts:457-463` 未转换 protobuf 字符串枚举且零值默认不正确。 +- 证据:归档 `central-node-ui-e2e-5UwmgY/results.json` 的 R02-initial/options/after-set/reopen 与四张 R02 截图。修复后六级设置、刷新和翻译均经真实浏览器与 RPC 重新验证。 + +## 修复回归(2026-09-30) + +**当前清单 59 项均为通过,0 项待测、0 项未关闭缺陷。** 原55项的证据保留;本次重跑受影响的主数据面、原浏览器生命周期及专项,未声称将首轮所有独立脚本再执行一遍。 + +- 后端:在数据库写事务开始前的中央 `compile()` 里检查候选配置。网络级 URL 复用 Core 支持规则,覆盖无成员的网络;成员配置先合并中央字段与 override,再执行 Core 转换及 Portal 客户端校验。失败返回400,旧数据库和运行状态不变。 +- Core:只公开现有 `validate_manual_url`、`validate_clients`,没有改变实例运行逻辑、增加 RPC 或读取远端设备状态;不执行主机能力校验。 +- 前端:protobuf 日志枚举转换为数字,省略字段对应 Disabled=0;下拉项使用随语言更新的翻译字符串。 +- IPv6 代理子网按当前 Core 转换能力拒绝,本轮未扩展 IPv6 代理功能。 + +| 回归 | 结果 | 本机证据 | +|---|---|---| +| 后端完整测试 | 232/232 通过,含协议/成员配置/事务不变回归 | `.test-env/central-fix-20260930/central-fix-web-tests.log` | +| 前端构建与 Web embed 构建 | 通过 | `.test-env/central-fix-20260930/central-fix-web-build.log` | +| Dashboard 浏览器回归 | 17/17 通过,含日志枚举专项;mock API | `.test-env/central-fix-20260930/central-fix-dashboard.log` | +| 真实主数据面 | 22/22 通过 | `.test-env/central-e2e-6121215/results.json` | +| 原真实浏览器生命周期 | 1/1 通过,15.92 秒 | `.test-env/central-fix-20260930/central-fix-baseline.log` | +| 配置拒绝与原子性专项 | 14/14 通过 | `.test-env/central-validation-6182364/results.json`;脚本 `easytier-web/frontend/tests/central-validation.mjs` | +| 真实日志级别 UI/RPC | R01/R02 均通过;六级逐次设置、刷新、中文选项 | `.test-env/central-fix-logger-20260930/evidence/results.json` | + +专项的12个非法请求均返回400:对照3张中央意图表完整行、网络与成员配置、两个真实 Core 的 `show_node_info` TOML,全部不变;拒绝期间每条连续15个 ping,共180包零丢包。合法 HTTP/HTTPS/TXT/SRV × Manual/PublicServer 共8次空网保存成功;IPv4代理路由/实际ping、有效WireGuard客户端和清空均通过。 + +原 baseline 最初在旧断言“IPv6代理保存200”处失败,这正是本次修复改变的行为。现改为先断言IPv6返回400,再用受支持的IPv4代理子网完成浏览器ACL流程,重跑通过;旧输出保留在 `central-fix-baseline-old-expectation.log`,不计为新的产品回退。 + +### 覆盖边界 + +- 本轮覆盖所列中央管理功能在 Linux + Chromium + TCP Gateway 下的真实链路;不据此声称所有操作系统、浏览器、底层传输协议组合或长时间压力场景均通过。 +- PublicServer 使用隔离自建真实 peer;外部 Console 仅模拟第三方 webhook 的 validate-token 响应,其 Web、Core、SQLite、Full/Patch 路径真实执行。 +- HTTP 随机数用例在 loopback origin 手动禁用 randomUUID,实际调用 getRandomValues;不冒充公网非安全 origin 的浏览器兼容矩阵。 +- 所有测试专属 Web/Core、bridge 和 network namespace 均已清理;保留忽略目录证据。修复只涉及中央编译器、Core 校验函数可见性、日志页面及回归测试;未修改 ClientManager,未 push。 diff --git a/easytier-core/src/config/api.rs b/easytier-core/src/config/api.rs index 03b98d9a..47b58b85 100644 --- a/easytier-core/src/config/api.rs +++ b/easytier-core/src/config/api.rs @@ -84,6 +84,7 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig { if let Some(vpn_config) = config.get_vpn_portal_config() { result.vpn_portal_config = Some(manage::VpnPortalConfig { + enabled: vpn_config.enabled, wireguard_listen: vpn_config.wireguard_listen.to_string(), wireguard_private_key: vpn_config.wireguard_private_key, clients: vpn_config diff --git a/easytier-core/src/config/api_input.rs b/easytier-core/src/config/api_input.rs index e621f77f..62dd96c0 100644 --- a/easytier-core/src/config/api_input.rs +++ b/easytier-core/src/config/api_input.rs @@ -370,6 +370,7 @@ impl NetworkConfigExt for NetworkConfig { if let Some(vpn_config) = &self.vpn_portal_config { cfg.set_vpn_portal_config(VpnPortalConfig { + enabled: vpn_config.enabled, wireguard_listen: vpn_config.wireguard_listen.parse().with_context(|| { format!( "failed to parse vpn portal wireguard listen address: {}", @@ -719,6 +720,7 @@ impl NetworkConfigExt for NetworkConfig { if let Some(vpn_config) = config.get_vpn_portal_config() { result.vpn_portal_config = Some(manage::VpnPortalConfig { + enabled: vpn_config.enabled, wireguard_listen: vpn_config.wireguard_listen.to_string(), wireguard_private_key: vpn_config.wireguard_private_key, clients: vpn_config @@ -833,6 +835,7 @@ mod tests { fn api_portal_config() -> manage::VpnPortalConfig { manage::VpnPortalConfig { + enabled: None, wireguard_listen: "0.0.0.0:51820".to_owned(), wireguard_private_key: Some("server-private-key".to_owned()), clients: vec![manage::VpnPortalClientConfig { @@ -873,6 +876,32 @@ mod tests { assert_eq!(output.enable_vpn_portal, None); } + #[test] + fn disabled_vpn_portal_preserves_clients_and_key_without_a_runtime() { + let mut portal = api_portal_config(); + portal.enabled = Some(false); + let input = NetworkConfig { + vpn_portal_config: Some(portal), + ..standalone_config() + }; + let config = input.gen_config().unwrap(); + let restored = crate::config::toml::TomlConfig::new_from_str(&config.dump()).unwrap(); + let output = NetworkConfig::new_from_config(&restored).unwrap(); + assert_eq!(output.vpn_portal_config, input.vpn_portal_config); + assert!( + crate::instance::CoreInstanceConfig::from_toml(&restored) + .unwrap() + .vpn_portal + .is_none() + ); + + let mut portal = restored.get_vpn_portal_config().unwrap(); + portal.enabled = Some(true); + restored.set_vpn_portal_config(portal); + let runtime = crate::instance::CoreInstanceConfig::from_toml(&restored).unwrap(); + assert_eq!(runtime.vpn_portal.unwrap().clients[0].name, "alice"); + } + #[test] fn managed_credentials_round_trip_through_toml_model() { let input = NetworkConfig { diff --git a/easytier-core/src/config/toml.rs b/easytier-core/src/config/toml.rs index a4591450..81692860 100644 --- a/easytier-core/src/config/toml.rs +++ b/easytier-core/src/config/toml.rs @@ -449,6 +449,8 @@ impl LoggingConfigLoader for &LoggingConfig { #[derive(Clone, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct VpnPortalConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, pub wireguard_listen: SocketAddr, #[serde(default, skip_serializing_if = "Option::is_none")] pub wireguard_private_key: Option, @@ -460,6 +462,7 @@ impl std::fmt::Debug for VpnPortalConfig { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("VpnPortalConfig") + .field("enabled", &self.enabled) .field("wireguard_listen", &self.wireguard_listen) .field( "wireguard_private_key", diff --git a/easytier-core/src/connectivity/manual/mod.rs b/easytier-core/src/connectivity/manual/mod.rs index bcca4f12..74e5d4e8 100644 --- a/easytier-core/src/connectivity/manual/mod.rs +++ b/easytier-core/src/connectivity/manual/mod.rs @@ -47,7 +47,8 @@ fn is_manual_endpoint_scheme(scheme: &str) -> bool { matches!(scheme, "http" | "https" | "txt" | "srv") } -fn validate_manual_url(url: &Url) -> anyhow::Result<()> { +/// Checks supported connector and discovery schemes without resolving the URL. +pub fn validate_manual_url(url: &Url) -> anyhow::Result<()> { if ManualTransport::from_url(url).is_ok() || is_manual_endpoint_scheme(url.scheme()) { Ok(()) } else { diff --git a/easytier-core/src/gateway/dhcp.rs b/easytier-core/src/gateway/dhcp.rs index 299869ee..8ad8bb6f 100644 --- a/easytier-core/src/gateway/dhcp.rs +++ b/easytier-core/src/gateway/dhcp.rs @@ -22,10 +22,15 @@ pub enum DhcpIpv4Decision { }, } +// Give an already-addressed peer time to announce its subnet before a fresh +// network's first node falls back to the bootstrap subnet. +const BOOTSTRAP_WAIT_ROUNDS: u32 = 2; + #[derive(Debug, Default)] pub struct DhcpIpv4Allocator { default_subnet: Option, current: Option, + no_ipv4_rounds: u32, } impl DhcpIpv4Allocator { @@ -33,6 +38,7 @@ impl DhcpIpv4Allocator { Self { default_subnet: Some(default_subnet), current: None, + no_ipv4_rounds: 0, } } @@ -42,23 +48,57 @@ impl DhcpIpv4Allocator { pub fn reset(&mut self) { self.current = None; + self.no_ipv4_rounds = 0; } pub fn commit(&mut self, next: Option) { self.current = next; } - pub fn evaluate(&self, has_routes: bool, used_ipv4: &HashSet) -> DhcpIpv4Decision { - if !has_routes { + pub fn evaluate( + &mut self, + has_routes: bool, + used_ipv4: &HashSet, + ) -> DhcpIpv4Decision { + self.evaluate_with_rng(has_routes, used_ipv4, &mut rand::thread_rng()) + } + + fn evaluate_with_rng( + &mut self, + has_routes: bool, + used_ipv4: &HashSet, + rng: &mut impl Rng, + ) -> DhcpIpv4Decision { + if !has_routes && self.default_subnet.is_none() { return DhcpIpv4Decision::WaitForPeers; } - let Some(subnet) = self - .default_subnet - .as_ref() - .or_else(|| used_ipv4.iter().next()) - else { - return DhcpIpv4Decision::WaitForPeers; + let preferred = used_ipv4 + .iter() + .filter(|subnet| { + self.current + .is_some_and(|current| current.network() == subnet.network()) + }) + .min_by_key(|subnet| (subnet.first_address(), subnet.network_length())); + let advertised = preferred.or_else(|| { + used_ipv4 + .iter() + .min_by_key(|subnet| (subnet.first_address(), subnet.network_length())) + }); + let subnet = if let Some(subnet) = advertised { + self.no_ipv4_rounds = 0; + *subnet + } else if self.current.is_some() { + return DhcpIpv4Decision::Unchanged; + } else { + let Some(default_subnet) = self.default_subnet else { + return DhcpIpv4Decision::WaitForPeers; + }; + self.no_ipv4_rounds = self.no_ipv4_rounds.saturating_add(1); + if self.no_ipv4_rounds <= BOOTSTRAP_WAIT_ROUNDS { + return DhcpIpv4Decision::WaitForPeers; + } + default_subnet }; if let Some(current) = self.current && current.network() == subnet.network() @@ -67,10 +107,18 @@ impl DhcpIpv4Allocator { return DhcpIpv4Decision::Unchanged; } - let next = subnet.network().iter().find(|candidate| { - candidate.address() != subnet.first_address() - && candidate.address() != subnet.last_address() - && !used_ipv4.contains(candidate) + let first = u32::from(subnet.first_address()); + let span = u32::from(subnet.last_address()) - first; + // Spread simultaneous bootstrap allocations and conflict retries across + // the subnet. Existing route announcements still detect collisions. + let offset = if span > 1 && (used_ipv4.is_empty() || self.current.is_some()) { + rng.gen_range(1..span) + } else { + 1 + }; + let next = (offset..span).chain(1..offset).find_map(|offset| { + let candidate = Ipv4Inet::new((first + offset).into(), subnet.network_length()).ok()?; + (!used_ipv4.contains(&candidate)).then_some(candidate) }); if self.current == next { return DhcpIpv4Decision::Unchanged; @@ -216,7 +264,10 @@ impl DhcpIpv4Service { ) -> Arc { Arc::new(Self { operation: tokio::sync::Mutex::new(()), - allocator: std::sync::Mutex::new(DhcpIpv4Allocator::default()), + allocator: std::sync::Mutex::new(DhcpIpv4Allocator::new( + Ipv4Inet::new(std::net::Ipv4Addr::new(10, 126, 126, 0), 24) + .expect("valid bootstrap subnet"), + )), route_source, runtime_config, host, @@ -401,7 +452,7 @@ mod tests { #[test] fn waits_until_at_least_one_route_exists() { - let allocator = DhcpIpv4Allocator::default(); + let mut allocator = DhcpIpv4Allocator::default(); assert_eq!( allocator.evaluate(false, &HashSet::new()), @@ -411,7 +462,7 @@ mod tests { #[test] fn does_not_fall_back_to_a_builtin_subnet_without_assigned_ipv4() { - let allocator = DhcpIpv4Allocator::default(); + let mut allocator = DhcpIpv4Allocator::default(); assert_eq!( allocator.evaluate(true, &HashSet::new()), @@ -419,6 +470,126 @@ mod tests { ); } + #[test] + fn bootstraps_default_subnet_after_bounded_wait() { + let mut allocator = DhcpIpv4Allocator::new("10.126.126.0/24".parse().unwrap()); + + for _ in 0..2 { + assert_eq!( + allocator.evaluate(true, &HashSet::new()), + DhcpIpv4Decision::WaitForPeers + ); + } + let DhcpIpv4Decision::Change { + previous: None, + next: Some(address), + } = allocator.evaluate(true, &HashSet::new()) + else { + panic!("expected bootstrap address") + }; + assert_eq!( + address.network(), + "10.126.126.0/24".parse::().unwrap().network() + ); + assert_ne!(address.address(), address.first_address()); + assert_ne!(address.address(), address.last_address()); + } + + #[test] + fn announced_peer_subnet_takes_priority_over_bootstrap_subnet() { + let mut allocator = DhcpIpv4Allocator::new("10.126.126.0/24".parse().unwrap()); + let used = HashSet::from(["198.18.106.5/24".parse().unwrap()]); + + assert_eq!( + allocator.evaluate(true, &used), + DhcpIpv4Decision::Change { + previous: None, + next: Some("198.18.106.1/24".parse().unwrap()), + } + ); + } + + #[test] + fn keeps_current_address_when_peers_stop_announcing_ipv4() { + let mut allocator = DhcpIpv4Allocator::new("10.126.126.0/24".parse().unwrap()); + let current = "198.18.106.1/24".parse().unwrap(); + allocator.commit(Some(current)); + + for _ in 0..BOOTSTRAP_WAIT_ROUNDS + 2 { + assert_eq!( + allocator.evaluate(true, &HashSet::new()), + DhcpIpv4Decision::Unchanged + ); + assert_eq!(allocator.current(), Some(current)); + } + } + + #[test] + fn simultaneous_dhcp_only_peers_resolve_conflicts() { + use rand::SeedableRng; + let subnet = "10.126.126.0/24".parse().unwrap(); + let mut allocators = (0..32) + .map(|_| DhcpIpv4Allocator::new(subnet)) + .collect::>(); + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + for round in 0..30 { + // All peers evaluate the same previous-round snapshot before any + // candidate is published, including simultaneous empty startup. + let current = allocators + .iter() + .map(|allocator| allocator.current()) + .collect::>(); + let decisions = allocators + .iter_mut() + .enumerate() + .map(|(index, allocator)| { + let used = current + .iter() + .enumerate() + .filter_map(|(peer, address)| (peer != index).then_some(*address).flatten()) + .collect(); + allocator.evaluate_with_rng(true, &used, &mut rng) + }) + .collect::>(); + for (allocator, decision) in allocators.iter_mut().zip(decisions) { + if let DhcpIpv4Decision::Change { next, .. } = decision { + allocator.commit(next); + } + } + if round > BOOTSTRAP_WAIT_ROUNDS { + let addresses = allocators + .iter() + .filter_map(|allocator| allocator.current()) + .collect::>(); + if addresses.len() == allocators.len() { + return; + } + } + } + panic!("simultaneous allocations did not converge"); + } + + #[test] + fn keeps_advertised_current_subnet_and_uses_deterministic_fallback() { + let mut allocator = DhcpIpv4Allocator::default(); + allocator.commit(Some("10.2.0.8/24".parse().unwrap())); + let used = HashSet::from([ + "10.1.0.2/24".parse().unwrap(), + "10.2.0.2/24".parse().unwrap(), + ]); + for _ in 0..20 { + assert_eq!(allocator.evaluate(true, &used), DhcpIpv4Decision::Unchanged); + } + allocator.reset(); + assert_eq!( + allocator.evaluate(true, &used), + DhcpIpv4Decision::Change { + previous: None, + next: Some("10.1.0.1/24".parse().unwrap()), + } + ); + } + #[test] fn keeps_current_address_when_it_is_free_in_the_selected_subnet() { let mut allocator = DhcpIpv4Allocator::default(); @@ -429,7 +600,7 @@ mod tests { } #[test] - fn selects_first_available_host_after_a_conflict() { + fn selects_an_unused_host_after_a_conflict() { let mut allocator = DhcpIpv4Allocator::default(); allocator.commit(Some("10.1.2.1/24".parse().unwrap())); let used = HashSet::from([ @@ -437,13 +608,16 @@ mod tests { "10.1.2.2/24".parse().unwrap(), ]); - assert_eq!( - allocator.evaluate(true, &used), - DhcpIpv4Decision::Change { - previous: Some("10.1.2.1/24".parse().unwrap()), - next: Some("10.1.2.3/24".parse().unwrap()), - } - ); + let DhcpIpv4Decision::Change { + previous, + next: Some(next), + } = allocator.evaluate(true, &used) + else { + panic!("expected conflict resolution"); + }; + assert_eq!(previous, Some("10.1.2.1/24".parse().unwrap())); + assert!(!used.contains(&next)); + assert_eq!(next.network(), previous.unwrap().network()); } #[test] @@ -457,17 +631,28 @@ mod tests { } #[tokio::test] - async fn service_does_not_apply_ipv4_when_no_peer_has_one() { + async fn service_bootstraps_ipv4_when_no_peer_has_one() { + check_service_bootstrap(true).await; + } + + #[tokio::test] + async fn service_bootstraps_ipv4_without_other_peers() { + check_service_bootstrap(false).await; + } + + async fn check_service_bootstrap(has_routes: bool) { let host = Arc::new(RecordingHost::default()); let (service, runtime_config) = service( DhcpIpv4RouteSnapshot { - has_routes: true, + has_routes, used_ipv4: HashSet::new(), }, host.clone(), ); - assert!(service.reconcile_once().await); + for _ in 0..BOOTSTRAP_WAIT_ROUNDS { + assert_eq!(service.reconcile_once().await, has_routes); + } assert_eq!(service.current(), None); assert!(host.changes.lock().unwrap().is_empty()); @@ -482,6 +667,24 @@ mod tests { .ipv4 .is_none() ); + + assert_eq!(service.reconcile_once().await, has_routes); + let address = service.current().expect("bootstrap address"); + assert_eq!( + address.network(), + "10.126.126.0/24".parse::().unwrap().network() + ); + assert_eq!(*host.changes.lock().unwrap(), [(None, Some(address))]); + assert_eq!( + runtime_config.snapshot().peer.runtime.core.routes.ipv4, + Some(IpPrefix { + address: address.address().into(), + prefix_len: address.network_length(), + }) + ); + assert_eq!(service.reconcile_once().await, has_routes); + assert_eq!(service.current(), Some(address)); + assert_eq!(host.changes.lock().unwrap().len(), 1); } #[tokio::test] diff --git a/easytier-core/src/gateway/vpn_portal.rs b/easytier-core/src/gateway/vpn_portal.rs index b4ffd7ed..1ffffc4f 100644 --- a/easytier-core/src/gateway/vpn_portal.rs +++ b/easytier-core/src/gateway/vpn_portal.rs @@ -5,5 +5,5 @@ mod runtime; pub use runtime::{ MAX_VPN_PORTAL_CLIENTS, PortalClientConfig, PortalClientConfigPlan, PortalClientInfoSnapshot, PortalClientState, PortalHost, PortalInfoSnapshot, PortalListener, PortalModule, - PortalRuntimeConfig, PortalSession, + PortalRuntimeConfig, PortalSession, validate_clients, }; diff --git a/easytier-core/src/gateway/vpn_portal/runtime.rs b/easytier-core/src/gateway/vpn_portal/runtime.rs index d4ce3dce..418ab298 100644 --- a/easytier-core/src/gateway/vpn_portal/runtime.rs +++ b/easytier-core/src/gateway/vpn_portal/runtime.rs @@ -799,7 +799,7 @@ impl PortalModule { /// a portal with zero clients keeps listening and accepts nothing, so /// clearing all clients never produces a configuration that fails a later /// instance recreation. -fn validate_clients( +pub fn validate_clients( config: &PortalRuntimeConfig, runtime_config: &CoreInstanceRuntimeConfig, ) -> anyhow::Result<()> { diff --git a/easytier-core/src/instance/config.rs b/easytier-core/src/instance/config.rs index 7cb91dd4..7bfdd23a 100644 --- a/easytier-core/src/instance/config.rs +++ b/easytier-core/src/instance/config.rs @@ -337,6 +337,7 @@ impl CoreInstanceConfig { vpn_portal: (!host.ignore_unsupported_config || host.vpn_portal_enabled) .then(|| config.get_vpn_portal_config()) .flatten() + .filter(|config| config.enabled != Some(false)) .map(|config| PortalRuntimeConfig { clients: config .clients diff --git a/easytier-core/src/instance/mod.rs b/easytier-core/src/instance/mod.rs index 9bad5b3b..00b299ad 100644 --- a/easytier-core/src/instance/mod.rs +++ b/easytier-core/src/instance/mod.rs @@ -898,6 +898,12 @@ where CoreInstanceState::from_u8(self.state.load(Ordering::Acquire)) } + /// Returns the peer manager for tunnels accepted outside this instance's + /// regular listener path, such as the shared Web listener. + pub fn peer_manager(&self) -> &Arc { + &self.peer_manager + } + fn set_state(&self, state: CoreInstanceState) { self.state.store(state as u8, Ordering::Release); } diff --git a/easytier-core/src/management/full/config_patch.rs b/easytier-core/src/management/full/config_patch.rs index e10535b4..ed261ce6 100644 --- a/easytier-core/src/management/full/config_patch.rs +++ b/easytier-core/src/management/full/config_patch.rs @@ -308,7 +308,9 @@ where Ok(runtime_config_from_normalized(&normalized)) } -fn runtime_config_from_normalized(config: &CoreInstanceConfig) -> CoreInstanceRuntimeConfig { +pub(super) fn runtime_config_from_normalized( + config: &CoreInstanceConfig, +) -> CoreInstanceRuntimeConfig { CoreInstanceRuntimeConfig { services: config.connectivity.runtime.clone(), peer: Arc::new(config.peer.snapshot.clone()), @@ -497,7 +499,7 @@ fn patch_mapped_listeners(config: &TomlConfig, patches: Vec) -> anyhow /// Applies VPN portal client patches to the candidate TOML model. The live /// portal is updated by the caller after the candidate commits, so deep /// validation runs against the final configuration state. -fn apply_vpn_portal_client_patches( +pub(super) fn apply_vpn_portal_client_patches( config: &TomlConfig, patches: Vec, ) -> anyhow::Result<()> { @@ -507,6 +509,10 @@ fn apply_vpn_portal_client_patches( let mut portal = config .get_vpn_portal_config() .ok_or_else(|| anyhow::anyhow!("VPN portal is not configured; cannot patch its clients"))?; + anyhow::ensure!( + portal.enabled != Some(false), + "VPN portal is disabled; cannot patch its clients" + ); for patch in patches { match ConfigPatchAction::try_from(patch.action) { Ok(ConfigPatchAction::Add) => { @@ -600,6 +606,7 @@ mod tests { fn portal_config() -> TomlConfig { let config = TomlConfig::default(); config.set_vpn_portal_config(VpnPortalConfig { + enabled: None, wireguard_listen: "0.0.0.0:51820".parse().unwrap(), wireguard_private_key: None, clients: vec![VpnPortalClientConfig { @@ -664,6 +671,17 @@ mod tests { assert!(configured_names(&config).is_empty()); } + #[test] + fn vpn_portal_client_patches_preserve_disabled_configuration() { + let config = portal_config(); + let mut portal = config.get_vpn_portal_config().unwrap(); + portal.enabled = Some(false); + config.set_vpn_portal_config(portal.clone()); + let error = apply_vpn_portal_client_patches(&config, vec![remove("alice")]).unwrap_err(); + assert!(error.to_string().contains("disabled")); + assert_eq!(config.get_vpn_portal_config(), Some(portal)); + } + #[test] fn vpn_portal_client_patches_reject_missing_prerequisites() { let bare = TomlConfig::default(); diff --git a/easytier-core/src/management/full/remote_client.rs b/easytier-core/src/management/full/remote_client.rs index 5a911017..579441ca 100644 --- a/easytier-core/src/management/full/remote_client.rs +++ b/easytier-core/src/management/full/remote_client.rs @@ -2,6 +2,8 @@ use async_trait::async_trait; use uuid::Uuid; use easytier_proto::{ + api::config::{ConfigRpc, InstanceConfigPatch, PatchConfigRequest, VpnPortalClientPatch}, + api::instance::{InstanceIdentifier, instance_identifier}, api::manage::{ CollectNetworkInfoRequest, CollectNetworkInfoResponse, DeleteNetworkInstanceRequest, GetNetworkInstanceConfigRequest, ListNetworkInstanceMetaRequest, @@ -11,7 +13,7 @@ use easytier_proto::{ rpc_types::controller::BaseController, }; -use crate::config::toml::ConfigSource; +use crate::config::{api_input::NetworkConfigExt as _, toml::ConfigSource}; use super::{config_source_from_rpc, config_source_to_rpc}; @@ -29,6 +31,100 @@ where fn get_storage(&self) -> &impl Storage; + fn get_config_rpc_client( + &self, + identify: T, + ) -> Option + Send>>; + + async fn handle_patch_vpn_portal_clients( + &self, + identify: T, + inst_id: uuid::Uuid, + patches: Vec, + ) -> Result<(), RemoteClientError> { + // The caller serializes this operation with its other device changes + // and (for Web) desired-config updates and runtime reconciliation. + if patches.is_empty() { + return Ok(()); + } + let stored = self + .get_storage() + .get_network_config(identify.clone(), &inst_id.to_string()) + .await + .map_err(RemoteClientError::PersistentError)?; + let mut desired = None; + if let Some(stored) = stored { + let mut config = stored + .get_network_config() + .map_err(RemoteClientError::PersistentError)?; + let candidate = config + .gen_config() + .map_err(|e| RemoteClientError::Other(e.to_string()))?; + super::config_patch::apply_vpn_portal_client_patches(&candidate, patches.clone()) + .map_err(|e| RemoteClientError::Other(e.to_string()))?; + let normalized = crate::instance::CoreInstanceConfig::from_toml(&candidate) + .map_err(|e| RemoteClientError::Other(e.to_string()))?; + // Pending saved clients, addresses and ACL declarations may differ + // from runtime. Validate the actual saved candidate as well. + crate::gateway::vpn_portal::validate_clients( + normalized.vpn_portal.as_ref().unwrap(), + &super::config_patch::runtime_config_from_normalized(&normalized), + ) + .map_err(|e| RemoteClientError::Other(e.to_string()))?; + let updated = NetworkConfig::new_from_config(&candidate) + .map_err(|e| RemoteClientError::Other(e.to_string()))?; + // Keep pending saved changes, including the listener and key. + // Only the requested device changes belong to this operation. + config.vpn_portal_config.as_mut().unwrap().clients = + updated.vpn_portal_config.unwrap().clients; + desired = Some((config, stored.get_network_config_source())); + } + let client = self + .get_config_rpc_client(identify.clone()) + .ok_or(RemoteClientError::ClientNotFound)?; + client + .patch_config( + BaseController::default(), + PatchConfigRequest { + instance: Some(InstanceIdentifier { + selector: Some(instance_identifier::Selector::Id(inst_id.into())), + }), + patch: Some(InstanceConfigPatch { + vpn_portal_clients: patches, + ..Default::default() + }), + }, + ) + .await?; + + // PatchConfig persists the node's TOML file, when present. GUI and + // Web also own saved configurations that must retain this change. + let (config, source) = if let Some(desired) = desired { + desired + } else { + let rpc = self + .get_rpc_client(identify.clone()) + .ok_or(RemoteClientError::ClientNotFound)?; + let response = rpc + .get_network_instance_config( + BaseController::default(), + GetNetworkInstanceConfigRequest { + inst_id: Some(inst_id.into()), + }, + ) + .await?; + let config = response.config.ok_or_else(|| { + RemoteClientError::NotFound(format!("No running network instance: {inst_id}")) + })?; + let source = config_source_from_rpc(response.source).unwrap_or(ConfigSource::User); + (config, source) + }; + self.get_storage() + .insert_or_update_user_network_config(identify, inst_id, config, source) + .await + .map_err(RemoteClientError::PersistentError) + } + async fn handle_validate_config( &self, identify: T, @@ -438,3 +534,216 @@ where async fn get_network_config(&self, identify: T, network_inst_id: &str) -> Result, E>; } + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use easytier_proto::api::{ + config::{ConfigPatchAction, GetConfigRequest, GetConfigResponse, PatchConfigResponse}, + manage::{NetworkingMethod, VpnPortalClientConfig, VpnPortalConfig}, + }; + + use super::*; + + #[derive(Clone)] + struct SavedConfig(NetworkConfig, ConfigSource); + + impl PersistentConfig for SavedConfig { + fn get_network_inst_id(&self) -> &str { + self.0.instance_id.as_deref().unwrap() + } + + fn get_network_config(&self) -> anyhow::Result { + Ok(self.0.clone()) + } + + fn get_network_config_source(&self) -> ConfigSource { + self.1 + } + } + + #[async_trait] + impl Storage<(), SavedConfig, anyhow::Error> for Mutex> { + async fn insert_or_update_user_network_config( + &self, + _: (), + _: Uuid, + config: NetworkConfig, + source: ConfigSource, + ) -> anyhow::Result<()> { + *self.lock().unwrap() = Some(SavedConfig(config, source)); + Ok(()) + } + + async fn get_network_config(&self, _: (), _: &str) -> anyhow::Result> { + Ok(self.lock().unwrap().clone()) + } + + async fn delete_network_configs(&self, _: (), _: &[Uuid]) -> anyhow::Result<()> { + unreachable!() + } + + async fn update_network_config_state(&self, _: (), _: Uuid, _: bool) -> anyhow::Result<()> { + unreachable!() + } + + async fn list_network_configs( + &self, + _: (), + _: ListNetworkProps, + ) -> anyhow::Result> { + unreachable!() + } + } + + struct PatchRpc { + fail: bool, + } + + #[async_trait] + impl ConfigRpc for PatchRpc { + type Controller = BaseController; + + async fn patch_config( + &self, + _: BaseController, + _: PatchConfigRequest, + ) -> easytier_proto::rpc_types::error::Result { + if self.fail { + Err(easytier_proto::rpc_types::error::Error::Shutdown) + } else { + Ok(PatchConfigResponse::default()) + } + } + + async fn get_config( + &self, + _: BaseController, + _: GetConfigRequest, + ) -> easytier_proto::rpc_types::error::Result { + unreachable!() + } + } + + struct ClientManager { + storage: Mutex>, + patch_fails: bool, + } + + #[async_trait] + impl RemoteClientManager<(), SavedConfig, anyhow::Error> for ClientManager { + fn get_rpc_client( + &self, + _: (), + ) -> Option + Send>> { + // The session is unavailable for any read after PatchConfig. + None + } + + fn get_storage(&self) -> &impl Storage<(), SavedConfig, anyhow::Error> { + &self.storage + } + + fn get_config_rpc_client( + &self, + _: (), + ) -> Option + Send>> { + Some(Box::new(PatchRpc { + fail: self.patch_fails, + })) + } + } + + fn saved_config() -> NetworkConfig { + NetworkConfig { + instance_id: Some(Uuid::new_v4().to_string()), + hostname: Some("pending-hostname".to_owned()), + network_secret: Some("network-secret".to_owned()), + networking_method: Some(NetworkingMethod::Standalone as i32), + vpn_portal_config: Some(VpnPortalConfig { + wireguard_listen: "0.0.0.0:51821".to_owned(), + wireguard_private_key: Some( + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE=".to_owned(), + ), + ..Default::default() + }), + ..Default::default() + } + } + + fn add_client() -> VpnPortalClientPatch { + VpnPortalClientPatch { + action: ConfigPatchAction::Add as i32, + client: Some(VpnPortalClientConfig { + name: "alice".to_owned(), + virtual_ip: "10.0.0.2/24".to_owned(), + groups: Vec::new(), + }), + } + } + + #[tokio::test] + async fn portal_patch_persists_saved_config_without_a_followup_rpc() { + for source in [ConfigSource::Web, ConfigSource::User] { + let mut expected = saved_config(); + let manager = ClientManager { + storage: Mutex::new(Some(SavedConfig(expected.clone(), source))), + patch_fails: false, + }; + manager + .handle_patch_vpn_portal_clients( + (), + expected.instance_id.as_ref().unwrap().parse().unwrap(), + vec![add_client()], + ) + .await + .unwrap(); + + expected.vpn_portal_config.as_mut().unwrap().clients = + vec![add_client().client.unwrap()]; + let saved = manager.storage.lock().unwrap(); + let saved = saved.as_ref().unwrap(); + assert_eq!(saved.0, expected); + assert_eq!(saved.1, source); + } + } + + #[tokio::test] + async fn portal_patch_failure_preserves_saved_config() { + for source in [ConfigSource::Web, ConfigSource::User] { + let config = saved_config(); + let manager = ClientManager { + storage: Mutex::new(Some(SavedConfig(config.clone(), source))), + patch_fails: true, + }; + let result = manager + .handle_patch_vpn_portal_clients( + (), + config.instance_id.as_ref().unwrap().parse().unwrap(), + vec![add_client()], + ) + .await; + + assert!(matches!(result, Err(RemoteClientError::RpcError(_)))); + let saved = manager.storage.lock().unwrap(); + let saved = saved.as_ref().unwrap(); + assert_eq!(saved.0, config); + assert_eq!(saved.1, source); + } + } + + #[tokio::test] + async fn portal_patch_without_saved_config_requires_runtime_config() { + let manager = ClientManager { + storage: Mutex::new(None), + patch_fails: false, + }; + let result = manager + .handle_patch_vpn_portal_clients((), Uuid::new_v4(), vec![add_client()]) + .await; + + assert!(matches!(result, Err(RemoteClientError::ClientNotFound))); + assert!(manager.storage.lock().unwrap().is_none()); + } +} diff --git a/easytier-core/src/peers/credential_manager.rs b/easytier-core/src/peers/credential_manager.rs index 67cb3801..266b8c4e 100644 --- a/easytier-core/src/peers/credential_manager.rs +++ b/easytier-core/src/peers/credential_manager.rs @@ -236,6 +236,27 @@ impl Default for CredentialManager { } } +/// Validate a complete managed credential replacement with the same +/// normalization and key-identity rules used when it is installed. +#[cfg(any(feature = "management-rpc", feature = "browser-config"))] +pub fn validate_managed_credential_set( + credentials: &[crate::proto::api::manage::ManagedCredentialConfig], +) -> Result<(), String> { + let credentials: Vec<_> = credentials + .iter() + .map(|credential| ManagedCredentialConfig { + credential_id: credential.credential_id.clone(), + credential_secret: credential.credential_secret.clone(), + groups: credential.groups.clone(), + allow_relay: credential.allow_relay, + allowed_proxy_cidrs: credential.allowed_proxy_cidrs.clone(), + expiry_unix: credential.expiry_unix, + reusable: credential.reusable.unwrap_or(true), + }) + .collect(); + CredentialManager::build_managed_entries(&credentials).map(|_| ()) +} + impl CredentialManager { pub fn new() -> Self { Self { diff --git a/easytier-gui/src-tauri/src/lib.rs b/easytier-gui/src-tauri/src/lib.rs index f966c155..64bdbff6 100644 --- a/easytier-gui/src-tauri/src/lib.rs +++ b/easytier-gui/src-tauri/src/lib.rs @@ -7,8 +7,7 @@ use anyhow::Context; #[cfg(target_os = "android")] use easytier::instance::factory::subscribe_native_instance_event; use easytier::proto::api::config::{ - ConfigPatchAction, ConfigRpc, ConfigRpcClientFactory, InstanceConfigPatch, PatchConfigRequest, - VpnPortalClientPatch, + ConfigPatchAction, ConfigRpc, ConfigRpcClientFactory, VpnPortalClientPatch, }; use easytier::proto::api::instance::{ GetVpnPortalInfoRequest, InstanceIdentifier, VpnPortalInfo, VpnPortalRpc, @@ -131,6 +130,7 @@ async fn run_network_instance( ) -> Result<(), String> { let client_manager = get_client_manager!()?; let toml_config = cfg.gen_config().map_err(|e| e.to_string())?; + let _mutation = client_manager.config_mutation.lock().await; client_manager .pre_run_network_instance_hook(&app, &toml_config, manager::PersistedConfigSource::User) .await?; @@ -184,6 +184,7 @@ async fn get_vpn_portal_info(instance_id: String) -> Result, @@ -210,28 +211,18 @@ async fn patch_vpn_portal_clients( }; let client_manager = get_client_manager!()?; - let rpc = client_manager - .rpc_manager - .rpc_client() - .scoped_client::>(1, 1, "".to_string()); - rpc.patch_config( - BaseController::default(), - PatchConfigRequest { - instance: Some(InstanceIdentifier { - selector: Some(instance_identifier::Selector::Id(instance_id.into())), - }), - patch: Some(InstanceConfigPatch { - vpn_portal_clients: vec![VpnPortalClientPatch { - action: action as i32, - client, - }], - ..Default::default() - }), - }, - ) - .await - .map_err(|e| e.to_string())?; - Ok(()) + let _mutation = client_manager.config_mutation.lock().await; + client_manager + .handle_patch_vpn_portal_clients( + app, + instance_id, + vec![VpnPortalClientPatch { + action: action as i32, + client, + }], + ) + .await + .map_err(|e| e.to_string()) } #[tauri::command] @@ -275,6 +266,7 @@ async fn remove_network_instance(app: AppHandle, instance_id: String) -> Result< .parse() .map_err(|e: uuid::Error| e.to_string())?; let client_manager = get_client_manager!()?; + let _mutation = client_manager.config_mutation.lock().await; client_manager .handle_remove_network_instances(app.clone(), vec![instance_id]) .await @@ -296,6 +288,7 @@ async fn update_network_config_state( .parse() .map_err(|e: uuid::Error| e.to_string())?; let client_manager = get_client_manager!()?; + let _mutation = client_manager.config_mutation.lock().await; if !disabled { let (cfg, source) = client_manager .handle_get_network_config_with_source(app.clone(), instance_id) @@ -334,7 +327,9 @@ async fn save_network_config(app: AppHandle, cfg: NetworkConfig) -> Result<(), S .instance_id() .parse() .map_err(|e: uuid::Error| e.to_string())?; - get_client_manager!()? + let client_manager = get_client_manager!()?; + let _mutation = client_manager.config_mutation.lock().await; + client_manager .handle_save_network_config(app, instance_id, cfg) .await .map_err(|e| e.to_string()) @@ -369,7 +364,9 @@ async fn load_configs( configs: Vec, enabled_networks: Vec, ) -> Result<(), String> { - get_client_manager!()? + let client_manager = get_client_manager!()?; + let _mutation = client_manager.config_mutation.lock().await; + client_manager .load_configs(app, configs, enabled_networks) .await .map_err(|e| e.to_string())?; @@ -977,6 +974,7 @@ mod manager { pub(super) struct GUIClientManager { pub(super) storage: GUIStorage, pub(super) rpc_manager: BidirectRpcManager, + pub(super) config_mutation: Mutex<()>, } impl GUIClientManager { pub async fn new( @@ -997,6 +995,7 @@ mod manager { Ok(Self { storage: GUIStorage::new(), rpc_manager, + config_mutation: Mutex::new(()), }) } @@ -1240,6 +1239,17 @@ mod manager { } } impl RemoteClientManager for GUIClientManager { + fn get_config_rpc_client( + &self, + _: AppHandle, + ) -> Option + Send>> { + Some( + self.rpc_manager + .rpc_client() + .scoped_client::>(1, 1, String::new()), + ) + } + fn get_rpc_client( &self, _: AppHandle, diff --git a/easytier-proto/proto/api_manage.proto b/easytier-proto/proto/api_manage.proto index 82e63ab7..af4f86d5 100644 --- a/easytier-proto/proto/api_manage.proto +++ b/easytier-proto/proto/api_manage.proto @@ -132,6 +132,8 @@ message VpnPortalConfig { string wireguard_listen = 1; optional string wireguard_private_key = 2; repeated VpnPortalClientConfig clients = 3; + // Omitted in existing configurations, where the portal is enabled. + optional bool enabled = 4; } message NetworkPeerConfig { diff --git a/easytier-proto/src/api.rs b/easytier-proto/src/api.rs index 9105b4bc..337e75c1 100644 --- a/easytier-proto/src/api.rs +++ b/easytier-proto/src/api.rs @@ -384,6 +384,7 @@ mod tests { #[test] fn vpn_portal_debug_redacts_private_key() { let config = super::manage::VpnPortalConfig { + enabled: None, wireguard_listen: "0.0.0.0:51820".to_owned(), wireguard_private_key: Some("private-key-material".to_owned()), clients: Vec::new(), diff --git a/easytier-web/Cargo.toml b/easytier-web/Cargo.toml index d036f218..f158ba47 100644 --- a/easytier-web/Cargo.toml +++ b/easytier-web/Cargo.toml @@ -16,6 +16,8 @@ tokio-util = { workspace = true, features = ["rt"] } dashmap.workspace = true url.workspace = true async-trait.workspace = true +futures.workspace = true +prost.workspace = true maxminddb = "0.32" @@ -43,7 +45,11 @@ sea-orm-migration.workspace = true # for captcha rust-embed = { version = "8.12.0", features = ["debug-embed", "include-exclude"] } base64.workspace = true +cidr.workspace = true rand.workspace = true +sha2.workspace = true +x25519-dalek = { workspace = true, features = ["static_secrets"] } +easytier-proto.workspace = true image = { version = "0.25", default-features = false, features = ["png"] } ab_glyph = "0.2.32" imageproc = { version = "0.27.0", default-features = false, features = ["text"] } @@ -88,3 +94,6 @@ ignored = [ # Windows-only build.rs setup calls thunk::thunk(). "thunk-rs", ] + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } diff --git a/easytier-web/frontend-lib/package.json b/easytier-web/frontend-lib/package.json index 6c5aa77a..59c98433 100644 --- a/easytier-web/frontend-lib/package.json +++ b/easytier-web/frontend-lib/package.json @@ -31,6 +31,7 @@ "floating-vue": "^5.2", "ip-num": "1.5.1", "primeicons": "^7.0.0", + "qrcode": "1.5.4", "tailwindcss-primeui": "^0.3.4", "ts-md5": "^1.3.1", "uuid": "^11.0.2", @@ -42,6 +43,7 @@ "@protobuf-ts/plugin": "2.11.1", "@protobuf-ts/protoc": "2.11.1", "@types/node": "^22.8.6", + "@types/qrcode": "1.5.6", "@vitejs/plugin-vue": "^5.1.4", "@vue/test-utils": "^2.4.11", "autoprefixer": "^10.4.20", diff --git a/easytier-web/frontend-lib/scripts/test-network-config.mjs b/easytier-web/frontend-lib/scripts/test-network-config.mjs index 3e08faf3..89841c19 100644 --- a/easytier-web/frontend-lib/scripts/test-network-config.mjs +++ b/easytier-web/frontend-lib/scripts/test-network-config.mjs @@ -236,8 +236,20 @@ function allFieldFixture() { }, }, credential_file: '/tmp/easytier-credential.toml', + managed_credentials: [ + { + credential_id: 'managed-credential', + credential_secret: 'managed-secret', + groups: ['ops'], + allow_relay: true, + allowed_proxy_cidrs: ['10.30.0.0/16'], + expiry_unix: '1791971218', + reusable: true, + }, + ], lazy_p2p: true, need_p2p: true, + prefer_peer_relay: true, instance_recv_bps_limit: '9007199254740993', disable_upnp: true, ipv6_public_addr_provider: true, diff --git a/easytier-web/frontend-lib/src/components/Config.vue b/easytier-web/frontend-lib/src/components/Config.vue index 7aca4deb..44f513cf 100644 --- a/easytier-web/frontend-lib/src/components/Config.vue +++ b/easytier-web/frontend-lib/src/components/Config.vue @@ -1,6 +1,6 @@ @@ -286,11 +334,36 @@ function removeVpnPortalClient(index: number) {
+
+ + + +
-
+
+ +
+
+ + + {{ t('credential_secret_hint') }} +
@@ -347,61 +420,49 @@ function removeVpnPortalClient(index: number) { -
-
- - -
-
-
- - -
-
- - -
-
- -
- -
- -
- {{ t('vpn_portal_no_clients') }} -
-
-
- - -
-
- - -
-
- - -
-
-
+
+
+ +
+

{{ t(editVpnPortalClients ? 'vpn_portal_config_clients_help' : 'vpn_portal_setup_help') }}

+
+ {{ t('vpn_portal_advanced') }} +
+ + +
+
+
@@ -626,7 +687,7 @@ function removeVpnPortalClient(index: number) {
-
diff --git a/easytier-web/frontend-lib/src/components/RemoteManagement.vue b/easytier-web/frontend-lib/src/components/RemoteManagement.vue index e55a13c7..b9d1e4dd 100644 --- a/easytier-web/frontend-lib/src/components/RemoteManagement.vue +++ b/easytier-web/frontend-lib/src/components/RemoteManagement.vue @@ -471,7 +471,7 @@ onUnmounted(() => { -
+
@@ -556,7 +556,7 @@ onUnmounted(() => {
-
+
@@ -588,7 +588,7 @@ onUnmounted(() => {
{{ @@ -630,6 +630,9 @@ onUnmounted(() => { height: 100%; display: flex; flex-direction: column; + /* hosts that mount this full-page (easytier-gui) have no padding of + their own, so keep a safe inset here; panel chrome stays with hosts */ + padding: 0.75rem; } .network-content { @@ -695,9 +698,12 @@ onUnmounted(() => { max-width: 100%; } -/* Dark mode adaptations */ +/* Dark mode adaptations: surface utilities follow host theme tokens; the + app-dark branch supplies dark fallbacks for hosts that define no tokens. + :global must own the whole selector — mixing it with :deep in one selector + makes the compiler drop the :deep part. */ :deep(.bg-surface-50) { - background-color: var(--surface-50, #f8fafc); + background-color: var(--surface-ground, #f8fafc); } :deep(.bg-surface-0) { @@ -712,25 +718,16 @@ onUnmounted(() => { color: var(--text-color-secondary, #64748b); } -@media (prefers-color-scheme: dark) { - :deep(.bg-surface-50) { - background-color: var(--surface-ground, #0f172a); - } +:global(html.app-dark .device-management .bg-surface-50) { + background-color: var(--surface-ground, #0f172a); +} - :deep(.bg-surface-0) { - background-color: var(--surface-card, #1e293b); - } +:global(html.app-dark .device-management .bg-surface-0) { + background-color: var(--surface-card, #1e293b); } /* Responsive design for mobile devices */ @media (max-width: 768px) { - .network-header { - padding: 0.75rem; - } - - .network-content { - padding: 0.75rem; - } /* 在小屏幕上缩短网络标签文本 */ .network-label { diff --git a/easytier-web/frontend-lib/src/components/Status.vue b/easytier-web/frontend-lib/src/components/Status.vue index 16c93e06..8710f0e1 100644 --- a/easytier-web/frontend-lib/src/components/Status.vue +++ b/easytier-web/frontend-lib/src/components/Status.vue @@ -1,17 +1,19 @@ + + diff --git a/easytier-web/frontend-lib/src/components/index.ts b/easytier-web/frontend-lib/src/components/index.ts index a1ddfab5..814b8df5 100644 --- a/easytier-web/frontend-lib/src/components/index.ts +++ b/easytier-web/frontend-lib/src/components/index.ts @@ -1,4 +1,5 @@ export { default as Config } from './Config.vue'; export { default as Status } from './Status.vue'; export { default as ConfigEditDialog } from './ConfigEditDialog.vue'; -export { default as RemoteManagement } from './RemoteManagement.vue'; \ No newline at end of file +export { default as RemoteManagement } from './RemoteManagement.vue'; +export { default as UrlListInput } from './UrlListInput.vue'; \ No newline at end of file diff --git a/easytier-web/frontend-lib/src/easytier-frontend-lib.ts b/easytier-web/frontend-lib/src/easytier-frontend-lib.ts index a47503fd..16a2f538 100644 --- a/easytier-web/frontend-lib/src/easytier-frontend-lib.ts +++ b/easytier-web/frontend-lib/src/easytier-frontend-lib.ts @@ -1,7 +1,7 @@ import './style.css' import type { App } from 'vue'; -import { Config, Status, ConfigEditDialog, RemoteManagement } from "./components"; +import { Config, Status, ConfigEditDialog, RemoteManagement, UrlListInput } from "./components"; import Aura from '@primeuix/themes/aura'; import PrimeVue from 'primevue/config' @@ -17,28 +17,48 @@ import { vTooltip } from 'floating-vue'; import * as Api from './modules/api'; import * as Utils from './modules/utils'; -export default { - install: (app: App): void => { +export interface FrontendLibOptions { + /// Skip the built-in PrimeVue theme config so the host app can install + /// its own (e.g. a class-based dark mode selector). The host must + /// install PrimeVue itself when this is set. + skipPrimeVue?: boolean; +} + +const EasytierFrontendLib: { install: (app: App, options?: FrontendLibOptions) => void } = { + install: (app: App, options: FrontendLibOptions = {}): void => { app.use(I18nUtils.i18n, { useScope: 'global' }) - app.use(PrimeVue, { - theme: { - preset: Aura, - options: { - prefix: 'p', - darkModeSelector: 'system', - cssLayer: { - name: 'primevue', - order: 'tailwind-base, primevue, tailwind-utilities' - } + if (!options.skipPrimeVue) { + app.use(PrimeVue, { + theme: { + preset: Aura, + options: { + prefix: 'p', + darkModeSelector: 'system', + cssLayer: { + name: 'primevue', + order: 'tailwind-base, primevue, tailwind-utilities' + } + }, }, - }, - zIndex: { - modal: 1100, //dialog, drawer - overlay: 1200, //select, popover - menu: 1300, //overlay menus - tooltip: 1400 //tooltip - } - }); + zIndex: { + modal: 1100, //dialog, drawer + overlay: 1200, //select, popover + menu: 1300, //overlay menus + tooltip: 1400 //tooltip + } + }); + + // The built-in theme keys PrimeVue dark mode off the OS, while the + // lib's tailwind dark: utilities key off the .app-dark class. Keep + // the class in sync with the OS so both mechanisms stay consistent + // for hosts using the default theme. + const colorScheme = window.matchMedia('(prefers-color-scheme: dark)'); + const applyOsColorScheme = () => { + document.documentElement.classList.toggle('app-dark', colorScheme.matches); + }; + applyOsColorScheme(); + colorScheme.addEventListener('change', applyOsColorScheme); + } app.component('Config', Config); app.component('ConfigEditDialog', ConfigEditDialog); @@ -49,4 +69,6 @@ export default { } }; -export { Config, ConfigEditDialog, RemoteManagement, Status, I18nUtils, NetworkTypes, Api, Utils }; +export default EasytierFrontendLib; + +export { Config, ConfigEditDialog, RemoteManagement, Status, UrlListInput, I18nUtils, NetworkTypes, Api, Utils }; diff --git a/easytier-web/frontend-lib/src/locales/cn.yaml b/easytier-web/frontend-lib/src/locales/cn.yaml index a6cb183d..fb047bfd 100644 --- a/easytier-web/frontend-lib/src/locales/cn.yaml +++ b/easytier-web/frontend-lib/src/locales/cn.yaml @@ -15,23 +15,56 @@ virtual_ipv4: 虚拟IPv4地址 virtual_ipv4_dhcp: DHCP network_name: 网络名称 network_secret: 网络密码 +secure_mode: 安全模式 +secure_mode_hint: 节点间使用 Noise 加密握手与身份认证;对端节点需支持安全模式 +credential_secret: 临时凭证 +credential_secret_hint: 粘贴管理员签发的临时凭证 +use_credential: 我有管理员签发的临时凭证 +use_network_secret: 改用网络密钥 +credential_mode_hint: 使用临时凭证加入网络:连接自动加密,到期自动失效。如果你没有收到凭证,请改用网络密钥。 public_server_url: 公共服务器地址 peer_urls: 对等节点地址 proxy_cidrs: 子网代理CIDR +vpn_portal_enable: "启用 WireGuard" +vpn_portal_setup_help: "启动节点后,在状态页的“WireGuard 设备”中添加设备并扫码连接。停用会保留密钥和设备。" +vpn_portal_advanced: "高级设置" +vpn_portal_config_clients_help: "设备变更会随成员配置一起保存并应用到节点。" +vpn_portal_devices: "WireGuard 设备" +vpn_portal_devices_help: "每台设备使用独立的连接配置。" +vpn_portal_client_address: "设备虚拟 IP" +vpn_portal_client_prefix: "网段前缀长度" +vpn_portal_address_help: "这是设备在 EasyTier 网络中的独立地址。建议值会避开当前已知的占用地址。" +vpn_portal_invalid_name: "名称不能重复,限 1–63 个英文字母、数字或连字符,首尾须为字母或数字。" +vpn_portal_invalid_address: "请输入有效且未被占用的设备 IP,并在高级设置中确认网段前缀。" +vpn_portal_generate_config: "生成连接配置" +vpn_portal_save_failed: "添加设备失败" +vpn_portal_remove_failed: "删除设备失败" +vpn_portal_remove_confirm: "删除后,此设备将无法继续连接。" +vpn_portal_connect_device: "连接配置" +vpn_portal_server_endpoint: "服务器连接地址" +vpn_portal_custom_endpoint: "自定义连接地址" +vpn_portal_endpoint_help: "默认使用本节点公网地址。使用域名或不同的映射端口时,可在此修改。" +vpn_portal_endpoint_required: "未获得有效连接地址,请填写设备可访问的 IP 或域名及 UDP 端口。" +vpn_portal_scan_help: "在设备的 WireGuard App 中扫描二维码,或导入下载的配置文件。" +vpn_portal_qr_alt: "WireGuard 连接配置二维码" +vpn_portal_qr_failed: "无法生成二维码,请下载或复制配置。" +vpn_portal_download_config: "下载配置" +vpn_portal_show_config: "查看配置文本" +vpn_portal_copy_failed: "复制失败,请下载配置。" vpn_portal_wireguard_listen: WireGuard 监听地址 vpn_portal_wireguard_listen_placeholder: 例如:0.0.0.0:22022 vpn_portal_wireguard_private_key: WireGuard 服务端私钥 vpn_portal_wireguard_private_key_placeholder: 必填 Base64 密钥(可用 wg genkey 生成) vpn_portal_clients: WireGuard 客户端 -vpn_portal_add_client: 添加客户端 -vpn_portal_no_clients: 尚未配置客户端 -vpn_portal_client_name: 客户端名称 +vpn_portal_add_client: "添加设备" +vpn_portal_no_clients: "尚未添加设备。添加后即可扫码或下载配置。" +vpn_portal_client_name: "设备名称(选填)" vpn_portal_client_name_placeholder: 例如:alice-phone vpn_portal_client_virtual_ip: 虚拟网 CIDR vpn_portal_client_virtual_ip_placeholder: 例如:10.126.126.10/24 vpn_portal_client_groups: ACL 组 vpn_portal_client_groups_placeholder: 选择 ACL 组 -vpn_portal_remove_client: 删除客户端 +vpn_portal_remove_client: "删除设备" dev_name: TUN接口名称 advanced_settings: 高级设置 basic_settings: 基础设置 @@ -116,7 +149,7 @@ upload: 上传 download: 下载 show_vpn_portal_config: 显示VPN门户配置 vpn_portal_config: VPN门户配置 -vpn_portal_not_configured: 当前节点未配置 VPN 门户 +vpn_portal_not_configured: "WireGuard 未启用或尚未开始监听。" vpn_portal_load_failed: VPN 门户信息加载失败 vpn_portal_listener: 监听地址 vpn_portal_type: 类型 @@ -124,7 +157,7 @@ vpn_portal_state: 状态 vpn_portal_peer_id: 节点 ID vpn_portal_endpoint: 客户端端点 vpn_portal_tunnel_ip: 客户端隧道地址 -vpn_portal_client_config: 客户端配置 +vpn_portal_client_config: "连接配置" vpn_portal_copy_client_config: 复制客户端配置 vpn_portal_state_unspecified: 未知 vpn_portal_state_offline: 离线 @@ -316,6 +349,70 @@ event: UdpBroadcastRelayStartResult: UDP广播中继启动结果 web: + console: + theme_mode: 主题(浅色 / 深色 / 跟随系统) + location: "位置" + console: "管理控制台" + workspace: "工作空间" + navigation: "导航" + documentation: "使用文档" + language: "切换语言" + account: "账户" + skip_content: "跳转到正文" + devices_description: "查看接入的设备,管理设备上的网络实例。" + device_count: "设备" + online_device_count: "在线设备" + network_count: "管理网络" + instance_count: "运行实例" + device_note: "注册过的设备总数" + online_device_note: "当前在线的设备数量" + delete_device: "删除设备" + set_alias: "设置别名" + alias_dialog_title: "设置设备别名" + alias_hint: "别名将在控制台中替代主机名显示,留空即清除。" + delete_device_confirm: "确定删除设备 {device}?若它在网络中,将被移出并回收托管配置。" + block_device: "同时拉黑该设备" + block_device_hint: "拉黑后该设备将无法重新接入,可在顶栏通知中解除。" + no_block_device_hint: "不拉黑:设备重新连接控制台后会自动回到设备列表。" + enroll_title: "设备接入" + enroll_hint: "在设备上运行以下命令,将其接入本控制台托管:" + enroll_copy: "复制接入命令" + enroll_token_note: "命令末尾的路径是接入令牌(当前用户名)。设备接入后会出现在设备列表中。" + enroll_webhook_note: "本控制台使用外部认证系统签发接入令牌,请使用认证系统分配的令牌替换命令末尾的用户名。" + blocked_devices: "设备黑名单" + blocked_empty: "暂无拉黑的设备。" + blocked_attempted: "{time} 尝试接入,已拦截(共 {count} 次)" + blocked_no_attempt: "已拉黑,暂无接入尝试。" + unblock: "解除拉黑" + network_note: "集中管理的网络数量" + instance_note: "接入设备上的运行实例" + view_all: "查看全部" + refresh: "刷新" + retry: "重试" + load_failed: "数据刷新失败,已显示的数据来自上次成功刷新。" + devices_empty: "还没有设备" + devices_empty_hint: "将 EasyTier 客户端连接至此控制器,即可在这里管理设备。" + networks_empty: "还没有管理网络" + networks_empty_hint: "创建网络后,添加设备来建立连接。" + search_devices: "搜索名称或地址" + search_networks: "搜索网络" + no_results: "没有匹配结果" + clear_search: "清除搜索" + manage: "管理" + details: "详细信息" + more_details: "更多详情" + items: "共 {count} 项" + automatic_refresh: "每 2 秒自动更新" + online_members: "在线 / 总成员" + back_networks: "返回网络列表" + all_details: "展开全部详情" + ascending: "升序" + descending: "降序" + device_missing: "设备已不在当前设备列表中。" + loading: "加载中" + instances: "个实例" + view_table: "表格视图" + view_card: "卡片视图" login: title: 登录 username: 用户名 @@ -323,7 +420,6 @@ web: submit: 登录 register: 注册 remember_me: 记住我 - api_host: API主机 captcha: 验证码 back_to_login: 返回登录 login: 登录 @@ -346,6 +442,7 @@ web: logout: 退出登录 language: 语言 change_password: 修改密码 + network_list: 网络 device: list: 设备列表 @@ -358,16 +455,6 @@ web: offline: 离线 last_seen: 最后在线 no_devices: 未找到设备 - sort_by: 排序依据 - sort_direction: 排序方向 - show_detailed_view: 显示详情 - hide_detailed_view: 隐藏详情 - sort_by_hostname: 主机名 - sort_by_public_ip: 公网IP - sort_by_version: 版本 - sort_by_networks: 网络数量 - sort_direction_asc: 当前升序,点击切换为降序 - sort_direction_desc: 当前降序,点击切换为升序 hostname: 主机名 public_ip: 公网IP networks: 网络数量 @@ -375,6 +462,7 @@ web: version: 版本 machine_id: 机器ID unknown_location: 未知位置 + central_networks: 所属网络 device_management: edit_network: 编辑网络 @@ -410,6 +498,188 @@ web: import: 导入配置 export: 导出配置 + network_list: + title: 网络 + create: 创建网络 + empty: 暂无网络,点击“创建网络”开始集中管理设备 + members: 成员设备 + display_name: 网络显示名称 + virtual_cidr: 虚拟网段 + secure_mode: 安全模式 + secure_mode_hint: 节点间使用 Noise 加密握手与身份认证;开启后可签发临时凭证,设备实例将重建 + virtual_cidr_hint: 设置后成员将按加入顺序从该网段分配静态 IP(10.x.0.1、.2…);留空则由节点自动协商(DHCP) + virtual_cidr_placeholder: 例如 10.200.0.0/24,留空自动 + peer_urls_placeholder: 每行一个节点地址,例如 tcp://10.0.0.1:11010 + create_hint: 网络密码将自动生成,可在网络详情中查看或重置 + gateway_mode: 接入点 + gateway_hint: 成员将通过本控制台的内置接入点自动组网(发现与打洞辅助,默认不中继数据) + advanced_mode: 高级:手动指定初始节点 + use_gateway: 返回:使用控制台接入点 + + network_detail: + tab_members: 成员设备 + mesh_name: 网络标识(自动生成,设备加入时使用) + tab_settings: 网络设置 + tab_status: 运行状态 + members_hint: 成员的组网配置由控制台统一下发,设备离线时配置将在其重新上线后生效 + add_member: 添加设备 + add_temporary_member: 添加为临时节点 + add_temporary_hint: 临时节点使用专属凭证接入,不会收到网络密钥;凭证到期或被吊销后自动断开 + add_temporary_needs_secure: 需要先在网络设置中开启安全模式才能添加临时节点 + expires_at: 到期 + locate_credential: 查看凭证 + no_candidate_devices: 暂无可添加的设备 + remove_member: 移除设备 + remove_member_confirm: 确定将设备 {device} 移出该网络?其托管配置将被回收 + edit_member: 编辑成员 + tab_basic_settings: 常规设置 + hostname_override: 主机名覆盖 + hostname_override_placeholder: 留空使用设备上报的主机名 + static_ip: 静态虚拟 IP + static_ip_hint: 留空使用 DHCP;指定后该成员固定使用此 IP(/24) + settings: 网络设置 + regenerate_secret: 重新生成网络密码 + danger_zone: 危险操作 + delete: 删除网络 + delete_confirm: 确定删除网络 {name}?所有成员的托管配置将被回收 + delete_hint: 删除网络会移除所有成员并回收其托管配置 + gateway_followed: 初始节点仅保留控制台接入点,成员配置将跟随接入点地址自动更新 + advanced_config: 高级配置 + has_override: 该成员已配置高级覆盖 + identity_ignored: 网络名称与密码由网络设置统一管理,本次修改将被忽略 + reset_default: 恢复默认 + config_reset: 已恢复默认编译配置 + proxy_cidr_hint: 该成员路由进虚拟网的真实网段;点击下方检测到的网段即可添加 + proxy_add_suggestion: 添加 {cidr} + proxy_remove_suggestion: 移除 {cidr} + proxy_no_suggestions: 未检测到可共享的本机网段,可手动输入 + proxy_cidr_placeholder: 例如 192.168.1.1 或 10.0.0.0/16,回车添加 + proxy_cidr_invalid: 无法识别的地址,请输入 IPv4 地址或 CIDR(如 192.168.1.0/24) + tab_credentials: 临时凭证 + credentials_hint: 凭证用于临时设备短期接入本网络,到期自动失效 + generate_credential: 生成凭证 + credential_id: 凭证 ID + credential_secret: 凭证密钥 + credential_show_secret: 显示密钥 + credential_hide_secret: 隐藏密钥 + credential_copy_secret: 复制密钥 + credential_show_command: 查看加入命令 + credential_copy_command: 复制加入命令 + credential_join_cli: 命令行 + credential_join_toml: 配置文件 + copy_failed: 复制失败,请选中文本手动复制 + credential_online_devices: 在线设备 + credential_expiry: 到期时间 + credential_status: 状态 + credential_active: 有效 + credential_expired: 已过期 + credential_reusable: 可复用 + revoke_credential: 吊销凭证 + revoke_credential_confirm: 确定吊销凭证 {id}?使用它的临时设备将被断开 + credential_ttl: 有效期 + ttl_1h: 1 小时 + ttl_24h: 1 天 + ttl_7d: 7 天 + ttl_30d: 30 天 + credential_reusable_hint: 取消勾选则同一凭证同时只允许一台设备在线 + credential_secret_hint: "凭证密钥(已保存,可随时在凭证列表中查看):" + join_command: 临时设备加入命令 + temporary_devices: 临时设备 + temporary_devices_hint: 通过凭证接入的在线设备,吊销对应凭证可将其断开 + temporary_device: 临时设备 + temporary_tag: 临时 + temporary_credential: 凭证 + temporary_credential_unknown: 未知凭证(可能已吊销) + member_error: 错误 + tab_general: 常规 + node_overview: 概览 + node_actions: 设置与操作 + node_peers: 对等节点 + node_no_peers: 暂无其他节点 + node_no_peers_hint: 此网络的其他节点连接后,会显示在这里。 + peer_ipv4: 虚拟 IP + log_level: 日志级别 + log_level_hint: 对整台设备进程生效(含所有网络实例) + log_disabled: 禁用 + log_error: 错误 + log_warning: 警告 + log_info: 信息 + log_debug: 调试 + log_trace: 跟踪 + export_config: 导出配置 + export_config_hint: 该节点当前生效的 TOML 配置(同 easytier-cli node config) + download: 下载 + node_detail: 节点详情 + node_detail_offline: 设备在线时才能查看节点详情 + node_routes: 路由 + node_conns: 连接明细 + route_cost: 连接方式 + path_latency: 路径延迟 + conn_type: 类型 + conn_remote: 对端地址 + loss_rate: 丢包率 + traffic: 流量 + acl_stats: ACL 统计 + acl_rule: 规则 + acl_packets: 包数 + acl_bytes: 字节数 + instance_state: 实例状态 + running: 运行中 + stopped: 未运行 + + acl: + trust_hint: 永久成员属于可信管理员。ACL 用于管理其正常流量,不能防止设备管理员修改身份或策略。不可信设备请使用临时凭据接入。 + tab: 访问控制 + default_action: 未命中规则时 + default_allow: 默认允许 + default_deny: 默认拒绝 + default_allow_hint: 未被规则命中的流量默认放行 + default_deny_hint: 未被规则命中的流量默认拒绝 + rule_summary: "{total} 条规则 · {enabled} 条启用" + unsaved: 有未保存的修改 + saved: 访问策略已保存 + save_failed: 保存失败 + save: 保存策略 + add_rule: 新增规则 + edit_rule: 编辑规则 + save_rule: 保存规则 + rule_name: 规则名称 + name_placeholder: 例如:允许财务设备访问 MySQL + action: 动作 + allow: 允许 + deny: 拒绝 + enabled: 启用 + disabled: 已停用 + source: 来源 + target: 目标 + all_members: 全部成员 + subnet: 子网 + source_placeholder: 搜索并选择发起访问的成员 + target_placeholder: 搜索并选择目标成员或子网 + target_hint: 选择成员或成员代理的子网 + protocols: 协议 + stateful: 有状态跟踪 + stateful_hint: 仅统计已建立连接的流量信息,适用于 TCP 允许规则 + port_single: 单端口 + port_range: 范围 + port_start: 起始端口 + port_end: 结束端口 + port_to: 到 + add_port: 新增端口 + add_range: 新增范围 + all_ports: 全部端口 + quick_add: 快捷添加 + icmp_note: ICMP 不需要填写端口 + swap: 与目标交换 + drag_hint: 拖拽调整顺序 + empty_title: 暂无访问规则 + empty_allow: 网络当前保持默认放行,未命中的流量不受限制 + empty_deny: 网络当前默认拒绝,未命中规则的流量将被拦截 + error_name: 请填写规则名称 + error_source: 请至少选择一个来源 + error_target: 请至少选择一个目标或填写自定义网段 + error_protocol: 请至少勾选一个协议 + error_ports: "{protocol} 规则至少需要一个端口(可点“全部端口”)" common: confirm: 确认 cancel: 取消 diff --git a/easytier-web/frontend-lib/src/locales/en.yaml b/easytier-web/frontend-lib/src/locales/en.yaml index e988ad45..60fedd4d 100644 --- a/easytier-web/frontend-lib/src/locales/en.yaml +++ b/easytier-web/frontend-lib/src/locales/en.yaml @@ -15,23 +15,56 @@ virtual_ipv4: Virtual IPv4 virtual_ipv4_dhcp: DHCP network_name: Network Name network_secret: Network Secret +secure_mode: Secure Mode +secure_mode_hint: Noise-encrypted handshake between nodes; peers must support secure mode +credential_secret: Temporary Credential +credential_secret_hint: Paste the credential issued by your network admin +use_credential: I have a temporary credential from my admin +use_network_secret: Use the network secret instead +credential_mode_hint: "Join with an admin-issued credential: connections are encrypted automatically and expire on their own. If you did not receive one, use the network secret instead." public_server_url: Public Server URL peer_urls: Peer URLs proxy_cidrs: Subnet Proxy CIDRs +vpn_portal_enable: "Enable WireGuard" +vpn_portal_setup_help: "After starting the node, open WireGuard devices on the status page to add a device and scan its QR code. Disabling keeps your keys and devices." +vpn_portal_advanced: "Advanced settings" +vpn_portal_config_clients_help: "Device changes are saved with the member configuration and applied to the node." +vpn_portal_devices: "WireGuard devices" +vpn_portal_devices_help: "Each device has its own connection configuration." +vpn_portal_client_address: "Device virtual IP" +vpn_portal_client_prefix: "Network prefix length" +vpn_portal_address_help: "The device uses its own address in the EasyTier network. Suggested addresses exclude known occupied addresses." +vpn_portal_invalid_name: "Use a unique name with 1–63 letters, digits or hyphens, starting and ending with a letter or digit." +vpn_portal_invalid_address: "Enter a valid, unused device IP and check the network prefix in advanced settings." +vpn_portal_generate_config: "Generate connection config" +vpn_portal_save_failed: "Failed to add device" +vpn_portal_remove_failed: "Failed to delete device" +vpn_portal_remove_confirm: "This device will no longer be able to connect." +vpn_portal_connect_device: "Connection config" +vpn_portal_server_endpoint: "Server address" +vpn_portal_custom_endpoint: "Custom connection address" +vpn_portal_endpoint_help: "Uses this node’s public address by default. Override it to use a domain or a different forwarded port." +vpn_portal_endpoint_required: "No valid connection address is available. Enter a reachable IP or domain and UDP port." +vpn_portal_scan_help: "Scan the QR code in the WireGuard app, or import the downloaded configuration file." +vpn_portal_qr_alt: "WireGuard connection QR code" +vpn_portal_qr_failed: "Could not generate the QR code. Download or copy the configuration instead." +vpn_portal_download_config: "Download config" +vpn_portal_show_config: "Show config text" +vpn_portal_copy_failed: "Could not copy the configuration. Please download it." vpn_portal_wireguard_listen: WireGuard Listen Address vpn_portal_wireguard_listen_placeholder: "Example: 0.0.0.0:22022" vpn_portal_wireguard_private_key: WireGuard Server Private Key vpn_portal_wireguard_private_key_placeholder: Required base64 key (generate with wg genkey) vpn_portal_clients: WireGuard Clients -vpn_portal_add_client: Add Client -vpn_portal_no_clients: No clients configured -vpn_portal_client_name: Client Name +vpn_portal_add_client: "Add device" +vpn_portal_no_clients: "No devices yet. Add one to scan a QR code or download its configuration." +vpn_portal_client_name: "Device name (optional)" vpn_portal_client_name_placeholder: "Example: alice-phone" vpn_portal_client_virtual_ip: Virtual Network CIDR vpn_portal_client_virtual_ip_placeholder: "Example: 10.126.126.10/24" vpn_portal_client_groups: ACL Groups vpn_portal_client_groups_placeholder: Select ACL groups -vpn_portal_remove_client: Remove Client +vpn_portal_remove_client: "Delete device" dev_name: TUN interface name advanced_settings: Advanced Settings basic_settings: Basic Settings @@ -115,7 +148,7 @@ upload: Upload download: Download show_vpn_portal_config: Show VPN Portal Config vpn_portal_config: VPN Portal Config -vpn_portal_not_configured: VPN Portal is not configured on this node +vpn_portal_not_configured: "WireGuard is disabled or has not started listening yet." vpn_portal_load_failed: Failed to load VPN Portal information vpn_portal_listener: Listener vpn_portal_type: Type @@ -123,7 +156,7 @@ vpn_portal_state: State vpn_portal_peer_id: Peer ID vpn_portal_endpoint: Client Endpoint vpn_portal_tunnel_ip: Client Tunnel Address -vpn_portal_client_config: Client Config +vpn_portal_client_config: "Connection config" vpn_portal_copy_client_config: Copy Client Config vpn_portal_state_unspecified: Unknown vpn_portal_state_offline: Offline @@ -316,6 +349,70 @@ event: UdpBroadcastRelayStartResult: UDP Broadcast Relay Start Result web: + console: + theme_mode: Theme (light / dark / system) + location: "Location" + console: "Management console" + workspace: "Workspace" + navigation: "Navigation" + documentation: "Documentation" + language: "Switch language" + account: "Account" + skip_content: "Skip to content" + devices_description: "Inspect connected devices and manage their network instances." + device_count: "Devices" + online_device_count: "Online" + network_count: "Managed networks" + instance_count: "Running instances" + device_note: "Total registered devices" + online_device_note: "Devices currently online" + delete_device: "Delete Device" + set_alias: "Set Alias" + alias_dialog_title: "Set Device Alias" + alias_hint: "The alias is shown in place of the hostname across the console. Leave empty to clear." + delete_device_confirm: "Delete device {device}? It will be removed from its networks and its managed configs withdrawn." + block_device: "Also block this device" + block_device_hint: "A blocked device cannot re-register; unblock it from the bell menu." + no_block_device_hint: "Without blocking, the device re-registers itself automatically on its next connection." + enroll_title: "Device Enrollment" + enroll_hint: "Run this command on a device to enroll it with this console:" + enroll_copy: "Copy enrollment command" + enroll_token_note: "The path at the end is the enrollment token (your username). Enrolled devices show up in the device list." + enroll_webhook_note: "This console issues enrollment tokens through an external auth system; replace the username at the end with the token it assigned." + blocked_devices: "Blocked Devices" + blocked_empty: "No blocked devices." + blocked_attempted: "{time} connection attempt blocked ({count} total)" + blocked_no_attempt: "Blocked, no connection attempts so far." + unblock: "Unblock" + network_note: "Centrally managed networks" + instance_note: "Across connected devices" + view_all: "View all" + refresh: "Refresh" + retry: "Retry" + load_failed: "Unable to refresh data. Any displayed data is from the last successful refresh." + devices_empty: "No devices yet" + devices_empty_hint: "Connect an EasyTier client to this controller to start managing it here." + networks_empty: "No managed networks yet" + networks_empty_hint: "Create a network, then add devices to connect them." + search_devices: "Search name or address" + search_networks: "Search networks" + no_results: "No matching results" + clear_search: "Clear search" + manage: "Manage" + details: "Details" + more_details: "More details" + items: "{count} items" + automatic_refresh: "Updates automatically every 2 seconds" + online_members: "Online / total members" + back_networks: "Back to networks" + all_details: "Expand all details" + ascending: "Ascending" + descending: "Descending" + device_missing: "This device is no longer in the device list." + loading: "Loading" + instances: "instances" + view_table: "Table view" + view_card: "Card view" login: title: Login username: Username @@ -323,7 +420,6 @@ web: submit: Login register: Register remember_me: Remember Me - api_host: API Host captcha: Captcha back_to_login: Back to Login login: Login @@ -346,6 +442,7 @@ web: logout: Logout language: Language change_password: Change Password + network_list: Networks device: list: Device List @@ -358,16 +455,6 @@ web: offline: Offline last_seen: Last Seen no_devices: No Devices Found - sort_by: Sort By - sort_direction: Sort Direction - show_detailed_view: Show Details - hide_detailed_view: Hide Details - sort_by_hostname: Hostname - sort_by_public_ip: Public IP - sort_by_version: Version - sort_by_networks: Network Count - sort_direction_asc: Currently ascending, click to switch to descending - sort_direction_desc: Currently descending, click to switch to ascending hostname: Hostname public_ip: Public IP networks: Network Count @@ -375,6 +462,7 @@ web: version: Version machine_id: Machine ID unknown_location: Unknown Location + central_networks: Joined Networks device_management: edit_network: Edit Network @@ -410,6 +498,188 @@ web: import: Import Config export: Export Config + network_list: + title: Networks + create: Create Network + empty: No networks yet. Click "Create Network" to manage devices centrally + members: Member Devices + display_name: Display Name + virtual_cidr: Virtual Subnet + secure_mode: Secure Mode + secure_mode_hint: Noise encrypted handshakes with identity verification; enables temporary credentials. Member instances restart on change + virtual_cidr_hint: When set, members get static IPs assigned from this subnet in join order (.1, .2, ...); leave empty for node-negotiated DHCP + virtual_cidr_placeholder: e.g. 10.200.0.0/24, empty for auto + peer_urls_placeholder: One peer URL per line, e.g. tcp://10.0.0.1:11010 + create_hint: The network secret is generated automatically and can be viewed or reset in the network detail page + gateway_mode: Gateway + gateway_hint: Members are meshed automatically through this console's built-in gateway (discovery and hole-punch assistance, no data relay by default) + advanced_mode: "Advanced: specify initial nodes manually" + use_gateway: "Back: use the console gateway" + + network_detail: + tab_members: Members + mesh_name: Mesh name (auto-generated, used when joining devices) + tab_settings: Settings + tab_status: Status + members_hint: Member configs are delivered centrally by this console; changes apply when offline devices reconnect + add_member: Add Devices + add_temporary_member: Add as temporary node + add_temporary_hint: Temporary nodes join with a dedicated credential and never receive the network secret; they disconnect when it expires or is revoked + add_temporary_needs_secure: Enable secure mode in the network settings before adding temporary nodes + expires_at: expires + locate_credential: Show credential + no_candidate_devices: No devices available to add + remove_member: Remove Device + remove_member_confirm: Remove device {device} from this network? Its managed config will be withdrawn + edit_member: Edit Member + tab_basic_settings: Basic Settings + hostname_override: Hostname Override + hostname_override_placeholder: Leave empty to use the device-reported hostname + static_ip: Static Virtual IP + static_ip_hint: Leave empty for DHCP; a fixed IP (/24) is assigned when set + settings: Network Settings + regenerate_secret: Regenerate network secret + danger_zone: Danger Zone + delete: Delete Network + delete_confirm: Delete network {name}? Managed configs of all members will be withdrawn + delete_hint: Deleting a network removes all members and withdraws their managed configs + gateway_followed: The initial-node list keeps only the console gateway; member configs follow the gateway URL automatically + advanced_config: Advanced Config + has_override: This member has an advanced override + identity_ignored: Network name and secret are owned by the network settings; those edits are ignored + reset_default: Reset to Default + config_reset: Member reset to default compilation + proxy_cidr_hint: Real subnets this member routes into the mesh; click a detected subnet below to add it + proxy_add_suggestion: Add {cidr} + proxy_remove_suggestion: Remove {cidr} + proxy_no_suggestions: No shareable local subnet detected; enter one manually + proxy_cidr_placeholder: e.g. 192.168.1.1 or 10.0.0.0/16, Enter to add + proxy_cidr_invalid: Unrecognized address; enter an IPv4 address or CIDR like 192.168.1.0/24 + tab_credentials: Credentials + credentials_hint: Credentials grant temporary devices short-lived access to this network; they expire automatically + generate_credential: Generate Credential + credential_id: Credential ID + credential_secret: Credential Secret + credential_show_secret: Show secret + credential_hide_secret: Hide secret + credential_copy_secret: Copy secret + credential_show_command: Show join command + credential_copy_command: Copy join command + credential_join_cli: Command line + credential_join_toml: Config file + copy_failed: Copy failed; select the text and copy manually + credential_online_devices: Online Devices + credential_expiry: Expires + credential_status: Status + credential_active: Active + credential_expired: Expired + credential_reusable: Reusable + revoke_credential: Revoke + revoke_credential_confirm: Revoke credential {id}? Devices using it will be disconnected + credential_ttl: Validity + ttl_1h: 1 hour + ttl_24h: 1 day + ttl_7d: 7 days + ttl_30d: 30 days + credential_reusable_hint: "Unchecked means only one device may use the credential at a time" + credential_secret_hint: "Credential secret (saved and always visible in the credential list):" + join_command: Temporary device join command + temporary_devices: Temporary Devices + temporary_devices_hint: Devices online via credentials; revoke the credential to disconnect them + temporary_device: Temporary device + temporary_tag: Temporary + temporary_credential: Credential + temporary_credential_unknown: unknown credential (possibly revoked) + member_error: Error + tab_general: General + node_overview: Overview + node_actions: Settings & actions + node_peers: Peers + node_no_peers: No other nodes yet + node_no_peers_hint: Other nodes in this network will appear here when they connect. + peer_ipv4: Virtual IP + log_level: Log Level + log_level_hint: Applies to the whole device process (all instances) + log_disabled: Disabled + log_error: Error + log_warning: Warning + log_info: Info + log_debug: Debug + log_trace: Trace + export_config: Export Config + export_config_hint: The node's effective TOML config (as easytier-cli node config) + download: Download + node_detail: Node Detail + node_detail_offline: Node detail requires the device to be online + node_routes: Routes + node_conns: Connections + route_cost: Path + path_latency: Latency + conn_type: Type + conn_remote: Remote + loss_rate: Loss + traffic: Traffic + acl_stats: ACL Stats + acl_rule: Rule + acl_packets: Packets + acl_bytes: Bytes + instance_state: Instance State + running: Running + stopped: Stopped + + acl: + trust_hint: Permanent members are trusted administrators. ACL rules manage their traffic but do not prevent a device administrator from changing its identity or policy. Use temporary credentials for untrusted devices. + tab: Access Control + default_action: When no rule matches + default_allow: Allow by default + default_deny: Deny by default + default_allow_hint: Traffic that matches no rule is allowed + default_deny_hint: Traffic that matches no rule is dropped + rule_summary: "{total} rules · {enabled} enabled" + unsaved: Unsaved changes + saved: Access policy saved + save_failed: Save failed + save: Save Policy + add_rule: Add Rule + edit_rule: Edit Rule + save_rule: Save Rule + rule_name: Rule Name + name_placeholder: "For example: allow finance devices to reach MySQL" + action: Action + allow: Allow + deny: Deny + enabled: Enabled + disabled: Disabled + source: Source + target: Target + all_members: All members + subnet: Subnet + source_placeholder: Search and choose the members that initiate access + target_placeholder: Search and choose target members or subnets + target_hint: Pick members or subnets proxied by members + protocols: Protocols + stateful: Stateful tracking + stateful_hint: Track established TCP connections; applies to allow rules + port_single: Single + port_range: Range + port_start: Start port + port_end: End port + port_to: to + add_port: Add Port + add_range: Add Range + all_ports: All Ports + quick_add: Quick add + icmp_note: ICMP needs no ports + swap: Swap with target + drag_hint: Drag to reorder + empty_title: No access rules yet + empty_allow: The network keeps default-allow behavior; unmatched traffic is unrestricted + empty_deny: The network denies unmatched traffic by default + error_name: Rule name is required + error_source: Pick at least one source + error_target: Pick at least one target or add a custom CIDR + error_protocol: Pick at least one protocol + error_ports: "{protocol} rules need at least one port (or use All Ports)" common: confirm: Confirm cancel: Cancel diff --git a/easytier-web/frontend-lib/src/modules/utils.ts b/easytier-web/frontend-lib/src/modules/utils.ts index 3b29086a..089a95cd 100644 --- a/easytier-web/frontend-lib/src/modules/utils.ts +++ b/easytier-web/frontend-lib/src/modules/utils.ts @@ -82,8 +82,15 @@ export interface Location { region: string | undefined; } +export interface DeviceNetwork { + network_id: string; + display_name: string; + network_name: string; +} + export interface DeviceInfo { hostname: string; + alias?: string; public_ip: string; running_network_count: number; report_time: string; @@ -91,12 +98,16 @@ export interface DeviceInfo { running_network_instances?: Array; machine_id: string; location: Location | undefined; + networks?: Array; + online?: boolean; + last_seen?: string; } export function buildDeviceInfo(device: any): DeviceInfo { const runningInstances = device.info?.running_network_instances ?? []; let dev_info: DeviceInfo = { hostname: device.info?.hostname, + alias: device.alias || undefined, public_ip: device.client_url, running_network_instances: runningInstances.map((instance: any) => UuidToStr(instance)), running_network_count: runningInstances.length, @@ -104,6 +115,9 @@ export function buildDeviceInfo(device: any): DeviceInfo { easytier_version: device.info?.easytier_version, machine_id: UuidToStr(device.info?.machine_id), location: device.location, + networks: device.networks ?? [], + online: device.online ?? false, + last_seen: device.last_seen, }; return dev_info; diff --git a/easytier-web/frontend-lib/src/modules/vpnPortal.ts b/easytier-web/frontend-lib/src/modules/vpnPortal.ts new file mode 100644 index 00000000..7d82d82d --- /dev/null +++ b/easytier-web/frontend-lib/src/modules/vpnPortal.ts @@ -0,0 +1,102 @@ +import { IPv4 } from 'ip-num/IPNumber' +import { IPv4CidrRange } from 'ip-num/IPRange' +import type { NetworkInstance, NodeInfo, VpnPortalClientConfig, VpnPortalConfig } from '../types/network' +import { ipv4ToString, ipv6ToString } from './utils' + +export function createVpnPortalConfig(): VpnPortalConfig { + const key = crypto.getRandomValues(new Uint8Array(32)) + key[0] &= 248 + key[31] = (key[31] & 127) | 64 + return { + wireguard_listen: '0.0.0.0:22022', + wireguard_private_key: btoa(String.fromCharCode(...key)), + clients: [], + } +} + +export function vpnPortalListener(listener: string): URL | undefined { + try { + return new URL(listener.includes('://') ? listener : `wg://${listener}`) + } catch { + return undefined + } +} + +export function vpnPortalEndpoint(listener: string, node?: NodeInfo): string { + const url = vpnPortalListener(listener) + if (!url?.port || url.port === '0') return '' + + const ipv4 = node?.ips?.public_ipv4?.addr ? ipv4ToString(node.ips.public_ipv4) : '' + const ipv6 = node?.ips?.public_ipv6 + const publicIpv6 = ipv6 && [ipv6.part1, ipv6.part2, ipv6.part3, ipv6.part4].some(part => part) + ? `[${ipv6ToString(ipv6)}]` : '' + const wildcard = url.hostname === '0.0.0.0' || url.hostname === '[::]' + const host = wildcard ? ipv4 || publicIpv6 : url.hostname + return host ? `${host}:${url.port}` : '' +} + +export function normalizeVpnPortalEndpoint(value: string, port: string): string { + const input = value.trim() + if (!input || /[\s/#?@]/.test(input)) return '' + const url = vpnPortalListener(input) + if (!url || ['0.0.0.0', '[::]'].includes(url.hostname)) return '' + const resolvedPort = url.port || port + if (!resolvedPort || Number(resolvedPort) < 1 || Number(resolvedPort) > 65535) return '' + return `${url.hostname}:${resolvedPort}` +} + +export function vpnPortalClientConfig(config: string, endpoint: string): string { + if (!config || !endpoint) return '' + return config.replace(/^Endpoint\s*=.*$/m, `Endpoint = ${endpoint}`) +} + +export function vpnPortalIpv4(value: string): number | undefined { + try { + return Number(IPv4.fromString(value).getValue()) + } catch { + return undefined + } +} + +export function vpnPortalUsedIps(instance: NetworkInstance, clients: { virtual_ip: string }[]): Set { + const addresses = clients.map(client => vpnPortalIpv4(client.virtual_ip.split('/')[0])) + addresses.push(instance.detail?.my_node_info?.virtual_ipv4?.address?.addr) + for (const pair of instance.detail?.peer_route_pairs ?? []) { + const address = pair.route?.ipv4_addr + addresses.push(typeof address === 'string' ? vpnPortalIpv4(address.split('/')[0]) : address?.address?.addr) + } + return new Set(addresses.filter((address): address is number => address !== undefined)) +} + +export function suggestVpnPortalAddress( + instance: NetworkInstance, + clients: VpnPortalClientConfig[], + used: Set, +): { address: string, prefix?: number } { + const local = instance.detail?.my_node_info?.virtual_ipv4 + const cidr = clients[0]?.virtual_ip + || (local?.address?.addr ? `${ipv4ToString(local.address)}/${local.network_length}` : '') + try { + const range = IPv4CidrRange.fromCidr(cidr) + const first = Number(range.getFirst().getValue()) + const last = Number(range.getLast().getValue()) + const prefix = Number(range.getPrefix().getValue()) + for (let address = first + 1; address < last; address++) { + if (!used.has(address)) return { address: IPv4.fromNumber(address).toString(), prefix } + } + return { address: '', prefix } + } catch { + return { address: '' } + } +} + +export function validVpnPortalAddress(address: string, prefix: number | undefined, used: Set): boolean { + const parsed = vpnPortalIpv4(address) + if (parsed === undefined || used.has(parsed) || prefix === undefined) return false + try { + const range = IPv4CidrRange.fromCidr(`${address}/${prefix}`) + return parsed > Number(range.getFirst().getValue()) && parsed < Number(range.getLast().getValue()) + } catch { + return false + } +} diff --git a/easytier-web/frontend-lib/src/types/network.ts b/easytier-web/frontend-lib/src/types/network.ts index cea23fb2..2f4a0c41 100644 --- a/easytier-web/frontend-lib/src/types/network.ts +++ b/easytier-web/frontend-lib/src/types/network.ts @@ -48,6 +48,10 @@ export type NetworkConfig = Omit< mtu: number | null instance_recv_bps_limit: number | string | null networking_method: NetworkingMethod | string + /// Form-only field: an admin-issued credential secret for joining as a + /// temporary device. Saved as secure_mode.local_private_key with an empty + /// network_secret - the CLI `--credential` equivalent. + credential_secret?: string } export type NormalizedAclV1 = AclV1 & { @@ -323,6 +327,15 @@ export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig { } normalized.acl = config.acl === undefined ? undefined : normalizeAcl(normalized.acl) + // A credential-mode instance (no network secret, private key from the + // admin-issued credential) surfaces its key in the form's credential + // field instead of a raw secure_mode key. + normalized.credential_secret = config.credential_secret + if (!normalized.network_secret && normalized.secure_mode?.local_private_key) { + normalized.credential_secret ??= normalized.secure_mode.local_private_key + normalized.secure_mode = { enabled: true } + } + return normalized } @@ -338,6 +351,15 @@ export function toBackendNetworkConfig(config: NetworkConfig): NetworkConfig { backend.acl = undefined } + // The form credential field compiles into the credential-identity shape: + // empty network secret plus secure mode keyed by the credential (the CLI + // `--credential` equivalent); the node derives the matching public key. + const credentialSecret = (config.credential_secret ?? '').trim() + if (credentialSecret) { + backend.network_secret = undefined + backend.secure_mode = { enabled: true, local_private_key: credentialSecret } + } + return NetworkConfigPb.toJson(backend, { useProtoFieldName: true, }) as unknown as NetworkConfig diff --git a/easytier-web/frontend-lib/src/types/networkCompat.ts b/easytier-web/frontend-lib/src/types/networkCompat.ts index 4368b480..c76a415e 100644 --- a/easytier-web/frontend-lib/src/types/networkCompat.ts +++ b/easytier-web/frontend-lib/src/types/networkCompat.ts @@ -45,6 +45,10 @@ function applyLegacyAclDefaults(config: NetworkConfig): NetworkConfig { function dropUnsupportedJsonValues(value: unknown): unknown { if (value === undefined) return undefined if (typeof value === 'number' && !Number.isFinite(value)) return undefined + // Form state may hold protobuf-ts bigint values (int64/uint64 fields such + // as managed credential expiry); proto3 JSON represents them as strings, + // and fromJson rejects raw bigints. + if (typeof value === 'bigint') return value.toString() if (Array.isArray(value)) { return value.map(dropUnsupportedJsonValues).filter((v) => v !== undefined) diff --git a/easytier-web/frontend-lib/tailwind.config.js b/easytier-web/frontend-lib/tailwind.config.js index b31957b5..510dd3ac 100644 --- a/easytier-web/frontend-lib/tailwind.config.js +++ b/easytier-web/frontend-lib/tailwind.config.js @@ -4,6 +4,7 @@ export default { './index.html', './src/**/*.{vue,js,ts,jsx,tsx}', ], + darkMode: ['class', '.app-dark'], theme: { extend: {}, }, diff --git a/easytier-web/frontend-lib/tests/config-ui.spec.ts b/easytier-web/frontend-lib/tests/config-ui.spec.ts index 7da4745b..106a8bc1 100644 --- a/easytier-web/frontend-lib/tests/config-ui.spec.ts +++ b/easytier-web/frontend-lib/tests/config-ui.spec.ts @@ -4,6 +4,7 @@ import { defineComponent, h, nextTick, reactive } from 'vue' import Config from '../src/components/Config.vue' import { DEFAULT_NETWORK_CONFIG, + normalizeNetworkConfig, toBackendNetworkConfig, type NetworkConfig, } from '../src/types/network' @@ -419,10 +420,6 @@ describe('Config.vue network config projection', () => { expect(input(wrapper, '#hostname').value).toBe('host-a') expect(input(wrapper, '#subnet-proxy').value).toBe('10.10.0.0/16,172.16.1.0/24') expect(input(wrapper, '#vpn_portal_wireguard_listen').value).toBe('0.0.0.0:22023') - expect(input(wrapper, '#vpn_portal_wireguard_private_key').value).toBe('portal-private-key') - expect(input(wrapper, '#vpn_portal_client_name_0').value).toBe('phone-a') - expect(input(wrapper, '#vpn_portal_client_virtual_ip_0').value).toBe('10.1.2.10/24') - expect(input(wrapper, '#vpn_portal_client_groups_0').value).toBe('ops') expect(input(wrapper, '#dev_name').value).toBe('tun-test') expect(input(wrapper, '#mtu').value).toBe('1280') expect(input(wrapper, '#instance_recv_bps_limit').value).toBe('9007199254740993') @@ -453,10 +450,6 @@ describe('Config.vue network config projection', () => { await setInput(wrapper, '#hostname', 'host-edited') await setInput(wrapper, '#subnet-proxy', '10.7.0.0/16,172.17.0.0/16') await setInput(wrapper, '#vpn_portal_wireguard_listen', '[::]:23000') - await setInput(wrapper, '#vpn_portal_wireguard_private_key', 'edited-private-key') - await setInput(wrapper, '#vpn_portal_client_name_0', 'laptop-a') - await setInput(wrapper, '#vpn_portal_client_virtual_ip_0', '10.1.2.20/24') - await setInput(wrapper, '#vpn_portal_client_groups_0', 'ops,admin') await setInput(wrapper, 'input[data-add-label="add_listener_url"]', 'tcp://0.0.0.0:13010') await setInput(wrapper, '#dev_name', 'tun-edited') await setInput(wrapper, '#mtu', '1260') @@ -486,11 +479,11 @@ describe('Config.vue network config projection', () => { proxy_cidrs: ['10.7.0.0/16', '172.17.0.0/16'], vpn_portal_config: { wireguard_listen: '[::]:23000', - wireguard_private_key: 'edited-private-key', + wireguard_private_key: 'portal-private-key', clients: [{ - name: 'laptop-a', - virtual_ip: '10.1.2.20/24', - groups: ['ops', 'admin'], + name: 'phone-a', + virtual_ip: '10.1.2.10/24', + groups: ['ops'], }], }, listener_urls: ['tcp://0.0.0.0:13010'], @@ -522,11 +515,11 @@ describe('Config.vue network config projection', () => { instance_recv_bps_limit: '9007199254740993', vpn_portal_config: { wireguard_listen: '[::]:23000', - wireguard_private_key: 'edited-private-key', + wireguard_private_key: 'portal-private-key', clients: [{ - name: 'laptop-a', - virtual_ip: '10.1.2.20/24', - groups: ['ops', 'admin'], + name: 'phone-a', + virtual_ip: '10.1.2.10/24', + groups: ['ops'], }], }, port_forwards: [{ @@ -560,10 +553,10 @@ describe('Config.vue network config projection', () => { } const toggleButtons = wrapper.findAll('button[data-stub="toggle-button"]') - expect(toggleButtons).toHaveLength(CONFIG_TOGGLE_FIELDS.length + 1) + expect(toggleButtons).toHaveLength(CONFIG_TOGGLE_FIELDS.length) for (const [index, field] of CONFIG_TOGGLE_FIELDS.entries()) { const value = originalFlagValues.get(field) - const toggle = toggleButtons[index + 1] + const toggle = toggleButtons[index] expect(toggle.attributes('aria-pressed'), `${field} should project into UI`) .toBe(String(value)) await toggle.trigger('click') @@ -578,53 +571,43 @@ describe('Config.vue network config projection', () => { } }) - it('uses VPN Portal config presence as the enable switch', async () => { - const config = DEFAULT_NETWORK_CONFIG() - const { curNetwork, wrapper } = mountConfig(config) + it('generates a private key on enable and preserves devices across disable and reload', async () => { + const { curNetwork, wrapper } = mountConfig(DEFAULT_NETWORK_CONFIG()) await nextTick() + expect(input(wrapper, '#vpn_portal_enabled').checked).toBe(false) - const portalToggle = wrapper.findAll('button[data-stub="toggle-button"]')[0] - expect(portalToggle.attributes('aria-pressed')).toBe('false') + await wrapper.find('#vpn_portal_enabled').setValue(true) + const key = curNetwork.vpn_portal_config!.wireguard_private_key! + expect(atob(key)).toHaveLength(32) + expect(curNetwork.vpn_portal_config!.wireguard_listen).toBe('0.0.0.0:22022') + expect(wrapper.find('#vpn_portal_wireguard_private_key').exists()).toBe(false) + expect(wrapper.find('#vpn_portal_client_name_0').exists()).toBe(false) + curNetwork.vpn_portal_config!.clients.push({ name: 'phone', virtual_ip: '10.0.0.2/24', groups: [] }) - await portalToggle.trigger('click') + await wrapper.find('#vpn_portal_enabled').setValue(false) + expect(curNetwork.vpn_portal_config!.enabled).toBe(false) + const restored = normalizeNetworkConfig(toBackendNetworkConfig(curNetwork)) + const reloaded = mountConfig(restored) await nextTick() - expect(curNetwork.vpn_portal_config).toEqual({ - wireguard_listen: '0.0.0.0:22022', - clients: [], + expect(input(reloaded.wrapper, '#vpn_portal_enabled').checked).toBe(false) + await reloaded.wrapper.find('#vpn_portal_enabled').setValue(true) + expect(reloaded.curNetwork.vpn_portal_config).toMatchObject({ + enabled: true, + wireguard_private_key: key, + clients: [{ name: 'phone', virtual_ip: '10.0.0.2/24' }], }) - - await portalToggle.trigger('click') - await nextTick() - expect(curNetwork.vpn_portal_config).toBeUndefined() }) - it('keeps each VPN Portal client row bound to the same client when reordered', async () => { - const config = makeConfig() - config.vpn_portal_config!.clients.push({ - name: 'phone-b', - virtual_ip: '10.1.2.11', - groups: ['guests'], - }) - const { curNetwork, wrapper } = mountConfig(config) + it('preserves existing keys and completes older enabled configurations without one', async () => { + const original = makeConfig() + const existing = mountConfig(original) await nextTick() - - const firstClient = curNetwork.vpn_portal_config!.clients[0] - const secondClient = curNetwork.vpn_portal_config!.clients[1] - const firstClientInput = input(wrapper, '#vpn_portal_client_name_0') - curNetwork.vpn_portal_config!.clients = [secondClient, firstClient] + expect(existing.curNetwork.vpn_portal_config!.wireguard_private_key).toBe('portal-private-key') + const incomplete = makeConfig() + delete incomplete.vpn_portal_config!.wireguard_private_key + const generated = mountConfig(incomplete) await nextTick() - - expect(input(wrapper, '#vpn_portal_client_name_1')).toBe(firstClientInput) - await setInput(wrapper, '#vpn_portal_client_name_1', 'phone-a-edited') - expect(firstClient.name).toBe('phone-a-edited') - expect(secondClient.name).toBe('phone-b') - }) - - it('keeps VPN Portal ACL group menus inside the management drawer', async () => { - const { wrapper } = mountConfig() - await nextTick() - - expect(wrapper.find('#vpn_portal_client_groups_0').attributes('data-append-to')).toBe('self') + expect(atob(generated.curNetwork.vpn_portal_config!.wireguard_private_key!)).toHaveLength(32) }) it('keeps uint64 input editable without losing large values', async () => { diff --git a/easytier-web/frontend-lib/tests/network-config-bigint.spec.ts b/easytier-web/frontend-lib/tests/network-config-bigint.spec.ts new file mode 100644 index 00000000..f35d5370 --- /dev/null +++ b/easytier-web/frontend-lib/tests/network-config-bigint.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' + +import { NetworkConfig as NetworkConfigPb } from '../src/generated/proto/api_manage' +import { + normalizeNetworkConfig, + toBackendNetworkConfig, +} from '../src/types/network' + +// The member config form round-trips a NetworkConfig through fromJson twice: +// once when loading the backend response, once when converting the form back +// for saving. int64 fields (managed credential expiry) become protobuf-ts +// bigints after the first pass; the second pass must still work. +describe('network config int64 round trip', () => { + it('converts bigint form state back to backend JSON', () => { + const backend: any = { + network_name: 'test-mesh', + network_secret: 's', + managed_credentials: [ + { + credential_id: 'cred-x', + credential_secret: 'sec', + allow_relay: true, + expiry_unix: '1791971218', + reusable: true, + }, + ], + } + + const form: any = normalizeNetworkConfig(backend) + expect(typeof form.managed_credentials[0].expiry_unix).toBe('bigint') + + const saved: any = toBackendNetworkConfig(form) + expect(saved.managed_credentials[0].expiry_unix).toBe('1791971218') + + // The saved shape must deserialize on the backend (pbjson accepts the + // string form for int64) and survive a further fromJson pass. + expect(() => + NetworkConfigPb.fromJson(saved, { ignoreUnknownFields: true }), + ).not.toThrow() + }) +}) diff --git a/easytier-web/frontend-lib/tests/network-config-credential.spec.ts b/easytier-web/frontend-lib/tests/network-config-credential.spec.ts new file mode 100644 index 00000000..42e79212 --- /dev/null +++ b/easytier-web/frontend-lib/tests/network-config-credential.spec.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' + +import { + DEFAULT_NETWORK_CONFIG, + normalizeNetworkConfig, + toBackendNetworkConfig, +} from '../src/types/network' + +// The form's credential field compiles into the credential-identity shape +// (empty network secret, secure mode keyed by the credential) and back. +describe('network config credential mode', () => { + it('saves the form credential as secure mode with an empty secret', () => { + const form = { + ...DEFAULT_NETWORK_CONFIG(), + network_name: 'mesh', + network_secret: 'leaked-secret', + credential_secret: 'EWAhomework/', + secure_mode: undefined, + } + + const saved: any = toBackendNetworkConfig(form) + expect(saved.network_secret).toBeFalsy() + expect(saved.secure_mode).toEqual({ + enabled: true, + local_private_key: 'EWAhomework/', + }) + }) + + it('normalizes a credential instance back into the form field', () => { + const backend: any = { + network_name: 'mesh', + peer_urls: ['tcp://10.1.1.1:11010'], + secure_mode: { enabled: true, local_private_key: 'EWAhomework/' }, + } + + const form: any = normalizeNetworkConfig(backend) + expect(form.network_secret).toBeFalsy() + expect(form.credential_secret).toBe('EWAhomework/') + expect(form.secure_mode).toEqual({ enabled: true }) + }) + + it('preserves the credential when normalizing a form again', () => { + const backend = { + ...DEFAULT_NETWORK_CONFIG(), + secure_mode: { enabled: true, local_private_key: 'EWAhomework/' }, + } + + const form = normalizeNetworkConfig(backend) + expect(normalizeNetworkConfig(form)).toEqual(form) + expect(normalizeNetworkConfig(form).credential_secret).toBe('EWAhomework/') + }) + + it('restores a saved form credential into the backend config', () => { + const form = { + ...DEFAULT_NETWORK_CONFIG(), + credential_secret: 'EWAhomework/', + } + const saved = JSON.stringify(normalizeNetworkConfig(form)) + const restored = normalizeNetworkConfig(JSON.parse(saved)) + const backend = toBackendNetworkConfig(restored) + + expect(restored.credential_secret).toBe('EWAhomework/') + expect(backend.network_secret).toBeFalsy() + expect(backend.secure_mode).toEqual({ + enabled: true, + local_private_key: 'EWAhomework/', + }) + }) + + it.each(['new-credential', ''])('preserves the explicit form credential %j over a backend key', (credential) => { + const form = normalizeNetworkConfig({ + ...DEFAULT_NETWORK_CONFIG(), + credential_secret: credential, + secure_mode: { enabled: true, local_private_key: 'old-credential' }, + }) + + expect(form.credential_secret).toBe(credential) + expect(form.secure_mode).toEqual({ enabled: true }) + expect(toBackendNetworkConfig(form).secure_mode?.local_private_key) + .toBe(credential || undefined) + }) + + it('keeps admin instances with a secret untouched', () => { + const backend: any = { + network_name: 'mesh', + network_secret: 's3cret', + secure_mode: { enabled: true, local_private_key: 'node-key' }, + } + + const form: any = normalizeNetworkConfig(backend) + expect(form.credential_secret).toBeUndefined() + expect(form.secure_mode?.local_private_key).toBe('node-key') + + const saved: any = toBackendNetworkConfig(form) + expect(saved.network_secret).toBe('s3cret') + expect(saved.secure_mode?.local_private_key).toBe('node-key') + }) + + it('clears the credential when switching back to the network secret', () => { + // The form's back-link clears credential_secret (Config.useSecretMode); + // with the field cleared, a later save must keep the network secret. + const form = { + ...DEFAULT_NETWORK_CONFIG(), + network_name: 'mesh', + network_secret: 's3cret', + credential_secret: undefined, + } + + const saved: any = toBackendNetworkConfig(form) + expect(saved.network_secret).toBe('s3cret') + expect(saved.secure_mode).toBeFalsy() + }) +}) diff --git a/easytier-web/frontend-lib/tests/status-vpn-portal.spec.ts b/easytier-web/frontend-lib/tests/status-vpn-portal.spec.ts index 7235cb2c..0ab81339 100644 --- a/easytier-web/frontend-lib/tests/status-vpn-portal.spec.ts +++ b/easytier-web/frontend-lib/tests/status-vpn-portal.spec.ts @@ -1,160 +1,234 @@ -import { flushPromises, mount } from '@vue/test-utils' -import { describe, expect, it, vi } from 'vitest' +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent, h } from 'vue' import Status from '../src/components/Status.vue' -import { VpnPortalClientState, type NetworkInstance } from '../src/types/network' +import VpnPortalDialog from '../src/components/VpnPortalDialog.vue' +import { DEFAULT_NETWORK_CONFIG, VpnPortalClientState, type NetworkInstance } from '../src/types/network' -vi.mock('vue-i18n', () => ({ - useI18n: () => ({ t: (key: string) => key }), -})) - -vi.mock('@vueuse/core', () => ({ - useTimeAgo: () => '', -})) - -vi.mock('../src/components/NetworkChart.vue', () => ({ - default: defineComponent({ render: () => h('div') }), -})) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +vi.mock('@vueuse/core', () => ({ useTimeAgo: () => '' })) +vi.mock('../src/components/NetworkChart.vue', () => ({ default: defineComponent({ render: () => h('div') }) })) vi.mock('primevue', () => { - const PassThrough = defineComponent({ - setup(_, { slots }) { - return () => h('div', slots.default?.()) - }, + const PassThrough = defineComponent({ setup: (_, { slots }) => () => h('div', slots.default?.()) }) + const Input = defineComponent({ + props: ['modelValue', 'inputId'], + emits: ['update:modelValue'], + setup: (props, { attrs, emit }) => () => h('input', { + ...attrs, + id: props.inputId ?? attrs.id, + value: props.modelValue ?? '', + onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value), + }), }) - const CardStub = defineComponent({ - setup(_, { slots }) { - return () => h('div', [slots.title?.(), slots.content?.()]) - }, - }) - const ButtonStub = defineComponent({ - props: { label: String }, + const Button = defineComponent({ + props: { label: String, disabled: Boolean, type: { type: String, default: 'button' } }, emits: ['click'], - setup(props, { emit }) { - return () => h('button', { - 'data-label': props.label, - onClick: (event: MouseEvent) => emit('click', event), - }, props.label) - }, + setup: (props, { emit }) => () => h('button', { + 'data-label': props.label, + disabled: props.disabled, + type: props.type, + onClick: (event: MouseEvent) => emit('click', event), + }, props.label), }) - + const Card = defineComponent({ setup: (_, { slots }) => () => h('div', [slots.title?.(), slots.content?.()]) }) return { - Badge: PassThrough, - Button: ButtonStub, - Card: CardStub, - Chip: PassThrough, - Column: PassThrough, - DataTable: PassThrough, - Dialog: PassThrough, - Divider: PassThrough, - ScrollPanel: PassThrough, - Tag: PassThrough, - Timeline: PassThrough, + Badge: PassThrough, Button, Card, Chip: PassThrough, Column: PassThrough, + DataTable: PassThrough, Dialog: PassThrough, Divider: PassThrough, + InputText: Input, InputNumber: Input, MultiSelect: Input, Tag: PassThrough, Timeline: PassThrough, } }) function runningInstance(): NetworkInstance { return { - instance_id: '12345678-9abc-def0-fedc-ba9876543210', - running: true, - error_msg: '', + instance_id: '12345678-9abc-def0-fedc-ba9876543210', running: true, error_msg: '', detail: { - dev_name: 'tun0', - running: true, - events: [], - routes: [], - peers: [], - peer_route_pairs: [], + dev_name: 'tun0', running: true, events: [], routes: [], peers: [], + peer_route_pairs: [{ route: { ipv4_addr: '10.0.0.2/24' } } as any], my_node_info: { virtual_ipv4: { address: { addr: 0x0a000001 }, network_length: 24 }, - hostname: 'portal-node', - version: 'test', + hostname: 'portal-node', version: 'test', ips: { - public_ipv4: { addr: 0 }, - interface_ipv4s: [], - public_ipv6: { part1: 0, part2: 0, part3: 0, part4: 0 }, - interface_ipv6s: [], - listeners: [], + public_ipv4: { addr: 0xcb007109 }, interface_ipv4s: [], + public_ipv6: { part1: 0, part2: 0, part3: 0, part4: 0 }, interface_ipv6s: [], listeners: [], }, - stun_info: { udp_nat_type: 0, tcp_nat_type: 0, last_update_time: 0 }, - listeners: [], - peer_id: 1, + stun_info: { udp_nat_type: 0, tcp_nat_type: 0, last_update_time: 0 }, listeners: [], peer_id: 1, }, }, } } -describe('Status VPN Portal details', () => { - it('fetches client configs only when the user opens the dialog', async () => { - const getVpnPortalInfo = vi.fn(async () => ({ - vpn_type: 'wireguard', - client_config: '', - connected_clients: [], - listener: '0.0.0.0:22022', - clients: [{ - name: 'phone-a', - virtual_ip: '10.0.0.10', - groups: ['ops'], - state: VpnPortalClientState.ONLINE, - peer_id: 42, - endpoint: '203.0.113.5:51820', - tunnel_ip: '192.0.2.1', - client_config: '[Interface]\nPrivateKey = secret', - }], - })) - const wrapper = mount(Status, { - props: { - curNetworkInst: runningInstance(), - api: { get_vpn_portal_info: getVpnPortalInfo } as any, - }, - global: { - directives: { tooltip: () => {} }, - stubs: { HumanEvent: true }, - }, - }) +function backend() { + const config = DEFAULT_NETWORK_CONFIG() + config.vpn_portal_config = { + wireguard_listen: '0.0.0.0:22022', wireguard_private_key: 'stable-key', + clients: [{ name: 'phone-a', virtual_ip: '10.0.0.3/24', groups: [] }], + } + const api = { + get_network_config: vi.fn(async () => structuredClone(config)), + get_vpn_portal_info: vi.fn(async () => ({ + vpn_type: 'wireguard', listener: 'wg://0.0.0.0:22022', + clients: config.vpn_portal_config!.clients.map(client => ({ + name: client.name, virtual_ip: client.virtual_ip.split('/')[0], groups: client.groups, + state: VpnPortalClientState.OFFLINE, + client_config: `[Interface]\nPrivateKey = device-secret\nAddress = ${client.virtual_ip.split('/')[0]}/32\n\n[Peer]\nPublicKey = server-key\nAllowedIPs = 10.0.0.0/24\nEndpoint = 0.0.0.0:22022 # replace wildcard with the public address\nPersistentKeepalive = 25\n`, + })), + })), + add_vpn_portal_client: vi.fn(async (_: string, client: any) => { + config.vpn_portal_config!.clients.push({ ...client, groups: [...client.groups] }) + }), + remove_vpn_portal_client: vi.fn(async (_: string, name: string) => { + config.vpn_portal_config!.clients = config.vpn_portal_config!.clients.filter(client => client.name !== name) + }), + } + return { api, config } +} - try { - expect(getVpnPortalInfo).not.toHaveBeenCalled() +const wrappers: VueWrapper[] = [] +function render(component: any, api: any, extra: Record = {}) { + const instance = runningInstance() + const wrapper = mount(component, { + props: component === Status ? { curNetworkInst: instance, api, ...extra } : { instance, api, ...extra }, + global: { directives: { tooltip: () => {} }, stubs: { HumanEvent: true } }, + }) + wrappers.push(wrapper) + return wrapper +} +const button = (wrapper: VueWrapper, label: string) => wrapper.find(`button[data-label="${label}"]`) +async function load() { + await vi.advanceTimersByTimeAsync(1) + await flushPromises() +} - await wrapper.find('button[data-label="show_vpn_portal_config"]').trigger('click') - await flushPromises() +beforeEach(() => vi.useFakeTimers()) +afterEach(() => { + wrappers.splice(0).forEach(wrapper => wrapper.unmount()) + vi.useRealTimers() + vi.restoreAllMocks() +}) - expect(getVpnPortalInfo).toHaveBeenCalledOnce() - expect(getVpnPortalInfo).toHaveBeenCalledWith('12345678-9abc-def0-fedc-ba9876543210') - expect(wrapper.text()).toContain('phone-a · 10.0.0.10') - expect(wrapper.text()).toContain('203.0.113.5:51820') - expect(wrapper.text()).toContain('PrivateKey = secret') - } finally { - wrapper.unmount() - } +describe('WireGuard devices', () => { + it('opens device configuration only from an enabled running node', async () => { + const { api } = backend() + const wrapper = render(Status, api) + await flushPromises() + expect(api.get_vpn_portal_info).not.toHaveBeenCalled() + await button(wrapper, 'vpn_portal_devices').trigger('click') + await load() + expect(api.get_vpn_portal_info).toHaveBeenCalledWith(runningInstance().instance_id) + expect(wrapper.text()).toContain('phone-a') + await wrapper.setProps({ curNetworkInst: { ...runningInstance(), running: false } }) + expect(button(wrapper, 'vpn_portal_devices').exists()).toBe(false) + expect(wrapper.findComponent(VpnPortalDialog).exists()).toBe(false) }) - it('renders the unconfigured portal sentinel as an empty state', async () => { - const getVpnPortalInfo = vi.fn(async () => ({ - vpn_type: 'null', - client_config: '', - connected_clients: [], - clients: [], - })) - const wrapper = mount(Status, { - props: { - curNetworkInst: runningInstance(), - api: { get_vpn_portal_info: getVpnPortalInfo } as any, - }, - global: { - directives: { tooltip: () => {} }, - stubs: { HumanEvent: true }, - }, + it.each([undefined, { enabled: false, wireguard_listen: '0.0.0.0:22022', clients: [] }])('hides the entry when the portal is disabled', async portal => { + const { api, config } = backend() + config.vpn_portal_config = portal + const wrapper = render(Status, api) + await flushPromises() + expect(button(wrapper, 'vpn_portal_devices').exists()).toBe(false) + }) + + it('refreshes same-instance settings, retries failures, and stops after unmount', async () => { + const { api, config } = backend() + const portal = config.vpn_portal_config + config.vpn_portal_config = undefined + api.get_network_config.mockRejectedValueOnce(new Error('temporarily offline')) + const wrapper = render(Status, api) + await flushPromises() + expect(button(wrapper, 'vpn_portal_devices').exists()).toBe(false) + await vi.advanceTimersByTimeAsync(10_000) + expect(api.get_network_config).toHaveBeenCalledTimes(2) + expect(button(wrapper, 'vpn_portal_devices').exists()).toBe(false) + config.vpn_portal_config = portal + await vi.advanceTimersByTimeAsync(10_000) + expect(button(wrapper, 'vpn_portal_devices').exists()).toBe(true) + config.vpn_portal_config!.enabled = false + await vi.advanceTimersByTimeAsync(10_000) + expect(button(wrapper, 'vpn_portal_devices').exists()).toBe(false) + wrapper.unmount() + await vi.advanceTimersByTimeAsync(30_000) + expect(api.get_network_config).toHaveBeenCalledTimes(4) + }) + + it('adds a device with a suggested address and immediately exports the public endpoint', async () => { + const { api, config } = backend() + const wrapper = render(VpnPortalDialog, api) + await load() + await button(wrapper, 'vpn_portal_add_client').trigger('click') + expect(wrapper.find('#vpn_portal_client_address').element.value).toBe('10.0.0.4') + expect(wrapper.find('#vpn_portal_client_groups').exists()).toBe(false) + await wrapper.find('form').trigger('submit') + await flushPromises() + expect(api.add_vpn_portal_client).toHaveBeenCalledWith(runningInstance().instance_id, { + name: expect.stringMatching(/^device-[a-f0-9]{8}$/), virtual_ip: '10.0.0.4/24', groups: [], }) + expect(config.vpn_portal_config!.wireguard_private_key).toBe('stable-key') + expect(wrapper.find('pre').text()).toContain('Endpoint = 203.0.113.9:22022') + expect(wrapper.find('pre').text()).not.toContain('replace wildcard') + expect(wrapper.find('img').attributes('src')).toContain('data:image/svg+xml') + expect(button(wrapper, 'vpn_portal_download_config').exists()).toBe(true) + }) - try { - await wrapper.find('button[data-label="show_vpn_portal_config"]').trigger('click') - await flushPromises() - - expect(wrapper.text()).toContain('vpn_portal_not_configured') - expect(wrapper.text()).not.toContain('vpn_portal_type: null') - } finally { - wrapper.unmount() + it('rejects known occupied addresses and duplicate names before submitting', async () => { + const { api } = backend() + const wrapper = render(VpnPortalDialog, api) + await load() + await button(wrapper, 'vpn_portal_add_client').trigger('click') + for (const address of ['10.0.0.1', '10.0.0.2', '10.0.0.3', '10.0.0.0', '10.0.0.255']) { + await wrapper.find('#vpn_portal_client_address').setValue(address) + expect(button(wrapper, 'vpn_portal_generate_config').attributes('disabled')).toBeDefined() + await wrapper.find('form').trigger('submit') } + await wrapper.find('#vpn_portal_client_address').setValue('10.0.0.4') + await wrapper.find('#vpn_portal_client_name').setValue('phone-a') + expect(button(wrapper, 'vpn_portal_generate_config').attributes('disabled')).toBeDefined() + expect(api.add_vpn_portal_client).not.toHaveBeenCalled() + }) + + it('requires a reachable endpoint only when no public address is available', async () => { + const { api } = backend() + const instance = runningInstance() + instance.detail!.my_node_info.ips.public_ipv4.addr = 0 + const wrapper = render(VpnPortalDialog, api, { instance }) + await load() + await button(wrapper, 'vpn_portal_connect_device').trigger('click') + expect(wrapper.text()).toContain('vpn_portal_endpoint_required') + expect(button(wrapper, 'vpn_portal_download_config').exists()).toBe(false) + await wrapper.find('#vpn_portal_server_endpoint').setValue('vpn.example.com:51820') + await flushPromises() + expect(wrapper.find('pre').text()).toContain('Endpoint = vpn.example.com:51820') + const copy = vi.fn(async () => {}) + Object.defineProperty(navigator, 'clipboard', { value: { writeText: copy }, configurable: true }) + await button(wrapper, 'vpn_portal_copy_client_config').trigger('click') + expect(copy).toHaveBeenCalledWith(wrapper.find('pre').text() + '\n') + }) + + it('offers connection configs without mutations for read-only networks', async () => { + const { api } = backend() + const wrapper = render(VpnPortalDialog, api, { readonly: true }) + await load() + expect(button(wrapper, 'vpn_portal_add_client').exists()).toBe(false) + expect(wrapper.find('button[aria-label="vpn_portal_remove_client"]').exists()).toBe(false) + await button(wrapper, 'vpn_portal_connect_device').trigger('click') + expect(button(wrapper, 'vpn_portal_download_config').exists()).toBe(true) + }) + + it('keeps failed deletions visible and removes the device only after a successful request', async () => { + const { api } = backend() + api.remove_vpn_portal_client.mockRejectedValueOnce(new Error('save failed')) + const wrapper = render(VpnPortalDialog, api) + await load() + await wrapper.find('button[aria-label="vpn_portal_remove_client"]').trigger('click') + expect(api.remove_vpn_portal_client).not.toHaveBeenCalled() + await button(wrapper, 'vpn_portal_remove_client').trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('phone-a') + expect(wrapper.find('[role="alert"]').text()).toContain('save failed') + await button(wrapper, 'vpn_portal_remove_client').trigger('click') + await flushPromises() + expect(wrapper.text()).not.toContain('phone-a') + expect(wrapper.text()).toContain('vpn_portal_no_clients') }) }) diff --git a/easytier-web/frontend-lib/tests/vpn-portal.spec.ts b/easytier-web/frontend-lib/tests/vpn-portal.spec.ts new file mode 100644 index 00000000..74d0e800 --- /dev/null +++ b/easytier-web/frontend-lib/tests/vpn-portal.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { + createVpnPortalConfig, normalizeVpnPortalEndpoint, suggestVpnPortalAddress, + validVpnPortalAddress, vpnPortalClientConfig, vpnPortalEndpoint, +} from '../src/modules/vpnPortal' +import type { NetworkInstance, NodeInfo } from '../src/types/network' + +const node = { + virtual_ipv4: { address: { addr: 0x0a000001 }, network_length: 24 }, + ips: { + public_ipv4: { addr: 0xcb007109 }, + public_ipv6: { part1: 0, part2: 0, part3: 0, part4: 0 }, + }, +} as NodeInfo + +describe('WireGuard configuration defaults', () => { + it('generates independent private keys that survive serialization', () => { + const first = createVpnPortalConfig() + const second = createVpnPortalConfig() + expect(first.wireguard_private_key).not.toBe(second.wireguard_private_key) + expect(atob(first.wireguard_private_key!)).toHaveLength(32) + expect(JSON.parse(JSON.stringify(first))).toEqual(first) + }) + + it('combines the running listener port with the public address', () => { + expect(vpnPortalEndpoint('wg://0.0.0.0:22022', node)).toBe('203.0.113.9:22022') + expect(vpnPortalEndpoint('[::]:23456', node)).toBe('203.0.113.9:23456') + expect(vpnPortalEndpoint('wg://0.0.0.0:0', node)).toBe('') + expect(vpnPortalEndpoint('wg://0.0.0.0:22022', { ips: {} } as NodeInfo)).toBe('') + }) + + it('preserves explicit IPv4, IPv6 and hostname listeners', () => { + for (const host of ['127.0.0.1', '192.168.1.10', '[2001:db8::2]', 'vpn.example.com']) { + expect(vpnPortalEndpoint(`${host}:22022`, node)).toBe(`${host}:22022`) + } + }) + + it('handles IPv6 and rejects unspecified addresses in all spellings', () => { + const ipv6Node = { ips: { public_ipv6: { part1: 0x20010db8, part2: 0, part3: 0, part4: 1 } } } as NodeInfo + expect(normalizeVpnPortalEndpoint(vpnPortalEndpoint('wg://[::]:22022', ipv6Node), '')).toBe('[2001:db8::1]:22022') + expect(normalizeVpnPortalEndpoint('[0:0:0:0:0:0:0:0]:22022', '')).toBe('') + expect(normalizeVpnPortalEndpoint('0.0.0.0:22022', '')).toBe('') + }) + + it('uses a custom domain or port and replaces only the endpoint line', () => { + expect(normalizeVpnPortalEndpoint('vpn.example.com', '22022')).toBe('vpn.example.com:22022') + expect(normalizeVpnPortalEndpoint('vpn.example.com:51820', '22022')).toBe('vpn.example.com:51820') + for (const endpoint of ['vpn.example.com:0', 'vpn.example.com:65536', 'https://vpn.example.com', 'user@vpn.example.com', 'vpn.example.com\nAddress=1']) { + expect(normalizeVpnPortalEndpoint(endpoint, '22022')).toBe('') + } + expect(vpnPortalClientConfig('[Interface]\nPrivateKey = stable\n\n[Peer]\nEndpoint = 0.0.0.0:22022 # edit\nPersistentKeepalive = 25\n', 'vpn.example.com:51820')) + .toBe('[Interface]\nPrivateKey = stable\n\n[Peer]\nEndpoint = vpn.example.com:51820\nPersistentKeepalive = 25\n') + }) + + it('suggests an unused host address and retains an existing independent client subnet', () => { + const instance = { detail: { my_node_info: node } } as NetworkInstance + expect(suggestVpnPortalAddress(instance, [], new Set([0x0a000001, 0x0a000002]))) + .toEqual({ address: '10.0.0.3', prefix: 24 }) + expect(suggestVpnPortalAddress(instance, [{ name: 'phone', virtual_ip: '10.80.0.2/16', groups: [] }], new Set([0x0a500002]))) + .toEqual({ address: '10.80.0.1', prefix: 16 }) + expect(suggestVpnPortalAddress({} as NetworkInstance, [], new Set())).toEqual({ address: '' }) + }) + + it('does not suggest broadcast, network or occupied addresses in exhausted small subnets', () => { + const clients = [{ name: 'phone', virtual_ip: '10.0.0.2/30', groups: [] }] + expect(suggestVpnPortalAddress({} as NetworkInstance, clients, new Set([0x0a000001, 0x0a000002]))) + .toEqual({ address: '', prefix: 30 }) + for (const address of ['10.0.0.0', '10.0.0.1', '10.0.0.3', 'not-an-ip']) { + expect(validVpnPortalAddress(address, 30, new Set([0x0a000001]))).toBe(false) + } + expect(validVpnPortalAddress('10.0.0.2', 30, new Set())).toBe(true) + expect(validVpnPortalAddress('10.0.0.2', undefined, new Set())).toBe(false) + }) +}) diff --git a/easytier-web/frontend/README.md b/easytier-web/frontend/README.md index 33895ab2..e9bdbf55 100644 --- a/easytier-web/frontend/README.md +++ b/easytier-web/frontend/README.md @@ -1,5 +1,68 @@ -# Vue 3 + TypeScript + Vite +# EasyTier Web frontend -This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + + + diff --git a/easytier-web/frontend/src/components/Dashboard.vue b/easytier-web/frontend/src/components/Dashboard.vue index 7f036001..91687de7 100644 --- a/easytier-web/frontend/src/components/Dashboard.vue +++ b/easytier-web/frontend/src/components/Dashboard.vue @@ -1,66 +1,94 @@ \ No newline at end of file + diff --git a/easytier-web/frontend/src/components/DeviceDetails.vue b/easytier-web/frontend/src/components/DeviceDetails.vue index 33647217..9de61b28 100644 --- a/easytier-web/frontend/src/components/DeviceDetails.vue +++ b/easytier-web/frontend/src/components/DeviceDetails.vue @@ -22,6 +22,14 @@ defineProps<{
{{ t('web.device.hostname') }}
{{ device.hostname }}
+
+
{{ t('web.device.status') }}
+
{{ device.online ? t('web.device.online') : t('web.device.offline') }}
+
+
+
{{ t('web.device.last_seen') }}
+
{{ device.last_seen }}
+
{{ t('web.device.public_ip') }}
{{ device.public_ip }}
@@ -30,62 +38,60 @@ defineProps<{
{{ t('web.device.networks') }}
{{ device.running_network_count }}
-
-
{{ t('web.device.last_report') }}
-
{{ device.report_time }}
+
+
{{ t('web.console.location') }}
+
{{ device.location ? [device.location.country, device.location.region, device.location.city].filter(Boolean).join(' · ') : t('web.device.unknown_location') }}
+
+
+
{{ t('web.device.central_networks') }}
+
+ {{ network.display_name }} +
{{ t('web.device.version') }}
{{ device.easytier_version }}
-
-
{{ t('web.device.machine_id') }}
-
- {{ device.machine_id }} +
+ {{ t('web.console.more_details') }} +
+
{{ t('web.device.last_report') }}
+
{{ device.report_time }}
-
+
+
{{ t('web.device.machine_id') }}
+
+ {{ device.machine_id }} +
+
+
diff --git a/easytier-web/frontend/src/components/DeviceList.vue b/easytier-web/frontend/src/components/DeviceList.vue index f1563f38..f0375db8 100644 --- a/easytier-web/frontend/src/components/DeviceList.vue +++ b/easytier-web/frontend/src/components/DeviceList.vue @@ -1,6 +1,6 @@ - -