mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-10-08 10:56:13 -08:00
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.
This commit is contained in:
1 parent
a017dc133c
commit
651a8d9e25
101 files changed
+19322
-1787
No files matched your search
@@ -49,3 +49,4 @@ easytier-gui/src-tauri/*.sys
|
||||
|
||||
# contrib
|
||||
go.sum
|
||||
.test-env/
|
||||
+19
@@ -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.
|
||||
Generated
+7
@@ -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]]
|
||||
|
||||
@@ -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。
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<bool>,
|
||||
pub wireguard_listen: SocketAddr,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub wireguard_private_key: Option<String>,
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Ipv4Inet>,
|
||||
current: Option<Ipv4Inet>,
|
||||
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<Ipv4Inet>) {
|
||||
self.current = next;
|
||||
}
|
||||
|
||||
pub fn evaluate(&self, has_routes: bool, used_ipv4: &HashSet<Ipv4Inet>) -> DhcpIpv4Decision {
|
||||
if !has_routes {
|
||||
pub fn evaluate(
|
||||
&mut self,
|
||||
has_routes: bool,
|
||||
used_ipv4: &HashSet<Ipv4Inet>,
|
||||
) -> 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<Ipv4Inet>,
|
||||
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<Self> {
|
||||
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::<Ipv4Inet>().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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<HashSet<_>>();
|
||||
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::<Ipv4Inet>().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]
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<PeerManagerCore> {
|
||||
&self.peer_manager
|
||||
}
|
||||
|
||||
fn set_state(&self, state: CoreInstanceState) {
|
||||
self.state.store(state as u8, Ordering::Release);
|
||||
}
|
||||
|
||||
@@ -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<UrlPatch>) -> 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<VpnPortalClientPatch>,
|
||||
) -> 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();
|
||||
|
||||
@@ -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<T, C, E>;
|
||||
|
||||
fn get_config_rpc_client(
|
||||
&self,
|
||||
identify: T,
|
||||
) -> Option<Box<dyn ConfigRpc<Controller = BaseController> + Send>>;
|
||||
|
||||
async fn handle_patch_vpn_portal_clients(
|
||||
&self,
|
||||
identify: T,
|
||||
inst_id: uuid::Uuid,
|
||||
patches: Vec<VpnPortalClientPatch>,
|
||||
) -> Result<(), RemoteClientError<E>> {
|
||||
// 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<Option<C>, 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<anyhow::Error> for SavedConfig {
|
||||
fn get_network_inst_id(&self) -> &str {
|
||||
self.0.instance_id.as_deref().unwrap()
|
||||
}
|
||||
|
||||
fn get_network_config(&self) -> anyhow::Result<NetworkConfig> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
|
||||
fn get_network_config_source(&self) -> ConfigSource {
|
||||
self.1
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Storage<(), SavedConfig, anyhow::Error> for Mutex<Option<SavedConfig>> {
|
||||
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<Option<SavedConfig>> {
|
||||
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<Vec<SavedConfig>> {
|
||||
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<PatchConfigResponse> {
|
||||
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<GetConfigResponse> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
struct ClientManager {
|
||||
storage: Mutex<Option<SavedConfig>>,
|
||||
patch_fails: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteClientManager<(), SavedConfig, anyhow::Error> for ClientManager {
|
||||
fn get_rpc_client(
|
||||
&self,
|
||||
_: (),
|
||||
) -> Option<Box<dyn WebClientService<Controller = BaseController> + 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<Box<dyn ConfigRpc<Controller = BaseController> + 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());
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Option<VpnPortalInfo
|
||||
|
||||
#[tauri::command]
|
||||
async fn patch_vpn_portal_clients(
|
||||
app: AppHandle,
|
||||
instance_id: String,
|
||||
action: String,
|
||||
name: Option<String>,
|
||||
@@ -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::<ConfigRpcClientFactory<BaseController>>(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<manager::StoredGuiConfig>,
|
||||
enabled_networks: Vec<String>,
|
||||
) -> 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<AppHandle, GUIConfig, anyhow::Error> for GUIClientManager {
|
||||
fn get_config_rpc_client(
|
||||
&self,
|
||||
_: AppHandle,
|
||||
) -> Option<Box<dyn ConfigRpc<Controller = BaseController> + Send>> {
|
||||
Some(
|
||||
self.rpc_manager
|
||||
.rpc_client()
|
||||
.scoped_client::<ConfigRpcClientFactory<BaseController>>(1, 1, String::new()),
|
||||
)
|
||||
}
|
||||
|
||||
fn get_rpc_client(
|
||||
&self,
|
||||
_: AppHandle,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { AutoComplete, Button, Checkbox, Dialog, Divider, InputNumber, InputText, MultiSelect, Panel, Password, SelectButton, ToggleButton } from 'primevue'
|
||||
import { AutoComplete, Button, Checkbox, Dialog, Divider, InputNumber, InputSwitch, InputText, MultiSelect, Panel, Password, SelectButton, ToggleButton } from 'primevue'
|
||||
import InputGroup from 'primevue/inputgroup'
|
||||
import InputGroupAddon from 'primevue/inputgroupaddon'
|
||||
import {
|
||||
@@ -9,18 +9,24 @@ import {
|
||||
NetworkConfig,
|
||||
normalizeNetworkConfig,
|
||||
removeRow,
|
||||
type VpnPortalClientConfig,
|
||||
type VpnPortalConfig,
|
||||
} from '../types/network'
|
||||
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AclManager from './acl/AclManager.vue'
|
||||
import UrlListInput from './UrlListInput.vue'
|
||||
import { createVpnPortalConfig } from '../modules/vpnPortal'
|
||||
import type { VpnPortalClientConfig } from '../types/network'
|
||||
|
||||
const props = defineProps<{
|
||||
actionLabel?: string
|
||||
configInvalid?: boolean
|
||||
hostname?: string
|
||||
/// Hide the secure-mode switch and the temporary-credential entry; for
|
||||
/// callers where secure mode is a network-level property managed
|
||||
/// elsewhere.
|
||||
hideSecureMode?: boolean
|
||||
/// Central members save their clients through the configuration owner.
|
||||
editVpnPortalClients?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits(['runNetwork'])
|
||||
@@ -30,6 +36,50 @@ const curNetwork = defineModel('curNetwork', {
|
||||
default: DEFAULT_NETWORK_CONFIG,
|
||||
})
|
||||
|
||||
// --- network identity: shared secret (default) or admin-issued credential ---
|
||||
const identityMode = ref<'secret' | 'credential'>('secret')
|
||||
|
||||
// A pasted credential switches the form to credential mode; replacing the
|
||||
// whole config object (loading another network) re-derives the mode from it.
|
||||
watch(
|
||||
() => curNetwork.value.credential_secret,
|
||||
(secret) => {
|
||||
if (secret) {
|
||||
identityMode.value = 'credential'
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
watch(
|
||||
curNetwork,
|
||||
(network) => {
|
||||
identityMode.value = network.credential_secret ? 'credential' : 'secret'
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const useCredentialMode = () => {
|
||||
identityMode.value = 'credential'
|
||||
}
|
||||
|
||||
const useSecretMode = () => {
|
||||
// Switching back is an explicit choice: drop the credential so saving
|
||||
// uses the network secret instead of silently joining as the credential.
|
||||
curNetwork.value.credential_secret = undefined
|
||||
identityMode.value = 'secret'
|
||||
}
|
||||
|
||||
const credentialInvalid = computed(
|
||||
() => identityMode.value === 'credential' && !(curNetwork.value.credential_secret ?? '').trim(),
|
||||
)
|
||||
|
||||
const secureModeEnabled = computed({
|
||||
get: () => curNetwork.value.secure_mode?.enabled === true,
|
||||
set: (value: boolean) => {
|
||||
curNetwork.value.secure_mode = value ? { enabled: true } : undefined
|
||||
},
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const protos: { [proto: string]: number } = {
|
||||
@@ -168,6 +218,10 @@ function syncNormalizedNetwork(network: NetworkConfig | undefined): void {
|
||||
}
|
||||
|
||||
Object.assign(network, normalizeNetworkConfig(network))
|
||||
if (network.vpn_portal_config?.enabled !== false && network.vpn_portal_config
|
||||
&& !network.vpn_portal_config.wireguard_private_key) {
|
||||
network.vpn_portal_config.wireguard_private_key = createVpnPortalConfig().wireguard_private_key
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => curNetwork.value, syncNormalizedNetwork, { immediate: true, deep: false })
|
||||
@@ -205,26 +259,18 @@ const instanceRecvBpsLimitInput = computed<string>({
|
||||
},
|
||||
})
|
||||
|
||||
function defaultVpnPortalConfig(): VpnPortalConfig {
|
||||
return {
|
||||
wireguard_listen: '0.0.0.0:22022',
|
||||
clients: [],
|
||||
}
|
||||
}
|
||||
|
||||
const vpnPortalEnabled = computed({
|
||||
get: () => curNetwork.value.vpn_portal_config !== undefined,
|
||||
get: () => curNetwork.value.vpn_portal_config !== undefined
|
||||
&& curNetwork.value.vpn_portal_config.enabled !== false,
|
||||
set: (enabled: boolean) => {
|
||||
curNetwork.value.vpn_portal_config = enabled ? defaultVpnPortalConfig() : undefined
|
||||
},
|
||||
})
|
||||
|
||||
const vpnPortalConfig = computed(() => curNetwork.value.vpn_portal_config ?? defaultVpnPortalConfig())
|
||||
|
||||
const vpnPortalPrivateKey = computed({
|
||||
get: () => vpnPortalConfig.value.wireguard_private_key ?? '',
|
||||
set: (value: string | null | undefined) => {
|
||||
vpnPortalConfig.value.wireguard_private_key = value && value.length > 0 ? value : undefined
|
||||
if (enabled && !curNetwork.value.vpn_portal_config) {
|
||||
curNetwork.value.vpn_portal_config = createVpnPortalConfig()
|
||||
} else if (curNetwork.value.vpn_portal_config) {
|
||||
if (enabled && !curNetwork.value.vpn_portal_config.wireguard_private_key) {
|
||||
curNetwork.value.vpn_portal_config.wireguard_private_key = createVpnPortalConfig().wireguard_private_key
|
||||
}
|
||||
curNetwork.value.vpn_portal_config.enabled = enabled
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -244,11 +290,13 @@ function vpnPortalClientViewKey(client: VpnPortalClientConfig): string {
|
||||
}
|
||||
|
||||
function addVpnPortalClient() {
|
||||
vpnPortalConfig.value.clients.push({ name: '', virtual_ip: '', groups: [] })
|
||||
curNetwork.value.vpn_portal_config?.clients.push({
|
||||
name: `device-${uuidv4().slice(0, 8)}`, virtual_ip: '', groups: [],
|
||||
})
|
||||
}
|
||||
|
||||
function removeVpnPortalClient(index: number) {
|
||||
vpnPortalConfig.value.clients.splice(index, 1)
|
||||
curNetwork.value.vpn_portal_config?.clients.splice(index, 1)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -286,11 +334,36 @@ function removeVpnPortalClient(index: number) {
|
||||
<div class="flex flex-col gap-2 basis-5/12 grow">
|
||||
<label for="network_name">{{ t('network_name') }}</label>
|
||||
<InputText id="network_name" v-model="curNetwork.network_name" aria-describedby="network_name-help" />
|
||||
<div v-if="!hideSecureMode && identityMode === 'secret'" class="flex items-center gap-2">
|
||||
<InputSwitch inputId="secure_mode" v-model="secureModeEnabled" />
|
||||
<label for="secure_mode" class="cursor-pointer">{{ t('secure_mode') }}</label>
|
||||
<span class="pi pi-question-circle text-sm" v-tooltip.top="t('secure_mode_hint')"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 basis-5/12 grow">
|
||||
<div v-if="identityMode === 'secret'" class="flex flex-col gap-2 basis-5/12 grow">
|
||||
<label for="network_secret">{{ t('network_secret') }}</label>
|
||||
<Password id="network_secret" v-model="curNetwork.network_secret"
|
||||
aria-describedby="network_secret-help" toggleMask :feedback="false" fluid />
|
||||
<template v-if="!hideSecureMode">
|
||||
<button type="button" class="self-start text-sm underline cursor-pointer bg-transparent border-none p-0"
|
||||
@click="useCredentialMode">
|
||||
{{ t('use_credential') }} <i class="pi pi-angle-right"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2 basis-5/12 grow">
|
||||
<label for="credential_secret">{{ t('credential_secret') }}</label>
|
||||
<Password id="credential_secret" v-model="curNetwork.credential_secret"
|
||||
aria-describedby="credential_secret-help" toggleMask :feedback="false" fluid
|
||||
:class="{ 'p-invalid': credentialInvalid }" />
|
||||
<small id="credential_secret-help" class="text-xs">{{ t('credential_secret_hint') }}</small>
|
||||
<template v-if="!hideSecureMode">
|
||||
<div class="text-xs">{{ t('credential_mode_hint') }}</div>
|
||||
<button type="button" class="self-start text-sm underline cursor-pointer bg-transparent border-none p-0"
|
||||
@click="useSecretMode">
|
||||
<i class="pi pi-angle-left"></i> {{ t('use_network_secret') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -347,61 +420,49 @@ function removeVpnPortalClient(index: number) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-x-9 flex-wrap ">
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<label>VPN Portal</label>
|
||||
<ToggleButton v-model="vpnPortalEnabled" on-icon="pi pi-check" off-icon="pi pi-times"
|
||||
:on-label="t('off_text')" :off-label="t('on_text')" class="w-48" />
|
||||
<div v-if="vpnPortalEnabled" class="flex flex-col gap-3 w-full">
|
||||
<div class="flex flex-row gap-x-9 gap-y-3 flex-wrap w-full">
|
||||
<div class="flex flex-col gap-2 basis-5/12 grow">
|
||||
<label for="vpn_portal_wireguard_listen">{{ t('vpn_portal_wireguard_listen') }}</label>
|
||||
<InputText id="vpn_portal_wireguard_listen" v-model="vpnPortalConfig.wireguard_listen"
|
||||
:placeholder="t('vpn_portal_wireguard_listen_placeholder')" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 basis-5/12 grow">
|
||||
<label for="vpn_portal_wireguard_private_key">{{ t('vpn_portal_wireguard_private_key') }}</label>
|
||||
<Password id="vpn_portal_wireguard_private_key"
|
||||
v-model="vpnPortalPrivateKey"
|
||||
:placeholder="t('vpn_portal_wireguard_private_key_placeholder')"
|
||||
toggleMask :feedback="false" fluid />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<label>{{ t('vpn_portal_clients') }}</label>
|
||||
<Button icon="pi pi-plus" :label="t('vpn_portal_add_client')" severity="secondary" size="small"
|
||||
:disabled="vpnPortalConfig.clients.length >= 64"
|
||||
@click="addVpnPortalClient" />
|
||||
</div>
|
||||
|
||||
<div v-if="vpnPortalConfig.clients.length === 0"
|
||||
class="text-sm text-surface-500 dark:text-surface-400">
|
||||
{{ t('vpn_portal_no_clients') }}
|
||||
</div>
|
||||
<div v-for="(client, index) in vpnPortalConfig.clients" :key="vpnPortalClientViewKey(client)"
|
||||
class="flex flex-row gap-3 flex-wrap items-end rounded border border-surface-200 dark:border-surface-700 p-3">
|
||||
<div class="flex flex-col gap-2 grow basis-3/12">
|
||||
<label :for="`vpn_portal_client_name_${index}`">{{ t('vpn_portal_client_name') }}</label>
|
||||
<InputText :id="`vpn_portal_client_name_${index}`" v-model="client.name"
|
||||
:placeholder="t('vpn_portal_client_name_placeholder')" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 grow basis-3/12">
|
||||
<label :for="`vpn_portal_client_virtual_ip_${index}`">{{ t('vpn_portal_client_virtual_ip') }}</label>
|
||||
<InputText :id="`vpn_portal_client_virtual_ip_${index}`" v-model="client.virtual_ip"
|
||||
:placeholder="t('vpn_portal_client_virtual_ip_placeholder')" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 grow basis-4/12">
|
||||
<label :for="`vpn_portal_client_groups_${index}`">{{ t('vpn_portal_client_groups') }}</label>
|
||||
<MultiSelect :input-id="`vpn_portal_client_groups_${index}`" v-model="client.groups"
|
||||
:options="vpnPortalGroupOptions" appendTo="self" filter fluid
|
||||
:placeholder="t('vpn_portal_client_groups_placeholder')" />
|
||||
</div>
|
||||
<Button icon="pi pi-trash" severity="danger" text rounded
|
||||
:aria-label="t('vpn_portal_remove_client')" @click="removeVpnPortalClient(index)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<Checkbox v-model="vpnPortalEnabled" input-id="vpn_portal_enabled" :binary="true" />
|
||||
<label for="vpn_portal_enabled">{{ t('vpn_portal_enable') }}</label>
|
||||
</div>
|
||||
<p class="text-sm text-surface-500 dark:text-surface-400">{{ t(editVpnPortalClients ? 'vpn_portal_config_clients_help' : 'vpn_portal_setup_help') }}</p>
|
||||
<details v-if="vpnPortalEnabled && curNetwork.vpn_portal_config">
|
||||
<summary class="cursor-pointer text-sm">{{ t('vpn_portal_advanced') }}</summary>
|
||||
<div class="flex flex-col gap-2 mt-3">
|
||||
<label for="vpn_portal_wireguard_listen">{{ t('vpn_portal_wireguard_listen') }}</label>
|
||||
<InputText id="vpn_portal_wireguard_listen" v-model="curNetwork.vpn_portal_config.wireguard_listen"
|
||||
:placeholder="t('vpn_portal_wireguard_listen_placeholder')" />
|
||||
</div>
|
||||
</details>
|
||||
<template v-if="editVpnPortalClients && vpnPortalEnabled && curNetwork.vpn_portal_config">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<label>{{ t('vpn_portal_clients') }}</label>
|
||||
<Button icon="pi pi-plus" :label="t('vpn_portal_add_client')" severity="secondary" size="small"
|
||||
:disabled="curNetwork.vpn_portal_config.clients.length >= 64" @click="addVpnPortalClient" />
|
||||
</div>
|
||||
<div v-if="curNetwork.vpn_portal_config.clients.length === 0"
|
||||
class="text-sm text-surface-500 dark:text-surface-400">{{ t('vpn_portal_no_clients') }}</div>
|
||||
<div v-for="(client, index) in curNetwork.vpn_portal_config.clients" :key="vpnPortalClientViewKey(client)"
|
||||
class="flex flex-row gap-3 flex-wrap items-end rounded border border-surface-200 dark:border-surface-700 p-3">
|
||||
<div class="flex flex-col gap-2 grow basis-3/12">
|
||||
<label :for="`vpn_portal_client_name_${index}`">{{ t('vpn_portal_client_name') }}</label>
|
||||
<InputText :id="`vpn_portal_client_name_${index}`" v-model="client.name" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 grow basis-3/12">
|
||||
<label :for="`vpn_portal_client_virtual_ip_${index}`">{{ t('vpn_portal_client_virtual_ip') }}</label>
|
||||
<InputText :id="`vpn_portal_client_virtual_ip_${index}`" v-model="client.virtual_ip"
|
||||
:placeholder="t('vpn_portal_client_virtual_ip_placeholder')" />
|
||||
</div>
|
||||
<div v-if="vpnPortalGroupOptions.length" class="flex flex-col gap-2 grow basis-4/12">
|
||||
<label :for="`vpn_portal_client_groups_${index}`">{{ t('vpn_portal_client_groups') }}</label>
|
||||
<MultiSelect :input-id="`vpn_portal_client_groups_${index}`" v-model="client.groups"
|
||||
:options="vpnPortalGroupOptions" appendTo="self" filter fluid
|
||||
:placeholder="t('vpn_portal_client_groups_placeholder')" />
|
||||
</div>
|
||||
<Button icon="pi pi-trash" severity="danger" text rounded
|
||||
:aria-label="t('vpn_portal_remove_client')" @click="removeVpnPortalClient(index)" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-x-9 flex-wrap">
|
||||
@@ -626,7 +687,7 @@ function removeVpnPortalClient(index: number) {
|
||||
</Panel>
|
||||
|
||||
<div class="flex pt-6 justify-center">
|
||||
<Button :label="actionLabel || t('run_network')" icon="pi pi-arrow-right" icon-pos="right" :disabled="configInvalid"
|
||||
<Button :label="actionLabel || t('run_network')" icon="pi pi-arrow-right" icon-pos="right" :disabled="configInvalid || credentialInvalid"
|
||||
@click="$emit('runNetwork', curNetwork)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -471,7 +471,7 @@ onUnmounted(() => {
|
||||
<ConfirmPopup></ConfirmPopup>
|
||||
|
||||
<!-- 网络选择和操作按钮始终在同一行 -->
|
||||
<div class="network-header bg-surface-50 p-3 rounded-lg shadow-sm mb-1">
|
||||
<div class="network-header mb-3">
|
||||
<div class="flex flex-row justify-between items-center gap-2" style="align-items: center;">
|
||||
<!-- 网络选择 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -556,7 +556,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="network-content bg-surface-0 p-4 rounded-lg shadow-sm">
|
||||
<div class="network-content">
|
||||
<!-- Network Creation Form -->
|
||||
<div v-if="isEditingNetwork || networkIsDisabled" class="network-creation-container">
|
||||
<div class="network-creation-header flex items-center gap-2 mb-3">
|
||||
@@ -588,7 +588,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<Status v-if="curNetworkInfo && curNetworkInfo.error_msg === ''" v-bind:cur-network-inst="curNetworkInfo"
|
||||
:api="api"
|
||||
:api="api" :readonly="!currentNetworkControl.editable.value"
|
||||
class="mb-4">
|
||||
</Status>
|
||||
<Message v-else-if="curNetworkInfo?.error_msg" severity="error" class="mb-4">{{
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeAgo } from '@vueuse/core'
|
||||
import { NetworkInstance, VpnPortalClientState, type TunnelInfo, type NodeInfo, type PeerRoutePair, type VpnPortalClientInfo, type VpnPortalInfo } from '../types/network'
|
||||
import { NetworkInstance, type NetworkConfig, type TunnelInfo, type NodeInfo, type PeerRoutePair } from '../types/network'
|
||||
import type { RemoteClient } from '../modules/api'
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { ipv4InetToString, ipv4ToString, ipv6ToString } from '../modules/utils';
|
||||
import { latencyMs, lossRate, numericValue, peerConns } from '../modules/statusDisplay';
|
||||
import { Badge, DataTable, Column, Tag, Chip, Button, Dialog, ScrollPanel, Timeline, Divider, Card, } from 'primevue';
|
||||
import { Badge, DataTable, Column, Tag, Chip, Button, Dialog, Timeline, Divider, Card, } from 'primevue';
|
||||
import NetworkChart from './NetworkChart.vue';
|
||||
import VpnPortalDialog from './VpnPortalDialog.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
curNetworkInst: NetworkInstance | null,
|
||||
api: RemoteClient,
|
||||
readonly?: boolean,
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -329,67 +331,34 @@ onUnmounted(() => {
|
||||
const dialogVisible = ref(false)
|
||||
const dialogContent = ref<any>('')
|
||||
const dialogHeader = ref('event_log')
|
||||
const vpnPortalInfo = ref<VpnPortalInfo>()
|
||||
const vpnPortalClients = computed(() => vpnPortalInfo.value?.clients ?? [])
|
||||
const vpnPortalLoading = ref(false)
|
||||
const vpnPortalError = ref('')
|
||||
const copiedVpnPortalClient = ref('')
|
||||
const vpnPortalVisible = ref(false)
|
||||
const networkConfig = ref<NetworkConfig>()
|
||||
const vpnPortalEnabled = computed(() => props.curNetworkInst?.running
|
||||
&& networkConfig.value?.vpn_portal_config !== undefined
|
||||
&& networkConfig.value.vpn_portal_config.enabled !== false)
|
||||
|
||||
async function showVpnPortalConfig() {
|
||||
const instanceId = props.curNetworkInst?.instance_id
|
||||
if (!instanceId)
|
||||
return
|
||||
|
||||
dialogHeader.value = 'vpn_portal_config'
|
||||
dialogVisible.value = true
|
||||
vpnPortalInfo.value = undefined
|
||||
vpnPortalError.value = ''
|
||||
copiedVpnPortalClient.value = ''
|
||||
vpnPortalLoading.value = true
|
||||
try {
|
||||
vpnPortalInfo.value = await props.api.get_vpn_portal_info(instanceId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load VPN Portal information', error)
|
||||
vpnPortalError.value = t('vpn_portal_load_failed')
|
||||
} finally {
|
||||
vpnPortalLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function vpnPortalStateKey(state: VpnPortalClientState | string): string {
|
||||
const normalized = typeof state === 'string'
|
||||
? state.toLowerCase().replace('vpn_portal_client_state_', '')
|
||||
: VpnPortalClientState[state]?.toLowerCase()
|
||||
return `vpn_portal_state_${normalized ?? 'unspecified'}`
|
||||
}
|
||||
|
||||
function vpnPortalStateSeverity(state: VpnPortalClientState | string): 'success' | 'warn' | 'danger' | 'secondary' {
|
||||
const key = vpnPortalStateKey(state)
|
||||
if (key.endsWith('online')) return 'success'
|
||||
if (key.endsWith('connecting')) return 'warn'
|
||||
if (key.endsWith('error')) return 'danger'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
async function copyVpnPortalClientConfig(client: VpnPortalClientInfo) {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(client.client_config)
|
||||
} else {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = client.client_config
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
textarea.remove()
|
||||
watch(() => props.curNetworkInst?.instance_id, async (instanceId, _, onCleanup) => {
|
||||
let active = true
|
||||
vpnPortalVisible.value = false
|
||||
networkConfig.value = undefined
|
||||
if (!instanceId) return
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined
|
||||
onCleanup(() => {
|
||||
active = false
|
||||
clearTimeout(refreshTimer)
|
||||
})
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const config = await props.api.get_network_config(instanceId)
|
||||
if (active) networkConfig.value = config
|
||||
} catch (error) {
|
||||
console.error('Failed to load WireGuard settings', error)
|
||||
} finally {
|
||||
if (active) refreshTimer = setTimeout(refresh, 10_000)
|
||||
}
|
||||
copiedVpnPortalClient.value = client.name
|
||||
} catch (error) {
|
||||
console.error('Failed to copy VPN Portal client config', error)
|
||||
}
|
||||
}
|
||||
await refresh()
|
||||
}, { immediate: true })
|
||||
|
||||
function showEventLogs() {
|
||||
const detail = props.curNetworkInst?.detail
|
||||
@@ -406,54 +375,7 @@ function showEventLogs() {
|
||||
<div class="frontend-lib">
|
||||
<Dialog v-model:visible="dialogVisible" modal :header="t(dialogHeader)" class="w-full h-auto max-h-full"
|
||||
:baseZIndex="2000">
|
||||
<ScrollPanel v-if="dialogHeader === 'vpn_portal_config'" class="max-h-[75vh] pr-3">
|
||||
<div v-if="vpnPortalLoading" class="py-8 text-center text-surface-500">
|
||||
{{ t('web.device_management.loading_network_status') }}
|
||||
</div>
|
||||
<div v-else-if="vpnPortalError" class="py-4 text-red-500">
|
||||
{{ vpnPortalError }}
|
||||
</div>
|
||||
<div v-else-if="!vpnPortalInfo || ((!vpnPortalInfo.vpn_type || vpnPortalInfo.vpn_type === 'null') && vpnPortalClients.length === 0)"
|
||||
class="py-4 text-surface-500">
|
||||
{{ t('vpn_portal_not_configured') }}
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
||||
<span v-if="vpnPortalInfo.vpn_type"><strong>{{ t('vpn_portal_type') }}:</strong>
|
||||
{{ vpnPortalInfo.vpn_type }}</span>
|
||||
<span v-if="vpnPortalInfo.listener"><strong>{{ t('vpn_portal_listener') }}:</strong>
|
||||
{{ vpnPortalInfo.listener }}</span>
|
||||
</div>
|
||||
|
||||
<div v-for="client in vpnPortalClients" :key="client.name"
|
||||
class="rounded border border-surface-200 dark:border-surface-700 p-4">
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="font-semibold">{{ client.name }} · {{ client.virtual_ip }}</div>
|
||||
<Tag :severity="vpnPortalStateSeverity(client.state)"
|
||||
:value="t(vpnPortalStateKey(client.state))" />
|
||||
</div>
|
||||
<div class="mb-3 grid gap-x-6 gap-y-1 text-sm sm:grid-cols-2">
|
||||
<span v-if="client.groups.length"><strong>{{ t('vpn_portal_client_groups') }}:</strong>
|
||||
{{ client.groups.join(', ') }}</span>
|
||||
<span v-if="client.peer_id !== undefined"><strong>{{ t('vpn_portal_peer_id') }}:</strong>
|
||||
{{ client.peer_id }}</span>
|
||||
<span v-if="client.endpoint"><strong>{{ t('vpn_portal_endpoint') }}:</strong>
|
||||
{{ client.endpoint }}</span>
|
||||
<span v-if="client.tunnel_ip"><strong>{{ t('vpn_portal_tunnel_ip') }}:</strong>
|
||||
{{ client.tunnel_ip }}</span>
|
||||
<span v-if="client.error" class="text-red-500 sm:col-span-2">{{ client.error }}</span>
|
||||
</div>
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<label class="font-medium">{{ t('vpn_portal_client_config') }}</label>
|
||||
<Button size="small" severity="secondary" icon="pi pi-copy"
|
||||
:label="copiedVpnPortalClient === client.name ? t('config_copied') : t('vpn_portal_copy_client_config')"
|
||||
@click="copyVpnPortalClientConfig(client)" />
|
||||
</div>
|
||||
<pre class="max-w-full overflow-x-auto whitespace-pre-wrap break-all rounded bg-surface-100 p-3 text-xs dark:bg-surface-800">{{ client.client_config }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollPanel>
|
||||
<Timeline v-else :value="dialogContent">
|
||||
<Timeline :value="dialogContent">
|
||||
<template #opposite="slotProps">
|
||||
<small class="text-surface-500 dark:text-surface-400">{{ useTimeAgo(Date.parse(slotProps.item.time))
|
||||
}}</small>
|
||||
@@ -464,6 +386,9 @@ function showEventLogs() {
|
||||
</Timeline>
|
||||
</Dialog>
|
||||
|
||||
<VpnPortalDialog v-if="vpnPortalVisible && curNetworkInst && vpnPortalEnabled" :key="curNetworkInst.instance_id"
|
||||
:instance="curNetworkInst" :api="api" :readonly="readonly" @close="vpnPortalVisible = false" />
|
||||
|
||||
<Card v-if="curNetworkInst?.error_msg">
|
||||
<template #title>
|
||||
Run Network Error
|
||||
@@ -506,7 +431,7 @@ function showEventLogs() {
|
||||
</div>
|
||||
|
||||
<div v-if="myNodeInfo" class="m-0 flex flex-row justify-center gap-x-5 text-sm">
|
||||
<Button severity="info" :label="t('show_vpn_portal_config')" @click="showVpnPortalConfig" />
|
||||
<Button v-if="vpnPortalEnabled" severity="info" :label="t('vpn_portal_devices')" @click="vpnPortalVisible = true" />
|
||||
<Button severity="info" :label="t('show_event_log')" @click="showEventLogs" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Button, Dialog, InputNumber, InputText, MultiSelect, Tag } from 'primevue'
|
||||
import QRCode from 'qrcode'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import type { RemoteClient } from '../modules/api'
|
||||
import { PeriodicTask } from '../modules/utils'
|
||||
import {
|
||||
normalizeVpnPortalEndpoint, suggestVpnPortalAddress, validVpnPortalAddress,
|
||||
vpnPortalClientConfig, vpnPortalEndpoint, vpnPortalListener, vpnPortalUsedIps,
|
||||
} from '../modules/vpnPortal'
|
||||
import { VpnPortalClientState, type NetworkConfig, type NetworkInstance, type VpnPortalInfo } from '../types/network'
|
||||
|
||||
const props = defineProps<{
|
||||
instance: NetworkInstance,
|
||||
api: RemoteClient,
|
||||
readonly?: boolean,
|
||||
}>()
|
||||
const emit = defineEmits(['close'])
|
||||
const { t } = useI18n()
|
||||
const info = ref<VpnPortalInfo>()
|
||||
const config = ref<NetworkConfig>()
|
||||
const loading = ref(true)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const loadError = ref('')
|
||||
const adding = ref(false)
|
||||
const name = ref('')
|
||||
const defaultName = ref('')
|
||||
const address = ref('')
|
||||
const prefix = ref<number>()
|
||||
const groups = ref<string[]>([])
|
||||
const selectedName = ref('')
|
||||
const deletingName = ref('')
|
||||
const endpointOverride = ref('')
|
||||
const copied = ref(false)
|
||||
const qrCode = ref('')
|
||||
const qrError = ref('')
|
||||
let refreshVersion = 0
|
||||
let disposed = false
|
||||
|
||||
const clients = computed(() => info.value?.clients ?? [])
|
||||
const available = computed(() => info.value?.vpn_type === 'wireguard' && !!info.value.listener)
|
||||
const groupOptions = computed(() => config.value?.acl?.acl_v1?.group?.declares.map(group => group.group_name) ?? [])
|
||||
const usedIps = computed(() => vpnPortalUsedIps(props.instance, clients.value))
|
||||
const clientName = computed(() => name.value.trim() || defaultName.value)
|
||||
const validName = computed(() => /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/.test(clientName.value)
|
||||
&& !clients.value.some(client => client.name === clientName.value))
|
||||
const validAddress = computed(() => validVpnPortalAddress(address.value.trim(), prefix.value, usedIps.value))
|
||||
const canAdd = computed(() => available.value && !props.readonly && validName.value && validAddress.value
|
||||
&& clients.value.length < 64 && !busy.value)
|
||||
const selectedClient = computed(() => clients.value.find(client => client.name === selectedName.value))
|
||||
const automaticEndpoint = computed(() => vpnPortalEndpoint(info.value?.listener ?? '', props.instance.detail?.my_node_info))
|
||||
const endpoint = computed(() => normalizeVpnPortalEndpoint(
|
||||
endpointOverride.value || automaticEndpoint.value, vpnPortalListener(info.value?.listener ?? '')?.port ?? '',
|
||||
))
|
||||
const clientConfig = computed(() => vpnPortalClientConfig(selectedClient.value?.client_config ?? '', endpoint.value))
|
||||
|
||||
async function refresh() {
|
||||
if (disposed) return
|
||||
const version = ++refreshVersion
|
||||
try {
|
||||
const [portal, network] = await Promise.all([
|
||||
props.api.get_vpn_portal_info(props.instance.instance_id),
|
||||
props.api.get_network_config(props.instance.instance_id),
|
||||
])
|
||||
if (disposed || version !== refreshVersion) return
|
||||
info.value = portal
|
||||
config.value = network
|
||||
} catch (cause) {
|
||||
if (!disposed && version === refreshVersion) throw cause
|
||||
}
|
||||
}
|
||||
|
||||
const refreshTask = new PeriodicTask(async () => {
|
||||
if (busy.value) return
|
||||
try {
|
||||
await refresh()
|
||||
loadError.value = ''
|
||||
} catch (cause) {
|
||||
loadError.value = `${t('vpn_portal_load_failed')}: ${String(cause)}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}, 3000)
|
||||
|
||||
onMounted(() => refreshTask.start())
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
refreshVersion++
|
||||
refreshTask.stop()
|
||||
})
|
||||
|
||||
function addDevice() {
|
||||
name.value = ''
|
||||
// Names determine WireGuard identities; a new device must not recycle a deleted one's default name.
|
||||
defaultName.value = `device-${uuidv4().slice(0, 8)}`
|
||||
const suggestion = suggestVpnPortalAddress(props.instance, config.value?.vpn_portal_config?.clients ?? [], usedIps.value)
|
||||
address.value = suggestion.address
|
||||
prefix.value = suggestion.prefix
|
||||
groups.value = []
|
||||
selectedName.value = ''
|
||||
error.value = ''
|
||||
adding.value = true
|
||||
}
|
||||
|
||||
async function saveDevice() {
|
||||
if (!canAdd.value) return
|
||||
busy.value = true
|
||||
refreshVersion++
|
||||
error.value = ''
|
||||
const newName = clientName.value
|
||||
try {
|
||||
await props.api.add_vpn_portal_client(props.instance.instance_id, {
|
||||
name: newName,
|
||||
virtual_ip: `${address.value.trim()}/${prefix.value}`,
|
||||
groups: groups.value,
|
||||
})
|
||||
adding.value = false
|
||||
selectedName.value = newName
|
||||
await refresh()
|
||||
} catch (cause) {
|
||||
error.value = `${t('vpn_portal_save_failed')}: ${String(cause)}`
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDevice(clientName: string) {
|
||||
if (busy.value || props.readonly) return
|
||||
busy.value = true
|
||||
refreshVersion++
|
||||
error.value = ''
|
||||
try {
|
||||
await props.api.remove_vpn_portal_client(props.instance.instance_id, clientName)
|
||||
deletingName.value = ''
|
||||
if (selectedName.value === clientName) selectedName.value = ''
|
||||
await refresh()
|
||||
} catch (cause) {
|
||||
error.value = `${t('vpn_portal_remove_failed')}: ${String(cause)}`
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function stateKey(state: VpnPortalClientState | string): string {
|
||||
const normalized = typeof state === 'string'
|
||||
? state.toLowerCase().replace('vpn_portal_client_state_', '')
|
||||
: VpnPortalClientState[state]?.toLowerCase()
|
||||
return `vpn_portal_state_${normalized ?? 'unspecified'}`
|
||||
}
|
||||
|
||||
function stateSeverity(state: VpnPortalClientState | string): 'success' | 'warn' | 'danger' | 'secondary' {
|
||||
const key = stateKey(state)
|
||||
if (key.endsWith('online')) return 'success'
|
||||
if (key.endsWith('connecting')) return 'warn'
|
||||
if (key.endsWith('error')) return 'danger'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
watch(clientConfig, async (value, _, onCleanup) => {
|
||||
let active = true
|
||||
onCleanup(() => { active = false })
|
||||
qrCode.value = ''
|
||||
qrError.value = ''
|
||||
copied.value = false
|
||||
if (!value) return
|
||||
try {
|
||||
const svg = await QRCode.toString(value, { type: 'svg', errorCorrectionLevel: 'M', margin: 4 })
|
||||
if (active) qrCode.value = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
||||
} catch {
|
||||
if (active) qrError.value = t('vpn_portal_qr_failed')
|
||||
}
|
||||
})
|
||||
|
||||
async function copyConfig() {
|
||||
if (!clientConfig.value) return
|
||||
const value = clientConfig.value
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value)
|
||||
} else {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = value
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
try {
|
||||
textarea.select()
|
||||
if (!document.execCommand('copy')) throw new Error(t('vpn_portal_copy_failed'))
|
||||
} finally {
|
||||
textarea.remove()
|
||||
}
|
||||
}
|
||||
if (clientConfig.value === value) copied.value = true
|
||||
} catch {
|
||||
error.value = t('vpn_portal_copy_failed')
|
||||
}
|
||||
}
|
||||
|
||||
function downloadConfig() {
|
||||
if (!clientConfig.value || !selectedClient.value) return
|
||||
const url = URL.createObjectURL(new Blob([clientConfig.value], { type: 'text/plain' }))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${selectedClient.value.name}.conf`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :visible="true" modal :header="t('vpn_portal_devices')" class="w-[48rem] max-w-[95vw]"
|
||||
:baseZIndex="2000" @update:visible="emit('close')">
|
||||
<div class="flex flex-col gap-4">
|
||||
<p v-if="loading" class="py-6 text-center">{{ t('web.device_management.loading_network_status') }}</p>
|
||||
<p v-if="error || loadError" role="alert" class="text-red-500">{{ error || loadError }}</p>
|
||||
<p v-if="!loading && !available">{{ t('vpn_portal_not_configured') }}</p>
|
||||
<template v-if="available">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-sm text-surface-500">{{ t('vpn_portal_devices_help') }}</span>
|
||||
<Button v-if="!readonly" icon="pi pi-plus" :label="t('vpn_portal_add_client')"
|
||||
:disabled="busy || clients.length >= 64 || adding" @click="addDevice" />
|
||||
</div>
|
||||
<p v-if="clients.length === 0 && !adding" class="py-6 text-center text-surface-500">{{ t('vpn_portal_no_clients') }}</p>
|
||||
<div v-for="client in clients" :key="client.name" class="rounded border border-surface-200 dark:border-surface-700 p-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<span class="font-medium">{{ client.name }}</span>
|
||||
<span class="ml-2 text-sm text-surface-500">{{ client.virtual_ip }}</span>
|
||||
<Tag class="ml-2" :severity="stateSeverity(client.state)" :value="t(stateKey(client.state))" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button size="small" severity="secondary" :label="t('vpn_portal_connect_device')" :disabled="busy"
|
||||
@click="selectedName = client.name; adding = false" />
|
||||
<Button v-if="!readonly" icon="pi pi-trash" severity="danger" text
|
||||
:aria-label="t('vpn_portal_remove_client')" :disabled="busy" @click="deletingName = client.name" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="client.error" class="mt-2 text-sm text-red-500">{{ client.error }}</p>
|
||||
<div v-if="deletingName === client.name" class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm">{{ t('vpn_portal_remove_confirm') }}</span>
|
||||
<Button size="small" severity="danger" :label="t('vpn_portal_remove_client')" :disabled="busy"
|
||||
@click="removeDevice(client.name)" />
|
||||
<Button size="small" text :label="t('web.common.cancel')" :disabled="busy" @click="deletingName = ''" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form v-if="adding" class="flex flex-col gap-3 rounded border border-surface-200 dark:border-surface-700 p-4"
|
||||
@submit.prevent="saveDevice">
|
||||
<label for="vpn_portal_client_name">{{ t('vpn_portal_client_name') }}</label>
|
||||
<InputText id="vpn_portal_client_name" v-model="name" :placeholder="defaultName" :disabled="busy" />
|
||||
<small v-if="!validName" class="text-red-500">{{ t('vpn_portal_invalid_name') }}</small>
|
||||
<label for="vpn_portal_client_address">{{ t('vpn_portal_client_address') }}</label>
|
||||
<InputText id="vpn_portal_client_address" v-model="address" placeholder="10.126.126.10" :disabled="busy" />
|
||||
<small class="text-surface-500">{{ t('vpn_portal_address_help') }}</small>
|
||||
<small v-if="address && !validAddress" class="text-red-500">{{ t('vpn_portal_invalid_address') }}</small>
|
||||
<details :open="prefix === undefined">
|
||||
<summary class="cursor-pointer text-sm">{{ t('vpn_portal_advanced') }}</summary>
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
<label for="vpn_portal_client_prefix">{{ t('vpn_portal_client_prefix') }}</label>
|
||||
<InputNumber input-id="vpn_portal_client_prefix" v-model="prefix" :min="0" :max="30" :disabled="busy" />
|
||||
<template v-if="groupOptions.length">
|
||||
<label for="vpn_portal_client_groups">{{ t('vpn_portal_client_groups') }}</label>
|
||||
<MultiSelect input-id="vpn_portal_client_groups" v-model="groups" :options="groupOptions"
|
||||
:placeholder="t('vpn_portal_client_groups_placeholder')" appendTo="self" filter fluid :disabled="busy" />
|
||||
</template>
|
||||
</div>
|
||||
</details>
|
||||
<div class="flex gap-2">
|
||||
<Button type="submit" :label="t('vpn_portal_generate_config')" :disabled="!canAdd" :loading="busy" />
|
||||
<Button text :label="t('web.common.cancel')" :disabled="busy" @click="adding = false" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="selectedClient" class="flex flex-col gap-3 border-t border-surface-200 dark:border-surface-700 pt-4">
|
||||
<div class="font-medium">{{ t('vpn_portal_client_config') }} · {{ selectedClient.name }}</div>
|
||||
<p v-if="endpoint" class="text-sm">{{ t('vpn_portal_server_endpoint') }}: {{ endpoint }}</p>
|
||||
<details :open="!automaticEndpoint">
|
||||
<summary class="cursor-pointer text-sm">{{ t('vpn_portal_custom_endpoint') }}</summary>
|
||||
<div class="mt-2 flex flex-col gap-2">
|
||||
<label for="vpn_portal_server_endpoint">{{ t('vpn_portal_server_endpoint') }}</label>
|
||||
<InputText id="vpn_portal_server_endpoint" v-model="endpointOverride"
|
||||
:placeholder="automaticEndpoint || 'vpn.example.com:22022'" />
|
||||
<small>{{ t('vpn_portal_endpoint_help') }}</small>
|
||||
</div>
|
||||
</details>
|
||||
<p v-if="!endpoint" class="text-sm text-amber-600">{{ t('vpn_portal_endpoint_required') }}</p>
|
||||
<template v-if="clientConfig">
|
||||
<p class="text-sm">{{ t('vpn_portal_scan_help') }}</p>
|
||||
<img v-if="qrCode" :src="qrCode" :alt="t('vpn_portal_qr_alt')" width="280" height="280"
|
||||
class="max-w-full self-center" />
|
||||
<p v-if="qrError" class="text-sm text-amber-600">{{ qrError }}</p>
|
||||
<div class="flex flex-wrap gap-2 justify-center">
|
||||
<Button icon="pi pi-download" :label="t('vpn_portal_download_config')" @click="downloadConfig" />
|
||||
<Button icon="pi pi-copy" severity="secondary"
|
||||
:label="copied ? t('config_copied') : t('vpn_portal_copy_client_config')" @click="copyConfig" />
|
||||
</div>
|
||||
<details>
|
||||
<summary class="cursor-pointer text-sm">{{ t('vpn_portal_show_config') }}</summary>
|
||||
<pre class="mt-2 overflow-x-auto whitespace-pre-wrap break-all rounded bg-surface-100 p-3 text-xs dark:bg-surface-800">{{ clientConfig }}</pre>
|
||||
</details>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -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';
|
||||
export { default as RemoteManagement } from './RemoteManagement.vue';
|
||||
export { default as UrlListInput } from './UrlListInput.vue';
|
||||
@@ -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 };
|
||||
@@ -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: 取消
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string>;
|
||||
machine_id: string;
|
||||
location: Location | undefined;
|
||||
networks?: Array<DeviceNetwork>;
|
||||
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;
|
||||
|
||||
@@ -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<number> {
|
||||
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<number>,
|
||||
): { 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<number>): 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,6 +4,7 @@ export default {
|
||||
'./index.html',
|
||||
'./src/**/*.{vue,js,ts,jsx,tsx}',
|
||||
],
|
||||
darkMode: ['class', '.app-dark'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, any> = {}) {
|
||||
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<HTMLInputElement>('#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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
The management console uses Vue 3, PrimeVue 4 and Tailwind 3. Device
|
||||
configuration and runtime views reuse `easytier-frontend-lib`.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
## Development
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --dir easytier-web/frontend dev
|
||||
```
|
||||
|
||||
The development proxy defaults to `http://localhost:11211`. Set
|
||||
`API_BASE_URL` to use another backend. Production API discovery and hash
|
||||
routes remain compatible with existing deployments.
|
||||
|
||||
`src/theme.ts` contains the Web-specific Aura preset; `src/console.css`
|
||||
contains the console layout and shared-component compatibility tokens.
|
||||
Register the Web PrimeVue configuration first, then register the shared
|
||||
library with `skipPrimeVue: true` to preserve the console preset. These
|
||||
styles are not imported by the GUI or config generator.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
pnpm --dir easytier-web/frontend exec playwright install chromium
|
||||
pnpm --dir easytier-web/frontend test:browser
|
||||
pnpm --dir easytier-web/frontend-lib test:config-ui
|
||||
pnpm --dir easytier-web/frontend-lib test:network-config
|
||||
```
|
||||
|
||||
The browser command builds the application, starts a local preview on
|
||||
port 5198, and uses intercepted API responses. It covers summary refresh
|
||||
and recovery, search and sorting, device deep links and drawer history,
|
||||
network creation, gateway discovery, unsaved settings, secret regeneration,
|
||||
member changes and deletion confirmation. No real controller is modified.
|
||||
|
||||
The layout checks cover English and Chinese, light and dark themes, and
|
||||
1440px, 1024px and 390px viewports. To save screenshots outside the repo:
|
||||
|
||||
```sh
|
||||
WEB_SCREENSHOT_DIR=/tmp/easytier-web-redesign \
|
||||
pnpm --dir easytier-web/frontend test:browser
|
||||
```
|
||||
|
||||
Set `WEB_TEST_URL` to test an already running frontend instead of starting
|
||||
the preview server. Browser checks exercise simulated device responses;
|
||||
they do not replace integration testing against a live EasyTier network.
|
||||
|
||||
### End-to-end test
|
||||
|
||||
The end-to-end test needs the `rust` Docker container with access to the
|
||||
repository and its shared `target` directory. Build the Core binary inside
|
||||
the container so it can run there, then restore the build directory's owner
|
||||
for the host-side Web build:
|
||||
|
||||
```sh
|
||||
docker exec rust bash -lc "cd \"$PWD\" && cargo build -p easytier --bin easytier-core"
|
||||
sudo chown -R "$(id -u):$(id -g)" target
|
||||
pnpm --dir easytier-web/frontend test:e2e
|
||||
```
|
||||
|
||||
This test uses a fresh SQLite database, a real Web server, a Chromium browser,
|
||||
and a Core process with a TUN device. It checks network creation, member
|
||||
configuration, ACL persistence, live peer and route RPCs, and offline member
|
||||
deletion across Web and Core restarts. It does not intercept API requests.
|
||||
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "pnpm --dir ../frontend-lib build && vite",
|
||||
"build": "pnpm --dir ../frontend-lib build && vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test:browser": "pnpm build && node --test tests/dashboard.test.mjs",
|
||||
"test:e2e": "pnpm build && cargo build -p easytier-web --features embed && node --test tests/central-e2e.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
||||
@@ -16,11 +18,13 @@
|
||||
"primevue": "^4.3.9",
|
||||
"tailwindcss-primeui": "^0.3.4",
|
||||
"ts-md5": "^1.3.1",
|
||||
"uuid": "^11.0.2",
|
||||
"vue": "^3.5.12",
|
||||
"vue-i18n": "^9.9.1",
|
||||
"vue-router": "4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "1.61.0",
|
||||
"@primevue/auto-import-resolver": "4.3.9",
|
||||
"@types/node": "^22.8.6",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Button, Drawer, InputNumber, InputText, InputSwitch, Message, MultiSelect, Select, SelectButton, useToast } from 'primevue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import ApiClient, { type AclPolicy, type AclPolicyRule, type AclProtocolTarget, type AclSelector, type CentralNetworkMember } from '../modules/api';
|
||||
|
||||
const props = defineProps<{
|
||||
api: ApiClient;
|
||||
networkId: string;
|
||||
members: CentralNetworkMember[] | undefined;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const toast = useToast();
|
||||
|
||||
// ---- policy state -------------------------------------------------------------
|
||||
|
||||
const policy = ref<AclPolicy>({ default_action: 'allow', rules: [] });
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const dirty = ref(false);
|
||||
const loadError = ref('');
|
||||
|
||||
const memberName = (member_id: string) => {
|
||||
const member = props.members?.find(m => m.member_id === member_id);
|
||||
return member ? (member.hostname_override || member.alias || member.hostname || member.device_id.slice(0, 8)) : member_id.slice(0, 8);
|
||||
};
|
||||
|
||||
const loadPolicy = async () => {
|
||||
loading.value = true;
|
||||
loadError.value = '';
|
||||
try {
|
||||
const info = await props.api.get_network_acl_policy(props.networkId);
|
||||
policy.value = info.policy;
|
||||
dirty.value = false;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
loadError.value = t('web.console.load_failed');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.networkId, () => { policy.value = { default_action: 'allow', rules: [] }; loadPolicy(); }, { immediate: true });
|
||||
|
||||
const enabledCount = computed(() => policy.value.rules.filter(r => r.enabled).length);
|
||||
|
||||
const save = async () => {
|
||||
saving.value = true;
|
||||
try {
|
||||
const info = await props.api.update_network_acl_policy(props.networkId, policy.value);
|
||||
policy.value = info.policy;
|
||||
dirty.value = false;
|
||||
toast.add({ severity: 'success', summary: t('web.acl.saved'), life: 2000 });
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
toast.add({ severity: 'error', summary: e?.response?.data?.message ?? t('web.acl.save_failed'), life: 5000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const markDirty = () => { dirty.value = true; };
|
||||
|
||||
// ---- drag reorder ---------------------------------------------------------------
|
||||
|
||||
const dragIndex = ref<number | null>(null);
|
||||
const dragOverIndex = ref<number | null>(null);
|
||||
|
||||
const onDragStart = (index: number) => { dragIndex.value = index; };
|
||||
const onDragOver = (index: number, event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
dragOverIndex.value = index;
|
||||
};
|
||||
const onDrop = (index: number) => {
|
||||
const from = dragIndex.value;
|
||||
if (from === null || from === index) return;
|
||||
const rules = policy.value.rules;
|
||||
const [moved] = rules.splice(from, 1);
|
||||
rules.splice(index, 0, moved);
|
||||
dragIndex.value = null;
|
||||
dragOverIndex.value = null;
|
||||
markDirty();
|
||||
};
|
||||
|
||||
// ---- rule editor ----------------------------------------------------------------
|
||||
|
||||
type PortEntry = { kind: 'single' | 'range'; start: number | null; end: number | null };
|
||||
|
||||
const editorVisible = ref(false);
|
||||
const editingIndex = ref<number | null>(null);
|
||||
const editor = ref(blankEditor());
|
||||
|
||||
function blankEditor() {
|
||||
return {
|
||||
name: '',
|
||||
action: 'allow' as 'allow' | 'deny',
|
||||
enabled: true,
|
||||
sourceValues: [] as string[],
|
||||
targetValues: [] as string[],
|
||||
tcp: { enabled: false, entries: [] as PortEntry[], stateful: true },
|
||||
udp: { enabled: false, entries: [] as PortEntry[] },
|
||||
icmp: { enabled: false },
|
||||
otherProtocols: [] as AclProtocolTarget[],
|
||||
};
|
||||
}
|
||||
|
||||
const sourceOptions = computed(() => [
|
||||
{ label: t('web.acl.all_members'), value: 'all' },
|
||||
...(props.members ?? []).map(m => ({
|
||||
label: `${memberName(m.member_id)}${m.virtual_ipv4 ? ` (${m.virtual_ipv4})` : ''}`,
|
||||
value: `member:${m.member_id}`,
|
||||
})),
|
||||
...[...new Set(policy.value.rules.flatMap(rule => rule.sources
|
||||
.filter(source => source.type === 'group')
|
||||
.map(source => source.name)))].map(name => ({ label: name, value: `group:${name}` })),
|
||||
]);
|
||||
|
||||
const targetOptions = computed(() => {
|
||||
const base = [
|
||||
{ label: t('web.acl.all_members'), value: 'all' },
|
||||
...(props.members ?? []).map(m => ({
|
||||
label: `${memberName(m.member_id)}${m.virtual_ipv4 ? ` (${m.virtual_ipv4})` : ''}`,
|
||||
value: `member:${m.member_id}`,
|
||||
})),
|
||||
];
|
||||
for (const m of props.members ?? []) {
|
||||
for (const cidr of m.proxy_cidrs ?? []) {
|
||||
base.push({ label: `${t('web.acl.subnet')} · ${memberName(m.member_id)} · ${cidr}`, value: `subnet:${m.member_id}:${cidr}` });
|
||||
}
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
const openCreateRule = () => {
|
||||
editingIndex.value = null;
|
||||
editor.value = blankEditor();
|
||||
editorVisible.value = true;
|
||||
};
|
||||
|
||||
const openEditRule = (index: number) => {
|
||||
const rule = policy.value.rules[index];
|
||||
editingIndex.value = index;
|
||||
editor.value = {
|
||||
name: rule.name,
|
||||
action: rule.action,
|
||||
enabled: rule.enabled,
|
||||
sourceValues: rule.sources.flatMap(selectorValue),
|
||||
targetValues: rule.destinations.flatMap(selectorValue),
|
||||
tcp: {
|
||||
enabled: rule.protocols.some(p => p.protocol === 'tcp'),
|
||||
entries: portEntries(rule.protocols.find(p => p.protocol === 'tcp')),
|
||||
stateful: rule.protocols.find(p => p.protocol === 'tcp')?.stateful ?? true,
|
||||
},
|
||||
udp: { enabled: rule.protocols.some(p => p.protocol === 'udp'), entries: portEntries(rule.protocols.find(p => p.protocol === 'udp')) },
|
||||
icmp: { enabled: rule.protocols.some(p => p.protocol === 'icmp') },
|
||||
otherProtocols: rule.protocols.filter(p => p.protocol === 'icmpv6' || p.protocol === 'any'),
|
||||
};
|
||||
editorVisible.value = true;
|
||||
};
|
||||
|
||||
const selectorValue = (selector: AclSelector): string[] => {
|
||||
switch (selector.type) {
|
||||
case 'all': return ['all'];
|
||||
case 'member': return [`member:${selector.member_id}`];
|
||||
case 'subnet': return selector.cidrs.map(cidr => `subnet:${selector.member_id}:${cidr}`);
|
||||
case 'group': return [`group:${selector.name}`];
|
||||
}
|
||||
};
|
||||
|
||||
const portEntries = (target: AclProtocolTarget | undefined): PortEntry[] =>
|
||||
(target?.ports ?? []).map(port => {
|
||||
const [start, end] = port.split('-');
|
||||
return { kind: end === undefined ? 'single' : 'range', start: Number(start), end: end === undefined ? null : Number(end) };
|
||||
});
|
||||
|
||||
const swapSourceTarget = () => {
|
||||
const sources = editor.value.sourceValues;
|
||||
const targets = editor.value.targetValues.filter(v => !v.startsWith('subnet:'));
|
||||
if (editor.value.targetValues.some(v => v.startsWith('subnet:'))
|
||||
|| sources.some(v => v.startsWith('group:'))) return;
|
||||
editor.value.sourceValues = targets;
|
||||
editor.value.targetValues = sources;
|
||||
};
|
||||
|
||||
const addPortEntry = (proto: 'tcp' | 'udp', kind: 'single' | 'range') => {
|
||||
editor.value[proto].entries.push({ kind, start: null, end: null });
|
||||
};
|
||||
const addAllPorts = (proto: 'tcp' | 'udp') => {
|
||||
editor.value[proto].entries = [{ kind: 'range', start: 1, end: 65535 }];
|
||||
};
|
||||
const quickPorts: Record<'tcp' | 'udp', { label: string; value: [number, number] }[]> = {
|
||||
tcp: [
|
||||
{ label: 'SSH 22', value: [22, 22] },
|
||||
{ label: 'HTTP 80', value: [80, 80] },
|
||||
{ label: 'HTTPS 443', value: [443, 443] },
|
||||
{ label: 'RDP 3389', value: [3389, 3389] },
|
||||
{ label: 'MySQL 3306', value: [3306, 3306] },
|
||||
],
|
||||
udp: [{ label: 'DNS 53', value: [53, 53] }],
|
||||
};
|
||||
const appendQuickPort = (proto: 'tcp' | 'udp', value: [number, number]) => {
|
||||
const exists = editor.value[proto].entries.some(e => e.start === value[0] && e.end === value[1]);
|
||||
if (!exists) editor.value[proto].entries.push({ kind: value[0] === value[1] ? 'single' : 'range', start: value[0], end: value[1] });
|
||||
};
|
||||
|
||||
const editorError = ref('');
|
||||
|
||||
const saveRule = () => {
|
||||
const e = editor.value;
|
||||
if (!e.name.trim()) { editorError.value = t('web.acl.error_name'); return; }
|
||||
if (e.sourceValues.length === 0) { editorError.value = t('web.acl.error_source'); return; }
|
||||
if (e.targetValues.length === 0) { editorError.value = t('web.acl.error_target'); return; }
|
||||
const protocols: AclProtocolTarget[] = [];
|
||||
if (e.tcp.enabled) {
|
||||
const ports = e.tcp.entries.map(entryToString).filter((p): p is string => !!p);
|
||||
if (ports.length === 0) { editorError.value = t('web.acl.error_ports', { protocol: 'TCP' }); return; }
|
||||
protocols.push({ protocol: 'tcp', ports, stateful: e.action === 'allow' && e.tcp.stateful });
|
||||
}
|
||||
if (e.udp.enabled) {
|
||||
const ports = e.udp.entries.map(entryToString).filter((p): p is string => !!p);
|
||||
if (ports.length === 0) { editorError.value = t('web.acl.error_ports', { protocol: 'UDP' }); return; }
|
||||
protocols.push({ protocol: 'udp', ports, stateful: false });
|
||||
}
|
||||
if (e.icmp.enabled) protocols.push({ protocol: 'icmp', ports: [], stateful: false });
|
||||
protocols.push(...e.otherProtocols);
|
||||
if (protocols.length === 0) { editorError.value = t('web.acl.error_protocol'); return; }
|
||||
|
||||
const sources = e.sourceValues.map(parseSelector);
|
||||
const destinations = e.targetValues.map(parseSelector);
|
||||
|
||||
const rule: AclPolicyRule = {
|
||||
id: editingIndex.value === null ? uuidv4() : policy.value.rules[editingIndex.value].id,
|
||||
name: e.name.trim(),
|
||||
enabled: e.enabled,
|
||||
action: e.action,
|
||||
sources,
|
||||
destinations,
|
||||
protocols,
|
||||
};
|
||||
if (editingIndex.value === null) policy.value.rules.push(rule);
|
||||
else policy.value.rules[editingIndex.value] = rule;
|
||||
editorVisible.value = false;
|
||||
markDirty();
|
||||
};
|
||||
|
||||
const parseSelector = (value: string): AclSelector => {
|
||||
if (value === 'all') return { type: 'all' };
|
||||
if (value.startsWith('member:')) return { type: 'member', member_id: value.slice('member:'.length) };
|
||||
if (value.startsWith('group:')) return { type: 'group', name: value.slice('group:'.length) };
|
||||
if (value.startsWith('subnet:')) {
|
||||
const rest = value.slice('subnet:'.length);
|
||||
const sep = rest.indexOf(':');
|
||||
return { type: 'subnet', member_id: rest.slice(0, sep), cidrs: [rest.slice(sep + 1)] };
|
||||
}
|
||||
throw new Error(`Unknown ACL selector: ${value}`);
|
||||
};
|
||||
|
||||
const entryToString = (entry: PortEntry): string | null => {
|
||||
if (entry.start === null) return null;
|
||||
if (entry.kind === 'single') return String(entry.start);
|
||||
if (entry.end === null || entry.end < entry.start) return null;
|
||||
return `${entry.start}-${entry.end}`;
|
||||
};
|
||||
|
||||
const removeRule = (index: number) => {
|
||||
policy.value.rules.splice(index, 1);
|
||||
markDirty();
|
||||
};
|
||||
|
||||
// ---- display helpers ------------------------------------------------------------
|
||||
|
||||
const selectorLabel = (selector: AclSelector): string => {
|
||||
switch (selector.type) {
|
||||
case 'all': return t('web.acl.all_members');
|
||||
case 'member': return memberName(selector.member_id);
|
||||
case 'subnet': {
|
||||
const cidr = selector.cidrs?.[0] ?? '';
|
||||
return `${t('web.acl.subnet')} ${memberName(selector.member_id)} ${cidr}`.trim();
|
||||
}
|
||||
case 'group': return selector.name;
|
||||
}
|
||||
};
|
||||
|
||||
const protocolLabel = (target: AclProtocolTarget): string => {
|
||||
const proto = target.protocol.toUpperCase();
|
||||
if (target.protocol === 'icmp') return 'ICMP';
|
||||
const ports = target.ports;
|
||||
if (ports.length === 0) return proto;
|
||||
if (ports.length === 1 && ports[0] === '1-65535') return `${proto} ${t('web.acl.all_ports')}`;
|
||||
return `${proto} ${ports.join(', ')}`;
|
||||
};
|
||||
|
||||
const defaultActionOptions = [
|
||||
{ label: t('web.acl.default_allow'), value: 'allow' },
|
||||
{ label: t('web.acl.default_deny'), value: 'deny' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<span class="text-sm font-medium">{{ t('web.acl.default_action') }}</span>
|
||||
<SelectButton v-model="policy.default_action" :options="defaultActionOptions"
|
||||
optionLabel="label" optionValue="value" size="small" @update:model-value="markDirty" />
|
||||
<span class="text-xs muted">{{ policy.default_action === 'deny'
|
||||
? t('web.acl.default_deny_hint') : t('web.acl.default_allow_hint') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="table-count">{{ t('web.acl.rule_summary', { total: policy.rules.length, enabled: enabledCount }) }}</span>
|
||||
<span v-if="dirty" class="text-xs" style="color: var(--p-orange-400)">{{ t('web.acl.unsaved') }}</span>
|
||||
<Button icon="pi pi-plus" :label="t('web.acl.add_rule')" size="small" @click="openCreateRule" />
|
||||
<Button icon="pi pi-check" :label="t('web.acl.save')" size="small" severity="primary"
|
||||
:loading="saving" :disabled="!dirty" @click="save" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Message v-if="loadError" severity="error">{{ loadError }}</Message>
|
||||
|
||||
<div class="console-panel">
|
||||
<div v-if="policy.rules.length === 0" class="console-empty-state">
|
||||
<i class="pi pi-shield" aria-hidden="true"></i>
|
||||
<h2>{{ t('web.acl.empty_title') }}</h2>
|
||||
<p>{{ policy.default_action === 'deny' ? t('web.acl.empty_deny') : t('web.acl.empty_allow') }}</p>
|
||||
</div>
|
||||
<div v-else class="acl-rule-list">
|
||||
<div v-for="(rule, index) in policy.rules" :key="rule.id" class="acl-rule-row"
|
||||
:class="{ 'acl-rule-row--dragging': dragIndex === index, 'acl-rule-row--over': dragOverIndex === index && dragIndex !== index }"
|
||||
draggable="true" @dragstart="onDragStart(index)" @dragover="onDragOver(index, $event)"
|
||||
@drop="onDrop(index)" @dragend="dragIndex = null; dragOverIndex = null">
|
||||
<div class="flex items-center gap-2 shrink-0" @click.stop>
|
||||
<span class="pi pi-bars acl-rule-drag" :title="t('web.acl.drag_hint')"></span>
|
||||
<span class="acl-rule-index" :class="rule.action">{{ index + 1 }}</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-medium truncate">{{ rule.name }}</span>
|
||||
<span class="acl-badge" :class="rule.action">{{ rule.action === 'allow' ? t('web.acl.allow') : t('web.acl.deny') }}</span>
|
||||
<span v-if="!rule.enabled" class="acl-badge muted">{{ t('web.acl.disabled') }}</span>
|
||||
<span class="flex-1"></span>
|
||||
<Button icon="pi pi-pencil" text rounded severity="secondary" size="small"
|
||||
:aria-label="t('web.common.edit')" @click.stop="openEditRule(index)" />
|
||||
<Button icon="pi pi-trash" text rounded severity="danger" size="small"
|
||||
:aria-label="t('web.common.delete')" @click.stop="removeRule(index)" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap text-xs mt-1">
|
||||
<span class="muted">{{ t('web.acl.source') }}</span>
|
||||
<span v-for="source in rule.sources" :key="`s-${rule.id}-${selectorLabel(source)}`"
|
||||
class="acl-chip">{{ selectorLabel(source) }}</span>
|
||||
<i class="pi pi-arrow-right muted" aria-hidden="true"></i>
|
||||
<span class="muted">{{ t('web.acl.target') }}</span>
|
||||
<span v-for="target in rule.destinations" :key="`d-${rule.id}-${selectorLabel(target)}`"
|
||||
class="acl-chip">{{ selectorLabel(target) }}</span>
|
||||
<span class="muted">·</span>
|
||||
<span v-for="target in rule.protocols" :key="`p-${rule.id}-${target.protocol}`"
|
||||
class="acl-chip proto">{{ protocolLabel(target) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Drawer v-model:visible="editorVisible" position="right" :style="{ width: 'min(46rem, 96vw)' }"
|
||||
:header="editingIndex === null ? t('web.acl.add_rule') : t('web.acl.edit_rule')">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div class="flex-1 min-w-[14rem]">
|
||||
<label class="text-sm font-medium" for="acl-rule-name">{{ t('web.acl.rule_name') }}</label>
|
||||
<InputText id="acl-rule-name" v-model="editor.name" class="w-full mt-1"
|
||||
:placeholder="t('web.acl.name_placeholder')" :maxlength="64" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium" for="acl-rule-action">{{ t('web.acl.action') }}</label>
|
||||
<Select inputId="acl-rule-action" v-model="editor.action" class="w-36 mt-1"
|
||||
:options="[{ label: t('web.acl.allow'), value: 'allow' }, { label: t('web.acl.deny'), value: 'deny' }]"
|
||||
optionLabel="label" optionValue="value" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-4">
|
||||
<InputSwitch inputId="acl-rule-enabled" v-model="editor.enabled" />
|
||||
<label for="acl-rule-enabled" class="text-sm">{{ t('web.acl.enabled') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium">{{ t('web.acl.source') }}</label>
|
||||
<Button :label="t('web.acl.swap')" text size="small"
|
||||
:disabled="editor.targetValues.some(v => v.startsWith('subnet:')) || editor.sourceValues.some(v => v.startsWith('group:'))" @click="swapSourceTarget" />
|
||||
</div>
|
||||
<MultiSelect v-model="editor.sourceValues" :options="sourceOptions" optionLabel="label"
|
||||
optionValue="value" filter :maxSelectedLabels="6" :placeholder="t('web.acl.source_placeholder')"
|
||||
class="w-full" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium">{{ t('web.acl.target') }}</label>
|
||||
<MultiSelect v-model="editor.targetValues" :options="targetOptions" optionLabel="label"
|
||||
optionValue="value" filter :maxSelectedLabels="6" :placeholder="t('web.acl.target_placeholder')"
|
||||
class="w-full" />
|
||||
<div class="text-xs muted">{{ t('web.acl.target_hint') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium">{{ t('web.acl.protocols') }}</label>
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" v-model="editor.tcp.enabled" /> TCP
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" v-model="editor.udp.enabled" /> UDP
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" v-model="editor.icmp.enabled" /> ICMP
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-for="proto of (['tcp', 'udp'] as const)" v-show="editor[proto].enabled" :key="proto"
|
||||
class="acl-port-block">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-sm">{{ proto.toUpperCase() }}</span>
|
||||
<div v-if="proto === 'tcp' && editor.action === 'allow'" class="flex items-center gap-2">
|
||||
<span class="text-xs muted">{{ t('web.acl.stateful') }}</span>
|
||||
<InputSwitch v-model="editor.tcp.stateful" v-tooltip.top="t('web.acl.stateful_hint')" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="(entry, entryIndex) in editor[proto].entries" :key="entryIndex"
|
||||
class="flex items-center gap-2 mt-2">
|
||||
<Select v-model="entry.kind" :options="[
|
||||
{ label: t('web.acl.port_single'), value: 'single' },
|
||||
{ label: t('web.acl.port_range'), value: 'range' }]"
|
||||
optionLabel="label" optionValue="value" size="small" class="w-28" />
|
||||
<InputNumber v-model="entry.start" :min="1" :max="65535" :useGrouping="false"
|
||||
class="w-24" :placeholder="t('web.acl.port_start')" />
|
||||
<template v-if="entry.kind === 'range'">
|
||||
<span class="text-sm muted">{{ t('web.acl.port_to') }}</span>
|
||||
<InputNumber v-model="entry.end" :min="1" :max="65535" :useGrouping="false"
|
||||
class="w-24" :placeholder="t('web.acl.port_end')" />
|
||||
</template>
|
||||
<Button icon="pi pi-times" text rounded severity="danger" size="small"
|
||||
:aria-label="t('web.common.delete')"
|
||||
@click="editor[proto].entries.splice(entryIndex, 1)" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-2 flex-wrap">
|
||||
<Button :label="t('web.acl.add_port')" size="small" severity="secondary"
|
||||
@click="addPortEntry(proto, 'single')" />
|
||||
<Button :label="t('web.acl.add_range')" size="small" severity="secondary"
|
||||
@click="addPortEntry(proto, 'range')" />
|
||||
<Button :label="t('web.acl.all_ports')" size="small" severity="secondary"
|
||||
@click="addAllPorts(proto)" />
|
||||
<span class="muted text-xs">{{ t('web.acl.quick_add') }}:</span>
|
||||
<Button v-for="quick in quickPorts[proto]" :key="quick.label" :label="quick.label"
|
||||
size="small" text severity="info" @click="appendQuickPort(proto, quick.value)" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="editor.icmp.enabled" class="text-xs muted">{{ t('web.acl.icmp_note') }}</div>
|
||||
</div>
|
||||
|
||||
<Message v-if="editorError" severity="error" :closable="false">{{ editorError }}</Message>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button :label="t('web.common.cancel')" severity="secondary" @click="editorVisible = false" />
|
||||
<Button :label="t('web.acl.save_rule')" severity="primary" @click="saveRule" />
|
||||
</template>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.acl-rule-list { display: flex; flex-direction: column; }
|
||||
.acl-rule-row { display: flex; gap: 12px; align-items: flex-start; padding: 12px 16px; border-bottom: 1px solid var(--console-border); cursor: pointer; }
|
||||
.acl-rule-row:last-child { border-bottom: 0; }
|
||||
.acl-rule-row:hover { background: var(--p-content-hover-background); }
|
||||
.acl-rule-row--dragging { opacity: 0.4; }
|
||||
.acl-rule-row--over { box-shadow: inset 0 2px 0 var(--p-primary-color); }
|
||||
.acl-rule-drag { cursor: grab; color: var(--console-muted); font-size: 13px; padding-top: 6px; }
|
||||
.acl-rule-index { display: inline-flex; width: 26px; height: 26px; border-radius: 6px; align-items: center; justify-content: center; font-size: 13px; font-weight: 600; }
|
||||
.acl-rule-index.allow { background: var(--p-green-100); color: var(--p-green-700); }
|
||||
.acl-rule-index.deny { background: var(--p-red-100); color: var(--p-red-700); }
|
||||
html.app-dark .acl-rule-index.allow { background: color-mix(in srgb, var(--p-green-500) 22%, transparent); color: var(--p-green-300); }
|
||||
html.app-dark .acl-rule-index.deny { background: color-mix(in srgb, var(--p-red-500) 22%, transparent); color: var(--p-red-300); }
|
||||
.acl-badge { font-size: 11px; padding: 2px 8px; border-radius: 999px; white-space: nowrap; }
|
||||
.acl-badge.allow { background: var(--p-green-100); color: var(--p-green-700); }
|
||||
.acl-badge.deny { background: var(--p-red-100); color: var(--p-red-700); }
|
||||
html.app-dark .acl-badge.allow { background: color-mix(in srgb, var(--p-green-500) 22%, transparent); color: var(--p-green-300); }
|
||||
html.app-dark .acl-badge.deny { background: color-mix(in srgb, var(--p-red-500) 22%, transparent); color: var(--p-red-300); }
|
||||
.acl-badge.muted { background: var(--p-content-border); color: var(--p-text-muted-color); }
|
||||
.acl-badge.warn { background: var(--p-orange-100); color: var(--p-orange-700); }
|
||||
html.app-dark .acl-badge.warn { background: color-mix(in srgb, var(--p-orange-500) 22%, transparent); color: var(--p-orange-300); }
|
||||
.acl-chip { background: var(--p-content-border); border-radius: 999px; padding: 1px 8px; color: var(--p-text-color); white-space: nowrap; }
|
||||
.acl-chip.proto { background: color-mix(in srgb, var(--p-primary-color) 14%, transparent); color: var(--p-primary-color); }
|
||||
.acl-port-block { border: 1px solid var(--console-border); border-radius: 8px; padding: 12px 16px; }
|
||||
</style>
|
||||
@@ -1,66 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { Card, useToast } from 'primevue';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { Button, Message, Skeleton } from 'primevue';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Utils } from 'easytier-frontend-lib';
|
||||
import ApiClient, { Summary } from '../modules/api';
|
||||
import ApiClient, { type Summary, type CentralNetworkSummary } from '../modules/api';
|
||||
|
||||
const props = defineProps({
|
||||
api: ApiClient,
|
||||
});
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const summary = ref<Summary | undefined>(undefined);
|
||||
const props = defineProps<{ api: ApiClient; centralEnabled: boolean }>();
|
||||
const { t } = useI18n();
|
||||
const summary = ref<Summary>();
|
||||
const devices = ref<Utils.DeviceInfo[]>();
|
||||
const networks = ref<CentralNetworkSummary[]>();
|
||||
const loadError = ref(false);
|
||||
const refreshing = ref(false);
|
||||
|
||||
const loadSummary = async () => {
|
||||
const resp = await props.api?.get_summary();
|
||||
summary.value = resp;
|
||||
if (refreshing.value) return;
|
||||
refreshing.value = true;
|
||||
const results = await Promise.allSettled([
|
||||
props.api.get_summary().then(value => { summary.value = value; }),
|
||||
props.api.list_machines().then(value => { devices.value = value.map(Utils.buildDeviceInfo); }),
|
||||
...(props.centralEnabled ? [props.api.list_networks().then(value => { networks.value = value; })] : []),
|
||||
]);
|
||||
loadError.value = results.some(result => result.status === 'rejected');
|
||||
refreshing.value = false;
|
||||
};
|
||||
watch(() => props.centralEnabled, () => loadSummary());
|
||||
const periodFunc = new Utils.PeriodicTask(loadSummary, 2000);
|
||||
onMounted(() => periodFunc.start());
|
||||
onUnmounted(() => periodFunc.stop());
|
||||
|
||||
const periodFunc = new Utils.PeriodicTask(async () => {
|
||||
try {
|
||||
await loadSummary();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Load Summary Failed', detail: e, life: 2000 });
|
||||
console.error(e);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
onMounted(async () => {
|
||||
periodFunc.start();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
periodFunc.stop();
|
||||
});
|
||||
|
||||
const deviceCount = computed<number | undefined>(
|
||||
() => {
|
||||
return summary.value?.device_count;
|
||||
},
|
||||
);
|
||||
|
||||
const devicePreview = computed(() => [...(devices.value ?? [])]
|
||||
.sort((a, b) => ((a.alias || a.hostname) ?? '').localeCompare((b.alias || b.hostname) ?? '')).slice(0, 5));
|
||||
const networkPreview = computed(() => [...(networks.value ?? [])]
|
||||
.sort((a, b) => a.display_name.localeCompare(b.display_name)).slice(0, 5));
|
||||
const stats = computed(() => [
|
||||
{ label: 'device_count', note: 'device_note', icon: 'pi-server', value: summary.value?.device_count },
|
||||
{ label: 'online_device_count', note: 'online_device_note', icon: 'pi-check-circle', value: devices.value?.filter(device => device.online).length },
|
||||
...(props.centralEnabled ? [{ label: 'network_count', note: 'network_note', icon: 'pi-globe', value: networks.value?.length }] : []),
|
||||
{ label: 'instance_count', note: 'instance_note', icon: 'pi-share-alt', value: devices.value?.reduce((sum, device) => sum + device.running_network_count, 0) },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<Card class="h-full">
|
||||
<template #title>Device Count</template>
|
||||
<template #content>
|
||||
<div class="w-full flex justify-center text-7xl font-bold text-green-800 mt-4">
|
||||
{{ deviceCount }}
|
||||
<div class="console-page">
|
||||
<header class="page-heading">
|
||||
<div><h1>{{ t('web.main.dashboard') }}</h1></div>
|
||||
<Button icon="pi pi-refresh" :label="t('web.console.refresh')" severity="secondary" outlined :loading="refreshing" @click="loadSummary" />
|
||||
</header>
|
||||
<Message v-if="loadError" severity="warn" :closable="false">{{ t('web.console.load_failed') }}</Message>
|
||||
<section class="console-panel summary-strip" :aria-label="t('web.main.dashboard')">
|
||||
<div v-for="stat in stats" :key="stat.label" class="summary-stat">
|
||||
<div class="summary-label"><span>{{ t(`web.console.${stat.label}`) }}</span><i :class="['pi', stat.icon]" aria-hidden="true"></i></div>
|
||||
<div class="summary-value">
|
||||
<Skeleton v-if="stat.value === undefined && !loadError" width="4rem" height="2.8rem" />
|
||||
<template v-else>{{ stat.value ?? '—' }}</template>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<div class="flex items-center justify-center rounded bg-gray-50 dark:bg-gray-800">
|
||||
<p class="text-2xl text-gray-400 dark:text-gray-500">
|
||||
<!-- <svg class="w-3.5 h-3.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 18 18">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 1v16M1 9h16" />
|
||||
</svg> -->
|
||||
</p>
|
||||
<p class="summary-note">{{ t(`web.console.${stat.note}`) }}</p>
|
||||
</div>
|
||||
</section>
|
||||
<div class="overview-columns">
|
||||
<section class="console-panel">
|
||||
<header class="section-heading"><h2>{{ t('web.main.device_list') }}</h2>
|
||||
<RouterLink :to="{ name: 'deviceList' }" class="entity-link text-xs">{{ t('web.console.view_all') }}<i class="pi pi-arrow-right" aria-hidden="true"></i></RouterLink>
|
||||
</header>
|
||||
<div v-if="devices === undefined && !loadError" class="loading-rows"><Skeleton v-for="i in 3" :key="i" height="2rem" /></div>
|
||||
<div v-else-if="devices?.length === 0" class="console-empty-state">
|
||||
<i class="pi pi-server" aria-hidden="true"></i><h2>{{ t('web.console.devices_empty') }}</h2><p>{{ t('web.console.devices_empty_hint') }}</p>
|
||||
<a href="https://easytier.cn" target="_blank" rel="noopener noreferrer" class="entity-link">{{ t('web.console.documentation') }}<i class="pi pi-arrow-up-right" aria-hidden="true"></i></a>
|
||||
</div>
|
||||
<RouterLink v-for="device in devicePreview" :key="device.machine_id" class="preview-row"
|
||||
:to="{ name: 'deviceManagement', params: { deviceId: device.machine_id, instanceId: device.running_network_instances?.[0] } }">
|
||||
<div class="entity-link"><i class="pi pi-server entity-icon" aria-hidden="true"></i><div class="min-w-0"><div class="preview-name">{{ device.alias || device.hostname || device.machine_id }}</div><div class="preview-secondary mono-value">{{ device.public_ip || '—' }}</div></div></div>
|
||||
<div class="preview-number">{{ device.running_network_count }}<div class="preview-secondary">{{ t('web.console.instances') }}</div></div>
|
||||
</RouterLink>
|
||||
</section>
|
||||
<section v-if="centralEnabled" class="console-panel">
|
||||
<header class="section-heading"><h2>{{ t('web.main.network_list') }}</h2>
|
||||
<RouterLink :to="{ name: 'networkList' }" class="entity-link text-xs">{{ t('web.console.view_all') }}<i class="pi pi-arrow-right" aria-hidden="true"></i></RouterLink>
|
||||
</header>
|
||||
<div v-if="networks === undefined && !loadError" class="loading-rows"><Skeleton v-for="i in 3" :key="i" height="2rem" /></div>
|
||||
<div v-else-if="networks?.length === 0" class="console-empty-state">
|
||||
<i class="pi pi-globe" aria-hidden="true"></i><h2>{{ t('web.console.networks_empty') }}</h2><p>{{ t('web.console.networks_empty_hint') }}</p>
|
||||
<RouterLink :to="{ name: 'networkList' }" class="entity-link">{{ t('web.network_list.create') }}<i class="pi pi-arrow-right" aria-hidden="true"></i></RouterLink>
|
||||
</div>
|
||||
<RouterLink v-for="network in networkPreview" :key="network.network_id" class="preview-row" :to="{ name: 'networkDetail', params: { networkId: network.network_id } }">
|
||||
<div class="entity-link"><i class="pi pi-globe entity-icon" aria-hidden="true"></i><div class="min-w-0"><div class="preview-name">{{ network.display_name }}</div><div class="preview-secondary">{{ network.network_name }}</div></div></div>
|
||||
<div class="preview-number">{{ network.online_member_count }} / {{ network.member_count }}<div class="preview-secondary">{{ t('web.console.online_members') }}</div></div>
|
||||
</RouterLink>
|
||||
</section>
|
||||
</div>
|
||||
<p class="page-footnote"><i class="pi pi-sync" aria-hidden="true"></i>{{ t('web.console.automatic_refresh') }}</p>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</template>
|
||||
@@ -22,6 +22,14 @@ defineProps<{
|
||||
<div class="detail-label">{{ t('web.device.hostname') }}</div>
|
||||
<div class="detail-value">{{ device.hostname }}</div>
|
||||
</div>
|
||||
<div class="detail-item status">
|
||||
<div class="detail-label">{{ t('web.device.status') }}</div>
|
||||
<div class="detail-value">{{ device.online ? t('web.device.online') : t('web.device.offline') }}</div>
|
||||
</div>
|
||||
<div v-if="!device.online && device.last_seen" class="detail-item last-seen">
|
||||
<div class="detail-label">{{ t('web.device.last_seen') }}</div>
|
||||
<div class="detail-value">{{ device.last_seen }}</div>
|
||||
</div>
|
||||
<div class="detail-item public-ip">
|
||||
<div class="detail-label">{{ t('web.device.public_ip') }}</div>
|
||||
<div class="detail-value">{{ device.public_ip }}</div>
|
||||
@@ -30,62 +38,60 @@ defineProps<{
|
||||
<div class="detail-label">{{ t('web.device.networks') }}</div>
|
||||
<div class="detail-value">{{ device.running_network_count }}</div>
|
||||
</div>
|
||||
<div class="detail-item last-report">
|
||||
<div class="detail-label">{{ t('web.device.last_report') }}</div>
|
||||
<div class="detail-value">{{ device.report_time }}</div>
|
||||
<div class="detail-item location">
|
||||
<div class="detail-label">{{ t('web.console.location') }}</div>
|
||||
<div class="detail-value">{{ device.location ? [device.location.country, device.location.region, device.location.city].filter(Boolean).join(' · ') : t('web.device.unknown_location') }}</div>
|
||||
</div>
|
||||
<div v-if="(device.networks?.length ?? 0) > 0" class="detail-item central-networks">
|
||||
<div class="detail-label">{{ t('web.device.central_networks') }}</div>
|
||||
<div class="detail-value">
|
||||
<span v-for="network in device.networks" :key="network.network_id" class="central-network-name"
|
||||
:title="network.network_name">{{ network.display_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-item version">
|
||||
<div class="detail-label">{{ t('web.device.version') }}</div>
|
||||
<div class="detail-value">{{ device.easytier_version }}</div>
|
||||
</div>
|
||||
<div class="detail-item machine-id">
|
||||
<div class="detail-label">{{ t('web.device.machine_id') }}</div>
|
||||
<div class="detail-value">
|
||||
<span class="machine-id-value" :title="device.machine_id">{{ device.machine_id }}</span>
|
||||
<details class="more-details">
|
||||
<summary>{{ t('web.console.more_details') }}</summary>
|
||||
<div class="detail-item last-report">
|
||||
<div class="detail-label">{{ t('web.device.last_report') }}</div>
|
||||
<div class="detail-value">{{ device.report_time }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-item machine-id">
|
||||
<div class="detail-label">{{ t('web.device.machine_id') }}</div>
|
||||
<div class="detail-value">
|
||||
<span class="machine-id-value" :title="device.machine_id">{{ device.machine_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 基础布局 */
|
||||
.device-details {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
/* 紧凑布局样式 */
|
||||
.device-details.compact {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* 标准布局的详情项样式 */
|
||||
.detail-item {
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--surface-border, #e9ecef);
|
||||
padding-bottom: 0.75rem;
|
||||
transition: all 0.2s;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.more-details summary {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--console-muted, #64748b);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.detail-item:hover {
|
||||
background-color: var(--surface-hover, rgba(245, 247, 250, 0.5));
|
||||
}
|
||||
|
||||
.detail-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-color, #334155);
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 紧凑布局样式 */
|
||||
.device-details.compact {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.compact .detail-item {
|
||||
padding: 0.3rem 0.2rem;
|
||||
display: grid;
|
||||
@@ -93,69 +99,37 @@ defineProps<{
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.compact .detail-label {
|
||||
margin-bottom: 0;
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-label::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background-color: #3b82f6;
|
||||
margin-right: 0.5rem;
|
||||
.compact .detail-label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: var(--text-color-secondary, #475569);
|
||||
word-break: break-all;
|
||||
padding-left: 1rem;
|
||||
line-height: 1.4;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* 紧凑布局的标签和值样式 */
|
||||
.compact .detail-label::before {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
/* 紧凑布局的值样式 */
|
||||
.compact .detail-value {
|
||||
padding-left: 0.3rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* 特定字段的样式 */
|
||||
.hostname .detail-label::before {
|
||||
background-color: #3b82f6;
|
||||
/* 蓝色 */
|
||||
}
|
||||
|
||||
.public-ip .detail-label::before {
|
||||
background-color: #10b981;
|
||||
/* 绿色 */
|
||||
}
|
||||
|
||||
.running-networks .detail-label::before {
|
||||
background-color: #f59e0b;
|
||||
/* 橙色 */
|
||||
}
|
||||
|
||||
.last-report .detail-label::before {
|
||||
background-color: #8b5cf6;
|
||||
/* 紫色 */
|
||||
}
|
||||
|
||||
.version .detail-label::before {
|
||||
background-color: #ec4899;
|
||||
/* 粉色 */
|
||||
}
|
||||
|
||||
.machine-id .detail-label::before {
|
||||
background-color: #6b7280;
|
||||
/* 灰色 */
|
||||
.central-network-name {
|
||||
display: inline-block;
|
||||
background-color: var(--surface-ground, #f1f5f9);
|
||||
border: 1px solid var(--surface-border, #e2e8f0);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.05rem 0.4rem;
|
||||
margin: 0.1rem 0.2rem 0.1rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* 机器ID特殊样式 */
|
||||
@@ -179,33 +153,4 @@ defineProps<{
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
/* 暗黑模式适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.detail-item {
|
||||
border-bottom: 1px solid var(--surface-border, #334155);
|
||||
}
|
||||
|
||||
.detail-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-item:hover {
|
||||
background-color: var(--surface-hover, rgba(30, 41, 59, 0.4));
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: var(--text-color-secondary, #cbd5e1);
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: var(--text-color, #e2e8f0);
|
||||
}
|
||||
|
||||
.machine-id-value {
|
||||
background-color: var(--surface-ground, #1e293b);
|
||||
color: var(--text-color, #f1f5f9);
|
||||
border-color: var(--surface-border, #334155);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -122,33 +122,6 @@ const newConfigGenerator = () => {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Dark mode adaptations */
|
||||
:deep(.bg-surface-50) {
|
||||
background-color: var(--surface-50, #f8fafc);
|
||||
}
|
||||
|
||||
:deep(.bg-surface-0) {
|
||||
background-color: var(--surface-card, #ffffff);
|
||||
}
|
||||
|
||||
:deep(.text-primary) {
|
||||
color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
:deep(.text-secondary) {
|
||||
color: var(--text-color-secondary, #64748b);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:deep(.bg-surface-50) {
|
||||
background-color: var(--surface-ground, #0f172a);
|
||||
}
|
||||
|
||||
:deep(.bg-surface-0) {
|
||||
background-color: var(--surface-card, #1e293b);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive design for mobile devices */
|
||||
@media (max-width: 768px) {
|
||||
.network-header {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { Card, InputText, Password, Button, AutoComplete } from 'primevue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { Card, InputText, Password, Button } from 'primevue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { I18nUtils } from 'easytier-frontend-lib';
|
||||
import { getInitialApiHost, cleanAndLoadApiHosts, saveApiHost } from "../modules/api-host"
|
||||
import { getApiBase } from "../modules/api-host"
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ApiClient, { Credential, RegisterData } from '../modules/api';
|
||||
|
||||
@@ -14,7 +14,7 @@ defineProps<{
|
||||
isRegistering: boolean;
|
||||
}>();
|
||||
|
||||
const api = computed<ApiClient>(() => new ApiClient(apiHost.value));
|
||||
const api = computed<ApiClient>(() => new ApiClient(getApiBase()));
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
|
||||
@@ -25,25 +25,18 @@ const registerPassword = ref('');
|
||||
const captcha = ref('');
|
||||
const captchaSrc = computed(() => api.value.captcha_url());
|
||||
|
||||
|
||||
const onSubmit = async () => {
|
||||
// Add your login logic here
|
||||
saveApiHost(apiHost.value);
|
||||
const credential: Credential = { username: username.value, password: password.value, };
|
||||
let ret = await api.value?.login(credential);
|
||||
if (ret.success) {
|
||||
localStorage.setItem('apiHost', btoa(apiHost.value));
|
||||
router.push({
|
||||
name: 'dashboard',
|
||||
params: { apiHost: btoa(apiHost.value) },
|
||||
});
|
||||
router.push({ name: 'dashboard' });
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Login Failed', detail: ret.message, life: 2000 });
|
||||
}
|
||||
};
|
||||
|
||||
const onRegister = async () => {
|
||||
saveApiHost(apiHost.value);
|
||||
const credential: Credential = { username: registerUsername.value, password: registerPassword.value };
|
||||
const registerReq: RegisterData = { credentials: credential, captcha: captcha.value };
|
||||
let ret = await api.value?.register(registerReq);
|
||||
@@ -55,56 +48,18 @@ const onRegister = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const apiHost = ref<string>(getInitialApiHost())
|
||||
const apiHostSuggestions = ref<Array<string>>([])
|
||||
const apiHostSearch = async (event: { query: string }) => {
|
||||
apiHostSuggestions.value = [];
|
||||
let hosts = cleanAndLoadApiHosts();
|
||||
if (event.query) {
|
||||
apiHostSuggestions.value.push(event.query);
|
||||
}
|
||||
hosts.forEach((host) => {
|
||||
apiHostSuggestions.value.push(host.value);
|
||||
});
|
||||
}
|
||||
|
||||
const oidcEnabled = ref(false);
|
||||
const lastCheckedHost = ref('');
|
||||
const oidcCheckTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
||||
const checkOidcConfig = () => {
|
||||
if (oidcCheckTimer.value) clearTimeout(oidcCheckTimer.value);
|
||||
oidcCheckTimer.value = setTimeout(async () => {
|
||||
const host = apiHost.value;
|
||||
if (host === lastCheckedHost.value) return;
|
||||
|
||||
const enabled = (await new ApiClient(host).getOidcConfig()).enabled;
|
||||
// If host changes while request is in-flight, do not overwrite UI state.
|
||||
if (apiHost.value !== host) return;
|
||||
|
||||
lastCheckedHost.value = host;
|
||||
oidcEnabled.value = enabled;
|
||||
}, 300);
|
||||
const checkOidcConfig = async () => {
|
||||
oidcEnabled.value = (await api.value.getOidcConfig()).enabled;
|
||||
};
|
||||
|
||||
watch(apiHost, () => {
|
||||
checkOidcConfig();
|
||||
});
|
||||
|
||||
const onSsoLogin = () => {
|
||||
saveApiHost(apiHost.value);
|
||||
localStorage.setItem('apiHost', btoa(apiHost.value));
|
||||
window.location.href = api.value.oidcLoginUrl();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkOidcConfig();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (oidcCheckTimer.value) {
|
||||
clearTimeout(oidcCheckTimer.value);
|
||||
oidcCheckTimer.value = null;
|
||||
}
|
||||
onMounted(async () => {
|
||||
await checkOidcConfig();
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -118,11 +73,6 @@ onBeforeUnmount(() => {
|
||||
</h2>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="p-field mb-4">
|
||||
<label for="api-host" class="block text-sm font-medium">{{ t('web.login.api_host') }}</label>
|
||||
<AutoComplete id="api-host" v-model="apiHost" dropdown :suggestions="apiHostSuggestions"
|
||||
@complete="apiHostSearch" class="w-full" />
|
||||
</div>
|
||||
<form v-if="!isRegistering" @submit.prevent="onSubmit" class="space-y-4">
|
||||
<div class="p-field">
|
||||
<label for="username" class="block text-sm font-medium">{{ t('web.login.username') }}</label>
|
||||
@@ -137,7 +87,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Button :label="t('web.login.register')" type="button" class="w-full"
|
||||
@click="saveApiHost(apiHost); $router.replace({ name: 'register' })" severity="secondary" />
|
||||
@click="$router.replace({ name: 'register' })" severity="secondary" />
|
||||
</div>
|
||||
<div v-if="oidcEnabled" class="flex items-center justify-between">
|
||||
<Button :label="t('web.login.sso_login')" type="button" class="w-full" severity="info"
|
||||
@@ -148,14 +98,14 @@ onBeforeUnmount(() => {
|
||||
<form v-else @submit.prevent="onRegister" class="space-y-4">
|
||||
<div class="p-field">
|
||||
<label for="register-username" class="block text-sm font-medium">{{ t('web.login.username')
|
||||
}}</label>
|
||||
}}</label>
|
||||
<InputText id="register-username" v-model="registerUsername" required class="w-full" />
|
||||
</div>
|
||||
<div class="p-field">
|
||||
<label for="register-password" class="block text-sm font-medium">{{ t('web.login.password')
|
||||
}}</label>
|
||||
}}</label>
|
||||
<Password id="register-password" v-model="registerPassword" required toggleMask
|
||||
:feedback="false" class="w-full" />
|
||||
:feedback="false" />
|
||||
</div>
|
||||
<div class="p-field">
|
||||
<label for="captcha" class="block text-sm font-medium">{{ t('web.login.captcha') }}</label>
|
||||
@@ -167,7 +117,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Button :label="t('web.login.back_to_login')" type="button" class="w-full"
|
||||
@click="saveApiHost(apiHost); $router.replace({ name: 'login' })" severity="secondary" />
|
||||
@click="$router.replace({ name: 'login' })" severity="secondary" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -1,187 +1,334 @@
|
||||
<script setup lang="ts">
|
||||
import { applyThemeMode, storedThemeMode, type ThemeMode } from '../modules/theme';
|
||||
|
||||
const themeMode = ref<ThemeMode>(storedThemeMode());
|
||||
const themeCycle = [
|
||||
{ mode: 'system' as ThemeMode, icon: 'pi pi-desktop' },
|
||||
{ mode: 'light' as ThemeMode, icon: 'pi pi-sun' },
|
||||
{ mode: 'dark' as ThemeMode, icon: 'pi pi-moon' },
|
||||
];
|
||||
const themeIcon = computed(() => themeCycle.find(entry => entry.mode === themeMode.value)?.icon ?? 'pi pi-desktop');
|
||||
const cycleThemeMode = () => {
|
||||
const index = themeCycle.findIndex(entry => entry.mode === themeMode.value);
|
||||
const next = themeCycle[(index + 1) % themeCycle.length].mode;
|
||||
themeMode.value = next;
|
||||
applyThemeMode(next);
|
||||
};
|
||||
|
||||
import { I18nUtils } from 'easytier-frontend-lib'
|
||||
import { computed, onMounted, ref, onUnmounted, nextTick } from 'vue';
|
||||
import { Button, TieredMenu } from 'primevue';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { Button, Drawer, Menu, OverlayBadge, Popover, TieredMenu, useToast } from 'primevue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useDialog } from 'primevue/usedialog';
|
||||
import ChangePassword from './ChangePassword.vue';
|
||||
import Icon from '../assets/easytier.png'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ApiClient from '../modules/api';
|
||||
import ApiClient, { type BlockedDevice, type CentralNetworkSummary } from '../modules/api';
|
||||
import { getApiBase } from '../modules/api-host';
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const api = computed<ApiClient | undefined>(() => {
|
||||
try {
|
||||
return new ApiClient(atob(route.params.apiHost as string), () => {
|
||||
router.push({ name: 'login' });
|
||||
})
|
||||
} catch (e) {
|
||||
router.push({ name: 'login' });
|
||||
}
|
||||
});
|
||||
|
||||
const toast = useToast();
|
||||
const route = useRoute();
|
||||
const api = new ApiClient(getApiBase(), () => router.push({ name: 'login' }));
|
||||
const dialog = useDialog();
|
||||
|
||||
const userMenu = ref();
|
||||
const userMenuItems = ref([
|
||||
const userMenuItems = computed(() => [
|
||||
{
|
||||
label: t('web.main.change_password'),
|
||||
icon: 'pi pi-key',
|
||||
command: () => {
|
||||
console.log('File');
|
||||
let ret = dialog.open(ChangePassword, {
|
||||
props: {
|
||||
modal: true,
|
||||
},
|
||||
data: {
|
||||
api: api.value,
|
||||
}
|
||||
});
|
||||
|
||||
console.log("return", ret)
|
||||
},
|
||||
command: () => dialog.open(ChangePassword, {
|
||||
props: { modal: true, header: t('web.main.change_password') },
|
||||
data: { api },
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: t('web.main.logout'),
|
||||
icon: 'pi pi-sign-out',
|
||||
command: async () => {
|
||||
try {
|
||||
await api.value?.logout();
|
||||
await api.logout();
|
||||
} catch (e) {
|
||||
console.error("logout failed", e);
|
||||
console.error('logout failed', e);
|
||||
}
|
||||
router.push({ name: 'login' });
|
||||
},
|
||||
},
|
||||
])
|
||||
]);
|
||||
|
||||
const forceShowSideBar = ref(false)
|
||||
const sidebarRef = ref<HTMLElement>()
|
||||
const toggleButtonRef = ref<HTMLElement>()
|
||||
// Networks are first-class navigation targets: each network gets a direct
|
||||
// entry so managing one is a single click from anywhere.
|
||||
const networks = ref<CentralNetworkSummary[]>([]);
|
||||
// Blocked devices that still try to come online raise the bell badge.
|
||||
const blockedDevices = ref<BlockedDevice[]>([]);
|
||||
const loadNetworks = async () => {
|
||||
if (!centralEnabled.value) return;
|
||||
try {
|
||||
networks.value = (await api.list_networks()) ?? [];
|
||||
} catch (e) {
|
||||
// Keep the last known list; the workspace links stay usable.
|
||||
}
|
||||
};
|
||||
const loadBlockedDevices = async () => {
|
||||
if (!centralEnabled.value) return;
|
||||
try {
|
||||
blockedDevices.value = (await api.list_blocked_devices()) ?? [];
|
||||
} catch (e) {
|
||||
// Notifications are best-effort.
|
||||
}
|
||||
};
|
||||
let networkTimer: number | undefined;
|
||||
const loadNavigation = async () => {
|
||||
if (!enrollInfo.value) {
|
||||
try {
|
||||
enrollInfo.value = await api.get_console_info();
|
||||
} catch (error) {
|
||||
console.error('Failed to load console mode', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await Promise.all([loadNetworks(), loadBlockedDevices()]);
|
||||
};
|
||||
onMounted(() => {
|
||||
loadNavigation();
|
||||
networkTimer = window.setInterval(loadNavigation, 10_000);
|
||||
});
|
||||
onUnmounted(() => window.clearInterval(networkTimer));
|
||||
|
||||
// 处理点击外部区域关闭侧边栏
|
||||
const handleClickOutside = (event: Event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
// Device enrollment info: the config-server command devices run to join
|
||||
// this console. Also identifies whether central management is available.
|
||||
const enrollBell = ref();
|
||||
const enrollInfo = ref<import('../modules/api').ConsoleInfo | null>(null);
|
||||
const enrollLoading = ref(false);
|
||||
const centralEnabled = computed(() => enrollInfo.value?.webhook_auth === false);
|
||||
watch([() => enrollInfo.value?.webhook_auth, () => route.name], ([external, name]) => {
|
||||
if (external && (name === 'networkList' || name === 'networkDetail')) {
|
||||
router.replace({ name: 'dashboard' });
|
||||
}
|
||||
});
|
||||
|
||||
// 如果侧边栏是隐藏的,不需要处理
|
||||
if (!forceShowSideBar.value) return;
|
||||
const enrollCommand = computed(() => {
|
||||
if (!enrollInfo.value) return '';
|
||||
const host = new URL(getApiBase(), window.location.href).hostname;
|
||||
const { config_server_protocol: proto, config_server_port: port, username } = enrollInfo.value;
|
||||
return `easytier-core --config-server ${proto}://${host}:${port}/${username}`;
|
||||
});
|
||||
|
||||
// 检查点击是否在侧边栏内部或切换按钮上
|
||||
const isClickInsideSidebar = sidebarRef.value?.contains(target);
|
||||
const isClickOnToggleButton = toggleButtonRef.value?.contains(target);
|
||||
|
||||
// 如果点击在侧边栏外部且不在切换按钮上,则关闭侧边栏
|
||||
if (!isClickInsideSidebar && !isClickOnToggleButton) {
|
||||
forceShowSideBar.value = false;
|
||||
const toggleEnrollBell = async (event: Event) => {
|
||||
enrollBell.value.toggle(event);
|
||||
if (!enrollInfo.value && !enrollLoading.value) {
|
||||
enrollLoading.value = true;
|
||||
try {
|
||||
enrollInfo.value = await api.get_console_info();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
enrollLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 切换侧边栏显示状态
|
||||
const toggleSidebar = () => {
|
||||
forceShowSideBar.value = !forceShowSideBar.value;
|
||||
const copyEnrollCommand = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(enrollCommand.value);
|
||||
toast.add({ severity: 'success', summary: t('web.common.confirm'), life: 1500 });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
// 点击背景遮罩关闭侧边栏
|
||||
const closeSidebar = () => {
|
||||
forceShowSideBar.value = false;
|
||||
const blockedAttemptCount = computed(() =>
|
||||
blockedDevices.value.filter(device => device.attempt_count > 0).length);
|
||||
const blockBell = ref();
|
||||
const toggleBlockBell = (event: Event) => blockBell.value.toggle(event);
|
||||
const unblockDevice = async (device: BlockedDevice) => {
|
||||
try {
|
||||
await api.unblock_device(device.id);
|
||||
await loadBlockedDevices();
|
||||
} catch (e: any) {
|
||||
toast.add({ severity: 'error', summary: t('web.console.blocked_devices'), detail: e?.response?.data?.message ?? String(e), life: 5000 });
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
// 等待 DOM 渲染完成后添加事件监听器
|
||||
await nextTick();
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
});
|
||||
type NavItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
to: { name: string; params?: Record<string, string> };
|
||||
badge?: string;
|
||||
child?: boolean;
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
const workspaceNavigation = computed<NavItem[]>(() => [
|
||||
{ key: 'dashboard', label: t('web.main.dashboard'), icon: 'pi pi-chart-pie', to: { name: 'dashboard' } },
|
||||
{ key: 'deviceList', label: t('web.main.device_list'), icon: 'pi pi-server', to: { name: 'deviceList' } },
|
||||
]);
|
||||
const networkNavigation = computed<NavItem[]>(() => [
|
||||
{ key: 'networkList', label: t('web.main.network_list'), icon: 'pi pi-globe', to: { name: 'networkList' } },
|
||||
...networks.value.map(network => ({
|
||||
key: `network:${network.network_id}`,
|
||||
label: network.display_name || network.network_name,
|
||||
icon: 'pi pi-sitemap',
|
||||
to: { name: 'networkDetail', params: { networkId: network.network_id } },
|
||||
badge: `${network.online_member_count}/${network.member_count}`,
|
||||
child: true,
|
||||
})),
|
||||
]);
|
||||
|
||||
const activeKey = computed(() => {
|
||||
if (route.name === 'networkDetail') return `network:${route.params.networkId}`;
|
||||
if (route.name === 'deviceManagement') return 'deviceList';
|
||||
if (typeof route.name === 'string') return route.name;
|
||||
return '';
|
||||
});
|
||||
const pageTitle = computed(() => {
|
||||
if (route.name === 'networkDetail') {
|
||||
const network = networks.value.find(item => `network:${item.network_id}` === activeKey.value);
|
||||
if (network) return network.display_name || network.network_name;
|
||||
return t('web.main.network_list');
|
||||
}
|
||||
return [...workspaceNavigation.value, ...networkNavigation.value]
|
||||
.find(item => item.key === activeKey.value)?.label;
|
||||
});
|
||||
const forceShowSideBar = ref(false);
|
||||
const mainContent = ref<HTMLElement>();
|
||||
|
||||
// 切换页面后关闭移动端侧边栏。
|
||||
watch(() => route.fullPath, () => { forceShowSideBar.value = false; });
|
||||
</script>
|
||||
|
||||
<!-- https://flowbite.com/docs/components/sidebar/#sidebar-with-navbar -->
|
||||
<template>
|
||||
<nav
|
||||
class="fixed top-0 z-50 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700 top-navbar">
|
||||
<div class="px-3 py-3 lg:px-5 lg:pl-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-start rtl:justify-end">
|
||||
<div class="sm:hidden">
|
||||
<Button ref="toggleButtonRef" type="button" aria-haspopup="true" icon="pi pi-list"
|
||||
variant="text" size="large" severity="contrast" @click="toggleSidebar" />
|
||||
</div>
|
||||
<a href="https://easytier.top" class="flex ms-2 md:me-24">
|
||||
<img :src="Icon" class="h-9 me-3" alt="FlowBite Logo" />
|
||||
<span
|
||||
class="self-center text-xl font-semibold sm:text-2xl whitespace-nowrap dark:text-white">EasyTier</span>
|
||||
</a>
|
||||
<div class="web-console">
|
||||
<a class="skip-link" href="#main-content" @click.prevent="mainContent?.focus()">{{ t('web.console.skip_content') }}</a>
|
||||
<aside class="console-sidebar">
|
||||
<RouterLink :to="{ name: 'dashboard' }" class="console-brand">
|
||||
<img :src="Icon" alt="" /><span>EasyTier<span class="brand-caption">{{ t('web.console.console') }}</span></span>
|
||||
</RouterLink>
|
||||
<div class="nav-caption">{{ t('web.console.workspace') }}</div>
|
||||
<nav :aria-label="t('web.console.navigation')">
|
||||
<Menu :model="workspaceNavigation" :aria-label="t('web.console.navigation')" class="console-navigation">
|
||||
<template #item="{ item, props }">
|
||||
<RouterLink :to="item.to" custom v-slot="{ href, navigate }">
|
||||
<a v-bind="props.action" :href="href" @click="navigate"
|
||||
:class="{ 'is-active': activeKey === item.key }" :aria-current="activeKey === item.key ? 'page' : undefined">
|
||||
<span v-bind="props.icon" aria-hidden="true"></span><span v-bind="props.label">{{ item.label }}</span>
|
||||
</a>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</Menu>
|
||||
<div v-if="centralEnabled" class="nav-caption">{{ t('web.main.network_list') }}</div>
|
||||
<Menu v-if="centralEnabled" :model="networkNavigation" :aria-label="t('web.main.network_list')" class="console-navigation">
|
||||
<template #item="{ item, props }">
|
||||
<RouterLink :to="item.to" custom v-slot="{ href, navigate }">
|
||||
<a v-bind="props.action" :href="href" @click="navigate"
|
||||
:class="{ 'is-active': activeKey === item.key, 'nav-child': item.child }" :aria-current="activeKey === item.key ? 'page' : undefined">
|
||||
<span v-bind="props.icon" aria-hidden="true"></span><span v-bind="props.label">{{ item.label }}</span>
|
||||
<span v-if="item.badge" class="nav-badge">{{ item.badge }}</span>
|
||||
</a>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</Menu>
|
||||
</nav>
|
||||
<a href="https://easytier.cn" target="_blank" rel="noopener noreferrer" class="console-docs">
|
||||
<i class="pi pi-book" aria-hidden="true"></i>{{ t('web.console.documentation') }}<i class="pi pi-arrow-up-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</aside>
|
||||
<Drawer v-model:visible="forceShowSideBar" :header="t('web.console.navigation')" class="console-mobile-nav">
|
||||
<nav :aria-label="t('web.console.navigation')">
|
||||
<div class="nav-caption">{{ t('web.console.workspace') }}</div>
|
||||
<Menu :model="workspaceNavigation" :aria-label="t('web.console.navigation')" class="console-navigation">
|
||||
<template #item="{ item, props }">
|
||||
<RouterLink :to="item.to" custom v-slot="{ href, navigate }">
|
||||
<a v-bind="props.action" :href="href" @click="navigate($event); forceShowSideBar = false"
|
||||
:class="{ 'is-active': activeKey === item.key }" :aria-current="activeKey === item.key ? 'page' : undefined">
|
||||
<span v-bind="props.icon" aria-hidden="true"></span><span v-bind="props.label">{{ item.label }}</span>
|
||||
</a>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</Menu>
|
||||
<div v-if="centralEnabled" class="nav-caption">{{ t('web.main.network_list') }}</div>
|
||||
<Menu v-if="centralEnabled" :model="networkNavigation" :aria-label="t('web.main.network_list')" class="console-navigation">
|
||||
<template #item="{ item, props }">
|
||||
<RouterLink :to="item.to" custom v-slot="{ href, navigate }">
|
||||
<a v-bind="props.action" :href="href" @click="navigate($event); forceShowSideBar = false"
|
||||
:class="{ 'is-active': activeKey === item.key, 'nav-child': item.child }" :aria-current="activeKey === item.key ? 'page' : undefined">
|
||||
<span v-bind="props.icon" aria-hidden="true"></span><span v-bind="props.label">{{ item.label }}</span>
|
||||
<span v-if="item.badge" class="nav-badge">{{ item.badge }}</span>
|
||||
</a>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</Menu>
|
||||
</nav>
|
||||
</Drawer>
|
||||
<div class="console-workspace">
|
||||
<header class="console-topbar">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Button icon="pi pi-bars" text severity="secondary" class="mobile-nav-toggle"
|
||||
:aria-label="t('web.console.navigation')" @click="forceShowSideBar = true" />
|
||||
<span class="topbar-brand">{{ t('web.console.workspace') }}</span>
|
||||
<i class="pi pi-angle-right topbar-brand" aria-hidden="true"></i><span>{{ pageTitle }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="language-switch">
|
||||
<Button icon="pi pi-language" @click="I18nUtils.toggleLanguage" rounded severity="contrast" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center ms-3">
|
||||
<div>
|
||||
<Button type="button" @click="userMenu.toggle($event)" aria-haspopup="true"
|
||||
aria-controls="user-menu" icon="pi pi-user" raised rounded />
|
||||
<TieredMenu ref="userMenu" id="user-menu" :model="userMenuItems" popup />
|
||||
<div class="flex items-center gap-2">
|
||||
<Button icon="pi pi-objects-column" :label="t('web.console.enroll_title')"
|
||||
severity="secondary" outlined size="small" class="enroll-button"
|
||||
:aria-label="t('web.console.enroll_title')"
|
||||
@click="toggleEnrollBell" />
|
||||
<Popover ref="enrollBell" appendTo="body" style="min-width: 34rem">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold">{{ t('web.console.enroll_title') }}</span>
|
||||
<p class="text-sm muted m-0">{{ t('web.console.enroll_hint') }}</p>
|
||||
<div v-if="enrollLoading" class="text-sm muted">{{ t('web.common.loading') }}</div>
|
||||
<template v-else-if="enrollInfo">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="mono-value flex-1 p-2 rounded"
|
||||
style="background: var(--console-ground); word-break: break-all">{{ enrollCommand }}</code>
|
||||
<Button icon="pi pi-copy" severity="secondary" outlined size="small"
|
||||
:aria-label="t('web.console.enroll_copy')" @click="copyEnrollCommand" />
|
||||
</div>
|
||||
<p class="text-xs muted m-0">
|
||||
{{ enrollInfo.webhook_auth ? t('web.console.enroll_webhook_note') : t('web.console.enroll_token_note') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
<OverlayBadge v-if="centralEnabled" :value="blockedAttemptCount > 0 ? String(blockedAttemptCount) : undefined">
|
||||
<Button icon="pi pi-bell" text severity="secondary" :aria-label="t('web.console.blocked_devices')" @click="toggleBlockBell" />
|
||||
</OverlayBadge>
|
||||
<Popover ref="blockBell" appendTo="body" style="min-width: 22rem">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-semibold">{{ t('web.console.blocked_devices') }}</span>
|
||||
</div>
|
||||
<div v-if="blockedDevices.length === 0" class="text-sm muted p-2">{{ t('web.console.blocked_empty') }}</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div v-for="device in blockedDevices" :key="device.id" class="flex items-center justify-between gap-3 border-b border-surface last:border-b-0 pb-2 last:pb-0">
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate">{{ device.alias || device.hostname || device.id }}</div>
|
||||
<div class="text-xs muted">
|
||||
<template v-if="device.attempt_count > 0">
|
||||
{{ t('web.console.blocked_attempted', { time: (device.last_attempt_time || '').replace('T', ' ').slice(0, 19), count: device.attempt_count }) }}
|
||||
</template>
|
||||
<template v-else>{{ t('web.console.blocked_no_attempt') }}</template>
|
||||
</div>
|
||||
</div>
|
||||
<Button :label="t('web.console.unblock')" size="small" severity="secondary" outlined @click="unblockDevice(device)" />
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
<Button :icon="themeIcon" text severity="secondary"
|
||||
:aria-label="t('web.console.theme_mode')" v-tooltip.bottom="t('web.console.theme_mode')"
|
||||
@click="cycleThemeMode" />
|
||||
<Button icon="pi pi-language" text severity="secondary" :aria-label="t('web.console.language')" @click="I18nUtils.toggleLanguage" />
|
||||
<span class="topbar-divider"></span>
|
||||
<Button type="button" @click="userMenu.toggle($event)" aria-haspopup="true" aria-controls="user-menu"
|
||||
:aria-label="t('web.console.account')" icon="pi pi-user" text severity="secondary" />
|
||||
<TieredMenu ref="userMenu" id="user-menu" :model="userMenuItems" popup />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 背景遮罩 - 只在侧边栏显示时显示 -->
|
||||
<div v-if="forceShowSideBar" class="fixed inset-0 z-30 bg-black bg-opacity-50 sm:hidden" @click="closeSidebar">
|
||||
</div>
|
||||
|
||||
<aside ref="sidebarRef" id="logo-sidebar"
|
||||
class="fixed top-1 left-0 z-40 w-64 h-screen pt-20 transition-transform bg-white border-r border-gray-201 sm:translate-x-0 dark:bg-gray-800 dark:border-gray-700"
|
||||
:class="{ '-translate-x-full': !forceShowSideBar }" aria-label="Sidebar">
|
||||
<div class="h-full px-3 pb-4 overflow-y-auto bg-white dark:bg-gray-800">
|
||||
<ul class="space-y-2 font-medium">
|
||||
<li>
|
||||
<Button variant="text" class="w-full justify-start gap-x-3 pl-1.5 sidebar-button"
|
||||
severity="contrast" @click="router.push({ name: 'dashboard' })">
|
||||
<i class="pi pi-chart-pie text-xl"></i>
|
||||
<span class="mb-0.5">{{ t('web.main.dashboard') }}</span>
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button variant="text" class="w-full justify-start gap-x-3 pl-1.5 sidebar-button"
|
||||
severity="contrast" @click="router.push({ name: 'deviceList' })">
|
||||
<i class="pi pi-server text-xl"></i>
|
||||
<span class="mb-0.5">{{ t('web.main.device_list') }}</span>
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button variant="text" class="w-full justify-start gap-x-3 pl-1.5 sidebar-button"
|
||||
severity="contrast" @click="router.push({ name: 'login' })">
|
||||
<i class="pi pi-sign-in text-xl"></i>
|
||||
<span class="mb-0.5">{{ t('web.main.login_page') }}</span>
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="p-4 sm:ml-64">
|
||||
<div class="p-4 border-2 border-gray-200 border-dashed rounded-lg dark:border-gray-700">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
</header>
|
||||
<main ref="mainContent" id="main-content" class="console-content" tabindex="-1">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" :api="api" />
|
||||
<component :is="Component" :api="api" :central-enabled="centralEnabled" :key="route.name === 'networkDetail' ? String(route.params.networkId) : undefined" />
|
||||
</RouterView>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-button {
|
||||
text-align: left;
|
||||
justify-content: left;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { Button, Column, DataTable, Dialog, InputSwitch, InputText, Message, Skeleton, Tag, useToast } from 'primevue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { UrlListInput, Utils } from 'easytier-frontend-lib';
|
||||
import ApiClient, { type CentralNetworkSettings, type CentralNetworkSummary, type GatewayInfo } from '../modules/api';
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
|
||||
const props = defineProps({
|
||||
api: ApiClient,
|
||||
});
|
||||
|
||||
const api = props.api;
|
||||
|
||||
const networks = ref<CentralNetworkSummary[] | undefined>(undefined);
|
||||
const gatewayInfo = ref<GatewayInfo | undefined>(undefined);
|
||||
const advancedMode = ref(false);
|
||||
const search = ref('');
|
||||
const loadError = ref(false);
|
||||
const refreshing = ref(false);
|
||||
const filteredNetworks = computed(() => {
|
||||
const query = search.value.trim().toLocaleLowerCase();
|
||||
return (networks.value ?? []).filter(network =>
|
||||
`${network.display_name} ${network.network_name}`.toLocaleLowerCase().includes(query)
|
||||
).sort((a, b) => a.display_name.localeCompare(b.display_name));
|
||||
});
|
||||
|
||||
const gatewayEnabled = computed(() => gatewayInfo.value?.enabled === true);
|
||||
|
||||
const protos: { [proto: string]: number } = {
|
||||
tcp: 11010,
|
||||
udp: 11010,
|
||||
ws: 11011,
|
||||
wss: 11012,
|
||||
};
|
||||
|
||||
const networkingMethodLabels: { [method: string]: () => string } = {
|
||||
PublicServer: () => t('public_server'),
|
||||
Manual: () => t('manual'),
|
||||
Standalone: () => t('standalone'),
|
||||
Gateway: () => t('web.network_list.gateway_mode'),
|
||||
};
|
||||
|
||||
const networkingMethodLabel = (method: string) => {
|
||||
return networkingMethodLabels[method]?.() ?? method;
|
||||
};
|
||||
|
||||
const createVisible = ref(false);
|
||||
const creating = ref(false);
|
||||
const createForm = ref({
|
||||
display_name: '',
|
||||
virtual_cidr: '',
|
||||
secure_mode: false,
|
||||
});
|
||||
// Initial nodes in advanced mode, same model as the GUI config form.
|
||||
const initialNodes = ref<string[]>([]);
|
||||
|
||||
const loadNetworks = async () => {
|
||||
if (refreshing.value) return;
|
||||
refreshing.value = true;
|
||||
try {
|
||||
networks.value = await api?.list_networks();
|
||||
loadError.value = false;
|
||||
} catch (e) {
|
||||
loadError.value = true;
|
||||
console.error(e);
|
||||
} finally {
|
||||
refreshing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const periodFunc = new Utils.PeriodicTask(async () => {
|
||||
try {
|
||||
await loadNetworks();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
onMounted(async () => {
|
||||
periodFunc.start();
|
||||
try {
|
||||
gatewayInfo.value = await api?.get_gateway_info();
|
||||
} catch (e) {
|
||||
gatewayInfo.value = { enabled: false, relay_data: false };
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
periodFunc.stop();
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
createForm.value = {
|
||||
display_name: '',
|
||||
virtual_cidr: '',
|
||||
// The built-in gateway speaks the Noise handshake; third-party
|
||||
// peers of advanced networking may not, so secure mode defaults on
|
||||
// for gateway networks only.
|
||||
secure_mode: gatewayEnabled.value,
|
||||
};
|
||||
advancedMode.value = !gatewayEnabled.value;
|
||||
initialNodes.value =
|
||||
gatewayEnabled.value && gatewayInfo.value?.peer_url ? [gatewayInfo.value.peer_url] : [];
|
||||
createVisible.value = true;
|
||||
};
|
||||
|
||||
const createNetwork = async () => {
|
||||
creating.value = true;
|
||||
try {
|
||||
const virtual_cidr = createForm.value.virtual_cidr.trim() || null;
|
||||
let settings: CentralNetworkSettings;
|
||||
if (!advancedMode.value && gatewayEnabled.value) {
|
||||
settings = {
|
||||
display_name: createForm.value.display_name,
|
||||
network_name: null,
|
||||
networking_method: 'Gateway',
|
||||
public_server_url: null,
|
||||
peer_urls: [],
|
||||
virtual_cidr,
|
||||
secure_mode: createForm.value.secure_mode,
|
||||
};
|
||||
} else {
|
||||
settings = {
|
||||
display_name: createForm.value.display_name,
|
||||
network_name: null,
|
||||
networking_method: initialNodes.value.length > 0 ? 'Manual' : 'Standalone',
|
||||
public_server_url: null,
|
||||
peer_urls: initialNodes.value,
|
||||
virtual_cidr,
|
||||
secure_mode: createForm.value.secure_mode,
|
||||
};
|
||||
}
|
||||
const detail = await api?.create_network(settings);
|
||||
createVisible.value = false;
|
||||
await loadNetworks();
|
||||
if (detail) {
|
||||
router.push({ name: 'networkDetail', params: { ...route.params, networkId: detail.network_id } });
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.add({ severity: 'error', summary: t('web.network.create'), detail: e?.response?.data?.message ?? String(e), life: 5000 });
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAdvancedMode = () => {
|
||||
advancedMode.value = !advancedMode.value;
|
||||
// Seed the gateway URL only when the list has not been edited, so
|
||||
// toggling back and forth does not drop user input.
|
||||
if (advancedMode.value && initialNodes.value.length === 0 && gatewayInfo.value?.peer_url) {
|
||||
initialNodes.value = [gatewayInfo.value.peer_url];
|
||||
}
|
||||
// Secure mode follows the networking method's default; users can flip
|
||||
// it again afterwards.
|
||||
createForm.value.secure_mode = !advancedMode.value;
|
||||
};
|
||||
|
||||
const openNetwork = (network: CentralNetworkSummary) => {
|
||||
router.push({ name: 'networkDetail', params: { ...route.params, networkId: network.network_id } });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="console-page">
|
||||
<header class="page-heading">
|
||||
<div><h1>{{ t('web.network_list.title') }}</h1></div>
|
||||
<Button :label="t('web.network_list.create')" icon="pi pi-plus" @click="openCreate" />
|
||||
</header>
|
||||
<Message v-if="loadError" severity="warn" :closable="false">{{ t('web.console.load_failed') }}</Message>
|
||||
<section class="console-panel">
|
||||
<div class="list-toolbar">
|
||||
<div class="search-field"><i class="pi pi-search" aria-hidden="true"></i><InputText v-model="search" :placeholder="t('web.console.search_networks')" :aria-label="t('web.console.search_networks')" /></div>
|
||||
<div class="flex items-center gap-3"><span v-if="networks" class="table-count">{{ t('web.console.items', { count: filteredNetworks.length }) }}</span><Button icon="pi pi-refresh" text severity="secondary" :aria-label="t('web.console.refresh')" :loading="refreshing" @click="loadNetworks" /></div>
|
||||
</div>
|
||||
<div v-if="networks === undefined && !loadError" class="loading-rows"><Skeleton v-for="i in 4" :key="i" height="2rem" /></div>
|
||||
<div v-else-if="networks === undefined" class="console-empty-state"><Button :label="t('web.console.retry')" severity="secondary" @click="loadNetworks" /></div>
|
||||
<div v-else-if="networks.length === 0" class="console-empty-state"><i class="pi pi-globe" aria-hidden="true"></i><h2>{{ t('web.console.networks_empty') }}</h2><p>{{ t('web.console.networks_empty_hint') }}</p><Button :label="t('web.network_list.create')" icon="pi pi-plus" @click="openCreate" /></div>
|
||||
<div v-else-if="filteredNetworks.length === 0" class="console-empty-state"><h2>{{ t('web.console.no_results') }}</h2><Button :label="t('web.console.clear_search')" text @click="search = ''" /></div>
|
||||
<template v-else>
|
||||
<DataTable :value="filteredNetworks" dataKey="network_id" class="console-table desktop-list" size="small" scrollable :paginator="filteredNetworks.length > 20" :rows="20">
|
||||
<Column field="display_name" :header="t('web.network_list.display_name')" sortable><template #body="{ data }"><button class="entity-link" @click="openNetwork(data)"><i class="pi pi-globe entity-icon" aria-hidden="true"></i>{{ data.display_name }}</button></template></Column>
|
||||
<Column field="networking_method" :header="t('networking_method')"><template #body="{ data }"><Tag :value="networkingMethodLabel(data.networking_method)" severity="secondary" /></template></Column>
|
||||
<Column field="online_member_count" :header="t('web.console.online_members')" sortable><template #body="{ data }">{{ data.online_member_count }} <span class="muted">/ {{ data.member_count }}</span></template></Column>
|
||||
<Column :header="t('web.console.manage')"><template #body="{ data }"><Button icon="pi pi-arrow-up-right" text severity="secondary" :aria-label="`${t('web.console.manage')} ${data.display_name}`" @click="openNetwork(data)" /></template></Column>
|
||||
</DataTable>
|
||||
<div class="mobile-list">
|
||||
<article v-for="network in filteredNetworks" :key="network.network_id" class="mobile-list-item">
|
||||
<div class="flex items-center justify-between gap-3"><button class="entity-link" @click="openNetwork(network)"><i class="pi pi-globe entity-icon" aria-hidden="true"></i>{{ network.display_name }}</button><Tag :value="networkingMethodLabel(network.networking_method)" severity="secondary" /></div>
|
||||
<p class="preview-secondary">{{ t('web.console.online_members') }} · {{ network.online_member_count }} / {{ network.member_count }}</p>
|
||||
<details><summary>{{ t('web.console.details') }}</summary><p class="mono-value mt-2">{{ network.network_name }}</p></details>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<Dialog v-model:visible="createVisible" modal class="console-dialog" :header="t('web.network_list.create')" :style="{ width: '32rem' }">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-if="gatewayEnabled && !advancedMode" class="flex items-start gap-2 p-3 surface-100 rounded-md">
|
||||
<i class="pi pi-check-circle text-green-500 mt-0.5"></i>
|
||||
<div class="text-sm">
|
||||
{{ t('web.network_list.gateway_hint') }}
|
||||
<span v-if="gatewayInfo?.peer_url" class="block font-mono text-xs mt-1">{{ gatewayInfo.peer_url }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="network-display-name">{{ t('web.network_list.display_name') }}</label>
|
||||
<InputText id="network-display-name" v-model="createForm.display_name" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center">
|
||||
<label for="network-cidr">{{ t('web.network_list.virtual_cidr') }}</label>
|
||||
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('web.network_list.virtual_cidr_hint')"></span>
|
||||
</div>
|
||||
<InputText id="network-cidr" v-model="createForm.virtual_cidr" class="font-mono"
|
||||
:placeholder="t('web.network_list.virtual_cidr_placeholder')" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<InputSwitch inputId="create-secure-mode" v-model="createForm.secure_mode" />
|
||||
<label for="create-secure-mode" class="text-sm">{{ t('web.network_list.secure_mode') }}</label>
|
||||
<span class="pi pi-question-circle text-sm" v-tooltip.top="t('web.network_list.secure_mode_hint')"></span>
|
||||
</div>
|
||||
<template v-if="advancedMode || !gatewayEnabled">
|
||||
<div class="flex items-center">
|
||||
<label for="initial-nodes">{{ t('initial_nodes') }}</label>
|
||||
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('initial_nodes_help')"></span>
|
||||
</div>
|
||||
<div class="items-center flex flex-col p-fluid gap-y-2">
|
||||
<UrlListInput id="initial-nodes" v-model="initialNodes" :protos="protos"
|
||||
defaultUrl="tcp://:11010" :add-label="t('add_initial_node')"
|
||||
:placeholder="t('initial_node_placeholder')" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="gatewayEnabled" class="text-sm">
|
||||
<button type="button" class="text-primary text-left" @click="toggleAdvancedMode">
|
||||
{{ advancedMode ? t('web.network_list.use_gateway') : t('web.network_list.advanced_mode') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-500">{{ t('web.network_list.create_hint') }}</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button :label="t('web.common.cancel')" icon="pi pi-times" text severity="secondary" @click="createVisible = false" />
|
||||
<Button :label="t('web.common.confirm')" icon="pi pi-check" :loading="creating" @click="createNetwork" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,235 @@
|
||||
/* Compatibility tokens for shared frontend-lib components, in the Web app only.
|
||||
Define them at the document root so teleported PrimeVue dialogs inherit them. */
|
||||
:root {
|
||||
--surface-card: var(--p-content-background);
|
||||
--surface-ground: var(--p-surface-50);
|
||||
--surface-section: var(--p-content-background);
|
||||
--surface-border: var(--p-content-border-color);
|
||||
--surface-hover: var(--p-content-hover-background);
|
||||
--surface-50: var(--p-surface-50);
|
||||
--surface-200: var(--p-surface-200);
|
||||
--text-color: var(--p-text-color);
|
||||
--text-color-secondary: var(--p-text-muted-color);
|
||||
--primary-color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
.web-console {
|
||||
--console-ground: var(--p-surface-50);
|
||||
--console-panel: var(--p-surface-0);
|
||||
--console-border: var(--p-surface-200);
|
||||
--console-muted: var(--p-surface-500);
|
||||
--console-stripe: var(--p-surface-50);
|
||||
min-height: 100dvh;
|
||||
background: var(--console-ground);
|
||||
color: var(--p-text-color);
|
||||
font-size: 14px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', 'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.5;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.console-sidebar { position: fixed; inset: 0 auto 0 0; width: 208px; display: flex; flex-direction: column; padding: 30px 16px 20px; background: var(--console-panel); border-right: 1px solid var(--console-border); z-index: 20; }
|
||||
.console-brand { display: flex; gap: 10px; align-items: center; padding: 0 12px 36px; font-size: 21px; font-weight: 650; letter-spacing: -.6px; }
|
||||
.console-brand img { width: 34px; height: 34px; }
|
||||
.brand-caption { display: block; font-size: 11px; font-weight: 450; letter-spacing: .03em; color: var(--console-muted); }
|
||||
.nav-caption { padding: 0 12px 10px; color: var(--console-muted); font-size: 11px; font-weight: 600; }
|
||||
.console-navigation.p-menu { min-width: 0; background: transparent; border: 0; }
|
||||
.console-navigation .p-menu-list { padding: 0; gap: 5px; }
|
||||
.console-navigation a { display: flex; gap: 12px; align-items: center; border-radius: 6px; padding: 11px 12px; color: var(--p-text-muted-color); font-weight: 500; transition: background-color .15s, color .15s; }
|
||||
.console-navigation a:hover { background: var(--p-content-hover-background); color: var(--p-text-color); }
|
||||
.console-navigation a.is-active { color: var(--p-primary-color); background: var(--p-highlight-background); }
|
||||
.console-navigation .pi { font-size: 15px; }
|
||||
.console-navigation a.nav-child { padding-left: 34px; }
|
||||
.console-navigation a.nav-child .pi { font-size: 12px; opacity: .85; }
|
||||
.console-navigation .nav-badge { margin-left: auto; font-size: 11px; padding: 1px 7px; border-radius: 999px; background: var(--p-content-border); color: var(--p-text-muted-color); font-variant-numeric: tabular-nums; }
|
||||
.console-navigation a.is-active .nav-badge { background: var(--p-primary-color); color: var(--p-primary-contrast-color); }
|
||||
.console-docs { display: flex; gap: 10px; align-items: center; margin-top: auto; padding: 12px; font-size: 12px; color: var(--console-muted); }
|
||||
.console-docs .pi:last-child { margin-left: auto; font-size: 11px; }
|
||||
.console-workspace { margin-left: 208px; min-width: 0; }
|
||||
.console-topbar { min-height: 65px; padding: 10px 36px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--console-border); background: var(--console-panel); }
|
||||
.topbar-brand { color: var(--console-muted); font-size: 12px; }
|
||||
.topbar-divider { height: 18px; width: 1px; background: var(--console-border); margin: 0 4px; }
|
||||
.console-content { max-width: 1536px; padding: 36px; margin: 0 auto; }
|
||||
.console-content:focus { outline: none; }
|
||||
.web-console .mobile-nav-toggle { display: none; }
|
||||
.enroll-button .p-button-label { white-space: nowrap; }
|
||||
@media (max-width: 767px) { .enroll-button .p-button-label { display: none; } }
|
||||
.skip-link { position: fixed; top: -100px; left: 16px; z-index: 2000; padding: 12px; background: var(--console-panel); }
|
||||
.skip-link:focus { top: 8px; }
|
||||
.console-page { display: flex; flex-direction: column; gap: 24px; min-width: 0; }
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
|
||||
.page-heading h1 { font-size: 26px; line-height: 1.3; font-weight: 600; letter-spacing: -.7px; overflow-wrap: anywhere; }
|
||||
.page-description { color: var(--console-muted); font-size: 13px; margin-top: 7px; }
|
||||
.section-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 18px 20px; border-bottom: 1px solid var(--console-border); }
|
||||
.section-heading h2 { font-weight: 600; font-size: 14px; }
|
||||
.console-panel { min-width: 0; background: var(--console-panel); border: 1px solid var(--console-border); border-radius: 8px; overflow: hidden; }
|
||||
.list-toolbar { padding: 14px 18px; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--console-border); }
|
||||
.search-field { position: relative; flex: 1; max-width: 340px; min-width: 180px; }
|
||||
.search-field > .pi { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--console-muted); }
|
||||
.search-field .p-inputtext { width: 100%; padding-left: 34px; }
|
||||
.table-count { font-size: 12px; color: var(--console-muted); }
|
||||
.console-table .p-datatable-header-cell { background: var(--console-ground); font-size: 12px; color: var(--console-muted); font-weight: 500; padding: 12px 18px; white-space: nowrap; }
|
||||
.console-table .p-datatable-tbody > tr > td { padding: 13px 18px; font-size: 13px; border-color: var(--console-border); }
|
||||
.console-table .p-datatable-tbody > tr.p-row-even:not(.p-datatable-row-expansion) > td { background: var(--console-stripe); }
|
||||
.console-table .p-datatable-tbody > tr:not(.p-datatable-row-expansion):hover > td { background: var(--p-content-hover-background); }
|
||||
.console-table .p-tag { white-space: nowrap; }
|
||||
.console-table .p-datatable-tbody > tr:last-child > td { border-bottom: 0; }
|
||||
.console-table .p-datatable-tbody > tr.p-datatable-row-expansion > td { background: var(--console-ground); box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.04); }
|
||||
.console-table .p-datatable-paginator-bottom { border-bottom: 0; }
|
||||
.entity-link { display: inline-flex; align-items: center; gap: 10px; max-width: 100%; font-weight: 550; text-align: left; overflow-wrap: anywhere; }
|
||||
.entity-link:hover { color: var(--p-primary-color); }
|
||||
.entity-icon { width: 30px; height: 30px; flex-shrink: 0; display: inline-flex; justify-content: center; align-items: center; background: color-mix(in srgb, var(--p-text-color) 5%, transparent); border: 1px solid var(--console-border); color: var(--console-muted); border-radius: 6px; font-size: 13px; }
|
||||
.status-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; display: inline-block; }
|
||||
.status-dot.online { background: var(--p-green-500); box-shadow: 0 0 0 3px color-mix(in srgb, var(--p-green-500) 18%, transparent); }
|
||||
.status-dot.offline { background: var(--p-surface-400); box-shadow: 0 0 0 3px color-mix(in srgb, var(--p-surface-400) 15%, transparent); }
|
||||
.mono-value { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.muted { color: var(--console-muted); }
|
||||
.console-empty-state { padding: 48px 24px; text-align: center; color: var(--console-muted); }
|
||||
.console-empty-state > .pi { font-size: 26px; display: block; margin-bottom: 14px; }
|
||||
.console-empty-state h2 { font-size: 15px; color: var(--p-text-color); font-weight: 550; margin-bottom: 8px; }
|
||||
.console-empty-state p { max-width: 380px; margin: 0 auto 16px; font-size: 13px; }
|
||||
.loading-rows { padding: 24px; display: flex; flex-direction: column; gap: 24px; }
|
||||
.summary-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.summary-stat { padding: 18px 20px; border-right: 1px solid var(--console-border); }
|
||||
.summary-stat:last-child { border-right: 0; }
|
||||
.summary-label { display: flex; align-items: center; justify-content: space-between; color: var(--console-muted); font-size: 12px; gap: 8px; }
|
||||
.summary-value { font-size: 30px; font-weight: 550; letter-spacing: -1px; line-height: 1.25; margin: 10px 0 4px; }
|
||||
.summary-note { color: var(--console-muted); font-size: 12px; }
|
||||
.overview-columns { display: grid; grid-template-columns: 1.2fr 1fr; gap: 24px; align-items: start; }
|
||||
.preview-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 20px; border-bottom: 1px solid var(--console-border); }
|
||||
.preview-row:last-child { border-bottom: 0; }
|
||||
.preview-row:hover { background: var(--p-content-hover-background); }
|
||||
.preview-row .entity-link { min-width: 0; }
|
||||
.preview-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.preview-secondary { margin-top: 3px; color: var(--console-muted); font-size: 12px; font-weight: 400; }
|
||||
.preview-number { text-align: right; white-space: nowrap; font-size: 13px; }
|
||||
.page-footnote { font-size: 12px; color: var(--console-muted); display: flex; align-items: center; gap: 7px; }
|
||||
.network-tabs > .p-tabpanels { padding: 24px; }
|
||||
.network-tabs .p-tablist-tab-list { padding: 0 12px; }
|
||||
.network-tabs .p-tab { font-size: 13px; padding: 16px; }
|
||||
.network-tabs .p-tabpanel { min-width: 0; }
|
||||
.settings-form { max-width: 640px; display: flex; flex-direction: column; gap: 18px; }
|
||||
.console-device-drawer.p-drawer { width: min(88vw, 960px); }
|
||||
.console-device-drawer .p-drawer-header { border-bottom: 1px solid var(--p-content-border-color); padding: 14px 16px; }
|
||||
.console-device-drawer .p-drawer-content { padding: 16px; }
|
||||
.console-device-drawer .frontend-lib .p-card .p-card-body { padding: 1rem; }
|
||||
.console-device-drawer .frontend-lib .p-card .p-card-content { padding: 0; }
|
||||
.console-device-drawer .network-header .p-button { border-radius: 6px; }
|
||||
.console-device-drawer .network-header .p-button-help { background: transparent; border-color: var(--p-content-border-color); color: var(--p-text-muted-color); }
|
||||
.console-device-drawer .network-header .p-button-help:hover { background: var(--p-content-hover-background); }
|
||||
.console-node-drawer.p-drawer { --node-border: var(--p-content-border-color); --node-soft: var(--p-surface-50); --node-muted: var(--p-text-muted-color); }
|
||||
.console-node-drawer .p-drawer-header { padding: 18px 24px 16px; border-bottom: 1px solid var(--node-border); align-items: flex-start; gap: 16px; }
|
||||
.console-node-drawer .p-drawer-content { padding: 0; }
|
||||
.node-drawer-heading { display: flex; align-items: flex-start; gap: 12px; flex: 1; min-width: 0; }
|
||||
.node-drawer-icon { display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; flex: none; border: 1px solid var(--node-border); border-radius: 7px; background: var(--node-soft); color: var(--p-primary-color); font-size: 15px; }
|
||||
.node-drawer-identity { min-width: 0; flex: 1; }
|
||||
.node-drawer-title { color: var(--p-text-color); font-size: 19px; font-weight: 650; line-height: 1.25; letter-spacing: -.02em; overflow-wrap: anywhere; }
|
||||
.node-drawer-meta { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 4px; color: var(--node-muted); font-size: 12px; }
|
||||
.node-drawer-meta .mono-value { font-size: 12px; }
|
||||
.node-drawer-presence { display: inline-flex; align-items: center; gap: 8px; flex: none; margin-top: 5px; color: var(--node-muted); font-size: 12px; }
|
||||
.node-drawer-presence .status-dot { width: 8px; height: 8px; }
|
||||
.node-drawer-main { padding: 16px 24px 24px; }
|
||||
.node-drawer-summary { display: flex; align-items: center; flex-wrap: wrap; row-gap: 8px; padding-bottom: 14px; border-bottom: 1px solid var(--node-border); }
|
||||
.node-drawer-metric { display: inline-flex; align-items: baseline; min-width: 0; gap: 5px; padding: 0 16px; border-right: 1px solid var(--node-border); white-space: nowrap; }
|
||||
.node-drawer-metric:first-child { padding-left: 0; }
|
||||
.node-drawer-metric:last-child { border-right: 0; }
|
||||
.node-drawer-metric-label { color: var(--node-muted); font-size: 12px; }
|
||||
.node-drawer-metric strong { font-size: 15px; font-weight: 600; line-height: 1.25; }
|
||||
.node-drawer-metric-state strong { font-size: 13px; letter-spacing: 0; }
|
||||
.node-drawer-tabs { margin-top: 4px; }
|
||||
.node-drawer-tabs .p-tablist-tab-list { gap: 24px; border-bottom: 1px solid var(--node-border); }
|
||||
.node-drawer-tabs .p-tab { padding: 10px 0 11px; font-size: 13px; font-weight: 550; }
|
||||
.node-drawer-tabs .p-tabpanels { padding: 12px 0 0; }
|
||||
.node-drawer-panel { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
||||
.node-drawer-section, .node-drawer-disclosure, .node-drawer-action { border: 1px solid var(--node-border); border-radius: 10px; background: var(--p-content-background); overflow: hidden; }
|
||||
.node-drawer-section-heading { padding: 12px 16px; border-bottom: 1px solid var(--node-border); }
|
||||
.node-drawer-section-heading h3 { font-size: 14px; font-weight: 600; }
|
||||
.node-drawer-loading { display: flex; flex-direction: column; gap: 8px; padding: 16px; }
|
||||
.node-drawer-empty { display: flex; align-items: center; gap: 10px; padding: 13px 16px; color: var(--node-muted); font-size: 13px; line-height: 1.5; }
|
||||
.node-drawer-empty .pi { flex: none; color: var(--p-primary-color); font-size: 15px; }
|
||||
.node-drawer-empty strong { color: var(--p-text-color); font-weight: 600; }
|
||||
.node-drawer-mobile-peers { display: none; }
|
||||
.node-drawer-mobile-peer { padding: 12px 16px; }
|
||||
.node-drawer-mobile-peer + .node-drawer-mobile-peer { border-top: 1px solid var(--node-border); }
|
||||
.node-drawer-mobile-peer-heading { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 4px 12px; }
|
||||
.node-drawer-mobile-peer-heading strong { font-size: 13px; font-weight: 600; }
|
||||
.node-drawer-mobile-peer-path { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 5px; color: var(--node-muted); font-size: 12px; }
|
||||
.node-drawer-mobile-peer-path span + span::before { content: '·'; margin-right: 12px; }
|
||||
.node-drawer-disclosure summary { display: flex; align-items: center; gap: 12px; padding: 12px 16px; cursor: pointer; list-style: none; font-size: 14px; font-weight: 600; }
|
||||
.node-drawer-disclosure summary::-webkit-details-marker { display: none; }
|
||||
.node-drawer-disclosure summary:hover { background: var(--node-soft); }
|
||||
.node-drawer-disclosure summary:focus-visible { outline: 2px solid var(--p-primary-color); outline-offset: -3px; }
|
||||
.node-drawer-disclosure-count { margin-left: auto; color: var(--node-muted); font-size: 12px; font-weight: 500; }
|
||||
.node-drawer-disclosure summary .pi { color: var(--node-muted); font-size: 11px; transition: transform .15s; }
|
||||
.node-drawer-disclosure[open] summary .pi { transform: rotate(180deg); }
|
||||
.node-drawer-disclosure .console-table { border-top: 1px solid var(--node-border); }
|
||||
.node-drawer-actions { gap: 0; }
|
||||
.node-drawer-action { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 14px; padding: 16px; }
|
||||
.node-drawer-action + .node-drawer-action { margin-top: 10px; }
|
||||
.node-drawer-action > div { min-width: 0; flex: 1; }
|
||||
.node-drawer-action .muted { margin-top: 5px; line-height: 1.5; }
|
||||
.node-drawer-action .p-select { min-width: 10rem; }
|
||||
.console-node-drawer .console-table .p-datatable-header-cell { background: var(--node-soft); }
|
||||
.console-node-drawer .console-table .p-datatable-tbody > tr > td { border-color: var(--node-border); }
|
||||
.drawer-heading { min-width: 0; }
|
||||
.drawer-heading h2 { font-size: 18px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.drawer-heading p { color: var(--p-text-muted-color); font-size: 12px; }
|
||||
.web-console a:focus-visible, .console-mobile-nav a:focus-visible, .entity-link:focus-visible { outline: 2px solid var(--p-primary-color); outline-offset: 3px; border-radius: 4px; }
|
||||
.mobile-list { display: none; }
|
||||
.console-dialog.p-dialog { max-width: calc(100vw - 24px); }
|
||||
.device-details { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 32px; display: grid; }
|
||||
.device-details .detail-label { color: var(--console-muted); font-size: 12px; }
|
||||
.device-details .detail-value { font-size: 13px; }
|
||||
.device-details .detail-item { border-bottom: 0; }
|
||||
@media (max-width: 767px) { .device-details { grid-template-columns: 1fr; } }
|
||||
|
||||
html.app-dark { --surface-ground: var(--p-surface-950); }
|
||||
html.app-dark .web-console { --console-ground: var(--p-surface-950); --console-panel: var(--p-surface-900); --console-border: var(--p-surface-800); --console-muted: var(--p-surface-400); --console-stripe: var(--p-surface-800); }
|
||||
html.app-dark .console-node-drawer.p-drawer { --node-soft: var(--p-surface-800); }
|
||||
@media (max-width: 1100px) {
|
||||
.console-content { padding: 28px 24px; }
|
||||
.console-topbar { padding-inline: 24px; }
|
||||
.overview-columns { grid-template-columns: 1fr; }
|
||||
.summary-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.summary-stat:nth-child(2n) { border-right: 0; }
|
||||
.summary-stat:nth-child(-n+2) { border-bottom: 1px solid var(--console-border); }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.console-sidebar { display: none; }
|
||||
.console-workspace { margin-left: 0; }
|
||||
.web-console .mobile-nav-toggle { display: inline-flex; }
|
||||
.topbar-brand { display: none; }
|
||||
.console-topbar { padding: max(10px, env(safe-area-inset-top)) 12px 10px; }
|
||||
.console-content { padding: 24px 16px; }
|
||||
.console-page { gap: 20px; }
|
||||
.page-heading { align-items: flex-start; }
|
||||
.page-heading h1 { font-size: 23px; }
|
||||
.summary-stat { padding: 16px 12px; }
|
||||
.summary-label > .pi { display: none; }
|
||||
.summary-value { font-size: 28px; }
|
||||
.summary-note { font-size: 11px; }
|
||||
.desktop-list { display: none; }
|
||||
.mobile-list { display: block; }
|
||||
.view-toggle { display: none; }
|
||||
.mobile-list-item { padding: 16px; border-bottom: 1px solid var(--console-border); }
|
||||
.mobile-list-item:last-child { border-bottom: 0; }
|
||||
.mobile-list-item summary { cursor: pointer; font-size: 12px; color: var(--console-muted); margin-top: 10px; }
|
||||
.mobile-list-item .device-details { margin-top: 16px; grid-template-columns: 1fr; }
|
||||
.console-device-drawer.p-drawer { width: 100%; }
|
||||
.console-device-drawer .p-drawer-content { padding: 16px; }
|
||||
.console-device-drawer .p-drawer-header { padding: max(16px, env(safe-area-inset-top)) 16px 16px; }
|
||||
.console-node-drawer .p-drawer-header { padding: max(14px, env(safe-area-inset-top)) 16px 14px; }
|
||||
.node-drawer-heading { gap: 10px; }
|
||||
.node-drawer-icon { width: 30px; height: 30px; font-size: 14px; }
|
||||
.node-drawer-title { font-size: 18px; }
|
||||
.node-drawer-presence { margin-top: 4px; }
|
||||
.node-drawer-main { padding: 14px 16px 20px; }
|
||||
.node-drawer-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 8px; row-gap: 6px; padding-bottom: 12px; }
|
||||
.node-drawer-metric, .node-drawer-metric:first-child { padding: 0; border-right: 0; }
|
||||
.node-drawer-tabs .p-tabpanels { padding-top: 10px; }
|
||||
.node-drawer-desktop-peers { display: none; }
|
||||
.node-drawer-mobile-peers { display: block; }
|
||||
.network-tabs > .p-tabpanels { padding: 16px; }
|
||||
.list-toolbar { padding: 12px; }
|
||||
.search-field { max-width: none; }
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import 'easytier-frontend-lib/style.css'
|
||||
import './style.css'
|
||||
import './console.css'
|
||||
import App from './App.vue'
|
||||
import EasytierFrontendLib from 'easytier-frontend-lib'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
import ConsoleTheme from './theme';
|
||||
import { initThemeMode } from './modules/theme';
|
||||
import ConfirmationService from 'primevue/confirmationservice';
|
||||
import { I18nUtils } from 'easytier-frontend-lib'
|
||||
|
||||
@@ -14,6 +16,8 @@ import Login from './components/Login.vue'
|
||||
import DeviceList from './components/DeviceList.vue'
|
||||
import DeviceManagement from './components/DeviceManagement.vue'
|
||||
import Dashboard from './components/Dashboard.vue'
|
||||
import NetworkList from './components/NetworkList.vue'
|
||||
import NetworkDetail from './components/NetworkDetail.vue'
|
||||
import DialogService from 'primevue/dialogservice';
|
||||
import ToastService from 'primevue/toastservice';
|
||||
|
||||
@@ -36,7 +40,7 @@ const routes = [
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/h/:apiHost', component: MainPage, children: [
|
||||
path: '/h', component: MainPage, children: [
|
||||
{
|
||||
path: '',
|
||||
alias: 'dashboard',
|
||||
@@ -55,17 +59,20 @@ const routes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'networks',
|
||||
name: 'networkList',
|
||||
component: NetworkList,
|
||||
},
|
||||
{
|
||||
path: 'networks/:networkId',
|
||||
name: 'networkDetail',
|
||||
component: NetworkDetail,
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*', name: 'notFound', redirect: () => {
|
||||
let apiHost = localStorage.getItem('apiHost');
|
||||
if (apiHost) {
|
||||
return { name: 'dashboard', params: { apiHost: apiHost } }
|
||||
} else {
|
||||
return { name: 'login' }
|
||||
}
|
||||
}
|
||||
path: '/:pathMatch(.*)*', name: 'notFound', redirect: { name: 'dashboard' }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -78,14 +85,16 @@ const app = createApp(App)
|
||||
|
||||
// Use i18n
|
||||
app.use(I18nUtils.i18n)
|
||||
|
||||
// Apply the Web-specific PrimeVue theme first; register the shared
|
||||
// component library without its built-in theme so our dark mode selector
|
||||
// is the only one that takes effect.
|
||||
app.use(PrimeVue,
|
||||
{
|
||||
theme: {
|
||||
preset: Aura,
|
||||
preset: ConsoleTheme,
|
||||
options: {
|
||||
prefix: 'p',
|
||||
darkModeSelector: 'system',
|
||||
darkModeSelector: '.app-dark',
|
||||
cssLayer: {
|
||||
name: 'primevue',
|
||||
order: 'tailwind-base, primevue, tailwind-utilities'
|
||||
@@ -93,4 +102,9 @@ app.use(PrimeVue,
|
||||
}
|
||||
}
|
||||
}
|
||||
).use(ToastService as any).use(DialogService as any).use(router).use(ConfirmationService as any).use(EasytierFrontendLib).mount('#app')
|
||||
).use(ToastService as any).use(DialogService as any).use(router).use(ConfirmationService as any)
|
||||
|
||||
app.use(EasytierFrontendLib, { skipPrimeVue: true })
|
||||
|
||||
initThemeMode()
|
||||
app.mount('#app')
|
||||
@@ -1,8 +1,6 @@
|
||||
interface ApiHost {
|
||||
value: string;
|
||||
usedAt: number;
|
||||
}
|
||||
|
||||
// The API base is decided by deployment, not by user input:
|
||||
// - embedded / same-origin serving: relative requests ('')
|
||||
// - split deploy with --api-host: injected as window.apiMeta by the server
|
||||
let apiMeta: {
|
||||
api_host: string;
|
||||
} | undefined = (window as any).apiMeta;
|
||||
@@ -10,62 +8,6 @@ let apiMeta: {
|
||||
// remove trailing slashes from the URL
|
||||
const cleanUrl = (url: string) => url.replace(/\/+$/, '');
|
||||
|
||||
const defaultApiHost = cleanUrl(apiMeta?.api_host ?? `${location.origin}${location.pathname}`);
|
||||
const getApiBase = (): string => cleanUrl(apiMeta?.api_host ?? '');
|
||||
|
||||
const isValidHttpUrl = (s: string): boolean => {
|
||||
let url;
|
||||
|
||||
try {
|
||||
url = new URL(s);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
};
|
||||
|
||||
const cleanAndLoadApiHosts = (): Array<ApiHost> => {
|
||||
const maxHosts = 10;
|
||||
const apiHosts = localStorage.getItem('apiHosts');
|
||||
if (apiHosts) {
|
||||
const hosts: Array<ApiHost> = JSON.parse(apiHosts);
|
||||
// sort by usedAt
|
||||
hosts.sort((a, b) => b.usedAt - a.usedAt);
|
||||
|
||||
// only keep the first 10
|
||||
if (hosts.length > maxHosts) {
|
||||
hosts.splice(maxHosts);
|
||||
}
|
||||
|
||||
localStorage.setItem('apiHosts', JSON.stringify(hosts));
|
||||
return hosts;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const saveApiHost = (host: string) => {
|
||||
console.log('Save API Host:', host);
|
||||
if (!isValidHttpUrl(host)) {
|
||||
console.error('Invalid API Host:', host);
|
||||
return;
|
||||
}
|
||||
|
||||
let hosts = cleanAndLoadApiHosts();
|
||||
const newHost: ApiHost = { value: host, usedAt: Date.now() };
|
||||
hosts = hosts.filter((h) => h.value !== host);
|
||||
hosts.push(newHost);
|
||||
localStorage.setItem('apiHosts', JSON.stringify(hosts));
|
||||
};
|
||||
|
||||
const getInitialApiHost = (): string => {
|
||||
const hosts = cleanAndLoadApiHosts();
|
||||
if (hosts.length > 0) {
|
||||
return hosts[0].value;
|
||||
} else {
|
||||
saveApiHost(defaultApiHost)
|
||||
return defaultApiHost;
|
||||
}
|
||||
};
|
||||
|
||||
export { getInitialApiHost, cleanAndLoadApiHosts, saveApiHost }
|
||||
export { getApiBase }
|
||||
@@ -59,6 +59,156 @@ export interface ParseConfigResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CentralNetworkSettings {
|
||||
display_name: string;
|
||||
network_name?: string | null;
|
||||
networking_method: string;
|
||||
public_server_url?: string | null;
|
||||
peer_urls: string[];
|
||||
virtual_cidr?: string | null;
|
||||
secure_mode?: boolean;
|
||||
}
|
||||
|
||||
export interface CentralNetworkSummary {
|
||||
network_id: string;
|
||||
display_name: string;
|
||||
network_name: string;
|
||||
networking_method: string;
|
||||
virtual_cidr?: string | null;
|
||||
secure_mode?: boolean;
|
||||
member_count: number;
|
||||
online_member_count: number;
|
||||
}
|
||||
|
||||
export interface CentralNetworkDetail extends CentralNetworkSummary {
|
||||
network_secret: string;
|
||||
virtual_cidr?: string | null;
|
||||
public_server_url?: string | null;
|
||||
peer_urls: string[];
|
||||
}
|
||||
|
||||
export interface CentralNetworkMember {
|
||||
member_id: string;
|
||||
device_id: string;
|
||||
hostname: string | null;
|
||||
hostname_override: string | null;
|
||||
alias?: string | null;
|
||||
virtual_ipv4: string | null;
|
||||
allocated_ipv4?: string | null;
|
||||
online: boolean;
|
||||
running: boolean | null;
|
||||
runtime_virtual_ipv4: string | null;
|
||||
version: string | null;
|
||||
error_msg: string | null;
|
||||
has_override?: boolean;
|
||||
proxy_cidrs?: string[];
|
||||
temporary?: boolean;
|
||||
credential_id?: string | null;
|
||||
credential_expiry_unix?: number | null;
|
||||
}
|
||||
|
||||
export interface GatewayInfo {
|
||||
enabled: boolean;
|
||||
peer_url?: string;
|
||||
relay_data: boolean;
|
||||
}
|
||||
|
||||
export interface NodeRouteInfo {
|
||||
peer_id: number;
|
||||
hostname: string;
|
||||
ipv4_addr?: { address?: { addr?: number }, network_length?: number } | null;
|
||||
cost: number;
|
||||
path_latency: number;
|
||||
proxy_cidrs: string[];
|
||||
version: string;
|
||||
next_hop_peer_id: number;
|
||||
}
|
||||
|
||||
export interface NodePeerConn {
|
||||
conn_id: string;
|
||||
tunnel?: { tunnel_type?: string, local_addr?: any, remote_addr?: any } | null;
|
||||
stats?: { latency_us?: number, rx_bytes?: number, tx_bytes?: number } | null;
|
||||
loss_rate?: number;
|
||||
is_closed?: boolean;
|
||||
}
|
||||
|
||||
export interface NodePeerInfo {
|
||||
peer_id: number;
|
||||
conns: NodePeerConn[];
|
||||
}
|
||||
|
||||
export interface NodeAclRuleStat {
|
||||
rule?: { name?: string };
|
||||
stat?: { packet_count?: number, byte_count?: number };
|
||||
}
|
||||
|
||||
export interface NetworkCredential {
|
||||
credential_id: string;
|
||||
credential_secret: string;
|
||||
expiry_unix: number;
|
||||
reusable: boolean;
|
||||
online_peers: TemporaryPeer[];
|
||||
}
|
||||
|
||||
/// A device currently online through a credential; `credential_id` is null
|
||||
/// when it matches no stored credential anymore (e.g. revoked mid-flight).
|
||||
export interface TemporaryPeer {
|
||||
peer_id: number;
|
||||
credential_id: string | null;
|
||||
credential_expiry_unix: number | null;
|
||||
hostname: string | null;
|
||||
ipv4: string | null;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
export interface BlockedDevice {
|
||||
id: string;
|
||||
user_id: number;
|
||||
hostname: string;
|
||||
alias?: string | null;
|
||||
blocked_time: string;
|
||||
attempt_count: number;
|
||||
last_attempt_time?: string | null;
|
||||
}
|
||||
|
||||
export interface ConsoleInfo {
|
||||
username: string;
|
||||
config_server_protocol: string;
|
||||
config_server_port: number;
|
||||
webhook_auth: boolean;
|
||||
}
|
||||
|
||||
export type AclSelector =
|
||||
| { type: 'all' }
|
||||
| { type: 'member'; member_id: string }
|
||||
| { type: 'subnet'; member_id: string; cidrs: string[] }
|
||||
| { type: 'group'; name: string };
|
||||
|
||||
export interface AclProtocolTarget {
|
||||
protocol: 'tcp' | 'udp' | 'icmp' | 'icmpv6' | 'any';
|
||||
ports: string[];
|
||||
stateful: boolean;
|
||||
}
|
||||
|
||||
export interface AclPolicyRule {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
action: 'allow' | 'deny';
|
||||
sources: AclSelector[];
|
||||
destinations: AclSelector[];
|
||||
protocols: AclProtocolTarget[];
|
||||
}
|
||||
|
||||
export interface AclPolicy {
|
||||
default_action: 'allow' | 'deny';
|
||||
rules: AclPolicyRule[];
|
||||
}
|
||||
|
||||
export interface AclPolicyInfo {
|
||||
policy: AclPolicy;
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
private authFailedCb: Function | undefined;
|
||||
@@ -169,11 +319,197 @@ export class ApiClient {
|
||||
return response.machines;
|
||||
}
|
||||
|
||||
public async delete_machine(machine_id: string, block = false): Promise<undefined> {
|
||||
await this.client.delete(`/machines/${machine_id}`, { params: block ? { block: true } : {} });
|
||||
}
|
||||
|
||||
public async update_machine_alias(machine_id: string, alias: string): Promise<undefined> {
|
||||
await this.client.put(`/machines/${machine_id}/alias`, { alias });
|
||||
}
|
||||
|
||||
public async list_blocked_devices(): Promise<BlockedDevice[]> {
|
||||
const response = await this.client.get<any, { blocked: BlockedDevice[] }>('/blocked-devices');
|
||||
return response.blocked;
|
||||
}
|
||||
|
||||
public async unblock_device(machine_id: string): Promise<undefined> {
|
||||
await this.client.delete(`/blocked-devices/${machine_id}`);
|
||||
}
|
||||
|
||||
public async get_console_info(): Promise<ConsoleInfo> {
|
||||
const response = await this.client.get<any, ConsoleInfo>('/console-info');
|
||||
return response;
|
||||
}
|
||||
|
||||
public async get_summary(): Promise<Summary> {
|
||||
const response = await this.client.get<any, Summary>('/summary');
|
||||
return response;
|
||||
}
|
||||
|
||||
// --- Central networks ---
|
||||
|
||||
public async list_networks(): Promise<CentralNetworkSummary[]> {
|
||||
const response = await this.client.get<any, { networks: CentralNetworkSummary[] }>('/networks');
|
||||
return response.networks;
|
||||
}
|
||||
|
||||
public async create_network(settings: CentralNetworkSettings, network_secret?: string): Promise<CentralNetworkDetail> {
|
||||
return await this.client.post<any, CentralNetworkDetail>('/networks', {
|
||||
settings,
|
||||
network_secret,
|
||||
});
|
||||
}
|
||||
|
||||
public async get_network(network_id: string): Promise<CentralNetworkDetail> {
|
||||
return await this.client.get<any, CentralNetworkDetail>(`/networks/${network_id}`);
|
||||
}
|
||||
|
||||
public async update_network(network_id: string, settings: CentralNetworkSettings, network_secret?: string): Promise<CentralNetworkDetail> {
|
||||
return await this.client.patch<any, CentralNetworkDetail>(`/networks/${network_id}`, {
|
||||
settings,
|
||||
network_secret,
|
||||
});
|
||||
}
|
||||
|
||||
public async delete_network(network_id: string): Promise<undefined> {
|
||||
await this.client.delete(`/networks/${network_id}`);
|
||||
}
|
||||
|
||||
public async list_network_members(network_id: string): Promise<{ members: CentralNetworkMember[]; temporary_peers: TemporaryPeer[] }> {
|
||||
const response = await this.client.get<any, { members: CentralNetworkMember[]; temporary_peers?: TemporaryPeer[] }>(`/networks/${network_id}/members`);
|
||||
return { members: response.members, temporary_peers: response.temporary_peers ?? [] };
|
||||
}
|
||||
|
||||
public async add_network_members(network_id: string, device_ids: string[], temporary = false, ttl_seconds?: number): Promise<undefined> {
|
||||
await this.client.post(`/networks/${network_id}/members`, { device_ids, temporary, ttl_seconds });
|
||||
}
|
||||
|
||||
public async get_network_acl_policy(network_id: string): Promise<AclPolicyInfo> {
|
||||
const response = await this.client.get<any, AclPolicyInfo>(`/networks/${network_id}/acl-policy`);
|
||||
return response;
|
||||
}
|
||||
|
||||
public async update_network_acl_policy(network_id: string, policy: AclPolicy): Promise<AclPolicyInfo> {
|
||||
const response = await this.client.put<any, AclPolicyInfo>(`/networks/${network_id}/acl-policy`, policy);
|
||||
return response;
|
||||
}
|
||||
|
||||
public async update_network_member(network_id: string, device_id: string, update: { hostname_override?: string | null, virtual_ipv4?: string | null, proxy_cidrs?: string[] }): Promise<CentralNetworkMember> {
|
||||
return await this.client.patch<any, CentralNetworkMember>(`/networks/${network_id}/members/${device_id}`, update);
|
||||
}
|
||||
|
||||
public async remove_network_member(network_id: string, device_id: string): Promise<undefined> {
|
||||
await this.client.delete(`/networks/${network_id}/members/${device_id}`);
|
||||
}
|
||||
|
||||
// --- Node runtime detail (per network instance, via proxy-rpc) ---
|
||||
|
||||
public async get_node_routes(machine_id: string, inst_id: string): Promise<NodeRouteInfo[]> {
|
||||
const response = await this.client.post<any, { routes?: NodeRouteInfo[] }>(`/machines/${machine_id}/proxy-rpc`, {
|
||||
service_name: 'api.instance.PeerManageRpcService',
|
||||
method_name: 'list_route',
|
||||
payload: { instance: { id: Utils.StrToUuid(inst_id) } },
|
||||
});
|
||||
return response.routes ?? [];
|
||||
}
|
||||
|
||||
public async get_node_peers(machine_id: string, inst_id: string): Promise<NodePeerInfo[]> {
|
||||
const response = await this.client.post<any, { peer_infos?: NodePeerInfo[] }>(`/machines/${machine_id}/proxy-rpc`, {
|
||||
service_name: 'api.instance.PeerManageRpcService',
|
||||
method_name: 'list_peer',
|
||||
payload: { instance: { id: Utils.StrToUuid(inst_id) } },
|
||||
});
|
||||
return response.peer_infos ?? [];
|
||||
}
|
||||
|
||||
public async get_node_acl_stats(machine_id: string, inst_id: string): Promise<NodeAclRuleStat[]> {
|
||||
const response = await this.client.post<any, { acl_stats?: { rules?: NodeAclRuleStat[] } }>(`/machines/${machine_id}/proxy-rpc`, {
|
||||
service_name: 'api.instance.AclManageRpcService',
|
||||
method_name: 'get_acl_stats',
|
||||
payload: { instance: { id: Utils.StrToUuid(inst_id) } },
|
||||
});
|
||||
return response.acl_stats?.rules ?? [];
|
||||
}
|
||||
|
||||
public async get_member_config(network_id: string, device_id: string): Promise<any> {
|
||||
return await this.client.get(`/networks/${network_id}/members/${device_id}/config`);
|
||||
}
|
||||
|
||||
public async set_member_config(network_id: string, device_id: string, config: object): Promise<CentralNetworkMember> {
|
||||
return await this.client.put(`/networks/${network_id}/members/${device_id}/config`, { config });
|
||||
}
|
||||
|
||||
public async clear_member_config(network_id: string, device_id: string): Promise<undefined> {
|
||||
await this.client.delete(`/networks/${network_id}/members/${device_id}/config`);
|
||||
}
|
||||
|
||||
/// Full TOML config of the member's instance (what `easytier-cli node
|
||||
/// config` prints), via proxy-rpc.
|
||||
public async get_node_toml_config(machine_id: string, inst_id: string): Promise<string> {
|
||||
const response = await this.client.post<any, { node_info?: { config?: string } }>(`/machines/${machine_id}/proxy-rpc`, {
|
||||
service_name: 'api.instance.PeerManageRpcService',
|
||||
method_name: 'show_node_info',
|
||||
payload: { instance: { id: Utils.StrToUuid(inst_id) } },
|
||||
});
|
||||
return response.node_info?.config ?? '';
|
||||
}
|
||||
|
||||
/// Device-wide logger level (0=disabled .. 5=trace), via proxy-rpc.
|
||||
public async get_node_logger_level(machine_id: string): Promise<number> {
|
||||
const response = await this.client.post<any, { level?: number | string }>(`/machines/${machine_id}/proxy-rpc`, {
|
||||
service_name: 'api.logger.LoggerRpcService',
|
||||
method_name: 'get_logger_config',
|
||||
payload: {},
|
||||
});
|
||||
const level = response.level ?? 0;
|
||||
return typeof level === 'number' ? level
|
||||
: ['DISABLED', 'ERROR', 'WARNING', 'INFO', 'DEBUG', 'TRACE'].indexOf(level);
|
||||
}
|
||||
|
||||
public async set_node_logger_level(machine_id: string, level: number): Promise<undefined> {
|
||||
await this.client.post(`/machines/${machine_id}/proxy-rpc`, {
|
||||
service_name: 'api.logger.LoggerRpcService',
|
||||
method_name: 'set_logger_config',
|
||||
payload: { level },
|
||||
});
|
||||
}
|
||||
|
||||
/// Interface IPv4 addresses reported by the device (per instance),
|
||||
/// used to suggest selectable subnets in the member editor.
|
||||
public async get_node_interface_ips(machine_id: string, inst_id: string): Promise<number[]> {
|
||||
const response = await this.client.post<any, {
|
||||
node_info?: { ip_list?: { interface_ipv4s?: Array<{ addr?: number }> } },
|
||||
}>(`/machines/${machine_id}/proxy-rpc`, {
|
||||
service_name: 'api.instance.PeerManageRpcService',
|
||||
method_name: 'show_node_info',
|
||||
payload: { instance: { id: Utils.StrToUuid(inst_id) } },
|
||||
});
|
||||
return (response.node_info?.ip_list?.interface_ipv4s ?? [])
|
||||
.map(entry => entry.addr)
|
||||
.filter((addr): addr is number => addr != null);
|
||||
}
|
||||
|
||||
public async list_credentials(network_id: string): Promise<NetworkCredential[]> {
|
||||
const response = await this.client.get<any, { credentials: NetworkCredential[] }>(`/networks/${network_id}/credentials`);
|
||||
return response.credentials;
|
||||
}
|
||||
|
||||
public async generate_credential(network_id: string, ttl_seconds: number, reusable = true, credential_id?: string): Promise<NetworkCredential> {
|
||||
return await this.client.post<any, NetworkCredential>(`/networks/${network_id}/credentials`, {
|
||||
ttl_seconds,
|
||||
reusable,
|
||||
credential_id,
|
||||
});
|
||||
}
|
||||
|
||||
public async revoke_credential(network_id: string, credential_id: string): Promise<undefined> {
|
||||
await this.client.delete(`/networks/${network_id}/credentials/${encodeURIComponent(credential_id)}`);
|
||||
}
|
||||
|
||||
public async get_gateway_info(): Promise<GatewayInfo> {
|
||||
return await this.client.get<any, GatewayInfo>('/networks/gateway-info');
|
||||
}
|
||||
|
||||
public captcha_url() {
|
||||
return this.client.defaults.baseURL + '/auth/captcha';
|
||||
}
|
||||
@@ -238,20 +574,9 @@ class WebRemoteClient implements Api.RemoteClient {
|
||||
: undefined;
|
||||
}
|
||||
async patch_vpn_portal_clients(inst_id: string, patches: Array<Record<string, any>>): Promise<undefined> {
|
||||
await this.client.post(
|
||||
`/machines/${this.machine_id}/proxy-rpc`,
|
||||
{
|
||||
service_name: 'api.config.ConfigRpcService',
|
||||
method_name: 'patch_config',
|
||||
payload: {
|
||||
instance: {
|
||||
id: Utils.StrToUuid(inst_id),
|
||||
},
|
||||
patch: {
|
||||
vpn_portal_clients: patches,
|
||||
},
|
||||
},
|
||||
},
|
||||
await this.client.patch(
|
||||
`/machines/${this.machine_id}/networks/${inst_id}/vpn-portal-clients`,
|
||||
{ patches },
|
||||
);
|
||||
}
|
||||
async add_vpn_portal_client(inst_id: string, client: { name: string, virtual_ip: string, groups: string[] }): Promise<undefined> {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Console theme mode: light / dark / system, persisted in localStorage and
|
||||
// applied by toggling the `app-dark` class PrimeVue and Tailwind key on.
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
const STORAGE_KEY = 'console-theme-mode';
|
||||
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
export function storedThemeMode(): ThemeMode {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored === 'light' || stored === 'dark' ? stored : 'system';
|
||||
}
|
||||
|
||||
export function applyThemeMode(mode: ThemeMode) {
|
||||
localStorage.setItem(STORAGE_KEY, mode);
|
||||
const dark = mode === 'dark' || (mode === 'system' && media.matches);
|
||||
document.documentElement.classList.toggle('app-dark', dark);
|
||||
}
|
||||
|
||||
export function initThemeMode() {
|
||||
applyThemeMode(storedThemeMode());
|
||||
media.addEventListener('change', () => {
|
||||
if (storedThemeMode() === 'system') {
|
||||
applyThemeMode('system');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -49,4 +49,5 @@
|
||||
|
||||
.device-manage-drawer {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
}
|
||||
}
|
||||
html.app-dark { color-scheme: dark; }
|
||||
@@ -0,0 +1,27 @@
|
||||
import { definePreset } from '@primeuix/themes';
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
|
||||
const surface = {
|
||||
0: '#ffffff', 50: '#f7f8f9', 100: '#f0f2f4', 200: '#e3e6e9',
|
||||
300: '#d0d5da', 400: '#9ba4ae', 500: '#697581', 600: '#4e5965',
|
||||
700: '#38434e', 800: '#242e38', 900: '#18212a', 950: '#10171e',
|
||||
};
|
||||
|
||||
export default definePreset(Aura, {
|
||||
semantic: {
|
||||
primary: {
|
||||
50: '{emerald.50}', 100: '{emerald.100}', 200: '{emerald.200}',
|
||||
300: '{emerald.300}', 400: '{emerald.400}', 500: '{emerald.500}',
|
||||
600: '{emerald.600}', 700: '{emerald.700}', 800: '{emerald.800}',
|
||||
900: '{emerald.900}', 950: '{emerald.950}',
|
||||
},
|
||||
colorScheme: {
|
||||
light: {
|
||||
surface,
|
||||
primary: { color: '{primary.700}', inverseColor: '#ffffff', hoverColor: '{primary.800}', activeColor: '{primary.900}' },
|
||||
highlight: { background: '{primary.50}', focusBackground: '{primary.100}', color: '{primary.700}', focusColor: '{primary.800}' },
|
||||
},
|
||||
dark: { surface },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -4,6 +4,7 @@ export default {
|
||||
'./index.html',
|
||||
'./src/**/*.{vue,js,ts,jsx,tsx}',
|
||||
],
|
||||
darkMode: ['class', '.app-dark'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
// Real central-management coverage. Requires built Web (embed), Core and CLI
|
||||
// binaries and the privileged `rust` container with the shared /data mount.
|
||||
// Results and process logs are retained in .test-env/central-e2e-* for diagnosis.
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { createServer } from 'node:net';
|
||||
import { resolve, join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const run = String(Date.now()).slice(-7);
|
||||
const output = join(repo, '.test-env', `central-e2e-${run}`);
|
||||
await mkdir(output, { recursive: true });
|
||||
console.log(`ARTIFACTS=${output}`);
|
||||
const webBin = join(repo, 'target/debug/easytier-web');
|
||||
const coreBin = join(repo, 'target/debug/easytier-core');
|
||||
const username = `audit-${run}`;
|
||||
const password = 'e2e-password';
|
||||
const passwordHash = '$argon2id$v=19$m=4096,t=3,p=1$ZWFzeXRpZXItZTJlLXNhbHQ$FHbDjVElaTXuArgEEQrQrO4AdsnqNOcXGA5PFY8cG/s';
|
||||
const database = join(output, 'web.db');
|
||||
const results = [];
|
||||
const processes = [];
|
||||
const namespaces = [];
|
||||
const bridge = `ce${run}`;
|
||||
const subnet = `10.245.${170 + Math.floor(Math.random() * 50)}`;
|
||||
const gatewayIp = `${subnet}.1`;
|
||||
let bridgeCreated = false;
|
||||
let browser, context, page, web;
|
||||
const nodes = [];
|
||||
let network;
|
||||
|
||||
function docker(args, { allowFailure = false, input, timeout = 20000 } = {}) {
|
||||
const r = spawnSync('docker', ['exec', ...(input === undefined ? [] : ['-i']), 'rust', ...args], { encoding: 'utf8', input, timeout });
|
||||
if (!allowFailure && r.status !== 0) throw new Error(`docker ${args.join(' ')}: ${r.stderr || r.stdout || r.error}`);
|
||||
return allowFailure ? r : r.stdout.trim();
|
||||
}
|
||||
function ns(node, args, options) { return docker(['ip', 'netns', 'exec', node.ns, ...args], options); }
|
||||
async function waitFor(check, label, timeout = 45000) {
|
||||
const end = Date.now() + timeout;
|
||||
let last;
|
||||
while (Date.now() < end) {
|
||||
try { const value = await check(); if (value) return value; } catch (error) { last = error.message; }
|
||||
await delay(300);
|
||||
}
|
||||
throw new Error(`Timed out: ${label}${last ? `; last error: ${last}` : ''}`);
|
||||
}
|
||||
async function step(ids, name, action) {
|
||||
const started = new Date().toISOString();
|
||||
try {
|
||||
const evidence = await action();
|
||||
results.push({ ids, name, status: 'PASS', started, evidence: evidence ?? 'assertions passed' });
|
||||
console.log(`PASS ${ids.join(',')} ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
results.push({ ids, name, status: 'FAIL', started, error: error.stack });
|
||||
console.error(`FAIL ${ids.join(',')} ${name}: ${error.message}`);
|
||||
if (network && context) await writeFile(join(output, `failure-${results.length}-members.json`), JSON.stringify(await members().catch(() => null), null, 2));
|
||||
if (network && context) for (const node of nodes.slice(0, 3)) {
|
||||
const config = await runtimeConfig(node).catch(() => null);
|
||||
const evidence = {
|
||||
proxy_cidrs: config?.proxy_cidrs,
|
||||
grants: config?.managed_credentials?.map(c => ({ credential_id: c.credential_id, allowed_proxy_cidrs: c.allowed_proxy_cidrs })),
|
||||
routes: await rpc(node, network.network_id, 'list_route').catch(() => null),
|
||||
};
|
||||
await writeFile(join(output, `failure-${results.length}-${node.name}-routes.json`), JSON.stringify(evidence, null, 2));
|
||||
}
|
||||
if (page) await page.screenshot({ path: join(output, `failure-${results.length}.png`), fullPage: true }).catch(() => {});
|
||||
return false;
|
||||
} finally {
|
||||
await writeFile(join(output, 'results.json'), JSON.stringify(results, null, 2));
|
||||
}
|
||||
}
|
||||
async function freePort() {
|
||||
return new Promise((resolvePort, reject) => {
|
||||
const server = createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '0.0.0.0', () => { const port = server.address().port; server.close(() => resolvePort(port)); });
|
||||
});
|
||||
}
|
||||
function processLog(child, label, pidFile) {
|
||||
let logs = '';
|
||||
for (const stream of [child.stdout, child.stderr]) stream.on('data', data => { logs += data; });
|
||||
const p = { child, label, pidFile, logs: () => logs };
|
||||
processes.push(p);
|
||||
return p;
|
||||
}
|
||||
async function dockerProcess(label, args) {
|
||||
const pidFile = join(output, `${label}.pid`);
|
||||
return processLog(spawn('docker', ['exec', 'rust', 'sh', '-c', 'echo $$ > "$1"; shift; exec "$@"', 'audit', pidFile, ...args], { stdio: ['ignore', 'pipe', 'pipe'] }), label, pidFile);
|
||||
}
|
||||
async function stop(p) {
|
||||
if (!p || p.stopped) return;
|
||||
if (p.pidFile) {
|
||||
const pid = await readFile(p.pidFile, 'utf8').catch(() => '');
|
||||
if (pid.trim()) docker(['kill', '-INT', pid.trim()], { allowFailure: true });
|
||||
} else p.child.kill('SIGINT');
|
||||
if (p.child.exitCode === null) await Promise.race([new Promise(r => p.child.once('exit', r)), delay(3000)]);
|
||||
if (p.child.exitCode === null && p.pidFile) {
|
||||
const pid = await readFile(p.pidFile, 'utf8').catch(() => '');
|
||||
if (pid.trim()) docker(['kill', '-KILL', pid.trim()], { allowFailure: true });
|
||||
}
|
||||
if (p.child.exitCode === null) p.child.kill('SIGTERM');
|
||||
p.stopped = true;
|
||||
await writeFile(join(output, `${p.label}.log`), p.logs());
|
||||
}
|
||||
const apiPort = await freePort();
|
||||
const configPort = await freePort();
|
||||
const proxyPort = await freePort();
|
||||
const base = `http://127.0.0.1:${apiPort}`;
|
||||
const peerUrl = `tcp://${gatewayIp}:${proxyPort}`;
|
||||
function startWeb(relay = true) {
|
||||
return processLog(spawn(webBin, ['--db', database, '--api-server-addr', '127.0.0.1', '--api-server-port', String(apiPort), '--config-server-protocol', 'tcp', '--config-server-port', String(configPort), '--gateway-peer-url', peerUrl, ...(relay ? ['--gateway-relay-data'] : []), '--console-log-level', 'error'], { stdio: ['ignore', 'pipe', 'pipe'] }), `web-${processes.length}`);
|
||||
}
|
||||
async function request(method, path, data, expected = 200) {
|
||||
const r = await context.request.fetch(`/api/v1${path}`, { method, ...(data === undefined ? {} : { data }) });
|
||||
const text = await r.text();
|
||||
assert.equal(r.status(), expected, `${method} ${path}: ${text}`);
|
||||
return text ? JSON.parse(text) : undefined;
|
||||
}
|
||||
function protoUuid(value) {
|
||||
const hex = value.replaceAll('-', '');
|
||||
return Object.fromEntries([0, 1, 2, 3].map(i => [`part${i + 1}`, parseInt(hex.slice(i * 8, i * 8 + 8), 16)]));
|
||||
}
|
||||
async function rpc(node, id, method, service = 'api.instance.PeerManageRpcService', payload = {}) {
|
||||
return request('POST', `/machines/${node.id}/proxy-rpc`, { service_name: service, method_name: method, payload: { ...(id ? { instance: { id: protoUuid(id) } } : {}), ...payload } });
|
||||
}
|
||||
const memberPath = (node, id = network.network_id) => `/networks/${id}/members/${node.id}`;
|
||||
async function runtimeConfig(node, id = network.network_id) { return request('GET', `/machines/${node.id}/networks/config/${id}`); }
|
||||
async function runningInfo(node, id = network.network_id) { return (await rpc(node, id, 'show_node_info')).node_info; }
|
||||
async function members(id = network.network_id) { return (await request('GET', `/networks/${id}/members`)).members; }
|
||||
async function setOverride(node, override, id = network.network_id) {
|
||||
await request('PUT', `${memberPath(node, id)}/config`, { config: override });
|
||||
}
|
||||
function baseOverride(node) {
|
||||
return { no_tun: false, disable_ipv6: true, multi_thread: false, dev_name: `et${node.name}`, listener_urls: ['tcp://0.0.0.0:11010'], disable_udp_hole_punching: true, disable_tcp_hole_punching: true, disable_upnp: true };
|
||||
}
|
||||
async function createNetwork(name, options = {}) {
|
||||
return request('POST', '/networks', { settings: { display_name: name, network_name: `${username}-${name}`, networking_method: 'Gateway', virtual_cidr: '10.88.99.0/24', secure_mode: true, ...options } });
|
||||
}
|
||||
async function updateNetwork(settings, extra = {}) {
|
||||
network = await request('PATCH', `/networks/${network.network_id}`, { settings: { ...network, ...settings }, ...extra });
|
||||
}
|
||||
async function addNodes(selected, id = network.network_id, extra = {}) {
|
||||
await request('POST', `/networks/${id}/members`, { device_ids: selected.map(n => n.id), ...extra }, 204);
|
||||
await waitFor(async () => {
|
||||
const current = await members(id);
|
||||
return selected.every(n => current.some(m => m.device_id === n.id && m.running));
|
||||
}, 'members running');
|
||||
}
|
||||
async function startNode(node) {
|
||||
node.process = await dockerProcess(`core-${node.name}-${processes.length}`, ['ip', 'netns', 'exec', node.ns, coreBin, '--config-server', `${peerUrl}/${username}`, '--machine-id', node.id, '--config-dir', node.config, '--no-listener', '--no-tun', '--hostname', `audit-${node.name}`, '--rpc-portal', '127.0.0.1:15888', '--console-log-level', 'error']);
|
||||
await waitFor(async () => (await request('GET', '/machines')).machines.some(m => m.info?.machine_id && m.online && m.info.hostname === `audit-${node.name}`), `enroll ${node.name}`);
|
||||
}
|
||||
async function login() {
|
||||
await page.goto('/');
|
||||
await page.locator('#username').fill(username);
|
||||
await page.locator('#password input').fill(password);
|
||||
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
||||
await page.locator('.console-page').waitFor();
|
||||
await context.storageState({ path: join(output, 'browser-state.json') });
|
||||
}
|
||||
function ping(from, to, expected = true) {
|
||||
const r = ns(from, ['ping', '-c', '2', '-W', '1', to], { allowFailure: true, timeout: 5000 });
|
||||
assert.equal(r.status === 0, expected, `ping ${from.name} -> ${to}: ${r.stdout} ${r.stderr}`);
|
||||
return r.stdout;
|
||||
}
|
||||
async function eventualPing(from, to) { return waitFor(async () => ns(from, ['ping', '-c', '1', '-W', '1', to], { allowFailure: true }).status === 0, `ping ${from.name} -> ${to}`); }
|
||||
async function policy(value) { await request('PUT', `/networks/${network.network_id}/acl-policy`, value); await delay(2000); }
|
||||
function rule(id, sources, destinations, protocols, action = 'allow') { return { id, name: id, enabled: true, action, sources, destinations, protocols: protocols.map(p => ({ stateful: false, ports: [], ...p })) }; }
|
||||
|
||||
try {
|
||||
docker(['ip', 'link', 'add', bridge, 'type', 'bridge']); bridgeCreated = true;
|
||||
docker(['ip', 'addr', 'add', `${gatewayIp}/24`, 'dev', bridge]);
|
||||
docker(['ip', 'link', 'set', bridge, 'up']);
|
||||
for (const [i, name] of ['a', 'b', 'c', 'wg'].entries()) {
|
||||
const node = { name, ns: `ce-${run}-${name}`, id: randomUUID(), ip: `${subnet}.${10 + i}`, config: join(output, name) };
|
||||
await mkdir(node.config);
|
||||
docker(['ip', 'netns', 'add', node.ns]); namespaces.push(node.ns);
|
||||
const veth = `v${run}${name}`;
|
||||
docker(['ip', 'link', 'add', veth, 'type', 'veth', 'peer', 'name', 'eth0', 'netns', node.ns]);
|
||||
docker(['ip', 'link', 'set', veth, 'master', bridge]);
|
||||
docker(['ip', 'link', 'set', veth, 'up']);
|
||||
ns(node, ['ip', 'link', 'set', 'lo', 'up']);
|
||||
ns(node, ['ip', 'addr', 'add', `${node.ip}/24`, 'dev', 'eth0']);
|
||||
ns(node, ['ip', 'link', 'set', 'eth0', 'up']);
|
||||
nodes.push(node);
|
||||
}
|
||||
const route = docker(['ip', 'route']);
|
||||
const host = route.match(/default via ([\d.]+)/)?.[1];
|
||||
assert.ok(host);
|
||||
const proxyScript = join(output, 'proxy.py');
|
||||
await writeFile(proxyScript, `import socket,socketserver,threading\nclass H(socketserver.BaseRequestHandler):\n def handle(self):\n remote=socket.create_connection(('${host}',${configPort}))\n def copy(a,b):\n try:\n while True:\n data=a.recv(65536)\n if not data: break\n b.sendall(data)\n except OSError: pass\n finally:\n try: b.shutdown(socket.SHUT_WR)\n except OSError: pass\n t=threading.Thread(target=copy,args=(self.request,remote));t.start();copy(remote,self.request);t.join();remote.close()\nclass S(socketserver.ThreadingTCPServer):\n allow_reuse_address=True\n daemon_threads=True\nS(('${gatewayIp}',${proxyPort}),H).serve_forever()\n`);
|
||||
await dockerProcess('config-proxy', ['python3', proxyScript]);
|
||||
web = startWeb();
|
||||
await waitFor(async () => (await fetch(`${base}/api_meta.js`)).ok, 'Web ready');
|
||||
const db = new DatabaseSync(database);
|
||||
const user = db.prepare('INSERT INTO users(username,password) VALUES (?,?)').run(username, passwordHash);
|
||||
const group = db.prepare("SELECT id FROM groups WHERE name='users'").get();
|
||||
db.prepare('INSERT INTO users_groups(user_id,group_id) VALUES (?,?)').run(user.lastInsertRowid, group.id); db.close();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
context = await browser.newContext({ baseURL: base });
|
||||
await context.addInitScript(() => localStorage.setItem('lang', 'en'));
|
||||
page = await context.newPage();
|
||||
await login();
|
||||
await writeFile(join(output, 'environment.json'), JSON.stringify({ run, base, apiPort, configPort, peerUrl, bridge, nodes, code: spawnSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).stdout.trim() }, null, 2));
|
||||
const [a, b, c, wg] = nodes;
|
||||
await step(['D01'], 'enroll three real managed Cores', async () => { for (const n of [a, b, c]) await startNode(n); return request('GET', '/machines'); });
|
||||
await step(['N01', 'M01'], 'secure Gateway with three real members and TUN devices', async () => {
|
||||
network = await createNetwork('gateway'); await addNodes([a, b, c]);
|
||||
for (const n of [a, b, c]) await setOverride(n, baseOverride(n));
|
||||
const current = await waitFor(async () => { const current = await members(); return current.length === 3 && current.every(m => m.runtime_virtual_ipv4) ? current : false; }, 'member IPv4 addresses');
|
||||
for (const n of [a, b, c]) n.vip = current.find(m => m.device_id === n.id).runtime_virtual_ipv4.split("/")[0];
|
||||
await eventualPing(a, b.vip); return { members: current, ping: ping(a, b.vip) };
|
||||
});
|
||||
await step(['REG-DIRECT'], 'direct enabled and disabled networks survive central deletion and Web restart', async () => {
|
||||
const directIds = [randomUUID(), randomUUID()];
|
||||
for (const id of directIds) await request('POST', `/machines/${a.id}/networks`, { save: true, config: { instance_id: id, network_name: id, networking_method: 'Standalone', no_tun: true, disable_ipv6: true, multi_thread: false } });
|
||||
await request('PUT', `/machines/${a.id}/networks/${directIds[1]}`, { disabled: true });
|
||||
const extra = await createNetwork('direct-coexist', { networking_method: 'Standalone', virtual_cidr: null });
|
||||
await addNodes([a], extra.network_id);
|
||||
await request('DELETE', `/networks/${extra.network_id}`, undefined, 204);
|
||||
await delay(6500);
|
||||
await stop(web); web = startWeb(); await waitFor(async () => (await fetch(`${base}/api_meta.js`)).ok, 'Web restart with direct configs'); await login();
|
||||
await waitFor(async () => (await members()).find(m => m.device_id === a.id)?.online, 'direct device reconnected');
|
||||
await delay(6500);
|
||||
const db = new DatabaseSync(database);
|
||||
try {
|
||||
for (const [index, id] of directIds.entries()) {
|
||||
const stored = db.prepare('SELECT disabled FROM user_running_network_configs WHERE device_id = ? AND network_instance_id = ?').get(a.id, id);
|
||||
assert.ok(stored, 'direct row retained');
|
||||
assert.equal(stored.disabled, index);
|
||||
}
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM user_running_network_configs WHERE network_instance_id = ?').get(extra.network_id).n, 0);
|
||||
} finally { db.close(); }
|
||||
const state = await request('GET', `/machines/${a.id}/networks`);
|
||||
assert.ok(state.running_inst_ids.some(id => JSON.stringify(id) === JSON.stringify(protoUuid(directIds[0]))));
|
||||
assert.ok(!state.running_inst_ids.some(id => JSON.stringify(id) === JSON.stringify(protoUuid(directIds[1]))));
|
||||
for (const id of directIds) await request('DELETE', `/machines/${a.id}/networks/${id}`);
|
||||
});
|
||||
await step(['D03'], 'device alias set, clear and length validation', async () => {
|
||||
await request('PUT', `/machines/${a.id}/alias`, { alias: 'Audit Alias' }, 204);
|
||||
assert.ok((await request('GET', '/machines')).machines.some(m => m.alias === 'Audit Alias'));
|
||||
await request('PUT', `/machines/${a.id}/alias`, { alias: 'x'.repeat(65) }, 400);
|
||||
await request('PUT', `/machines/${a.id}/alias`, { alias: '' }, 204);
|
||||
await request('PUT', `/machines/${a.id}/alias`, { alias: 'Persisted Alias' }, 204);
|
||||
});
|
||||
await step(['M02'], 'member hostname and static address updates and rejections', async () => {
|
||||
await request('PATCH', memberPath(a), { hostname_override: 'member-a', virtual_ipv4: '10.88.99.101' });
|
||||
await waitFor(async () => (await runningInfo(a)).hostname === 'member-a', 'hostname update');
|
||||
await request('PATCH', memberPath(b), { virtual_ipv4: '10.88.99.101' }, 400);
|
||||
await request('PATCH', memberPath(b), { virtual_ipv4: '10.99.0.1' }, 400);
|
||||
await request('PATCH', memberPath(a), { hostname_override: '', virtual_ipv4: '' });
|
||||
await waitFor(async () => (await members()).find(m => m.device_id === a.id).runtime_virtual_ipv4?.split("/")[0] === a.vip, 'address restored');
|
||||
await eventualPing(a, b.vip);
|
||||
return ping(a, b.vip);
|
||||
});
|
||||
await step(['M04', 'M05'], 'advanced overrides and protected central identity', async () => {
|
||||
await setOverride(a, { ...baseOverride(a), mtu: 1300, instance_id: randomUUID(), network_name: 'hijack', network_secret: 'hijack', dhcp: true, virtual_ipv4: '1.2.3.4', peer_urls: ['tcp://127.0.0.1:9'] });
|
||||
await waitFor(async () => (await runtimeConfig(a)).mtu === 1300, 'MTU override');
|
||||
const actual = await runtimeConfig(a);
|
||||
assert.equal(actual.network_name, network.network_name); assert.equal(actual.instance_id, network.network_id); assert.equal(actual.virtual_ipv4, a.vip);
|
||||
assert.notEqual(actual.network_secret, 'hijack');
|
||||
await request('DELETE', `${memberPath(a)}/config`, undefined, 204);
|
||||
assert.deepEqual(await request('GET', `${memberPath(a)}/config`), {});
|
||||
await setOverride(a, baseOverride(a));
|
||||
return { identity: actual.network_name, mtu: actual.mtu };
|
||||
});
|
||||
await step(['M07'], 'concurrent member changes preserve both updates', async () => {
|
||||
await Promise.all([request('PATCH', memberPath(a), { hostname_override: 'concurrent-a' }), request('PATCH', memberPath(b), { hostname_override: 'concurrent-b' })]);
|
||||
const current = await members();
|
||||
assert.equal(current.find(m => m.device_id === a.id).hostname_override, 'concurrent-a');
|
||||
assert.equal(current.find(m => m.device_id === b.id).hostname_override, 'concurrent-b');
|
||||
return current;
|
||||
});
|
||||
await step(['R01', 'R02'], 'runtime details and device logger round trip', async () => {
|
||||
const routes = await waitFor(async () => { const v = await rpc(a, network.network_id, 'list_route'); return v.routes?.length ? v : false; }, 'route snapshot');
|
||||
const peers = await waitFor(async () => { const v = await rpc(a, network.network_id, 'list_peer'); return v.peer_infos?.length ? v : false; }, 'peer snapshot');
|
||||
const info = await waitFor(() => runningInfo(a), 'runtime snapshot after member update');
|
||||
assert.ok(routes.routes.length); assert.ok(peers.peer_infos.length); assert.ok(info.config.includes(network.network_name));
|
||||
const service = 'api.logger.LoggerRpcService';
|
||||
const before = await rpc(a, null, 'get_logger_config', service);
|
||||
await rpc(a, null, 'set_logger_config', service, { level: 2 });
|
||||
assert.equal((await rpc(a, null, 'get_logger_config', service)).level, 'WARNING');
|
||||
await rpc(a, null, 'set_logger_config', service, { level: before.level ?? 0 });
|
||||
return { peerCount: peers.peer_infos.length, routeCount: routes.routes.length };
|
||||
});
|
||||
await step(['L02'], 'ACL default deny blocks actual ICMP and default allow restores it', async () => {
|
||||
await policy({ default_action: 'deny', rules: [] }); ping(a, b.vip, false);
|
||||
await policy({ default_action: 'allow', rules: [] }); await eventualPing(a, b.vip); return ping(a, b.vip);
|
||||
});
|
||||
await step(['L03', 'L06'], 'ACL authenticated member selection and counters', async () => {
|
||||
const current = await members();
|
||||
const select = n => ({ type: 'member', member_id: current.find(m => m.device_id === n.id).member_id });
|
||||
await policy({ default_action: 'deny', rules: [rule('icmp-a-b', [select(a)], [select(b)], [{ protocol: 'icmp' }])] });
|
||||
await eventualPing(a, b.vip); ping(c, b.vip, false);
|
||||
const stats = await rpc(b, network.network_id, 'get_acl_stats', 'api.instance.AclManageRpcService');
|
||||
assert.ok(JSON.stringify(stats).includes('icmp-a-b'));
|
||||
await policy({ default_action: 'allow', rules: [] }); return stats;
|
||||
});
|
||||
await step(['L04'], 'TCP and UDP port policy with real socket traffic', async () => {
|
||||
const echo = join(output, 'echo.py');
|
||||
await writeFile(echo, `import socket,threading
|
||||
def udp(port):
|
||||
s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);s.bind(("0.0.0.0",port))
|
||||
while True:
|
||||
d,a=s.recvfrom(1024);s.sendto(d,a)
|
||||
def tcp(port):
|
||||
s=socket.socket();s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1);s.bind(("0.0.0.0",port));s.listen()
|
||||
while True:
|
||||
c,a=s.accept();c.sendall(c.recv(1024));c.close()
|
||||
for port in [18080,18082]: threading.Thread(target=tcp,args=(port,),daemon=True).start()
|
||||
for port in [18081,18082,18083]: threading.Thread(target=udp,args=(port,),daemon=True).start()
|
||||
threading.Event().wait()
|
||||
`);
|
||||
await dockerProcess('echo-b', ['ip', 'netns', 'exec', b.ns, 'python3', echo]);
|
||||
const probe = (proto, port) => ns(a, ['python3', '-c', `import socket;s=socket.socket(socket.AF_INET,socket.${proto === 'tcp' ? 'SOCK_STREAM' : 'SOCK_DGRAM'});s.settimeout(2);s.connect(('${b.vip}',${port}));s.send(b'audit');assert s.recv(1024)==b'audit'`], { allowFailure: true, timeout: 5000 }).status === 0;
|
||||
await policy({ default_action: 'allow', rules: [] });
|
||||
for (const port of [18080, 18082]) assert.ok(probe('tcp', port));
|
||||
for (const port of [18081, 18082, 18083]) assert.ok(probe('udp', port));
|
||||
await policy({ default_action: 'deny', rules: [rule('tcp-only', [{ type: 'all' }], [{ type: 'all' }], [{ protocol: 'tcp', ports: ['18080'], stateful: true }])] });
|
||||
assert.ok(probe('tcp', 18080)); assert.equal(probe('tcp', 18082), false); assert.equal(probe('udp', 18081), false);
|
||||
await policy({ default_action: 'deny', rules: [rule('udp-range', [{ type: 'all' }], [{ type: 'all' }], [{ protocol: 'udp', ports: ['18081-18082'] }])] });
|
||||
assert.ok(probe('udp', 18081)); assert.ok(probe('udp', 18082));
|
||||
assert.equal(probe('udp', 18083), false); assert.equal(probe('tcp', 18080), false);
|
||||
await policy({ default_action: 'allow', rules: [] });
|
||||
});
|
||||
await step(['M03', 'L05'], 'proxy CIDR advertisement and real subnet ICMP under ACL', async () => {
|
||||
ns(b, ['ip', 'addr', 'add', '198.18.240.1/24', 'dev', 'lo']);
|
||||
await request('PATCH', memberPath(b), { proxy_cidrs: ['198.18.240.0/24'] });
|
||||
await waitFor(async () => JSON.stringify(await rpc(a, network.network_id, 'list_route')).includes('198.18.240.0/24'), 'proxy route advertised');
|
||||
await eventualPing(a, '198.18.240.1');
|
||||
const current = await members(); const bid = current.find(m => m.device_id === b.id).member_id;
|
||||
await policy({ default_action: 'deny', rules: [rule('proxy-ping', [{ type: 'all' }], [{ type: 'subnet', member_id: bid, cidrs: ['198.18.240.0/24'] }], [{ protocol: 'icmp' }])] });
|
||||
await eventualPing(a, '198.18.240.1'); ping(a, b.vip, false);
|
||||
await policy({ default_action: 'allow', rules: [] });
|
||||
return rpc(a, network.network_id, 'list_route');
|
||||
});
|
||||
await step(['REG-PROXY'], 'temporary member mapped proxy routes are granted through PATCH and PUT', async () => {
|
||||
ns(c, ['ip', 'addr', 'add', '198.18.241.1/24', 'dev', 'lo']);
|
||||
for (const [method, subnet] of [['PATCH', 242], ['PUT', 243]]) {
|
||||
// Configure each fresh credential while its proxy is offline. Live
|
||||
// grant updates have a separate preexisting Core route-cache race.
|
||||
await stop(c.process);
|
||||
await request('DELETE', memberPath(c), undefined, 204);
|
||||
await request('POST', `/networks/${network.network_id}/members`, { device_ids: [c.id], temporary: true, ttl_seconds: 600 }, 204);
|
||||
const proxy_cidrs = [`198.18.241.0/24->198.18.${subnet}.0/24`];
|
||||
await setOverride(c, baseOverride(c));
|
||||
if (method === 'PATCH') await request('PATCH', memberPath(c), { proxy_cidrs });
|
||||
else await setOverride(c, { ...baseOverride(c), proxy_cidrs });
|
||||
for (const permanent of [a, b]) await waitFor(async () => (await runtimeConfig(permanent)).managed_credentials?.some(grant => grant.allowed_proxy_cidrs?.includes(`198.18.${subnet}.0/24`)), `${method} mapped CIDR grant`);
|
||||
await startNode(c);
|
||||
await waitFor(async () => JSON.stringify(await rpc(a, network.network_id, 'list_route')).includes(`198.18.${subnet}.0/24`), `${method} mapped proxy route`);
|
||||
await eventualPing(a, `198.18.${subnet}.1`);
|
||||
}
|
||||
await request('DELETE', memberPath(c), undefined, 204);
|
||||
await addNodes([c]); await setOverride(c, baseOverride(c));
|
||||
});
|
||||
await step(['REG-SUBNET'], 'changing the subnet reallocates automatic addresses without member edits', async () => {
|
||||
await updateNetwork({ virtual_cidr: '10.89.98.0/24' });
|
||||
await waitFor(async () => (await members()).every(m => m.runtime_virtual_ipv4?.startsWith('10.89.98.')), 'automatic subnet migration');
|
||||
const migrated = await members();
|
||||
assert.ok(migrated.every(m => m.virtual_ipv4 === null && m.allocated_ipv4?.startsWith('10.89.98.')));
|
||||
await eventualPing(a, migrated.find(m => m.device_id === b.id).allocated_ipv4);
|
||||
await updateNetwork({ virtual_cidr: '10.88.99.0/24' });
|
||||
await waitFor(async () => (await members()).every(m => m.runtime_virtual_ipv4?.startsWith('10.88.99.')), 'subnet restored');
|
||||
for (const n of [a, b, c]) n.vip = (await members()).find(m => m.device_id === n.id).runtime_virtual_ipv4.split('/')[0];
|
||||
});
|
||||
await step(['N05', 'N06'], 'network identity, secret and secure-mode updates converge', async () => {
|
||||
await updateNetwork({ display_name: 'Renamed audit', network_name: `${username}-renamed` }, { network_secret: 'audit-rotated-secret' });
|
||||
await waitFor(async () => (await runtimeConfig(b)).network_name === network.network_name && (await runtimeConfig(b)).network_secret === 'audit-rotated-secret', 'identity rotated');
|
||||
await eventualPing(a, b.vip);
|
||||
await updateNetwork({ secure_mode: false });
|
||||
await waitFor(async () => !(await runtimeConfig(b)).secure_mode?.enabled, 'secure disabled'); await eventualPing(a, b.vip);
|
||||
await updateNetwork({ secure_mode: true });
|
||||
await waitFor(async () => (await runtimeConfig(b)).secure_mode?.enabled, 'secure restored'); await eventualPing(a, b.vip);
|
||||
});
|
||||
await step(['N02', 'N03', 'N04', 'N07'], 'Gateway to Manual, PublicServer, Standalone and back', async () => {
|
||||
await updateNetwork({ networking_method: 'Manual', peer_urls: [`tcp://${a.ip}:11010`] }); await eventualPing(b, a.vip);
|
||||
assert.equal((await runtimeConfig(b)).peer_urls[0], `tcp://${a.ip}:11010`);
|
||||
await updateNetwork({ networking_method: 'PublicServer', public_server_url: `tcp://${a.ip}:11010` }); await eventualPing(b, a.vip);
|
||||
await waitFor(async () => (await runtimeConfig(b)).peer_urls?.includes(`tcp://${a.ip}:11010`), 'public server applied');
|
||||
await updateNetwork({ networking_method: 'Standalone', peer_urls: [] });
|
||||
await waitFor(async () => !(await runtimeConfig(b)).peer_urls?.length, 'standalone applied');
|
||||
assert.deepEqual((await runtimeConfig(b)).peer_urls ?? [], []);
|
||||
await updateNetwork({ networking_method: 'Gateway', peer_urls: ['tcp://invalid:9'] });
|
||||
await waitFor(async () => (await runtimeConfig(b)).peer_urls.includes(peerUrl), 'Gateway restored'); await eventualPing(a, b.vip);
|
||||
});
|
||||
await step(['N10'], 'one device in two networks retains the other on deletion', async () => {
|
||||
const second = await createNetwork('second', { networking_method: 'Standalone', virtual_cidr: '10.89.99.0/24' });
|
||||
await addNodes([a], second.network_id);
|
||||
let state = await request('GET', `/machines/${a.id}/networks`); assert.equal(state.running_inst_ids.length, 2);
|
||||
await request('DELETE', `/networks/${second.network_id}`, undefined, 204);
|
||||
await waitFor(async () => (await request('GET', `/machines/${a.id}/networks`)).running_inst_ids.length === 1, 'secondary instance gone');
|
||||
return runtimeConfig(a);
|
||||
});
|
||||
await step(['R03', 'R04'], 'all-DHCP network obtains distinct addresses and retains survivor address', async () => {
|
||||
await updateNetwork({ virtual_cidr: null });
|
||||
const current = await waitFor(async () => { const m = await members(); return m.every(x => x.runtime_virtual_ipv4) && new Set(m.map(x => x.runtime_virtual_ipv4)).size === 3 && m.every(x => x.runtime_virtual_ipv4.startsWith('10.126.126.')) ? m : false; }, 'distinct DHCP addresses', 60000);
|
||||
const aa = current.find(m => m.device_id === a.id).runtime_virtual_ipv4;
|
||||
const bb = current.find(m => m.device_id === b.id).runtime_virtual_ipv4.split('/')[0];
|
||||
await eventualPing(a, bb);
|
||||
await stop(b.process); await stop(c.process); await delay(16000);
|
||||
assert.equal((await members()).find(m => m.device_id === a.id).runtime_virtual_ipv4, aa);
|
||||
await startNode(b); await startNode(c);
|
||||
await updateNetwork({ virtual_cidr: '10.88.99.0/24' });
|
||||
await waitFor(async () => (await members()).every(m => m.runtime_virtual_ipv4?.startsWith('10.88.99.')), 'static addresses restored');
|
||||
for (const n of [a, b, c]) n.vip = (await members()).find(m => m.device_id === n.id).runtime_virtual_ipv4.split('/')[0];
|
||||
return current;
|
||||
});
|
||||
await step(['P01', 'P02', 'D04', 'M06'], 'offline update, Web restart and Core reconnect use latest intent', async () => {
|
||||
await stop(b.process);
|
||||
await request('PATCH', memberPath(b), { hostname_override: 'offline-latest' });
|
||||
await stop(web); web = startWeb(); await waitFor(async () => (await fetch(`${base}/api_meta.js`)).ok, 'Web restart'); await login();
|
||||
await startNode(b);
|
||||
await waitFor(async () => (await runningInfo(b)).hostname === 'offline-latest', 'offline desired config replay');
|
||||
await eventualPing(a, b.vip);
|
||||
assert.ok((await request('GET', '/machines')).machines.some(m => m.alias === 'Persisted Alias'));
|
||||
return { members: await members(), configFiles: docker(['ls', b.config]) };
|
||||
});
|
||||
await step(['W01', 'W03'], 'real native WireGuard handshake and ping to managed member', async () => {
|
||||
const privateKey = docker(['wg', 'genkey']);
|
||||
a.portal = { enabled: true, wireguard_listen: '0.0.0.0:15820', wireguard_private_key: privateKey, clients: [{ name: 'audit-wg', virtual_ip: '10.88.99.210/24', groups: [] }] };
|
||||
await setOverride(a, { ...baseOverride(a), vpn_portal_config: a.portal });
|
||||
const portal = await waitFor(async () => { const p = await rpc(a, network.network_id, 'get_vpn_portal_info', 'api.instance.VpnPortalRpcService'); return p.vpn_portal_info?.clients?.[0]?.client_config ? p.vpn_portal_info : false; }, 'WireGuard portal');
|
||||
const config = portal.clients[0].client_config.replace(/^Endpoint\s*=.*$/m, `Endpoint = ${a.ip}:15820`);
|
||||
await writeFile(join(output, 'wg-client.conf'), config);
|
||||
const stripped = config.split('\n').filter(line => !/^\s*(Address|DNS|MTU|Table|PreUp|PostUp|PreDown|PostDown)\s*=/.test(line)).join('\n');
|
||||
await writeFile(join(output, 'wg-client-stripped.conf'), stripped);
|
||||
ns(wg, ['ip', 'link', 'add', 'wgaudit', 'type', 'wireguard']);
|
||||
ns(wg, ['wg', 'setconf', 'wgaudit', join(output, 'wg-client-stripped.conf')]);
|
||||
ns(wg, ['ip', 'addr', 'add', '10.88.99.210/24', 'dev', 'wgaudit']); ns(wg, ['ip', 'link', 'set', 'wgaudit', 'up']);
|
||||
await eventualPing(wg, b.vip);
|
||||
const handshake = ns(wg, ['wg', 'show', 'wgaudit', 'latest-handshakes']); assert.ok(!handshake.endsWith('\t0'));
|
||||
return { handshake, ping: ping(wg, b.vip) };
|
||||
});
|
||||
await step(['W01', 'W05'], 'WireGuard disable preserves config, re-enable and restart recover', async () => {
|
||||
assert.ok(a.portal);
|
||||
await setOverride(a, { ...baseOverride(a), vpn_portal_config: { ...a.portal, enabled: false } });
|
||||
await delay(3000); ping(wg, b.vip, false);
|
||||
const stored = await request('GET', `${memberPath(a)}/config`); assert.equal(stored.vpn_portal_config.wireguard_private_key, a.portal.wireguard_private_key); assert.equal(stored.vpn_portal_config.clients.length, 1);
|
||||
await setOverride(a, { ...baseOverride(a), vpn_portal_config: a.portal }); await eventualPing(wg, b.vip);
|
||||
await stop(a.process); await startNode(a); await eventualPing(wg, b.vip);
|
||||
return { clientsRetained: stored.vpn_portal_config.clients.length };
|
||||
});
|
||||
await step(['W02', 'W05'], 'removed WireGuard client loses access', async () => {
|
||||
assert.ok(a.portal);
|
||||
await eventualPing(wg, b.vip);
|
||||
await setOverride(a, { ...baseOverride(a), vpn_portal_config: { ...a.portal, clients: [] } });
|
||||
await delay(3000); ping(wg, b.vip, false);
|
||||
});
|
||||
await step(['R06'], 'Gateway relay-data off blocks forced relay; on restores traffic', async () => {
|
||||
for (const n of [a, b]) await setOverride(n, { ...baseOverride(n), disable_p2p: true });
|
||||
await eventualPing(a, b.vip);
|
||||
await stop(web); web = startWeb(false); await waitFor(async () => (await fetch(`${base}/api_meta.js`)).ok, 'Web relay off'); await login();
|
||||
await waitFor(async () => (await members()).filter(m => [a.id, b.id].includes(m.device_id)).every(m => m.running), 'nodes reconnected');
|
||||
await delay(3000); ping(a, b.vip, false);
|
||||
await stop(web); web = startWeb(true); await waitFor(async () => (await fetch(`${base}/api_meta.js`)).ok, 'Web relay on'); await login(); await eventualPing(a, b.vip);
|
||||
});
|
||||
await step(['D05', 'D06'], 'device ban rejects reconnect, unban reenrolls without old memberships', async () => {
|
||||
await waitFor(async () => (await members()).some(m => m.device_id === c.id && m.online && m.running), 'member online before ban');
|
||||
await waitFor(async () => ns(c, ['ip', 'link', 'show', 'dev', 'etc'], { allowFailure: true }).status === 0, 'member TUN before ban');
|
||||
await request('DELETE', `/machines/${c.id}?block=true`, undefined, 204);
|
||||
assert.notEqual(ns(c, ['ip', 'link', 'show', 'dev', 'etc'], { allowFailure: true }).status, 0, 'deleted device TUN is gone before DELETE returns');
|
||||
await waitFor(async () => (await request('GET', '/blocked-devices')).blocked.some(d => d.id === c.id && d.attempt_count > 0), 'blocked reconnect attempt', 45000);
|
||||
assert.ok(!(await members()).some(m => m.device_id === c.id));
|
||||
await request('DELETE', `/blocked-devices/${c.id}`, undefined, 204);
|
||||
await waitFor(async () => (await request('GET', '/machines')).machines.some(m => m.info?.hostname === 'audit-c' && m.online), 'unban reenroll');
|
||||
assert.ok(!(await members()).some(m => m.device_id === c.id));
|
||||
await stop(c.process);
|
||||
await request('DELETE', `/machines/${c.id}`, undefined, 204);
|
||||
await startNode(c);
|
||||
});
|
||||
await step(['N08'], 'delete final network removes online and offline Core configurations', async () => {
|
||||
await stop(b.process);
|
||||
await request('DELETE', `/networks/${network.network_id}`, undefined, 204);
|
||||
await waitFor(async () => (await request('GET', `/machines/${a.id}/networks`)).running_inst_ids.length === 0, 'online instance deletion');
|
||||
await stop(web); web = startWeb(); await waitFor(async () => (await fetch(`${base}/api_meta.js`)).ok, 'Web restart after delete'); await login(); await startNode(b);
|
||||
await waitFor(async () => (await request('GET', `/machines/${b.id}/networks`)).running_inst_ids.length === 0, 'offline instance deletion');
|
||||
assert.deepEqual((await request('GET', '/networks')).networks, []);
|
||||
assert.ok(!docker(['ls', b.config]).includes(network.network_id));
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({ ids: [], name: 'suite setup or infrastructure', status: 'FAIL', error: error.stack });
|
||||
console.error(error);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
for (const p of [...processes].reverse()) await stop(p);
|
||||
for (const name of namespaces) docker(['ip', 'netns', 'delete', name], { allowFailure: true });
|
||||
if (bridgeCreated) docker(['ip', 'link', 'delete', bridge], { allowFailure: true });
|
||||
await writeFile(join(output, 'results.json'), JSON.stringify(results, null, 2));
|
||||
console.log(`RESULTS=${join(output, 'results.json')}`);
|
||||
}
|
||||
process.exitCode = results.some(r => r.status === 'FAIL') ? 1 : 0;
|
||||
@@ -0,0 +1,362 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { mkdtemp, mkdir, readFile, readdir, rm } from 'node:fs/promises';
|
||||
import { createServer } from 'node:net';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { test } from 'node:test';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const webBinary = join(repo, 'target/debug/easytier-web');
|
||||
const coreBinary = join(repo, 'target/debug/easytier-core');
|
||||
const cliBinary = join(repo, 'target/debug/easytier-cli');
|
||||
const username = 'central-e2e';
|
||||
const password = 'e2e-password';
|
||||
const machineId = '00000000-0000-0000-0000-000000000002';
|
||||
// Argon2id of the MD5 digest sent by the frontend for the test password.
|
||||
const passwordHash = '$argon2id$v=19$m=4096,t=3,p=1$ZWFzeXRpZXItZTJlLXNhbHQ$FHbDjVElaTXuArgEEQrQrO4AdsnqNOcXGA5PFY8cG/s';
|
||||
|
||||
function docker(...args) {
|
||||
const result = spawnSync('docker', ['exec', 'rust', ...args], { encoding: 'utf8' });
|
||||
if (result.status !== 0) throw new Error(result.stderr || result.stdout);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function buildCoreForDocker() {
|
||||
docker('sh', '-c', 'cd "$1" && cargo build -p easytier --bins && chown -R "$2:$3" target',
|
||||
'e2e-build', repo, String(process.getuid()), String(process.getgid()));
|
||||
}
|
||||
|
||||
function freePort() {
|
||||
return new Promise((resolvePort, reject) => {
|
||||
const server = createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '0.0.0.0', () => {
|
||||
const port = server.address().port;
|
||||
server.close(() => resolvePort(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(check, label, timeout = 30000) {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
const result = await check().catch(() => null);
|
||||
if (result) return result;
|
||||
await delay(250);
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
function startWeb(database, apiPort, configPort, gatewayHost) {
|
||||
const child = spawn(webBinary, [
|
||||
'--db', database,
|
||||
'--api-server-addr', '127.0.0.1',
|
||||
'--api-server-port', String(apiPort),
|
||||
'--config-server-protocol', 'tcp',
|
||||
'--config-server-port', String(configPort),
|
||||
'--gateway-peer-url', `tcp://${gatewayHost}:${configPort}`,
|
||||
'--console-log-level', 'error',
|
||||
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let output = '';
|
||||
for (const stream of [child.stdout, child.stderr]) {
|
||||
stream.on('data', chunk => { output = (output + chunk).slice(-12000); });
|
||||
}
|
||||
return { child, logs: () => output };
|
||||
}
|
||||
|
||||
async function stopWeb(web) {
|
||||
web.child.kill('SIGINT');
|
||||
if (web.child.exitCode === null) {
|
||||
await Promise.race([new Promise(resolveExit => web.child.once('exit', resolveExit)), delay(3000)]);
|
||||
}
|
||||
}
|
||||
|
||||
function startCore(coreConfig, gatewayHost, configPort) {
|
||||
const child = spawn('docker', ['exec', 'rust', 'sh', '-c', `
|
||||
echo $$ > ${coreConfig}/core.pid
|
||||
exec ${coreBinary} \
|
||||
--config-server tcp://${gatewayHost}:${configPort}/${username} \
|
||||
--machine-id ${machineId} --config-dir ${coreConfig} \
|
||||
--no-listener --hostname e2e-device --console-log-level error
|
||||
`], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let output = '';
|
||||
for (const stream of [child.stdout, child.stderr]) {
|
||||
stream.on('data', chunk => { output = (output + chunk).slice(-12000); });
|
||||
}
|
||||
return { child, logs: () => output };
|
||||
}
|
||||
|
||||
function startCredentialCore(coreConfig, networkName, credential, peerUrl, hostname, rpcPort) {
|
||||
const child = spawn('docker', ['exec', 'rust', 'sh', '-c', `
|
||||
echo $$ > "$1/core.pid"
|
||||
exec "$2" --network-name "$3" --secure-mode --credential "$4" \
|
||||
-p "$5" --no-listener --no-tun --hostname "$6" \
|
||||
--rpc-portal "127.0.0.1:$7" --console-log-level error
|
||||
`, 'credential-core', coreConfig, coreBinary, networkName, credential, peerUrl, hostname, String(rpcPort)],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let output = '';
|
||||
for (const stream of [child.stdout, child.stderr]) {
|
||||
stream.on('data', chunk => { output = (output + chunk).slice(-12000); });
|
||||
}
|
||||
return { child, logs: () => output };
|
||||
}
|
||||
|
||||
function credentialPeers(rpcPort) {
|
||||
return JSON.parse(docker(cliBinary, '--rpc-portal', `127.0.0.1:${rpcPort}`, '--output', 'json', 'peer'));
|
||||
}
|
||||
|
||||
async function stopCore(core, coreConfig) {
|
||||
if (!core) return;
|
||||
const pid = await readFile(join(coreConfig, 'core.pid'), 'utf8').catch(() => null);
|
||||
if (pid) {
|
||||
try { docker('kill', '-INT', pid.trim()); } catch { /* Core already exited. */ }
|
||||
}
|
||||
if (core.child.exitCode === null) {
|
||||
await Promise.race([new Promise(resolveExit => core.child.once('exit', resolveExit)), delay(3000)]);
|
||||
if (core.child.exitCode === null) core.child.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
|
||||
function seedUser(database) {
|
||||
const db = new DatabaseSync(database);
|
||||
try {
|
||||
const user = db.prepare('INSERT INTO users(username, password) VALUES (?, ?)')
|
||||
.run(username, passwordHash);
|
||||
const group = db.prepare("SELECT id FROM groups WHERE name = 'users'").get();
|
||||
db.prepare('INSERT INTO users_groups(user_id, group_id) VALUES (?, ?)')
|
||||
.run(user.lastInsertRowid, group.id);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function login(page) {
|
||||
await page.goto('/');
|
||||
await page.locator('#username').fill(username);
|
||||
await page.locator('#password input').fill(password);
|
||||
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
||||
await page.locator('.console-page').waitFor();
|
||||
}
|
||||
|
||||
async function api(context, path) {
|
||||
const response = await context.request.get(`/api/v1${path}`);
|
||||
assert.equal(response.status(), 200, `${path}: ${await response.text()}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function protoUuid(uuid) {
|
||||
const hex = uuid.replaceAll('-', '');
|
||||
return Object.fromEntries([0, 1, 2, 3].map(index =>
|
||||
[`part${index + 1}`, parseInt(hex.slice(index * 8, index * 8 + 8), 16)]));
|
||||
}
|
||||
|
||||
async function nodeRpc(context, networkId, method) {
|
||||
const response = await context.request.post(`/api/v1/machines/${machineId}/proxy-rpc`, {
|
||||
data: {
|
||||
service_name: 'api.instance.PeerManageRpcService',
|
||||
method_name: method,
|
||||
payload: { instance: { id: protoUuid(networkId) } },
|
||||
},
|
||||
});
|
||||
assert.equal(response.status(), 200, await response.text());
|
||||
return response.json();
|
||||
}
|
||||
|
||||
test('browser, Web API, database and Core agree on central network lifecycle', { timeout: 300000 }, async () => {
|
||||
buildCoreForDocker();
|
||||
const temp = await mkdtemp(join(repo, '.central-e2e-'));
|
||||
const database = join(temp, 'web.db');
|
||||
const coreConfig = join(temp, 'core');
|
||||
const credentialConfigs = [join(temp, 'credential-one'), join(temp, 'credential-two')];
|
||||
await mkdir(coreConfig);
|
||||
for (const config of credentialConfigs) await mkdir(config);
|
||||
const apiPort = await freePort();
|
||||
const configPort = await freePort();
|
||||
const credentialRpcPorts = [await freePort(), await freePort()];
|
||||
const gatewayHostname = (await readFile('/etc/hostname', 'utf8')).trim() || 'easytier-web-gateway';
|
||||
const base = `http://127.0.0.1:${apiPort}`;
|
||||
const route = docker('sh', '-c', 'ip route');
|
||||
const gatewayHost = route.match(/default via ([\d.]+)/)?.[1];
|
||||
assert.ok(gatewayHost, `Docker bridge gateway missing: ${route}`);
|
||||
docker(coreBinary, '--version');
|
||||
docker(cliBinary, '--version');
|
||||
let web = startWeb(database, apiPort, configPort, gatewayHost);
|
||||
let core;
|
||||
const credentialCores = [];
|
||||
let browser;
|
||||
try {
|
||||
await waitFor(async () => (await fetch(`${base}/api_meta.js`)).ok, 'Web server');
|
||||
seedUser(database);
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ baseURL: base });
|
||||
await context.addInitScript(() => localStorage.setItem('lang', 'en'));
|
||||
const page = await context.newPage();
|
||||
await login(page);
|
||||
|
||||
core = startCore(coreConfig, gatewayHost, configPort);
|
||||
await waitFor(async () => {
|
||||
const { machines } = await api(context, '/machines');
|
||||
return machines.some(machine => machine.info.hostname === 'e2e-device');
|
||||
}, 'Core enrollment');
|
||||
await page.goto('/#/h/networks');
|
||||
await page.getByRole('button', { name: 'Create Network', exact: true }).click();
|
||||
await page.locator('#network-display-name').fill('E2E Network');
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Confirm', exact: true }).click();
|
||||
await page.waitForURL(/\/networks\/[0-9a-f-]{36}$/);
|
||||
const networkId = page.url().split('/').at(-1);
|
||||
|
||||
await page.getByRole('button', { name: 'Add Devices' }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Add Devices' });
|
||||
await dialog.getByText('e2e-device').waitFor();
|
||||
await dialog.getByRole('row', { name: /e2e-device/ }).getByRole('checkbox').check();
|
||||
await dialog.getByRole('button', { name: 'Confirm' }).click();
|
||||
await waitFor(async () => {
|
||||
const { members } = await api(context, `/networks/${networkId}/members`);
|
||||
return members.find(member => member.device_id === machineId && member.running);
|
||||
}, 'managed Core instance', 45000);
|
||||
await waitFor(async () => {
|
||||
return (await nodeRpc(context, networkId, 'list_peer')).peer_infos?.length > 0;
|
||||
}, 'Core peer connection', 15000);
|
||||
await waitFor(async () => {
|
||||
const routes = (await nodeRpc(context, networkId, 'list_route')).routes ?? [];
|
||||
return routes.length > 0;
|
||||
}, 'Core route through the Gateway', 15000);
|
||||
|
||||
const network = await api(context, `/networks/${networkId}`);
|
||||
assert.equal(network.secure_mode, true);
|
||||
const issued = await context.request.post(`/api/v1/networks/${networkId}/credentials`, {
|
||||
data: { ttl_seconds: 3600, reusable: true, credential_id: 'e2e-shared' },
|
||||
});
|
||||
assert.equal(issued.status(), 200, await issued.text());
|
||||
const credential = await issued.json();
|
||||
const credentialPeerUrl = `tcp://${gatewayHost}:${configPort}`;
|
||||
for (const [index, hostname] of ['e2e-temp-a', 'e2e-temp-b'].entries()) {
|
||||
credentialCores.push(startCredentialCore(
|
||||
credentialConfigs[index], network.network_name, credential.credential_secret,
|
||||
credentialPeerUrl, hostname, credentialRpcPorts[index],
|
||||
));
|
||||
}
|
||||
const onlinePeers = await waitFor(async () => {
|
||||
const { credentials } = await api(context, `/networks/${networkId}/credentials`);
|
||||
const peers = credentials.find(item => item.credential_id === 'e2e-shared')?.online_peers;
|
||||
return peers?.length === 2 ? peers : null;
|
||||
}, 'both credential peers online', 45000);
|
||||
assert.deepEqual(onlinePeers.map(peer => peer.hostname).sort(), ['e2e-temp-a', 'e2e-temp-b']);
|
||||
assert.equal(new Set(onlinePeers.map(peer => peer.peer_id)).size, 2);
|
||||
const { temporary_peers: memberPeers } = await api(context, `/networks/${networkId}/members`);
|
||||
assert.deepEqual(memberPeers.map(peer => peer.hostname).sort(), ['e2e-temp-a', 'e2e-temp-b']);
|
||||
await waitFor(async () => credentialRpcPorts.every(port =>
|
||||
credentialPeers(port).some(peer => peer.hostname === gatewayHostname)),
|
||||
'both credential Cores connected to Gateway');
|
||||
await page.reload();
|
||||
await page.getByRole('cell', { name: /e2e-temp-a/ }).waitFor();
|
||||
await page.getByRole('cell', { name: /e2e-temp-b/ }).waitFor();
|
||||
await page.getByRole('tab', { name: 'Credentials' }).click();
|
||||
await page.getByRole('cell', { name: /e2e-temp-a/ }).waitFor();
|
||||
await page.getByRole('cell', { name: /e2e-temp-b/ }).waitFor();
|
||||
const revoked = await context.request.delete(`/api/v1/networks/${networkId}/credentials/e2e-shared`);
|
||||
assert.equal(revoked.status(), 204, await revoked.text());
|
||||
assert.deepEqual((await api(context, `/networks/${networkId}/members`)).temporary_peers, []);
|
||||
await waitFor(async () => credentialRpcPorts.every(port =>
|
||||
!credentialPeers(port).some(peer => peer.hostname === gatewayHostname)),
|
||||
'revoked Core peers disconnected from Gateway');
|
||||
await delay(6000);
|
||||
assert.ok(credentialRpcPorts.every(port =>
|
||||
!credentialPeers(port).some(peer => peer.hostname === gatewayHostname)),
|
||||
'revoked credential Cores must not reconnect to Gateway');
|
||||
assert.deepEqual((await api(context, `/networks/${networkId}/members`)).temporary_peers, [],
|
||||
'revoked credential peers must not reconnect');
|
||||
for (const [index, credentialCore] of credentialCores.entries()) {
|
||||
await stopCore(credentialCore, credentialConfigs[index]);
|
||||
}
|
||||
credentialCores.length = 0;
|
||||
|
||||
const invalidMemberUpdate = await context.request.patch(`/api/v1/networks/${networkId}/members/${machineId}`, {
|
||||
data: { proxy_cidrs: ['2001:db8::/64'] },
|
||||
});
|
||||
assert.equal(invalidMemberUpdate.status(), 400, await invalidMemberUpdate.text());
|
||||
const memberUpdate = await context.request.patch(`/api/v1/networks/${networkId}/members/${machineId}`, {
|
||||
data: { proxy_cidrs: ['198.18.240.0/24'] },
|
||||
});
|
||||
assert.equal(memberUpdate.status(), 200, await memberUpdate.text());
|
||||
|
||||
await page.getByRole('tab', { name: 'Access Control' }).click();
|
||||
await page.getByRole('button', { name: 'Add Rule' }).click();
|
||||
const drawer = page.locator('.p-drawer');
|
||||
await drawer.locator('#acl-rule-name').fill('DNS and ping');
|
||||
await drawer.locator('.p-multiselect').filter({ hasText: 'Search and choose the members that initiate access' }).click();
|
||||
await page.getByRole('option', { name: /e2e-device/ }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await drawer.locator('.p-multiselect').filter({ hasText: 'Search and choose target members or subnets' }).click();
|
||||
await page.getByRole('option', { name: /198\.18\.240\.0\/24/ }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await drawer.locator('label').filter({ hasText: 'UDP' }).locator('input[type="checkbox"]').check();
|
||||
await drawer.locator('label').filter({ hasText: 'ICMP' }).locator('input[type="checkbox"]').check();
|
||||
await drawer.getByRole('button', { name: 'DNS 53' }).click();
|
||||
await drawer.getByRole('button', { name: 'Save Rule' }).click();
|
||||
const saveResponse = page.waitForResponse(response =>
|
||||
response.url().endsWith(`/api/v1/networks/${networkId}/acl-policy`)
|
||||
&& response.request().method() === 'PUT');
|
||||
await page.getByRole('button', { name: 'Save Policy' }).click();
|
||||
const response = await saveResponse;
|
||||
assert.equal(response.status(), 200, await response.text());
|
||||
await page.getByText('Access policy saved').waitFor();
|
||||
|
||||
const saved = await api(context, `/networks/${networkId}/acl-policy`);
|
||||
assert.equal(saved.policy.rules.length, 1);
|
||||
assert.equal(saved.policy.rules[0].sources[0].type, 'member');
|
||||
assert.deepEqual(saved.policy.rules[0].destinations[0], {
|
||||
type: 'subnet', member_id: saved.policy.rules[0].sources[0].member_id,
|
||||
cidrs: ['198.18.240.0/24'],
|
||||
});
|
||||
const deletion = await context.request.delete(`/api/v1/networks/${networkId}/members/${machineId}`);
|
||||
assert.equal(deletion.status(), 204, await deletion.text());
|
||||
const remaining = await api(context, `/networks/${networkId}/acl-policy`);
|
||||
assert.equal(remaining.policy.rules.length, 0);
|
||||
|
||||
const addAgain = await context.request.post(`/api/v1/networks/${networkId}/members`, {
|
||||
data: { device_ids: [machineId] },
|
||||
});
|
||||
assert.equal(addAgain.status(), 204, await addAgain.text());
|
||||
await waitFor(async () => {
|
||||
const { members } = await api(context, `/networks/${networkId}/members`);
|
||||
return members.find(member => member.device_id === machineId && member.running);
|
||||
}, 'restored Core instance', 45000);
|
||||
assert.ok((await readdir(coreConfig)).includes(`${networkId}.toml`));
|
||||
|
||||
await stopCore(core, coreConfig);
|
||||
core = undefined;
|
||||
const offlineDelete = await context.request.delete(`/api/v1/networks/${networkId}/members/${machineId}`);
|
||||
assert.equal(offlineDelete.status(), 204, await offlineDelete.text());
|
||||
await stopWeb(web);
|
||||
web = startWeb(database, apiPort, configPort, gatewayHost);
|
||||
await waitFor(async () => (await fetch(`${base}/api_meta.js`)).ok, 'restarted Web server');
|
||||
await login(page);
|
||||
core = startCore(coreConfig, gatewayHost, configPort);
|
||||
await waitFor(async () => {
|
||||
const { machines } = await api(context, '/machines');
|
||||
return machines.some(machine => machine.info.hostname === 'e2e-device' && machine.online);
|
||||
}, 'Core reconnect after Web restart');
|
||||
await waitFor(async () => {
|
||||
const state = await api(context, `/machines/${machineId}/networks`);
|
||||
return state.running_inst_ids?.length === 0;
|
||||
}, 'offline managed config removal', 45000);
|
||||
await waitFor(async () => !(await readdir(coreConfig)).includes(`${networkId}.toml`),
|
||||
'removed Core config file');
|
||||
assert.deepEqual((await api(context, `/networks/${networkId}/members`)).members, []);
|
||||
} catch (error) {
|
||||
throw new Error(`${error.message}\nWeb logs:\n${web.logs()}\nCore logs:\n${core?.logs() ?? ''}\nCredential Core logs:\n${credentialCores.map(item => item.logs()).join('\n')}`, { cause: error });
|
||||
} finally {
|
||||
await browser?.close();
|
||||
for (const [index, credentialCore] of credentialCores.entries()) {
|
||||
await stopCore(credentialCore, credentialConfigs[index]);
|
||||
}
|
||||
await stopCore(core, coreConfig);
|
||||
await stopWeb(web);
|
||||
await rm(temp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
// Regression E2E for central configuration validation before persistence.
|
||||
// Requires built Web (embed) and Core binaries and the privileged `rust`
|
||||
// container with the shared /data mount. Results and logs are retained in
|
||||
// .test-env/central-validation-* for diagnosis.
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { createServer } from 'node:net';
|
||||
import { resolve, join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const run = String(Date.now()).slice(-7);
|
||||
const output = join(repo, '.test-env', `central-validation-${run}`);
|
||||
await mkdir(output, { recursive: true });
|
||||
console.log(`ARTIFACTS=${output}`);
|
||||
const webBin = join(repo, 'target/debug/easytier-web');
|
||||
const coreBin = join(repo, 'target/debug/easytier-core');
|
||||
const username = `validation-${run}`;
|
||||
const password = 'e2e-password';
|
||||
const passwordHash = '$argon2id$v=19$m=4096,t=3,p=1$ZWFzeXRpZXItZTJlLXNhbHQ$FHbDjVElaTXuArgEEQrQrO4AdsnqNOcXGA5PFY8cG/s';
|
||||
const database = join(output, 'web.db');
|
||||
const results = [];
|
||||
const processes = [];
|
||||
const namespaces = [];
|
||||
const bridge = `ce${run}`;
|
||||
const subnet = `10.244.${170 + Math.floor(Math.random() * 50)}`;
|
||||
const gatewayIp = `${subnet}.1`;
|
||||
let bridgeCreated = false;
|
||||
let browser, context, page, web;
|
||||
const nodes = [];
|
||||
let network;
|
||||
|
||||
function docker(args, { allowFailure = false, input, timeout = 20000 } = {}) {
|
||||
const r = spawnSync('docker', ['exec', ...(input === undefined ? [] : ['-i']), 'rust', ...args], { encoding: 'utf8', input, timeout });
|
||||
if (!allowFailure && r.status !== 0) throw new Error(`docker ${args.join(' ')}: ${r.stderr || r.stdout || r.error}`);
|
||||
return allowFailure ? r : r.stdout.trim();
|
||||
}
|
||||
function ns(node, args, options) { return docker(['ip', 'netns', 'exec', node.ns, ...args], options); }
|
||||
async function waitFor(check, label, timeout = 45000) {
|
||||
const end = Date.now() + timeout;
|
||||
let last;
|
||||
while (Date.now() < end) {
|
||||
try { const value = await check(); if (value) return value; } catch (error) { last = error.message; }
|
||||
await delay(300);
|
||||
}
|
||||
throw new Error(`Timed out: ${label}${last ? `; last error: ${last}` : ''}`);
|
||||
}
|
||||
async function step(ids, name, action) {
|
||||
const started = new Date().toISOString();
|
||||
try {
|
||||
const evidence = await action();
|
||||
results.push({ ids, name, status: 'PASS', started, evidence: evidence ?? 'assertions passed' });
|
||||
console.log(`PASS ${ids.join(',')} ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
results.push({ ids, name, status: 'FAIL', started, error: error.stack });
|
||||
console.error(`FAIL ${ids.join(',')} ${name}: ${error.message}`);
|
||||
if (network && context) await writeFile(join(output, `failure-${results.length}-members.json`), JSON.stringify(await members().catch(() => null), null, 2));
|
||||
if (page) await page.screenshot({ path: join(output, `failure-${results.length}.png`), fullPage: true }).catch(() => {});
|
||||
return false;
|
||||
} finally {
|
||||
await writeFile(join(output, 'results.json'), JSON.stringify(results, null, 2));
|
||||
}
|
||||
}
|
||||
async function freePort() {
|
||||
return new Promise((resolvePort, reject) => {
|
||||
const server = createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '0.0.0.0', () => { const port = server.address().port; server.close(() => resolvePort(port)); });
|
||||
});
|
||||
}
|
||||
function processLog(child, label, pidFile) {
|
||||
let logs = '';
|
||||
for (const stream of [child.stdout, child.stderr]) stream.on('data', data => { logs += data; });
|
||||
const p = { child, label, pidFile, logs: () => logs };
|
||||
processes.push(p);
|
||||
return p;
|
||||
}
|
||||
async function dockerProcess(label, args) {
|
||||
const pidFile = join(output, `${label}.pid`);
|
||||
return processLog(spawn('docker', ['exec', 'rust', 'sh', '-c', 'echo $$ > "$1"; shift; exec "$@"', 'audit', pidFile, ...args], { stdio: ['ignore', 'pipe', 'pipe'] }), label, pidFile);
|
||||
}
|
||||
async function stop(p) {
|
||||
if (!p || p.stopped) return;
|
||||
if (p.pidFile) {
|
||||
const pid = await readFile(p.pidFile, 'utf8').catch(() => '');
|
||||
if (pid.trim()) docker(['kill', '-INT', pid.trim()], { allowFailure: true });
|
||||
} else p.child.kill('SIGINT');
|
||||
if (p.child.exitCode === null) await Promise.race([new Promise(r => p.child.once('exit', r)), delay(3000)]);
|
||||
if (p.child.exitCode === null && p.pidFile) {
|
||||
const pid = await readFile(p.pidFile, 'utf8').catch(() => '');
|
||||
if (pid.trim()) docker(['kill', '-KILL', pid.trim()], { allowFailure: true });
|
||||
}
|
||||
if (p.child.exitCode === null) p.child.kill('SIGTERM');
|
||||
p.stopped = true;
|
||||
await writeFile(join(output, `${p.label}.log`), p.logs());
|
||||
}
|
||||
const apiPort = await freePort();
|
||||
const configPort = await freePort();
|
||||
const proxyPort = await freePort();
|
||||
const base = `http://127.0.0.1:${apiPort}`;
|
||||
const peerUrl = `tcp://${gatewayIp}:${proxyPort}`;
|
||||
function startWeb(relay = true) {
|
||||
return processLog(spawn(webBin, ['--db', database, '--api-server-addr', '127.0.0.1', '--api-server-port', String(apiPort), '--config-server-protocol', 'tcp', '--config-server-port', String(configPort), '--gateway-peer-url', peerUrl, ...(relay ? ['--gateway-relay-data'] : []), '--console-log-level', 'error'], { stdio: ['ignore', 'pipe', 'pipe'] }), `web-${processes.length}`);
|
||||
}
|
||||
async function request(method, path, data, expected = 200) {
|
||||
const r = await context.request.fetch(`/api/v1${path}`, { method, ...(data === undefined ? {} : { data }) });
|
||||
const text = await r.text();
|
||||
assert.equal(r.status(), expected, `${method} ${path}: ${text}`);
|
||||
return text ? JSON.parse(text) : undefined;
|
||||
}
|
||||
function protoUuid(value) {
|
||||
const hex = value.replaceAll('-', '');
|
||||
return Object.fromEntries([0, 1, 2, 3].map(i => [`part${i + 1}`, parseInt(hex.slice(i * 8, i * 8 + 8), 16)]));
|
||||
}
|
||||
async function rpc(node, id, method, service = 'api.instance.PeerManageRpcService', payload = {}) {
|
||||
return request('POST', `/machines/${node.id}/proxy-rpc`, { service_name: service, method_name: method, payload: { ...(id ? { instance: { id: protoUuid(id) } } : {}), ...payload } });
|
||||
}
|
||||
const memberPath = (node, id = network.network_id) => `/networks/${id}/members/${node.id}`;
|
||||
async function runtimeConfig(node, id = network.network_id) { return request('GET', `/machines/${node.id}/networks/config/${id}`); }
|
||||
async function members(id = network.network_id) { return (await request('GET', `/networks/${id}/members`)).members; }
|
||||
async function setOverride(node, override, id = network.network_id) {
|
||||
await request('PUT', `${memberPath(node, id)}/config`, { config: override });
|
||||
}
|
||||
function baseOverride(node) {
|
||||
return { no_tun: false, disable_ipv6: true, multi_thread: false, dev_name: `et${node.name}`, listener_urls: ['tcp://0.0.0.0:11010'], disable_udp_hole_punching: true, disable_tcp_hole_punching: true, disable_upnp: true };
|
||||
}
|
||||
async function createNetwork(name, options = {}) {
|
||||
return request('POST', '/networks', { settings: { display_name: name, network_name: `${username}-${name}`, networking_method: 'Gateway', virtual_cidr: '10.88.99.0/24', secure_mode: true, ...options } });
|
||||
}
|
||||
async function addNodes(selected, id = network.network_id, extra = {}) {
|
||||
await request('POST', `/networks/${id}/members`, { device_ids: selected.map(n => n.id), ...extra }, 204);
|
||||
await waitFor(async () => {
|
||||
const current = await members(id);
|
||||
return selected.every(n => current.some(m => m.device_id === n.id && m.running));
|
||||
}, 'members running');
|
||||
}
|
||||
async function startNode(node) {
|
||||
node.process = await dockerProcess(`core-${node.name}-${processes.length}`, ['ip', 'netns', 'exec', node.ns, coreBin, '--config-server', `${peerUrl}/${username}`, '--machine-id', node.id, '--config-dir', node.config, '--no-listener', '--no-tun', '--hostname', `audit-${node.name}`, '--rpc-portal', '127.0.0.1:15888', '--console-log-level', 'error']);
|
||||
await waitFor(async () => (await request('GET', '/machines')).machines.some(m => m.info?.machine_id && m.online && m.info.hostname === `audit-${node.name}`), `enroll ${node.name}`);
|
||||
}
|
||||
async function login() {
|
||||
await page.goto('/');
|
||||
await page.locator('#username').fill(username);
|
||||
await page.locator('#password input').fill(password);
|
||||
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
||||
await page.locator('.console-page').waitFor();
|
||||
await context.storageState({ path: join(output, 'browser-state.json') });
|
||||
}
|
||||
function ping(from, to, expected = true) {
|
||||
const r = ns(from, ['ping', '-c', '2', '-W', '1', to], { allowFailure: true, timeout: 5000 });
|
||||
assert.equal(r.status === 0, expected, `ping ${from.name} -> ${to}: ${r.stdout} ${r.stderr}`);
|
||||
return r.stdout;
|
||||
}
|
||||
async function eventualPing(from, to) { return waitFor(async () => ns(from, ['ping', '-c', '1', '-W', '1', to], { allowFailure: true }).status === 0, `ping ${from.name} -> ${to}`); }
|
||||
|
||||
try {
|
||||
docker(['ip', 'link', 'add', bridge, 'type', 'bridge']); bridgeCreated = true;
|
||||
docker(['ip', 'addr', 'add', `${gatewayIp}/24`, 'dev', bridge]);
|
||||
docker(['ip', 'link', 'set', bridge, 'up']);
|
||||
for (const [i, name] of ['a', 'b'].entries()) {
|
||||
const node = { name, ns: `ce-${run}-${name}`, id: randomUUID(), ip: `${subnet}.${10 + i}`, config: join(output, name) };
|
||||
await mkdir(node.config);
|
||||
docker(['ip', 'netns', 'add', node.ns]); namespaces.push(node.ns);
|
||||
const veth = `v${run}${name}`;
|
||||
docker(['ip', 'link', 'add', veth, 'type', 'veth', 'peer', 'name', 'eth0', 'netns', node.ns]);
|
||||
docker(['ip', 'link', 'set', veth, 'master', bridge]);
|
||||
docker(['ip', 'link', 'set', veth, 'up']);
|
||||
ns(node, ['ip', 'link', 'set', 'lo', 'up']);
|
||||
ns(node, ['ip', 'addr', 'add', `${node.ip}/24`, 'dev', 'eth0']);
|
||||
ns(node, ['ip', 'link', 'set', 'eth0', 'up']);
|
||||
nodes.push(node);
|
||||
}
|
||||
const route = docker(['ip', 'route']);
|
||||
const host = route.match(/default via ([\d.]+)/)?.[1];
|
||||
assert.ok(host);
|
||||
const proxyScript = join(output, 'proxy.py');
|
||||
await writeFile(proxyScript, `import socket,socketserver,threading\nclass H(socketserver.BaseRequestHandler):\n def handle(self):\n remote=socket.create_connection(('${host}',${configPort}))\n def copy(a,b):\n try:\n while True:\n data=a.recv(65536)\n if not data: break\n b.sendall(data)\n except OSError: pass\n finally:\n try: b.shutdown(socket.SHUT_WR)\n except OSError: pass\n t=threading.Thread(target=copy,args=(self.request,remote));t.start();copy(remote,self.request);t.join();remote.close()\nclass S(socketserver.ThreadingTCPServer):\n allow_reuse_address=True\n daemon_threads=True\nS(('${gatewayIp}',${proxyPort}),H).serve_forever()\n`);
|
||||
await dockerProcess('config-proxy', ['python3', proxyScript]);
|
||||
web = startWeb();
|
||||
await waitFor(async () => (await fetch(`${base}/api_meta.js`)).ok, 'Web ready');
|
||||
const db = new DatabaseSync(database);
|
||||
const user = db.prepare('INSERT INTO users(username,password) VALUES (?,?)').run(username, passwordHash);
|
||||
const group = db.prepare("SELECT id FROM groups WHERE name='users'").get();
|
||||
db.prepare('INSERT INTO users_groups(user_id,group_id) VALUES (?,?)').run(user.lastInsertRowid, group.id); db.close();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
context = await browser.newContext({ baseURL: base });
|
||||
await context.addInitScript(() => localStorage.setItem('lang', 'en'));
|
||||
page = await context.newPage();
|
||||
await login();
|
||||
await writeFile(join(output, 'environment.json'), JSON.stringify({ run, base, apiPort, configPort, peerUrl, bridge, nodes, code: spawnSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).stdout.trim() }, null, 2));
|
||||
const [a, b] = nodes;
|
||||
for (const node of nodes) await startNode(node);
|
||||
network = await createNetwork('validation');
|
||||
await addNodes(nodes);
|
||||
for (const node of nodes) await setOverride(node, baseOverride(node));
|
||||
const current = await waitFor(async () => {
|
||||
const value = await members();
|
||||
return value.length === 2 && value.every(member => member.runtime_virtual_ipv4) ? value : false;
|
||||
}, 'two assigned addresses');
|
||||
for (const node of nodes) node.vip = current.find(member => member.device_id === node.id).runtime_virtual_ipv4.split('/')[0];
|
||||
await eventualPing(a, b.vip);
|
||||
|
||||
// Read only intent tables: device heartbeats and browser sessions may change.
|
||||
function intentSnapshot() {
|
||||
const db = new DatabaseSync(database, { readOnly: true });
|
||||
try {
|
||||
return Object.fromEntries(['networks', 'network_members', 'network_credentials'].map(table => [table, db.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all()]));
|
||||
} finally { db.close(); }
|
||||
}
|
||||
async function snapshot() {
|
||||
return {
|
||||
database: intentSnapshot(),
|
||||
network: await request('GET', `/networks/${network.network_id}`),
|
||||
overrides: await Promise.all(nodes.map(node => request('GET', `${memberPath(node)}/config`))),
|
||||
runtime: await Promise.all(nodes.map(node => runtimeConfig(node))),
|
||||
coreConfig: await Promise.all(nodes.map(async node => {
|
||||
const result = await rpc(node, network.network_id, 'show_node_info');
|
||||
assert.ok(result.node_info?.config);
|
||||
return result.node_info.config;
|
||||
})),
|
||||
};
|
||||
}
|
||||
async function rejectWithoutMutation(method, path, candidate) {
|
||||
await eventualPing(a, b.vip);
|
||||
const before = await snapshot();
|
||||
// A continuous probe spans the rejected request and reconciliation window.
|
||||
const probe = spawn('docker', ['exec', 'rust', 'ip', 'netns', 'exec', a.ns, 'ping', '-i', '0.2', '-c', '15', '-W', '1', b.vip], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let packets = '';
|
||||
probe.stdout.on('data', data => { packets += data; });
|
||||
probe.stderr.on('data', data => { packets += data; });
|
||||
const completed = new Promise(resolveExit => probe.on('exit', code => resolveExit(code)));
|
||||
const response = await request(method, path, candidate, 400);
|
||||
assert.equal(await completed, 0, packets);
|
||||
assert.match(packets, /15 packets transmitted, 15 received, 0% packet loss/);
|
||||
assert.deepEqual(await snapshot(), before, 'rejected candidate changed intent or runtime config');
|
||||
return { response, ping: packets, databaseUnchanged: true, runtimeUnchanged: true };
|
||||
}
|
||||
|
||||
await step(['F01'], 'unsupported Manual and PublicServer URLs rejected without members', async () => {
|
||||
const evidence = [];
|
||||
for (const method of ['Manual', 'PublicServer']) {
|
||||
const settings = { display_name: `empty-${method}`, network_name: `${username}-${method}`, networking_method: method, virtual_cidr: '10.90.0.0/24', secure_mode: true, ...(method === 'Manual' ? { peer_urls: ['bogus://127.0.0.1:9999'] } : { public_server_url: 'bogus://127.0.0.1:9999' }) };
|
||||
evidence.push(await rejectWithoutMutation('POST', '/networks', { settings }));
|
||||
}
|
||||
return evidence;
|
||||
});
|
||||
await step(['F01'], 'unsupported Manual and PublicServer updates rejected with running members', async () => {
|
||||
const evidence = [];
|
||||
for (const method of ['Manual', 'PublicServer']) {
|
||||
const settings = { ...network, networking_method: method, ...(method === 'Manual' ? { peer_urls: ['bogus://127.0.0.1:9999'] } : { public_server_url: 'bogus://127.0.0.1:9999' }) };
|
||||
evidence.push(await rejectWithoutMutation('PATCH', `/networks/${network.network_id}`, { settings }));
|
||||
}
|
||||
return evidence;
|
||||
});
|
||||
await step(['F01'], 'supported discovery protocols accepted for empty networks', async () => {
|
||||
const evidence = [];
|
||||
for (const protocol of ['http', 'https', 'txt', 'srv']) {
|
||||
for (const method of ['Manual', 'PublicServer']) {
|
||||
const url = `${protocol}://discovery.invalid`;
|
||||
const created = await createNetwork(`${method}-${protocol}`, { networking_method: method, ...(method === 'Manual' ? { peer_urls: [url] } : { public_server_url: url }) });
|
||||
const saved = await request('GET', `/networks/${created.network_id}`);
|
||||
assert.equal(method === 'Manual' ? saved.peer_urls[0] : saved.public_server_url, url);
|
||||
evidence.push({ method, url, accepted: true });
|
||||
await request('DELETE', `/networks/${created.network_id}`, undefined, 204);
|
||||
}
|
||||
}
|
||||
return evidence;
|
||||
});
|
||||
await step(['F02'], 'IPv6 proxy CIDR rejected before persistence', () => rejectWithoutMutation('PATCH', memberPath(a), { proxy_cidrs: ['2001:db8:240::/64'] }));
|
||||
await step(['F02'], 'IPv4 proxy CIDR accepted and advertised by real Core', async () => {
|
||||
ns(a, ['ip', 'addr', 'add', '198.18.240.1/24', 'dev', 'lo']);
|
||||
await request('PATCH', memberPath(a), { proxy_cidrs: ['198.18.240.0/24'] });
|
||||
await waitFor(async () => JSON.stringify(await rpc(b, network.network_id, 'list_route')).includes('198.18.240.0/24'), 'IPv4 route advertisement');
|
||||
await eventualPing(b, '198.18.240.1');
|
||||
const evidence = ping(b, '198.18.240.1');
|
||||
await request('PATCH', memberPath(a), { proxy_cidrs: [] });
|
||||
await waitFor(async () => !(await runtimeConfig(a)).proxy_cidrs?.length, 'proxy clear applied');
|
||||
return evidence;
|
||||
});
|
||||
const portal = { enabled: true, wireguard_listen: '0.0.0.0:15820', wireguard_private_key: docker(['wg', 'genkey']), clients: [{ name: 'valid-client', virtual_ip: '10.88.99.210/24', groups: [] }] };
|
||||
await step(['F02'], 'valid WireGuard client accepted and applied', async () => {
|
||||
await setOverride(a, { ...baseOverride(a), vpn_portal_config: portal });
|
||||
const runtime = await waitFor(async () => {
|
||||
const result = await rpc(a, network.network_id, 'get_vpn_portal_info', 'api.instance.VpnPortalRpcService');
|
||||
return result.vpn_portal_info?.clients?.[0]?.client_config ? result.vpn_portal_info : false;
|
||||
}, 'valid WireGuard portal');
|
||||
await eventualPing(a, b.vip);
|
||||
return { clients: runtime.clients.map(client => ({ name: client.name, virtual_ip: client.virtual_ip })), ping: ping(a, b.vip) };
|
||||
});
|
||||
const validClient = portal.clients[0];
|
||||
const invalidClients = [
|
||||
['duplicate IP', [validClient, { ...validClient, name: 'second-client' }]],
|
||||
['duplicate name', [validClient, { ...validClient, virtual_ip: '10.88.99.211/24' }]],
|
||||
['invalid IP', [{ ...validClient, virtual_ip: 'not-an-ip' }]],
|
||||
['network address', [{ ...validClient, virtual_ip: '10.88.99.0/24' }]],
|
||||
['broadcast address', [{ ...validClient, virtual_ip: '10.88.99.255/24' }]],
|
||||
['host address', [{ ...validClient, virtual_ip: `${a.vip}/24` }]],
|
||||
['unknown ACL group', [{ ...validClient, groups: ['not-declared'] }]],
|
||||
];
|
||||
for (const [label, clients] of invalidClients) {
|
||||
await step(['F02'], `WireGuard ${label} rejected without disrupting active network`, () => rejectWithoutMutation('PUT', `${memberPath(a)}/config`, { config: { ...baseOverride(a), vpn_portal_config: { ...portal, clients } } }));
|
||||
}
|
||||
await step(['F02'], 'WireGuard client clearing accepted and applied', async () => {
|
||||
await setOverride(a, { ...baseOverride(a), vpn_portal_config: { ...portal, clients: [] } });
|
||||
await waitFor(async () => {
|
||||
const result = await rpc(a, network.network_id, 'get_vpn_portal_info', 'api.instance.VpnPortalRpcService');
|
||||
return result.vpn_portal_info && !(result.vpn_portal_info.clients?.length);
|
||||
}, 'WireGuard client clear');
|
||||
await eventualPing(a, b.vip);
|
||||
return { config: await request('GET', `${memberPath(a)}/config`), ping: ping(a, b.vip) };
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({ ids: [], name: 'suite setup or infrastructure', status: 'FAIL', error: error.stack });
|
||||
console.error(error);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
for (const p of [...processes].reverse()) await stop(p);
|
||||
for (const name of namespaces) docker(['ip', 'netns', 'delete', name], { allowFailure: true });
|
||||
if (bridgeCreated) docker(['ip', 'link', 'delete', bridge], { allowFailure: true });
|
||||
await writeFile(join(output, 'results.json'), JSON.stringify(results, null, 2));
|
||||
console.log(`RESULTS=${join(output, 'results.json')}`);
|
||||
}
|
||||
process.exitCode = results.some(r => r.status === 'FAIL') ? 1 : 0;
|
||||
@@ -0,0 +1,664 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { after, before, test } from 'node:test';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const base = process.env.WEB_TEST_URL || 'http://127.0.0.1:5198';
|
||||
const screenshotDir = process.env.WEB_SCREENSHOT_DIR;
|
||||
let browser;
|
||||
let server;
|
||||
|
||||
before(async () => {
|
||||
if (!process.env.WEB_TEST_URL) {
|
||||
server = spawn(process.execPath, ['node_modules/vite/bin/vite.js', 'preview', '--host', '127.0.0.1', '--port', '5198', '--strictPort'], { stdio: 'pipe' });
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (server.exitCode !== null) throw new Error('Vite exited before starting');
|
||||
if (await fetch(base).then(r => r.ok).catch(() => false)) break;
|
||||
await delay(100);
|
||||
}
|
||||
}
|
||||
browser = await chromium.launch({ headless: true });
|
||||
if (screenshotDir) await mkdir(screenshotDir, { recursive: true });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
server?.kill();
|
||||
});
|
||||
|
||||
const uuid = n => ({ part1: 0, part2: 0, part3: 0, part4: n });
|
||||
const id = n => `00000000-0000-0000-0000-${n.toString(16).padStart(12, '0')}`;
|
||||
|
||||
function fixture() {
|
||||
const machines = ['Amsterdam gateway', 'Build server', 'Office workstation', 'Storage node', 'Travel laptop', 'Windows desktop'].map((hostname, i) => ({
|
||||
client_url: `tcp://192.0.2.${i + 10}:11010`,
|
||||
info: { hostname, machine_id: uuid(i + 1), easytier_version: '2.4.5', report_time: '2026-09-13 12:00:00', running_network_instances: i === 0 ? [uuid(100)] : [] },
|
||||
location: { country: 'Netherlands', region: '', city: 'Amsterdam' },
|
||||
networks: [],
|
||||
}));
|
||||
const networks = ['Engineering', 'Home lab', 'Office network'].map((display_name, i) => ({
|
||||
network_id: `network-${i}`, display_name, network_name: `team-${i}`, networking_method: i === 0 ? 'Gateway' : 'Manual',
|
||||
online_member_count: 1, member_count: 2, network_secret: 'test-secret', peer_urls: i === 0 ? [] : ['tcp://192.0.2.1:11010'],
|
||||
}));
|
||||
const members = [{ member_id: id(101), device_id: id(1), hostname: 'Amsterdam gateway', hostname_override: null, virtual_ipv4: '10.126.126.1', online: true, running: true, version: '2.4.5', error_msg: null, runtime_virtual_ipv4: '10.126.126.1' }];
|
||||
return { machines, networks, members, memberConfig: {}, aclPolicy: { default_action: 'allow', rules: [] }, credentials: [], temporaryPeers: [], nodeRoutes: [], nodePeers: [], nodeAclStats: [], loggerConfig: { level: 'INFO' }, writes: [], requests: [], failures: new Set(), gatewayDelay: 0, gatewayEnabled: true };
|
||||
}
|
||||
|
||||
async function open(t, route = '/h', options = {}, configure = () => {}) {
|
||||
const state = fixture();
|
||||
configure(state);
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, ...options });
|
||||
t.after(() => context.close());
|
||||
await context.addInitScript(() => localStorage.setItem('lang', 'en'));
|
||||
if (state.noRandomUUID) {
|
||||
await context.addInitScript(() => {
|
||||
// Remote HTTP exposes getRandomValues but not randomUUID.
|
||||
Object.defineProperty(crypto, 'randomUUID', { value: undefined });
|
||||
const getRandomValues = crypto.getRandomValues.bind(crypto);
|
||||
window.secureRandomCalls = 0;
|
||||
crypto.getRandomValues = values => {
|
||||
window.secureRandomCalls++;
|
||||
return getRandomValues(values);
|
||||
};
|
||||
});
|
||||
}
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
t.after(() => assert.deepEqual(errors, [], 'no uncaught browser errors'));
|
||||
await page.route('**/api_meta.js', route => route.fulfill({
|
||||
contentType: 'text/javascript',
|
||||
body: `window.apiMeta = ${JSON.stringify({ api_host: state.apiHost ?? '' })};`,
|
||||
}));
|
||||
await page.route('**/api/v1/**', async route => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname.replace('/api/v1', '');
|
||||
const method = request.method();
|
||||
const payload = request.postDataJSON();
|
||||
state.requests.push({ path, method });
|
||||
if (method !== 'GET') state.writes.push({ path, method, payload });
|
||||
if (state.failures.has(path)) return route.fulfill({ status: 503, json: { message: 'Test unavailable' } });
|
||||
let result = {};
|
||||
if (path === '/summary') result = { device_count: state.machines.length };
|
||||
else if (path === '/console-info') result = { username: 'test-user', config_server_protocol: 'udp', config_server_port: 22020, webhook_auth: state.externalConsole ?? false };
|
||||
else if (path === '/machines') result = { machines: state.machines };
|
||||
else if (path === '/networks/gateway-info') {
|
||||
await delay(state.gatewayDelay);
|
||||
result = { enabled: state.gatewayEnabled, peer_url: 'tcp://192.0.2.1:11010', relay_data: true };
|
||||
} else if (path === '/networks') {
|
||||
if (method === 'POST') {
|
||||
result = { ...payload.settings, network_id: `created-${state.networks.length}`, network_secret: 'new-secret', member_count: 0, online_member_count: 0 };
|
||||
state.networks.push(result);
|
||||
} else result = { networks: state.networks };
|
||||
} else if (/^\/networks\/[^/]+$/.test(path)) {
|
||||
const network = state.networks.find(n => n.network_id === path.split('/')[2]);
|
||||
if (method === 'PATCH') Object.assign(network, payload.settings, payload.network_secret ? { network_secret: payload.network_secret } : {});
|
||||
if (method === 'DELETE') state.networks = state.networks.filter(n => n !== network);
|
||||
result = network ?? {};
|
||||
} else if (path.endsWith('/acl-policy')) {
|
||||
if (method === 'PUT') state.aclPolicy = payload;
|
||||
result = { policy: state.aclPolicy };
|
||||
} else if (/^\/networks\/[^/]+\/members$/.test(path)) {
|
||||
if (method === 'POST') payload.device_ids.forEach(device_id => state.members.push({ device_id, hostname: 'Added device', online: true }));
|
||||
result = { members: state.members, temporary_peers: state.temporaryPeers };
|
||||
} else if (/^\/networks\/[^/]+\/members\/[^/]+\/config$/.test(path)) {
|
||||
if (method === 'PUT') state.memberConfig = payload.config;
|
||||
result = method === 'GET' ? state.memberConfig : state.members[0];
|
||||
} else if (/^\/networks\/[^/]+\/members\//.test(path)) {
|
||||
const member = state.members.find(m => m.device_id === path.split('/').at(-1));
|
||||
if (method === 'PATCH') Object.assign(member, payload);
|
||||
if (method === 'DELETE') state.members = state.members.filter(m => m !== member);
|
||||
result = member ?? {};
|
||||
} else if (/^\/networks\/[^/]+\/credentials$/.test(path)) {
|
||||
result = { credentials: state.credentials };
|
||||
} else if (path.endsWith('/proxy-rpc')) {
|
||||
if (payload.method_name === 'list_route') result = { routes: state.nodeRoutes };
|
||||
else if (payload.method_name === 'list_peer') result = { peer_infos: state.nodePeers };
|
||||
else if (payload.method_name === 'get_acl_stats') result = { acl_stats: { rules: state.nodeAclStats } };
|
||||
else if (payload.method_name === 'get_logger_config') result = state.loggerConfig;
|
||||
else if (payload.method_name === 'set_logger_config') state.loggerConfig = { level: ['DISABLED', 'ERROR', 'WARNING', 'INFO', 'DEBUG', 'TRACE'][payload.payload.level] };
|
||||
else if (payload.method_name === 'show_node_info') result = { node_info: { config: '[instance]\nname = "Engineering"' } };
|
||||
} else if (/\/machines\/[^/]+\/networks$/.test(path)) result = { running_inst_ids: [uuid(100)], disabled_inst_ids: [] };
|
||||
else if (path.endsWith('/networks/metas')) result = { metas: { [id(100)]: { network_name: 'Engineering', config_permission: 7 } } };
|
||||
else if (path.includes('/networks/info/')) result = { info: { map: { [id(100)]: { error_msg: 'Test device is reconnecting' } } } };
|
||||
else if (path.includes('/networks/config/')) result = { instance_id: id(100), network_name: 'Engineering', hostname: 'Amsterdam gateway', networking_method: 'Standalone' };
|
||||
await route.fulfill({ json: result });
|
||||
});
|
||||
await page.goto(`${base}/#${route}`);
|
||||
await page.locator('.console-page').waitFor();
|
||||
return { page, state, context };
|
||||
}
|
||||
|
||||
async function refresh(page) {
|
||||
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
|
||||
}
|
||||
|
||||
test('overview uses real counts and recovers from partial refresh failures', async t => {
|
||||
const { page, state } = await open(t);
|
||||
await page.waitForFunction(() => document.querySelectorAll('.summary-value')[2]?.textContent.trim() === '3');
|
||||
assert.deepEqual(await page.locator('.summary-value').allTextContents().then(values => values.map(s => s.trim())), ['6', '0', '3', '1']);
|
||||
state.machines[0].online = true;
|
||||
await refresh(page);
|
||||
await page.waitForFunction(() => document.querySelectorAll('.summary-value')[1]?.textContent.trim() === '1');
|
||||
assert.equal(await page.locator('.overview-columns section').first().locator('.preview-row').count(), 5);
|
||||
state.failures.add('/networks');
|
||||
state.machines.pop();
|
||||
await refresh(page);
|
||||
await page.getByText('Unable to refresh data.', { exact: false }).waitFor();
|
||||
assert.equal((await page.locator('.summary-value').nth(2).textContent()).trim(), '3');
|
||||
await page.waitForFunction(() => document.querySelector('.summary-value')?.textContent.trim() === '5');
|
||||
state.failures.clear();
|
||||
state.networks.pop();
|
||||
await page.waitForFunction(() => document.querySelectorAll('.summary-value')[2]?.textContent.trim() === '2');
|
||||
assert.equal(await page.getByText('Unable to refresh data.', { exact: false }).count(), 0);
|
||||
});
|
||||
|
||||
test('enrollment command uses the console info response', async t => {
|
||||
const { page } = await open(t);
|
||||
await page.getByRole('button', { name: 'Device Enrollment', exact: true }).click();
|
||||
await page.getByText('easytier-core --config-server udp://127.0.0.1:22020/test-user').waitFor();
|
||||
});
|
||||
|
||||
for (const [apiHost, hostname] of [
|
||||
['https://api.example.test:8443/', 'api.example.test'],
|
||||
['http://[2001:db8::1]:8848/', '[2001:db8::1]'],
|
||||
['.', '127.0.0.1'],
|
||||
]) {
|
||||
test(`enrollment command uses the configured API hostname for ${apiHost}`, async t => {
|
||||
const { page } = await open(t, '/h', {}, state => { state.apiHost = apiHost; });
|
||||
await page.getByRole('button', { name: 'Device Enrollment', exact: true }).click();
|
||||
await page.getByText(`easytier-core --config-server udp://${hostname}:22020/test-user`).waitFor();
|
||||
});
|
||||
}
|
||||
|
||||
test('device search, sort, expansion and routed drawer survive reload and history', async t => {
|
||||
const { page } = await open(t, '/h/deviceList');
|
||||
const table = page.locator('.desktop-list');
|
||||
await table.getByRole('button', { name: 'Amsterdam gateway', exact: true }).waitFor();
|
||||
await page.getByRole('textbox', { name: 'Search name or address' }).fill('192.0.2.10');
|
||||
assert.equal(await table.locator('tbody > tr').count(), 1);
|
||||
await page.getByRole('textbox', { name: 'Search name or address' }).fill('');
|
||||
// 无静态 sortField:离线排最后的预排序是默认视图;点击 hostname 列头一次升序、再点一次降序
|
||||
await page.getByRole('columnheader', { name: 'Hostname' }).click(); // 升序
|
||||
await page.getByRole('columnheader', { name: 'Hostname' }).click(); // 降序
|
||||
assert.match(await table.locator('tbody > tr').first().textContent(), /Windows desktop/);
|
||||
await table.locator('tbody > tr').first().getByRole('button').first().click();
|
||||
await table.locator('.device-details').waitFor();
|
||||
await table.getByRole('button', { name: 'Amsterdam gateway', exact: true }).click();
|
||||
await page.locator('.console-device-drawer').waitFor();
|
||||
assert.ok(page.url().endsWith(`/device/${id(1)}/${id(100)}`));
|
||||
await page.reload();
|
||||
await page.locator('.console-device-drawer h2').filter({ hasText: 'Amsterdam gateway' }).waitFor();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForURL('**/#/h/deviceList');
|
||||
await page.goBack();
|
||||
await page.locator('.console-device-drawer').waitFor();
|
||||
});
|
||||
|
||||
test('device list card view toggle renders cards and persists across reload', async t => {
|
||||
const { page } = await open(t, '/h/deviceList');
|
||||
const table = page.locator('.desktop-list');
|
||||
await table.getByRole('button', { name: 'Amsterdam gateway', exact: true }).waitFor();
|
||||
assert.equal(await page.locator('.device-card').count(), 0);
|
||||
await page.locator('.view-toggle .pi-th-large').click();
|
||||
await page.locator('.device-card').first().waitFor();
|
||||
assert.equal(await page.locator('.device-card').count(), 6);
|
||||
assert.equal(await table.count(), 0);
|
||||
await page.reload();
|
||||
await page.locator('.device-card').first().waitFor();
|
||||
assert.equal(await page.locator('.device-card').count(), 6);
|
||||
await page.locator('.view-toggle .pi-table').click();
|
||||
await table.getByRole('button', { name: 'Amsterdam gateway', exact: true }).waitFor();
|
||||
assert.equal(await table.locator('tbody > tr').count(), 6);
|
||||
});
|
||||
|
||||
test('network creation retains gateway and advanced standalone modes', async t => {
|
||||
const { page, state } = await open(t, '/h/networks');
|
||||
await page.locator('.desktop-list').getByRole('button', { name: 'Engineering', exact: true }).waitFor();
|
||||
await page.getByRole('textbox', { name: 'Search networks' }).fill('no-match');
|
||||
await page.getByText('No matching results', { exact: true }).waitFor();
|
||||
await page.getByRole('button', { name: 'Clear search' }).click();
|
||||
await page.getByRole('button', { name: 'Create Network', exact: true }).click();
|
||||
await page.getByRole('dialog').getByText('Secure Mode', { exact: true }).waitFor();
|
||||
await page.getByRole('dialog').locator('label[for="create-secure-mode"] + .pi-question-circle').hover();
|
||||
await page.getByText('Noise encrypted handshakes with identity verification; enables temporary credentials. Member instances restart on change').waitFor();
|
||||
await page.locator('#network-display-name').fill('Research');
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Confirm', exact: true }).click();
|
||||
await page.waitForURL('**/networks/created-*');
|
||||
assert.equal(state.writes.find(w => w.path === '/networks')?.payload.settings.networking_method, 'Gateway');
|
||||
await page.getByRole('button', { name: 'Back to networks' }).click();
|
||||
await page.getByRole('button', { name: 'Create Network', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: /Advanced/ }).click();
|
||||
await page.getByRole('dialog').locator('.p-button-danger:visible').click();
|
||||
await page.locator('#network-display-name').fill('Isolated lab');
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Confirm', exact: true }).click();
|
||||
await page.waitForURL('**/networks/created-*');
|
||||
assert.equal(state.writes.filter(w => w.path === '/networks').at(-1).payload.settings.networking_method, 'Standalone');
|
||||
});
|
||||
|
||||
test('PublicServer settings retain discovery mode when renamed or edited to the gateway URL', async t => {
|
||||
const { page, state } = await open(t, '/h/networks/network-0', {}, state => {
|
||||
Object.assign(state.networks[0], {
|
||||
networking_method: 'PublicServer',
|
||||
public_server_url: 'tcp://public.example:11010',
|
||||
});
|
||||
});
|
||||
await page.getByRole('tab', { name: 'Settings', exact: true }).click();
|
||||
await page.locator('#settings-display-name').fill('Public network');
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await page.getByRole('heading', { name: 'Public network', exact: true }).waitFor();
|
||||
let settings = state.writes.filter(w => w.method === 'PATCH').at(-1).payload.settings;
|
||||
assert.equal(settings.networking_method, 'PublicServer');
|
||||
assert.equal(settings.public_server_url, 'tcp://public.example:11010');
|
||||
assert.deepEqual(settings.peer_urls, []);
|
||||
|
||||
await page.locator('#settings-initial-nodes .url-input-full input.grow').fill('192.0.2.1');
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await page.waitForResponse(response => response.request().method() === 'GET' && response.url().endsWith('/networks/network-0'));
|
||||
settings = state.writes.filter(w => w.method === 'PATCH').at(-1).payload.settings;
|
||||
assert.equal(settings.networking_method, 'PublicServer');
|
||||
assert.equal(settings.public_server_url, 'tcp://192.0.2.1:11010');
|
||||
assert.deepEqual(settings.peer_urls, []);
|
||||
});
|
||||
|
||||
test('network tabs preserve gateway settings and unsaved input across polling', async t => {
|
||||
const { page, state } = await open(t, '/h/networks/network-0');
|
||||
await page.getByRole('tab', { name: 'Settings', exact: true }).click();
|
||||
await page.locator('#settings-display-name').fill('Engineering draft');
|
||||
const count = state.requests.filter(r => r.path === '/networks/network-0').length;
|
||||
for (let i = 0; i < 50 && state.requests.filter(r => r.path === '/networks/network-0').length <= count; i++) await delay(100);
|
||||
assert.ok(state.requests.filter(r => r.path === '/networks/network-0').length > count, 'polling continues');
|
||||
assert.equal(await page.locator('#settings-display-name').inputValue(), 'Engineering draft');
|
||||
await page.getByRole('tab', { name: 'Members', exact: true }).click();
|
||||
await page.locator('.desktop-list').getByText('Running', { exact: true }).waitFor();
|
||||
await page.getByRole('tab', { name: 'Settings', exact: true }).click();
|
||||
assert.equal(await page.locator('#settings-display-name').inputValue(), 'Engineering draft');
|
||||
await page.locator('#regenerate-secret').check();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await page.getByRole('heading', { name: 'Engineering draft' }).waitFor();
|
||||
const saved = state.writes.find(w => w.method === 'PATCH');
|
||||
assert.equal(saved.payload.settings.networking_method, 'Gateway');
|
||||
assert.match(saved.payload.network_secret, /^[0-9a-f-]{36}$/);
|
||||
await page.getByRole('button', { name: 'Delete Network', exact: true }).click();
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
assert.equal(state.writes.some(w => w.method === 'DELETE'), false);
|
||||
await page.getByRole('button', { name: 'Delete Network', exact: true }).click();
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: 'Confirm', exact: true }).click();
|
||||
await page.waitForURL('**/#/h/networks');
|
||||
assert.equal(state.networks.length, 2);
|
||||
});
|
||||
|
||||
test('ACL rules and network secrets use secure randomness without randomUUID', async t => {
|
||||
const { page, state } = await open(t, '/h/networks/network-0', {}, state => { state.noRandomUUID = true; });
|
||||
assert.equal(await page.evaluate(() => typeof crypto.randomUUID), 'undefined');
|
||||
await page.getByRole('tab', { name: 'Access Control', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Add Rule', exact: true }).click();
|
||||
const editor = page.getByRole('complementary').filter({ has: page.locator('#acl-rule-name') });
|
||||
await editor.locator('#acl-rule-name').fill('Allow ping');
|
||||
for (const select of ['Search and choose the members that initiate access', 'Search and choose target members or subnets']) {
|
||||
await editor.getByText(select, { exact: true }).click();
|
||||
await page.getByRole('option', { name: 'All members', exact: true }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
await editor.getByRole('checkbox', { name: 'ICMP', exact: true }).check();
|
||||
await editor.getByRole('button', { name: 'Save Rule', exact: true }).click();
|
||||
await editor.waitFor({ state: 'detached' });
|
||||
await page.getByRole('button', { name: 'Save Policy', exact: true }).click();
|
||||
await page.getByText('Access policy saved', { exact: true }).waitFor();
|
||||
assert.match(state.aclPolicy.rules[0].id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
|
||||
await page.getByRole('tab', { name: 'Settings', exact: true }).click();
|
||||
const randomCalls = await page.evaluate(() => window.secureRandomCalls);
|
||||
await page.locator('#regenerate-secret').check();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await page.getByText('Config Saved', { exact: true }).waitFor();
|
||||
const saved = state.writes.find(write => write.method === 'PATCH');
|
||||
assert.match(saved.payload.network_secret, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
assert.ok(await page.evaluate(() => window.secureRandomCalls) > randomCalls);
|
||||
});
|
||||
|
||||
test('a late member configuration response cannot overwrite the next member', async t => {
|
||||
const { page, state } = await open(t, '/h/networks/network-0', {}, state => {
|
||||
state.members.push({ ...state.members[0], member_id: id(102), device_id: id(2), hostname: 'Build server' });
|
||||
});
|
||||
let releaseFirst;
|
||||
const firstResponse = new Promise(resolve => { releaseFirst = resolve; });
|
||||
t.after(() => releaseFirst());
|
||||
const firstPath = `/networks/network-0/members/${id(1)}/config`;
|
||||
await page.route('**/networks/network-0/members/*/config', async route => {
|
||||
if (route.request().method() !== 'GET') return route.fallback();
|
||||
const first = route.request().url().endsWith(firstPath);
|
||||
if (first) await firstResponse;
|
||||
await route.fulfill({ json: { hostname: first ? 'configuration-a' : 'configuration-b' } });
|
||||
});
|
||||
const firstRequested = page.waitForRequest(request => request.url().endsWith(firstPath));
|
||||
await page.getByRole('row').filter({ hasText: 'Amsterdam gateway' }).getByRole('button', { name: 'Edit Member' }).click();
|
||||
await firstRequested;
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await page.getByRole('dialog').waitFor({ state: 'detached' });
|
||||
await page.getByRole('row').filter({ hasText: 'Build server' }).getByRole('button', { name: 'Edit Member' }).click();
|
||||
await page.getByRole('dialog').getByRole('tab', { name: 'Advanced Config' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Advanced Settings', exact: true }).click();
|
||||
await page.locator('#hostname').waitFor();
|
||||
assert.equal(await page.locator('#hostname').inputValue(), 'configuration-b');
|
||||
const lateResponse = page.waitForResponse(response => response.url().endsWith(firstPath));
|
||||
releaseFirst();
|
||||
await (await lateResponse).finished();
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
assert.equal(await page.locator('#hostname').inputValue(), 'configuration-b');
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await page.getByRole('dialog').waitFor({ state: 'detached' });
|
||||
const saved = state.writes.find(write => write.method === 'PUT' && write.path.endsWith('/config'));
|
||||
assert.equal(saved.path, `/networks/network-0/members/${id(2)}/config`);
|
||||
assert.equal(saved.payload.config.hostname, 'configuration-b');
|
||||
});
|
||||
|
||||
test('settings wait for gateway discovery before exposing the save action', async t => {
|
||||
const { page, state } = await open(t, '/h/networks/network-0', {}, state => { state.gatewayDelay = 1500; });
|
||||
await page.getByRole('tab', { name: 'Settings', exact: true }).click();
|
||||
assert.equal(await page.getByRole('button', { name: 'Save', exact: true }).count(), 0);
|
||||
await page.locator('#settings-display-name').waitFor();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
for (let i = 0; i < 50 && !state.writes.some(w => w.method === 'PATCH'); i++) await delay(100);
|
||||
assert.equal(state.writes.find(w => w.method === 'PATCH').payload.settings.networking_method, 'Gateway');
|
||||
});
|
||||
|
||||
test('editing an automatic member address keeps automatic assignment', async t => {
|
||||
const { page, state } = await open(t, '/h/networks/network-0', {}, state => {
|
||||
state.networks[0].virtual_cidr = '10.126.0.0/16';
|
||||
Object.assign(state.members[0], { virtual_ipv4: null, allocated_ipv4: '10.126.126.7', runtime_virtual_ipv4: null, online: false, running: false });
|
||||
});
|
||||
await page.locator('.desktop-list').getByText('10.126.126.7/16', { exact: true }).waitFor();
|
||||
await page.getByRole('row').filter({ hasText: 'Amsterdam gateway' }).getByRole('button', { name: 'Edit Member' }).click();
|
||||
assert.equal(await page.locator('#edit-virtual-ipv4').inputValue(), '');
|
||||
await page.locator('#edit-hostname-override').fill('automatic-member');
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await page.getByRole('dialog').waitFor({ state: 'detached' });
|
||||
assert.equal(state.writes.find(write => write.method === 'PATCH').payload.virtual_ipv4, '');
|
||||
});
|
||||
|
||||
test('member editing, adding and removal keep existing request semantics', async t => {
|
||||
const { page, state } = await open(t, '/h/networks/network-0');
|
||||
await page.getByRole('row').filter({ hasText: 'Amsterdam gateway' }).getByRole('button', { name: 'Edit Member' }).click();
|
||||
await page.locator('#edit-hostname-override').fill('gateway-west');
|
||||
await page.locator('#edit-virtual-ipv4').fill('10.126.126.20');
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await page.getByRole('row').filter({ hasText: 'gateway-west' }).waitFor();
|
||||
assert.equal(state.members[0].virtual_ipv4, '10.126.126.20');
|
||||
await page.getByRole('button', { name: 'Add Devices' }).click();
|
||||
await page.getByRole('dialog').getByRole('row').filter({ hasText: 'Build server' }).getByRole('checkbox').check();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Confirm', exact: true }).click();
|
||||
await page.getByRole('row').filter({ hasText: 'Added device' }).waitFor();
|
||||
assert.deepEqual(state.writes.find(w => w.method === 'POST' && w.path.endsWith('/members')).payload.device_ids, [id(2)]);
|
||||
await page.getByRole('row').filter({ hasText: 'Added device' }).getByRole('button', { name: 'Remove Device' }).click();
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: 'Confirm', exact: true }).click();
|
||||
await page.getByRole('row').filter({ hasText: 'Added device' }).waitFor({ state: 'detached' });
|
||||
assert.equal(state.members.length, 1);
|
||||
});
|
||||
|
||||
test('central member WireGuard clients stay editable through the member configuration', async t => {
|
||||
const { page, state } = await open(t, '/h/networks/network-0', {}, state => {
|
||||
state.memberConfig = {
|
||||
proxy_cidrs: ['192.168.20.0/24'],
|
||||
vpn_portal_config: {
|
||||
enabled: true,
|
||||
wireguard_listen: '0.0.0.0:22022',
|
||||
wireguard_private_key: 'KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKio=',
|
||||
clients: [{ name: 'phone', virtual_ip: '10.126.126.2/24', groups: [] }],
|
||||
},
|
||||
};
|
||||
});
|
||||
const key = state.memberConfig.vpn_portal_config.wireguard_private_key;
|
||||
const edit = async () => {
|
||||
await page.getByRole('button', { name: 'Edit Member' }).click();
|
||||
await page.getByRole('dialog').getByRole('tab', { name: 'Advanced Config' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Advanced Settings', exact: true }).click();
|
||||
await page.locator('#vpn_portal_client_name_0').waitFor();
|
||||
};
|
||||
await edit();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Add device', exact: true }).click();
|
||||
await page.locator('#vpn_portal_client_virtual_ip_1').fill('10.126.126.3/24');
|
||||
const generatedName = await page.locator('#vpn_portal_client_name_1').inputValue();
|
||||
assert.match(generatedName, /^device-[a-f0-9]{8}$/);
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await page.getByRole('dialog').waitFor({ state: 'detached' });
|
||||
assert.equal(state.memberConfig.vpn_portal_config.clients.length, 2);
|
||||
assert.equal(state.memberConfig.vpn_portal_config.wireguard_private_key, key);
|
||||
assert.deepEqual(state.memberConfig.proxy_cidrs, ['192.168.20.0/24']);
|
||||
|
||||
await edit();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete device', exact: true }).first().click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await page.getByRole('dialog').waitFor({ state: 'detached' });
|
||||
assert.deepEqual(state.memberConfig.vpn_portal_config.clients.map(client => client.name), [generatedName]);
|
||||
assert.equal(state.writes.filter(write => write.method === 'PUT' && write.path.endsWith('/config')).length, 2);
|
||||
assert.equal(state.writes.some(write => write.path.endsWith('/vpn-portal-clients') || write.payload?.method_name === 'patch_config'), false);
|
||||
});
|
||||
|
||||
test('credentials tab and temporary devices render', async t => {
|
||||
const { page } = await open(t, '/h/networks/network-0', {}, state => {
|
||||
state.networks[0].secure_mode = true;
|
||||
const peers = [
|
||||
{
|
||||
peer_id: 42, credential_id: 'cred-1234567890abcdef', credential_expiry_unix: 1893456000,
|
||||
hostname: 'Visitor laptop', ipv4: '10.126.126.50', version: '2.4.5',
|
||||
},
|
||||
{
|
||||
peer_id: 99, credential_id: 'cred-1234567890abcdef', credential_expiry_unix: 1893456000,
|
||||
hostname: 'Visitor phone', ipv4: '10.126.126.51', version: '2.4.5',
|
||||
},
|
||||
];
|
||||
state.credentials = [{
|
||||
credential_id: 'cred-1234567890abcdef', credential_secret: 'test-credential-secret',
|
||||
expiry_unix: 1893456000, reusable: true, online_peers: peers,
|
||||
}];
|
||||
state.temporaryPeers = peers;
|
||||
});
|
||||
await page.getByRole('tab', { name: 'Credentials', exact: true }).click();
|
||||
await page.getByRole('cell', { name: /cred-1234/ }).waitFor();
|
||||
await page.getByText('Visitor laptop').waitFor();
|
||||
await page.getByText('Visitor phone').waitFor();
|
||||
await page.getByRole('tab', { name: 'Members', exact: true }).click();
|
||||
await page.getByText('Temporary Devices').waitFor();
|
||||
await page.getByRole('cell', { name: /Visitor laptop/ }).waitFor();
|
||||
await page.getByRole('cell', { name: /Visitor phone/ }).waitFor();
|
||||
});
|
||||
|
||||
test('PublicServer credential exports use the configured public server', async t => {
|
||||
const peer = 'tcp://public.example.test:11010';
|
||||
const { page } = await open(t, '/h/networks/network-0', {}, state => {
|
||||
Object.assign(state.networks[0], {
|
||||
secure_mode: true, networking_method: 'PublicServer', public_server_url: peer,
|
||||
peer_urls: [],
|
||||
});
|
||||
state.credentials = [{
|
||||
credential_id: 'cred-1234567890abcdef', credential_secret: 'test-credential-secret',
|
||||
expiry_unix: 1893456000, reusable: true, online_peers: [],
|
||||
}];
|
||||
});
|
||||
await page.getByRole('tab', { name: 'Credentials', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Show join command', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Temporary device join command' });
|
||||
assert.equal(await dialog.locator('pre').textContent(),
|
||||
`easytier-core --network-name team-0 --secure-mode --credential test-credential-secret -p ${peer}`);
|
||||
await dialog.getByRole('button', { name: 'Config file', exact: true }).click();
|
||||
assert.equal(await dialog.locator('pre').textContent(), [
|
||||
'[network_identity]', 'network_name = "team-0"', '',
|
||||
'[[peer]]', `uri = "${peer}"`, '',
|
||||
'[secure_mode]', 'enabled = true', 'local_private_key = "test-credential-secret"',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('node detail gives empty peers a clear home and keeps actions separate', async t => {
|
||||
const { page, state } = await open(t, `/h/networks/${id(100)}`, {}, state => { state.networks[0].network_id = id(100); });
|
||||
await page.getByRole('button', { name: 'Node Detail' }).click();
|
||||
const drawer = page.locator('.console-node-drawer');
|
||||
await drawer.getByText('No other nodes yet').waitFor();
|
||||
await page.waitForFunction(() => Math.abs(document.querySelector('.console-node-drawer').getBoundingClientRect().right - innerWidth) < 1);
|
||||
assert.equal(await drawer.getByRole('columnheader').count(), 0);
|
||||
assert.ok(await drawer.locator('.node-drawer-empty').evaluate(el => el.getBoundingClientRect().height < 60));
|
||||
assert.ok(await drawer.locator('.node-drawer-summary').evaluate(el => el.getBoundingClientRect().height < 60));
|
||||
assert.deepEqual(await drawer.locator('.node-drawer-metric strong').allTextContents().then(values => values.map(s => s.trim())), ['Running', '0', '0', '0']);
|
||||
assert.equal(await drawer.locator('.node-drawer-title').textContent(), 'Amsterdam gateway');
|
||||
assert.equal(await drawer.locator('.node-drawer-title').count(), 1);
|
||||
if (screenshotDir) await drawer.screenshot({ path: `${screenshotDir}/node-detail-empty.png`, animations: 'disabled' });
|
||||
await drawer.getByRole('tab', { name: 'Settings & actions' }).click();
|
||||
await drawer.getByText('Log Level', { exact: true }).waitFor();
|
||||
await drawer.getByRole('button', { name: 'Export Config' }).click();
|
||||
await page.getByText('[instance]', { exact: false }).waitFor();
|
||||
assert.ok(state.requests.some(r => r.path.endsWith('/proxy-rpc')));
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: 'Switch language' }).click();
|
||||
await page.getByRole('button', { name: '节点详情' }).click();
|
||||
await drawer.getByText('暂无其他节点').waitFor();
|
||||
await page.waitForFunction(() => Math.abs(document.querySelector('.console-node-drawer').getBoundingClientRect().right - innerWidth) < 1);
|
||||
if (screenshotDir) await drawer.screenshot({ path: `${screenshotDir}/node-detail-empty-cn-dark.png`, animations: 'disabled' });
|
||||
});
|
||||
|
||||
test('logger levels decode protobuf responses, persist selections and translate labels', async t => {
|
||||
const labels = ['Disabled', 'Error', 'Warning', 'Info', 'Debug', 'Trace'];
|
||||
const { page, state } = await open(t, `/h/networks/${id(100)}`, {}, state => { state.networks[0].network_id = id(100); });
|
||||
const drawer = page.locator('.console-node-drawer');
|
||||
const select = drawer.getByRole('combobox');
|
||||
const openSettings = async () => {
|
||||
await page.getByRole('button', { name: 'Node Detail', exact: true }).click();
|
||||
await drawer.getByRole('tab', { name: 'Settings & actions', exact: true }).click();
|
||||
};
|
||||
// Disabled is omitted by protobuf JSON; named and numeric values are valid.
|
||||
for (const [level, label] of [[undefined, 'Disabled'], ...labels.map(label => [label.toUpperCase(), label]), [3, 'Info']]) {
|
||||
state.loggerConfig = level === undefined ? {} : { level };
|
||||
await openSettings();
|
||||
await select.getByText(label, { exact: true }).waitFor();
|
||||
await page.keyboard.press('Escape');
|
||||
await drawer.waitFor({ state: 'hidden' });
|
||||
}
|
||||
await openSettings();
|
||||
for (const [level, label] of labels.entries()) {
|
||||
await select.click();
|
||||
assert.deepEqual(await page.getByRole('option').allTextContents(), labels);
|
||||
await page.getByRole('option', { name: label, exact: true }).click();
|
||||
await page.waitForFunction(() => !document.querySelector('.console-node-drawer .p-select').classList.contains('p-disabled'));
|
||||
assert.equal(state.writes.filter(write => write.payload?.method_name === 'set_logger_config').at(-1).payload.payload.level, level);
|
||||
await select.getByText(label, { exact: true }).waitFor();
|
||||
await page.reload();
|
||||
await openSettings();
|
||||
await select.getByText(label, { exact: true }).waitFor();
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await drawer.waitFor({ state: 'hidden' });
|
||||
await page.getByRole('button', { name: 'Switch language', exact: true }).click();
|
||||
await page.getByRole('button', { name: '节点详情', exact: true }).click();
|
||||
await drawer.getByRole('tab').nth(1).click();
|
||||
await select.getByText('跟踪', { exact: true }).waitFor();
|
||||
await select.click();
|
||||
assert.deepEqual(await page.getByRole('option').allTextContents(), ['禁用', '错误', '警告', '信息', '调试', '跟踪']);
|
||||
});
|
||||
|
||||
test('node detail groups populated peers, routes, connections and ACL stats', async t => {
|
||||
const { page } = await open(t, `/h/networks/${id(100)}`, { viewport: { width: 390, height: 844 }, colorScheme: 'dark' }, state => {
|
||||
state.networks[0].network_id = id(100);
|
||||
state.nodeRoutes = [{ peer_id: 2, hostname: 'Build server', ipv4_addr: { address: { addr: 175005442 }, network_length: 24 }, cost: 1, path_latency: 8, proxy_cidrs: [], version: '2.4.5', next_hop_peer_id: 2 }];
|
||||
state.nodePeers = [{ peer_id: 2, conns: [{ conn_id: 'conn-1', tunnel: { tunnel_type: 'tcp', remote_addr: { url: 'tcp://192.0.2.11:11010' } }, stats: { latency_us: 8000, rx_bytes: 1024, tx_bytes: 2048 }, loss_rate: 0 }] }];
|
||||
state.nodeAclStats = [{ rule: { name: 'Allow build server' }, stat: { packet_count: 42, byte_count: 4096 } }];
|
||||
});
|
||||
await page.locator('.mobile-list').getByRole('button', { name: 'Node Detail' }).click();
|
||||
const drawer = page.locator('.console-node-drawer');
|
||||
await drawer.locator('.node-drawer-mobile-peer').getByText('Build server').waitFor();
|
||||
await page.waitForFunction(() => Math.abs(document.querySelector('.console-node-drawer').getBoundingClientRect().right - innerWidth) < 1);
|
||||
assert.deepEqual(await drawer.locator('.node-drawer-metric strong').allTextContents().then(values => values.map(s => s.trim())), ['Running', '1', '1', '1']);
|
||||
assert.equal(await drawer.getByRole('tab').count(), 2);
|
||||
const drawerWidth = await drawer.evaluate(el => ({ drawer: el.getBoundingClientRect().width, viewport: innerWidth }));
|
||||
assert.ok(drawerWidth.drawer <= drawerWidth.viewport + 1, JSON.stringify(drawerWidth));
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||
if (screenshotDir) await drawer.screenshot({ path: `${screenshotDir}/node-detail-populated-mobile-dark.png`, animations: 'disabled' });
|
||||
await page.setViewportSize({ width: 1024, height: 844 });
|
||||
await drawer.getByRole('cell', { name: 'Build server' }).waitFor();
|
||||
await drawer.locator('details').filter({ hasText: 'Routes' }).locator('summary').click();
|
||||
await drawer.getByText('10.110.95.2/24').first().waitFor();
|
||||
await drawer.locator('details').filter({ hasText: 'Connections' }).locator('summary').click();
|
||||
await drawer.locator('details').filter({ hasText: 'ACL Stats' }).locator('summary').click();
|
||||
await drawer.getByRole('cell', { name: 'Allow build server' }).waitFor();
|
||||
});
|
||||
|
||||
test('empty and failed lists recover without presenting an empty result as success', async t => {
|
||||
const { page, state } = await open(t, '/h/deviceList');
|
||||
state.machines = [];
|
||||
await refresh(page);
|
||||
await page.getByRole('heading', { name: 'No devices yet' }).waitFor();
|
||||
state.failures.add('/machines');
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: 'Retry', exact: true }).waitFor();
|
||||
assert.equal(await page.getByRole('heading', { name: 'No devices yet' }).count(), 0);
|
||||
state.failures.clear();
|
||||
await page.getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await page.getByRole('heading', { name: 'No devices yet' }).waitFor();
|
||||
});
|
||||
|
||||
test('responsive layouts, localization, dark mode and mobile drawer', async t => {
|
||||
const { page, state } = await open(t);
|
||||
const workspaceMenu = page.locator('.console-sidebar').getByRole('menu', { name: 'Navigation' });
|
||||
await workspaceMenu.focus();
|
||||
await page.keyboard.press('ArrowDown'); // Dashboard -> Device List
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL('**/#/h/deviceList');
|
||||
await workspaceMenu.locator('[aria-current="page"]', { hasText: 'Device List' }).waitFor();
|
||||
assert.equal(await workspaceMenu.locator('[aria-current="page"]').textContent(), 'Device List');
|
||||
const networkMenu = page.locator('.console-sidebar').getByRole('menu', { name: 'Networks' });
|
||||
await networkMenu.focus();
|
||||
await page.keyboard.press('ArrowDown'); // Networks overview -> Engineering (first nav-child)
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL('**/networks/network-0');
|
||||
await networkMenu.locator('[aria-current="page"]', { hasText: 'Engineering' }).waitFor();
|
||||
for (const colorScheme of ['light', 'dark']) {
|
||||
await page.emulateMedia({ colorScheme });
|
||||
for (const width of [1440, 1024, 390]) {
|
||||
await page.setViewportSize({ width, height: 960 });
|
||||
for (const language of ['en', 'cn']) {
|
||||
for (const [name, route] of [['overview', '/h'], ['devices', '/h/deviceList'], ['networks', '/h/networks'], ['members', '/h/networks/network-0']]) {
|
||||
await page.goto(`${base}/#${route}`);
|
||||
await page.locator('.console-page').waitFor();
|
||||
if (await page.evaluate(() => localStorage.getItem('lang')) !== language) {
|
||||
await page.getByRole('button', { name: /Switch language|切换语言/ }).click();
|
||||
}
|
||||
await page.locator('.p-skeleton').first().waitFor({ state: 'detached' });
|
||||
assert.equal(await page.locator('.web-console').evaluate(el => getComputedStyle(el).backgroundColor), colorScheme === 'light' ? 'rgb(247, 248, 249)' : 'rgb(16, 23, 30)');
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), `${name} ${width} ${colorScheme} must not overflow`);
|
||||
if (screenshotDir) await page.screenshot({ path: `${screenshotDir}/${name}-${language}-${colorScheme}-${width}.png`, fullPage: true, animations: 'disabled' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
state.machines[0].info.hostname = 'A-very-long-device-hostname-that-must-wrap-without-breaking-the-layout.example.internal';
|
||||
await page.setViewportSize({ width: 390, height: 960 });
|
||||
await page.goto(`${base}/#/h/deviceList`);
|
||||
await page.locator('.mobile-list .entity-link').first().click();
|
||||
await page.locator('.console-device-drawer').waitFor();
|
||||
await page.waitForFunction(() => Math.abs(document.querySelector('.console-device-drawer').getBoundingClientRect().left) < 1);
|
||||
assert.ok(await page.locator('.console-device-drawer').evaluate(el => el.getBoundingClientRect().width <= innerWidth));
|
||||
if (screenshotDir) await page.screenshot({ path: `${screenshotDir}/device-drawer-mobile.png`, animations: 'disabled' });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.locator('.mobile-nav-toggle').click();
|
||||
await page.locator('.console-mobile-nav').getByRole('menu', { name: '导航' }).waitFor();
|
||||
await page.locator('.console-mobile-nav').getByRole('link', { name: '设备列表', exact: true }).click();
|
||||
await page.locator('.console-mobile-nav').waitFor({ state: 'detached' });
|
||||
await page.locator('.mobile-nav-toggle').click();
|
||||
await page.locator('.console-mobile-nav').getByRole('link').first().click();
|
||||
await page.locator('.console-mobile-nav').waitFor({ state: 'detached' });
|
||||
});
|
||||
|
||||
|
||||
test('external Console keeps the device dashboard without central requests or navigation', async t => {
|
||||
const { page, state } = await open(t, '/h', {}, state => {
|
||||
state.externalConsole = true;
|
||||
state.failures.add('/networks');
|
||||
});
|
||||
await page.getByText('Amsterdam gateway', { exact: true }).waitFor();
|
||||
await delay(2500);
|
||||
assert.equal(state.requests.some(request => request.path.startsWith('/networks')), false);
|
||||
assert.equal(await page.locator('a[href*="/networks"]').count(), 0);
|
||||
assert.equal(await page.locator('.p-message-warn').count(), 0);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,405 @@
|
||||
//! Central-console device registration through the ordinary webhook contract.
|
||||
|
||||
use easytier::proto::web::DeviceOsInfo;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
db::{Db, DeviceHeartbeatRecord},
|
||||
webhook::{ValidateTokenRequest, ValidateTokenResponse, WebhookHandler},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DeviceAuth {
|
||||
db: Db,
|
||||
allow_auto_create_user: bool,
|
||||
}
|
||||
|
||||
impl DeviceAuth {
|
||||
pub fn new(db: Db, allow_auto_create_user: bool) -> Self {
|
||||
Self {
|
||||
db,
|
||||
allow_auto_create_user,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl WebhookHandler for DeviceAuth {
|
||||
async fn validate_token(
|
||||
&self,
|
||||
request: &ValidateTokenRequest,
|
||||
) -> anyhow::Result<ValidateTokenResponse> {
|
||||
let mut response = ValidateTokenResponse {
|
||||
valid: false,
|
||||
pre_approved: false,
|
||||
binding_version: 0,
|
||||
config_revision: String::new(),
|
||||
};
|
||||
let Ok(machine_id) = Uuid::parse_str(&request.machine_id) else {
|
||||
return Ok(response);
|
||||
};
|
||||
let user_id = match self.db.get_user_id_by_token(&request.token).await? {
|
||||
Some(user_id) => user_id,
|
||||
None if self.allow_auto_create_user => {
|
||||
self.db.auto_create_user(&request.token).await?.id
|
||||
}
|
||||
None => return Ok(response),
|
||||
};
|
||||
let device = (user_id, machine_id);
|
||||
if self
|
||||
.db
|
||||
.record_blocked_attempt(device, &request.hostname)
|
||||
.await?
|
||||
{
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
// Validation carries a public IP, not the transport URL. Keep any
|
||||
// historical URL; REST obtains current addresses from live sessions.
|
||||
let client_url = self
|
||||
.db
|
||||
.get_device(device)
|
||||
.await?
|
||||
.map(|device| device.client_url)
|
||||
.unwrap_or_default();
|
||||
self.db
|
||||
.upsert_device_heartbeat(DeviceHeartbeatRecord {
|
||||
user_id,
|
||||
machine_id,
|
||||
hostname: request.hostname.clone(),
|
||||
easytier_version: request.version.clone(),
|
||||
device_os: serde_json::to_string(&DeviceOsInfo {
|
||||
os_type: request.os_type.clone().unwrap_or_default(),
|
||||
version: request.os_version.clone().unwrap_or_default(),
|
||||
distribution: request.os_distribution.clone().unwrap_or_default(),
|
||||
})?,
|
||||
client_url,
|
||||
})
|
||||
.await?;
|
||||
response.valid = true;
|
||||
response.pre_approved = true;
|
||||
response.config_revision = self
|
||||
.db
|
||||
.get_managed_config_revision(device)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use easytier::{
|
||||
common::config::{ConfigSource, NetworkConfig, NetworkConfigExt},
|
||||
instance::factory::native_instance_manager,
|
||||
proto::rpc::standalone::{runtime_udp_tunnel_dialer, runtime_udp_tunnel_listener},
|
||||
web_client::WebClient,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
FeatureFlags,
|
||||
client_manager::{ClientManager, HeartbeatPolicy},
|
||||
webhook::{ManagedNetworkConfig, WebhookConfig},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
async fn wait_until<F, Fut>(mut condition: F)
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = bool>,
|
||||
{
|
||||
tokio::time::timeout(Duration::from_secs(20), async {
|
||||
while !condition().await {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("local device authentication did not converge");
|
||||
}
|
||||
|
||||
async fn start_config_server(db: Db) -> (ClientManager, url::Url) {
|
||||
let webhook = WebhookConfig::new(None, None, None, None, None)
|
||||
.with_handler(Arc::new(DeviceAuth::new(db.clone(), false)));
|
||||
let mut manager = ClientManager::new(
|
||||
db,
|
||||
None,
|
||||
HeartbeatPolicy::from_millis(1_000, 10_000).unwrap(),
|
||||
Arc::new(FeatureFlags::default()),
|
||||
Arc::new(webhook),
|
||||
);
|
||||
let listener = runtime_udp_tunnel_listener(
|
||||
"udp://127.0.0.1:0".parse().unwrap(),
|
||||
"127.0.0.1:0".parse().unwrap(),
|
||||
);
|
||||
let url = manager.add_listener(listener).await.unwrap();
|
||||
(manager, url)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_client_registers_receives_full_config_and_cannot_reconnect_when_blocked() {
|
||||
let db = Db::memory_db().await;
|
||||
let user_id = db.auto_create_user("local-owner").await.unwrap().id;
|
||||
let machine_id = Uuid::new_v4();
|
||||
let instance_id = Uuid::new_v4();
|
||||
let core = Arc::new(native_instance_manager());
|
||||
let (manager, url) = start_config_server(db.clone()).await;
|
||||
let client = WebClient::new(
|
||||
runtime_udp_tunnel_dialer(url),
|
||||
"local-owner",
|
||||
machine_id,
|
||||
"local-device",
|
||||
false,
|
||||
core.clone(),
|
||||
None,
|
||||
);
|
||||
wait_until(|| async {
|
||||
db.get_device((user_id, machine_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
&& manager
|
||||
.get_session_by_machine_id(user_id, &machine_id)
|
||||
.is_some()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
db.get_device((user_id, machine_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.hostname,
|
||||
"local-device"
|
||||
);
|
||||
manager
|
||||
.reconcile_managed_network_configs(
|
||||
user_id,
|
||||
machine_id,
|
||||
vec![ManagedNetworkConfig {
|
||||
instance_id: instance_id.to_string(),
|
||||
network_config: serde_json::json!({
|
||||
"instance_id": instance_id.to_string(),
|
||||
"network_name": "local-managed-network",
|
||||
"network_secret": "local-secret",
|
||||
"networking_method": "Standalone",
|
||||
"no_tun": true,
|
||||
"disable_ipv6": true,
|
||||
"multi_thread": false
|
||||
}),
|
||||
}],
|
||||
Some("local-full-1".to_owned()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_until(|| async {
|
||||
core.config(instance_id)
|
||||
.and_then(|config| NetworkConfig::new_from_config(&config).ok())
|
||||
.is_some_and(|config| {
|
||||
config.network_name.as_deref() == Some("local-managed-network")
|
||||
})
|
||||
})
|
||||
.await;
|
||||
assert_eq!(core.config_source(instance_id), Some(ConfigSource::Web));
|
||||
|
||||
// Stop the original connection before deleting it. This exercises a
|
||||
// fresh authentication, without depending on in-flight revocation.
|
||||
drop(client);
|
||||
manager
|
||||
.disconnect_session_by_machine_id(user_id, &machine_id)
|
||||
.await;
|
||||
drop(manager);
|
||||
db.delete_device_with_optional_block((user_id, machine_id), true)
|
||||
.await
|
||||
.unwrap();
|
||||
let (manager, url) = start_config_server(db.clone()).await;
|
||||
let _blocked_client = WebClient::new(
|
||||
runtime_udp_tunnel_dialer(url),
|
||||
"local-owner",
|
||||
machine_id,
|
||||
"blocked-device",
|
||||
false,
|
||||
Arc::new(native_instance_manager()),
|
||||
None,
|
||||
);
|
||||
// A second validation comes from another reconnect after the first
|
||||
// rejected session fails its next heartbeat.
|
||||
wait_until(|| async {
|
||||
db.list_blocked_devices(user_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.first()
|
||||
.is_some_and(|blocked| blocked.attempt_count >= 2)
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
db.get_device((user_id, machine_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(manager.list_sessions().await.is_empty());
|
||||
assert!(
|
||||
manager
|
||||
.get_session_by_machine_id(user_id, &machine_id)
|
||||
.is_none()
|
||||
);
|
||||
core.delete_network_instances([instance_id]).await.unwrap();
|
||||
}
|
||||
|
||||
fn request(token: &str, machine_id: Uuid) -> ValidateTokenRequest {
|
||||
ValidateTokenRequest {
|
||||
token: token.to_owned(),
|
||||
machine_id: machine_id.to_string(),
|
||||
public_ip: Some("192.0.2.1".to_owned()),
|
||||
hostname: "device".to_owned(),
|
||||
version: "2.7.0".to_owned(),
|
||||
os_type: Some("Linux".to_owned()),
|
||||
os_version: Some("6.12".to_owned()),
|
||||
os_distribution: Some("Debian".to_owned()),
|
||||
web_instance_id: None,
|
||||
web_instance_api_base_url: None,
|
||||
persisted_config_revision: None,
|
||||
applied_config_revision: None,
|
||||
applied_config_revision_known: false,
|
||||
failed_instance_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_token_requires_auto_creation_and_a_valid_machine_id() {
|
||||
let db = Db::memory_db().await;
|
||||
let mut req = request("new-user", Uuid::new_v4());
|
||||
let auth = DeviceAuth::new(db.clone(), false);
|
||||
assert!(!auth.validate_token(&req).await.unwrap().valid);
|
||||
assert!(db.get_user_id_by_token(&req.token).await.unwrap().is_none());
|
||||
|
||||
let auth = DeviceAuth::new(db.clone(), true);
|
||||
let machine_id = req.machine_id.clone();
|
||||
req.machine_id = "invalid".to_owned();
|
||||
assert!(!auth.validate_token(&req).await.unwrap().valid);
|
||||
assert!(db.get_user_id_by_token(&req.token).await.unwrap().is_none());
|
||||
req.machine_id = machine_id;
|
||||
assert!(auth.validate_token(&req).await.unwrap().valid);
|
||||
let user_id = db.get_user_id_by_token(&req.token).await.unwrap().unwrap();
|
||||
assert_eq!(db.list_devices(user_id).await.unwrap().len(), 1);
|
||||
assert!(
|
||||
DeviceAuth::new(db, false)
|
||||
.validate_token(&req)
|
||||
.await
|
||||
.unwrap()
|
||||
.valid
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn registration_refreshes_metadata_and_preserves_alias_and_address() {
|
||||
let db = Db::memory_db().await;
|
||||
let user_id = db.auto_create_user("owner").await.unwrap().id;
|
||||
let machine_id = Uuid::new_v4();
|
||||
let device = (user_id, machine_id);
|
||||
let auth = DeviceAuth::new(db.clone(), false);
|
||||
let mut req = request("owner", machine_id);
|
||||
let response = auth.validate_token(&req).await.unwrap();
|
||||
assert!(response.valid && response.pre_approved);
|
||||
let initial = db.get_device(device).await.unwrap().unwrap();
|
||||
assert!(initial.client_url.is_empty());
|
||||
db.set_device_alias(device, "office".to_owned())
|
||||
.await
|
||||
.unwrap();
|
||||
db.upsert_device_heartbeat(DeviceHeartbeatRecord {
|
||||
user_id,
|
||||
machine_id,
|
||||
hostname: initial.hostname,
|
||||
easytier_version: initial.easytier_version,
|
||||
device_os: initial.device_os,
|
||||
client_url: "tcp://192.0.2.2:1234".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
db.set_managed_config_revision(device, "revision-1")
|
||||
.await
|
||||
.unwrap();
|
||||
req.hostname = "renamed".to_owned();
|
||||
req.version = "2.8.0".to_owned();
|
||||
assert_eq!(
|
||||
auth.validate_token(&req).await.unwrap().config_revision,
|
||||
"revision-1"
|
||||
);
|
||||
let refreshed = db.get_device(device).await.unwrap().unwrap();
|
||||
assert_eq!(refreshed.hostname, "renamed");
|
||||
assert_eq!(refreshed.easytier_version, "2.8.0");
|
||||
assert_eq!(refreshed.alias, "office");
|
||||
assert_eq!(refreshed.client_url, "tcp://192.0.2.2:1234");
|
||||
assert_eq!(refreshed.first_seen_time, initial.first_seen_time);
|
||||
let os: DeviceOsInfo = serde_json::from_str(&refreshed.device_os).unwrap();
|
||||
assert_eq!(os.os_type, "Linux");
|
||||
assert_eq!(os.version, "6.12");
|
||||
assert_eq!(os.distribution, "Debian");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_without_a_block_allows_registration_but_blocking_rejects_new_auth() {
|
||||
let db = Db::memory_db().await;
|
||||
let user_id = db.auto_create_user("owner").await.unwrap().id;
|
||||
let machine_id = Uuid::new_v4();
|
||||
let device = (user_id, machine_id);
|
||||
let auth = DeviceAuth::new(db.clone(), false);
|
||||
let mut req = request("owner", machine_id);
|
||||
assert!(auth.validate_token(&req).await.unwrap().valid);
|
||||
assert!(
|
||||
db.delete_device_with_optional_block(device, false)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(db.get_device(device).await.unwrap().is_none());
|
||||
assert!(auth.validate_token(&req).await.unwrap().valid);
|
||||
|
||||
assert!(
|
||||
db.delete_device_with_optional_block(device, true)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
req.hostname = "blocked-retry".to_owned();
|
||||
assert!(!auth.validate_token(&req).await.unwrap().valid);
|
||||
assert!(!auth.validate_token(&req).await.unwrap().valid);
|
||||
assert!(db.get_device(device).await.unwrap().is_none());
|
||||
let blocked = db.list_blocked_devices(user_id).await.unwrap();
|
||||
assert_eq!(blocked.len(), 1);
|
||||
assert_eq!(blocked[0].hostname, "blocked-retry");
|
||||
assert_eq!(blocked[0].attempt_count, 2);
|
||||
assert!(blocked[0].last_attempt_time.is_some());
|
||||
assert!(db.unblock_device(device).await.unwrap());
|
||||
assert!(auth.validate_token(&req).await.unwrap().valid);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn registration_and_blocks_are_scoped_to_the_token_tenant() {
|
||||
let db = Db::memory_db().await;
|
||||
let user_a = db.auto_create_user("tenant-a").await.unwrap().id;
|
||||
let user_b = db.auto_create_user("tenant-b").await.unwrap().id;
|
||||
let machine_id = Uuid::new_v4();
|
||||
let auth = DeviceAuth::new(db.clone(), false);
|
||||
let req_a = request("tenant-a", machine_id);
|
||||
assert!(auth.validate_token(&req_a).await.unwrap().valid);
|
||||
db.delete_device_with_optional_block((user_a, machine_id), true)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut req_b = request("tenant-b", machine_id);
|
||||
req_b.hostname = "tenant-b-host".to_owned();
|
||||
assert!(auth.validate_token(&req_b).await.unwrap().valid);
|
||||
assert!(!auth.validate_token(&req_a).await.unwrap().valid);
|
||||
assert!(db.get_device((user_a, machine_id)).await.unwrap().is_none());
|
||||
assert_eq!(
|
||||
db.get_device((user_b, machine_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.hostname,
|
||||
"tenant-b-host"
|
||||
);
|
||||
assert!(db.list_blocked_devices(user_b).await.unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
//! Applies Gateway-mode central-network intents to native runtimes.
|
||||
//!
|
||||
//! Runtime ownership follows the immutable central-network ID. Mesh names are
|
||||
//! only a secondary lookup used by the shared config-server listener.
|
||||
|
||||
pub mod listener;
|
||||
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine as _;
|
||||
use easytier::{
|
||||
common::config::{ConfigLoader as _, NetworkIdentity, TomlConfigLoader},
|
||||
instance::factory::{NativeCoreInstance, create_native_instance},
|
||||
tunnel::IpScheme,
|
||||
};
|
||||
use easytier_core::{
|
||||
config::toml::ManagedCredentialConfig,
|
||||
peers::{error::Error as PeerError, peer_manager::PeerManagerCore},
|
||||
tunnel::Tunnel,
|
||||
};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::central_network::model::{CentralNetworkIntent, NetworkMode};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GatewayConfig {
|
||||
pub peer_url: String,
|
||||
pub relay_data: bool,
|
||||
}
|
||||
|
||||
impl GatewayConfig {
|
||||
pub fn validate(&self, listener_protocol: &str) -> anyhow::Result<()> {
|
||||
let peer_url: url::Url = self
|
||||
.peer_url
|
||||
.parse()
|
||||
.map_err(|error| anyhow::anyhow!("invalid gateway peer URL: {error}"))?;
|
||||
let listener_protocol = listener_protocol.to_ascii_lowercase();
|
||||
let listener_scheme: IpScheme = listener_protocol.parse().map_err(|_| {
|
||||
anyhow::anyhow!("unsupported config server protocol: {listener_protocol}")
|
||||
})?;
|
||||
if !matches!(
|
||||
listener_scheme,
|
||||
IpScheme::Tcp | IpScheme::Udp | IpScheme::Ws
|
||||
) {
|
||||
anyhow::bail!("unsupported config server protocol: {listener_protocol}");
|
||||
}
|
||||
let scheme_matches = match listener_scheme {
|
||||
IpScheme::Ws => matches!(peer_url.scheme(), "ws" | "wss"),
|
||||
_ => peer_url.scheme() == listener_protocol,
|
||||
};
|
||||
if !scheme_matches {
|
||||
anyhow::bail!(
|
||||
"gateway peer URL scheme ({}) does not match config server protocol ({listener_protocol})",
|
||||
peer_url.scheme()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub(crate) struct GatewayRuntimeSpec {
|
||||
network_id: Uuid,
|
||||
user_id: i32,
|
||||
mesh_name: String,
|
||||
network_secret: String,
|
||||
secure_mode: bool,
|
||||
relay_data: bool,
|
||||
credentials: Vec<ManagedCredentialConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
struct GatewayRuntimeKey {
|
||||
user_id: i32,
|
||||
network_id: Uuid,
|
||||
}
|
||||
|
||||
impl GatewayRuntimeKey {
|
||||
fn from_intent(intent: &CentralNetworkIntent) -> Self {
|
||||
Self {
|
||||
user_id: intent.user_id,
|
||||
network_id: intent.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GatewayRuntimeSpec {
|
||||
fn from_intent(intent: &CentralNetworkIntent, relay_data: bool) -> Self {
|
||||
let mut credentials = intent
|
||||
.credentials
|
||||
.iter()
|
||||
.map(|credential| ManagedCredentialConfig {
|
||||
credential_id: credential.id.clone(),
|
||||
credential_secret: credential.secret.clone(),
|
||||
groups: credential.grant.acl_groups.clone(),
|
||||
allow_relay: credential.grant.allow_relay,
|
||||
allowed_proxy_cidrs: credential.grant.allowed_proxy_cidrs.clone(),
|
||||
expiry_unix: credential.expiry_unix,
|
||||
reusable: credential.grant.reusable,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
credentials.sort_by(|left, right| left.credential_id.cmp(&right.credential_id));
|
||||
Self {
|
||||
network_id: intent.id,
|
||||
user_id: intent.user_id,
|
||||
mesh_name: intent.network_name.clone(),
|
||||
network_secret: intent.network_secret.clone(),
|
||||
secure_mode: intent.secure_mode,
|
||||
relay_data,
|
||||
credentials,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub(crate) trait GatewayRuntime: Send + Sync {
|
||||
async fn start(&self) -> anyhow::Result<()>;
|
||||
async fn stop(&self);
|
||||
fn peer_manager(&self) -> Option<Arc<PeerManagerCore>>;
|
||||
|
||||
async fn observe_network(&self) -> Option<GatewayNetworkObservation> {
|
||||
let peer_manager = self.peer_manager()?;
|
||||
Some(observe_peer_manager(&peer_manager).await)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait GatewayRuntimeFactory: Send + Sync {
|
||||
fn build(&self, spec: &GatewayRuntimeSpec) -> anyhow::Result<Arc<dyn GatewayRuntime>>;
|
||||
}
|
||||
|
||||
struct NativeGatewayRuntime(Arc<NativeCoreInstance>);
|
||||
|
||||
#[async_trait]
|
||||
impl GatewayRuntime for NativeGatewayRuntime {
|
||||
async fn start(&self) -> anyhow::Result<()> {
|
||||
self.0.start().await
|
||||
}
|
||||
|
||||
async fn stop(&self) {
|
||||
self.0.stop().await;
|
||||
}
|
||||
|
||||
fn peer_manager(&self) -> Option<Arc<PeerManagerCore>> {
|
||||
Some(self.0.peer_manager().clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct NativeGatewayRuntimeFactory;
|
||||
|
||||
impl GatewayRuntimeFactory for NativeGatewayRuntimeFactory {
|
||||
fn build(&self, spec: &GatewayRuntimeSpec) -> anyhow::Result<Arc<dyn GatewayRuntime>> {
|
||||
let config = TomlConfigLoader::default();
|
||||
config.set_inst_name(format!(
|
||||
"easytier-web-gw-{}-{}",
|
||||
spec.user_id, spec.network_id
|
||||
));
|
||||
config.set_hostname(Some(hostname_or_default()));
|
||||
config.set_network_identity(NetworkIdentity::new(
|
||||
spec.mesh_name.clone(),
|
||||
spec.network_secret.clone(),
|
||||
));
|
||||
config.set_dhcp(false);
|
||||
config.set_listeners(vec![]);
|
||||
|
||||
let mut flags = config.get_flags();
|
||||
flags.no_tun = true;
|
||||
flags.disable_relay_data = !spec.relay_data;
|
||||
flags.bind_device = false;
|
||||
config.set_flags(flags);
|
||||
|
||||
if spec.secure_mode {
|
||||
let private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||
let public = x25519_dalek::PublicKey::from(&private);
|
||||
config.set_secure_mode(Some(easytier::proto::common::SecureModeConfig {
|
||||
enabled: true,
|
||||
local_private_key: Some(
|
||||
base64::engine::general_purpose::STANDARD.encode(private.as_bytes()),
|
||||
),
|
||||
local_public_key: Some(
|
||||
base64::engine::general_purpose::STANDARD.encode(public.as_bytes()),
|
||||
),
|
||||
}));
|
||||
}
|
||||
config.set_managed_credentials(spec.credentials.clone());
|
||||
Ok(Arc::new(NativeGatewayRuntime(create_native_instance(
|
||||
config,
|
||||
)?)))
|
||||
}
|
||||
}
|
||||
|
||||
struct PublishedRuntime {
|
||||
spec: GatewayRuntimeSpec,
|
||||
runtime: Arc<dyn GatewayRuntime>,
|
||||
retiring: CancellationToken,
|
||||
admissions: Arc<RwLock<()>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
by_key: HashMap<GatewayRuntimeKey, PublishedRuntime>,
|
||||
mesh_name_to_key: HashMap<String, GatewayRuntimeKey>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub(crate) struct GatewayNetworkObservation {
|
||||
pub connections: Vec<easytier_proto::core_peer::peer::PeerConnInfo>,
|
||||
pub routes: Vec<easytier_proto::core_peer::peer::Route>,
|
||||
}
|
||||
|
||||
pub struct NetworkInstanceManager {
|
||||
config: GatewayConfig,
|
||||
factory: Arc<dyn GatewayRuntimeFactory>,
|
||||
state: RwLock<State>,
|
||||
lifecycle: Mutex<()>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NetworkInstanceManager {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("NetworkInstanceManager")
|
||||
.field("config", &self.config)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkInstanceManager {
|
||||
pub fn new(config: GatewayConfig) -> Self {
|
||||
Self::with_factory(config, Arc::new(NativeGatewayRuntimeFactory))
|
||||
}
|
||||
|
||||
pub(crate) fn with_factory(
|
||||
config: GatewayConfig,
|
||||
factory: Arc<dyn GatewayRuntimeFactory>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
factory,
|
||||
state: RwLock::new(State::default()),
|
||||
lifecycle: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &GatewayConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
pub async fn reconcile(&self, intent: &CentralNetworkIntent) {
|
||||
let _lifecycle = self.lifecycle.lock().await;
|
||||
self.reconcile_locked(intent).await;
|
||||
}
|
||||
|
||||
async fn reconcile_locked(&self, intent: &CentralNetworkIntent) {
|
||||
let key = GatewayRuntimeKey::from_intent(intent);
|
||||
if !matches!(intent.mode, NetworkMode::Gateway { .. }) {
|
||||
self.remove_locked(key).await;
|
||||
return;
|
||||
}
|
||||
|
||||
let spec = GatewayRuntimeSpec::from_intent(intent, self.config.relay_data);
|
||||
{
|
||||
let state = self.state.write().await;
|
||||
if state
|
||||
.by_key
|
||||
.get(&key)
|
||||
.is_some_and(|published| published.spec == spec)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self.remove_locked(key).await;
|
||||
let mesh_owner = {
|
||||
let state = self.state.read().await;
|
||||
state.mesh_name_to_key.get(&spec.mesh_name).copied()
|
||||
};
|
||||
if let Some(owner) = mesh_owner {
|
||||
tracing::error!(
|
||||
user_id = key.user_id,
|
||||
network_id = %key.network_id,
|
||||
mesh_name = %spec.mesh_name,
|
||||
owner_user_id = owner.user_id,
|
||||
owner_network_id = %owner.network_id,
|
||||
"Gateway mesh name is already owned"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let candidate = match self.factory.build(&spec) {
|
||||
Ok(candidate) => candidate,
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
user_id = key.user_id,
|
||||
network_id = %key.network_id,
|
||||
"failed to build Gateway runtime: {error:#}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(error) = candidate.start().await {
|
||||
candidate.stop().await;
|
||||
tracing::error!(
|
||||
user_id = key.user_id,
|
||||
network_id = %key.network_id,
|
||||
"failed to start Gateway runtime: {error:#}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
let mut state = self.state.write().await;
|
||||
state.mesh_name_to_key.insert(spec.mesh_name.clone(), key);
|
||||
state.by_key.insert(
|
||||
key,
|
||||
PublishedRuntime {
|
||||
spec,
|
||||
runtime: candidate,
|
||||
retiring: CancellationToken::new(),
|
||||
admissions: Arc::new(RwLock::new(())),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn network_ids(&self) -> Vec<(i32, Uuid)> {
|
||||
self.state
|
||||
.read()
|
||||
.await
|
||||
.by_key
|
||||
.keys()
|
||||
.map(|key| (key.user_id, key.network_id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn remove(&self, user_id: i32, network_id: Uuid) {
|
||||
let _lifecycle = self.lifecycle.lock().await;
|
||||
self.remove_locked(GatewayRuntimeKey {
|
||||
user_id,
|
||||
network_id,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn remove_locked(&self, key: GatewayRuntimeKey) {
|
||||
let retired = {
|
||||
let mut state = self.state.write().await;
|
||||
let retired = state.by_key.remove(&key);
|
||||
if let Some(retired) = retired.as_ref()
|
||||
&& state.mesh_name_to_key.get(&retired.spec.mesh_name) == Some(&key)
|
||||
{
|
||||
state.mesh_name_to_key.remove(&retired.spec.mesh_name);
|
||||
}
|
||||
retired
|
||||
};
|
||||
if let Some(retired) = retired {
|
||||
retired.retiring.cancel();
|
||||
// Drain cancelled handshakes before stopping the runtime so none
|
||||
// can publish a connection after shutdown.
|
||||
let _admissions = retired.admissions.write().await;
|
||||
retired.runtime.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn accept_peer_tunnel(
|
||||
&self,
|
||||
mesh_name: &str,
|
||||
tunnel: Box<dyn Tunnel>,
|
||||
) -> Option<Result<(), PeerError>> {
|
||||
let state = self.state.read().await;
|
||||
let key = state.mesh_name_to_key.get(mesh_name)?;
|
||||
let published = state.by_key.get(key)?;
|
||||
let peer_manager = published.runtime.peer_manager()?;
|
||||
let retiring = published.retiring.clone();
|
||||
let _admission = published.admissions.clone().read_owned().await;
|
||||
drop(state);
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = retiring.cancelled() => None,
|
||||
result = peer_manager.add_tunnel_as_server(tunnel, true) => Some(result),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot a Gateway runtime only when both the tenant and immutable
|
||||
/// central-network ID match the published owner.
|
||||
pub(crate) async fn observe_network(
|
||||
&self,
|
||||
user_id: i32,
|
||||
network_id: Uuid,
|
||||
) -> Option<GatewayNetworkObservation> {
|
||||
let key = GatewayRuntimeKey {
|
||||
user_id,
|
||||
network_id,
|
||||
};
|
||||
let runtime = {
|
||||
let state = self.state.read().await;
|
||||
let published = state.by_key.get(&key)?;
|
||||
published.runtime.clone()
|
||||
};
|
||||
runtime.observe_network().await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn actual_mesh_name(&self, user_id: i32, network_id: Uuid) -> Option<String> {
|
||||
let key = GatewayRuntimeKey {
|
||||
user_id,
|
||||
network_id,
|
||||
};
|
||||
self.state
|
||||
.read()
|
||||
.await
|
||||
.by_key
|
||||
.get(&key)
|
||||
.map(|runtime| runtime.spec.mesh_name.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn runtime_ids(&self) -> Vec<Uuid> {
|
||||
let mut ids = self
|
||||
.state
|
||||
.read()
|
||||
.await
|
||||
.by_key
|
||||
.keys()
|
||||
.map(|key| key.network_id)
|
||||
.collect::<Vec<_>>();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn network_id_for_mesh(&self, mesh_name: &str) -> Option<Uuid> {
|
||||
self.state
|
||||
.read()
|
||||
.await
|
||||
.mesh_name_to_key
|
||||
.get(mesh_name)
|
||||
.map(|key| key.network_id)
|
||||
}
|
||||
}
|
||||
|
||||
async fn observe_peer_manager(peer_manager: &PeerManagerCore) -> GatewayNetworkObservation {
|
||||
let mut observation = GatewayNetworkObservation::default();
|
||||
for peer in peer_manager.list_peer_snapshots().await {
|
||||
observation.connections.extend(peer.conns);
|
||||
}
|
||||
observation.routes = peer_manager.list_route_snapshots().await;
|
||||
observation
|
||||
}
|
||||
|
||||
fn hostname_or_default() -> String {
|
||||
std::fs::read_to_string("/etc/hostname")
|
||||
.ok()
|
||||
.map(|hostname| hostname.trim().to_owned())
|
||||
.filter(|hostname| !hostname.is_empty())
|
||||
.unwrap_or_else(|| "easytier-web-gateway".to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::central_network::model::{
|
||||
CentralNetworkIntent, CredentialGrant, NetworkCredentialIntent, NetworkMode,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeFactory {
|
||||
built: Mutex<Vec<GatewayRuntimeSpec>>,
|
||||
fail_next: Mutex<bool>,
|
||||
fail_start_next: Mutex<bool>,
|
||||
stopped: Arc<Mutex<Vec<Uuid>>>,
|
||||
}
|
||||
|
||||
struct FakeRuntime {
|
||||
id: Uuid,
|
||||
fail_start: bool,
|
||||
stopped: Arc<Mutex<Vec<Uuid>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GatewayRuntime for FakeRuntime {
|
||||
async fn start(&self) -> anyhow::Result<()> {
|
||||
if self.fail_start {
|
||||
anyhow::bail!("injected start failure");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) {
|
||||
self.stopped.lock().unwrap().push(self.id);
|
||||
}
|
||||
|
||||
fn peer_manager(&self) -> Option<Arc<easytier_core::peers::peer_manager::PeerManagerCore>> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn observe_network(&self) -> Option<GatewayNetworkObservation> {
|
||||
Some(GatewayNetworkObservation::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl GatewayRuntimeFactory for FakeFactory {
|
||||
fn build(&self, spec: &GatewayRuntimeSpec) -> anyhow::Result<Arc<dyn GatewayRuntime>> {
|
||||
self.built.lock().unwrap().push(spec.clone());
|
||||
if std::mem::take(&mut *self.fail_next.lock().unwrap()) {
|
||||
anyhow::bail!("injected build failure");
|
||||
}
|
||||
Ok(Arc::new(FakeRuntime {
|
||||
id: spec.network_id,
|
||||
fail_start: std::mem::take(&mut *self.fail_start_next.lock().unwrap()),
|
||||
stopped: self.stopped.clone(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn gateway_intent(id: Uuid, mesh_name: &str) -> CentralNetworkIntent {
|
||||
CentralNetworkIntent {
|
||||
id,
|
||||
user_id: 7,
|
||||
display_name: mesh_name.to_owned(),
|
||||
network_name: mesh_name.to_owned(),
|
||||
network_secret: "secret".to_owned(),
|
||||
mode: NetworkMode::Gateway {
|
||||
peer_url: "tcp://gateway.example:22020".to_owned(),
|
||||
},
|
||||
virtual_cidr: None,
|
||||
secure_mode: false,
|
||||
members: Vec::new(),
|
||||
credentials: Vec::new(),
|
||||
acl_policy: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn manager(factory: Arc<FakeFactory>) -> NetworkInstanceManager {
|
||||
NetworkInstanceManager::with_factory(
|
||||
GatewayConfig {
|
||||
peer_url: "tcp://gateway.example:22020".to_owned(),
|
||||
relay_data: false,
|
||||
},
|
||||
factory,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stalled_admission_does_not_block_reconcile_and_is_cancelled_on_removal() {
|
||||
use easytier_core::tunnel::ring::create_ring_tunnel_pair;
|
||||
use std::time::Duration;
|
||||
let manager = Arc::new(NetworkInstanceManager::new(GatewayConfig {
|
||||
peer_url: "tcp://localhost:22020".into(),
|
||||
relay_data: true,
|
||||
}));
|
||||
let intent = gateway_intent(Uuid::new_v4(), "stalled-admission");
|
||||
manager.reconcile(&intent).await;
|
||||
let (server, _client) = create_ring_tunnel_pair();
|
||||
let accepting = {
|
||||
let manager = manager.clone();
|
||||
tokio::spawn(async move {
|
||||
manager
|
||||
.accept_peer_tunnel("stalled-admission", server)
|
||||
.await
|
||||
})
|
||||
};
|
||||
// Wait until the real peer handshake holds the runtime admission lease.
|
||||
let admissions = manager
|
||||
.state
|
||||
.read()
|
||||
.await
|
||||
.by_key
|
||||
.get(&GatewayRuntimeKey::from_intent(&intent))
|
||||
.unwrap()
|
||||
.admissions
|
||||
.clone();
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
while admissions.try_write().is_ok() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let other = gateway_intent(Uuid::new_v4(), "unrelated-network");
|
||||
tokio::time::timeout(Duration::from_secs(2), manager.reconcile(&other))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
manager.remove(intent.user_id, intent.id),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(accepting.await.unwrap().is_none());
|
||||
assert!(
|
||||
manager
|
||||
.actual_mesh_name(intent.user_id, intent.id)
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
manager.remove(other.user_id, other.id).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn state_is_keyed_by_immutable_network_id_and_rename_replaces_index() {
|
||||
let factory = Arc::new(FakeFactory::default());
|
||||
let manager = manager(factory.clone());
|
||||
let id = Uuid::new_v4();
|
||||
let first = gateway_intent(id, "mesh-one");
|
||||
manager.reconcile(&first).await;
|
||||
|
||||
let mut renamed = first;
|
||||
renamed.network_name = "mesh-two".to_owned();
|
||||
manager.reconcile(&renamed).await;
|
||||
|
||||
assert_eq!(manager.runtime_ids().await, vec![id]);
|
||||
assert_eq!(manager.network_id_for_mesh("mesh-one").await, None);
|
||||
assert_eq!(manager.network_id_for_mesh("mesh-two").await, Some(id));
|
||||
assert_eq!(factory.stopped.lock().unwrap().as_slice(), &[id]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn observation_requires_the_published_tenant_and_network_id() {
|
||||
let factory = Arc::new(FakeFactory::default());
|
||||
let manager = manager(factory);
|
||||
let id = Uuid::new_v4();
|
||||
manager.reconcile(&gateway_intent(id, "mesh")).await;
|
||||
|
||||
assert!(manager.observe_network(7, id).await.is_some());
|
||||
assert!(manager.observe_network(8, id).await.is_none());
|
||||
assert!(manager.observe_network(7, Uuid::new_v4()).await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mode_switch_and_delete_remove_runtime_by_id() {
|
||||
let factory = Arc::new(FakeFactory::default());
|
||||
let manager = manager(factory.clone());
|
||||
let id = Uuid::new_v4();
|
||||
let mut intent = gateway_intent(id, "mesh");
|
||||
manager.reconcile(&intent).await;
|
||||
|
||||
intent.mode = NetworkMode::Standalone;
|
||||
manager.reconcile(&intent).await;
|
||||
assert!(manager.runtime_ids().await.is_empty());
|
||||
|
||||
manager.reconcile(&gateway_intent(id, "mesh-again")).await;
|
||||
manager.remove(7, id).await;
|
||||
assert!(manager.runtime_ids().await.is_empty());
|
||||
assert_eq!(factory.stopped.lock().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_replacement_retires_old_runtime_and_records_error() {
|
||||
let factory = Arc::new(FakeFactory::default());
|
||||
let manager = manager(factory.clone());
|
||||
let id = Uuid::new_v4();
|
||||
manager.reconcile(&gateway_intent(id, "stable")).await;
|
||||
|
||||
*factory.fail_next.lock().unwrap() = true;
|
||||
manager.reconcile(&gateway_intent(id, "desired")).await;
|
||||
|
||||
assert_eq!(manager.network_id_for_mesh("stable").await, None);
|
||||
assert_eq!(manager.network_id_for_mesh("desired").await, None);
|
||||
assert_eq!(factory.stopped.lock().unwrap().as_slice(), &[id]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_start_retires_old_runtime() {
|
||||
let factory = Arc::new(FakeFactory::default());
|
||||
let manager = manager(factory.clone());
|
||||
let id = Uuid::new_v4();
|
||||
manager.reconcile(&gateway_intent(id, "stable")).await;
|
||||
|
||||
*factory.fail_start_next.lock().unwrap() = true;
|
||||
manager.reconcile(&gateway_intent(id, "desired")).await;
|
||||
|
||||
assert_eq!(manager.network_id_for_mesh("stable").await, None);
|
||||
assert_eq!(manager.network_id_for_mesh("desired").await, None);
|
||||
assert_eq!(factory.stopped.lock().unwrap().as_slice(), &[id, id]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mesh_name_conflict_retires_old_runtime() {
|
||||
let factory = Arc::new(FakeFactory::default());
|
||||
let manager = manager(factory.clone());
|
||||
let first_id = Uuid::new_v4();
|
||||
let second_id = Uuid::new_v4();
|
||||
manager.reconcile(&gateway_intent(first_id, "stable")).await;
|
||||
manager
|
||||
.reconcile(&gateway_intent(second_id, "occupied"))
|
||||
.await;
|
||||
|
||||
manager
|
||||
.reconcile(&gateway_intent(first_id, "occupied"))
|
||||
.await;
|
||||
|
||||
assert_eq!(manager.network_id_for_mesh("stable").await, None);
|
||||
assert_eq!(
|
||||
manager.network_id_for_mesh("occupied").await,
|
||||
Some(second_id)
|
||||
);
|
||||
assert_eq!(factory.stopped.lock().unwrap().as_slice(), &[first_id]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn maps_full_grants_without_filtering_expired_credentials() {
|
||||
let factory = Arc::new(FakeFactory::default());
|
||||
let manager = manager(factory.clone());
|
||||
let mut intent = gateway_intent(Uuid::new_v4(), "mesh");
|
||||
intent.credentials.push(NetworkCredentialIntent {
|
||||
id: "expired-but-core-owned".to_owned(),
|
||||
secret: "credential-secret".to_owned(),
|
||||
expiry_unix: 1,
|
||||
grant: CredentialGrant {
|
||||
acl_groups: vec!["member:one".to_owned(), "ops".to_owned()],
|
||||
allow_relay: false,
|
||||
allowed_proxy_cidrs: vec!["10.0.0.0/8".to_owned()],
|
||||
reusable: false,
|
||||
},
|
||||
});
|
||||
|
||||
manager.reconcile(&intent).await;
|
||||
|
||||
let built = factory.built.lock().unwrap();
|
||||
assert_eq!(built[0].credentials.len(), 1);
|
||||
let credential = &built[0].credentials[0];
|
||||
assert_eq!(credential.groups, vec!["member:one", "ops"]);
|
||||
assert!(!credential.allow_relay);
|
||||
assert_eq!(credential.allowed_proxy_cidrs, vec!["10.0.0.0/8"]);
|
||||
assert_eq!(credential.expiry_unix, 1);
|
||||
assert!(!credential.reusable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_peer_url_scheme_must_match_listener_protocol() {
|
||||
let tcp = GatewayConfig {
|
||||
peer_url: "tcp://gateway.example:22020".to_owned(),
|
||||
relay_data: false,
|
||||
};
|
||||
assert!(tcp.validate("tcp").is_ok());
|
||||
assert!(tcp.validate("udp").is_err());
|
||||
assert!(tcp.validate("unknown").is_err());
|
||||
|
||||
let secure_websocket = GatewayConfig {
|
||||
peer_url: "wss://gateway.example/mesh".to_owned(),
|
||||
relay_data: false,
|
||||
};
|
||||
assert!(secure_websocket.validate("ws").is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
//! Routes Gateway peers away from the ordinary Web configuration listener.
|
||||
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use easytier_core::{
|
||||
packet::{PacketType, ZCPacket},
|
||||
socket::{ListenerConnectionCounter, SocketListener},
|
||||
tunnel::{Tunnel, TunnelError, wrapper::TunnelWrapper},
|
||||
};
|
||||
|
||||
use futures::{FutureExt as _, StreamExt, future::BoxFuture};
|
||||
use tokio::{task::JoinSet, time::timeout};
|
||||
|
||||
use super::NetworkInstanceManager;
|
||||
|
||||
const GATEWAY_ACCEPT_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const MAX_PENDING_CONNECTIONS: usize = 32;
|
||||
|
||||
type PendingAccept<L> = BoxFuture<'static, (L, anyhow::Result<Box<dyn Tunnel>>)>;
|
||||
|
||||
pub struct GatewayListener<L> {
|
||||
inner: Option<L>,
|
||||
accepting: Option<PendingAccept<L>>,
|
||||
local_info: Option<(url::Url, Arc<dyn ListenerConnectionCounter>)>,
|
||||
instances: Arc<NetworkInstanceManager>,
|
||||
pending: JoinSet<Option<Box<dyn Tunnel>>>,
|
||||
}
|
||||
|
||||
impl<L> std::fmt::Debug for GatewayListener<L> {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("GatewayListener")
|
||||
.field("local_info", &self.local_info)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl<L> GatewayListener<L> {
|
||||
pub fn new(inner: L, instances: Arc<NetworkInstanceManager>) -> Self {
|
||||
Self {
|
||||
inner: Some(inner),
|
||||
accepting: None,
|
||||
local_info: None,
|
||||
instances,
|
||||
pending: JoinSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<L: SocketListener<Accepted = Box<dyn Tunnel>> + 'static> SocketListener
|
||||
for GatewayListener<L>
|
||||
{
|
||||
type Accepted = Box<dyn Tunnel>;
|
||||
|
||||
async fn listen(&mut self) -> anyhow::Result<()> {
|
||||
self.inner
|
||||
.as_mut()
|
||||
.expect("listen before accepting")
|
||||
.listen()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn accept(&mut self) -> anyhow::Result<Self::Accepted> {
|
||||
loop {
|
||||
if self.accepting.is_none() && self.pending.len() < MAX_PENDING_CONNECTIONS {
|
||||
let mut inner = self.inner.take().expect("idle listener");
|
||||
self.local_info = Some((inner.local_url(), inner.connection_counter()));
|
||||
// WebSocket acceptance includes its HTTP upgrade. Preserve
|
||||
// this future across other completions and accept() returns.
|
||||
self.accepting = Some(
|
||||
async move {
|
||||
let accepted = inner.accept().await;
|
||||
(inner, accepted)
|
||||
}
|
||||
.boxed(),
|
||||
);
|
||||
}
|
||||
tokio::select! {
|
||||
completed = self.pending.join_next(), if !self.pending.is_empty() => {
|
||||
match completed {
|
||||
Some(Ok(Some(tunnel))) => return Ok(tunnel),
|
||||
Some(Err(error)) => tracing::warn!(%error, "Gateway connection task failed"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(inner, accepted) = async { self.accepting.as_mut().unwrap().await }, if self.accepting.is_some() => {
|
||||
self.accepting = None;
|
||||
self.inner = Some(inner);
|
||||
let tunnel = accepted?;
|
||||
let instances = self.instances.clone();
|
||||
self.pending.spawn(async move {
|
||||
match accept_demux_server_tunnel(tunnel).await {
|
||||
Ok(AcceptedTunnelRoute::Web(tunnel)) => return Some(tunnel),
|
||||
Ok(AcceptedTunnelRoute::Peer(tunnel, mesh_name)) => {
|
||||
match instances.accept_peer_tunnel(&mesh_name, tunnel).await {
|
||||
Some(Ok(())) => {}
|
||||
Some(Err(error)) => tracing::warn!(%error, %mesh_name, "failed to accept Gateway peer tunnel"),
|
||||
None => tracing::warn!(%mesh_name, "Gateway network unavailable, dropping connection"),
|
||||
}
|
||||
}
|
||||
Err(error) => tracing::warn!(%error, "failed to demultiplex tunnel, dropping connection"),
|
||||
}
|
||||
None
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn local_url(&self) -> url::Url {
|
||||
self.inner.as_ref().map_or_else(
|
||||
|| self.local_info.as_ref().unwrap().0.clone(),
|
||||
|inner| inner.local_url(),
|
||||
)
|
||||
}
|
||||
|
||||
fn connection_counter(&self) -> Arc<dyn ListenerConnectionCounter> {
|
||||
self.inner.as_ref().map_or_else(
|
||||
|| self.local_info.as_ref().unwrap().1.clone(),
|
||||
|inner| inner.connection_counter(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Routing decision for a connection accepted on the shared Web listener.
|
||||
enum AcceptedTunnelRoute {
|
||||
Peer(Box<dyn Tunnel>, String),
|
||||
Web(Box<dyn Tunnel>),
|
||||
}
|
||||
|
||||
fn peer_first_packet_network_name(packet: &ZCPacket) -> Result<String, TunnelError> {
|
||||
use prost::Message as _;
|
||||
|
||||
let header = packet
|
||||
.peer_manager_header()
|
||||
.ok_or_else(|| TunnelError::InvalidPacket("peer handshake packet too short".to_string()))?;
|
||||
let network_name = if header.packet_type == PacketType::NoiseHandshakeMsg1 as u8 {
|
||||
const NOISE_XX_MSG1_EPHEMERAL_LEN: usize = 32;
|
||||
let payload = packet.payload();
|
||||
if payload.len() <= NOISE_XX_MSG1_EPHEMERAL_LEN {
|
||||
return Err(TunnelError::InvalidPacket(
|
||||
"noise msg1 has no handshake payload".to_string(),
|
||||
));
|
||||
}
|
||||
easytier_proto::peer_rpc::PeerConnNoiseMsg1Pb::decode(
|
||||
&payload[NOISE_XX_MSG1_EPHEMERAL_LEN..],
|
||||
)
|
||||
.map_err(|error| {
|
||||
TunnelError::InvalidPacket(format!("invalid noise msg1 handshake payload: {error}"))
|
||||
})?
|
||||
.a_network_name
|
||||
} else if header.packet_type == PacketType::HandShake as u8 {
|
||||
easytier_proto::peer_rpc::HandshakeRequest::decode(packet.payload())
|
||||
.map_err(|error| {
|
||||
TunnelError::InvalidPacket(format!("invalid peer handshake payload: {error}"))
|
||||
})?
|
||||
.network_name
|
||||
} else {
|
||||
return Err(TunnelError::InvalidPacket(format!(
|
||||
"packet type {} is not a peer handshake",
|
||||
header.packet_type
|
||||
)));
|
||||
};
|
||||
if network_name.is_empty() {
|
||||
return Err(TunnelError::InvalidPacket(
|
||||
"peer handshake has an empty network name".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(network_name)
|
||||
}
|
||||
|
||||
fn is_peer_handshake_first_packet(packet: &ZCPacket) -> bool {
|
||||
packet.peer_manager_header().is_some_and(|header| {
|
||||
header.packet_type == PacketType::HandShake as u8
|
||||
|| header.packet_type == PacketType::NoiseHandshakeMsg1 as u8
|
||||
})
|
||||
}
|
||||
|
||||
/// Classify a shared listener connection without upgrading its Web transport.
|
||||
/// The first packet is replayed so the ordinary Web accept path performs the
|
||||
/// Noise handshake exactly once. Idle connections are closed at the routing
|
||||
/// timeout so they cannot block the serial Web handshake path.
|
||||
async fn accept_demux_server_tunnel(
|
||||
tunnel: Box<dyn Tunnel>,
|
||||
) -> Result<AcceptedTunnelRoute, TunnelError> {
|
||||
let info = tunnel.info();
|
||||
let (mut stream, sink) = tunnel.split();
|
||||
let first_packet = match timeout(GATEWAY_ACCEPT_TIMEOUT, stream.next()).await {
|
||||
Ok(Some(Ok(packet))) => packet,
|
||||
Ok(Some(Err(error))) => return Err(error),
|
||||
Ok(None) => return Err(TunnelError::Shutdown),
|
||||
Err(_) => return Err(TunnelError::Shutdown),
|
||||
};
|
||||
if is_peer_handshake_first_packet(&first_packet) {
|
||||
let network_name = peer_first_packet_network_name(&first_packet)?;
|
||||
let stream = Box::pin(futures::stream::once(async move { Ok(first_packet) }).chain(stream));
|
||||
return Ok(AcceptedTunnelRoute::Peer(
|
||||
Box::new(TunnelWrapper::new(stream, sink, info)),
|
||||
network_name,
|
||||
));
|
||||
}
|
||||
let stream = Box::pin(futures::stream::once(async move { Ok(first_packet) }).chain(stream));
|
||||
Ok(AcceptedTunnelRoute::Web(Box::new(TunnelWrapper::new(
|
||||
stream, sink, info,
|
||||
))))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use easytier_core::tunnel::{
|
||||
ring::create_ring_tunnel_pair,
|
||||
web_security::{
|
||||
accept_or_upgrade_server_tunnel, upgrade_client_tunnel, web_secure_tunnel_supported,
|
||||
},
|
||||
};
|
||||
use futures::SinkExt;
|
||||
use prost::Message as _;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn pack_control_packet(payload: &[u8]) -> ZCPacket {
|
||||
let mut packet = ZCPacket::new_with_payload(payload);
|
||||
packet.fill_peer_manager_hdr(0, 0, PacketType::Data as u8);
|
||||
packet
|
||||
}
|
||||
|
||||
fn peer_handshake_packet(packet_type: PacketType, network_name: &str) -> ZCPacket {
|
||||
let payload = match packet_type {
|
||||
PacketType::HandShake => easytier_proto::peer_rpc::HandshakeRequest {
|
||||
network_name: network_name.to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
.encode_to_vec(),
|
||||
PacketType::NoiseHandshakeMsg1 => {
|
||||
let mut payload = vec![0; 32];
|
||||
easytier_proto::peer_rpc::PeerConnNoiseMsg1Pb {
|
||||
a_network_name: network_name.to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
.encode(&mut payload)
|
||||
.unwrap();
|
||||
payload
|
||||
}
|
||||
_ => panic!("not a peer handshake packet type"),
|
||||
};
|
||||
let mut packet = ZCPacket::new_with_payload(&payload);
|
||||
packet.fill_peer_manager_hdr(0, 0, packet_type as u8);
|
||||
packet
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_network_name_from_plain_and_noise_peer_handshakes() {
|
||||
for packet_type in [PacketType::HandShake, PacketType::NoiseHandshakeMsg1] {
|
||||
assert_eq!(
|
||||
peer_first_packet_network_name(&peer_handshake_packet(packet_type, "mesh-a"))
|
||||
.unwrap(),
|
||||
"mesh-a"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn demux_replays_peer_handshake_to_selected_network() {
|
||||
for packet_type in [PacketType::HandShake, PacketType::NoiseHandshakeMsg1] {
|
||||
let (server_tunnel, client_tunnel) = create_ring_tunnel_pair();
|
||||
let server_task =
|
||||
tokio::spawn(async move { accept_demux_server_tunnel(server_tunnel).await });
|
||||
let (_stream, mut sink) = client_tunnel.split();
|
||||
sink.send(peer_handshake_packet(packet_type, "mesh-a"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let AcceptedTunnelRoute::Peer(peer_tunnel, network_name) =
|
||||
server_task.await.unwrap().unwrap()
|
||||
else {
|
||||
panic!("peer handshake must not be routed as Web traffic");
|
||||
};
|
||||
assert_eq!(network_name, "mesh-a");
|
||||
let (mut stream, _sink) = peer_tunnel.split();
|
||||
let replayed = stream.next().await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
replayed.peer_manager_header().unwrap().packet_type,
|
||||
packet_type as u8
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_peer_handshake_before_dispatch() {
|
||||
let mut packet = ZCPacket::new_with_payload(&[0; 32]);
|
||||
packet.fill_peer_manager_hdr(0, 0, PacketType::NoiseHandshakeMsg1 as u8);
|
||||
|
||||
assert!(matches!(
|
||||
peer_first_packet_network_name(&packet),
|
||||
Err(TunnelError::InvalidPacket(message))
|
||||
if message == "noise msg1 has no handshake payload"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn demux_keeps_rpc_traffic_on_web_route() {
|
||||
let (server_tunnel, client_tunnel) = create_ring_tunnel_pair();
|
||||
let server_task =
|
||||
tokio::spawn(async move { accept_demux_server_tunnel(server_tunnel).await });
|
||||
let (_stream, mut sink) = client_tunnel.split();
|
||||
let mut packet = ZCPacket::new_with_payload(b"rpc");
|
||||
packet.fill_peer_manager_hdr(0, 0, PacketType::RpcReq as u8);
|
||||
sink.send(packet).await.unwrap();
|
||||
|
||||
let AcceptedTunnelRoute::Web(web_tunnel) = server_task.await.unwrap().unwrap() else {
|
||||
panic!("RPC packet must remain on the Web route");
|
||||
};
|
||||
let (web_tunnel, secure) = accept_or_upgrade_server_tunnel(web_tunnel).await.unwrap();
|
||||
assert!(!secure);
|
||||
let (mut stream, _sink) = web_tunnel.split();
|
||||
assert_eq!(
|
||||
stream
|
||||
.next()
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.peer_manager_header()
|
||||
.unwrap()
|
||||
.packet_type,
|
||||
PacketType::RpcReq as u8
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn demux_leaves_web_noise_upgrade_to_web_acceptance() {
|
||||
if !web_secure_tunnel_supported() {
|
||||
return;
|
||||
}
|
||||
|
||||
let (server_tunnel, client_tunnel) = create_ring_tunnel_pair();
|
||||
let server_task = tokio::spawn(async move {
|
||||
let AcceptedTunnelRoute::Web(tunnel) =
|
||||
accept_demux_server_tunnel(server_tunnel).await.unwrap()
|
||||
else {
|
||||
panic!("Web Noise handshake must remain on the Web route");
|
||||
};
|
||||
accept_or_upgrade_server_tunnel(tunnel).await.unwrap()
|
||||
});
|
||||
let client_tunnel = upgrade_client_tunnel(client_tunnel).await.unwrap();
|
||||
let (server_tunnel, secure) = server_task.await.unwrap();
|
||||
assert!(secure);
|
||||
|
||||
let (mut server_stream, mut server_sink) = server_tunnel.split();
|
||||
let (mut client_stream, mut client_sink) = client_tunnel.split();
|
||||
client_sink
|
||||
.send(pack_control_packet(b"request"))
|
||||
.await
|
||||
.unwrap();
|
||||
let request = timeout(Duration::from_secs(1), server_stream.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(request.payload(), b"request");
|
||||
server_sink
|
||||
.send(pack_control_packet(b"response"))
|
||||
.await
|
||||
.unwrap();
|
||||
let response = timeout(Duration::from_secs(1), client_stream.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(response.payload(), b"response");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idle_demux_connection_is_closed_after_routing_timeout() {
|
||||
let (server_tunnel, _client_tunnel) = create_ring_tunnel_pair();
|
||||
let started = std::time::Instant::now();
|
||||
assert!(matches!(
|
||||
accept_demux_server_tunnel(server_tunnel).await,
|
||||
Err(TunnelError::Shutdown)
|
||||
));
|
||||
assert!(started.elapsed() >= GATEWAY_ACCEPT_TIMEOUT);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestListener(tokio::sync::mpsc::UnboundedReceiver<Box<dyn Tunnel>>);
|
||||
|
||||
#[async_trait]
|
||||
impl SocketListener for TestListener {
|
||||
type Accepted = Box<dyn Tunnel>;
|
||||
async fn listen(&mut self) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn accept(&mut self) -> anyhow::Result<Self::Accepted> {
|
||||
self.0
|
||||
.recv()
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("listener closed"))
|
||||
}
|
||||
fn local_url(&self) -> url::Url {
|
||||
"ring://gateway".parse().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct UpgradingListener {
|
||||
connections: tokio::sync::mpsc::UnboundedReceiver<Box<dyn Tunnel>>,
|
||||
upgrades: Arc<tokio::sync::Semaphore>,
|
||||
accepted: Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SocketListener for UpgradingListener {
|
||||
type Accepted = Box<dyn Tunnel>;
|
||||
async fn listen(&mut self) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn accept(&mut self) -> anyhow::Result<Self::Accepted> {
|
||||
let tunnel = self
|
||||
.connections
|
||||
.recv()
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("closed"))?;
|
||||
self.accepted
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
self.upgrades.acquire().await.unwrap().forget();
|
||||
Ok(tunnel)
|
||||
}
|
||||
fn local_url(&self) -> url::Url {
|
||||
"ring://upgrade".parse().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_completion_and_web_return_preserve_inflight_transport_upgrade() {
|
||||
let instances = Arc::new(NetworkInstanceManager::new(super::super::GatewayConfig {
|
||||
peer_url: "tcp://localhost:22020".into(),
|
||||
relay_data: true,
|
||||
}));
|
||||
let (sender, connections) = tokio::sync::mpsc::unbounded_channel();
|
||||
let upgrades = Arc::new(tokio::sync::Semaphore::new(0));
|
||||
let accepted = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let mut listener = GatewayListener::new(
|
||||
UpgradingListener {
|
||||
connections,
|
||||
upgrades: upgrades.clone(),
|
||||
accepted: accepted.clone(),
|
||||
},
|
||||
instances,
|
||||
);
|
||||
let (server, client) = create_ring_tunnel_pair();
|
||||
sender.send(server).unwrap();
|
||||
// A previous Web connection finishes while the new one is upgrading.
|
||||
let (previous, _previous_client) = create_ring_tunnel_pair();
|
||||
listener.pending.spawn(async move {
|
||||
while accepted.load(std::sync::atomic::Ordering::SeqCst) == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
Some(previous)
|
||||
});
|
||||
let _web = timeout(Duration::from_secs(1), listener.accept())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
upgrades.add_permits(1);
|
||||
let (_, mut sink) = client.split();
|
||||
let mut packet = ZCPacket::new_with_payload(b"upgraded");
|
||||
packet.fill_peer_manager_hdr(0, 0, PacketType::RpcReq as u8);
|
||||
sink.send(packet).await.unwrap();
|
||||
let tunnel = timeout(Duration::from_secs(1), listener.accept())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let (mut stream, _) = tunnel.split();
|
||||
assert_eq!(stream.next().await.unwrap().unwrap().payload(), b"upgraded");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_connections_are_bounded_and_dropped_with_listener() {
|
||||
let instances = Arc::new(NetworkInstanceManager::new(super::super::GatewayConfig {
|
||||
peer_url: "tcp://localhost:22020".into(),
|
||||
relay_data: true,
|
||||
}));
|
||||
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut listener = GatewayListener::new(TestListener(receiver), instances);
|
||||
let mut clients = Vec::new();
|
||||
for _ in 0..MAX_PENDING_CONNECTIONS + 1 {
|
||||
let (server, client) = create_ring_tunnel_pair();
|
||||
sender.send(server).unwrap();
|
||||
clients.push(client);
|
||||
}
|
||||
assert!(
|
||||
timeout(Duration::from_secs(1), listener.accept())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(listener.pending.len(), MAX_PENDING_CONNECTIONS);
|
||||
assert_eq!(listener.inner.as_ref().unwrap().0.len(), 1);
|
||||
drop(listener);
|
||||
let (mut stream, _sink) = clients.remove(0).split();
|
||||
assert!(
|
||||
timeout(Duration::from_secs(1), stream.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idle_connection_does_not_block_active_web_client() {
|
||||
let instances = Arc::new(NetworkInstanceManager::new(super::super::GatewayConfig {
|
||||
peer_url: "tcp://localhost:22020".into(),
|
||||
relay_data: true,
|
||||
}));
|
||||
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut listener = GatewayListener::new(TestListener(receiver), instances);
|
||||
let (idle, _idle_client) = create_ring_tunnel_pair();
|
||||
sender.send(idle).unwrap();
|
||||
let (active, client) = create_ring_tunnel_pair();
|
||||
sender.send(active).unwrap();
|
||||
let (_, mut sink) = client.split();
|
||||
let mut packet = ZCPacket::new_with_payload(b"rpc");
|
||||
packet.fill_peer_manager_hdr(0, 0, PacketType::RpcReq as u8);
|
||||
sink.send(packet).await.unwrap();
|
||||
let tunnel = timeout(Duration::from_secs(1), listener.accept())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let (mut stream, _) = tunnel.split();
|
||||
assert_eq!(stream.next().await.unwrap().unwrap().payload(), b"rpc");
|
||||
assert_eq!(listener.pending.len(), 1);
|
||||
listener.pending.abort_all();
|
||||
while listener.pending.join_next().await.is_some() {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Tenant-owned central network intent and its pure compiler.
|
||||
|
||||
pub mod compiler;
|
||||
pub mod device_auth;
|
||||
pub mod gateway;
|
||||
pub mod model;
|
||||
pub mod service;
|
||||
mod temporary_peers;
|
||||
@@ -0,0 +1,183 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
use easytier::common::config::NetworkConfig;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CentralNetworkIntent {
|
||||
pub id: Uuid,
|
||||
pub user_id: i32,
|
||||
pub display_name: String,
|
||||
pub network_name: String,
|
||||
pub network_secret: String,
|
||||
pub mode: NetworkMode,
|
||||
pub virtual_cidr: Option<String>,
|
||||
pub secure_mode: bool,
|
||||
pub members: Vec<NetworkMemberIntent>,
|
||||
pub credentials: Vec<NetworkCredentialIntent>,
|
||||
pub acl_policy: Option<AclPolicy>,
|
||||
}
|
||||
|
||||
impl CentralNetworkIntent {
|
||||
pub fn remove_device(&mut self, device_id: Uuid) -> bool {
|
||||
let removed_members: Vec<_> = self
|
||||
.members
|
||||
.iter()
|
||||
.filter(|member| member.device_id == device_id.to_string())
|
||||
.cloned()
|
||||
.collect();
|
||||
if removed_members.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let removed_member_ids: HashSet<_> =
|
||||
removed_members.iter().map(|member| member.id).collect();
|
||||
let removed_credential_ids: HashSet<_> = removed_members
|
||||
.iter()
|
||||
.filter_map(|member| member.credential_id.as_deref())
|
||||
.collect();
|
||||
let removed_groups: HashSet<_> = self
|
||||
.credentials
|
||||
.iter()
|
||||
.filter(|credential| removed_credential_ids.contains(credential.id.as_str()))
|
||||
.flat_map(|credential| credential.grant.acl_groups.iter().cloned())
|
||||
.collect();
|
||||
|
||||
self.members
|
||||
.retain(|member| !removed_member_ids.contains(&member.id));
|
||||
self.credentials
|
||||
.retain(|credential| !removed_credential_ids.contains(credential.id.as_str()));
|
||||
let remaining_groups: HashSet<_> = self
|
||||
.credentials
|
||||
.iter()
|
||||
.flat_map(|credential| credential.grant.acl_groups.iter().cloned())
|
||||
.collect();
|
||||
let removed_groups: HashSet<_> = removed_groups
|
||||
.difference(&remaining_groups)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if let Some(policy) = &mut self.acl_policy {
|
||||
policy.rules.retain_mut(|rule| {
|
||||
rule.sources.retain(|source| match source {
|
||||
AclSource::Member { member_id } => !removed_member_ids.contains(member_id),
|
||||
AclSource::Group { name } => !removed_groups.contains(name),
|
||||
AclSource::All => true,
|
||||
});
|
||||
rule.destinations.retain(|destination| match destination {
|
||||
AclDestination::Member { member_id }
|
||||
| AclDestination::Subnet { member_id, .. } => {
|
||||
!removed_member_ids.contains(member_id)
|
||||
}
|
||||
AclDestination::All => true,
|
||||
});
|
||||
!rule.sources.is_empty() && !rule.destinations.is_empty()
|
||||
});
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum NetworkMode {
|
||||
PublicServer { url: String },
|
||||
Manual { peer_urls: Vec<String> },
|
||||
Standalone,
|
||||
Gateway { peer_url: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NetworkMemberIntent {
|
||||
pub id: Uuid,
|
||||
pub device_id: String,
|
||||
pub hostname: Option<String>,
|
||||
pub virtual_ipv4: Option<String>,
|
||||
#[serde(default)]
|
||||
pub allocated_ipv4: Option<String>,
|
||||
#[serde(default)]
|
||||
pub config_override: Option<NetworkConfig>,
|
||||
pub credential_id: Option<String>,
|
||||
/// HMAC material for permanent administrator group proofs. Temporary
|
||||
/// members authenticate their groups through their credential grant and
|
||||
/// must leave this empty.
|
||||
pub acl_group_secret: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NetworkCredentialIntent {
|
||||
pub id: String,
|
||||
pub secret: String,
|
||||
pub expiry_unix: i64,
|
||||
pub grant: CredentialGrant,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CredentialGrant {
|
||||
pub acl_groups: Vec<String>,
|
||||
pub allow_relay: bool,
|
||||
pub allowed_proxy_cidrs: Vec<String>,
|
||||
pub reusable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AclPolicy {
|
||||
pub default_action: AclAction,
|
||||
pub rules: Vec<AclRule>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AclAction {
|
||||
#[default]
|
||||
Allow,
|
||||
Deny,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AclRule {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
pub action: AclAction,
|
||||
pub sources: Vec<AclSource>,
|
||||
pub destinations: Vec<AclDestination>,
|
||||
pub protocols: Vec<AclProtocolTarget>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum AclSource {
|
||||
All,
|
||||
Member { member_id: Uuid },
|
||||
Group { name: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum AclDestination {
|
||||
All,
|
||||
Member { member_id: Uuid },
|
||||
Subnet { member_id: Uuid, cidrs: Vec<String> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AclProtocol {
|
||||
Tcp,
|
||||
Udp,
|
||||
Icmp,
|
||||
Icmpv6,
|
||||
Any,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AclProtocolTarget {
|
||||
pub protocol: AclProtocol,
|
||||
pub ports: Vec<String>,
|
||||
pub stateful: bool,
|
||||
}
|
||||
|
||||
pub fn member_group_name(member_id: Uuid) -> String {
|
||||
format!("member:{member_id}")
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,465 @@
|
||||
//! Runtime visibility for peers admitted by managed credentials.
|
||||
//!
|
||||
//! Credential peers do not need to register with the console. The x25519
|
||||
//! public key identifies their credential, while the peer ID identifies each
|
||||
//! online node and joins it with the live routing table.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use base64::Engine as _;
|
||||
use easytier::proto::api::instance::PeerManageRpc as _;
|
||||
use easytier::proto::rpc_types::controller::BaseController;
|
||||
use futures::StreamExt as _;
|
||||
use sha2::Digest as _;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::model::{CentralNetworkIntent, NetworkMode};
|
||||
use crate::central_network::gateway::NetworkInstanceManager;
|
||||
use crate::client_manager::ClientManager;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct TemporaryPeerInfo {
|
||||
pub peer_id: u32,
|
||||
pub credential_id: Option<String>,
|
||||
pub credential_expiry_unix: Option<i64>,
|
||||
pub hostname: Option<String>,
|
||||
pub ipv4: Option<String>,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct CredentialRef {
|
||||
pub credential_id: String,
|
||||
pub expiry_unix: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct ConnObservation {
|
||||
pub peer_id: u32,
|
||||
pub remote_static_pubkey: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct RouteObservation {
|
||||
pub peer_id: u32,
|
||||
pub hostname: String,
|
||||
pub ipv4: Option<String>,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
pub(crate) trait ConnFacts {
|
||||
fn peer_id(&self) -> u32;
|
||||
fn network_name(&self) -> &str;
|
||||
fn remote_static_pubkey(&self) -> &[u8];
|
||||
fn is_credential_identity(&self) -> bool;
|
||||
}
|
||||
|
||||
impl ConnFacts for easytier_proto::core_peer::peer::PeerConnInfo {
|
||||
fn peer_id(&self) -> u32 {
|
||||
self.peer_id
|
||||
}
|
||||
|
||||
fn network_name(&self) -> &str {
|
||||
&self.network_name
|
||||
}
|
||||
|
||||
fn remote_static_pubkey(&self) -> &[u8] {
|
||||
&self.noise_remote_static_pubkey
|
||||
}
|
||||
|
||||
fn is_credential_identity(&self) -> bool {
|
||||
self.peer_identity_type == easytier_proto::peer_rpc::PeerIdentityType::Credential as i32
|
||||
}
|
||||
}
|
||||
|
||||
impl ConnFacts for easytier_proto::api::instance::PeerConnInfo {
|
||||
fn peer_id(&self) -> u32 {
|
||||
self.peer_id
|
||||
}
|
||||
|
||||
fn network_name(&self) -> &str {
|
||||
&self.network_name
|
||||
}
|
||||
|
||||
fn remote_static_pubkey(&self) -> &[u8] {
|
||||
&self.noise_remote_static_pubkey
|
||||
}
|
||||
|
||||
fn is_credential_identity(&self) -> bool {
|
||||
self.peer_identity_type == easytier_proto::peer_rpc::PeerIdentityType::Credential as i32
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn conn_observation(
|
||||
conn: &impl ConnFacts,
|
||||
network_name: &str,
|
||||
) -> Option<ConnObservation> {
|
||||
if conn.network_name() != network_name
|
||||
|| !conn.is_credential_identity()
|
||||
|| conn.remote_static_pubkey().is_empty()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(ConnObservation {
|
||||
peer_id: conn.peer_id(),
|
||||
remote_static_pubkey: conn.remote_static_pubkey().to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
impl From<&easytier_proto::core_peer::peer::Route> for RouteObservation {
|
||||
fn from(route: &easytier_proto::core_peer::peer::Route) -> Self {
|
||||
Self {
|
||||
peer_id: route.peer_id,
|
||||
hostname: route.hostname.clone(),
|
||||
ipv4: route.ipv4_addr.as_ref().map(ipv4_to_string),
|
||||
version: route.version.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&easytier_proto::api::instance::Route> for RouteObservation {
|
||||
fn from(route: &easytier_proto::api::instance::Route) -> Self {
|
||||
Self {
|
||||
peer_id: route.peer_id,
|
||||
hostname: route.hostname.clone(),
|
||||
ipv4: route.ipv4_addr.as_ref().map(ipv4_to_string),
|
||||
version: route.version.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ipv4_to_string(inet: &easytier_proto::common::Ipv4Inet) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
std::net::Ipv4Addr::from(inet.address.unwrap_or_default().addr),
|
||||
inet.network_length
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn credential_fingerprint(secret_b64: &str) -> Option<String> {
|
||||
let secret: [u8; 32] = base64::engine::general_purpose::STANDARD
|
||||
.decode(secret_b64.trim())
|
||||
.ok()?
|
||||
.try_into()
|
||||
.ok()?;
|
||||
let secret = x25519_dalek::StaticSecret::from(secret);
|
||||
let public = x25519_dalek::PublicKey::from(&secret);
|
||||
Some(hex_sha256(public.as_bytes()))
|
||||
}
|
||||
|
||||
fn remote_pubkey_fingerprint(pubkey: &[u8]) -> String {
|
||||
hex_sha256(pubkey)
|
||||
}
|
||||
|
||||
fn hex_sha256(data: &[u8]) -> String {
|
||||
sha2::Sha256::digest(data)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn extract_temporary_peers(
|
||||
conns: &[ConnObservation],
|
||||
routes: &HashMap<u32, RouteObservation>,
|
||||
credential_by_fingerprint: &HashMap<String, CredentialRef>,
|
||||
) -> Vec<TemporaryPeerInfo> {
|
||||
let mut by_peer = HashMap::new();
|
||||
for conn in conns {
|
||||
let fingerprint = remote_pubkey_fingerprint(&conn.remote_static_pubkey);
|
||||
let credential = credential_by_fingerprint.get(&fingerprint);
|
||||
let info = by_peer
|
||||
.entry(conn.peer_id)
|
||||
.or_insert_with(|| TemporaryPeerInfo {
|
||||
peer_id: conn.peer_id,
|
||||
credential_id: None,
|
||||
credential_expiry_unix: None,
|
||||
hostname: None,
|
||||
ipv4: None,
|
||||
version: None,
|
||||
});
|
||||
if info.credential_id.is_none()
|
||||
&& let Some(credential) = credential
|
||||
{
|
||||
info.credential_id = Some(credential.credential_id.clone());
|
||||
info.credential_expiry_unix = Some(credential.expiry_unix);
|
||||
}
|
||||
}
|
||||
|
||||
let mut peers = by_peer
|
||||
.into_values()
|
||||
.map(|mut info| {
|
||||
if let Some(route) = routes.get(&info.peer_id) {
|
||||
info.hostname = (!route.hostname.is_empty()).then(|| route.hostname.clone());
|
||||
info.ipv4 = route.ipv4.clone();
|
||||
info.version = (!route.version.is_empty()).then(|| route.version.clone());
|
||||
}
|
||||
info
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
peers.sort_by(|left, right| {
|
||||
left.hostname
|
||||
.cmp(&right.hostname)
|
||||
.then_with(|| left.credential_id.cmp(&right.credential_id))
|
||||
.then_with(|| left.peer_id.cmp(&right.peer_id))
|
||||
});
|
||||
peers
|
||||
}
|
||||
|
||||
/// Collect the current credential-peer view from the authoritative runtime.
|
||||
/// Runtime queries are deliberately best-effort: an unavailable observer
|
||||
/// produces a partial or empty view without failing the control-plane read.
|
||||
pub(crate) async fn collect(
|
||||
client_manager: &ClientManager,
|
||||
intent: &CentralNetworkIntent,
|
||||
network_instances: Option<&NetworkInstanceManager>,
|
||||
) -> Vec<TemporaryPeerInfo> {
|
||||
if !intent.secure_mode || intent.credentials.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let credential_by_fingerprint = intent
|
||||
.credentials
|
||||
.iter()
|
||||
.filter_map(|credential| {
|
||||
credential_fingerprint(&credential.secret).map(|fingerprint| {
|
||||
(
|
||||
fingerprint,
|
||||
CredentialRef {
|
||||
credential_id: credential.id.clone(),
|
||||
expiry_unix: credential.expiry_unix,
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
if credential_by_fingerprint.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut conns = Vec::new();
|
||||
let mut routes = HashMap::new();
|
||||
match intent.mode {
|
||||
NetworkMode::Gateway { .. } => {
|
||||
if let Some(instances) = network_instances
|
||||
&& let Some(observation) =
|
||||
instances.observe_network(intent.user_id, intent.id).await
|
||||
{
|
||||
conns.extend(
|
||||
observation
|
||||
.connections
|
||||
.iter()
|
||||
.filter_map(|conn| conn_observation(conn, &intent.network_name)),
|
||||
);
|
||||
for route in &observation.routes {
|
||||
let route = RouteObservation::from(route);
|
||||
routes.entry(route.peer_id).or_insert(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => collect_from_members(client_manager, intent, &mut conns, &mut routes).await,
|
||||
}
|
||||
|
||||
extract_temporary_peers(&conns, &routes, &credential_by_fingerprint)
|
||||
}
|
||||
|
||||
async fn collect_from_members(
|
||||
client_manager: &ClientManager,
|
||||
intent: &CentralNetworkIntent,
|
||||
conns: &mut Vec<ConnObservation>,
|
||||
routes: &mut HashMap<u32, RouteObservation>,
|
||||
) {
|
||||
let mut observations = futures::stream::iter(
|
||||
intent
|
||||
.members
|
||||
.iter()
|
||||
.map(|member| async move {
|
||||
let Ok(device_id) = Uuid::parse_str(&member.device_id) else {
|
||||
return None;
|
||||
};
|
||||
let session =
|
||||
client_manager.get_session_by_machine_id(intent.user_id, &device_id)?;
|
||||
let client = session.scoped_client::<
|
||||
easytier::proto::api::instance::PeerManageRpcClientFactory<BaseController>,
|
||||
>();
|
||||
let instance = easytier::proto::api::instance::InstanceIdentifier {
|
||||
selector: Some(
|
||||
easytier::proto::api::instance::instance_identifier::Selector::Id(
|
||||
intent.id.into(),
|
||||
),
|
||||
),
|
||||
};
|
||||
let peers = client
|
||||
.list_peer(
|
||||
BaseController::default(),
|
||||
easytier::proto::api::instance::ListPeerRequest {
|
||||
instance: Some(instance.clone()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let routes = client
|
||||
.list_route(
|
||||
BaseController::default(),
|
||||
easytier::proto::api::instance::ListRouteRequest {
|
||||
instance: Some(instance),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
Some((peers, routes))
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.buffered(16);
|
||||
while let Some(observation) = observations.next().await {
|
||||
let Some((peers, peer_routes)) = observation else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(response) = peers {
|
||||
conns.extend(response.peer_infos.iter().flat_map(|peer| {
|
||||
peer.conns
|
||||
.iter()
|
||||
.filter_map(|conn| conn_observation(conn, &intent.network_name))
|
||||
}));
|
||||
}
|
||||
if let Ok(response) = peer_routes {
|
||||
for route in &response.routes {
|
||||
let route = RouteObservation::from(route);
|
||||
routes.entry(route.peer_id).or_insert(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn credential() -> (String, Vec<u8>, String) {
|
||||
let bytes = [7u8; 32];
|
||||
let secret = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
let private = x25519_dalek::StaticSecret::from(bytes);
|
||||
let public = x25519_dalek::PublicKey::from(&private).as_bytes().to_vec();
|
||||
let fingerprint = credential_fingerprint(&secret).unwrap();
|
||||
(secret, public, fingerprint)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_matches_the_handshake_public_key() {
|
||||
let (_, public, fingerprint) = credential();
|
||||
assert_eq!(remote_pubkey_fingerprint(&public), fingerprint);
|
||||
assert!(credential_fingerprint("not base64").is_none());
|
||||
assert!(
|
||||
credential_fingerprint(&base64::engine::general_purpose::STANDARD.encode([1u8; 8]))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_filter_requires_the_network_and_credential_identity() {
|
||||
let (_, public, _) = credential();
|
||||
let credential_identity = easytier_proto::peer_rpc::PeerIdentityType::Credential as i32;
|
||||
let admin_identity = easytier_proto::peer_rpc::PeerIdentityType::Admin as i32;
|
||||
let api_conn = |network_name: &str, identity, public: Vec<u8>| {
|
||||
easytier_proto::api::instance::PeerConnInfo {
|
||||
peer_id: 42,
|
||||
network_name: network_name.to_owned(),
|
||||
peer_identity_type: identity,
|
||||
noise_remote_static_pubkey: public,
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
let core_conn = easytier_proto::core_peer::peer::PeerConnInfo {
|
||||
peer_id: 42,
|
||||
network_name: "mesh".to_owned(),
|
||||
peer_identity_type: credential_identity,
|
||||
noise_remote_static_pubkey: public.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(
|
||||
conn_observation(
|
||||
&api_conn("mesh", credential_identity, public.clone()),
|
||||
"mesh"
|
||||
)
|
||||
.is_some()
|
||||
);
|
||||
assert!(conn_observation(&core_conn, "mesh").is_some());
|
||||
assert!(
|
||||
conn_observation(
|
||||
&api_conn("other", credential_identity, public.clone()),
|
||||
"mesh"
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
conn_observation(&api_conn("mesh", admin_identity, public.clone()), "mesh").is_none()
|
||||
);
|
||||
assert!(
|
||||
conn_observation(&api_conn("mesh", credential_identity, Vec::new()), "mesh").is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extraction_keeps_distinct_peers_using_the_same_credential() {
|
||||
let (_, public, fingerprint) = credential();
|
||||
let credentials = HashMap::from([(
|
||||
fingerprint,
|
||||
CredentialRef {
|
||||
credential_id: "credential-1".to_owned(),
|
||||
expiry_unix: 4_102_444_800,
|
||||
},
|
||||
)]);
|
||||
let conns = vec![
|
||||
ConnObservation {
|
||||
peer_id: 42,
|
||||
remote_static_pubkey: public.clone(),
|
||||
},
|
||||
ConnObservation {
|
||||
peer_id: 42,
|
||||
remote_static_pubkey: public.clone(),
|
||||
},
|
||||
ConnObservation {
|
||||
peer_id: 99,
|
||||
remote_static_pubkey: public,
|
||||
},
|
||||
ConnObservation {
|
||||
peer_id: 7,
|
||||
remote_static_pubkey: vec![9; 32],
|
||||
},
|
||||
];
|
||||
let routes = HashMap::from([
|
||||
(
|
||||
42,
|
||||
RouteObservation {
|
||||
peer_id: 42,
|
||||
hostname: "temporary-phone".to_owned(),
|
||||
ipv4: Some("10.126.0.3/24".to_owned()),
|
||||
version: "2.7.0".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
99,
|
||||
RouteObservation {
|
||||
peer_id: 99,
|
||||
hostname: "temporary-laptop".to_owned(),
|
||||
ipv4: Some("10.126.0.2/24".to_owned()),
|
||||
version: "2.7.0".to_owned(),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
let peers = extract_temporary_peers(&conns, &routes, &credentials);
|
||||
assert_eq!(peers.len(), 3);
|
||||
let matched = peers.iter().find(|peer| peer.peer_id == 99).unwrap();
|
||||
assert_eq!(matched.credential_id.as_deref(), Some("credential-1"));
|
||||
assert_eq!(matched.hostname.as_deref(), Some("temporary-laptop"));
|
||||
assert_eq!(matched.ipv4.as_deref(), Some("10.126.0.2/24"));
|
||||
assert!(peers.iter().any(|peer| peer.peer_id == 42
|
||||
&& peer.credential_id.as_deref() == Some("credential-1")
|
||||
&& peer.hostname.as_deref() == Some("temporary-phone")));
|
||||
assert!(
|
||||
peers
|
||||
.iter()
|
||||
.any(|peer| peer.peer_id == 7 && peer.credential_id.is_none())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use std::{
|
||||
|
||||
use dashmap::DashMap;
|
||||
use easytier::proto::{
|
||||
api::manage::WebClientService,
|
||||
api::{config::ConfigRpc, manage::WebClientService},
|
||||
rpc_types::controller::BaseController,
|
||||
web::{HeartbeatRequest, HeartbeatResponse},
|
||||
};
|
||||
@@ -512,6 +512,14 @@ impl
|
||||
Some(s.scoped_rpc_client())
|
||||
}
|
||||
|
||||
fn get_config_rpc_client(
|
||||
&self,
|
||||
(user_id, machine_id): (UserIdInDb, uuid::Uuid),
|
||||
) -> Option<Box<dyn ConfigRpc<Controller = BaseController> + Send>> {
|
||||
let session = self.get_session_by_machine_id(user_id, &machine_id)?;
|
||||
Some(session.scoped_config_client())
|
||||
}
|
||||
|
||||
fn get_storage(
|
||||
&self,
|
||||
) -> &impl remote_client::Storage<
|
||||
|
||||
@@ -352,13 +352,17 @@ fn web_source_runtime_patch(
|
||||
normalized_vpn_portal(desired)?,
|
||||
) {
|
||||
(Some(current_portal), Some(desired_portal)) => {
|
||||
if current_portal.wireguard_listen != desired_portal.wireguard_listen
|
||||
if current_portal.enabled.unwrap_or(true) != desired_portal.enabled.unwrap_or(true)
|
||||
|| current_portal.wireguard_listen != desired_portal.wireguard_listen
|
||||
|| current_portal.wireguard_private_key != desired_portal.wireguard_private_key
|
||||
{
|
||||
// The listener identity changed; the portal must be rebuilt.
|
||||
return Ok(None);
|
||||
}
|
||||
if current_portal.clients != desired_portal.clients {
|
||||
if desired_portal.enabled == Some(false) {
|
||||
return Ok(None);
|
||||
}
|
||||
patch.vpn_portal_clients =
|
||||
diff_vpn_portal_clients(¤t_portal.clients, &desired_portal.clients);
|
||||
}
|
||||
@@ -599,6 +603,7 @@ mod tests {
|
||||
config.virtual_ipv4 = Some("10.144.0.1".to_string());
|
||||
config.network_length = Some(24);
|
||||
config.vpn_portal_config = Some(easytier::proto::api::manage::VpnPortalConfig {
|
||||
enabled: None,
|
||||
wireguard_listen: listen.to_owned(),
|
||||
wireguard_private_key: Some("dGVzdC1rZXk=".to_owned()),
|
||||
clients,
|
||||
@@ -733,6 +738,30 @@ mod tests {
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let mut disabled = with_portal.clone();
|
||||
disabled.vpn_portal_config.as_mut().unwrap().enabled = Some(false);
|
||||
assert!(
|
||||
web_source_runtime_patch(&with_portal, &disabled)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
web_source_runtime_patch(&disabled, &with_portal)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
web_source_runtime_patch(&disabled, &disabled).unwrap(),
|
||||
Some(InstanceConfigPatch::default())
|
||||
);
|
||||
let mut edited = disabled.clone();
|
||||
edited.vpn_portal_config.as_mut().unwrap().clients.clear();
|
||||
assert!(
|
||||
web_source_runtime_patch(&disabled, &edited)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1076,8 +1076,7 @@ impl Session {
|
||||
self.data.read().await.req()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) async fn applied_config_revision(&self) -> Option<String> {
|
||||
pub(crate) async fn applied_config_revision(&self) -> Option<String> {
|
||||
let data = self.data.read().await;
|
||||
data.managed_runtime().applied_config_revision.clone()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,513 @@
|
||||
use sea_orm::{
|
||||
ColumnTrait as _, DbErr, EntityTrait, QueryFilter as _, QueryOrder as _, Set,
|
||||
sea_query::OnConflict,
|
||||
};
|
||||
use sqlx::types::chrono;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{Db, UserIdInDb, central_intent, entity, sqlx_db_error};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeviceHeartbeatRecord {
|
||||
pub user_id: UserIdInDb,
|
||||
pub machine_id: Uuid,
|
||||
pub hostname: String,
|
||||
pub easytier_version: String,
|
||||
pub device_os: String,
|
||||
pub client_url: String,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub async fn upsert_device_heartbeat(
|
||||
&self,
|
||||
record: DeviceHeartbeatRecord,
|
||||
) -> Result<(), DbErr> {
|
||||
use entity::devices as d;
|
||||
|
||||
let now = chrono::Local::now().fixed_offset();
|
||||
d::Entity::insert(d::ActiveModel {
|
||||
user_id: Set(record.user_id),
|
||||
machine_id: Set(record.machine_id.to_string()),
|
||||
hostname: Set(record.hostname),
|
||||
easytier_version: Set(record.easytier_version),
|
||||
device_os: Set(record.device_os),
|
||||
client_url: Set(record.client_url),
|
||||
first_seen_time: Set(now),
|
||||
last_seen_time: Set(now),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::columns([d::Column::UserId, d::Column::MachineId])
|
||||
.update_columns([
|
||||
d::Column::Hostname,
|
||||
d::Column::EasytierVersion,
|
||||
d::Column::DeviceOs,
|
||||
d::Column::ClientUrl,
|
||||
d::Column::LastSeenTime,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(self.orm_db())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_devices(
|
||||
&self,
|
||||
user_id: UserIdInDb,
|
||||
) -> Result<Vec<entity::devices::Model>, DbErr> {
|
||||
use entity::devices as d;
|
||||
|
||||
d::Entity::find()
|
||||
.filter(d::Column::UserId.eq(user_id))
|
||||
.order_by_desc(d::Column::LastSeenTime)
|
||||
.all(self.orm_db())
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_device(
|
||||
&self,
|
||||
(user_id, machine_id): (UserIdInDb, Uuid),
|
||||
) -> Result<Option<entity::devices::Model>, DbErr> {
|
||||
use entity::devices as d;
|
||||
|
||||
d::Entity::find()
|
||||
.filter(d::Column::UserId.eq(user_id))
|
||||
.filter(d::Column::MachineId.eq(machine_id.to_string()))
|
||||
.one(self.orm_db())
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_device_alias(
|
||||
&self,
|
||||
(user_id, machine_id): (UserIdInDb, Uuid),
|
||||
alias: String,
|
||||
) -> Result<bool, DbErr> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE devices
|
||||
SET alias = ?
|
||||
WHERE user_id = ? AND machine_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(alias)
|
||||
.bind(user_id)
|
||||
.bind(machine_id.to_string())
|
||||
.execute(&self.db)
|
||||
.await
|
||||
.map_err(sqlx_db_error)?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn is_device_blocked(
|
||||
&self,
|
||||
(user_id, machine_id): (UserIdInDb, Uuid),
|
||||
) -> Result<bool, DbErr> {
|
||||
use entity::blocked_devices as b;
|
||||
|
||||
Ok(b::Entity::find()
|
||||
.filter(b::Column::UserId.eq(user_id))
|
||||
.filter(b::Column::MachineId.eq(machine_id.to_string()))
|
||||
.one(self.orm_db())
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
pub async fn list_blocked_devices(
|
||||
&self,
|
||||
user_id: UserIdInDb,
|
||||
) -> Result<Vec<entity::blocked_devices::Model>, DbErr> {
|
||||
use entity::blocked_devices as b;
|
||||
|
||||
b::Entity::find()
|
||||
.filter(b::Column::UserId.eq(user_id))
|
||||
.order_by_desc(b::Column::LastAttemptTime)
|
||||
.order_by_desc(b::Column::BlockedTime)
|
||||
.all(self.orm_db())
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unblock_device(
|
||||
&self,
|
||||
(user_id, machine_id): (UserIdInDb, Uuid),
|
||||
) -> Result<bool, DbErr> {
|
||||
use entity::blocked_devices as b;
|
||||
|
||||
let result = b::Entity::delete_many()
|
||||
.filter(b::Column::UserId.eq(user_id))
|
||||
.filter(b::Column::MachineId.eq(machine_id.to_string()))
|
||||
.exec(self.orm_db())
|
||||
.await?;
|
||||
Ok(result.rows_affected > 0)
|
||||
}
|
||||
|
||||
pub async fn record_blocked_attempt(
|
||||
&self,
|
||||
(user_id, machine_id): (UserIdInDb, Uuid),
|
||||
hostname: &str,
|
||||
) -> Result<bool, DbErr> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE blocked_devices
|
||||
SET hostname = ?,
|
||||
attempt_count = attempt_count + 1,
|
||||
last_attempt_time = ?
|
||||
WHERE user_id = ? AND machine_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(hostname)
|
||||
.bind(chrono::Local::now().fixed_offset())
|
||||
.bind(user_id)
|
||||
.bind(machine_id.to_string())
|
||||
.execute(&self.db)
|
||||
.await
|
||||
.map_err(sqlx_db_error)?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn delete_device_with_optional_block(
|
||||
&self,
|
||||
(user_id, machine_id): (UserIdInDb, Uuid),
|
||||
block: bool,
|
||||
) -> Result<bool, DbErr> {
|
||||
let machine_id_value = machine_id.to_string();
|
||||
let mut transaction = self
|
||||
.db
|
||||
.begin_with("BEGIN IMMEDIATE")
|
||||
.await
|
||||
.map_err(sqlx_db_error)?;
|
||||
let hostname = sqlx::query_scalar::<_, String>(
|
||||
r#"
|
||||
SELECT hostname
|
||||
FROM devices
|
||||
WHERE user_id = ? AND machine_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&machine_id_value)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await
|
||||
.map_err(sqlx_db_error)?;
|
||||
let Some(hostname) = hostname else {
|
||||
transaction.commit().await.map_err(sqlx_db_error)?;
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
if block {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO blocked_devices (
|
||||
user_id, machine_id, hostname, blocked_time,
|
||||
attempt_count, last_attempt_time
|
||||
) VALUES (?, ?, ?, ?, 0, NULL)
|
||||
ON CONFLICT(user_id, machine_id) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&machine_id_value)
|
||||
.bind(hostname)
|
||||
.bind(chrono::Local::now().fixed_offset())
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(sqlx_db_error)?;
|
||||
}
|
||||
|
||||
if let Err(error) = central_intent::remove_device_from_central_intents(
|
||||
&mut transaction,
|
||||
user_id,
|
||||
machine_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
transaction.rollback().await.map_err(sqlx_db_error)?;
|
||||
return Err(DbErr::Custom(format!(
|
||||
"remove device from central network intents: {error}"
|
||||
)));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM devices
|
||||
WHERE user_id = ? AND machine_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&machine_id_value)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(sqlx_db_error)?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
DELETE FROM user_running_network_configs
|
||||
WHERE user_id = ? AND device_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&machine_id_value)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(sqlx_db_error)?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
DELETE FROM managed_config_revisions
|
||||
WHERE user_id = ? AND device_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&machine_id_value)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(sqlx_db_error)?;
|
||||
transaction.commit().await.map_err(sqlx_db_error)?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use easytier::common::config::{ConfigSource, NetworkConfig};
|
||||
use easytier_core::management::remote_client::{ListNetworkProps, Storage};
|
||||
use sea_orm::{EntityTrait, Set};
|
||||
|
||||
use super::{Db, DeviceHeartbeatRecord, entity::blocked_devices};
|
||||
|
||||
async fn insert_blocked_device(
|
||||
db: &Db,
|
||||
(user_id, machine_id): (i32, uuid::Uuid),
|
||||
hostname: &str,
|
||||
) {
|
||||
blocked_devices::Entity::insert(blocked_devices::ActiveModel {
|
||||
user_id: Set(user_id),
|
||||
machine_id: Set(machine_id.to_string()),
|
||||
hostname: Set(hostname.to_string()),
|
||||
blocked_time: Set(chrono::Local::now().fixed_offset()),
|
||||
attempt_count: Set(0),
|
||||
last_attempt_time: Set(None),
|
||||
})
|
||||
.exec(db.orm_db())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn registered_device_identity_is_scoped_by_tenant() {
|
||||
let db = Db::memory_db().await;
|
||||
let user_a = db.auto_create_user("device-owner-a").await.unwrap().id;
|
||||
let user_b = db.auto_create_user("device-owner-b").await.unwrap().id;
|
||||
let machine_id = uuid::Uuid::new_v4();
|
||||
|
||||
db.upsert_device_heartbeat(DeviceHeartbeatRecord {
|
||||
user_id: user_a,
|
||||
machine_id,
|
||||
hostname: "host-a".to_string(),
|
||||
easytier_version: "a".to_string(),
|
||||
device_os: "{}".to_string(),
|
||||
client_url: "tcp://127.0.0.1:1001".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
db.upsert_device_heartbeat(DeviceHeartbeatRecord {
|
||||
user_id: user_b,
|
||||
machine_id,
|
||||
hostname: "host-b".to_string(),
|
||||
easytier_version: "b".to_string(),
|
||||
device_os: "{}".to_string(),
|
||||
client_url: "tcp://127.0.0.1:1002".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
db.get_device((user_a, machine_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.hostname,
|
||||
"host-a"
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_device((user_b, machine_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.hostname,
|
||||
"host-b"
|
||||
);
|
||||
assert_eq!(db.list_devices(user_a).await.unwrap().len(), 1);
|
||||
assert_eq!(db.list_devices(user_b).await.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn heartbeat_upsert_preserves_alias_and_first_seen() {
|
||||
let db = Db::memory_db().await;
|
||||
let user_id = db.auto_create_user("device-upsert").await.unwrap().id;
|
||||
let machine_id = uuid::Uuid::new_v4();
|
||||
|
||||
db.upsert_device_heartbeat(DeviceHeartbeatRecord {
|
||||
user_id,
|
||||
machine_id,
|
||||
hostname: "before".to_string(),
|
||||
easytier_version: "1".to_string(),
|
||||
device_os: r#"{"os":"before"}"#.to_string(),
|
||||
client_url: "tcp://127.0.0.1:1001".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
db.set_device_alias((user_id, machine_id), "console-name".to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
let before = db.get_device((user_id, machine_id)).await.unwrap().unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
|
||||
db.upsert_device_heartbeat(DeviceHeartbeatRecord {
|
||||
user_id,
|
||||
machine_id,
|
||||
hostname: "after".to_string(),
|
||||
easytier_version: "2".to_string(),
|
||||
device_os: r#"{"os":"after"}"#.to_string(),
|
||||
client_url: "tcp://127.0.0.1:1002".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let after = db.get_device((user_id, machine_id)).await.unwrap().unwrap();
|
||||
assert_eq!(after.alias, "console-name");
|
||||
assert_eq!(after.first_seen_time, before.first_seen_time);
|
||||
assert!(after.last_seen_time > before.last_seen_time);
|
||||
assert_eq!(after.hostname, "after");
|
||||
assert_eq!(after.easytier_version, "2");
|
||||
assert_eq!(after.device_os, r#"{"os":"after"}"#);
|
||||
assert_eq!(after.client_url, "tcp://127.0.0.1:1002");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocklist_is_scoped_by_tenant() {
|
||||
let db = Db::memory_db().await;
|
||||
let user_a = db.auto_create_user("blocked-owner-a").await.unwrap().id;
|
||||
let user_b = db.auto_create_user("blocked-owner-b").await.unwrap().id;
|
||||
let machine_id = uuid::Uuid::new_v4();
|
||||
|
||||
insert_blocked_device(&db, (user_a, machine_id), "host-a").await;
|
||||
|
||||
assert!(db.is_device_blocked((user_a, machine_id)).await.unwrap());
|
||||
assert!(!db.is_device_blocked((user_b, machine_id)).await.unwrap());
|
||||
assert!(!db.unblock_device((user_b, machine_id)).await.unwrap());
|
||||
assert!(db.is_device_blocked((user_a, machine_id)).await.unwrap());
|
||||
let blocked_a = db.list_blocked_devices(user_a).await.unwrap();
|
||||
assert_eq!(blocked_a.len(), 1);
|
||||
assert_eq!(blocked_a[0].hostname, "host-a");
|
||||
assert!(db.list_blocked_devices(user_b).await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocked_attempt_increments_are_atomic() {
|
||||
const ATTEMPTS: usize = 32;
|
||||
|
||||
let db = Db::memory_db().await;
|
||||
let user_a = db.auto_create_user("attempt-owner-a").await.unwrap().id;
|
||||
let user_b = db.auto_create_user("attempt-owner-b").await.unwrap().id;
|
||||
let machine_id = uuid::Uuid::new_v4();
|
||||
insert_blocked_device(&db, (user_a, machine_id), "host-a").await;
|
||||
insert_blocked_device(&db, (user_b, machine_id), "host-b").await;
|
||||
|
||||
let mut tasks = Vec::with_capacity(ATTEMPTS);
|
||||
for _ in 0..ATTEMPTS {
|
||||
let db = db.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
db.record_blocked_attempt((user_a, machine_id), "host-a-new")
|
||||
.await
|
||||
.unwrap()
|
||||
}));
|
||||
}
|
||||
for task in tasks {
|
||||
assert!(task.await.unwrap());
|
||||
}
|
||||
|
||||
let blocked_a = db.list_blocked_devices(user_a).await.unwrap();
|
||||
let blocked_b = db.list_blocked_devices(user_b).await.unwrap();
|
||||
assert_eq!(blocked_a[0].attempt_count, ATTEMPTS as i32);
|
||||
assert_eq!(blocked_a[0].hostname, "host-a-new");
|
||||
assert!(blocked_a[0].last_attempt_time.is_some());
|
||||
assert_eq!(blocked_b[0].attempt_count, 0);
|
||||
assert_eq!(blocked_b[0].hostname, "host-b");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_device_preserves_or_creates_tenant_block() {
|
||||
let db = Db::memory_db().await;
|
||||
let user_a = db.auto_create_user("delete-owner-a").await.unwrap().id;
|
||||
let user_b = db.auto_create_user("delete-owner-b").await.unwrap().id;
|
||||
let machine_id = uuid::Uuid::new_v4();
|
||||
for (user_id, hostname) in [(user_a, "host-a"), (user_b, "host-b")] {
|
||||
db.upsert_device_heartbeat(DeviceHeartbeatRecord {
|
||||
user_id,
|
||||
machine_id,
|
||||
hostname: hostname.to_string(),
|
||||
easytier_version: "1".to_string(),
|
||||
device_os: "{}".to_string(),
|
||||
client_url: format!("tcp://127.0.0.1:{user_id}"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert_or_update_user_network_config(
|
||||
(user_id, machine_id),
|
||||
uuid::Uuid::new_v4(),
|
||||
NetworkConfig::default(),
|
||||
ConfigSource::Web,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.set_managed_config_revision((user_id, machine_id), "rev-1")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
insert_blocked_device(&db, (user_a, machine_id), "blocked-host-a").await;
|
||||
assert!(
|
||||
db.delete_device_with_optional_block((user_a, machine_id), false)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(db.get_device((user_a, machine_id)).await.unwrap().is_none());
|
||||
assert!(db.is_device_blocked((user_a, machine_id)).await.unwrap());
|
||||
assert!(db.get_device((user_b, machine_id)).await.unwrap().is_some());
|
||||
assert!(
|
||||
db.list_network_configs((user_a, machine_id), ListNetworkProps::All)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_managed_config_revision((user_a, machine_id))
|
||||
.await
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
db.list_network_configs((user_b, machine_id), ListNetworkProps::All)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_managed_config_revision((user_b, machine_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("rev-1")
|
||||
);
|
||||
|
||||
assert!(
|
||||
db.delete_device_with_optional_block((user_b, machine_id), true)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(db.get_device((user_b, machine_id)).await.unwrap().is_none());
|
||||
let blocked_b = db.list_blocked_devices(user_b).await.unwrap();
|
||||
assert_eq!(blocked_b.len(), 1);
|
||||
assert_eq!(blocked_b[0].hostname, "host-b");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! `SeaORM` Entity for tenant-scoped blocked devices.
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "blocked_devices")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: i32,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub machine_id: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub hostname: String,
|
||||
pub blocked_time: DateTimeWithTimeZone,
|
||||
pub attempt_count: i32,
|
||||
pub last_attempt_time: Option<DateTimeWithTimeZone>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! `SeaORM` Entity for tenant-scoped registered devices.
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "devices")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: i32,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub machine_id: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub hostname: String,
|
||||
#[sea_orm(column_type = "Text", default_value = "")]
|
||||
pub alias: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub easytier_version: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub device_os: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub client_url: String,
|
||||
pub first_seen_time: DateTimeWithTimeZone,
|
||||
pub last_seen_time: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
pub mod prelude;
|
||||
|
||||
pub mod blocked_devices;
|
||||
pub mod devices;
|
||||
pub mod groups;
|
||||
pub mod groups_permissions;
|
||||
pub mod managed_config_revisions;
|
||||
pub mod network_members;
|
||||
pub mod networks;
|
||||
pub mod permissions;
|
||||
pub mod tower_sessions;
|
||||
pub mod user_running_network_configs;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//! A member of a tenant-scoped central network.
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "network_members")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: i32,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub network_id: String,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub id: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub device_id: String,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub hostname_override: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub virtual_ipv4: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub allocated_ipv4: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub override_config: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub credential_id: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub acl_group_secret: Option<String>,
|
||||
pub create_time: DateTimeWithTimeZone,
|
||||
pub update_time: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Tenant-scoped central network intent.
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "networks")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: i32,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub id: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub display_name: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub network_name: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub network_secret: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub networking_method: String,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub public_server_url: Option<String>,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub peer_urls: String,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub virtual_cidr: Option<String>,
|
||||
pub secure_mode: bool,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub acl_policy: Option<String>,
|
||||
pub create_time: DateTimeWithTimeZone,
|
||||
pub update_time: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -1,8 +1,12 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
|
||||
|
||||
pub use super::blocked_devices::Entity as BlockedDevices;
|
||||
pub use super::devices::Entity as Devices;
|
||||
pub use super::groups::Entity as Groups;
|
||||
pub use super::groups_permissions::Entity as GroupsPermissions;
|
||||
pub use super::managed_config_revisions::Entity as ManagedConfigRevisions;
|
||||
pub use super::network_members::Entity as NetworkMembers;
|
||||
pub use super::networks::Entity as Networks;
|
||||
pub use super::permissions::Entity as Permissions;
|
||||
pub use super::tower_sessions::Entity as TowerSessions;
|
||||
pub use super::user_running_network_configs::Entity as UserRunningNetworkConfigs;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
// sea-orm-cli generate entity -u sqlite:./et.db -o easytier-web/src/db/entity/ --with-serde both --with-copy-enums
|
||||
mod central_intent;
|
||||
mod devices;
|
||||
#[allow(unused_imports)]
|
||||
pub mod entity;
|
||||
|
||||
pub(crate) use central_intent::{CentralIntentError, CentralOwnedRuntimeConfig};
|
||||
pub use devices::DeviceHeartbeatRecord;
|
||||
|
||||
use easytier::common::config::{ConfigSource, NetworkConfig};
|
||||
use easytier_core::management::remote_client::{ListNetworkProps, Storage};
|
||||
use entity::user_running_network_configs;
|
||||
|
||||
@@ -6,6 +6,8 @@ extern crate rust_i18n;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use central_network::gateway;
|
||||
|
||||
use clap::Parser;
|
||||
use easytier::tunnel::websocket::WsTunnelListener;
|
||||
use easytier::{
|
||||
@@ -24,6 +26,7 @@ use easytier_core::{socket::SocketListener, tunnel::Tunnel};
|
||||
use easytier::tunnel::IpScheme;
|
||||
use mimalloc::MiMalloc;
|
||||
|
||||
mod central_network;
|
||||
mod client_manager;
|
||||
mod db;
|
||||
mod migrator;
|
||||
@@ -129,6 +132,12 @@ struct Cli {
|
||||
)]
|
||||
heartbeat_timeout_ms: u64,
|
||||
|
||||
#[arg(long, env = "ET_GATEWAY_PEER_URL")]
|
||||
gateway_peer_url: Option<url::Url>,
|
||||
|
||||
#[arg(long, env = "ET_GATEWAY_RELAY_DATA", default_value = "false")]
|
||||
gateway_relay_data: bool,
|
||||
|
||||
#[cfg(feature = "embed")]
|
||||
#[arg(
|
||||
long,
|
||||
@@ -341,13 +350,25 @@ async fn main() {
|
||||
// let db = db::Db::new(":memory:").await.unwrap();
|
||||
let db = db::Db::new(cli.db).await.unwrap();
|
||||
let feature_flags = Arc::new(cli.feature_flags);
|
||||
let webhook_config = Arc::new(webhook::WebhookConfig::new(
|
||||
let mut webhook_config = webhook::WebhookConfig::new(
|
||||
cli.webhook.webhook_url,
|
||||
cli.webhook.webhook_secret,
|
||||
cli.webhook.internal_auth_token,
|
||||
cli.webhook.web_instance_id,
|
||||
cli.webhook.web_instance_api_base_url,
|
||||
));
|
||||
);
|
||||
let central_mode = !webhook_config.has_external_endpoint();
|
||||
if central_mode {
|
||||
webhook_config =
|
||||
webhook_config.with_handler(Arc::new(central_network::device_auth::DeviceAuth::new(
|
||||
db.clone(),
|
||||
feature_flags.allow_auto_create_user,
|
||||
)));
|
||||
} else if cli.gateway_peer_url.is_some() {
|
||||
eprintln!("Gateway requires central network mode; remove --webhook-url");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let webhook_config = Arc::new(webhook_config);
|
||||
let heartbeat_policy = client_manager::HeartbeatPolicy::from_millis(
|
||||
cli.heartbeat_min_response_ms,
|
||||
cli.heartbeat_timeout_ms,
|
||||
@@ -356,6 +377,21 @@ async fn main() {
|
||||
eprintln!("Invalid heartbeat configuration: {error}");
|
||||
std::process::exit(2);
|
||||
});
|
||||
|
||||
let network_instances = cli.gateway_peer_url.as_ref().map(|peer_url| {
|
||||
let config = gateway::GatewayConfig {
|
||||
peer_url: peer_url.to_string(),
|
||||
relay_data: cli.gateway_relay_data,
|
||||
};
|
||||
config
|
||||
.validate(&cli.config_server_protocol)
|
||||
.unwrap_or_else(|error| {
|
||||
eprintln!("Invalid Gateway configuration: {error}");
|
||||
std::process::exit(2);
|
||||
});
|
||||
Arc::new(gateway::NetworkInstanceManager::new(config))
|
||||
});
|
||||
|
||||
let mut mgr = client_manager::ClientManager::new(
|
||||
db.clone(),
|
||||
cli.geoip_db,
|
||||
@@ -370,14 +406,30 @@ async fn main() {
|
||||
if v4_listener.is_none() && v6_listener.is_none() {
|
||||
panic!("Listen to both IPv4 and IPv6 failed");
|
||||
}
|
||||
if let Some(listener) = v6_listener {
|
||||
mgr.add_listener(listener).await.unwrap();
|
||||
}
|
||||
if let Some(listener) = v4_listener {
|
||||
mgr.add_listener(listener).await.unwrap();
|
||||
for listener in [v6_listener, v4_listener].into_iter().flatten() {
|
||||
if let Some(instances) = network_instances.as_ref() {
|
||||
mgr.add_listener(gateway::listener::GatewayListener::new(
|
||||
listener,
|
||||
instances.clone(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
} else {
|
||||
mgr.add_listener(listener).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let mgr = Arc::new(mgr);
|
||||
let central_service = Arc::new(
|
||||
central_network::service::CentralNetworkService::with_gateway(
|
||||
db.clone(),
|
||||
mgr.clone(),
|
||||
network_instances,
|
||||
),
|
||||
);
|
||||
if central_mode {
|
||||
central_service.start_reconciler();
|
||||
}
|
||||
|
||||
#[cfg(feature = "embed")]
|
||||
let (web_router_restful, web_router_static) = if cli.no_web {
|
||||
@@ -412,11 +464,14 @@ async fn main() {
|
||||
let _restful_server_tasks = restful::RestfulServer::new(
|
||||
std::net::SocketAddr::new(cli.api_server_addr, cli.api_server_port),
|
||||
mgr.clone(),
|
||||
central_service,
|
||||
db,
|
||||
web_router_restful,
|
||||
feature_flags,
|
||||
oidc_config,
|
||||
webhook_config,
|
||||
cli.config_server_protocol.clone(),
|
||||
cli.config_server_port,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
pub struct Migration;
|
||||
|
||||
impl MigrationName for Migration {
|
||||
fn name(&self) -> &str {
|
||||
"m20260920_000007_device_registry"
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared(
|
||||
r#"
|
||||
CREATE TABLE devices (
|
||||
user_id INTEGER NOT NULL,
|
||||
machine_id TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL DEFAULT '',
|
||||
alias TEXT NOT NULL DEFAULT '',
|
||||
easytier_version TEXT NOT NULL DEFAULT '',
|
||||
device_os TEXT NOT NULL DEFAULT '{}',
|
||||
client_url TEXT NOT NULL DEFAULT '',
|
||||
first_seen_time TEXT NOT NULL,
|
||||
last_seen_time TEXT NOT NULL,
|
||||
CONSTRAINT pk_devices PRIMARY KEY (user_id, machine_id),
|
||||
CONSTRAINT fk_devices_user_id_to_users_id
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_devices_user_last_seen
|
||||
ON devices(user_id, last_seen_time DESC);
|
||||
|
||||
CREATE TABLE blocked_devices (
|
||||
user_id INTEGER NOT NULL,
|
||||
machine_id TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL DEFAULT '',
|
||||
blocked_time TEXT NOT NULL,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (attempt_count >= 0),
|
||||
last_attempt_time TEXT,
|
||||
CONSTRAINT pk_blocked_devices PRIMARY KEY (user_id, machine_id),
|
||||
CONSTRAINT fk_blocked_devices_user_id_to_users_id
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_blocked_devices_user_last_attempt
|
||||
ON blocked_devices(user_id, last_attempt_time DESC);
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared(
|
||||
r#"
|
||||
DROP TABLE blocked_devices;
|
||||
DROP TABLE devices;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
pub struct Migration;
|
||||
|
||||
impl MigrationName for Migration {
|
||||
fn name(&self) -> &str {
|
||||
"m20260920_000009_central_network_intent"
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared(
|
||||
r#"
|
||||
CREATE TABLE networks (
|
||||
user_id INTEGER NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
network_name TEXT NOT NULL,
|
||||
network_secret TEXT NOT NULL,
|
||||
networking_method TEXT NOT NULL
|
||||
CHECK (networking_method IN (
|
||||
'PublicServer', 'Manual', 'Standalone', 'Gateway'
|
||||
)),
|
||||
public_server_url TEXT,
|
||||
peer_urls TEXT NOT NULL DEFAULT '[]',
|
||||
virtual_cidr TEXT,
|
||||
secure_mode INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (secure_mode IN (0, 1)),
|
||||
acl_policy TEXT,
|
||||
create_time TEXT NOT NULL,
|
||||
update_time TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, id),
|
||||
CONSTRAINT uq_networks_tenant_name
|
||||
UNIQUE (user_id, network_name),
|
||||
CONSTRAINT fk_networks_user_id_to_users_id
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_networks_gateway_name
|
||||
ON networks(network_name)
|
||||
WHERE networking_method = 'Gateway';
|
||||
|
||||
CREATE TABLE network_credentials (
|
||||
user_id INTEGER NOT NULL,
|
||||
network_id TEXT NOT NULL,
|
||||
credential_id TEXT NOT NULL,
|
||||
credential_secret TEXT NOT NULL,
|
||||
expiry_unix INTEGER NOT NULL,
|
||||
acl_groups TEXT NOT NULL DEFAULT '[]',
|
||||
allow_relay INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (allow_relay IN (0, 1)),
|
||||
allowed_proxy_cidrs TEXT NOT NULL DEFAULT '[]',
|
||||
reusable INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (reusable IN (0, 1)),
|
||||
create_time TEXT NOT NULL,
|
||||
update_time TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, network_id, credential_id),
|
||||
CONSTRAINT fk_network_credentials_tenant_network
|
||||
FOREIGN KEY (user_id, network_id)
|
||||
REFERENCES networks(user_id, id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE network_members (
|
||||
user_id INTEGER NOT NULL,
|
||||
network_id TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
hostname_override TEXT,
|
||||
virtual_ipv4 TEXT,
|
||||
override_config TEXT,
|
||||
credential_id TEXT,
|
||||
acl_group_secret TEXT,
|
||||
create_time TEXT NOT NULL,
|
||||
update_time TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, network_id, id),
|
||||
CONSTRAINT uq_network_members_tenant_device
|
||||
UNIQUE (user_id, network_id, device_id),
|
||||
CONSTRAINT fk_network_members_tenant_network
|
||||
FOREIGN KEY (user_id, network_id)
|
||||
REFERENCES networks(user_id, id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
CONSTRAINT fk_network_members_tenant_device
|
||||
FOREIGN KEY (user_id, device_id)
|
||||
REFERENCES devices(user_id, machine_id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
CONSTRAINT fk_network_members_tenant_credential
|
||||
FOREIGN KEY (user_id, network_id, credential_id)
|
||||
REFERENCES network_credentials(
|
||||
user_id, network_id, credential_id
|
||||
)
|
||||
ON DELETE RESTRICT
|
||||
ON UPDATE CASCADE,
|
||||
CONSTRAINT ck_network_members_temporary_secret
|
||||
CHECK (
|
||||
credential_id IS NULL
|
||||
OR acl_group_secret IS NULL
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_network_members_dedicated_credential
|
||||
ON network_members(user_id, network_id, credential_id)
|
||||
WHERE credential_id IS NOT NULL;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared(
|
||||
r#"
|
||||
DROP TABLE network_members;
|
||||
DROP TABLE network_credentials;
|
||||
DROP TABLE networks;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use sea_orm::{ConnectionTrait as _, Database};
|
||||
use sea_orm_migration::MigratorTrait as _;
|
||||
|
||||
#[tokio::test]
|
||||
async fn tenant_scope_is_part_of_every_intent_foreign_key() {
|
||||
let db = Database::connect("sqlite::memory:").await.unwrap();
|
||||
crate::migrator::Migrator::up(&db, None).await.unwrap();
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
INSERT INTO networks (
|
||||
user_id, id, display_name, network_name, network_secret,
|
||||
networking_method, create_time, update_time
|
||||
) VALUES
|
||||
(1, 'same-id', 'one', 'one', 'secret', 'Standalone', 'now', 'now'),
|
||||
(2, 'same-id', 'two', 'two', 'secret', 'Standalone', 'now', 'now');
|
||||
INSERT INTO network_credentials (
|
||||
user_id, network_id, credential_id, credential_secret,
|
||||
expiry_unix, create_time, update_time
|
||||
) VALUES
|
||||
(1, 'same-id', 'only-user-one', 'secret', 2000000000, 'now', 'now');
|
||||
INSERT INTO devices (
|
||||
user_id, machine_id, first_seen_time, last_seen_time
|
||||
) VALUES
|
||||
(1, 'device-one', 'now', 'now'),
|
||||
(2, 'device-two', 'now', 'now');
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let cross_tenant_credential = db
|
||||
.execute_unprepared(
|
||||
r#"
|
||||
INSERT INTO network_members (
|
||||
user_id, network_id, id, device_id, credential_id,
|
||||
create_time, update_time
|
||||
) VALUES (
|
||||
2, 'same-id', 'member', 'device-two', 'only-user-one',
|
||||
'now', 'now'
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.await;
|
||||
assert!(cross_tenant_credential.is_err());
|
||||
|
||||
let cross_tenant_device = db
|
||||
.execute_unprepared(
|
||||
r#"
|
||||
INSERT INTO network_members (
|
||||
user_id, network_id, id, device_id,
|
||||
create_time, update_time
|
||||
) VALUES (
|
||||
2, 'same-id', 'member', 'device-one',
|
||||
'now', 'now'
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.await;
|
||||
assert!(cross_tenant_device.is_err());
|
||||
|
||||
let missing_tenant_network = db
|
||||
.execute_unprepared(
|
||||
r#"
|
||||
INSERT INTO network_credentials (
|
||||
user_id, network_id, credential_id, credential_secret,
|
||||
expiry_unix, create_time, update_time
|
||||
) VALUES (
|
||||
2, 'user-one-only-network', 'credential', 'secret',
|
||||
2000000000, 'now', 'now'
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.await;
|
||||
assert!(missing_tenant_network.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_names_are_globally_unique() {
|
||||
let db = Database::connect("sqlite::memory:").await.unwrap();
|
||||
crate::migrator::Migrator::up(&db, None).await.unwrap();
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
INSERT INTO networks (
|
||||
user_id, id, display_name, network_name, network_secret,
|
||||
networking_method, create_time, update_time
|
||||
) VALUES (
|
||||
1, 'gateway-one', 'gateway', 'shared-name', 'secret',
|
||||
'Gateway', 'now', 'now'
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let duplicate = db
|
||||
.execute_unprepared(
|
||||
r#"
|
||||
INSERT INTO networks (
|
||||
user_id, id, display_name, network_name, network_secret,
|
||||
networking_method, create_time, update_time
|
||||
) VALUES (
|
||||
2, 'gateway-two', 'gateway', 'shared-name', 'secret',
|
||||
'Gateway', 'now', 'now'
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.await;
|
||||
assert!(duplicate.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn temporary_member_cannot_store_an_acl_group_secret() {
|
||||
let db = Database::connect("sqlite::memory:").await.unwrap();
|
||||
crate::migrator::Migrator::up(&db, None).await.unwrap();
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
INSERT INTO networks (
|
||||
user_id, id, display_name, network_name, network_secret,
|
||||
networking_method, create_time, update_time
|
||||
) VALUES (
|
||||
1, 'network', 'network', 'network', 'secret',
|
||||
'Standalone', 'now', 'now'
|
||||
);
|
||||
INSERT INTO network_credentials (
|
||||
user_id, network_id, credential_id, credential_secret,
|
||||
expiry_unix, acl_groups, reusable, create_time, update_time
|
||||
) VALUES (
|
||||
1, 'network', 'temporary', 'secret', 2000000000,
|
||||
'["member:1"]', 0, 'now', 'now'
|
||||
);
|
||||
INSERT INTO devices (
|
||||
user_id, machine_id, first_seen_time, last_seen_time
|
||||
) VALUES
|
||||
(1, 'device-one', 'now', 'now'),
|
||||
(1, 'device-two', 'now', 'now');
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = db
|
||||
.execute_unprepared(
|
||||
r#"
|
||||
INSERT INTO network_members (
|
||||
user_id, network_id, id, device_id, credential_id,
|
||||
acl_group_secret, create_time, update_time
|
||||
) VALUES (
|
||||
1, 'network', 'member', 'device-one', 'temporary',
|
||||
'must-not-be-stored', 'now', 'now'
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.await;
|
||||
let error = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error.contains("ck_network_members_temporary_secret"),
|
||||
"{error}"
|
||||
);
|
||||
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
INSERT INTO network_members (
|
||||
user_id, network_id, id, device_id, credential_id,
|
||||
create_time, update_time
|
||||
) VALUES (
|
||||
1, 'network', 'member-one', 'device-one', 'temporary',
|
||||
'now', 'now'
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let reused = db
|
||||
.execute_unprepared(
|
||||
r#"
|
||||
INSERT INTO network_members (
|
||||
user_id, network_id, id, device_id, credential_id,
|
||||
create_time, update_time
|
||||
) VALUES (
|
||||
1, 'network', 'member-two', 'device-two', 'temporary',
|
||||
'now', 'now'
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.await;
|
||||
assert!(reused.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// Older rows do not record whether virtual_ipv4 was assigned or typed
|
||||
// by the administrator. Preserve those values as explicit addresses.
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared("ALTER TABLE network_members ADD COLUMN allocated_ipv4 TEXT")
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared(
|
||||
"UPDATE network_members SET virtual_ipv4 = COALESCE(virtual_ipv4, allocated_ipv4); \
|
||||
ALTER TABLE network_members DROP COLUMN allocated_ipv4",
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sea_orm::{Database, Statement};
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_preserves_addresses_without_guessing_their_origin() {
|
||||
let db = Database::connect("sqlite::memory:").await.unwrap();
|
||||
db.execute_unprepared(
|
||||
"CREATE TABLE network_members (virtual_ipv4 TEXT); \
|
||||
INSERT INTO network_members VALUES ('10.42.0.1')",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let manager = SchemaManager::new(&db);
|
||||
Migration.up(&manager).await.unwrap();
|
||||
let row = db
|
||||
.query_one(Statement::from_string(
|
||||
db.get_database_backend(),
|
||||
"SELECT virtual_ipv4, allocated_ipv4 FROM network_members".to_owned(),
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
row.try_get::<String>("", "virtual_ipv4").unwrap(),
|
||||
"10.42.0.1"
|
||||
);
|
||||
assert!(
|
||||
row.try_get::<Option<String>>("", "allocated_ipv4")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
Migration.down(&manager).await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@ mod m20260403_000002_scope_network_config_unique;
|
||||
mod m20260421_000003_add_network_config_source;
|
||||
mod m20260514_000004_rename_web_config_source;
|
||||
mod m20260619_000005_managed_config_revisions;
|
||||
mod m20260920_000007_device_registry;
|
||||
mod m20260920_000009_central_network_intent;
|
||||
|
||||
mod m20261001_000010_member_ip_allocation;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -17,6 +21,9 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260421_000003_add_network_config_source::Migration),
|
||||
Box::new(m20260514_000004_rename_web_config_source::Migration),
|
||||
Box::new(m20260619_000005_managed_config_revisions::Migration),
|
||||
Box::new(m20260920_000007_device_registry::Migration),
|
||||
Box::new(m20260920_000009_central_network_intent::Migration),
|
||||
Box::new(m20261001_000010_member_ip_allocation::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Extension, Json, Router,
|
||||
extract::Path,
|
||||
http::StatusCode,
|
||||
routing::{delete, get, patch},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{AppStateInner, Error, HttpHandleError, authed_user_id, users::AuthSession};
|
||||
use crate::central_network::{
|
||||
model::AclPolicy,
|
||||
service::{
|
||||
AddMembersReq, CentralNetworkService, CentralNetworkServiceError, GenerateCredentialReq,
|
||||
MemberInfo, MembersView, NetworkCredentialInfo, NetworkDetail, NetworkSettings,
|
||||
NetworkSummary, UpdateMemberReq, UpdateNetworkReq,
|
||||
},
|
||||
};
|
||||
|
||||
type Service = Extension<Arc<CentralNetworkService>>;
|
||||
|
||||
pub(super) fn convert_error(error: CentralNetworkServiceError) -> HttpHandleError {
|
||||
let status = match error {
|
||||
CentralNetworkServiceError::Invalid(_) => StatusCode::BAD_REQUEST,
|
||||
CentralNetworkServiceError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
CentralNetworkServiceError::Conflict(_) => StatusCode::CONFLICT,
|
||||
CentralNetworkServiceError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
(
|
||||
status,
|
||||
Json(Error {
|
||||
message: error.to_string(),
|
||||
code: None,
|
||||
current_config_revision: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn user_id(auth_session: &AuthSession) -> Result<i32, HttpHandleError> {
|
||||
authed_user_id(auth_session)
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct CreateNetworkJsonReq {
|
||||
settings: NetworkSettings,
|
||||
network_secret: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct ListNetworksJsonResp {
|
||||
networks: Vec<NetworkSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct ListCredentialsJsonResp {
|
||||
credentials: Vec<NetworkCredentialInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct SetMemberConfigJsonReq {
|
||||
config: easytier::common::config::NetworkConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct AclPolicyInfo {
|
||||
policy: AclPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct GatewayInfoJsonResp {
|
||||
enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
peer_url: Option<String>,
|
||||
relay_data: bool,
|
||||
}
|
||||
|
||||
pub struct CentralNetworkApi;
|
||||
|
||||
impl CentralNetworkApi {
|
||||
async fn list_networks(
|
||||
auth: AuthSession,
|
||||
Extension(service): Service,
|
||||
) -> Result<Json<ListNetworksJsonResp>, HttpHandleError> {
|
||||
let networks = service
|
||||
.list_networks(user_id(&auth)?)
|
||||
.await
|
||||
.map_err(convert_error)?;
|
||||
Ok(Json(ListNetworksJsonResp { networks }))
|
||||
}
|
||||
|
||||
async fn create_network(
|
||||
auth: AuthSession,
|
||||
Extension(service): Service,
|
||||
Json(request): Json<CreateNetworkJsonReq>,
|
||||
) -> Result<Json<NetworkDetail>, HttpHandleError> {
|
||||
service
|
||||
.create_network(user_id(&auth)?, request.settings, request.network_secret)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(convert_error)
|
||||
}
|
||||
|
||||
async fn get_network(
|
||||
auth: AuthSession,
|
||||
Extension(service): Service,
|
||||
Path(network_id): Path<Uuid>,
|
||||
) -> Result<Json<NetworkDetail>, HttpHandleError> {
|
||||
service
|
||||
.get_network(user_id(&auth)?, network_id)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(convert_error)
|
||||
}
|
||||
|
||||
async fn update_network(
|
||||
auth: AuthSession,
|
||||
Extension(service): Service,
|
||||
Path(network_id): Path<Uuid>,
|
||||
Json(request): Json<UpdateNetworkReq>,
|
||||
) -> Result<Json<NetworkDetail>, HttpHandleError> {
|
||||
service
|
||||
.update_network(user_id(&auth)?, network_id, request)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(convert_error)
|
||||
}
|
||||
|
||||
async fn delete_network(
|
||||
auth: AuthSession,
|
||||
Extension(service): Service,
|
||||
Path(network_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, HttpHandleError> {
|
||||
service
|
||||
.delete_network(user_id(&auth)?, network_id)
|
||||
.await
|
||||
.map_err(convert_error)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn list_members(
|
||||
auth: AuthSession,
|
||||
Extension(service): Service,
|
||||
Path(network_id): Path<Uuid>,
|
||||
) -> Result<Json<MembersView>, HttpHandleError> {
|
||||
service
|
||||
.list_members(user_id(&auth)?, network_id)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(convert_error)
|
||||
}
|
||||
|
||||
async fn add_members(
|
||||
auth: AuthSession,
|
||||
Extension(service): Service,
|
||||
Path(network_id): Path<Uuid>,
|
||||
Json(request): Json<AddMembersReq>,
|
||||
) -> Result<StatusCode, HttpHandleError> {
|
||||
service
|
||||
.add_members(user_id(&auth)?, network_id, request)
|
||||
.await
|
||||
.map_err(convert_error)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn update_member(
|
||||
auth: AuthSession,
|
||||
Extension(service): Service,
|
||||
Path((network_id, device_id)): Path<(Uuid, Uuid)>,
|
||||
Json(request): Json<UpdateMemberReq>,
|
||||
) -> Result<Json<MemberInfo>, HttpHandleError> {
|
||||
service
|
||||
.update_member(user_id(&auth)?, network_id, device_id, request)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(convert_error)
|
||||
}
|
||||
|
||||
async fn remove_member(
|
||||
auth: AuthSession,
|
||||
Extension(service): Service,
|
||||
Path((network_id, device_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<StatusCode, HttpHandleError> {
|
||||
service
|
||||
.remove_member(user_id(&auth)?, network_id, device_id)
|
||||
.await
|
||||
.map_err(convert_error)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn get_member_config(
|
||||
auth: AuthSession,
|
||||
Extension(service): Service,
|
||||
Path((network_id, device_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<easytier::common::config::NetworkConfig>, HttpHandleError> {
|
||||
service
|
||||
.get_member_config(user_id(&auth)?, network_id, device_id)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(convert_error)
|
||||
}
|
||||
|
||||
async fn set_member_config(
|
||||
auth: AuthSession,
|
||||
Extension(service): Service,
|
||||
Path((network_id, device_id)): Path<(Uuid, Uuid)>,
|
||||
Json(request): Json<SetMemberConfigJsonReq>,
|
||||
) -> Result<Json<MemberInfo>, HttpHandleError> {
|
||||
service
|
||||
.set_member_config(user_id(&auth)?, network_id, device_id, request.config)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(convert_error)
|
||||
}
|
||||
|
||||
async fn clear_member_config(
|
||||
auth: AuthSession,
|
||||
Extension(service): Service,
|
||||
Path((network_id, device_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<StatusCode, HttpHandleError> {
|
||||
service
|
||||
.clear_member_config(user_id(&auth)?, network_id, device_id)
|
||||
.await
|
||||
.map_err(convert_error)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn get_acl_policy(
|
||||
auth: AuthSession,
|
||||
Extension(service): Service,
|
||||
Path(network_id): Path<Uuid>,
|
||||
) -> Result<Json<AclPolicyInfo>, HttpHandleError> {
|
||||
let policy = service
|
||||
.get_acl_policy(user_id(&auth)?, network_id)
|
||||
.await
|
||||
.map_err(convert_error)?;
|
||||
Ok(Json(AclPolicyInfo { policy }))
|
||||
}
|
||||
|
||||
async fn update_acl_policy(
|
||||
auth: AuthSession,
|
||||
Extension(service): Service,
|
||||
Path(network_id): Path<Uuid>,
|
||||
Json(policy): Json<AclPolicy>,
|
||||
) -> Result<Json<AclPolicyInfo>, HttpHandleError> {
|
||||
let policy = service
|
||||
.update_acl_policy(user_id(&auth)?, network_id, policy)
|
||||
.await
|
||||
.map_err(convert_error)?;
|
||||
Ok(Json(AclPolicyInfo { policy }))
|
||||
}
|
||||
|
||||
async fn list_credentials(
|
||||
auth: AuthSession,
|
||||
Extension(service): Service,
|
||||
Path(network_id): Path<Uuid>,
|
||||
) -> Result<Json<ListCredentialsJsonResp>, HttpHandleError> {
|
||||
let credentials = service
|
||||
.list_credentials(user_id(&auth)?, network_id)
|
||||
.await
|
||||
.map_err(convert_error)?;
|
||||
Ok(Json(ListCredentialsJsonResp { credentials }))
|
||||
}
|
||||
|
||||
async fn generate_credential(
|
||||
auth: AuthSession,
|
||||
Extension(service): Service,
|
||||
Path(network_id): Path<Uuid>,
|
||||
Json(request): Json<GenerateCredentialReq>,
|
||||
) -> Result<Json<NetworkCredentialInfo>, HttpHandleError> {
|
||||
service
|
||||
.generate_credential(user_id(&auth)?, network_id, request)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(convert_error)
|
||||
}
|
||||
|
||||
async fn revoke_credential(
|
||||
auth: AuthSession,
|
||||
Extension(service): Service,
|
||||
Path((network_id, credential_id)): Path<(Uuid, String)>,
|
||||
) -> Result<StatusCode, HttpHandleError> {
|
||||
service
|
||||
.revoke_credential(user_id(&auth)?, network_id, &credential_id)
|
||||
.await
|
||||
.map_err(convert_error)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn gateway_info(Extension(service): Service) -> Json<GatewayInfoJsonResp> {
|
||||
let config = service.gateway_config();
|
||||
Json(GatewayInfoJsonResp {
|
||||
enabled: config.is_some(),
|
||||
peer_url: config.map(|config| config.peer_url.clone()),
|
||||
relay_data: config.is_some_and(|config| config.relay_data),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_route() -> Router<AppStateInner> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/networks",
|
||||
get(Self::list_networks).post(Self::create_network),
|
||||
)
|
||||
.route("/api/v1/networks/gateway-info", get(Self::gateway_info))
|
||||
.route(
|
||||
"/api/v1/networks/{network-id}",
|
||||
get(Self::get_network)
|
||||
.patch(Self::update_network)
|
||||
.delete(Self::delete_network),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/networks/{network-id}/acl-policy",
|
||||
get(Self::get_acl_policy).put(Self::update_acl_policy),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/networks/{network-id}/members",
|
||||
get(Self::list_members).post(Self::add_members),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/networks/{network-id}/members/{device-id}",
|
||||
patch(Self::update_member).delete(Self::remove_member),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/networks/{network-id}/members/{device-id}/config",
|
||||
get(Self::get_member_config)
|
||||
.put(Self::set_member_config)
|
||||
.delete(Self::clear_member_config),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/networks/{network-id}/credentials",
|
||||
get(Self::list_credentials).post(Self::generate_credential),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/networks/{network-id}/credentials/{credential-id}",
|
||||
delete(Self::revoke_credential),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn service_errors_map_to_stable_http_statuses() {
|
||||
assert_eq!(
|
||||
convert_error(CentralNetworkServiceError::Invalid("x".into())).0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert_eq!(
|
||||
convert_error(CentralNetworkServiceError::NotFound("x".into())).0,
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
assert_eq!(
|
||||
convert_error(CentralNetworkServiceError::Conflict("x".into())).0,
|
||||
StatusCode::CONFLICT
|
||||
);
|
||||
assert_eq!(
|
||||
convert_error(CentralNetworkServiceError::Database("x".into())).0,
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_info_is_disabled_without_runtime_lifecycle() {
|
||||
let db = crate::db::Db::memory_db().await;
|
||||
let manager = Arc::new(crate::client_manager::ClientManager::new(
|
||||
db.clone(),
|
||||
None,
|
||||
crate::client_manager::HeartbeatPolicy::default(),
|
||||
Arc::new(crate::FeatureFlags::default()),
|
||||
Arc::new(crate::webhook::WebhookConfig::new(
|
||||
None, None, None, None, None,
|
||||
)),
|
||||
));
|
||||
let service = Arc::new(CentralNetworkService::new(db, manager));
|
||||
let Json(info) = CentralNetworkApi::gateway_info(Extension(service)).await;
|
||||
assert!(!info.enabled);
|
||||
assert_eq!(info.peer_url, None);
|
||||
assert!(!info.relay_data);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_info_exposes_enabled_runtime_configuration() {
|
||||
let db = crate::db::Db::memory_db().await;
|
||||
let instances = Arc::new(
|
||||
crate::central_network::gateway::NetworkInstanceManager::new(
|
||||
crate::central_network::gateway::GatewayConfig {
|
||||
peer_url: "tcp://gateway.example:22020".to_owned(),
|
||||
relay_data: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
let manager = Arc::new(crate::client_manager::ClientManager::new(
|
||||
db.clone(),
|
||||
None,
|
||||
crate::client_manager::HeartbeatPolicy::default(),
|
||||
Arc::new(crate::FeatureFlags::default()),
|
||||
Arc::new(crate::webhook::WebhookConfig::new(
|
||||
None, None, None, None, None,
|
||||
)),
|
||||
));
|
||||
let service = Arc::new(CentralNetworkService::with_gateway(
|
||||
db,
|
||||
manager,
|
||||
Some(instances),
|
||||
));
|
||||
|
||||
let Json(info) = CentralNetworkApi::gateway_info(Extension(service)).await;
|
||||
|
||||
assert!(info.enabled);
|
||||
assert_eq!(
|
||||
info.peer_url.as_deref(),
|
||||
Some("tcp://gateway.example:22020")
|
||||
);
|
||||
assert!(info.relay_data);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
mod auth;
|
||||
pub(crate) mod captcha;
|
||||
mod central_network;
|
||||
mod network;
|
||||
pub(crate) mod oidc;
|
||||
mod rpc;
|
||||
@@ -29,6 +30,7 @@ use tower_sessions_sqlx_store::SqliteStore;
|
||||
use users::{AuthSession, Backend};
|
||||
|
||||
use crate::FeatureFlags;
|
||||
use crate::central_network::service::CentralNetworkService;
|
||||
use crate::client_manager::ClientManager;
|
||||
use crate::client_manager::storage::StorageToken;
|
||||
use crate::db::{Db, UserIdInDb};
|
||||
@@ -43,8 +45,11 @@ struct Assets;
|
||||
pub struct RestfulServer {
|
||||
bind_addr: SocketAddr,
|
||||
client_mgr: Arc<ClientManager>,
|
||||
central_service: Arc<CentralNetworkService>,
|
||||
feature_flags: Arc<FeatureFlags>,
|
||||
webhook_config: SharedWebhookConfig,
|
||||
config_server_protocol: String,
|
||||
config_server_port: u16,
|
||||
db: Db,
|
||||
oidc_config: oidc::OidcConfig,
|
||||
web_router: Option<Router>,
|
||||
@@ -61,6 +66,21 @@ struct GetSummaryJsonResp {
|
||||
device_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ConsoleInfoConfig {
|
||||
config_server_protocol: String,
|
||||
config_server_port: u16,
|
||||
webhook_auth: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct GetConsoleInfoJsonResp {
|
||||
username: String,
|
||||
config_server_protocol: String,
|
||||
config_server_port: u16,
|
||||
webhook_auth: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
struct GenerateConfigRequest {
|
||||
config: NetworkConfig,
|
||||
@@ -102,6 +122,19 @@ pub fn other_error<T: ToString>(error_message: T) -> Error {
|
||||
}
|
||||
}
|
||||
|
||||
fn authed_user_id(auth_session: &AuthSession) -> Result<UserIdInDb, HttpHandleError> {
|
||||
auth_session
|
||||
.user
|
||||
.as_ref()
|
||||
.map(|user| user.id())
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(other_error("No user id found")),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn convert_db_error(e: DbErr) -> HttpHandleError {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -110,22 +143,29 @@ pub fn convert_db_error(e: DbErr) -> HttpHandleError {
|
||||
}
|
||||
|
||||
impl RestfulServer {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn new(
|
||||
bind_addr: SocketAddr,
|
||||
client_mgr: Arc<ClientManager>,
|
||||
central_service: Arc<CentralNetworkService>,
|
||||
db: Db,
|
||||
web_router: Option<Router>,
|
||||
feature_flags: Arc<FeatureFlags>,
|
||||
oidc_config: oidc::OidcConfig,
|
||||
webhook_config: SharedWebhookConfig,
|
||||
config_server_protocol: String,
|
||||
config_server_port: u16,
|
||||
) -> anyhow::Result<Self> {
|
||||
assert!(client_mgr.is_running());
|
||||
|
||||
Ok(RestfulServer {
|
||||
bind_addr,
|
||||
client_mgr,
|
||||
central_service,
|
||||
feature_flags,
|
||||
webhook_config,
|
||||
config_server_protocol,
|
||||
config_server_port,
|
||||
db,
|
||||
oidc_config,
|
||||
web_router,
|
||||
@@ -146,15 +186,43 @@ impl RestfulServer {
|
||||
async fn handle_get_summary(
|
||||
auth_session: AuthSession,
|
||||
State(client_mgr): AppState,
|
||||
Extension(config): Extension<ConsoleInfoConfig>,
|
||||
Extension(service): Extension<Arc<CentralNetworkService>>,
|
||||
) -> Result<Json<GetSummaryJsonResp>, HttpHandleError> {
|
||||
let Some(user) = auth_session.user else {
|
||||
return Err((StatusCode::UNAUTHORIZED, other_error("No such user").into()));
|
||||
};
|
||||
|
||||
let machines = client_mgr.list_machine_by_user_id(user.id()).await;
|
||||
let device_count = if config.webhook_auth {
|
||||
client_mgr.list_machine_by_user_id(user.id()).await.len()
|
||||
} else {
|
||||
service
|
||||
.db
|
||||
.list_devices(user.id())
|
||||
.await
|
||||
.map_err(convert_db_error)?
|
||||
.len()
|
||||
};
|
||||
|
||||
Ok(GetSummaryJsonResp {
|
||||
device_count: machines.len() as u32,
|
||||
device_count: device_count as u32,
|
||||
}
|
||||
.into())
|
||||
}
|
||||
|
||||
async fn handle_get_console_info(
|
||||
auth_session: AuthSession,
|
||||
Extension(config): Extension<ConsoleInfoConfig>,
|
||||
) -> Result<Json<GetConsoleInfoJsonResp>, HttpHandleError> {
|
||||
let Some(user) = auth_session.user else {
|
||||
return Err((StatusCode::UNAUTHORIZED, other_error("No such user").into()));
|
||||
};
|
||||
|
||||
Ok(GetConsoleInfoJsonResp {
|
||||
username: user.db_user.username,
|
||||
config_server_protocol: config.config_server_protocol,
|
||||
config_server_port: config.config_server_port,
|
||||
webhook_auth: config.webhook_auth,
|
||||
}
|
||||
.into())
|
||||
}
|
||||
@@ -267,9 +335,16 @@ impl RestfulServer {
|
||||
None
|
||||
};
|
||||
|
||||
let central_routes = if self.webhook_config.has_external_endpoint() {
|
||||
Router::new()
|
||||
} else {
|
||||
central_network::CentralNetworkApi::build_route()
|
||||
};
|
||||
let mut app = Router::new()
|
||||
.route("/api/v1/summary", get(Self::handle_get_summary))
|
||||
.route("/api/v1/console-info", get(Self::handle_get_console_info))
|
||||
.route("/api/v1/sessions", get(Self::handle_list_all_sessions))
|
||||
.merge(central_routes)
|
||||
.merge(NetworkApi::build_route())
|
||||
.merge(rpc::router())
|
||||
.route_layer(login_required!(Backend))
|
||||
@@ -281,7 +356,13 @@ impl RestfulServer {
|
||||
post(Self::handle_generate_config),
|
||||
)
|
||||
.route("/api/v1/parse-config", post(Self::handle_parse_config))
|
||||
.layer(Extension(self.central_service.clone()))
|
||||
.layer(Extension(self.oidc_config.clone()))
|
||||
.layer(Extension(ConsoleInfoConfig {
|
||||
config_server_protocol: self.config_server_protocol.clone(),
|
||||
config_server_port: self.config_server_port,
|
||||
webhook_auth: self.webhook_config.has_external_endpoint(),
|
||||
}))
|
||||
.layer(MessagesManagerLayer)
|
||||
.layer(auth_layer)
|
||||
.layer(tower_http::cors::CorsLayer::very_permissive())
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use axum::extract::{DefaultBodyLimit, Path};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{DefaultBodyLimit, Path, Query};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{delete, post, put};
|
||||
use axum::{Json, Router, extract::State, routing::get};
|
||||
use axum::{Extension, Json, Router, extract::State, routing::get};
|
||||
use axum_login::AuthUser;
|
||||
use easytier::common::config::{
|
||||
ConfigSource as RuntimeConfigSource, NetworkConfig, config_source_from_rpc,
|
||||
};
|
||||
use easytier::proto::api::config::VpnPortalClientPatch;
|
||||
use easytier::proto::common::Void;
|
||||
use easytier::proto::{api::manage::*, web::*};
|
||||
use easytier_core::management::remote_client::{
|
||||
@@ -13,6 +16,7 @@ use easytier_core::management::remote_client::{
|
||||
};
|
||||
use sea_orm::DbErr;
|
||||
|
||||
use crate::central_network::service::{CentralNetworkService, DeviceNetworkSummary};
|
||||
use crate::client_manager::session::Location;
|
||||
use crate::db::UserIdInDb;
|
||||
|
||||
@@ -23,6 +27,38 @@ use super::{
|
||||
|
||||
const MAX_MANAGED_CONFIG_REQUEST_BODY_SIZE: usize = 32 * 1024 * 1024;
|
||||
|
||||
pub(super) fn central_ownership_conflict() -> HttpHandleError {
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
Json(Error {
|
||||
message: "central network config must be changed through its network intent".to_owned(),
|
||||
code: Some("central_network_ownership_conflict".to_owned()),
|
||||
current_config_revision: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn ensure_direct_mutation_allowed(
|
||||
service: &CentralNetworkService,
|
||||
user_id: UserIdInDb,
|
||||
machine_id: uuid::Uuid,
|
||||
instance_id: uuid::Uuid,
|
||||
network_name: Option<&str>,
|
||||
) -> Result<(), HttpHandleError> {
|
||||
let central = service
|
||||
.db
|
||||
.central_owned_runtime_configs(user_id, machine_id)
|
||||
.await
|
||||
.map_err(convert_db_error)?;
|
||||
if central.iter().any(|config| {
|
||||
config.instance_id == instance_id
|
||||
|| network_name.is_some_and(|name| config.network_name == name)
|
||||
}) {
|
||||
return Err(central_ownership_conflict());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn convert_rpc_error(e: RpcError) -> (StatusCode, Json<Error>) {
|
||||
let status_code = match &e {
|
||||
RpcError::ExecutionError(_) => StatusCode::BAD_REQUEST,
|
||||
@@ -46,6 +82,9 @@ fn convert_error(e: RemoteClientError<DbErr>) -> (StatusCode, Json<Error>) {
|
||||
other_error("Client not found").into(),
|
||||
),
|
||||
RemoteClientError::NotFound(msg) => (StatusCode::NOT_FOUND, other_error(msg).into()),
|
||||
RemoteClientError::Other(msg) if msg.starts_with("invalid instance ID:") => {
|
||||
(StatusCode::BAD_REQUEST, other_error(msg).into())
|
||||
}
|
||||
RemoteClientError::Other(msg) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, other_error(msg).into())
|
||||
}
|
||||
@@ -62,6 +101,11 @@ struct SaveNetworkJsonReq {
|
||||
config: NetworkConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct PatchVpnPortalClientsJsonReq {
|
||||
patches: Vec<VpnPortalClientPatch>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
struct RunNetworkJsonReq {
|
||||
config: NetworkConfig,
|
||||
@@ -89,6 +133,44 @@ struct RemoveNetworkJsonReq {
|
||||
inst_ids: Vec<uuid::Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
struct DeleteMachineParams {
|
||||
block: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
struct UpdateDeviceAliasJsonReq {
|
||||
alias: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct BlockedDeviceItem {
|
||||
id: String,
|
||||
user_id: UserIdInDb,
|
||||
hostname: String,
|
||||
blocked_time: chrono::DateTime<chrono::FixedOffset>,
|
||||
attempt_count: i32,
|
||||
last_attempt_time: Option<chrono::DateTime<chrono::FixedOffset>>,
|
||||
}
|
||||
|
||||
impl From<crate::db::entity::blocked_devices::Model> for BlockedDeviceItem {
|
||||
fn from(device: crate::db::entity::blocked_devices::Model) -> Self {
|
||||
Self {
|
||||
id: device.machine_id,
|
||||
user_id: device.user_id,
|
||||
hostname: device.hostname,
|
||||
blocked_time: device.blocked_time,
|
||||
attempt_count: device.attempt_count,
|
||||
last_attempt_time: device.last_attempt_time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct ListBlockedDevicesJsonResp {
|
||||
blocked: Vec<BlockedDeviceItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
struct ManagedNetworkConfigJson {
|
||||
instance_id: uuid::Uuid,
|
||||
@@ -113,8 +195,16 @@ struct PatchManagedNetworkConfigsJsonReq {
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
struct ListMachineItem {
|
||||
client_url: Option<url::Url>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
alias: Option<String>,
|
||||
info: Option<HeartbeatRequest>,
|
||||
location: Option<Location>,
|
||||
#[serde(default)]
|
||||
networks: Vec<DeviceNetworkSummary>,
|
||||
#[serde(default)]
|
||||
online: bool,
|
||||
#[serde(default)]
|
||||
last_seen: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
@@ -124,6 +214,8 @@ struct ListMachineJsonResp {
|
||||
|
||||
pub struct NetworkApi;
|
||||
|
||||
const DEVICE_ALIAS_MAX_CHARS: usize = 64;
|
||||
|
||||
impl NetworkApi {
|
||||
fn convert_managed_config_error(error: anyhow::Error) -> HttpHandleError {
|
||||
let (status, code, current_config_revision) =
|
||||
@@ -157,13 +249,7 @@ impl NetworkApi {
|
||||
}
|
||||
|
||||
fn get_user_id(auth_session: &AuthSession) -> Result<UserIdInDb, (StatusCode, Json<Error>)> {
|
||||
let Some(user_id) = auth_session.user.as_ref().map(|x| x.id()) else {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
other_error("No user id found".to_string()).into(),
|
||||
));
|
||||
};
|
||||
Ok(user_id)
|
||||
super::authed_user_id(auth_session)
|
||||
}
|
||||
|
||||
async fn handle_validate_config(
|
||||
@@ -185,10 +271,30 @@ impl NetworkApi {
|
||||
async fn handle_run_network_instance(
|
||||
auth_session: AuthSession,
|
||||
State(client_mgr): AppState,
|
||||
Extension(network_service): Extension<Arc<CentralNetworkService>>,
|
||||
Path(machine_id): Path<uuid::Uuid>,
|
||||
Json(payload): Json<RunNetworkJsonReq>,
|
||||
Json(mut payload): Json<RunNetworkJsonReq>,
|
||||
) -> Result<Json<Void>, HttpHandleError> {
|
||||
let user_id = Self::get_user_id(&auth_session)?;
|
||||
let instance_id = match payload.config.instance_id.as_deref() {
|
||||
Some(raw) => uuid::Uuid::parse_str(raw).map_err(|error| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
other_error(format!("invalid instance ID: {error}")).into(),
|
||||
)
|
||||
})?,
|
||||
None => uuid::Uuid::new_v4(),
|
||||
};
|
||||
payload.config.instance_id = Some(instance_id.to_string());
|
||||
let _mutation = network_service.lock_mutations().await;
|
||||
ensure_direct_mutation_allowed(
|
||||
&network_service,
|
||||
user_id,
|
||||
machine_id,
|
||||
instance_id,
|
||||
payload.config.network_name.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
client_mgr
|
||||
.invalidate_applied_config_revision(user_id, machine_id)
|
||||
.await;
|
||||
@@ -253,9 +359,13 @@ impl NetworkApi {
|
||||
async fn handle_remove_network_instance(
|
||||
auth_session: AuthSession,
|
||||
State(client_mgr): AppState,
|
||||
Extension(network_service): Extension<Arc<CentralNetworkService>>,
|
||||
Path((machine_id, inst_id)): Path<(uuid::Uuid, uuid::Uuid)>,
|
||||
) -> Result<(), HttpHandleError> {
|
||||
let user_id = Self::get_user_id(&auth_session)?;
|
||||
let _mutation = network_service.lock_mutations().await;
|
||||
ensure_direct_mutation_allowed(&network_service, user_id, machine_id, inst_id, None)
|
||||
.await?;
|
||||
client_mgr
|
||||
.invalidate_applied_config_revision(user_id, machine_id)
|
||||
.await;
|
||||
@@ -269,32 +379,186 @@ impl NetworkApi {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_delete_machine(
|
||||
auth_session: AuthSession,
|
||||
Extension(network_service): Extension<Arc<CentralNetworkService>>,
|
||||
Path(machine_id): Path<uuid::Uuid>,
|
||||
Query(params): Query<DeleteMachineParams>,
|
||||
) -> Result<StatusCode, HttpHandleError> {
|
||||
let deleted = network_service
|
||||
.delete_device(
|
||||
Self::get_user_id(&auth_session)?,
|
||||
machine_id,
|
||||
params.block.unwrap_or(false),
|
||||
)
|
||||
.await
|
||||
.map_err(super::central_network::convert_error)?;
|
||||
if !deleted {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
other_error(format!("device not found: {machine_id}")).into(),
|
||||
));
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn handle_update_machine_alias(
|
||||
auth_session: AuthSession,
|
||||
Extension(network_service): Extension<Arc<CentralNetworkService>>,
|
||||
Path(machine_id): Path<uuid::Uuid>,
|
||||
Json(request): Json<UpdateDeviceAliasJsonReq>,
|
||||
) -> Result<StatusCode, HttpHandleError> {
|
||||
let alias = request.alias.trim().to_owned();
|
||||
if alias.chars().count() > DEVICE_ALIAS_MAX_CHARS {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
other_error(format!(
|
||||
"alias must be at most {DEVICE_ALIAS_MAX_CHARS} characters"
|
||||
))
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let updated = network_service
|
||||
.db
|
||||
.set_device_alias((Self::get_user_id(&auth_session)?, machine_id), alias)
|
||||
.await
|
||||
.map_err(convert_db_error)?;
|
||||
if !updated {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
other_error(format!("device not found: {machine_id}")).into(),
|
||||
));
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn handle_list_blocked_devices(
|
||||
auth_session: AuthSession,
|
||||
Extension(network_service): Extension<Arc<CentralNetworkService>>,
|
||||
) -> Result<Json<ListBlockedDevicesJsonResp>, HttpHandleError> {
|
||||
let blocked = network_service
|
||||
.db
|
||||
.list_blocked_devices(Self::get_user_id(&auth_session)?)
|
||||
.await
|
||||
.map_err(convert_db_error)?
|
||||
.into_iter()
|
||||
.map(BlockedDeviceItem::from)
|
||||
.collect();
|
||||
Ok(Json(ListBlockedDevicesJsonResp { blocked }))
|
||||
}
|
||||
|
||||
async fn handle_unblock_device(
|
||||
auth_session: AuthSession,
|
||||
Extension(network_service): Extension<Arc<CentralNetworkService>>,
|
||||
Path(machine_id): Path<uuid::Uuid>,
|
||||
) -> Result<StatusCode, HttpHandleError> {
|
||||
network_service
|
||||
.db
|
||||
.unblock_device((Self::get_user_id(&auth_session)?, machine_id))
|
||||
.await
|
||||
.map_err(convert_db_error)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn handle_list_machines(
|
||||
auth_session: AuthSession,
|
||||
State(client_mgr): AppState,
|
||||
Extension(config): Extension<super::ConsoleInfoConfig>,
|
||||
Extension(network_service): Extension<Arc<CentralNetworkService>>,
|
||||
) -> Result<Json<ListMachineJsonResp>, HttpHandleError> {
|
||||
let user_id = Self::get_user_id(&auth_session)?;
|
||||
if config.webhook_auth {
|
||||
let client_urls = client_mgr.list_machine_by_user_id(user_id).await;
|
||||
let mut machines = Vec::with_capacity(client_urls.len());
|
||||
for client_url in client_urls {
|
||||
let info = client_mgr.get_heartbeat_requests(&client_url).await;
|
||||
let location = client_mgr.get_machine_location(&client_url).await;
|
||||
machines.push(ListMachineItem {
|
||||
client_url: Some(client_url),
|
||||
online: info.is_some(),
|
||||
last_seen: info.as_ref().map(|info| info.report_time.clone()),
|
||||
info,
|
||||
location,
|
||||
alias: None,
|
||||
networks: Vec::new(),
|
||||
});
|
||||
}
|
||||
return Ok(Json(ListMachineJsonResp { machines }));
|
||||
}
|
||||
let devices = network_service
|
||||
.db
|
||||
.list_devices(user_id)
|
||||
.await
|
||||
.map_err(convert_db_error)?;
|
||||
let mut device_networks = network_service
|
||||
.list_device_networks(user_id)
|
||||
.await
|
||||
.map_err(super::central_network::convert_error)?;
|
||||
|
||||
let client_urls = client_mgr.list_machine_by_user_id(user_id).await;
|
||||
|
||||
let mut machines = vec![];
|
||||
for item in client_urls.iter() {
|
||||
let client_url = item.clone();
|
||||
let session = client_mgr.get_heartbeat_requests(&client_url).await;
|
||||
let location = client_mgr.get_machine_location(&client_url).await;
|
||||
let mut machines = Vec::with_capacity(devices.len());
|
||||
for device in devices {
|
||||
let machine_id = uuid::Uuid::parse_str(&device.machine_id).map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
other_error(format!(
|
||||
"invalid device id in registry: {}",
|
||||
device.machine_id
|
||||
))
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let session = client_mgr.get_session_by_machine_id(user_id, &machine_id);
|
||||
let client_url = match &session {
|
||||
Some(session) => session.get_token().await.map(|token| token.client_url),
|
||||
None => device.client_url.parse::<url::Url>().ok(),
|
||||
};
|
||||
let location = match &session {
|
||||
Some(session) => session.data().read().await.location().cloned(),
|
||||
None => None,
|
||||
};
|
||||
let online = session.is_some();
|
||||
let info = match session {
|
||||
Some(session) => session.get_heartbeat_req().await,
|
||||
None => Some(HeartbeatRequest {
|
||||
machine_id: Some(machine_id.into()),
|
||||
inst_id: None,
|
||||
user_token: String::new(),
|
||||
easytier_version: device.easytier_version.clone(),
|
||||
report_time: device.last_seen_time.to_rfc3339(),
|
||||
hostname: device.hostname.clone(),
|
||||
running_network_instances: Vec::new(),
|
||||
device_os: serde_json::from_str(&device.device_os).ok(),
|
||||
support_config_source: false,
|
||||
failed_network_instances: Vec::new(),
|
||||
support_heartbeat_policy: false,
|
||||
}),
|
||||
};
|
||||
machines.push(ListMachineItem {
|
||||
client_url: Some(client_url),
|
||||
info: session,
|
||||
client_url,
|
||||
alias: (!device.alias.is_empty()).then_some(device.alias),
|
||||
info,
|
||||
location,
|
||||
networks: device_networks
|
||||
.remove(&device.machine_id)
|
||||
.unwrap_or_default(),
|
||||
online,
|
||||
last_seen: Some(device.last_seen_time.to_rfc3339()),
|
||||
});
|
||||
}
|
||||
|
||||
machines.sort_by(|a, b| {
|
||||
b.online
|
||||
.cmp(&a.online)
|
||||
.then_with(|| b.last_seen.cmp(&a.last_seen))
|
||||
});
|
||||
|
||||
Ok(Json(ListMachineJsonResp { machines }))
|
||||
}
|
||||
|
||||
async fn handle_update_network_state(
|
||||
auth_session: AuthSession,
|
||||
State(client_mgr): AppState,
|
||||
Extension(network_service): Extension<Arc<CentralNetworkService>>,
|
||||
Path((machine_id, inst_id)): Path<(uuid::Uuid, Option<uuid::Uuid>)>,
|
||||
Json(payload): Json<UpdateNetworkStateJsonReq>,
|
||||
) -> Result<(), HttpHandleError> {
|
||||
@@ -307,6 +571,9 @@ impl NetworkApi {
|
||||
};
|
||||
|
||||
let user_id = Self::get_user_id(&auth_session)?;
|
||||
let _mutation = network_service.lock_mutations().await;
|
||||
ensure_direct_mutation_allowed(&network_service, user_id, machine_id, inst_id, None)
|
||||
.await?;
|
||||
client_mgr
|
||||
.invalidate_applied_config_revision(user_id, machine_id)
|
||||
.await;
|
||||
@@ -340,6 +607,7 @@ impl NetworkApi {
|
||||
async fn handle_save_network_config(
|
||||
auth_session: AuthSession,
|
||||
State(client_mgr): AppState,
|
||||
Extension(network_service): Extension<Arc<CentralNetworkService>>,
|
||||
Path((machine_id, inst_id)): Path<(uuid::Uuid, uuid::Uuid)>,
|
||||
Json(payload): Json<SaveNetworkJsonReq>,
|
||||
) -> Result<(), HttpHandleError> {
|
||||
@@ -350,6 +618,15 @@ impl NetworkApi {
|
||||
));
|
||||
}
|
||||
let user_id = Self::get_user_id(&auth_session)?;
|
||||
let _mutation = network_service.lock_mutations().await;
|
||||
ensure_direct_mutation_allowed(
|
||||
&network_service,
|
||||
user_id,
|
||||
machine_id,
|
||||
inst_id,
|
||||
payload.config.network_name.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
client_mgr
|
||||
.invalidate_applied_config_revision(user_id, machine_id)
|
||||
.await;
|
||||
@@ -380,6 +657,29 @@ impl NetworkApi {
|
||||
.into())
|
||||
}
|
||||
|
||||
async fn handle_patch_vpn_portal_clients(
|
||||
auth_session: AuthSession,
|
||||
State(client_mgr): AppState,
|
||||
Extension(network_service): Extension<Arc<CentralNetworkService>>,
|
||||
Path((machine_id, inst_id)): Path<(uuid::Uuid, uuid::Uuid)>,
|
||||
Json(payload): Json<PatchVpnPortalClientsJsonReq>,
|
||||
) -> Result<(), HttpHandleError> {
|
||||
let user_id = Self::get_user_id(&auth_session)?;
|
||||
let _mutation = network_service.lock_mutations().await;
|
||||
ensure_direct_mutation_allowed(&network_service, user_id, machine_id, inst_id, None)
|
||||
.await?;
|
||||
client_mgr
|
||||
.invalidate_applied_config_revision(user_id, machine_id)
|
||||
.await;
|
||||
let result = client_mgr
|
||||
.handle_patch_vpn_portal_clients((user_id, machine_id), inst_id, payload.patches)
|
||||
.await;
|
||||
client_mgr
|
||||
.invalidate_applied_config_revision(user_id, machine_id)
|
||||
.await;
|
||||
result.map_err(convert_error)
|
||||
}
|
||||
|
||||
// --- Token-authenticated machine-scoped handlers (no AuthSession) ---
|
||||
|
||||
async fn handle_run_network_instance_internal(
|
||||
@@ -525,6 +825,22 @@ impl NetworkApi {
|
||||
pub fn build_route() -> Router<AppStateInner> {
|
||||
Router::new()
|
||||
.route("/api/v1/machines", get(Self::handle_list_machines))
|
||||
.route(
|
||||
"/api/v1/machines/{machine-id}",
|
||||
delete(Self::handle_delete_machine),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/machines/{machine-id}/alias",
|
||||
put(Self::handle_update_machine_alias),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/blocked-devices",
|
||||
get(Self::handle_list_blocked_devices),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/blocked-devices/{machine-id}",
|
||||
delete(Self::handle_unblock_device),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/machines/{machine-id}/validate-config",
|
||||
post(Self::handle_validate_config),
|
||||
@@ -549,6 +865,10 @@ impl NetworkApi {
|
||||
"/api/v1/machines/{machine-id}/networks/config/{inst-id}",
|
||||
get(Self::handle_get_network_config).put(Self::handle_save_network_config),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/machines/{machine-id}/networks/{inst-id}/vpn-portal-clients",
|
||||
axum::routing::patch(Self::handle_patch_vpn_portal_clients),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/machines/{machine-id}/networks/metas",
|
||||
post(Self::handle_get_network_metas),
|
||||
@@ -560,6 +880,280 @@ impl NetworkApi {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn internal_runtime_only_write_reconciles_the_already_applied_revision() {
|
||||
use std::{future::Future, time::Duration};
|
||||
|
||||
use easytier::{
|
||||
common::config::NetworkConfigExt as _, instance::factory::native_instance_manager,
|
||||
web_client::WebClient,
|
||||
};
|
||||
use easytier_core::{
|
||||
connectivity::protocol::raw::TunnelDialer,
|
||||
socket::SocketListener,
|
||||
tunnel::{
|
||||
Tunnel,
|
||||
ring::{RING_TUNNEL_CAP, RingTunnel, create_ring_socket_pair},
|
||||
},
|
||||
};
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use crate::{
|
||||
client_manager::{ClientManager, HeartbeatPolicy},
|
||||
db::Db,
|
||||
webhook::{
|
||||
ManagedNetworkConfig, ValidateTokenRequest, ValidateTokenResponse, WebhookConfig,
|
||||
WebhookHandler,
|
||||
},
|
||||
};
|
||||
|
||||
// Exercise the actual REST, Web RPC, and reconcile paths without an
|
||||
// operating-system listener or a test-only ClientManager interface.
|
||||
#[derive(Debug)]
|
||||
struct Listener(tokio::sync::mpsc::UnboundedReceiver<Box<dyn Tunnel>>);
|
||||
#[async_trait::async_trait]
|
||||
impl SocketListener for Listener {
|
||||
type Accepted = Box<dyn Tunnel>;
|
||||
async fn listen(&mut self) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn accept(&mut self) -> anyhow::Result<Self::Accepted> {
|
||||
self.0
|
||||
.recv()
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("test listener closed"))
|
||||
}
|
||||
fn local_url(&self) -> url::Url {
|
||||
"ring://server".parse().unwrap()
|
||||
}
|
||||
}
|
||||
struct Dialer(tokio::sync::mpsc::UnboundedSender<Box<dyn Tunnel>>);
|
||||
#[async_trait::async_trait]
|
||||
impl TunnelDialer for Dialer {
|
||||
async fn connect(&self) -> anyhow::Result<Box<dyn Tunnel>> {
|
||||
let (server, client) = create_ring_socket_pair(RING_TUNNEL_CAP);
|
||||
let server = Box::new(RingTunnel::new(
|
||||
server,
|
||||
Some(easytier::proto::common::TunnelInfo {
|
||||
tunnel_type: "ring".to_owned(),
|
||||
local_addr: Some("ring://server".parse::<url::Url>().unwrap().into()),
|
||||
remote_addr: Some(
|
||||
format!("ring://{}", uuid::Uuid::new_v4())
|
||||
.parse::<url::Url>()
|
||||
.unwrap()
|
||||
.into(),
|
||||
),
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
self.0
|
||||
.send(server)
|
||||
.map_err(|_| anyhow::anyhow!("test listener closed"))?;
|
||||
Ok(Box::new(RingTunnel::new(client, None)))
|
||||
}
|
||||
fn remote_url(&self) -> url::Url {
|
||||
"ring://server".parse().unwrap()
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Default)]
|
||||
struct Webhook(std::sync::Mutex<Option<String>>);
|
||||
#[async_trait::async_trait]
|
||||
impl WebhookHandler for Webhook {
|
||||
async fn validate_token(
|
||||
&self,
|
||||
request: &ValidateTokenRequest,
|
||||
) -> anyhow::Result<ValidateTokenResponse> {
|
||||
*self.0.lock().unwrap() = request.applied_config_revision.clone();
|
||||
Ok(ValidateTokenResponse {
|
||||
valid: true,
|
||||
pre_approved: true,
|
||||
binding_version: 0,
|
||||
config_revision: "revision-1".to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
async fn wait_until<F, Fut>(mut condition: F)
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = bool>,
|
||||
{
|
||||
tokio::time::timeout(Duration::from_secs(20), async {
|
||||
while !condition().await {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("runtime did not converge");
|
||||
}
|
||||
|
||||
let db = Db::memory_db().await;
|
||||
let db_user = db.auto_create_user("rest-revision").await.unwrap();
|
||||
let user_id = db_user.id;
|
||||
let machine_id = uuid::Uuid::new_v4();
|
||||
let instance_id = uuid::Uuid::new_v4();
|
||||
let webhook = Arc::new(Webhook::default());
|
||||
let mut manager = ClientManager::new(
|
||||
db.clone(),
|
||||
None,
|
||||
HeartbeatPolicy::from_millis(1_000, 10_000).unwrap(),
|
||||
Arc::new(crate::FeatureFlags::default()),
|
||||
Arc::new(
|
||||
WebhookConfig::new(None, None, None, None, None).with_handler(webhook.clone()),
|
||||
),
|
||||
);
|
||||
let (connections, listener) = tokio::sync::mpsc::unbounded_channel();
|
||||
manager.add_listener(Listener(listener)).await.unwrap();
|
||||
let manager = Arc::new(manager);
|
||||
let core = Arc::new(native_instance_manager());
|
||||
let client = WebClient::new(
|
||||
Dialer(connections),
|
||||
"rest-revision",
|
||||
machine_id,
|
||||
"test-device",
|
||||
false,
|
||||
core.clone(),
|
||||
None,
|
||||
);
|
||||
let desired = serde_json::json!({
|
||||
"instance_id": instance_id.to_string(),
|
||||
"network_name": "managed-network", "network_secret": "secret",
|
||||
"networking_method": "Standalone", "no_tun": true,
|
||||
"disable_ipv6": true, "multi_thread": false,
|
||||
});
|
||||
manager
|
||||
.reconcile_managed_network_configs(
|
||||
user_id,
|
||||
machine_id,
|
||||
vec![ManagedNetworkConfig {
|
||||
instance_id: instance_id.to_string(),
|
||||
network_config: desired.clone(),
|
||||
}],
|
||||
Some("revision-1".to_owned()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_until(|| async { webhook.0.lock().unwrap().as_deref() == Some("revision-1") }).await;
|
||||
|
||||
// External Console authentication does not populate central devices.
|
||||
// Both public views must still expose the live, webhook-managed Core.
|
||||
assert!(db.list_devices(user_id).await.unwrap().is_empty());
|
||||
let service = Arc::new(CentralNetworkService::new(db.clone(), manager.clone()));
|
||||
let session_store = tower_sessions_sqlx_store::SqliteStore::new(db.inner());
|
||||
session_store.migrate().await.unwrap();
|
||||
let session_layer = axum_login::tower_sessions::SessionManagerLayer::new(session_store);
|
||||
let auth_layer = axum_login::AuthManagerLayerBuilder::new(
|
||||
super::super::users::Backend::new(db.clone()),
|
||||
session_layer,
|
||||
)
|
||||
.build();
|
||||
let probe_manager = manager.clone();
|
||||
let response = Router::new()
|
||||
.route(
|
||||
"/views",
|
||||
get(move |mut auth: AuthSession| async move {
|
||||
auth.user = Some(super::super::users::User {
|
||||
db_user,
|
||||
tokens: vec![],
|
||||
});
|
||||
let config = super::super::ConsoleInfoConfig {
|
||||
config_server_protocol: "tcp".into(),
|
||||
config_server_port: 22020,
|
||||
webhook_auth: true,
|
||||
};
|
||||
let machines = NetworkApi::handle_list_machines(
|
||||
auth.clone(),
|
||||
State(probe_manager.clone()),
|
||||
Extension(config.clone()),
|
||||
Extension(service.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.0;
|
||||
let summary = super::super::RestfulServer::handle_get_summary(
|
||||
auth,
|
||||
State(probe_manager),
|
||||
Extension(config),
|
||||
Extension(service),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(summary.device_count, 1);
|
||||
assert_eq!(machines.machines.len(), 1);
|
||||
assert!(machines.machines[0].online);
|
||||
assert_eq!(
|
||||
machines.machines[0].info.as_ref().unwrap().machine_id,
|
||||
Some(machine_id.into())
|
||||
);
|
||||
StatusCode::OK
|
||||
}),
|
||||
)
|
||||
.layer(auth_layer)
|
||||
.oneshot(
|
||||
axum::http::Request::get("/views")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let mut drifted = desired;
|
||||
drifted["network_name"] = serde_json::json!("runtime-only-change");
|
||||
let response = NetworkApi::build_route_internal()
|
||||
.with_state(manager.clone())
|
||||
.oneshot(
|
||||
axum::http::Request::post(format!(
|
||||
"/api/internal/users/{user_id}/machines/{machine_id}/networks"
|
||||
))
|
||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||
.body(axum::body::Body::from(
|
||||
serde_json::json!({"config": drifted, "save": false}).to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
wait_until(|| async {
|
||||
core.config(instance_id)
|
||||
.and_then(|config| NetworkConfig::new_from_config(&config).ok())
|
||||
.is_some_and(|config| config.network_name.as_deref() == Some("managed-network"))
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
db.get_managed_config_revision((user_id, machine_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("revision-1")
|
||||
);
|
||||
drop(client);
|
||||
manager
|
||||
.disconnect_session_by_machine_id(user_id, &machine_id)
|
||||
.await;
|
||||
core.delete_network_instances([instance_id]).await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_device_response_keeps_the_public_id_field() {
|
||||
let machine_id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Local::now().fixed_offset();
|
||||
let item = BlockedDeviceItem::from(crate::db::entity::blocked_devices::Model {
|
||||
user_id: 7,
|
||||
machine_id: machine_id.clone(),
|
||||
hostname: "blocked-device".to_owned(),
|
||||
blocked_time: now,
|
||||
attempt_count: 3,
|
||||
last_attempt_time: Some(now),
|
||||
});
|
||||
|
||||
let value = serde_json::to_value(item).unwrap();
|
||||
assert_eq!(value["id"], machine_id);
|
||||
assert!(value.get("machine_id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revision_conflict_response_exposes_machine_readable_current_revision() {
|
||||
let error = crate::client_manager::ManagedConfigError::RevisionConflict {
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
Extension, Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::post,
|
||||
};
|
||||
use axum_login::AuthUser as _;
|
||||
use easytier::proto::rpc_types::controller::BaseController;
|
||||
use easytier::proto::{
|
||||
api::{
|
||||
config::{ConfigRpc, GetConfigRequest, PatchConfigRequest},
|
||||
instance::{
|
||||
GenerateCredentialRequest, InstanceIdentifier, RevokeCredentialRequest,
|
||||
UpsertCredentialRequest, instance_identifier,
|
||||
},
|
||||
manage::{
|
||||
DeleteNetworkInstanceRequest, RetainNetworkInstanceRequest, RunNetworkInstanceRequest,
|
||||
},
|
||||
},
|
||||
rpc_types::controller::BaseController,
|
||||
};
|
||||
|
||||
use crate::db::UserIdInDb;
|
||||
use crate::{
|
||||
central_network::service::CentralNetworkService,
|
||||
db::{CentralOwnedRuntimeConfig, UserIdInDb},
|
||||
};
|
||||
|
||||
use super::{AppState, HttpHandleError, other_error};
|
||||
|
||||
@@ -156,6 +173,150 @@ async fn handle_proxy_rpc_by_session(
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_instance_mutation_allowed(
|
||||
req: &mut ProxyRpcRequest,
|
||||
central: &[CentralOwnedRuntimeConfig],
|
||||
client: &mut (impl ConfigRpc<Controller = BaseController> + Send + ?Sized),
|
||||
) -> Result<(), HttpHandleError> {
|
||||
if central.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let instance = match req.method_name.as_str() {
|
||||
"generate_credential" | "GenerateCredential" => {
|
||||
serde_json::from_value::<GenerateCredentialRequest>(req.payload.clone())
|
||||
.map(|request| request.instance)
|
||||
}
|
||||
"revoke_credential" | "RevokeCredential" => {
|
||||
serde_json::from_value::<RevokeCredentialRequest>(req.payload.clone())
|
||||
.map(|request| request.instance)
|
||||
}
|
||||
"upsert_credential" | "UpsertCredential" => {
|
||||
serde_json::from_value::<UpsertCredentialRequest>(req.payload.clone())
|
||||
.map(|request| request.instance)
|
||||
}
|
||||
"patch_config" | "PatchConfig" => {
|
||||
serde_json::from_value::<PatchConfigRequest>(req.payload.clone())
|
||||
.map(|request| request.instance)
|
||||
}
|
||||
_ => return Ok(()),
|
||||
}
|
||||
.map_err(|error| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
other_error(format!("Invalid RPC payload: {error}")).into(),
|
||||
)
|
||||
})?;
|
||||
let instance_id = match instance
|
||||
.as_ref()
|
||||
.and_then(|instance| instance.selector.as_ref())
|
||||
{
|
||||
Some(instance_identifier::Selector::Id(id)) => uuid::Uuid::from(*id),
|
||||
Some(instance_identifier::Selector::InstanceSelector(selector))
|
||||
if selector.name.is_some() =>
|
||||
{
|
||||
// Resolve names on the same session that receives the mutation:
|
||||
// the desired network name may differ from the running instance.
|
||||
let response = client
|
||||
.get_config(BaseController::default(), GetConfigRequest { instance })
|
||||
.await
|
||||
.map_err(|error| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
other_error(format!("RPC Error: {error:?}")).into(),
|
||||
)
|
||||
})?;
|
||||
response
|
||||
.config
|
||||
.and_then(|config| config.instance_id)
|
||||
.and_then(|id| id.parse::<uuid::Uuid>().ok())
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
other_error("RPC returned no valid instance ID").into(),
|
||||
)
|
||||
})?
|
||||
}
|
||||
_ => return Err(super::network::central_ownership_conflict()),
|
||||
};
|
||||
if central
|
||||
.iter()
|
||||
.any(|config| config.instance_id == instance_id)
|
||||
{
|
||||
return Err(super::network::central_ownership_conflict());
|
||||
}
|
||||
// Pin the authorized instance so a concurrent rename cannot retarget it.
|
||||
req.payload["instance"] = serde_json::json!(InstanceIdentifier {
|
||||
selector: Some(instance_identifier::Selector::Id(instance_id.into())),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_runtime_mutation_allowed(
|
||||
req: &mut ProxyRpcRequest,
|
||||
central: &[CentralOwnedRuntimeConfig],
|
||||
client: &mut (impl ConfigRpc<Controller = BaseController> + Send + ?Sized),
|
||||
) -> Result<(), HttpHandleError> {
|
||||
if central.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if req.service_name == "api.instance.CredentialManageRpcService" {
|
||||
return ensure_instance_mutation_allowed(req, central, client).await;
|
||||
}
|
||||
fn parse<T: serde::de::DeserializeOwned>(req: &ProxyRpcRequest) -> Result<T, HttpHandleError> {
|
||||
serde_json::from_value(req.payload.clone()).map_err(|error| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
other_error(format!("Invalid RPC payload: {error}")).into(),
|
||||
)
|
||||
})
|
||||
}
|
||||
let conflicts = match (req.service_name.as_str(), req.method_name.as_str()) {
|
||||
("api.manage.WebClientService", "run_network_instance" | "RunNetworkInstance") => {
|
||||
let request: RunNetworkInstanceRequest = parse(req)?;
|
||||
central.iter().any(|config| {
|
||||
request
|
||||
.inst_id
|
||||
.is_some_and(|id| uuid::Uuid::from(id) == config.instance_id)
|
||||
|| request.config.as_ref().is_some_and(|requested| {
|
||||
requested
|
||||
.instance_id
|
||||
.as_deref()
|
||||
.and_then(|id| uuid::Uuid::parse_str(id).ok())
|
||||
== Some(config.instance_id)
|
||||
|| requested.network_name.as_deref()
|
||||
== Some(config.network_name.as_str())
|
||||
})
|
||||
})
|
||||
}
|
||||
("api.manage.WebClientService", "retain_network_instance" | "RetainNetworkInstance") => {
|
||||
let request: RetainNetworkInstanceRequest = parse(req)?;
|
||||
central.iter().any(|config| {
|
||||
!request
|
||||
.inst_ids
|
||||
.iter()
|
||||
.any(|id| uuid::Uuid::from(*id) == config.instance_id)
|
||||
})
|
||||
}
|
||||
("api.manage.WebClientService", "delete_network_instance" | "DeleteNetworkInstance") => {
|
||||
let request: DeleteNetworkInstanceRequest = parse(req)?;
|
||||
central.iter().any(|config| {
|
||||
request
|
||||
.inst_ids
|
||||
.iter()
|
||||
.any(|id| uuid::Uuid::from(*id) == config.instance_id)
|
||||
})
|
||||
}
|
||||
("api.config.ConfigRpcService", "patch_config" | "PatchConfig") => {
|
||||
return ensure_instance_mutation_allowed(req, central, client).await;
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if conflicts {
|
||||
return Err(super::network::central_ownership_conflict());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn proxy_rpc_mutates_runtime_config(service_name: &str, method_name: &str) -> bool {
|
||||
matches!(
|
||||
(service_name, method_name),
|
||||
@@ -185,8 +346,9 @@ fn proxy_rpc_mutates_runtime_config(service_name: &str, method_name: &str) -> bo
|
||||
pub async fn handle_proxy_rpc(
|
||||
auth_session: super::users::AuthSession,
|
||||
State(client_mgr): AppState,
|
||||
Extension(network_service): Extension<Arc<CentralNetworkService>>,
|
||||
Path(machine_id): Path<uuid::Uuid>,
|
||||
Json(req): Json<ProxyRpcRequest>,
|
||||
Json(mut req): Json<ProxyRpcRequest>,
|
||||
) -> Result<Json<serde_json::Value>, HttpHandleError> {
|
||||
let user_id = auth_session
|
||||
.user
|
||||
@@ -194,12 +356,30 @@ pub async fn handle_proxy_rpc(
|
||||
.ok_or((StatusCode::UNAUTHORIZED, other_error("Unauthorized").into()))?
|
||||
.id();
|
||||
|
||||
let _mutation = if proxy_rpc_mutates_runtime_config(&req.service_name, &req.method_name) {
|
||||
Some(network_service.lock_mutations().await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let session = client_mgr
|
||||
.get_session_by_machine_id(user_id, &machine_id)
|
||||
.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
other_error("Session not found").into(),
|
||||
))?;
|
||||
if _mutation.is_some() {
|
||||
let central = network_service
|
||||
.db
|
||||
.central_owned_runtime_configs(user_id, machine_id)
|
||||
.await
|
||||
.map_err(super::convert_db_error)?;
|
||||
ensure_runtime_mutation_allowed(
|
||||
&mut req,
|
||||
¢ral,
|
||||
session.scoped_config_client().as_mut(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
handle_proxy_rpc_by_session(session.as_ref(), req).await
|
||||
}
|
||||
|
||||
@@ -234,7 +414,356 @@ pub fn router_internal() -> Router<super::AppStateInner> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::proxy_rpc_mutates_runtime_config;
|
||||
use super::*;
|
||||
use easytier::proto::api::config::{GetConfigResponse, PatchConfigResponse};
|
||||
|
||||
#[derive(Default)]
|
||||
struct ConfigClient {
|
||||
instances: Vec<(&'static str, uuid::Uuid)>,
|
||||
requests: std::sync::Mutex<Vec<InstanceIdentifier>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ConfigRpc for ConfigClient {
|
||||
type Controller = BaseController;
|
||||
|
||||
async fn get_config(
|
||||
&self,
|
||||
_: BaseController,
|
||||
request: GetConfigRequest,
|
||||
) -> easytier::proto::rpc_types::error::Result<GetConfigResponse> {
|
||||
let instance = request.instance.unwrap();
|
||||
self.requests.lock().unwrap().push(instance.clone());
|
||||
let Some(instance_identifier::Selector::InstanceSelector(selector)) = instance.selector
|
||||
else {
|
||||
panic!("UUID and default selectors must not require a runtime read");
|
||||
};
|
||||
let id = self
|
||||
.instances
|
||||
.iter()
|
||||
.find(|(name, _)| Some(*name) == selector.name.as_deref())
|
||||
.map(|(_, id)| *id)
|
||||
.ok_or_else(|| anyhow::anyhow!("No instance matches the selector"))?;
|
||||
Ok(GetConfigResponse {
|
||||
config: Some(easytier::proto::api::manage::NetworkConfig {
|
||||
instance_id: Some(id.to_string()),
|
||||
// Instance selectors resolve instance_name, not network_name.
|
||||
network_name: Some("different-network-name".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
async fn patch_config(
|
||||
&self,
|
||||
_: BaseController,
|
||||
_: PatchConfigRequest,
|
||||
) -> easytier::proto::rpc_types::error::Result<PatchConfigResponse> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_proxy_mutations_cannot_overwrite_or_remove_central_instances() {
|
||||
let central_id = uuid::Uuid::new_v4();
|
||||
let other_id = uuid::Uuid::new_v4();
|
||||
let central = [CentralOwnedRuntimeConfig {
|
||||
instance_id: central_id,
|
||||
network_name: "central-network".into(),
|
||||
}];
|
||||
let cases = [
|
||||
(
|
||||
"RunNetworkInstance",
|
||||
serde_json::to_value(RunNetworkInstanceRequest {
|
||||
inst_id: Some(central_id.into()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"RunNetworkInstance",
|
||||
serde_json::to_value(RunNetworkInstanceRequest {
|
||||
config: Some(easytier::proto::api::manage::NetworkConfig {
|
||||
instance_id: Some(other_id.to_string()),
|
||||
network_name: Some("central-network".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"RetainNetworkInstance",
|
||||
serde_json::to_value(RetainNetworkInstanceRequest {
|
||||
inst_ids: vec![other_id.into()],
|
||||
})
|
||||
.unwrap(),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"RetainNetworkInstance",
|
||||
serde_json::to_value(RetainNetworkInstanceRequest {
|
||||
inst_ids: vec![central_id.into()],
|
||||
})
|
||||
.unwrap(),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"DeleteNetworkInstance",
|
||||
serde_json::to_value(DeleteNetworkInstanceRequest {
|
||||
inst_ids: vec![central_id.into()],
|
||||
})
|
||||
.unwrap(),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"DeleteNetworkInstance",
|
||||
serde_json::to_value(DeleteNetworkInstanceRequest {
|
||||
inst_ids: vec![other_id.into()],
|
||||
})
|
||||
.unwrap(),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"PatchConfig",
|
||||
serde_json::to_value(PatchConfigRequest::default()).unwrap(),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"PatchConfig",
|
||||
serde_json::to_value(PatchConfigRequest {
|
||||
instance: Some(InstanceIdentifier {
|
||||
selector: Some(instance_identifier::Selector::Id(other_id.into())),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
false,
|
||||
),
|
||||
];
|
||||
for (method, payload, conflicts) in cases {
|
||||
let mut request = ProxyRpcRequest {
|
||||
service_name: if method == "PatchConfig" {
|
||||
"api.config.ConfigRpcService"
|
||||
} else {
|
||||
"api.manage.WebClientService"
|
||||
}
|
||||
.into(),
|
||||
method_name: method.into(),
|
||||
payload,
|
||||
scope: None,
|
||||
};
|
||||
let result = ensure_runtime_mutation_allowed(
|
||||
&mut request,
|
||||
¢ral,
|
||||
&mut ConfigClient::default(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.is_err(), conflicts, "{method}");
|
||||
if let Err(error) = result {
|
||||
assert_eq!(error.0, StatusCode::CONFLICT);
|
||||
}
|
||||
ensure_runtime_mutation_allowed(&mut request, &[], &mut ConfigClient::default())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credential_mutations_preserve_central_ownership_for_all_selectors() {
|
||||
let central_id = uuid::Uuid::new_v4();
|
||||
let central = [CentralOwnedRuntimeConfig {
|
||||
instance_id: central_id,
|
||||
network_name: "central-network".into(),
|
||||
}];
|
||||
let mut client = ConfigClient {
|
||||
instances: vec![
|
||||
("central-network", central_id),
|
||||
("console-network", uuid::Uuid::new_v4()),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let by_id = |id: uuid::Uuid| InstanceIdentifier {
|
||||
selector: Some(instance_identifier::Selector::Id(id.into())),
|
||||
};
|
||||
let by_name = |name: Option<&str>| InstanceIdentifier {
|
||||
selector: Some(instance_identifier::Selector::InstanceSelector(
|
||||
instance_identifier::InstanceSelector {
|
||||
name: name.map(str::to_owned),
|
||||
},
|
||||
)),
|
||||
};
|
||||
for (method, payload) in [
|
||||
(
|
||||
"GenerateCredential",
|
||||
serde_json::to_value(GenerateCredentialRequest::default()).unwrap(),
|
||||
),
|
||||
(
|
||||
"UpsertCredential",
|
||||
serde_json::to_value(UpsertCredentialRequest::default()).unwrap(),
|
||||
),
|
||||
(
|
||||
"RevokeCredential",
|
||||
serde_json::to_value(RevokeCredentialRequest::default()).unwrap(),
|
||||
),
|
||||
] {
|
||||
let snake_case = method.replace("Credential", "_credential").to_lowercase();
|
||||
for method in [method, snake_case.as_str()] {
|
||||
for (instance, conflicts) in [
|
||||
(None, true),
|
||||
(Some(InstanceIdentifier::default()), true),
|
||||
(Some(by_name(None)), true),
|
||||
(Some(by_id(central_id)), true),
|
||||
(Some(by_name(Some("central-network"))), true),
|
||||
(Some(by_id(uuid::Uuid::new_v4())), false),
|
||||
(Some(by_name(Some("console-network"))), false),
|
||||
] {
|
||||
let mut req = ProxyRpcRequest {
|
||||
service_name: "api.instance.CredentialManageRpcService".into(),
|
||||
method_name: method.into(),
|
||||
payload: payload.clone(),
|
||||
scope: None,
|
||||
};
|
||||
req.payload["instance"] = serde_json::to_value(instance).unwrap();
|
||||
let result =
|
||||
ensure_runtime_mutation_allowed(&mut req, ¢ral, &mut client).await;
|
||||
if conflicts {
|
||||
assert_eq!(result.unwrap_err().0, StatusCode::CONFLICT, "{method}");
|
||||
} else {
|
||||
result.unwrap();
|
||||
}
|
||||
// Devices with only external or manual configs remain unrestricted.
|
||||
ensure_runtime_mutation_allowed(&mut req, &[], &mut client)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn named_mutations_resolve_pending_renames_and_forward_the_checked_id() {
|
||||
let central_id = uuid::Uuid::new_v4();
|
||||
let ordinary_id = uuid::Uuid::new_v4();
|
||||
let central = [CentralOwnedRuntimeConfig {
|
||||
instance_id: central_id,
|
||||
network_name: "central-new-name".into(),
|
||||
}];
|
||||
let mut client = ConfigClient {
|
||||
instances: vec![
|
||||
("central-old-name", central_id),
|
||||
("ordinary-instance", ordinary_id),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
for (service, method, payload) in [
|
||||
(
|
||||
"api.instance.CredentialManageRpcService",
|
||||
"GenerateCredential",
|
||||
serde_json::to_value(GenerateCredentialRequest::default()).unwrap(),
|
||||
),
|
||||
(
|
||||
"api.instance.CredentialManageRpcService",
|
||||
"UpsertCredential",
|
||||
serde_json::to_value(UpsertCredentialRequest::default()).unwrap(),
|
||||
),
|
||||
(
|
||||
"api.instance.CredentialManageRpcService",
|
||||
"RevokeCredential",
|
||||
serde_json::to_value(RevokeCredentialRequest::default()).unwrap(),
|
||||
),
|
||||
(
|
||||
"api.config.ConfigRpcService",
|
||||
"PatchConfig",
|
||||
serde_json::to_value(PatchConfigRequest::default()).unwrap(),
|
||||
),
|
||||
] {
|
||||
let snake_case = method
|
||||
.replace("Credential", "_credential")
|
||||
.replace("Config", "_config")
|
||||
.to_lowercase();
|
||||
for method in [method, snake_case.as_str()] {
|
||||
for (name, expected_id) in [
|
||||
("central-old-name", None),
|
||||
("ordinary-instance", Some(ordinary_id)),
|
||||
] {
|
||||
let instance = InstanceIdentifier {
|
||||
selector: Some(instance_identifier::Selector::InstanceSelector(
|
||||
instance_identifier::InstanceSelector {
|
||||
name: Some(name.into()),
|
||||
},
|
||||
)),
|
||||
};
|
||||
let mut req = ProxyRpcRequest {
|
||||
service_name: service.into(),
|
||||
method_name: method.into(),
|
||||
payload: payload.clone(),
|
||||
scope: None,
|
||||
};
|
||||
req.payload["instance"] = serde_json::to_value(&instance).unwrap();
|
||||
let result =
|
||||
ensure_runtime_mutation_allowed(&mut req, ¢ral, &mut client).await;
|
||||
assert_eq!(client.requests.lock().unwrap().last(), Some(&instance));
|
||||
if let Some(expected_id) = expected_id {
|
||||
result.unwrap();
|
||||
// Forward the authorized UUID even if the runtime name changes
|
||||
// again before the actual mutation is dispatched.
|
||||
let forwarded: GetConfigRequest =
|
||||
serde_json::from_value(req.payload).unwrap();
|
||||
assert_eq!(
|
||||
forwarded.instance.unwrap().selector,
|
||||
Some(instance_identifier::Selector::Id(expected_id.into()))
|
||||
);
|
||||
} else {
|
||||
assert_eq!(result.unwrap_err().0, StatusCode::CONFLICT, "{method}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn named_mutations_require_resolution_only_on_central_devices() {
|
||||
let central = [CentralOwnedRuntimeConfig {
|
||||
instance_id: uuid::Uuid::new_v4(),
|
||||
network_name: "central-network".into(),
|
||||
}];
|
||||
let mut client = ConfigClient::default();
|
||||
let mut request = ProxyRpcRequest {
|
||||
service_name: "api.config.ConfigRpcService".into(),
|
||||
method_name: "PatchConfig".into(),
|
||||
payload: serde_json::to_value(PatchConfigRequest {
|
||||
instance: Some(InstanceIdentifier {
|
||||
selector: Some(instance_identifier::Selector::InstanceSelector(
|
||||
instance_identifier::InstanceSelector {
|
||||
name: Some("missing-instance".into()),
|
||||
},
|
||||
)),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
scope: None,
|
||||
};
|
||||
let payload = request.payload.clone();
|
||||
ensure_runtime_mutation_allowed(&mut request, &[], &mut client)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(request.payload, payload);
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
|
||||
assert_eq!(
|
||||
ensure_runtime_mutation_allowed(&mut request, ¢ral, &mut client)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.0,
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
assert_eq!(request.payload, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_config_mutation_detection_covers_proxy_rpc_aliases() {
|
||||
|
||||
+102
-5
@@ -22,6 +22,18 @@ struct ApiMetaResponse {
|
||||
}
|
||||
|
||||
async fn handle_api_meta(State(api_host): State<url::Url>) -> impl IntoResponse {
|
||||
api_meta_response(api_host.to_string())
|
||||
}
|
||||
|
||||
// The frontend dist may bundle a static api_meta.js pointing at the official
|
||||
// hosted console. Without --api-host this server is the API origin, so the
|
||||
// bundled file must be overridden to keep the frontend on relative requests
|
||||
// instead of sending credentials to a third-party domain.
|
||||
async fn handle_same_origin_api_meta() -> impl IntoResponse {
|
||||
api_meta_response(String::new())
|
||||
}
|
||||
|
||||
fn api_meta_response(api_host: String) -> Response<String> {
|
||||
Response::builder()
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
@@ -32,14 +44,26 @@ async fn handle_api_meta(State(api_host): State<url::Url>) -> impl IntoResponse
|
||||
.header(header::EXPIRES, "0")
|
||||
.body(format!(
|
||||
"window.apiMeta = {}",
|
||||
serde_json::to_string(&ApiMetaResponse {
|
||||
api_host: api_host.to_string()
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::to_string(&ApiMetaResponse { api_host }).unwrap(),
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// The hashed asset filenames change on every build, but browsers happily
|
||||
// serve index.html from their heuristic cache and keep loading the old
|
||||
// bundle. Serve the entry document explicitly with no-cache so upgrades are
|
||||
// picked up on the next reload.
|
||||
async fn handle_index() -> impl IntoResponse {
|
||||
let asset = Assets::get("index.html").expect("frontend dist must contain index.html");
|
||||
Response::builder()
|
||||
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
||||
.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
|
||||
.header(header::PRAGMA, "no-cache")
|
||||
.header(header::EXPIRES, "0")
|
||||
.body(String::from_utf8_lossy(&asset.data).to_string())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn build_router(api_host: Option<url::Url>) -> Router {
|
||||
let service = ServeEmbed::<Assets>::new();
|
||||
let router = Router::new();
|
||||
@@ -50,9 +74,13 @@ pub fn build_router(api_host: Option<url::Url>) -> Router {
|
||||
.with_state(api_host);
|
||||
router.merge(sub_router)
|
||||
} else {
|
||||
router
|
||||
router.route("/api_meta.js", routing::get(handle_same_origin_api_meta))
|
||||
};
|
||||
|
||||
let router = router
|
||||
.route("/", routing::get(handle_index))
|
||||
.route("/index.html", routing::get(handle_index));
|
||||
|
||||
router.fallback_service(service)
|
||||
}
|
||||
|
||||
@@ -82,3 +110,72 @@ impl WebServer {
|
||||
Ok(task)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn get_api_meta(router: Router) -> String {
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::get("/api_meta.js")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
String::from_utf8(body.to_vec()).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_origin_mode_overrides_bundled_api_meta() {
|
||||
// Without --api-host the server itself is the API origin and must
|
||||
// neutralize the api_meta.js bundled with the frontend dist, which
|
||||
// points at the official hosted console.
|
||||
let body = get_api_meta(build_router(None)).await;
|
||||
assert_eq!(body, "window.apiMeta = {\"api_host\":\"\"}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn index_html_is_served_with_no_cache() {
|
||||
for path in ["/", "/index.html"] {
|
||||
let response = build_router(None)
|
||||
.oneshot(Request::get(path).body(axum::body::Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"no-cache, no-store, must-revalidate"
|
||||
);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(body.contains("<div id=\"app\">"), "path: {path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_api_host_is_injected() {
|
||||
let body = get_api_meta(build_router(Some(
|
||||
"https://api.example.com".parse().unwrap(),
|
||||
)))
|
||||
.await;
|
||||
assert_eq!(
|
||||
body,
|
||||
"window.apiMeta = {\"api_host\":\"https://api.example.com/\"}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -255,6 +255,22 @@ pub struct WebhookConfig {
|
||||
|
||||
validate_limiter: Arc<AdaptiveValidateLimiter>,
|
||||
client: reqwest::Client,
|
||||
handler: Option<Arc<dyn WebhookHandler>>,
|
||||
}
|
||||
|
||||
/// An in-process consumer of the same management callbacks as an HTTP webhook.
|
||||
#[async_trait::async_trait]
|
||||
pub trait WebhookHandler: fmt::Debug + Send + Sync {
|
||||
async fn validate_token(
|
||||
&self,
|
||||
request: &ValidateTokenRequest,
|
||||
) -> anyhow::Result<ValidateTokenResponse>;
|
||||
|
||||
async fn node_connected(&self, _request: &NodeConnectedRequest) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn node_disconnected(&self, _request: &NodeDisconnectedRequest) {}
|
||||
}
|
||||
|
||||
impl WebhookConfig {
|
||||
@@ -271,6 +287,7 @@ impl WebhookConfig {
|
||||
internal_auth_token,
|
||||
web_instance_id,
|
||||
web_instance_api_base_url,
|
||||
handler: None,
|
||||
validate_limiter: AdaptiveValidateLimiter::new(),
|
||||
client: reqwest::Client::builder()
|
||||
.timeout(WEBHOOK_HTTP_TIMEOUT)
|
||||
@@ -280,11 +297,21 @@ impl WebhookConfig {
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.handler.is_some() || self.has_external_endpoint()
|
||||
}
|
||||
|
||||
pub fn has_external_endpoint(&self) -> bool {
|
||||
self.webhook_url
|
||||
.as_deref()
|
||||
.is_some_and(|url| !url.trim().is_empty())
|
||||
}
|
||||
|
||||
pub fn with_handler(mut self, handler: Arc<dyn WebhookHandler>) -> Self {
|
||||
assert!(!self.has_external_endpoint());
|
||||
self.handler = Some(handler);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn has_internal_auth(&self) -> bool {
|
||||
self.internal_auth_token.is_some()
|
||||
}
|
||||
@@ -396,6 +423,9 @@ impl WebhookConfig {
|
||||
&self,
|
||||
req: &ValidateTokenRequest,
|
||||
) -> anyhow::Result<ValidateTokenResponse> {
|
||||
if let Some(handler) = &self.handler {
|
||||
return handler.validate_token(req).await;
|
||||
}
|
||||
self.validate_token_with_http_timeout(req, WEBHOOK_HTTP_TIMEOUT)
|
||||
.await
|
||||
}
|
||||
@@ -452,6 +482,12 @@ impl WebhookConfig {
|
||||
&self,
|
||||
req: &NodeConnectedRequest,
|
||||
) -> Result<(), WebhookDeliveryError> {
|
||||
if let Some(handler) = &self.handler {
|
||||
return handler
|
||||
.node_connected(req)
|
||||
.await
|
||||
.map_err(WebhookDeliveryError::Configuration);
|
||||
}
|
||||
if !self.is_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -474,6 +510,10 @@ impl WebhookConfig {
|
||||
|
||||
/// Notify the webhook receiver that a node has disconnected.
|
||||
pub async fn notify_node_disconnected(&self, req: &NodeDisconnectedRequest) {
|
||||
if let Some(handler) = &self.handler {
|
||||
handler.node_disconnected(req).await;
|
||||
return;
|
||||
}
|
||||
if !self.is_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1123,6 +1123,11 @@ impl NetworkOptions {
|
||||
.vpn_portal_private_key
|
||||
.clone()
|
||||
.or_else(|| existing.as_ref()?.wireguard_private_key.clone());
|
||||
let enabled = if self.vpn_portal.is_some() {
|
||||
Some(true)
|
||||
} else {
|
||||
existing.as_ref().and_then(|portal| portal.enabled)
|
||||
};
|
||||
let clients = if self.vpn_portal_clients.is_empty() {
|
||||
existing.map_or_else(Vec::new, |portal| portal.clients)
|
||||
} else {
|
||||
@@ -1130,6 +1135,7 @@ impl NetworkOptions {
|
||||
};
|
||||
|
||||
cfg.set_vpn_portal_config(VpnPortalConfig {
|
||||
enabled,
|
||||
wireguard_listen,
|
||||
wireguard_private_key,
|
||||
clients,
|
||||
|
||||
@@ -255,7 +255,11 @@ fn configure_runtime_core_host_adapters(
|
||||
if host_config.vpn_portal_enabled {
|
||||
use crate::common::config::ConfigLoader as _;
|
||||
|
||||
if let Some(config) = global_ctx.config.get_vpn_portal_config() {
|
||||
if let Some(config) = global_ctx
|
||||
.config
|
||||
.get_vpn_portal_config()
|
||||
.filter(|config| config.enabled != Some(false))
|
||||
{
|
||||
adapters.vpn_portal = Some(crate::vpn_portal::wireguard::WireGuardPortalHost::new(
|
||||
global_ctx.clone(),
|
||||
config,
|
||||
|
||||
@@ -745,6 +745,7 @@ async fn credential_peer_reconnects_to_admin_with_portal_client_online() {
|
||||
|
||||
let admin_config = create_need_p2p_admin_config("udp");
|
||||
admin_config.set_vpn_portal_config(VpnPortalConfig {
|
||||
enabled: None,
|
||||
wireguard_listen: "0.0.0.0:22121".parse().unwrap(),
|
||||
wireguard_private_key: Some(BASE64_STANDARD.encode([42u8; 32])),
|
||||
clients: vec![VpnPortalClientConfig {
|
||||
|
||||
@@ -1866,6 +1866,7 @@ pub async fn wireguard_vpn_portal(#[values(true, false)] test_v6: bool) {
|
||||
}
|
||||
if config.get_inst_name() == "inst3" {
|
||||
config.set_vpn_portal_config(VpnPortalConfig {
|
||||
enabled: None,
|
||||
wireguard_listen: "0.0.0.0:22121".parse().unwrap(),
|
||||
wireguard_private_key: Some(BASE64_STANDARD.encode([42u8; 32])),
|
||||
clients: vec![VpnPortalClientConfig {
|
||||
@@ -1971,6 +1972,7 @@ pub async fn wireguard_vpn_portal_multi_client() {
|
||||
));
|
||||
if config.get_inst_name() == "inst3" {
|
||||
config.set_vpn_portal_config(VpnPortalConfig {
|
||||
enabled: None,
|
||||
wireguard_listen: "0.0.0.0:22121".parse().unwrap(),
|
||||
wireguard_private_key: Some(BASE64_STANDARD.encode([42u8; 32])),
|
||||
clients: vec![
|
||||
@@ -2111,6 +2113,7 @@ pub async fn wireguard_vpn_portal_client_roaming() {
|
||||
));
|
||||
if config.get_inst_name() == "inst3" {
|
||||
config.set_vpn_portal_config(VpnPortalConfig {
|
||||
enabled: None,
|
||||
wireguard_listen: "0.0.0.0:22121".parse().unwrap(),
|
||||
wireguard_private_key: Some(BASE64_STANDARD.encode([42u8; 32])),
|
||||
clients: vec![VpnPortalClientConfig {
|
||||
@@ -2261,6 +2264,7 @@ pub async fn wireguard_vpn_portal_dynamic_clients() {
|
||||
));
|
||||
if config.get_inst_name() == "inst3" {
|
||||
config.set_vpn_portal_config(VpnPortalConfig {
|
||||
enabled: None,
|
||||
wireguard_listen: "0.0.0.0:22121".parse().unwrap(),
|
||||
wireguard_private_key: Some(BASE64_STANDARD.encode([42u8; 32])),
|
||||
clients: vec![VpnPortalClientConfig {
|
||||
|
||||
@@ -409,6 +409,7 @@ mod tests {
|
||||
fn explicit_server_key_is_the_derivation_master() {
|
||||
let key = [9; 32];
|
||||
let config = VpnPortalConfig {
|
||||
enabled: None,
|
||||
wireguard_listen: "127.0.0.1:51820".parse().unwrap(),
|
||||
wireguard_private_key: Some(BASE64_STANDARD.encode(key)),
|
||||
clients: Vec::new(),
|
||||
@@ -419,6 +420,7 @@ mod tests {
|
||||
#[test]
|
||||
fn portal_key_has_no_network_secret_fallback() {
|
||||
let config = VpnPortalConfig {
|
||||
enabled: None,
|
||||
wireguard_listen: "127.0.0.1:51820".parse().unwrap(),
|
||||
wireguard_private_key: None,
|
||||
clients: Vec::new(),
|
||||
|
||||
Generated
+145
@@ -202,6 +202,9 @@ importers:
|
||||
ts-md5:
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1
|
||||
uuid:
|
||||
specifier: ^11.0.2
|
||||
version: 11.1.0
|
||||
vue:
|
||||
specifier: ^3.5.12
|
||||
version: 3.5.21(typescript@5.6.3)
|
||||
@@ -239,12 +242,18 @@ importers:
|
||||
vite-plugin-singlefile:
|
||||
specifier: ^2.0.3
|
||||
version: 2.3.0(rollup@4.50.1)(vite@5.4.21(@types/node@22.18.1))
|
||||
playwright:
|
||||
specifier: 1.61.0
|
||||
version: 1.61.0
|
||||
vue-tsc:
|
||||
specifier: ^2.1.10
|
||||
version: 2.2.12(typescript@5.6.3)
|
||||
|
||||
easytier-web/frontend-lib:
|
||||
dependencies:
|
||||
qrcode:
|
||||
specifier: 1.5.4
|
||||
version: 1.5.4
|
||||
'@primeuix/themes':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3
|
||||
@@ -294,6 +303,9 @@ importers:
|
||||
specifier: ^10.0.4
|
||||
version: 10.0.8(vue@3.5.21(typescript@5.6.3))
|
||||
devDependencies:
|
||||
'@types/qrcode':
|
||||
specifier: 1.5.6
|
||||
version: 1.5.6
|
||||
'@modyfi/vite-plugin-yaml':
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.1(rollup@4.50.1)(vite@5.4.21(@types/node@22.18.1))
|
||||
@@ -367,6 +379,56 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||
|
||||
camelcase@5.3.1:
|
||||
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
cliui@6.0.0:
|
||||
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||
|
||||
decamelize@1.2.0:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
dijkstrajs@1.0.3:
|
||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||
|
||||
pngjs@5.0.0:
|
||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
qrcode@1.5.4:
|
||||
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
require-main-filename@2.0.0:
|
||||
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||
|
||||
set-blocking@2.0.0:
|
||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||
|
||||
which-module@2.0.1:
|
||||
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
y18n@4.0.3:
|
||||
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
yargs@15.3.1:
|
||||
resolution: {integrity: sha512-92O1HWEjw27sBfgmXiixJWT5hRBp2eobqXicLtPBIDBhYB+1HpwZlXmbW2luivBJHBzki+7VyCLRtAkScbTBQA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@alloc/quick-lru@5.2.0':
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2914,6 +2976,11 @@ packages:
|
||||
resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -3681,6 +3748,16 @@ packages:
|
||||
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
playwright-core@1.61.0:
|
||||
resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.61.0:
|
||||
resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
postcss-import@15.1.0:
|
||||
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -4590,6 +4667,63 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
dependencies:
|
||||
'@types/node': 22.18.1
|
||||
|
||||
camelcase@5.3.1: {}
|
||||
|
||||
cliui@6.0.0:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 6.2.0
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
dijkstrajs@1.0.3: {}
|
||||
|
||||
pngjs@5.0.0: {}
|
||||
|
||||
qrcode@1.5.4:
|
||||
dependencies:
|
||||
dijkstrajs: 1.0.3
|
||||
pngjs: 5.0.0
|
||||
yargs: 15.3.1
|
||||
|
||||
require-main-filename@2.0.0: {}
|
||||
|
||||
set-blocking@2.0.0: {}
|
||||
|
||||
which-module@2.0.1: {}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
decamelize: 1.2.0
|
||||
|
||||
yargs@15.3.1:
|
||||
dependencies:
|
||||
cliui: 6.0.0
|
||||
decamelize: 1.2.0
|
||||
find-up: 4.1.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
require-main-filename: 2.0.0
|
||||
set-blocking: 2.0.0
|
||||
string-width: 4.2.3
|
||||
which-module: 2.0.1
|
||||
y18n: 4.0.3
|
||||
yargs-parser: 18.1.3
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@antfu/eslint-config@3.16.0(@typescript-eslint/utils@8.42.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.6.3))(@vue/compiler-sfc@3.5.21)(eslint-plugin-format@0.1.3(eslint@9.35.0(jiti@2.5.1)))(eslint@9.35.0(jiti@2.5.1))(typescript@5.6.3)(vitest@4.1.9(@types/node@22.18.1)(happy-dom@16.8.1)(vite@5.4.21(@types/node@22.18.1)))':
|
||||
@@ -7311,6 +7445,9 @@ snapshots:
|
||||
jsonfile: 6.2.0
|
||||
universalify: 2.0.1
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
@@ -8186,6 +8323,14 @@ snapshots:
|
||||
|
||||
pluralize@8.0.0: {}
|
||||
|
||||
playwright-core@1.61.0: {}
|
||||
|
||||
playwright@1.61.0:
|
||||
dependencies:
|
||||
playwright-core: 1.61.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
postcss-import@15.1.0(postcss@8.5.6):
|
||||
dependencies:
|
||||
postcss: 8.5.6
|
||||
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user