From 651a8d9e254b72c32c003694c37ce7a560d4f012 Mon Sep 17 00:00:00 2001 From: KKRainbow <5665404+KKRainbow@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:06:47 +0800 Subject: [PATCH] feat(web): add central network management console (#2622) Persist central network intent and compile complete per-device configs with secure credentials, ACL policy, and dedicated Gateway runtimes. Expose network, credential, device registry, and runtime observation APIs. Treat central management and external Console as alternative consumers of the upstream ClientManager. Register central devices through a local webhook handler and publish through existing runtime reconciliation. Serialize public mutations with enrollment, reject direct credential changes to managed instances, and retain REST revision invalidation. Keep Gateway lifecycle publication in the central service and remove obsolete incremental result bookkeeping. Restore persisted networks and retire orphan runtimes through the same serialized publisher. Add Core and protocol support for Gateway and WireGuard management, including GUI bindings and serialized GUI config writes. Bootstrap IPv4 for DHCP-only networks and retain assigned addresses without peer IPv4. Cover intent transactions, complete configuration publication, offline recovery, authentication, revocation, Gateway lifecycle, and DHCP. Validate central candidates before persistence using Core URL, config, and portal-client rules. Reject unsupported peer schemes, unconvertible proxy subnets, and invalid portal clients without changing live intent. Expose existing pure Core validators without changing runtime behavior. Gate API-facing credential validation on API-enabled Core builds so minimal WASM targets do not reference omitted management types. Preserve session-backed device views in external Console mode. Bound Gateway admissions without cancelling transport upgrades, and cancel pending peer handshakes before retiring runtimes. Batch registry reads and bound runtime observation concurrency. Randomize DHCP bootstrap and conflict retries, keep an advertised current subnet, and select fallback subnets deterministically. Cover concurrent startup, Gateway admission lifetime, and external Console regressions. Allow DHCP bootstrap with no remote routes. Count network members through one tenant-scoped query without write transactions. Cover zero- peer allocation and tenant/empty-network counts. Preserve direct Web configurations and disabled states in central device snapshots. Remove obsolete central rows atomically with membership changes. Stop online managed instances before deleting or blocking devices, retaining intent when shutdown or deletion fails. Persist automatic IPv4 allocations separately from manual overrides so subnet changes reallocate only automatic members. Normalize mapped proxy routes to their advertised CIDRs when granting temporary credentials. Cover publication, deletion rollback, subnet migration, and grant updates. Read central intent in deferred transactions so polling does not reserve the SQLite writer lock. Test reads with an active writer and assert the temporary-member secret constraint using a valid device fixture. Persist patched WireGuard clients from the saved Web or GUI candidate without a follow-up RPC. Preserve pending settings and ownership, and cover disconnects and failed patches. Resolve named credential and config mutations against the live Core instance before checking central ownership. Forward authorized mutations by UUID on the same session, including while network renames are pending. Cover all mutation methods and preserve direct Console behavior. * feat(web-ui): add central network console and WireGuard management Add network and device views with central membership, credentials, ACL policy, temporary peers, and per-instance runtime details. Update console navigation, styling, theme handling, and API clients. Extend shared configuration and status components for central networks. Add a WireGuard portal dialog for setup and running-device management, with matching translations and network configuration types. Use secure UUID generation on HTTP, discard stale member configuration responses, and stop node-detail polling when the component unmounts. Update frontend workspace dependencies and include component, dashboard, configuration serialization, and central console end-to-end tests. Add isolated real-Core E2E coverage for central data-plane traffic, ACL, DHCP, recovery, device lifecycle, and native WireGuard clients. Record all 59 functional checks with evidence and confirmed validation/UI defects; keep runtime artifacts and test credentials out of Git. Normalize protobuf logger levels and render translated labels correctly. Cover all six levels across setting, reload, and language changes. Add real configuration-rejection E2E checks for database and Core stability and uninterrupted traffic, and record the resolved audit findings. Gate central navigation and registry actions by console mode. Refresh WireGuard settings on mounted status views and preserve explicit portal listener endpoints. Explain the trusted permanent-member ACL boundary. Cover external Console rendering, portal refresh retries and teardown, and explicit IPv4/IPv6 listener exports. Preserve PublicServer discovery when saving its settings, including when its URL matches the Gateway. Cover renaming and endpoint edits in the browser. Display automatic member addresses without converting them to manual overrides during edits. Derive offline address prefixes from the network subnet. Add browser regression coverage and real-Core checks for direct configuration preservation, temporary proxy mappings, automatic subnet migration, and shutdown before device deletion. Build enrollment commands from the configured API hostname, including IPv6 and relative endpoints. Use the PublicServer connector in temporary credential CLI and TOML exports. Add browser regression coverage. Preserve form credentials across repeated normalization and GUI storage reloads. Keep explicit form values authoritative over backend keys and cover JSON persistence, idempotence, replacement, and clearing. --- .gitignore | 1 + CONTEXT.md | 19 + Cargo.lock | 7 + docs/central-network-e2e.md | 209 ++ easytier-core/src/config/api.rs | 1 + easytier-core/src/config/api_input.rs | 29 + easytier-core/src/config/toml.rs | 3 + easytier-core/src/connectivity/manual/mod.rs | 3 +- easytier-core/src/gateway/dhcp.rs | 255 +- easytier-core/src/gateway/vpn_portal.rs | 2 +- .../src/gateway/vpn_portal/runtime.rs | 2 +- easytier-core/src/instance/config.rs | 1 + easytier-core/src/instance/mod.rs | 6 + .../src/management/full/config_patch.rs | 22 +- .../src/management/full/remote_client.rs | 311 +- easytier-core/src/peers/credential_manager.rs | 21 + easytier-gui/src-tauri/src/lib.rs | 62 +- easytier-proto/proto/api_manage.proto | 2 + easytier-proto/src/api.rs | 1 + easytier-web/Cargo.toml | 9 + easytier-web/frontend-lib/package.json | 2 + .../scripts/test-network-config.mjs | 12 + .../frontend-lib/src/components/Config.vue | 219 +- .../src/components/RemoteManagement.vue | 35 +- .../frontend-lib/src/components/Status.vue | 145 +- .../src/components/VpnPortalDialog.vue | 311 ++ .../frontend-lib/src/components/index.ts | 3 +- .../frontend-lib/src/easytier-frontend-lib.ts | 66 +- easytier-web/frontend-lib/src/locales/cn.yaml | 304 +- easytier-web/frontend-lib/src/locales/en.yaml | 304 +- .../frontend-lib/src/modules/utils.ts | 14 + .../frontend-lib/src/modules/vpnPortal.ts | 102 + .../frontend-lib/src/types/network.ts | 22 + .../frontend-lib/src/types/networkCompat.ts | 4 + easytier-web/frontend-lib/tailwind.config.js | 1 + .../frontend-lib/tests/config-ui.spec.ts | 95 +- .../tests/network-config-bigint.spec.ts | 41 + .../tests/network-config-credential.spec.ts | 113 + .../tests/status-vpn-portal.spec.ts | 326 +- .../frontend-lib/tests/vpn-portal.spec.ts | 74 + easytier-web/frontend/README.md | 69 +- easytier-web/frontend/package.json | 6 +- .../frontend/src/components/AclPolicyTab.vue | 494 +++ .../frontend/src/components/Dashboard.vue | 134 +- .../frontend/src/components/DeviceDetails.vue | 167 +- .../frontend/src/components/DeviceList.vue | 1070 ++---- .../src/components/DeviceManagement.vue | 27 - .../frontend/src/components/Login.vue | 78 +- .../frontend/src/components/MainPage.vue | 421 ++- .../frontend/src/components/NetworkDetail.vue | 1663 +++++++++ .../frontend/src/components/NetworkList.vue | 252 ++ easytier-web/frontend/src/console.css | 235 ++ easytier-web/frontend/src/main.ts | 42 +- easytier-web/frontend/src/modules/api-host.ts | 68 +- easytier-web/frontend/src/modules/api.ts | 353 +- easytier-web/frontend/src/modules/theme.ts | 28 + easytier-web/frontend/src/style.css | 3 +- easytier-web/frontend/src/theme.ts | 27 + easytier-web/frontend/tailwind.config.js | 1 + .../frontend/tests/central-coverage.mjs | 498 +++ .../frontend/tests/central-e2e.test.mjs | 362 ++ .../frontend/tests/central-validation.mjs | 329 ++ .../frontend/tests/dashboard.test.mjs | 664 ++++ easytier-web/src/central_network/compiler.rs | 1289 +++++++ .../src/central_network/device_auth.rs | 405 +++ easytier-web/src/central_network/gateway.rs | 754 +++++ .../src/central_network/gateway/listener.rs | 535 +++ easytier-web/src/central_network/mod.rs | 8 + easytier-web/src/central_network/model.rs | 183 + easytier-web/src/central_network/service.rs | 2958 +++++++++++++++++ .../central_network/temporary_peers/mod.rs | 465 +++ easytier-web/src/client_manager/mod.rs | 10 +- .../src/client_manager/runtime_reconcile.rs | 31 +- easytier-web/src/client_manager/session.rs | 3 +- easytier-web/src/db/central_intent.rs | 1140 +++++++ easytier-web/src/db/devices.rs | 513 +++ easytier-web/src/db/entity/blocked_devices.rs | 23 + easytier-web/src/db/entity/devices.rs | 30 + easytier-web/src/db/entity/mod.rs | 4 + easytier-web/src/db/entity/network_members.rs | 36 + easytier-web/src/db/entity/networks.rs | 37 + easytier-web/src/db/entity/prelude.rs | 4 + easytier-web/src/db/mod.rs | 5 + easytier-web/src/main.rs | 69 +- .../m20260920_000007_device_registry.rs | 73 + ...m20260920_000009_central_network_intent.rs | 323 ++ .../m20261001_000010_member_ip_allocation.rs | 65 + easytier-web/src/migrator/mod.rs | 7 + easytier-web/src/restful/central_network.rs | 419 +++ easytier-web/src/restful/mod.rs | 85 +- easytier-web/src/restful/network.rs | 632 +++- easytier-web/src/restful/rpc.rs | 539 ++- easytier-web/src/web/mod.rs | 107 +- easytier-web/src/webhook.rs | 40 + easytier/src/core.rs | 6 + easytier/src/instance/composition.rs | 6 +- easytier/src/tests/credential_tests.rs | 1 + easytier/src/tests/three_node.rs | 4 + easytier/src/vpn_portal/wireguard.rs | 2 + pnpm-lock.yaml | 145 + pnpm-workspace.yaml | 3 + 101 files changed, 19322 insertions(+), 1787 deletions(-) create mode 100644 docs/central-network-e2e.md create mode 100644 easytier-web/frontend-lib/src/components/VpnPortalDialog.vue create mode 100644 easytier-web/frontend-lib/src/modules/vpnPortal.ts create mode 100644 easytier-web/frontend-lib/tests/network-config-bigint.spec.ts create mode 100644 easytier-web/frontend-lib/tests/network-config-credential.spec.ts create mode 100644 easytier-web/frontend-lib/tests/vpn-portal.spec.ts create mode 100644 easytier-web/frontend/src/components/AclPolicyTab.vue create mode 100644 easytier-web/frontend/src/components/NetworkDetail.vue create mode 100644 easytier-web/frontend/src/components/NetworkList.vue create mode 100644 easytier-web/frontend/src/console.css create mode 100644 easytier-web/frontend/src/modules/theme.ts create mode 100644 easytier-web/frontend/src/theme.ts create mode 100644 easytier-web/frontend/tests/central-coverage.mjs create mode 100644 easytier-web/frontend/tests/central-e2e.test.mjs create mode 100644 easytier-web/frontend/tests/central-validation.mjs create mode 100644 easytier-web/frontend/tests/dashboard.test.mjs create mode 100644 easytier-web/src/central_network/compiler.rs create mode 100644 easytier-web/src/central_network/device_auth.rs create mode 100644 easytier-web/src/central_network/gateway.rs create mode 100644 easytier-web/src/central_network/gateway/listener.rs create mode 100644 easytier-web/src/central_network/mod.rs create mode 100644 easytier-web/src/central_network/model.rs create mode 100644 easytier-web/src/central_network/service.rs create mode 100644 easytier-web/src/central_network/temporary_peers/mod.rs create mode 100644 easytier-web/src/db/central_intent.rs create mode 100644 easytier-web/src/db/devices.rs create mode 100644 easytier-web/src/db/entity/blocked_devices.rs create mode 100644 easytier-web/src/db/entity/devices.rs create mode 100644 easytier-web/src/db/entity/network_members.rs create mode 100644 easytier-web/src/db/entity/networks.rs create mode 100644 easytier-web/src/migrator/m20260920_000007_device_registry.rs create mode 100644 easytier-web/src/migrator/m20260920_000009_central_network_intent.rs create mode 100644 easytier-web/src/migrator/m20261001_000010_member_ip_allocation.rs create mode 100644 easytier-web/src/restful/central_network.rs 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 @@ - -