From 567208f418c3582dfe833209ce587023bc8e807b Mon Sep 17 00:00:00 2001 From: sunlei Date: Thu, 23 Jul 2026 00:44:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E9=80=9A=E7=94=A8?= =?UTF-8?q?=E7=BD=91=E7=BB=9C=E7=AB=AF=E5=8F=A3=E8=BD=AC=E5=8F=91=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 9 + API.md | 26 + Jenkinsfile | 7 + README.md | 5 +- package.json | 2 + pnpm-lock.yaml | 157 ++-- sql/network-management-init.sql | 82 ++ sql/network-management-menu.sql | 65 ++ sql/refactor-v3/00-full-schema.sql | 69 ++ sql/refactor-v3/01-seed-core.sql | 19 + sql/refactor-v3/99-verify.sql | 20 + sql/vben-admin-init.sql | 25 +- .../admin-platform-config.module.ts | 17 +- .../network-agent-mqtt.service.ts | 654 +++++++++++++++ .../network-agent-state.entity.ts | 62 ++ .../network-endpoint-history.entity.ts | 55 ++ .../network-management.controller.ts | 169 ++++ .../network-management.dto.ts | 188 +++++ .../network-management.entity.ts | 127 +++ .../network-management.service.ts | 580 +++++++++++++ .../network-management.types.ts | 776 ++++++++++++++++++ src/runtime/config/runtime-config.service.ts | 7 + .../network-agent-mqtt.service.spec.ts | 701 ++++++++++++++++ .../network-management-deployment.spec.ts | 43 + .../network-management.controller.spec.ts | 163 ++++ .../network-management.entity.spec.ts | 45 + .../network-management.service.spec.ts | 393 +++++++++ .../network-management.types.spec.ts | 184 +++++ test/modules/admin/admin-contract.spec.ts | 10 + test/runtime/runtime-config.service.spec.ts | 39 + 30 files changed, 4640 insertions(+), 59 deletions(-) create mode 100644 sql/network-management-init.sql create mode 100644 sql/network-management-menu.sql create mode 100644 src/modules/admin/platform-config/network-management/network-agent-mqtt.service.ts create mode 100644 src/modules/admin/platform-config/network-management/network-agent-state.entity.ts create mode 100644 src/modules/admin/platform-config/network-management/network-endpoint-history.entity.ts create mode 100644 src/modules/admin/platform-config/network-management/network-management.controller.ts create mode 100644 src/modules/admin/platform-config/network-management/network-management.dto.ts create mode 100644 src/modules/admin/platform-config/network-management/network-management.entity.ts create mode 100644 src/modules/admin/platform-config/network-management/network-management.service.ts create mode 100644 src/modules/admin/platform-config/network-management/network-management.types.ts create mode 100644 test/admin/network-management/network-agent-mqtt.service.spec.ts create mode 100644 test/admin/network-management/network-management-deployment.spec.ts create mode 100644 test/admin/network-management/network-management.controller.spec.ts create mode 100644 test/admin/network-management/network-management.entity.spec.ts create mode 100644 test/admin/network-management/network-management.service.spec.ts create mode 100644 test/admin/network-management/network-management.types.spec.ts diff --git a/.env.example b/.env.example index 4257524..c4cdf4b 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,15 @@ ADMIN_COOKIE_SECURE=false SNOWFLAKE_WORKER_ID=1 SNOWFLAKE_DATACENTER_ID=1 +# System network-management control plane; credentials come from runtime Secret. +NETWORK_AGENT_ID=nas-main +NETWORK_AGENT_TARGET_IPV4=192.168.31.224 +NETWORK_AGENT_MQTT_URL= +NETWORK_AGENT_MQTT_CLIENT_ID=kt-template-online-api-network-nas-main +NETWORK_AGENT_MQTT_USERNAME= +NETWORK_AGENT_MQTT_PASSWORD= +NETWORK_AGENT_MQTT_RETRY_MS=5000 + LOG_LEVEL=info LOG_APP_NAME=kt-template-online-api LOG_PRETTY=true diff --git a/API.md b/API.md index f338a48..cfb44fb 100644 --- a/API.md +++ b/API.md @@ -88,6 +88,31 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管 当前版本只提供观测和只读自检。重启 Pod、触发 Jenkins 部署、执行迁移、重建 NapCat 容器、启停插件、立即执行插件任务、修改 Caddy/OpenClash/WireGuard/Tencent Cloud 等高风险能力只会以禁用动作展示,后端不提供通用写动作入口。 +## System 网络端口转发管理 + +| 方法 | 路径 | 认证 | 说明 | +| -------- | -------------------------------------------------------------- | ------- | ----------------------------------------- | +| `GET` | `/system/network/port-forward/list` | `super` | 分页查询期望、同步、Keeper 与端点租约状态 | +| `POST` | `/system/network/port-forward` | `super` | 新增 TCP/UDP 期望记录 | +| `PUT` | `/system/network/port-forward/:id` | `super` | 修改名称、协议和端口期望 | +| `DELETE` | `/system/network/port-forward/:id` | `super` | 写入 absent tombstone,等待 Agent 确认 | +| `POST` | `/system/network/port-forward/:id/retry` | `super` | 提升 revision 并重试协调 | +| `POST` | `/system/network/port-forward/:id/keeper/enable` | `super` | 启用同源端口 UDP Keeper 并立即探测 | +| `POST` | `/system/network/port-forward/:id/keeper/disable` | `super` | 停用 UDP Keeper 并撤下当前端点 | +| `POST` | `/system/network/port-forward/:id/probe` | `super` | 为已启用 Keeper 生成新 probeRequestId | +| `GET` | `/system/network/port-forward/:id/endpoint-history` | `super` | 查询端点状态变化历史 | +| `GET` | `/system/network/agent/status` | `super` | 查询 Agent 在线与 revision 收敛状态 | + +新增和修改请求只接受名称、备注、`tcp|udp`、外部端口和内部端口;目标 NAS IPv4 固定来自 `NETWORK_AGENT_TARGET_IPV4`,请求体中的未知字段会返回 400。Snowflake ID 与 revision 在 HTTP JSON 中保留为字符串。所有动态响应设置 `Cache-Control: no-store`。 + +端点历史行使用 `portForwardId` 关联端口转发,撤下原因返回为 `withdrawalReason`。Agent 状态同时返回统一的 `lastErrorCode/lastErrorMessage`(reconciliation 优先于 MQTT)和细分错误字段,便于 Admin 展示与诊断。 + +API 数据库是唯一事实源。每次合法期望变更在同一事务中锁定 `network_agent_state`、全局 revision 只增加一次并保存稳定 `desiredIssuedAt`;事务提交后再发布 `kt/network/v1/agents/{agentId}/desired` 的 QoS 1 retained 完整快照。PUBACK 只推进 `publishedRevision`,Agent 的完整 `reported` 才推进 `appliedRevision` 和逐条实际状态。MQTT 或 Agent 离线不回滚已接受的期望状态。 + +Wire contract 采用 `kt-network-agent/internal/contract` schema-v1:desired mapping 使用 `state=present|absent`;reported 使用 `appliedRevision`、`desiredDigest`、helper 状态和逐条 router/route/Keeper 证据;endpoint event 使用唯一 `eventId`。API 不发布数据库备注,也不在 MQTT、HTTP 或数据库中接收/保存路由器密码和 token。事件按 `eventId` 幂等追加;删除只有在 Agent 明确回报 absent、synced、router/route 均不存在、Keeper 期望关闭且实际 disabled、current endpoint 为空,并且 helper 已确认且 `helperAppliedRevision === appliedRevision` 后才完成,随后生成新 revision 移除 tombstone。 + +TCP 记录支持 API CRUD,但不提供 STUN/Keeper;当前已验证的 Agent 切片尚未启用 TCP 路由器写入,会明确回报 `tcp_router_write_gated`,不能把 pending/failed TCP 记录描述为已生效转发。UDP 只有 `externalPort === internalPort` 时可启用 Keeper。当前端点只有在 `currentValidUntil` 未过期时才返回为可用值;租约过期不会删除最近观测或 `network_endpoint_history`。API 不直接访问小米路由器、不修改 NAS 路由;真实路由器、raw UDP 与回程规则只由固定 NAS Agent/helper 处理。 + ## 环境变量分组 | 分组 | 关键变量 | @@ -101,6 +126,7 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管 | NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` | | MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` | | Env Dashboard | `ENV_DASHBOARD_CACHE_TTL_MS`、`ENV_DASHBOARD_SIGNAL_TIMEOUT_MS`、`ENV_DASHBOARD_EVENT_BUS`、`ENV_DASHBOARD_MQTT_*`、`ENV_DASHBOARD_SSE_*`、`ENV_DASHBOARD_JENKINS_*`、`ENV_DASHBOARD_K8S_*`、`ENV_DASHBOARD_TENCENT_*`、`ENV_DASHBOARD_CADDY_*`、`ENV_DASHBOARD_R4SE_*` | +| Network | `NETWORK_AGENT_ID`、`NETWORK_AGENT_TARGET_IPV4`、`NETWORK_AGENT_MQTT_URL`、`NETWORK_AGENT_MQTT_CLIENT_ID`、`NETWORK_AGENT_MQTT_USERNAME`、`NETWORK_AGENT_MQTT_PASSWORD`、`NETWORK_AGENT_MQTT_RETRY_MS` | | BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` | | FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_DEFAULT_WORLD` | | FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` | diff --git a/Jenkinsfile b/Jenkinsfile index 213dfb1..03b2e00 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -39,6 +39,13 @@ def requiredRuntimeEnvKeys() { 'QQBOT_PLUGIN_QUEUE_REDIS_HOST', 'QQBOT_PLUGIN_QUEUE_REDIS_PORT', 'NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET', + 'NETWORK_AGENT_ID', + 'NETWORK_AGENT_TARGET_IPV4', + 'NETWORK_AGENT_MQTT_URL', + 'NETWORK_AGENT_MQTT_CLIENT_ID', + 'NETWORK_AGENT_MQTT_USERNAME', + 'NETWORK_AGENT_MQTT_PASSWORD', + 'NETWORK_AGENT_MQTT_RETRY_MS', ] } diff --git a/README.md b/README.md index 21038e3..6986f5e 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ | 模块 | 说明 | | ------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志、环境总览面板 | +| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志、环境总览面板和网络端口映射管理 | | `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 | | `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 | | `qqbot` | QQBot 账号、NapCat 扫码登录、运行态 Profile、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 | @@ -65,6 +65,7 @@ ci/ Jenkins Agent/Docker 辅助文件 | Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` | | QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_*`、`QQBOT_SEND_*`、`QQBOT_PLUGIN_QUEUE_REDIS_*`、`QQBOT_PLUGIN_TASK_QUEUE_REDIS_*`、`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` | | Environment Dashboard | `ENV_DASHBOARD_CACHE_TTL_MS`、`ENV_DASHBOARD_SIGNAL_TIMEOUT_MS`、`ENV_DASHBOARD_EVENT_BUS`、`ENV_DASHBOARD_MQTT_*`、`ENV_DASHBOARD_SSE_*`、`ENV_DASHBOARD_JENKINS_*`、`ENV_DASHBOARD_K8S_*`、`ENV_DASHBOARD_TENCENT_*`、`ENV_DASHBOARD_CADDY_*`、`ENV_DASHBOARD_R4SE_*` | +| Network Management | `NETWORK_AGENT_ID`、`NETWORK_AGENT_TARGET_IPV4`、`NETWORK_AGENT_MQTT_URL`、`NETWORK_AGENT_MQTT_CLIENT_ID`、`NETWORK_AGENT_MQTT_USERNAME`、`NETWORK_AGENT_MQTT_PASSWORD`、`NETWORK_AGENT_MQTT_RETRY_MS` | | BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` | | FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_MARKET_CACHE_TTL_MS` | | FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` | @@ -79,6 +80,8 @@ QQBot 插件定时任务由 manifest 的 `tasks` 声明,平台持久化到 `qq Admin 环境总览面板使用 `ENV_DASHBOARD_*` 只读配置聚合 local-dev、NAS 线上、腾讯云和 r4se 状态。`ENV_DASHBOARD_ADMIN_LOCAL_URL` / `ENV_DASHBOARD_ADMIN_PUBLIC_URL` 只用于展示 Admin 本机与线上入口证据。HTTP 快照提供当前拓扑,后端 local/MQTT 事件总线通过 SSE 推送增量事件给 Admin;前端不直连 MQTT,也不轮询刷新。Jenkins、K8s、Tencent Cloud、Caddy、WireGuard、Mihomo/OpenClash 未配置时会显示 `unwired` 证据,不能渲染成健康假象;第一版不暴露重启、部署、迁移、容器重建、插件启停或代理切换等写操作。 +System 网络管理以 MySQL 中的 TCP/UDP 端口转发期望状态为唯一事实源。`super` 通过统一 CRUD 和 UDP Keeper 动作修改期望状态;API 在事务内单调提升 revision,提交后使用固定 `kt/network/v1/agents/{agentId}` MQTT topic、QoS 1 retained 完整快照通知 NAS `kt-network-agent`,自身不登录路由器、不接收路由器密码,也不执行 raw socket。Agent 失联或 MQTT 暂不可用时合法请求仍保存为 pending,恢复后按 revision 自动收敛;消费端发生瞬时数据库错误或 SUBACK 失败时主动重连并依赖 broker 重投,非法负载则确认后丢弃,避免 poison message 阻塞。TCP 目前仅保存 CRUD 期望,真实路由器写入仍受设备协议证据门禁并回报 `tcp_router_write_gated`;只有外部端口等于内部端口的 UDP 记录允许启停 Keeper 和立即探测。当前公网端点受 `currentValidUntil` 租约约束,过期后列表隐藏当前值但保留最近观测与历史。生产发布同时把完整 `NETWORK_AGENT_*` 连接配置作为 Jenkins 私有 env 和 `/health/runtime` 必需项,任一项缺失时拒绝发布或报告运行态阻断,避免页面可见但 MQTT 控制链路未接线。 + NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/历史登录事件兼容表/风险模式表,真实物理设备风格 hostname/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v20 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后标记 native login service stale;API 在源 Docker 容器在线但 WebUI 明确 QQ 离线时会同容器调用 `RestartNapCat` 重启 NapCat worker,重建 QQCore login service 后再推进 quick/password/qrcode,不做 Docker 重建、补 env 或设备身份迁移,且同一个更新登录 session 只消费一次 worker restart 预算;v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露;v15 修复扫码成功时 `QQLoginInfo` 晚于登录态写入造成的 QQ 号回读空窗;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛;v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性;v19 保留 WebUI `RestartNapCat` 重启 worker 时的 `-q ` 快速登录参数,避免重启后退回无账号扫码;v20 保护 API 预写的 `/app/napcat/config`,避免上游首次解包 `NapCat.Shell/*` 覆盖 `bypass.*=true` 与 `o3HookMode=0`。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v20` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v20` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 运行时发布时,API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata,包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。 diff --git a/package.json b/package.json index 8cb8d0a..cf53fcf 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ "bullmq": "5.78.1", "chart.js": "^4.5.1", "chartjs-adapter-moment": "^1.0.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", "cron-parser": "4.9.0", "cross-env": "^7.0.3", "express": "5.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58dd757..985f660 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,31 +10,31 @@ importers: dependencies: '@kwitsukasa/knife4j-swagger-vue3': specifier: 0.1.2 - version: 0.1.2(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)) + version: 0.1.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) '@nestjs-modules/ioredis': specifier: ^2.2.1 - version: 2.2.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))))(ioredis@5.11.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))) + version: 2.2.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))))(ioredis@5.11.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))) '@nestjs/bullmq': specifier: 11.0.4 - version: 11.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(bullmq@5.78.1) + version: 11.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(bullmq@5.78.1) '@nestjs/common': specifier: ^11.1.24 - version: 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/config': specifier: ^4.0.4 - version: 4.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + version: 4.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': specifier: ^11.1.24 - version: 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': specifier: ^11.1.24 - version: 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) '@nestjs/swagger': specifier: ^11.4.4 - version: 11.4.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) + version: 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/typeorm': specifier: ^11.0.1 - version: 11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))) + version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))) axios: specifier: ^1.17.0 version: 1.17.0 @@ -47,6 +47,12 @@ importers: chartjs-adapter-moment: specifier: ^1.0.1 version: 1.0.1(chart.js@4.5.1)(moment@2.30.1) + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.2 + version: 0.14.4 cron-parser: specifier: 4.9.0 version: 4.9.0 @@ -76,10 +82,10 @@ importers: version: 3.22.4(@types/node@22.19.19) nestjs-minio-client: specifier: ^2.2.0 - version: 2.2.0(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + version: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) nestjs-pino: specifier: ^4.6.1 - version: 4.6.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2) + version: 4.6.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2) pino: specifier: ^10.3.1 version: 10.3.1 @@ -158,7 +164,7 @@ importers: version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.1.24 - version: 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-express@11.1.24) + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-express@11.1.24) '@types/express': specifier: ^5.0.6 version: 5.0.6 @@ -1370,6 +1376,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -1901,6 +1910,12 @@ packages: cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + class-validator@0.14.4: + resolution: {integrity: sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==} + cli-boxes@2.2.1: resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} engines: {node: '>=6'} @@ -3111,6 +3126,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.13.9: + resolution: {integrity: sha512-VNS5vWMM7r0P66BYv+TQJATxExEgLxN+34hfHDVhDkUsGAE4cRg0shCNSLTXNKm7nIUscC7AfB51TjxEeF7msQ==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -4623,6 +4641,10 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + validator@13.15.35: + resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} + engines: {node: '>= 0.10'} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -5698,9 +5720,9 @@ snapshots: '@kurkle/color@0.3.4': {} - '@kwitsukasa/knife4j-swagger-vue3@0.1.2(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))': + '@kwitsukasa/knife4j-swagger-vue3@0.1.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))': dependencies: - '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@lukeed/csprng@1.1.0': {} @@ -5739,13 +5761,13 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': optional: true - '@nestjs-modules/ioredis@2.2.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))))(ioredis@5.11.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))': + '@nestjs-modules/ioredis@2.2.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))))(ioredis@5.11.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))': dependencies: - '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) ioredis: 5.11.1 optionalDependencies: - '@nestjs/terminus': 11.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))))(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))) + '@nestjs/terminus': 11.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))))(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))) transitivePeerDependencies: - '@grpc/grpc-js' - '@grpc/proto-loader' @@ -5763,17 +5785,17 @@ snapshots: - sequelize - typeorm - '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)': + '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)': dependencies: - '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(bullmq@5.78.1)': + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(bullmq@5.78.1)': dependencies: - '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) - '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) bullmq: 5.78.1 tslib: 2.8.1 @@ -5813,7 +5835,7 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.3.4 iterare: 1.2.1 @@ -5822,20 +5844,23 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 transitivePeerDependencies: - supports-color - '@nestjs/config@4.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + '@nestjs/config@4.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) dotenv: 17.4.1 dotenv-expand: 12.0.3 lodash: 4.18.1 rxjs: 7.8.2 - '@nestjs/core@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -5845,17 +5870,20 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + '@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) - '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)': + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 - '@nestjs/platform-express@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)': + '@nestjs/platform-express@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)': dependencies: - '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.6 express: 5.2.1 multer: 2.1.1 @@ -5877,43 +5905,46 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/swagger@11.4.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)': + '@nestjs/swagger@11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': dependencies: '@microsoft/tsdoc': 0.16.0 - '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) js-yaml: 4.1.1 lodash: 4.18.1 path-to-regexp: 8.4.2 reflect-metadata: 0.2.2 swagger-ui-dist: 5.32.6 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 - '@nestjs/terminus@11.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))))(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))': + '@nestjs/terminus@11.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))))(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))': dependencies: - '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) boxen: 5.1.2 check-disk-space: 3.4.0 reflect-metadata: 0.2.2 rxjs: 7.8.2 optionalDependencies: - '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))) + '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))) typeorm: 0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)) optional: true - '@nestjs/testing@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-express@11.1.24)': + '@nestjs/testing@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-express@11.1.24)': dependencies: - '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + '@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) - '@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))': + '@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))': dependencies: - '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 rxjs: 7.8.2 typeorm: 0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)) @@ -6173,6 +6204,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/validator@13.15.10': {} + '@types/ws@8.18.1': dependencies: '@types/node': 22.19.19 @@ -6779,6 +6812,14 @@ snapshots: cjs-module-lexer@1.4.3: {} + class-transformer@0.5.1: {} + + class-validator@0.14.4: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.13.9 + validator: 13.15.35 + cli-boxes@2.2.1: optional: true @@ -8276,6 +8317,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.13.9: {} + lines-and-columns@1.2.4: {} load-bmfont@1.4.2: @@ -8840,17 +8883,17 @@ snapshots: neo-async@2.6.2: {} - nestjs-minio-client@2.2.0(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24): + nestjs-minio-client@2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24): dependencies: - '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) minio: 7.1.3 reflect-metadata: 0.1.14 rxjs: 7.8.2 - nestjs-pino@4.6.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2): + nestjs-pino@4.6.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2): dependencies: - '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) pino: 10.3.1 pino-http: 11.0.0 rxjs: 7.8.2 @@ -9977,6 +10020,8 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + validator@13.15.35: {} + vary@1.1.2: {} vfile-location@5.0.3: diff --git a/sql/network-management-init.sql b/sql/network-management-init.sql new file mode 100644 index 0000000..46edb65 --- /dev/null +++ b/sql/network-management-init.sql @@ -0,0 +1,82 @@ +-- 通用端口转发控制面数据表与单 Agent 初始状态。 + +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS `network_port_forward` ( + `id` BIGINT NOT NULL, + `name` VARCHAR(100) NOT NULL, + `remark` TEXT NULL, + `protocol` VARCHAR(8) NOT NULL, + `external_port` INT UNSIGNED NOT NULL, + `internal_port` INT UNSIGNED NOT NULL, + `active_key` VARCHAR(32) NULL, + `target_ipv4` VARCHAR(15) NOT NULL, + `desired_presence` VARCHAR(16) NOT NULL DEFAULT 'present', + `keeper_desired_enabled` TINYINT(1) NOT NULL DEFAULT 0, + `probe_request_id` VARCHAR(64) NULL, + `desired_revision` BIGINT NOT NULL DEFAULT 0, + `desired_issued_at` DATETIME(6) NOT NULL, + `reported_revision` BIGINT NOT NULL DEFAULT 0, + `sync_status` VARCHAR(16) NOT NULL DEFAULT 'pending', + `keeper_status` VARCHAR(16) NOT NULL DEFAULT 'disabled', + `current_public_ipv4` VARCHAR(15) NULL, + `current_public_port` INT NULL, + `current_observed_at` DATETIME(6) NULL, + `current_valid_until` DATETIME(6) NULL, + `last_observed_ipv4` VARCHAR(15) NULL, + `last_observed_port` INT NULL, + `last_observed_at` DATETIME(6) NULL, + `last_error_code` VARCHAR(64) NULL, + `last_error_message` VARCHAR(512) NULL, + `is_deleted` TINYINT(1) NOT NULL DEFAULT 0, + `create_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_network_port_forward_active_key` (`active_key`), + KEY `idx_network_port_forward_status` (`is_deleted`, `sync_status`), + KEY `idx_network_port_forward_protocol` (`protocol`, `external_port`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `network_agent_state` ( + `agent_id` VARCHAR(64) NOT NULL, + `target_ipv4` VARCHAR(15) NOT NULL, + `desired_revision` BIGINT NOT NULL DEFAULT 0, + `desired_issued_at` DATETIME(6) NOT NULL, + `published_revision` BIGINT NOT NULL DEFAULT 0, + `applied_revision` BIGINT NOT NULL DEFAULT 0, + `online` TINYINT(1) NOT NULL DEFAULT 0, + `version` VARCHAR(64) NULL, + `started_at` DATETIME(6) NULL, + `last_heartbeat_at` DATETIME(6) NULL, + `last_mqtt_error_code` VARCHAR(64) NULL, + `last_mqtt_error_message` VARCHAR(500) NULL, + `last_reconcile_error_code` VARCHAR(64) NULL, + `last_reconcile_error_message` VARCHAR(500) NULL, + `create_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`agent_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `network_endpoint_history` ( + `id` BIGINT NOT NULL, + `event_id` VARCHAR(128) NOT NULL, + `mapping_id` BIGINT NOT NULL, + `event_type` VARCHAR(16) NOT NULL, + `public_ipv4` VARCHAR(15) NULL, + `public_port` INT NULL, + `first_observed_at` DATETIME(6) NOT NULL, + `last_observed_at` DATETIME(6) NOT NULL, + `occurred_at` DATETIME(6) NOT NULL, + `reason` VARCHAR(128) NULL, + `create_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_network_endpoint_history_event_id` (`event_id`), + KEY `idx_network_endpoint_history_mapping` (`mapping_id`, `occurred_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO `network_agent_state` ( + `agent_id`, `target_ipv4`, `desired_revision`, `desired_issued_at`, `published_revision`, `applied_revision`, `online` +) +VALUES ('nas-main', '192.168.31.224', 0, CURRENT_TIMESTAMP(6), 0, 0, 0) +ON DUPLICATE KEY UPDATE + `agent_id` = VALUES(`agent_id`); diff --git a/sql/network-management-menu.sql b/sql/network-management-menu.sql new file mode 100644 index 0000000..8ac841f --- /dev/null +++ b/sql/network-management-menu.sql @@ -0,0 +1,65 @@ +-- 增量初始化通用网络端口转发菜单;仅授予启用中的超级管理员。 + +SET NAMES utf8mb4; + +INSERT INTO `admin_menu` ( + `id`, `pid`, `name`, `path`, `component`, `redirect`, `auth_code`, `type`, `meta`, `status`, `sort` +) +VALUES + (2041700000000100207, 2041700000000100002, 'SystemNetwork', '/system/network', '/system/network/list', NULL, NULL, 'menu', '{"icon":"lucide:router","title":"system.network.title"}', 1, 8), + (2041700000000120214, 2041700000000100207, 'SystemNetworkPortForwardList', NULL, NULL, NULL, 'System:Network:PortForward:List', 'button', '{"title":"common.list"}', 1, 1), + (2041700000000120215, 2041700000000100207, 'SystemNetworkPortForwardCreate', NULL, NULL, NULL, 'System:Network:PortForward:Create', 'button', '{"title":"common.create"}', 1, 2), + (2041700000000120216, 2041700000000100207, 'SystemNetworkPortForwardUpdate', NULL, NULL, NULL, 'System:Network:PortForward:Update', 'button', '{"title":"common.edit"}', 1, 3), + (2041700000000120217, 2041700000000100207, 'SystemNetworkPortForwardDelete', NULL, NULL, NULL, 'System:Network:PortForward:Delete', 'button', '{"title":"common.delete"}', 1, 4), + (2041700000000120218, 2041700000000100207, 'SystemNetworkPortForwardRetry', NULL, NULL, NULL, 'System:Network:PortForward:Retry', 'button', '{"title":"common.retry"}', 1, 5), + (2041700000000120219, 2041700000000100207, 'SystemNetworkPortForwardKeeper', NULL, NULL, NULL, 'System:Network:PortForward:Keeper', 'button', '{"title":"system.network.keeper"}', 1, 6), + (2041700000000120220, 2041700000000100207, 'SystemNetworkPortForwardProbe', NULL, NULL, NULL, 'System:Network:PortForward:Probe', 'button', '{"title":"system.network.probe"}', 1, 7), + (2041700000000120221, 2041700000000100207, 'SystemNetworkPortForwardHistory', NULL, NULL, NULL, 'System:Network:PortForward:History', 'button', '{"title":"system.network.history"}', 1, 8) +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `pid` = VALUES(`pid`), + `path` = VALUES(`path`), + `component` = VALUES(`component`), + `redirect` = VALUES(`redirect`), + `auth_code` = VALUES(`auth_code`), + `type` = VALUES(`type`), + `meta` = VALUES(`meta`), + `status` = VALUES(`status`), + `sort` = VALUES(`sort`), + `is_deleted` = 0; + +DELETE role_menu +FROM `admin_role_menu` role_menu +JOIN `admin_role` role ON role.`id` = role_menu.`role_id` +JOIN `admin_menu` menu ON menu.`id` = role_menu.`menu_id` +WHERE role.`role_code` <> 'super' + AND menu.`name` IN ( + 'SystemNetwork', + 'SystemNetworkPortForwardList', + 'SystemNetworkPortForwardCreate', + 'SystemNetworkPortForwardUpdate', + 'SystemNetworkPortForwardDelete', + 'SystemNetworkPortForwardRetry', + 'SystemNetworkPortForwardKeeper', + 'SystemNetworkPortForwardProbe', + 'SystemNetworkPortForwardHistory' + ); + +INSERT IGNORE INTO `admin_role_menu` (`role_id`, `menu_id`) +SELECT role.`id`, menu.`id` +FROM `admin_role` role +JOIN `admin_menu` menu ON menu.`name` IN ( + 'SystemNetwork', + 'SystemNetworkPortForwardList', + 'SystemNetworkPortForwardCreate', + 'SystemNetworkPortForwardUpdate', + 'SystemNetworkPortForwardDelete', + 'SystemNetworkPortForwardRetry', + 'SystemNetworkPortForwardKeeper', + 'SystemNetworkPortForwardProbe', + 'SystemNetworkPortForwardHistory' +) +WHERE role.`role_code` = 'super' + AND role.`status` = 1 + AND role.`is_deleted` = 0 + AND menu.`is_deleted` = 0; diff --git a/sql/refactor-v3/00-full-schema.sql b/sql/refactor-v3/00-full-schema.sql index 69cf956..2b431ed 100644 --- a/sql/refactor-v3/00-full-schema.sql +++ b/sql/refactor-v3/00-full-schema.sql @@ -126,6 +126,75 @@ CREATE TABLE IF NOT EXISTS admin_notice ( KEY idx_admin_notice_last_seen (last_seen_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS network_port_forward ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + remark TEXT NULL, + protocol VARCHAR(8) NOT NULL, + external_port INT UNSIGNED NOT NULL, + internal_port INT UNSIGNED NOT NULL, + active_key VARCHAR(32) NULL, + target_ipv4 VARCHAR(15) NOT NULL, + desired_presence VARCHAR(16) NOT NULL DEFAULT 'present', + keeper_desired_enabled TINYINT(1) NOT NULL DEFAULT 0, + probe_request_id VARCHAR(64) NULL, + desired_revision BIGINT NOT NULL DEFAULT 0, + desired_issued_at DATETIME(6) NOT NULL, + reported_revision BIGINT NOT NULL DEFAULT 0, + sync_status VARCHAR(16) NOT NULL DEFAULT 'pending', + keeper_status VARCHAR(16) NOT NULL DEFAULT 'disabled', + current_public_ipv4 VARCHAR(15) NULL, + current_public_port INT NULL, + current_observed_at DATETIME(6) NULL, + current_valid_until DATETIME(6) NULL, + last_observed_ipv4 VARCHAR(15) NULL, + last_observed_port INT NULL, + last_observed_at DATETIME(6) NULL, + last_error_code VARCHAR(64) NULL, + last_error_message VARCHAR(512) NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + UNIQUE KEY uk_network_port_forward_active_key (active_key), + KEY idx_network_port_forward_status (is_deleted, sync_status), + KEY idx_network_port_forward_protocol (protocol, external_port) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS network_agent_state ( + agent_id VARCHAR(64) NOT NULL PRIMARY KEY, + target_ipv4 VARCHAR(15) NOT NULL, + desired_revision BIGINT NOT NULL DEFAULT 0, + desired_issued_at DATETIME(6) NOT NULL, + published_revision BIGINT NOT NULL DEFAULT 0, + applied_revision BIGINT NOT NULL DEFAULT 0, + online TINYINT(1) NOT NULL DEFAULT 0, + version VARCHAR(64) NULL, + started_at DATETIME(6) NULL, + last_heartbeat_at DATETIME(6) NULL, + last_mqtt_error_code VARCHAR(64) NULL, + last_mqtt_error_message VARCHAR(500) NULL, + last_reconcile_error_code VARCHAR(64) NULL, + last_reconcile_error_message VARCHAR(500) NULL, + create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS network_endpoint_history ( + id BIGINT NOT NULL PRIMARY KEY, + event_id VARCHAR(128) NOT NULL, + mapping_id BIGINT NOT NULL, + event_type VARCHAR(16) NOT NULL, + public_ipv4 VARCHAR(15) NULL, + public_port INT NULL, + first_observed_at DATETIME(6) NOT NULL, + last_observed_at DATETIME(6) NOT NULL, + occurred_at DATETIME(6) NOT NULL, + reason VARCHAR(128) NULL, + create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + UNIQUE KEY uk_network_endpoint_history_event_id (event_id), + KEY idx_network_endpoint_history_mapping (mapping_id, occurred_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS admin_user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, diff --git a/sql/refactor-v3/01-seed-core.sql b/sql/refactor-v3/01-seed-core.sql index f731a84..2175ad1 100644 --- a/sql/refactor-v3/01-seed-core.sql +++ b/sql/refactor-v3/01-seed-core.sql @@ -65,6 +65,25 @@ INSERT INTO admin_user ( status = VALUES(status), is_deleted = 0; +INSERT INTO network_agent_state ( + agent_id, + target_ipv4, + desired_revision, + desired_issued_at, + published_revision, + applied_revision, + online +) VALUES ( + 'nas-main', + '192.168.31.224', + 0, + CURRENT_TIMESTAMP(6), + 0, + 0, + 0 +) ON DUPLICATE KEY UPDATE + agent_id = VALUES(agent_id); + INSERT INTO admin_menu ( id, pid, diff --git a/sql/refactor-v3/99-verify.sql b/sql/refactor-v3/99-verify.sql index aed6341..fa5bf50 100644 --- a/sql/refactor-v3/99-verify.sql +++ b/sql/refactor-v3/99-verify.sql @@ -1,6 +1,9 @@ SELECT 'admin_user' AS table_name, COUNT(*) AS row_count FROM admin_user; SELECT 'admin_role' AS table_name, COUNT(*) AS row_count FROM admin_role; SELECT 'admin_menu' AS table_name, COUNT(*) AS row_count FROM admin_menu; +SELECT 'network_port_forward' AS table_name, COUNT(*) AS row_count FROM network_port_forward; +SELECT 'network_agent_state' AS table_name, COUNT(*) AS row_count FROM network_agent_state; +SELECT 'network_endpoint_history' AS table_name, COUNT(*) AS row_count FROM network_endpoint_history; SELECT 'platform_setting' AS table_name, COUNT(*) AS row_count FROM platform_setting; SELECT 'admin_dict' AS table_name, COUNT(*) AS row_count FROM admin_dict; SELECT 'qqbot_command' AS table_name, COUNT(*) AS row_count FROM qqbot_command; @@ -36,6 +39,23 @@ FROM platform_setting WHERE setting_key = 'schema.version' AND setting_value = 'refactor-v3'; +SELECT 'seed_network_agent_state' AS check_name, COUNT(*) AS matched_rows +FROM network_agent_state +WHERE agent_id = 'nas-main' + AND target_ipv4 = '192.168.31.224'; + +SELECT 'index_network_port_forward_active_key' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'network_port_forward' + AND index_name = 'uk_network_port_forward_active_key'; + +SELECT 'index_network_endpoint_history_event_id' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'network_endpoint_history' + AND index_name = 'uk_network_endpoint_history_event_id'; + SELECT 'seed_qqbot_plugin_bangdream' AS check_name, COUNT(*) AS matched_rows FROM qqbot_plugin WHERE plugin_key = 'bangdream' diff --git a/sql/vben-admin-init.sql b/sql/vben-admin-init.sql index 7b6c7d9..e67fe96 100644 --- a/sql/vben-admin-init.sql +++ b/sql/vben-admin-init.sql @@ -239,6 +239,15 @@ VALUES (2041700000000100206, 2041700000000100002, 'SystemNotice', '/system/notice', '/system/notice/list', NULL, 'System:Notice:List', 'menu', '{"icon":"mdi:bell-outline","title":"system.notice.title"}', 1, 7), (2041700000000120212, 2041700000000100206, 'SystemNoticeEdit', NULL, NULL, NULL, 'System:Notice:Edit', 'button', '{"title":"system.notice.markHandled"}', 1, 1), (2041700000000120213, 2041700000000100206, 'SystemNoticeDelete', NULL, NULL, NULL, 'System:Notice:Delete', 'button', '{"title":"common.delete"}', 1, 2), + (2041700000000100207, 2041700000000100002, 'SystemNetwork', '/system/network', '/system/network/list', NULL, NULL, 'menu', '{"icon":"lucide:router","title":"system.network.title"}', 1, 8), + (2041700000000120214, 2041700000000100207, 'SystemNetworkPortForwardList', NULL, NULL, NULL, 'System:Network:PortForward:List', 'button', '{"title":"common.list"}', 1, 1), + (2041700000000120215, 2041700000000100207, 'SystemNetworkPortForwardCreate', NULL, NULL, NULL, 'System:Network:PortForward:Create', 'button', '{"title":"common.create"}', 1, 2), + (2041700000000120216, 2041700000000100207, 'SystemNetworkPortForwardUpdate', NULL, NULL, NULL, 'System:Network:PortForward:Update', 'button', '{"title":"common.edit"}', 1, 3), + (2041700000000120217, 2041700000000100207, 'SystemNetworkPortForwardDelete', NULL, NULL, NULL, 'System:Network:PortForward:Delete', 'button', '{"title":"common.delete"}', 1, 4), + (2041700000000120218, 2041700000000100207, 'SystemNetworkPortForwardRetry', NULL, NULL, NULL, 'System:Network:PortForward:Retry', 'button', '{"title":"common.retry"}', 1, 5), + (2041700000000120219, 2041700000000100207, 'SystemNetworkPortForwardKeeper', NULL, NULL, NULL, 'System:Network:PortForward:Keeper', 'button', '{"title":"system.network.keeper"}', 1, 6), + (2041700000000120220, 2041700000000100207, 'SystemNetworkPortForwardProbe', NULL, NULL, NULL, 'System:Network:PortForward:Probe', 'button', '{"title":"system.network.probe"}', 1, 7), + (2041700000000120221, 2041700000000100207, 'SystemNetworkPortForwardHistory', NULL, NULL, NULL, 'System:Network:PortForward:History', 'button', '{"title":"system.network.history"}', 1, 8), (2041700000000100300, 0, 'Blog', '/blog', NULL, '/blog/article', NULL, 'catalog', '{"icon":"lucide:newspaper","order":100,"title":"博客管理"}', 1, 100), (2041700000000100301, 2041700000000100300, 'BlogArticle', '/blog/article', '/blog/article/list', NULL, 'Blog:Article:List', 'menu', '{"icon":"lucide:file-text","title":"文章管理"}', 1, 0), (2041700000000120301, 2041700000000100301, 'BlogArticleCreate', NULL, NULL, NULL, 'Blog:Article:Create', 'button', '{"title":"common.create"}', 1, 0), @@ -264,6 +273,7 @@ VALUES (2041700000000100010, 0, 'About', '/about', '_core/about/index', NULL, NULL, 'menu', '{"icon":"lucide:copyright","order":9999,"title":"demos.vben.about"}', 1, 9999), (2041700000000100011, 0, 'Profile', '/profile', '_core/profile/index', NULL, NULL, 'menu', '{"hideInMenu":true,"icon":"lucide:user","title":"page.auth.profile"}', 1, 10000) ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), `pid` = VALUES(`pid`), `path` = VALUES(`path`), `component` = VALUES(`component`), @@ -353,7 +363,20 @@ INSERT INTO `admin_role_menu` (`role_id`, `menu_id`) SELECT 2041700000000010002, `id` FROM `admin_menu` WHERE `is_deleted` = 0 - AND `name` NOT IN ('SystemNotice', 'SystemNoticeEdit', 'SystemNoticeDelete'); + AND `name` NOT IN ( + 'SystemNotice', + 'SystemNoticeEdit', + 'SystemNoticeDelete', + 'SystemNetwork', + 'SystemNetworkPortForwardList', + 'SystemNetworkPortForwardCreate', + 'SystemNetworkPortForwardUpdate', + 'SystemNetworkPortForwardDelete', + 'SystemNetworkPortForwardRetry', + 'SystemNetworkPortForwardKeeper', + 'SystemNetworkPortForwardProbe', + 'SystemNetworkPortForwardHistory' + ); INSERT INTO `admin_role_menu` (`role_id`, `menu_id`) VALUES diff --git a/src/modules/admin/platform-config/admin-platform-config.module.ts b/src/modules/admin/platform-config/admin-platform-config.module.ts index 349c907..1b5896c 100644 --- a/src/modules/admin/platform-config/admin-platform-config.module.ts +++ b/src/modules/admin/platform-config/admin-platform-config.module.ts @@ -8,6 +8,12 @@ import { DictController } from '@/modules/admin/platform-config/dict/dict.contro import { DictModule } from '@/modules/admin/platform-config/dict/dict.module'; import { AdminNoticeController } from '@/modules/admin/platform-config/notice/admin-notice.controller'; import { NoticeModule } from '@/modules/admin/platform-config/notice/notice.module'; +import { NetworkAgentMqttService } from '@/modules/admin/platform-config/network-management/network-agent-mqtt.service'; +import { NetworkAgentState } from '@/modules/admin/platform-config/network-management/network-agent-state.entity'; +import { NetworkEndpointHistory } from '@/modules/admin/platform-config/network-management/network-endpoint-history.entity'; +import { NetworkManagementController } from '@/modules/admin/platform-config/network-management/network-management.controller'; +import { NetworkPortForward } from '@/modules/admin/platform-config/network-management/network-management.entity'; +import { NetworkManagementService } from '@/modules/admin/platform-config/network-management/network-management.service'; import { SystemLogController } from '@/modules/admin/platform-config/system-log/system-log.controller'; import { SystemLogService } from '@/modules/admin/platform-config/system-log/system-log.service'; import { AdminTimezoneController } from '@/modules/admin/platform-config/timezone/admin-timezone.controller'; @@ -41,6 +47,7 @@ export const ADMIN_PLATFORM_CONFIG_DIRECT_CONTROLLERS = [ SystemLogController, AdminTimezoneController, EnvironmentDashboardController, + NetworkManagementController, ]; export const ADMIN_PLATFORM_CONFIG_IMPORTED_CONTROLLERS = [ @@ -73,11 +80,19 @@ export const ADMIN_PLATFORM_CONFIG_PROVIDERS = [ EnvironmentEventBusService, EnvironmentEventMaterializer, EnvironmentEventStreamService, + NetworkManagementService, + NetworkAgentMqttService, ]; @Module({ imports: [ - TypeOrmModule.forFeature([Component, AdminUser]), + TypeOrmModule.forFeature([ + Component, + AdminUser, + NetworkPortForward, + NetworkAgentState, + NetworkEndpointHistory, + ]), AdminAuthGuardModule, DictModule, NoticeModule, diff --git a/src/modules/admin/platform-config/network-management/network-agent-mqtt.service.ts b/src/modules/admin/platform-config/network-management/network-agent-mqtt.service.ts new file mode 100644 index 0000000..0c020f7 --- /dev/null +++ b/src/modules/admin/platform-config/network-management/network-agent-mqtt.service.ts @@ -0,0 +1,654 @@ +import { + Inject, + Injectable, + Logger, + Optional, + type OnModuleDestroy, + type OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource } from 'typeorm'; +import * as mqtt from 'mqtt'; +import type { IClientOptions, MqttClient } from 'mqtt'; +import { KtDateTime } from '@/common'; +import { NetworkAgentState } from './network-agent-state.entity'; +import { NetworkEndpointHistory } from './network-endpoint-history.entity'; +import { NetworkPortForward } from './network-management.entity'; +import { + buildDesiredSnapshot, + desiredSnapshotDigest, + desiredSnapshotBytes, + NetworkMessageValidationError, + parseEndpointEvent, + parseReportedSnapshot, + parseStatusSnapshot, + type NetworkEndpointEvent, + type NetworkReportedSnapshot, + type NetworkStatusSnapshot, +} from './network-management.types'; + +const DEFAULT_AGENT_ID = 'nas-main'; +const DEFAULT_CLIENT_ID = 'kt-template-online-api-network-nas-main'; +const DEFAULT_RETRY_MS = 5000; +const MAX_MESSAGE_BYTES = 256 * 1024; + +export const NETWORK_MQTT_CLIENT_FACTORY = Symbol( + 'NETWORK_MQTT_CLIENT_FACTORY', +); + +export type NetworkMqttClientFactory = ( + url: string, + options: IClientOptions, +) => MqttClient; + +@Injectable() +export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(NetworkAgentMqttService.name); + private client: MqttClient | null = null; + private retryTimer: NodeJS.Timeout | null = null; + private publishPromise: Promise | null = null; + private publishRequested = false; + private forcePublishRequested = false; + private recoveryInProgress = false; + private shuttingDown = false; + + /** + * Creates the dedicated network Agent MQTT bridge. + * @param configService - Runtime broker, Agent, and client identity settings. + * @param dataSource - Transaction boundary for publish acknowledgements and inbound state. + * @param clientFactory - Optional deterministic MQTT client factory used by tests. + */ + constructor( + private readonly configService: ConfigService, + private readonly dataSource: DataSource, + @Optional() + @Inject(NETWORK_MQTT_CLIENT_FACTORY) + private readonly clientFactory?: NetworkMqttClientFactory, + ) {} + + /** Opens a persistent MQTT 5 session and schedules convergence publication. */ + onModuleInit(): void { + const url = this.configService.get('NETWORK_AGENT_MQTT_URL'); + if (!url) { + this.logger.warn('Network Agent MQTT is not configured'); + return; + } + + const factory = this.clientFactory || mqtt.connect; + this.client = factory(url, { + clean: false, + clientId: + this.configService.get('NETWORK_AGENT_MQTT_CLIENT_ID') || + DEFAULT_CLIENT_ID, + customHandleAcks: (topic, payload, _packet, callback) => { + void this.acknowledgeIncoming(topic, payload, callback); + }, + password: this.configService.get('NETWORK_AGENT_MQTT_PASSWORD'), + protocolVersion: 5, + properties: { sessionExpiryInterval: 7 * 24 * 60 * 60 }, + reconnectPeriod: this.retryMs(), + resubscribe: true, + username: this.configService.get('NETWORK_AGENT_MQTT_USERNAME'), + }); + this.client.on('connect', () => this.handleConnect()); + this.client.on('error', (error) => this.handleClientError(error)); + + this.retryTimer = setInterval(() => { + this.requestDesiredPublish(); + }, this.retryMs()); + this.retryTimer.unref(); + } + + /** Prevents new recovery work, then closes timers and the persistent MQTT connection. */ + async onModuleDestroy(): Promise { + this.shuttingDown = true; + if (this.retryTimer) clearInterval(this.retryTimer); + this.retryTimer = null; + const client = this.client; + if (!client) return; + await new Promise((resolve) => { + client.end(false, {}, () => resolve()); + }); + if (this.client === client) this.client = null; + this.recoveryInProgress = false; + } + + /** + * Schedules publication after a committed desired-state mutation. + * Broker unavailability never rejects the already accepted HTTP operation. + */ + requestDesiredPublish(): void { + this.publishRequested = true; + this.startPublishDrain(); + } + + /** + * Publishes the latest complete desired snapshot and waits for QoS 1 PUBACK. + * @param force - Republish once even when published and desired revisions match. + * @returns True when a snapshot reached PUBACK, otherwise false. + */ + async publishLatestDesired(force = false): Promise { + if (!this.client?.connected) return false; + const snapshot = await this.dataSource.transaction(async (manager) => { + const state = await manager.getRepository(NetworkAgentState).findOne({ + where: { agentId: this.agentId() }, + }); + if (!state || BigInt(state.desiredRevision) === 0n) return null; + if ( + !force && + BigInt(state.publishedRevision) >= BigInt(state.desiredRevision) + ) { + return null; + } + const mappings = await manager.getRepository(NetworkPortForward).find({ + where: { isDeleted: false }, + }); + return buildDesiredSnapshot(state, mappings); + }); + if (!snapshot) return false; + await this.publishWithPuback( + this.topic('desired'), + desiredSnapshotBytes(snapshot), + ); + await this.markRevisionPublished(String(snapshot.revision)); + return true; + } + + /** + * Consumes one exact inbound Agent topic after parsing bounded JSON. + * @param topic - Exact reported, status, or events topic. + * @param payload - Untrusted MQTT payload. + */ + async consumeMessage(topic: string, payload: Buffer): Promise { + if (payload.byteLength > MAX_MESSAGE_BYTES) { + throw new NetworkMessageValidationError('Network MQTT message too large'); + } + let parsed: unknown; + try { + parsed = JSON.parse(payload.toString('utf8')); + } catch { + throw new NetworkMessageValidationError('Invalid network MQTT JSON'); + } + + if (topic === this.topic('reported')) { + await this.applyReported(parseReportedSnapshot(parsed)); + return; + } + if (topic === this.topic('status')) { + await this.applyStatus(parseStatusSnapshot(parsed)); + return; + } + if (topic === this.topic('events')) { + await this.appendEndpointEvent(parseEndpointEvent(parsed)); + return; + } + throw new NetworkMessageValidationError('Unexpected network MQTT topic'); + } + + /** Restores exact subscriptions and one retained desired republish after each connection. */ + private handleConnect(): void { + const client = this.client; + if (this.shuttingDown || !client) return; + this.recoveryInProgress = false; + client.subscribe( + { + [this.topic('reported')]: { qos: 1 }, + [this.topic('status')]: { qos: 1 }, + [this.topic('events')]: { qos: 1 }, + }, + (error) => { + if (this.shuttingDown || this.client !== client) return; + if (error) { + this.logger.warn('Network MQTT subscription failed'); + this.recoverClient(); + return; + } + this.forcePublishRequested = true; + this.requestDesiredPublish(); + }, + ); + } + + /** + * Delays MQTT QoS 1 acknowledgement until protocol validation and DB commit finish. + * Permanent malformed messages are acknowledged and dropped; transient DB errors are not. + * @param topic - Inbound exact topic. + * @param payload - Raw MQTT bytes. + * @param callback - MQTT.js protocol acknowledgement callback. + */ + private async acknowledgeIncoming( + topic: string, + payload: Buffer, + callback: (error: Error | number, code?: number) => void, + ): Promise { + try { + await this.consumeMessage(topic, payload); + callback(0); + } catch (error) { + if (error instanceof NetworkMessageValidationError) { + this.logger.warn(error.message); + callback(0); + return; + } + callback( + error instanceof Error + ? error + : new Error('Network MQTT database transaction failed'), + ); + } + } + + /** Starts one serialized publisher drain without overlapping retained sends. */ + private startPublishDrain(): void { + if (this.publishPromise) return; + this.publishPromise = this.drainPublishRequests().finally(() => { + this.publishPromise = null; + if (this.publishRequested) this.startPublishDrain(); + }); + } + + /** Drains the current publication request and preserves retry state on failure. */ + private async drainPublishRequests(): Promise { + while (this.publishRequested) { + const force = this.forcePublishRequested; + this.publishRequested = false; + this.forcePublishRequested = false; + try { + await this.publishLatestDesired(force); + } catch { + this.publishRequested = false; + this.forcePublishRequested ||= force; + this.logger.warn('Network desired snapshot publication failed'); + return; + } + } + } + + /** + * Resolves only after MQTT.js receives PUBACK for a retained QoS 1 publication. + * @param topic - Fixed Agent desired topic. + * @param payload - Stable snapshot bytes. + */ + private async publishWithPuback( + topic: string, + payload: Buffer, + ): Promise { + const client = this.client; + if (!client?.connected) throw new Error('Network MQTT disconnected'); + await new Promise((resolve, reject) => { + client.publish(topic, payload, { qos: 1, retain: true }, (error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + + /** + * Advances published revision only after PUBACK and never beyond current desired state. + * @param revision - PUBACK-confirmed desired revision. + */ + private async markRevisionPublished(revision: string): Promise { + await this.dataSource.transaction(async (manager) => { + const repository = manager.getRepository(NetworkAgentState); + const state = await repository.findOne({ + lock: { mode: 'pessimistic_write' }, + where: { agentId: this.agentId() }, + }); + if (!state) return; + const confirmed = BigInt(revision); + if ( + confirmed > BigInt(state.publishedRevision) && + confirmed <= BigInt(state.desiredRevision) + ) { + state.publishedRevision = revision; + await repository.save(state); + } + }); + } + + /** Applies a full reported snapshot transactionally without creating desired rows. */ + private async applyReported(report: NetworkReportedSnapshot): Promise { + this.assertAgentId(report.agentId); + const desiredChanged = await this.dataSource.transaction( + async (manager) => { + const stateRepository = manager.getRepository(NetworkAgentState); + const mappingRepository = manager.getRepository(NetworkPortForward); + const state = await stateRepository.findOne({ + lock: { mode: 'pessimistic_write' }, + where: { agentId: report.agentId }, + }); + if ( + !state || + BigInt(report.appliedRevision) > BigInt(state.desiredRevision) + ) { + throw new NetworkMessageValidationError('Invalid reported revision'); + } + if (BigInt(report.appliedRevision) < BigInt(state.appliedRevision)) { + return; + } + const isCurrentRevision = + BigInt(report.appliedRevision) === BigInt(state.desiredRevision); + const desiredMappings = await mappingRepository.find({ + where: { isDeleted: false }, + }); + if (isCurrentRevision) { + const currentSnapshot = buildDesiredSnapshot(state, desiredMappings); + if (desiredSnapshotDigest(currentSnapshot) !== report.desiredDigest) { + throw new NetworkMessageValidationError( + 'Reported desired digest does not match current revision', + ); + } + } + const mappingById = new Map( + desiredMappings.map((mapping) => [mapping.id, mapping]), + ); + const finalizedIds = new Set(); + let finalizedDeletion = false; + for (const item of report.mappings) { + const mapping = mappingById.get(item.id); + if (mapping) continue; + if (isCurrentRevision) { + throw new NetworkMessageValidationError( + 'Unknown mapping in current reported snapshot', + ); + } + const historical = await mappingRepository.findOne({ + where: { id: item.id }, + }); + if ( + historical?.isDeleted && + item.desiredState === 'absent' && + item.syncStatus === 'synced' + ) { + finalizedIds.add(item.id); + } else { + throw new NetworkMessageValidationError('Unknown reported mapping'); + } + } + if (isCurrentRevision) { + const reportedIds = new Set(report.mappings.map((item) => item.id)); + if (desiredMappings.some((mapping) => !reportedIds.has(mapping.id))) { + throw new NetworkMessageValidationError( + 'Incomplete current reported snapshot', + ); + } + } + + for (const item of report.mappings) { + if (finalizedIds.has(item.id)) continue; + const mapping = mappingById.get(item.id) as NetworkPortForward; + if (BigInt(item.revision) < BigInt(mapping.desiredRevision)) { + continue; + } + if (item.desiredState !== mapping.desiredPresence) { + throw new NetworkMessageValidationError( + 'Reported mapping desired state does not match', + ); + } + if (item.keeperDesiredEnabled !== mapping.keeperDesiredEnabled) { + throw new NetworkMessageValidationError( + 'Reported mapping Keeper intent does not match', + ); + } + mapping.reportedRevision = String(item.revision); + mapping.syncStatus = item.syncStatus; + mapping.keeperStatus = item.keeperStatus; + mapping.lastErrorCode = item.errorCode || null; + mapping.lastErrorMessage = item.errorMessage || null; + this.applyReportedEndpoints( + mapping, + item.currentEndpoint, + item.lastObservedEndpoint, + report.reportedAt, + ); + + if ( + mapping.desiredPresence === 'absent' && + item.desiredState === 'absent' && + item.syncStatus === 'synced' && + item.routerPresent === false && + item.routePresent === false && + report.helperStatus === 'confirmed' && + report.helperAppliedRevision === report.appliedRevision && + item.keeperDesiredEnabled === false && + item.keeperStatus === 'disabled' && + !item.currentEndpoint + ) { + mapping.activeKey = null; + mapping.isDeleted = true; + finalizedDeletion = true; + } + await mappingRepository.save(mapping); + } + + if (BigInt(report.appliedRevision) > BigInt(state.appliedRevision)) { + state.appliedRevision = String(report.appliedRevision); + } + const failedMapping = report.mappings.find( + (item) => + item.syncStatus === 'conflict' || item.syncStatus === 'failed', + ); + state.lastReconcileErrorCode = failedMapping + ? failedMapping.errorCode || `sync_${failedMapping.syncStatus}` + : report.helperStatus === 'failed' + ? 'route_helper_failed' + : null; + state.lastReconcileErrorMessage = failedMapping?.errorMessage || null; + if (finalizedDeletion) { + state.desiredRevision = ( + BigInt(state.desiredRevision) + 1n + ).toString(); + state.desiredIssuedAt = new KtDateTime(); + } + await stateRepository.save(state); + return finalizedDeletion; + }, + ); + if (desiredChanged) this.requestDesiredPublish(); + } + + /** + * Refreshes or withdraws the current lease while retaining the last observation. + * @param mapping - Persisted desired mapping being updated. + * @param endpoint - Full current endpoint or explicit null withdrawal. + * @param lastObserved - Most recent successful endpoint evidence, when available. + * @param reportedAt - Snapshot time used to reject an out-of-order withdrawal. + */ + private applyReportedEndpoints( + mapping: NetworkPortForward, + endpoint: NetworkReportedSnapshot['mappings'][number]['currentEndpoint'], + lastObserved: NetworkReportedSnapshot['mappings'][number]['lastObservedEndpoint'], + reportedAt: string, + ): void { + if (!endpoint) { + const withdrawalTime = new Date(reportedAt).getTime(); + if ( + !mapping.currentObservedAt || + withdrawalTime >= new Date(mapping.currentObservedAt).getTime() + ) { + mapping.currentPublicIpv4 = null; + mapping.currentPublicPort = null; + mapping.currentObservedAt = null; + mapping.currentValidUntil = null; + } + } else { + const observedAt = new Date(endpoint.observedAt); + if ( + !mapping.currentObservedAt || + observedAt.getTime() >= new Date(mapping.currentObservedAt).getTime() + ) { + mapping.currentPublicIpv4 = endpoint.publicIpv4; + mapping.currentPublicPort = endpoint.publicPort; + mapping.currentObservedAt = new KtDateTime(observedAt); + mapping.currentValidUntil = new KtDateTime(endpoint.validUntil); + } + } + if (lastObserved) { + const observedAt = new Date(lastObserved.observedAt); + if ( + !mapping.lastObservedAt || + observedAt.getTime() >= new Date(mapping.lastObservedAt).getTime() + ) { + mapping.lastObservedIpv4 = lastObserved.publicIpv4; + mapping.lastObservedPort = lastObserved.publicPort; + mapping.lastObservedAt = new KtDateTime(observedAt); + } + } + } + + /** Applies retained Agent online status without changing mapping sync semantics. */ + private async applyStatus(status: NetworkStatusSnapshot): Promise { + this.assertAgentId(status.agentId); + await this.dataSource.transaction(async (manager) => { + const repository = manager.getRepository(NetworkAgentState); + const state = await repository.findOne({ + lock: { mode: 'pessimistic_write' }, + where: { agentId: status.agentId }, + }); + if (!state) { + throw new NetworkMessageValidationError('Unknown network Agent'); + } + const observedAt = new Date(status.observedAt); + const incomingStartedAt = status.startedAt + ? new Date(status.startedAt) + : null; + const currentStartedAt = state.startedAt + ? new Date(state.startedAt) + : null; + if ( + incomingStartedAt && + currentStartedAt && + incomingStartedAt.getTime() < currentStartedAt.getTime() + ) { + return; + } + const isSameSessionWill = + status.online === false && + !!incomingStartedAt && + !!currentStartedAt && + incomingStartedAt.getTime() === currentStartedAt.getTime(); + if ( + state.lastHeartbeatAt && + observedAt.getTime() < new Date(state.lastHeartbeatAt).getTime() && + !isSameSessionWill + ) { + return; + } + state.online = status.online; + state.version = status.version || null; + state.startedAt = incomingStartedAt + ? new KtDateTime(incomingStartedAt) + : null; + if (!isSameSessionWill) { + state.lastHeartbeatAt = new KtDateTime(observedAt); + } + state.lastMqttErrorCode = status.errorCode || null; + state.lastMqttErrorMessage = status.errorMessage || null; + await repository.save(state); + }); + } + + /** Appends one endpoint change event exactly once by event ID. */ + private async appendEndpointEvent( + event: NetworkEndpointEvent, + ): Promise { + this.assertAgentId(event.agentId); + await this.dataSource.transaction(async (manager) => { + const state = await manager.getRepository(NetworkAgentState).findOne({ + where: { agentId: event.agentId }, + }); + if (!state || BigInt(event.revision) > BigInt(state.desiredRevision)) { + throw new NetworkMessageValidationError('Invalid event revision'); + } + const mapping = await manager.getRepository(NetworkPortForward).findOne({ + where: { id: event.mappingId }, + }); + if (!mapping) { + throw new NetworkMessageValidationError('Unknown event mapping'); + } + const repository = manager.getRepository(NetworkEndpointHistory); + if (await repository.findOne({ where: { eventId: event.eventId } })) { + return; + } + const history = repository.create({ + eventId: event.eventId, + eventType: event.type, + firstObservedAt: new KtDateTime(event.endpoint.observedAt), + lastObservedAt: new KtDateTime(event.endpoint.observedAt), + mappingId: event.mappingId, + occurredAt: new KtDateTime(event.occurredAt), + publicIpv4: event.endpoint.publicIpv4, + publicPort: event.endpoint.publicPort, + reason: event.reason || null, + }); + try { + await repository.save(history); + } catch (error) { + if (!this.isDuplicateKeyError(error)) throw error; + } + }); + } + + /** Validates that an inbound message belongs to the one configured Agent. */ + private assertAgentId(agentId: string): void { + if (agentId !== this.agentId()) { + throw new NetworkMessageValidationError('Unexpected network Agent ID'); + } + } + + /** Returns the fixed configured Agent identifier. */ + private agentId(): string { + return ( + this.configService.get('NETWORK_AGENT_ID') || DEFAULT_AGENT_ID + ); + } + + /** Builds one exact topic under the fixed Agent namespace. */ + private topic(kind: 'desired' | 'events' | 'reported' | 'status'): string { + return `kt/network/v1/agents/${this.agentId()}/${kind}`; + } + + /** Returns the bounded publisher retry interval. */ + private retryMs(): number { + const configured = Number( + this.configService.get('NETWORK_AGENT_MQTT_RETRY_MS'), + ); + return Number.isFinite(configured) && configured >= 1000 + ? Math.min(configured, 60_000) + : DEFAULT_RETRY_MS; + } + + /** + * Closes a stuck MQTT packet pipeline once, then reconnects the same persistent client. + * A successful connect clears the single-flight guard; shutdown permanently suppresses recovery. + */ + private recoverClient(): void { + const client = this.client; + if (this.shuttingDown || this.recoveryInProgress || !client) return; + this.recoveryInProgress = true; + client.end(true, {}, () => { + if (this.shuttingDown || this.client !== client) { + this.recoveryInProgress = false; + return; + } + client.reconnect(); + }); + } + + /** + * Logs a safe broker error classification and recovers a potentially stuck pipeline. + * @param error - MQTT client error whose type is safe to expose in service logs. + */ + private handleClientError(error: Error): void { + if (this.shuttingDown) return; + this.logger.warn(`Network Agent MQTT client error (${error.name})`); + this.recoverClient(); + } + + /** Detects MySQL duplicate-key failures for QoS 1 event redelivery races. */ + private isDuplicateKeyError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const record = error as { code?: unknown; errno?: unknown }; + return record.code === 'ER_DUP_ENTRY' || record.errno === 1062; + } +} diff --git a/src/modules/admin/platform-config/network-management/network-agent-state.entity.ts b/src/modules/admin/platform-config/network-management/network-agent-state.entity.ts new file mode 100644 index 0000000..c5ce220 --- /dev/null +++ b/src/modules/admin/platform-config/network-management/network-agent-state.entity.ts @@ -0,0 +1,62 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; +import { + KtCreateDateColumn, + KtDateTime, + KtDateTimeColumn, + KtUpdateDateColumn, +} from '@/common'; + +@Entity('network_agent_state') +export class NetworkAgentState { + @PrimaryColumn({ length: 64, name: 'agent_id', type: 'varchar' }) + agentId: string; + + @Column({ length: 15, name: 'target_ipv4' }) + targetIpv4: string; + + @Column({ default: '0', name: 'desired_revision', type: 'bigint' }) + desiredRevision: string; + + @KtDateTimeColumn({ name: 'desired_issued_at', type: 'datetime' }) + desiredIssuedAt: KtDateTime; + + @Column({ default: '0', name: 'published_revision', type: 'bigint' }) + publishedRevision: string; + + @Column({ default: '0', name: 'applied_revision', type: 'bigint' }) + appliedRevision: string; + + @Column({ default: false, type: 'boolean' }) + online: boolean; + + @Column({ length: 64, nullable: true }) + version?: string | null; + + @KtDateTimeColumn({ name: 'started_at', nullable: true, type: 'datetime' }) + startedAt?: KtDateTime | null; + + @KtDateTimeColumn({ + name: 'last_heartbeat_at', + nullable: true, + type: 'datetime', + }) + lastHeartbeatAt?: KtDateTime | null; + + @Column({ length: 64, name: 'last_mqtt_error_code', nullable: true }) + lastMqttErrorCode?: string | null; + + @Column({ length: 500, name: 'last_mqtt_error_message', nullable: true }) + lastMqttErrorMessage?: string | null; + + @Column({ length: 64, name: 'last_reconcile_error_code', nullable: true }) + lastReconcileErrorCode?: string | null; + + @Column({ length: 500, name: 'last_reconcile_error_message', nullable: true }) + lastReconcileErrorMessage?: string | null; + + @KtCreateDateColumn({ name: 'create_time' }) + createTime: KtDateTime; + + @KtUpdateDateColumn({ name: 'update_time' }) + updateTime: KtDateTime; +} diff --git a/src/modules/admin/platform-config/network-management/network-endpoint-history.entity.ts b/src/modules/admin/platform-config/network-management/network-endpoint-history.entity.ts new file mode 100644 index 0000000..5b37380 --- /dev/null +++ b/src/modules/admin/platform-config/network-management/network-endpoint-history.entity.ts @@ -0,0 +1,55 @@ +import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { + ensureSnowflakeId, + KtCreateDateColumn, + KtDateTime, + KtDateTimeColumn, +} from '@/common'; +import type { EndpointEventType } from './network-management.types'; + +@Entity('network_endpoint_history') +@Index('uk_network_endpoint_history_event_id', ['eventId'], { unique: true }) +@Index('idx_network_endpoint_history_mapping', ['mappingId', 'occurredAt']) +export class NetworkEndpointHistory { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ length: 128, name: 'event_id' }) + eventId: string; + + @Column({ name: 'mapping_id', type: 'bigint' }) + mappingId: string; + + @Column({ length: 16, name: 'event_type' }) + eventType: EndpointEventType; + + @Column({ length: 15, name: 'public_ipv4', nullable: true }) + publicIpv4?: string | null; + + @Column({ name: 'public_port', nullable: true, type: 'int' }) + publicPort?: number | null; + + @KtDateTimeColumn({ name: 'first_observed_at', type: 'datetime' }) + firstObservedAt: KtDateTime; + + @KtDateTimeColumn({ name: 'last_observed_at', type: 'datetime' }) + lastObservedAt: KtDateTime; + + @KtDateTimeColumn({ name: 'occurred_at', type: 'datetime' }) + occurredAt: KtDateTime; + + @Column({ length: 128, nullable: true }) + reason?: string | null; + + @KtCreateDateColumn({ name: 'create_time' }) + createTime: KtDateTime; + + /** + * Assigns a Snowflake string before the append-only event is persisted. + * @returns The assigned endpoint history identifier. + */ + @BeforeInsert() + createId(): string { + return ensureSnowflakeId(this); + } +} diff --git a/src/modules/admin/platform-config/network-management/network-management.controller.ts b/src/modules/admin/platform-config/network-management/network-management.controller.ts new file mode 100644 index 0000000..f8c91d1 --- /dev/null +++ b/src/modules/admin/platform-config/network-management/network-management.controller.ts @@ -0,0 +1,169 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Put, + Query, + Res, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { Response } from 'express'; +import { vbenPage, vbenSuccess } from '@/common'; +import { AdminSuperGuard } from '@/modules/admin/identity/auth/admin-super.guard'; +import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard'; +import { + NetworkEndpointHistoryQueryDto, + NetworkPortForwardCreateDto, + NetworkPortForwardListQueryDto, + NetworkPortForwardUpdateDto, +} from './network-management.dto'; +import { NetworkManagementService } from './network-management.service'; + +@ApiTags('Admin - 网络端口转发') +@Controller('system/network') +@UseGuards(JwtAuthGuard, AdminSuperGuard) +@UsePipes( + new ValidationPipe({ + forbidNonWhitelisted: true, + transform: true, + whitelist: true, + }), +) +export class NetworkManagementController { + /** + * Creates the super-admin-only network desired-state controller. + * @param service - Persisted port-forward and Agent state service. + */ + constructor(private readonly service: NetworkManagementService) {} + + /** Lists persisted mappings without returning expired endpoint leases. */ + @Get('port-forward/list') + @ApiOperation({ summary: '分页查询端口转发' }) + async list( + @Query() query: NetworkPortForwardListQueryDto, + @Res({ passthrough: true }) response: Response, + ) { + this.noStore(response); + const page = await this.service.list(query); + return vbenPage(page.items, page.total); + } + + /** Persists a new TCP or UDP desired mapping without waiting for Agent apply. */ + @Post('port-forward') + @ApiOperation({ summary: '新增端口转发' }) + async create( + @Body() body: NetworkPortForwardCreateDto, + @Res({ passthrough: true }) response: Response, + ) { + this.noStore(response); + return vbenSuccess(await this.service.create(body)); + } + + /** Updates one desired mapping and advances the global revision once. */ + @Put('port-forward/:id') + @ApiOperation({ summary: '修改端口转发' }) + async update( + @Param('id') id: string, + @Body() body: NetworkPortForwardUpdateDto, + @Res({ passthrough: true }) response: Response, + ) { + this.noStore(response); + return vbenSuccess(await this.service.update(id, body)); + } + + /** Starts confirmed asynchronous deletion while retaining the active key. */ + @Delete('port-forward/:id') + @ApiOperation({ summary: '删除端口转发' }) + async remove( + @Param('id') id: string, + @Res({ passthrough: true }) response: Response, + ) { + this.noStore(response); + return vbenSuccess(await this.service.remove(id)); + } + + /** Requeues reconciliation for the current desired mapping. */ + @Post('port-forward/:id/retry') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '重试端口转发同步' }) + async retry( + @Param('id') id: string, + @Res({ passthrough: true }) response: Response, + ) { + this.noStore(response); + return vbenSuccess(await this.service.retry(id)); + } + + /** Enables continuous UDP Keeper and requests an immediate probe. */ + @Post('port-forward/:id/keeper/enable') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '启用 UDP STUN 保活' }) + async enableKeeper( + @Param('id') id: string, + @Res({ passthrough: true }) response: Response, + ) { + this.noStore(response); + return vbenSuccess(await this.service.enableKeeper(id)); + } + + /** Disables continuous UDP Keeper and immediately hides its public lease. */ + @Post('port-forward/:id/keeper/disable') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '停用 UDP STUN 保活' }) + async disableKeeper( + @Param('id') id: string, + @Res({ passthrough: true }) response: Response, + ) { + this.noStore(response); + return vbenSuccess(await this.service.disableKeeper(id)); + } + + /** Generates a new idempotent immediate-probe request for an enabled UDP Keeper. */ + @Post('port-forward/:id/probe') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '立即刷新 UDP 公网端点' }) + async probe( + @Param('id') id: string, + @Res({ passthrough: true }) response: Response, + ) { + this.noStore(response); + return vbenSuccess(await this.service.probe(id)); + } + + /** Lists append-only endpoint state-change history for one desired mapping. */ + @Get('port-forward/:id/endpoint-history') + @ApiOperation({ summary: '查询公网端点历史' }) + async endpointHistory( + @Param('id') id: string, + @Query() query: NetworkEndpointHistoryQueryDto, + @Res({ passthrough: true }) response: Response, + ) { + this.noStore(response); + const page = await this.service.endpointHistory(id, query); + return vbenPage(page.items, page.total); + } + + /** Returns independent Agent connectivity and revision convergence state. */ + @Get('agent/status') + @ApiOperation({ summary: '查询网络 Agent 状态' }) + async agentStatus(@Res({ passthrough: true }) response: Response) { + this.noStore(response); + return vbenSuccess(await this.service.agentStatus()); + } + + /** + * Marks all dynamic network state responses as non-cacheable. + * @param response - Express response receiving the fixed directive. + */ + private noStore(response: Response): void { + response.setHeader('Cache-Control', 'no-store'); + } +} diff --git a/src/modules/admin/platform-config/network-management/network-management.dto.ts b/src/modules/admin/platform-config/network-management/network-management.dto.ts new file mode 100644 index 0000000..8f5ea54 --- /dev/null +++ b/src/modules/admin/platform-config/network-management/network-management.dto.ts @@ -0,0 +1,188 @@ +import { Type } from 'class-transformer'; +import { + IsIn, + IsInt, + IsOptional, + IsString, + Length, + Matches, + Max, + MaxLength, + Min, + ValidateIf, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import type { + KeeperStatus, + PortForwardProtocol, + PortForwardSyncStatus, +} from './network-management.types'; + +export class NetworkPortForwardCreateDto { + @ApiProperty({ maxLength: 100 }) + @IsString() + @Length(1, 100) + @Matches(/\S/, { message: 'name must contain a non-whitespace character' }) + name: string; + + @ApiPropertyOptional({ maxLength: 500 }) + @ValidateIf(isProvided) + @IsString() + @MaxLength(500) + remark?: string; + + @ApiProperty({ enum: ['tcp', 'udp'] }) + @IsIn(['tcp', 'udp']) + protocol: PortForwardProtocol; + + @ApiProperty({ maximum: 65535, minimum: 1 }) + @IsInt() + @Min(1) + @Max(65535) + externalPort: number; + + @ApiProperty({ maximum: 65535, minimum: 1 }) + @IsInt() + @Min(1) + @Max(65535) + internalPort: number; +} + +export class NetworkPortForwardUpdateDto { + @ApiPropertyOptional({ maxLength: 100 }) + @ValidateIf(isProvided) + @IsString() + @Length(1, 100) + @Matches(/\S/, { message: 'name must contain a non-whitespace character' }) + name?: string; + + @ApiPropertyOptional({ maxLength: 500, nullable: true }) + @ValidateIf(isProvided) + @IsString() + @MaxLength(500) + remark?: string; + + @ApiPropertyOptional({ enum: ['tcp', 'udp'] }) + @ValidateIf(isProvided) + @IsIn(['tcp', 'udp']) + protocol?: PortForwardProtocol; + + @ApiPropertyOptional({ maximum: 65535, minimum: 1 }) + @ValidateIf(isProvided) + @IsInt() + @Min(1) + @Max(65535) + externalPort?: number; + + @ApiPropertyOptional({ maximum: 65535, minimum: 1 }) + @ValidateIf(isProvided) + @IsInt() + @Min(1) + @Max(65535) + internalPort?: number; +} + +export class NetworkPortForwardListQueryDto { + @ApiPropertyOptional({ minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageNo?: number; + + @ApiPropertyOptional({ maximum: 100, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize?: number; + + @ApiPropertyOptional({ maxLength: 100 }) + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @ApiPropertyOptional({ enum: ['tcp', 'udp'] }) + @IsOptional() + @IsIn(['tcp', 'udp']) + protocol?: PortForwardProtocol; + + @ApiPropertyOptional({ + enum: ['conflict', 'deleting', 'failed', 'pending', 'synced', 'syncing'], + }) + @IsOptional() + @IsIn(['conflict', 'deleting', 'failed', 'pending', 'synced', 'syncing']) + syncStatus?: PortForwardSyncStatus; +} + +export class NetworkEndpointHistoryQueryDto { + @ApiPropertyOptional({ minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageNo?: number; + + @ApiPropertyOptional({ maximum: 100, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize?: number; +} + +export class NetworkPortForwardResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiPropertyOptional({ nullable: true }) + remark?: string | null; + + @ApiProperty({ enum: ['tcp', 'udp'] }) + protocol: PortForwardProtocol; + + @ApiProperty() + externalPort: number; + + @ApiProperty() + internalPort: number; + + @ApiProperty() + targetIpv4: string; + + @ApiProperty() + keeperDesiredEnabled: boolean; + + @ApiProperty({ enum: ['active', 'disabled', 'failed', 'stale', 'starting'] }) + keeperStatus: KeeperStatus; + + @ApiProperty({ + enum: ['conflict', 'deleting', 'failed', 'pending', 'synced', 'syncing'], + }) + syncStatus: PortForwardSyncStatus; + + @ApiProperty() + desiredRevision: string; + + @ApiProperty() + reportedRevision: string; + + @ApiPropertyOptional({ nullable: true }) + currentPublicEndpoint?: string | null; +} + +/** + * Runs update validation for every supplied value, including explicit null. + * @param _object - DTO instance required by class-validator's predicate contract. + * @param value - Candidate property value. + * @returns True unless the property was omitted entirely. + */ +function isProvided(_object: object, value: unknown): boolean { + return value !== undefined; +} diff --git a/src/modules/admin/platform-config/network-management/network-management.entity.ts b/src/modules/admin/platform-config/network-management/network-management.entity.ts new file mode 100644 index 0000000..db31509 --- /dev/null +++ b/src/modules/admin/platform-config/network-management/network-management.entity.ts @@ -0,0 +1,127 @@ +import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { + ensureSnowflakeId, + KtCreateDateColumn, + KtDateTime, + KtDateTimeColumn, + KtUpdateDateColumn, +} from '@/common'; +import type { + KeeperStatus, + PortForwardProtocol, + PortForwardSyncStatus, + DesiredPresence, +} from './network-management.types'; + +@Entity('network_port_forward') +@Index('uk_network_port_forward_active_key', ['activeKey'], { unique: true }) +export class NetworkPortForward { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ length: 100 }) + name: string; + + @Column({ nullable: true, type: 'text' }) + remark?: string | null; + + @Column({ length: 8 }) + protocol: PortForwardProtocol; + + @Column({ name: 'external_port', type: 'int', unsigned: true }) + externalPort: number; + + @Column({ name: 'internal_port', type: 'int', unsigned: true }) + internalPort: number; + + @Column({ length: 32, name: 'active_key', nullable: true }) + activeKey?: string | null; + + @Column({ length: 15, name: 'target_ipv4' }) + targetIpv4: string; + + @Column({ default: 'present', length: 16, name: 'desired_presence' }) + desiredPresence: DesiredPresence; + + @Column({ + default: false, + name: 'keeper_desired_enabled', + type: 'boolean', + }) + keeperDesiredEnabled: boolean; + + @Column({ length: 64, name: 'probe_request_id', nullable: true }) + probeRequestId?: string | null; + + @Column({ default: '0', name: 'desired_revision', type: 'bigint' }) + desiredRevision: string; + + @KtDateTimeColumn({ name: 'desired_issued_at', type: 'datetime' }) + desiredIssuedAt: KtDateTime; + + @Column({ default: '0', name: 'reported_revision', type: 'bigint' }) + reportedRevision: string; + + @Column({ default: 'pending', length: 16, name: 'sync_status' }) + syncStatus: PortForwardSyncStatus; + + @Column({ default: 'disabled', length: 16, name: 'keeper_status' }) + keeperStatus: KeeperStatus; + + @Column({ length: 15, name: 'current_public_ipv4', nullable: true }) + currentPublicIpv4?: string | null; + + @Column({ name: 'current_public_port', nullable: true, type: 'int' }) + currentPublicPort?: number | null; + + @KtDateTimeColumn({ + name: 'current_observed_at', + nullable: true, + type: 'datetime', + }) + currentObservedAt?: KtDateTime | null; + + @KtDateTimeColumn({ + name: 'current_valid_until', + nullable: true, + type: 'datetime', + }) + currentValidUntil?: KtDateTime | null; + + @Column({ length: 15, name: 'last_observed_ipv4', nullable: true }) + lastObservedIpv4?: string | null; + + @Column({ name: 'last_observed_port', nullable: true, type: 'int' }) + lastObservedPort?: number | null; + + @KtDateTimeColumn({ + name: 'last_observed_at', + nullable: true, + type: 'datetime', + }) + lastObservedAt?: KtDateTime | null; + + @Column({ length: 64, name: 'last_error_code', nullable: true }) + lastErrorCode?: string | null; + + @Column({ length: 512, name: 'last_error_message', nullable: true }) + lastErrorMessage?: string | null; + + @Column({ default: false, name: 'is_deleted', type: 'boolean' }) + isDeleted: boolean; + + @KtCreateDateColumn({ name: 'create_time' }) + createTime: KtDateTime; + + @KtUpdateDateColumn({ name: 'update_time' }) + updateTime: KtDateTime; + + /** + * Assigns a Snowflake string before the desired mapping is first persisted. + * @returns The assigned stable mapping identifier. + */ + @BeforeInsert() + createId(): string { + return ensureSnowflakeId(this); + } +} diff --git a/src/modules/admin/platform-config/network-management/network-management.service.ts b/src/modules/admin/platform-config/network-management/network-management.service.ts new file mode 100644 index 0000000..6ea5b80 --- /dev/null +++ b/src/modules/admin/platform-config/network-management/network-management.service.ts @@ -0,0 +1,580 @@ +import { randomUUID } from 'node:crypto'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, type EntityManager, Repository } from 'typeorm'; +import { KtDateTime, throwVbenError } from '@/common'; +import { NetworkAgentMqttService } from './network-agent-mqtt.service'; +import { NetworkAgentState } from './network-agent-state.entity'; +import { NetworkEndpointHistory } from './network-endpoint-history.entity'; +import { + NetworkEndpointHistoryQueryDto, + NetworkPortForwardCreateDto, + NetworkPortForwardListQueryDto, + NetworkPortForwardUpdateDto, +} from './network-management.dto'; +import { NetworkPortForward } from './network-management.entity'; +import { + isIpv4Address, + portForwardActiveKey, +} from './network-management.types'; + +const DEFAULT_AGENT_ID = 'nas-main'; +const DEFAULT_TARGET_IPV4 = '192.168.31.224'; + +@Injectable() +export class NetworkManagementService { + /** + * Creates the persisted network desired-state application service. + * @param mappingRepository - Read model for port-forward list queries. + * @param historyRepository - Read model for endpoint history queries. + * @param stateRepository - Read model for Agent status queries. + * @param dataSource - Transaction boundary serializing global desired revision changes. + * @param configService - Fixed Agent and NAS target identity settings. + * @param mqttService - Asynchronous retained desired snapshot publisher. + */ + constructor( + @InjectRepository(NetworkPortForward) + private readonly mappingRepository: Repository, + @InjectRepository(NetworkEndpointHistory) + private readonly historyRepository: Repository, + @InjectRepository(NetworkAgentState) + private readonly stateRepository: Repository, + private readonly dataSource: DataSource, + private readonly configService: ConfigService, + private readonly mqttService: NetworkAgentMqttService, + ) {} + + /** + * Lists active desired mappings with independent sync, Keeper, and lease state. + * @param query - Validated pagination and filter query. + * @returns Page whose endpoint fields hide expired leases. + */ + async list(query: NetworkPortForwardListQueryDto = {}) { + const pageNo = query.pageNo || 1; + const pageSize = query.pageSize || 20; + const builder = this.mappingRepository + .createQueryBuilder('mapping') + .where('mapping.isDeleted = :isDeleted', { isDeleted: false }); + if (query.name) { + builder.andWhere('mapping.name LIKE :name', { + name: `%${query.name.trim()}%`, + }); + } + if (query.protocol) { + builder.andWhere('mapping.protocol = :protocol', { + protocol: query.protocol, + }); + } + if (query.syncStatus) { + builder.andWhere('mapping.syncStatus = :syncStatus', { + syncStatus: query.syncStatus, + }); + } + const [items, total] = await builder + .orderBy('mapping.createTime', 'DESC') + .skip((pageNo - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + return { items: items.map((item) => this.serialize(item)), total }; + } + + /** + * Creates one TCP or UDP desired mapping under the global revision lock. + * @param input - Strict mutable fields; target IPv4 comes from server configuration. + * @returns Latest desired record in pending state. + */ + async create(input: NetworkPortForwardCreateDto) { + const saved = await this.dataSource.transaction(async (manager) => { + const state = await this.lockAgentState(manager); + const repository = manager.getRepository(NetworkPortForward); + if ((await repository.count({ where: { isDeleted: false } })) >= 64) { + throwVbenError('端口转发记录已达到 64 条上限', HttpStatus.CONFLICT); + } + const activeKey = portForwardActiveKey( + input.protocol, + input.externalPort, + ); + if (await repository.findOne({ where: { activeKey } })) { + throwVbenError('同协议外部端口已存在', HttpStatus.CONFLICT); + } + const mapping = repository.create({ + activeKey, + currentObservedAt: null, + currentPublicIpv4: null, + currentPublicPort: null, + currentValidUntil: null, + desiredPresence: 'present', + externalPort: input.externalPort, + internalPort: input.internalPort, + isDeleted: false, + keeperDesiredEnabled: false, + keeperStatus: 'disabled', + lastErrorCode: null, + lastErrorMessage: null, + name: this.normalizeName(input.name), + probeRequestId: null, + protocol: input.protocol, + remark: input.remark?.trim() || null, + reportedRevision: '0', + syncStatus: 'pending', + targetIpv4: state.targetIpv4, + }); + this.advanceRevision(state, mapping); + try { + await repository.save(mapping); + await manager.getRepository(NetworkAgentState).save(state); + } catch (error) { + if (this.isDuplicateKeyError(error)) { + throwVbenError('同协议外部端口已存在', HttpStatus.CONFLICT); + } + throw error; + } + return mapping; + }); + this.notifyDesiredChanged(); + return this.serialize(saved); + } + + /** + * Updates editable desired fields while preserving Keeper prerequisites. + * @param id - Active mapping identifier. + * @param input - Non-empty strict update payload. + * @returns Latest desired record in pending state. + */ + async update(id: string, input: NetworkPortForwardUpdateDto) { + if (Object.keys(input).length === 0) { + throwVbenError('至少提供一个修改字段', HttpStatus.BAD_REQUEST); + } + return this.mutate(id, async (mapping, manager) => { + if (mapping.desiredPresence === 'absent') { + throwVbenError('端口转发正在删除', HttpStatus.CONFLICT); + } + const protocol = input.protocol || mapping.protocol; + const externalPort = input.externalPort || mapping.externalPort; + const internalPort = input.internalPort || mapping.internalPort; + if ( + mapping.keeperDesiredEnabled && + (protocol !== 'udp' || externalPort !== internalPort) + ) { + throwVbenError( + '请先停用 Keeper 再修改协议或同源端口', + HttpStatus.BAD_REQUEST, + ); + } + const activeKey = portForwardActiveKey(protocol, externalPort); + if (activeKey !== mapping.activeKey) { + const conflict = await manager + .getRepository(NetworkPortForward) + .findOne({ where: { activeKey } }); + if (conflict && conflict.id !== mapping.id) { + throwVbenError('同协议外部端口已存在', HttpStatus.CONFLICT); + } + } + if (input.name !== undefined) { + mapping.name = this.normalizeName(input.name); + } + mapping.remark = + input.remark === undefined + ? mapping.remark + : input.remark.trim() || null; + mapping.protocol = protocol; + mapping.externalPort = externalPort; + mapping.internalPort = internalPort; + mapping.activeKey = activeKey; + mapping.syncStatus = 'pending'; + }); + } + + /** + * Writes an absent tombstone while retaining the active key until explicit Agent proof. + * @param id - Active mapping identifier. + * @returns Latest deleting desired record. + */ + async remove(id: string) { + return this.mutate(id, async (mapping) => { + if (mapping.desiredPresence === 'absent') { + throwVbenError('端口转发正在删除', HttpStatus.CONFLICT); + } + mapping.desiredPresence = 'absent'; + mapping.keeperDesiredEnabled = false; + mapping.probeRequestId = null; + mapping.syncStatus = 'deleting'; + this.withdrawCurrentEndpoint(mapping); + }); + } + + /** + * Advances revision to retry reconciliation without rewriting actual state. + * @param id - Active or deleting mapping identifier. + * @returns Latest desired record. + */ + async retry(id: string) { + return this.mutate(id, async (mapping) => { + mapping.lastErrorCode = null; + mapping.lastErrorMessage = null; + mapping.syncStatus = + mapping.desiredPresence === 'absent' ? 'deleting' : 'pending'; + }); + } + + /** + * Enables continuous STUN only for same-port UDP and emits a new probe request ID. + * @param id - Active mapping identifier. + * @returns Latest desired record. + */ + async enableKeeper(id: string) { + return this.mutate(id, async (mapping) => { + this.assertKeeperCapable(mapping); + mapping.keeperDesiredEnabled = true; + mapping.probeRequestId = randomUUID(); + mapping.syncStatus = 'pending'; + }); + } + + /** + * Disables continuous STUN and immediately hides the current public lease. + * @param id - Active mapping identifier. + * @returns Latest desired record. + */ + async disableKeeper(id: string) { + return this.mutate(id, async (mapping) => { + this.assertKeeperCapable(mapping); + mapping.keeperDesiredEnabled = false; + mapping.probeRequestId = null; + mapping.syncStatus = 'pending'; + this.withdrawCurrentEndpoint(mapping); + }); + } + + /** + * Requests an immediate idempotent probe for an already-enabled UDP Keeper. + * @param id - Active mapping identifier. + * @returns Latest desired record. + */ + async probe(id: string) { + return this.mutate(id, async (mapping) => { + this.assertKeeperCapable(mapping); + if (!mapping.keeperDesiredEnabled) { + throwVbenError('请先启用 UDP Keeper', HttpStatus.BAD_REQUEST); + } + mapping.probeRequestId = randomUUID(); + mapping.syncStatus = 'pending'; + }); + } + + /** + * Lists append-only endpoint events for one active desired mapping. + * @param id - Active mapping identifier. + * @param query - Validated pagination query. + * @returns Endpoint event page ordered newest first. + */ + async endpointHistory( + id: string, + query: NetworkEndpointHistoryQueryDto = {}, + ) { + this.assertId(id); + const mapping = await this.mappingRepository.findOne({ + where: { id, isDeleted: false }, + }); + if (!mapping) throwVbenError('端口转发不存在', HttpStatus.NOT_FOUND); + const pageNo = query.pageNo || 1; + const pageSize = query.pageSize || 20; + const [items, total] = await this.historyRepository.findAndCount({ + order: { occurredAt: 'DESC' }, + skip: (pageNo - 1) * pageSize, + take: pageSize, + where: { mappingId: id }, + }); + return { items: items.map((item) => this.serializeHistory(item)), total }; + } + + /** + * Returns Agent connectivity and the three independent revision cursors. + * @returns Persisted singleton state or a safe offline bootstrap view. + */ + async agentStatus() { + const agentId = this.agentId(); + const state = await this.stateRepository.findOne({ where: { agentId } }); + if (!state) { + return { + agentId, + appliedRevision: '0', + desiredRevision: '0', + lastErrorCode: null, + lastErrorMessage: null, + lastHeartbeatAt: null, + online: false, + publishedRevision: '0', + targetIpv4: this.targetIpv4(), + version: null, + }; + } + return { + agentId: state.agentId, + appliedRevision: state.appliedRevision, + desiredRevision: state.desiredRevision, + lastErrorCode: + state.lastReconcileErrorCode || state.lastMqttErrorCode || null, + lastErrorMessage: + state.lastReconcileErrorMessage || state.lastMqttErrorMessage || null, + lastHeartbeatAt: state.lastHeartbeatAt || null, + lastMqttErrorCode: state.lastMqttErrorCode || null, + lastMqttErrorMessage: state.lastMqttErrorMessage || null, + lastReconcileErrorCode: state.lastReconcileErrorCode || null, + lastReconcileErrorMessage: state.lastReconcileErrorMessage || null, + online: state.online, + publishedRevision: state.publishedRevision, + startedAt: state.startedAt || null, + targetIpv4: state.targetIpv4, + version: state.version || null, + }; + } + + /** + * Serializes one mutation under the singleton pessimistic revision lock. + * @param id - Active mapping identifier. + * @param change - Scoped desired-state mutation executed before revision advance. + * @returns Latest serialized desired record. + */ + private async mutate( + id: string, + change: ( + mapping: NetworkPortForward, + manager: EntityManager, + ) => Promise, + ) { + this.assertId(id); + const saved = await this.dataSource.transaction(async (manager) => { + const state = await this.lockAgentState(manager); + const repository = manager.getRepository(NetworkPortForward); + const mapping = await repository.findOne({ + lock: { mode: 'pessimistic_write' }, + where: { id, isDeleted: false }, + }); + if (!mapping) throwVbenError('端口转发不存在', HttpStatus.NOT_FOUND); + await change(mapping, manager); + this.advanceRevision(state, mapping); + try { + await repository.save(mapping); + await manager.getRepository(NetworkAgentState).save(state); + } catch (error) { + if (this.isDuplicateKeyError(error)) { + throwVbenError('同协议外部端口已存在', HttpStatus.CONFLICT); + } + throw error; + } + return mapping; + }); + this.notifyDesiredChanged(); + return this.serialize(saved); + } + + /** + * Locks or initializes the one configured Agent state row. + * @param manager - Active desired-state transaction manager. + * @returns Pessimistically locked singleton Agent state. + */ + private async lockAgentState( + manager: EntityManager, + ): Promise { + const repository = manager.getRepository(NetworkAgentState); + const agentId = this.agentId(); + await repository + .createQueryBuilder() + .insert() + .into(NetworkAgentState) + .values({ + agentId, + appliedRevision: '0', + desiredIssuedAt: new KtDateTime(), + desiredRevision: '0', + online: false, + publishedRevision: '0', + targetIpv4: this.targetIpv4(), + }) + .orIgnore() + .execute(); + const existing = await repository.findOne({ + lock: { mode: 'pessimistic_write' }, + where: { agentId }, + }); + if (existing) { + if (existing.targetIpv4 !== this.targetIpv4()) { + throwVbenError( + 'Agent 目标 IPv4 与服务端固定配置不一致', + HttpStatus.CONFLICT, + ); + } + return existing; + } + throwVbenError('Agent 状态行初始化失败', HttpStatus.INTERNAL_SERVER_ERROR); + } + + /** + * Increments the global revision exactly once and stamps stable issue time on both rows. + * @param state - Locked singleton Agent state. + * @param mapping - Mapping changed by the current transaction. + */ + private advanceRevision( + state: NetworkAgentState, + mapping: NetworkPortForward, + ): void { + const revision = (BigInt(state.desiredRevision) + 1n).toString(); + const issuedAt = new KtDateTime(); + state.desiredRevision = revision; + state.desiredIssuedAt = issuedAt; + mapping.desiredRevision = revision; + mapping.desiredIssuedAt = issuedAt; + } + + /** Rejects TCP, mismatched ports, and deleting records for raw UDP Keeper actions. */ + private assertKeeperCapable(mapping: NetworkPortForward): void { + if (mapping.desiredPresence !== 'present') { + throwVbenError('删除中的记录不能操作 Keeper', HttpStatus.CONFLICT); + } + if (mapping.protocol !== 'udp') { + throwVbenError('TCP 仅支持端口转发 CRUD', HttpStatus.BAD_REQUEST); + } + if (mapping.externalPort !== mapping.internalPort) { + throwVbenError( + 'UDP Keeper 要求外部端口与内部端口一致', + HttpStatus.BAD_REQUEST, + ); + } + } + + /** Clears only current publishable lease fields while preserving last observation. */ + private withdrawCurrentEndpoint(mapping: NetworkPortForward): void { + mapping.currentPublicIpv4 = null; + mapping.currentPublicPort = null; + mapping.currentObservedAt = null; + mapping.currentValidUntil = null; + } + + /** + * Converts one entity to the Admin contract and hides an expired current endpoint. + * @param mapping - Persisted desired and reported record. + * @returns Response-safe record with bigint fields kept as strings. + */ + private serialize(mapping: NetworkPortForward) { + const leaseValid = + !!mapping.currentPublicIpv4 && + !!mapping.currentPublicPort && + !!mapping.currentValidUntil && + new Date(mapping.currentValidUntil).getTime() > Date.now(); + return { + id: String(mapping.id), + name: mapping.name, + remark: mapping.remark || null, + protocol: mapping.protocol, + externalPort: mapping.externalPort, + internalPort: mapping.internalPort, + targetIpv4: mapping.targetIpv4, + desiredPresence: mapping.desiredPresence, + keeperDesiredEnabled: mapping.keeperDesiredEnabled, + probeRequestId: mapping.probeRequestId || null, + desiredRevision: String(mapping.desiredRevision), + desiredIssuedAt: mapping.desiredIssuedAt, + reportedRevision: String(mapping.reportedRevision), + syncStatus: mapping.syncStatus, + keeperStatus: mapping.keeperStatus, + currentPublicIpv4: leaseValid ? mapping.currentPublicIpv4 : null, + currentPublicPort: leaseValid ? mapping.currentPublicPort : null, + currentPublicEndpoint: leaseValid + ? `${mapping.currentPublicIpv4}:${mapping.currentPublicPort}` + : null, + currentObservedAt: leaseValid ? mapping.currentObservedAt : null, + currentValidUntil: leaseValid ? mapping.currentValidUntil : null, + lastObservedIpv4: mapping.lastObservedIpv4 || null, + lastObservedPort: mapping.lastObservedPort || null, + lastObservedAt: mapping.lastObservedAt || null, + lastErrorCode: mapping.lastErrorCode || null, + lastErrorMessage: mapping.lastErrorMessage || null, + isDeleted: mapping.isDeleted, + createTime: mapping.createTime, + updateTime: mapping.updateTime, + }; + } + + /** + * Converts one append-only endpoint event to the Admin history contract. + * @param history - Persisted endpoint transition. + * @returns Stable string IDs and Admin-facing field names. + */ + private serializeHistory(history: NetworkEndpointHistory) { + return { + id: String(history.id), + eventId: history.eventId, + eventType: history.eventType, + firstObservedAt: history.firstObservedAt, + lastObservedAt: history.lastObservedAt, + occurredAt: history.occurredAt, + portForwardId: String(history.mappingId), + publicIpv4: history.publicIpv4 || null, + publicPort: history.publicPort || null, + withdrawalReason: history.reason || null, + createTime: history.createTime, + }; + } + + /** Schedules MQTT publication without allowing broker errors to reject HTTP state. */ + private notifyDesiredChanged(): void { + try { + this.mqttService.requestDesiredPublish(); + } catch { + // Desired state is durable; the periodic publisher will retry independently. + } + } + + /** Validates decimal Snowflake path input without number coercion. */ + private assertId(id: string): void { + if (!/^\d{1,24}$/.test(id)) { + throwVbenError('端口转发 ID 无效', HttpStatus.BAD_REQUEST); + } + } + + /** Returns the one configured Agent identifier. */ + private agentId(): string { + return ( + this.configService.get('NETWORK_AGENT_ID') || DEFAULT_AGENT_ID + ); + } + + /** Returns and validates the fixed NAS target IPv4. */ + private targetIpv4(): string { + const target = + this.configService.get('NETWORK_AGENT_TARGET_IPV4') || + DEFAULT_TARGET_IPV4; + if (!isIpv4Address(target)) { + throwVbenError( + 'NETWORK_AGENT_TARGET_IPV4 配置无效', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + return target; + } + + /** + * Normalizes a display name and enforces the Go schema-v1 UTF-8 byte limit. + * @param value - DTO-validated mapping display name. + * @returns Trimmed name safe for both MySQL and Agent validation. + */ + private normalizeName(value: string): string { + const normalized = value.trim(); + if (!normalized || Buffer.byteLength(normalized, 'utf8') > 128) { + throwVbenError( + '规则名称超出 Agent UTF-8 长度限制', + HttpStatus.BAD_REQUEST, + ); + } + return normalized; + } + + /** Detects MySQL nullable-unique active-key conflicts. */ + private isDuplicateKeyError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const record = error as { code?: unknown; errno?: unknown }; + return record.code === 'ER_DUP_ENTRY' || record.errno === 1062; + } +} diff --git a/src/modules/admin/platform-config/network-management/network-management.types.ts b/src/modules/admin/platform-config/network-management/network-management.types.ts new file mode 100644 index 0000000..5eade1a --- /dev/null +++ b/src/modules/admin/platform-config/network-management/network-management.types.ts @@ -0,0 +1,776 @@ +import { createHash } from 'node:crypto'; +import type { NetworkAgentState } from './network-agent-state.entity'; +import type { NetworkPortForward } from './network-management.entity'; + +export const NETWORK_AGENT_SCHEMA_VERSION = 1 as const; +export const NETWORK_AGENT_MAX_MAPPINGS = 64; +const RFC3339_NANO_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/; + +export type PortForwardProtocol = 'tcp' | 'udp'; +export type DesiredPresence = 'absent' | 'present'; +export type PortForwardSyncStatus = + | 'conflict' + | 'deleting' + | 'failed' + | 'pending' + | 'synced' + | 'syncing'; +export type KeeperStatus = + | 'active' + | 'disabled' + | 'failed' + | 'stale' + | 'starting'; +export type HelperStatus = 'confirmed' | 'failed' | 'unknown'; +export type EndpointEventType = + | 'changed' + | 'published' + | 'restored' + | 'withdrawn'; + +export type NetworkDesiredMapping = { + externalPort: number; + id: string; + internalPort: number; + keeperDesiredEnabled: boolean; + name: string; + probeRequestId?: string; + protocol: PortForwardProtocol; + state: DesiredPresence; + targetIpv4: string; +}; + +export type NetworkDesiredSnapshot = { + agentId: string; + issuedAt: string; + mappings: NetworkDesiredMapping[]; + revision: number; + schemaVersion: typeof NETWORK_AGENT_SCHEMA_VERSION; + targetIpv4: string; +}; + +export type NetworkEndpointLease = { + observedAt: string; + publicIpv4: string; + publicPort: number; + validUntil: string; +}; + +export type NetworkReportedMapping = { + currentEndpoint?: NetworkEndpointLease; + desiredState: DesiredPresence; + errorCode?: string; + errorMessage?: string; + id: string; + keeperDesiredEnabled: boolean; + keeperStatus: KeeperStatus; + lastObservedEndpoint?: NetworkEndpointLease; + lastProbeRequestId?: string; + revision: number; + routePresent: boolean; + routerPresent: boolean; + syncStatus: PortForwardSyncStatus; +}; + +export type NetworkReportedSnapshot = { + agentId: string; + appliedRevision: number; + desiredDigest: string; + helperAppliedRevision: number; + helperDigest: string; + helperStatus: HelperStatus; + mappings: NetworkReportedMapping[]; + reportedAt: string; + schemaVersion: typeof NETWORK_AGENT_SCHEMA_VERSION; +}; + +export type NetworkStatusSnapshot = { + agentId: string; + errorCode?: string | null; + errorMessage?: string | null; + observedAt: string; + online: boolean; + schemaVersion: typeof NETWORK_AGENT_SCHEMA_VERSION; + startedAt?: string | null; + version?: string | null; +}; + +export type NetworkEndpointEvent = { + agentId: string; + endpoint: NetworkEndpointLease; + eventId: string; + mappingId: string; + occurredAt: string; + reason?: string; + revision: number; + schemaVersion: typeof NETWORK_AGENT_SCHEMA_VERSION; + type: EndpointEventType; +}; + +/** Permanent MQTT schema validation failure safe to acknowledge and drop. */ +export class NetworkMessageValidationError extends Error {} + +/** + * Builds the exact Go schema-v1 desired snapshot with stable field and mapping order. + * @param state - Persisted singleton Agent state. + * @param mappings - Non-finalized mappings, including absent tombstones. + * @returns Complete retained desired snapshot. + */ +export function buildDesiredSnapshot( + state: NetworkAgentState, + mappings: NetworkPortForward[], +): NetworkDesiredSnapshot { + if (mappings.length > NETWORK_AGENT_MAX_MAPPINGS) { + invalid('desired mapping count'); + } + return { + schemaVersion: NETWORK_AGENT_SCHEMA_VERSION, + agentId: state.agentId, + revision: toSafeRevision(state.desiredRevision, 'desiredRevision'), + issuedAt: toIsoString(state.desiredIssuedAt), + targetIpv4: state.targetIpv4, + mappings: [...mappings] + .sort((left, right) => compareIds(left.id, right.id)) + .map((mapping) => ({ + id: String(mapping.id), + name: mapping.name, + protocol: mapping.protocol, + externalPort: mapping.externalPort, + internalPort: mapping.internalPort, + targetIpv4: mapping.targetIpv4, + state: mapping.desiredPresence, + keeperDesiredEnabled: mapping.keeperDesiredEnabled, + ...(mapping.probeRequestId + ? { probeRequestId: mapping.probeRequestId } + : {}), + })), + }; +} + +/** + * Serializes a desired snapshot into deterministic retained MQTT bytes. + * @param snapshot - Snapshot returned by `buildDesiredSnapshot`. + * @returns Stable UTF-8 JSON bytes. + */ +export function desiredSnapshotBytes(snapshot: NetworkDesiredSnapshot): Buffer { + return Buffer.from(JSON.stringify(snapshot), 'utf8'); +} + +/** + * Computes the exact Go canonical semantic digest, excluding revision and issue time. + * @param snapshot - Complete desired snapshot. + * @returns Lowercase hexadecimal SHA-256 digest accepted by the Agent contract. + */ +export function desiredSnapshotDigest( + snapshot: NetworkDesiredSnapshot, +): string { + const mappings = [...snapshot.mappings] + .sort((left, right) => { + const id = compareStrings(left.id, right.id); + if (id !== 0) return id; + const protocol = compareStrings(left.protocol, right.protocol); + return protocol !== 0 ? protocol : left.externalPort - right.externalPort; + }) + .map((mapping) => ({ + id: mapping.id, + name: mapping.name, + protocol: mapping.protocol, + externalPort: mapping.externalPort, + internalPort: mapping.internalPort, + targetIpv4: mapping.targetIpv4, + state: mapping.state, + keeperDesiredEnabled: mapping.keeperDesiredEnabled, + ...(mapping.probeRequestId + ? { probeRequestId: mapping.probeRequestId } + : {}), + })); + return createHash('sha256') + .update( + goJsonStringify({ + schemaVersion: snapshot.schemaVersion, + agentId: snapshot.agentId, + targetIpv4: snapshot.targetIpv4, + mappings, + }), + ) + .digest('hex'); +} + +/** Serializes JSON with the additional HTML and line-separator escaping used by Go. */ +function goJsonStringify(value: unknown): string { + return JSON.stringify(value) + .replace(//g, '\\u003e') + .replace(/&/g, '\\u0026') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); +} + +/** + * Parses the exact Go schema-v1 full reported snapshot without coercion. + * @param value - Untrusted MQTT JSON value. + * @returns Strict reported snapshot safe for transactional consumption. + */ +export function parseReportedSnapshot(value: unknown): NetworkReportedSnapshot { + const record = exactRecord( + value, + [ + 'agentId', + 'appliedRevision', + 'desiredDigest', + 'helperAppliedRevision', + 'helperDigest', + 'helperStatus', + 'mappings', + 'reportedAt', + 'schemaVersion', + ], + [], + 'reported', + ); + assertSchema(record.schemaVersion); + const appliedRevision = positiveRevision( + record.appliedRevision, + 'appliedRevision', + ); + const reportedAt = isoString(record.reportedAt, 'reportedAt'); + const helperStatus = enumValue( + record.helperStatus, + ['confirmed', 'failed', 'unknown'] as const, + 'helperStatus', + ); + const helperAppliedRevision = safeRevision( + record.helperAppliedRevision, + 'helperAppliedRevision', + ); + const helperDigest = stringValue( + record.helperDigest, + 'helperDigest', + 64, + true, + ); + validateHelperState(helperStatus, helperAppliedRevision, helperDigest); + if (!Array.isArray(record.mappings)) invalid('reported.mappings'); + if (record.mappings.length > NETWORK_AGENT_MAX_MAPPINGS) { + invalid('reported.mappings'); + } + const mappings = record.mappings.map((mapping, index) => + parseReportedMapping(mapping, index, appliedRevision, reportedAt), + ); + if (new Set(mappings.map((mapping) => mapping.id)).size !== mappings.length) { + invalid('reported mapping duplicate'); + } + return { + agentId: boundedString(record.agentId, 'agentId', 64), + appliedRevision, + desiredDigest: digest(record.desiredDigest, 'desiredDigest'), + helperAppliedRevision, + helperDigest, + helperStatus, + mappings, + reportedAt, + schemaVersion: NETWORK_AGENT_SCHEMA_VERSION, + }; +} + +/** + * Parses the retained Agent liveness contract used by the runtime/LWT layer. + * @param value - Untrusted MQTT JSON value. + * @returns Strict liveness status independent from mapping state. + */ +export function parseStatusSnapshot(value: unknown): NetworkStatusSnapshot { + const record = exactRecord( + value, + ['agentId', 'observedAt', 'online', 'schemaVersion'], + ['errorCode', 'errorMessage', 'startedAt', 'version'], + 'status', + ); + assertSchema(record.schemaVersion); + if (typeof record.online !== 'boolean') invalid('status.online'); + return { + agentId: boundedString(record.agentId, 'agentId', 64), + errorCode: optionalString(record.errorCode, 'errorCode', 64), + errorMessage: optionalString(record.errorMessage, 'errorMessage', 500), + observedAt: isoString(record.observedAt, 'observedAt'), + online: record.online, + schemaVersion: NETWORK_AGENT_SCHEMA_VERSION, + startedAt: optionalIsoString(record.startedAt, 'startedAt'), + version: optionalString(record.version, 'version', 64), + }; +} + +/** + * Parses the exact Go schema-v1 append-only endpoint event. + * @param value - Untrusted MQTT JSON value. + * @returns Strict idempotent endpoint transition. + */ +export function parseEndpointEvent(value: unknown): NetworkEndpointEvent { + const record = exactRecord( + value, + [ + 'agentId', + 'endpoint', + 'eventId', + 'mappingId', + 'occurredAt', + 'revision', + 'schemaVersion', + 'type', + ], + ['reason'], + 'event', + ); + assertSchema(record.schemaVersion); + return { + agentId: boundedString(record.agentId, 'agentId', 64), + endpoint: parseEndpointLease(record.endpoint, 'event.endpoint'), + eventId: requestId(record.eventId, 'eventId'), + mappingId: idString(record.mappingId, 'mappingId'), + occurredAt: isoString(record.occurredAt, 'occurredAt'), + reason: optionalString(record.reason, 'reason', 128) || undefined, + revision: positiveRevision(record.revision, 'revision'), + schemaVersion: NETWORK_AGENT_SCHEMA_VERSION, + type: enumValue( + record.type, + ['changed', 'published', 'restored', 'withdrawn'] as const, + 'event.type', + ), + }; +} + +/** + * Validates a canonical IPv4 string without accepting octal-looking octets. + * @param value - Untrusted address text. + * @returns True only for four canonical decimal octets. + */ +export function isIpv4Address(value: string): boolean { + const parts = value.split('.'); + return ( + parts.length === 4 && + parts.every((part) => { + if (!/^\d{1,3}$/.test(part)) return false; + const octet = Number(part); + return octet >= 0 && octet <= 255 && `${octet}` === part; + }) + ); +} + +/** + * Produces the nullable-unique active key for one protocol and WAN port. + * @param protocol - TCP or UDP desired protocol. + * @param externalPort - WAN-side port. + * @returns Stable database key. + */ +export function portForwardActiveKey( + protocol: PortForwardProtocol, + externalPort: number, +): string { + return `${protocol}:${externalPort}`; +} + +/** Parses one mapping using the exact Go MappingReport contract. */ +function parseReportedMapping( + value: unknown, + index: number, + appliedRevision: number, + reportedAt: string, +): NetworkReportedMapping { + const record = exactRecord( + value, + [ + 'desiredState', + 'id', + 'keeperDesiredEnabled', + 'keeperStatus', + 'revision', + 'routePresent', + 'routerPresent', + 'syncStatus', + ], + [ + 'currentEndpoint', + 'errorCode', + 'errorMessage', + 'lastObservedEndpoint', + 'lastProbeRequestId', + ], + `reported.mappings[${index}]`, + ); + const revision = positiveRevision(record.revision, 'mapping.revision'); + if (revision !== appliedRevision) invalid('mapping.revision'); + if ( + typeof record.routerPresent !== 'boolean' || + typeof record.routePresent !== 'boolean' || + typeof record.keeperDesiredEnabled !== 'boolean' + ) { + invalid('mapping booleans'); + } + const desiredState = enumValue( + record.desiredState, + ['absent', 'present'] as const, + 'desiredState', + ); + const syncStatus = enumValue( + record.syncStatus, + ['conflict', 'deleting', 'failed', 'pending', 'synced', 'syncing'] as const, + 'syncStatus', + ); + const keeperStatus = enumValue( + record.keeperStatus, + ['active', 'disabled', 'failed', 'stale', 'starting'] as const, + 'keeperStatus', + ); + const currentEndpoint = optionalEndpointLease( + record.currentEndpoint, + 'currentEndpoint', + ); + const lastObservedEndpoint = optionalEndpointLease( + record.lastObservedEndpoint, + 'lastObservedEndpoint', + ); + if ( + currentEndpoint && + (!lastObservedEndpoint || + !record.keeperDesiredEnabled || + new Date(currentEndpoint.validUntil).getTime() <= + new Date(reportedAt).getTime()) + ) { + invalid('current endpoint evidence'); + } + if ( + desiredState === 'absent' && + syncStatus === 'synced' && + (record.routerPresent || + record.routePresent || + record.keeperDesiredEnabled || + keeperStatus !== 'disabled' || + currentEndpoint) + ) { + invalid('absent deletion evidence'); + } + if ( + desiredState === 'present' && + syncStatus === 'synced' && + (!record.routerPresent || !record.routePresent) + ) { + invalid('present route evidence'); + } + const errorCode = optionalString(record.errorCode, 'errorCode', 64); + if (errorCode && !/^[a-z0-9_]{1,64}$/.test(errorCode)) { + invalid('errorCode'); + } + return { + currentEndpoint, + desiredState, + errorCode: errorCode || undefined, + errorMessage: + optionalString(record.errorMessage, 'errorMessage', 512) || undefined, + id: idString(record.id, 'mapping.id'), + keeperDesiredEnabled: record.keeperDesiredEnabled, + keeperStatus, + lastObservedEndpoint, + lastProbeRequestId: + optionalRequestId(record.lastProbeRequestId, 'lastProbeRequestId') || + undefined, + revision, + routePresent: record.routePresent, + routerPresent: record.routerPresent, + syncStatus, + }; +} + +/** Parses a required endpoint lease and mirrors Go public-address checks. */ +function parseEndpointLease( + value: unknown, + label: string, +): NetworkEndpointLease { + const record = exactRecord( + value, + ['observedAt', 'publicIpv4', 'publicPort', 'validUntil'], + [], + label, + ); + const observedAt = isoString(record.observedAt, `${label}.observedAt`); + const validUntil = isoString(record.validUntil, `${label}.validUntil`); + if (new Date(validUntil).getTime() <= new Date(observedAt).getTime()) { + invalid(`${label}.validUntil`); + } + const publicIpv4 = ipv4(record.publicIpv4, `${label}.publicIpv4`); + if (!isPublicIpv4(publicIpv4)) invalid(`${label}.publicIpv4`); + return { + observedAt, + publicIpv4, + publicPort: port(record.publicPort, `${label}.publicPort`), + validUntil, + }; +} + +/** Parses an omitted-or-present endpoint lease from Go `omitempty` fields. */ +function optionalEndpointLease( + value: unknown, + label: string, +): NetworkEndpointLease | undefined { + return value === undefined ? undefined : parseEndpointLease(value, label); +} + +/** Validates the helper revision/digest invariants from Go schema-v1. */ +function validateHelperState( + status: HelperStatus, + revision: number, + helperDigest: string, +): void { + if (status === 'confirmed' && (revision === 0 || !isDigest(helperDigest))) { + invalid('confirmed helper state'); + } + if ( + status === 'failed' && + !( + (revision === 0 && helperDigest === '') || + (revision > 0 && isDigest(helperDigest)) + ) + ) { + invalid('failed helper state'); + } + if (status === 'unknown' && (revision !== 0 || helperDigest !== '')) { + invalid('unknown helper state'); + } +} + +/** Requires an object to contain all and only declared keys. */ +function exactRecord( + value: unknown, + required: readonly string[], + optional: readonly string[], + label: string, +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + invalid(label); + } + const record = value as Record; + const allowed = new Set([...required, ...optional]); + if ( + required.some((key) => !Object.hasOwn(record, key)) || + Object.keys(record).some((key) => !allowed.has(key)) + ) { + invalid(label); + } + return record; +} + +/** Throws the stable validation error without including payload content. */ +function invalid(label: string): never { + throw new NetworkMessageValidationError(`Invalid network message: ${label}`); +} + +/** Validates the one supported schema version. */ +function assertSchema(value: unknown): void { + if (value !== NETWORK_AGENT_SCHEMA_VERSION) invalid('schemaVersion'); +} + +/** Parses a bounded non-empty string. */ +function boundedString(value: unknown, label: string, max: number): string { + if ( + typeof value !== 'string' || + !value || + Buffer.byteLength(value, 'utf8') > max + ) { + invalid(label); + } + return value; +} + +/** Parses a bounded optionally-empty string. */ +function stringValue( + value: unknown, + label: string, + max: number, + allowEmpty: boolean, +): string { + if ( + typeof value !== 'string' || + Buffer.byteLength(value, 'utf8') > max || + (!allowEmpty && !value) + ) { + invalid(label); + } + return value; +} + +/** Parses a nullable or omitted bounded string. */ +function optionalString( + value: unknown, + label: string, + max: number, +): string | null | undefined { + if (value === undefined || value === null) { + return value as null | undefined; + } + return stringValue(value, label, max, true); +} + +/** Parses a decimal mapping ID. */ +function idString(value: unknown, label: string): string { + const text = boundedString(value, label, 32); + if (!/^\d{1,32}$/.test(text)) invalid(label); + return text; +} + +/** Parses the Go request/event ID character contract. */ +function requestId(value: unknown, label: string): string { + const text = boundedString(value, label, 128); + if (!/^[A-Za-z0-9_-]{1,128}$/.test(text)) invalid(label); + return text; +} + +/** Parses an omitted request ID. */ +function optionalRequestId(value: unknown, label: string): string | undefined { + return value === undefined ? undefined : requestId(value, label); +} + +/** Parses the RFC3339Nano timestamps emitted by Go time.Time JSON. */ +function isoString(value: unknown, label: string): string { + if (typeof value !== 'string') invalid(label); + const match = RFC3339_NANO_PATTERN.exec(value); + if (!match) invalid(label); + const [, yearText, monthText, dayText, hourText, minuteText, secondText] = + match; + const year = Number(yearText); + const month = Number(monthText); + const day = Number(dayText); + const hour = Number(hourText); + const minute = Number(minuteText); + const second = Number(secondText); + if ( + month < 1 || + month > 12 || + day < 1 || + day > daysInMonth(year, month) || + hour > 23 || + minute > 59 || + second > 59 + ) { + invalid(label); + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) invalid(label); + return value; +} + +/** Returns the Gregorian day count for one validated year and month. */ +function daysInMonth(year: number, month: number): number { + if (month === 2) { + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + return leap ? 29 : 28; + } + return [4, 6, 9, 11].includes(month) ? 30 : 31; +} + +/** Parses a nullable or omitted exact ISO timestamp. */ +function optionalIsoString( + value: unknown, + label: string, +): string | null | undefined { + if (value === undefined || value === null) { + return value as null | undefined; + } + return isoString(value, label); +} + +/** Parses a safe non-negative JSON revision. */ +function safeRevision(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || Number(value) < 0) invalid(label); + return Number(value); +} + +/** Parses a safe positive JSON revision. */ +function positiveRevision(value: unknown, label: string): number { + const revision = safeRevision(value, label); + if (revision === 0) invalid(label); + return revision; +} + +/** Converts a persisted bigint revision into the schema-v1 JSON number. */ +function toSafeRevision(value: string, label: string): number { + return positiveRevision(Number(value), label); +} + +/** Parses an integer port without coercion. */ +function port(value: unknown, label: string): number { + if (!Number.isInteger(value) || Number(value) < 1 || Number(value) > 65535) { + invalid(label); + } + return Number(value); +} + +/** Parses a canonical IPv4 address. */ +function ipv4(value: unknown, label: string): string { + if (typeof value !== 'string' || !isIpv4Address(value)) invalid(label); + return value; +} + +/** Parses one fixed string enum. */ +function enumValue( + value: unknown, + allowed: readonly T[], + label: string, +): T { + if (typeof value !== 'string' || !allowed.includes(value as T)) { + invalid(label); + } + return value as T; +} + +/** Parses one 64-character hexadecimal digest. */ +function digest(value: unknown, label: string): string { + if (typeof value !== 'string' || !isDigest(value)) invalid(label); + return value; +} + +/** Checks the Go canonical SHA-256 digest representation. */ +function isDigest(value: string): boolean { + return /^[0-9a-f]{64}$/.test(value); +} + +/** Mirrors Go STUN public IPv4 exclusions for reported leases. */ +function isPublicIpv4(value: string): boolean { + const [a, b, c] = value.split('.').map(Number); + if ( + a === 0 || + a === 10 || + a === 127 || + a >= 224 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 192 && b === 0 && c === 0) || + (a === 192 && b === 0 && c === 2) || + (a === 192 && b === 88 && c === 99) || + (a === 198 && (b === 18 || b === 19)) || + (a === 198 && b === 51 && c === 100) || + (a === 203 && b === 0 && c === 113) + ) { + return false; + } + return true; +} + +/** Converts a TypeORM date value into exact ISO text. */ +function toIsoString(value: Date | string): string { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) invalid('issuedAt'); + return date.toISOString(); +} + +/** Sorts decimal IDs numerically without bigint precision loss. */ +function compareIds(left: string, right: string): number { + const leftValue = BigInt(left); + const rightValue = BigInt(right); + return leftValue < rightValue ? -1 : leftValue > rightValue ? 1 : 0; +} + +/** Compares ASCII contract identifiers without locale-dependent collation. */ +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/src/runtime/config/runtime-config.service.ts b/src/runtime/config/runtime-config.service.ts index a4ff202..da929b0 100644 --- a/src/runtime/config/runtime-config.service.ts +++ b/src/runtime/config/runtime-config.service.ts @@ -20,6 +20,13 @@ const REQUIRED_CONFIG_KEYS = [ 'DB_PASSWORD', 'DB_DATABASE', 'ADMIN_TOKEN_SECRET', + 'NETWORK_AGENT_ID', + 'NETWORK_AGENT_TARGET_IPV4', + 'NETWORK_AGENT_MQTT_URL', + 'NETWORK_AGENT_MQTT_CLIENT_ID', + 'NETWORK_AGENT_MQTT_USERNAME', + 'NETWORK_AGENT_MQTT_PASSWORD', + 'NETWORK_AGENT_MQTT_RETRY_MS', ] as const; const OPTIONAL_CONFIG_CHECKS: ReadonlyArray = [ diff --git a/test/admin/network-management/network-agent-mqtt.service.spec.ts b/test/admin/network-management/network-agent-mqtt.service.spec.ts new file mode 100644 index 0000000..391d9ed --- /dev/null +++ b/test/admin/network-management/network-agent-mqtt.service.spec.ts @@ -0,0 +1,701 @@ +import { EventEmitter } from 'node:events'; +import type { ConfigService } from '@nestjs/config'; +import type { DataSource, EntityManager, Repository } from 'typeorm'; +import type { IClientOptions, MqttClient } from 'mqtt'; +import { KtDateTime } from '../../../src/common'; +import { + NetworkAgentMqttService, + type NetworkMqttClientFactory, +} from '../../../src/modules/admin/platform-config/network-management/network-agent-mqtt.service'; +import { NetworkAgentState } from '../../../src/modules/admin/platform-config/network-management/network-agent-state.entity'; +import { NetworkEndpointHistory } from '../../../src/modules/admin/platform-config/network-management/network-endpoint-history.entity'; +import { NetworkPortForward } from '../../../src/modules/admin/platform-config/network-management/network-management.entity'; +import { + buildDesiredSnapshot, + desiredSnapshotDigest, +} from '../../../src/modules/admin/platform-config/network-management/network-management.types'; + +type MqttHarness = { + client: MqttClient & EventEmitter; + clientOptions: () => IClientOptions; + histories: NetworkEndpointHistory[]; + mapping: NetworkPortForward; + publishCallback: () => (error?: Error) => void; + service: NetworkAgentMqttService; + state: NetworkAgentState; + transactionCalls: () => number; +}; + +/** Creates a fake MQTT client plus in-memory TypeORM state for bridge tests. */ +function createHarness(): MqttHarness { + const state = Object.assign(new NetworkAgentState(), { + agentId: 'nas-main', + appliedRevision: '0', + desiredIssuedAt: new KtDateTime('2026-07-22T01:02:03.000Z'), + desiredRevision: '7', + online: false, + publishedRevision: '0', + targetIpv4: '192.168.31.224', + }); + const mapping = Object.assign(new NetworkPortForward(), { + activeKey: 'udp:9000', + currentObservedAt: null, + currentPublicIpv4: null, + currentPublicPort: null, + currentValidUntil: null, + desiredIssuedAt: state.desiredIssuedAt, + desiredPresence: 'present', + desiredRevision: '7', + externalPort: 9000, + id: '100', + internalPort: 9000, + isDeleted: false, + keeperDesiredEnabled: true, + keeperStatus: 'starting', + name: 'rule', + protocol: 'udp', + reportedRevision: '0', + syncStatus: 'pending', + targetIpv4: '192.168.31.224', + }); + const histories: NetworkEndpointHistory[] = []; + const stateRepository = { + findOne: async () => state, + save: async (value) => Object.assign(state, value), + } as unknown as Repository; + const mappingRepository = { + find: async () => (mapping.isDeleted ? [] : [mapping]), + findOne: async ({ where }) => (where.id === mapping.id ? mapping : null), + save: async (value) => Object.assign(mapping, value), + } as unknown as Repository; + const historyRepository = { + create: (input) => Object.assign(new NetworkEndpointHistory(), input), + findOne: async ({ where }) => + histories.find((item) => item.eventId === where.eventId) || null, + save: async (value) => { + histories.push(value); + return value; + }, + } as unknown as Repository; + const manager = { + getRepository: (entity) => { + if (entity === NetworkAgentState) return stateRepository; + if (entity === NetworkPortForward) return mappingRepository; + if (entity === NetworkEndpointHistory) return historyRepository; + throw new Error('unexpected repository'); + }, + } as unknown as EntityManager; + let transactionCallCount = 0; + const dataSource = { + getRepository: manager.getRepository.bind(manager), + transaction: async (work) => { + transactionCallCount += 1; + return await work(manager); + }, + } as unknown as DataSource; + const configService = { + get: (key) => { + const values = { + NETWORK_AGENT_ID: 'nas-main', + NETWORK_AGENT_MQTT_RETRY_MS: '60000', + NETWORK_AGENT_MQTT_URL: 'mqtt://broker.test:1883', + }; + return values[key]; + }, + } as ConfigService; + + const client = new EventEmitter() as MqttClient & EventEmitter; + client.connected = true; + let options: IClientOptions; + let publishAck: (error?: Error) => void = () => undefined; + client.subscribe = jest.fn((_topics, optionsOrCallback, callback) => { + const done = + typeof optionsOrCallback === 'function' ? optionsOrCallback : callback; + done?.(undefined, []); + return client; + }) as unknown as MqttClient['subscribe']; + client.publish = jest.fn((_topic, _payload, _options, callback) => { + publishAck = callback as (error?: Error) => void; + return client; + }) as unknown as MqttClient['publish']; + client.reconnect = jest.fn(() => client) as MqttClient['reconnect']; + client.end = jest.fn((_force, _opts, callback) => { + callback?.(); + return client; + }) as unknown as MqttClient['end']; + const factory: NetworkMqttClientFactory = (_url, clientOptions) => { + options = clientOptions; + return client; + }; + const service = new NetworkAgentMqttService( + configService, + dataSource, + factory, + ); + return { + client, + clientOptions: () => options, + histories, + mapping, + publishCallback: () => publishAck, + service, + state, + transactionCalls: () => transactionCallCount, + }; +} + +/** Waits for promise continuations scheduled by the MQTT bridge. */ +async function flushPromises(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +/** Mirrors MQTT.js by emitting callback errors through the client error event. */ +function createProtocolAck( + client: MqttClient & EventEmitter, +): jest.Mock { + return jest.fn((error: Error | number) => { + if (error instanceof Error) client.emit('error', error); + }); +} + +/** Builds one valid full reported snapshot. */ +function reported( + harness: MqttHarness, + revision: number, + overrides: Record = {}, +) { + const digest = + revision === Number(harness.state.desiredRevision) + ? desiredSnapshotDigest( + buildDesiredSnapshot(harness.state, [harness.mapping]), + ) + : 'd'.repeat(64); + return Buffer.from( + JSON.stringify({ + agentId: 'nas-main', + appliedRevision: revision, + desiredDigest: digest, + helperAppliedRevision: revision, + helperDigest: 'e'.repeat(64), + helperStatus: 'confirmed', + mappings: [ + { + currentEndpoint: { + observedAt: '2026-07-22T01:02:04.000Z', + publicIpv4: '8.8.8.8', + publicPort: 45000, + validUntil: '2026-07-22T01:04:04.000Z', + }, + desiredState: harness.mapping.desiredPresence, + id: '100', + keeperDesiredEnabled: harness.mapping.keeperDesiredEnabled, + keeperStatus: 'active', + lastObservedEndpoint: { + observedAt: '2026-07-22T01:02:04.000Z', + publicIpv4: '8.8.8.8', + publicPort: 45000, + validUntil: '2026-07-22T01:04:04.000Z', + }, + revision, + routePresent: true, + routerPresent: true, + syncStatus: 'synced', + ...overrides, + }, + ], + reportedAt: '2026-07-22T01:02:04.000Z', + schemaVersion: 1, + }), + ); +} + +describe('NetworkAgentMqttService', () => { + it('delays inbound QoS 1 acknowledgement until DB consumption resolves', async () => { + const harness = createHarness(); + let complete: () => void = () => undefined; + jest.spyOn(harness.service, 'consumeMessage').mockImplementation( + async () => + await new Promise((resolve) => { + complete = resolve; + }), + ); + harness.service.onModuleInit(); + const ack = jest.fn(); + + harness + .clientOptions() + .customHandleAcks?.( + 'kt/network/v1/agents/nas-main/reported', + Buffer.from('{}'), + { qos: 1 }, + ack, + ); + await flushPromises(); + expect(ack).not.toHaveBeenCalled(); + + complete(); + await flushPromises(); + expect(ack).toHaveBeenCalledWith(0); + await harness.service.onModuleDestroy(); + }); + + it('forces one persistent-session reconnect after a transient inbound transaction failure', async () => { + const harness = createHarness(); + jest + .spyOn(harness.service, 'consumeMessage') + .mockRejectedValueOnce(new Error('database unavailable')); + harness.service.onModuleInit(); + const ack = createProtocolAck(harness.client); + + harness + .clientOptions() + .customHandleAcks?.( + 'kt/network/v1/agents/nas-main/reported', + Buffer.from('{}'), + { qos: 1 }, + ack, + ); + await flushPromises(); + + expect(ack).toHaveBeenCalledWith(expect.any(Error)); + expect(harness.client.end).toHaveBeenCalledWith( + true, + {}, + expect.any(Function), + ); + expect(harness.client.reconnect).toHaveBeenCalledTimes(1); + expect(harness.clientOptions()).toMatchObject({ + clean: false, + clientId: 'kt-template-online-api-network-nas-main', + }); + harness.client.emit('error', new Error('duplicate recovery signal')); + expect(harness.client.end).toHaveBeenCalledTimes(1); + expect(harness.client.reconnect).toHaveBeenCalledTimes(1); + await harness.service.onModuleDestroy(); + }); + + it('acknowledges and drops permanent malformed messages without reconnecting', async () => { + const harness = createHarness(); + harness.service.onModuleInit(); + const ack = createProtocolAck(harness.client); + + harness + .clientOptions() + .customHandleAcks?.( + 'kt/network/v1/agents/nas-main/reported', + Buffer.from('{'), + { qos: 1 }, + ack, + ); + await flushPromises(); + + expect(ack).toHaveBeenCalledWith(0); + expect(harness.client.end).not.toHaveBeenCalled(); + expect(harness.client.reconnect).not.toHaveBeenCalled(); + await harness.service.onModuleDestroy(); + }); + + it('recovers from SUBACK failure before restoring exact subscriptions and forced retained desired publication', async () => { + const harness = createHarness(); + harness.state.publishedRevision = harness.state.desiredRevision; + const subscribe = harness.client.subscribe as jest.Mock; + subscribe + .mockImplementationOnce((_topics, optionsOrCallback, callback) => { + const done = + typeof optionsOrCallback === 'function' + ? optionsOrCallback + : callback; + done?.(new Error('SUBACK rejected')); + return harness.client; + }) + .mockImplementationOnce((_topics, optionsOrCallback, callback) => { + const done = + typeof optionsOrCallback === 'function' + ? optionsOrCallback + : callback; + done?.(undefined, []); + return harness.client; + }); + const expectedTopics = { + 'kt/network/v1/agents/nas-main/events': { qos: 1 }, + 'kt/network/v1/agents/nas-main/reported': { qos: 1 }, + 'kt/network/v1/agents/nas-main/status': { qos: 1 }, + }; + harness.service.onModuleInit(); + + harness.client.emit('connect'); + expect(subscribe).toHaveBeenNthCalledWith( + 1, + expectedTopics, + expect.any(Function), + ); + expect(harness.client.end).toHaveBeenCalledWith( + true, + {}, + expect.any(Function), + ); + expect(harness.client.reconnect).toHaveBeenCalledTimes(1); + expect(harness.client.publish).not.toHaveBeenCalled(); + + harness.client.emit('connect'); + await flushPromises(); + expect(subscribe).toHaveBeenNthCalledWith( + 2, + expectedTopics, + expect.any(Function), + ); + expect(harness.client.publish).toHaveBeenCalledWith( + 'kt/network/v1/agents/nas-main/desired', + expect.any(Buffer), + { qos: 1, retain: true }, + expect.any(Function), + ); + + harness.publishCallback()(); + await flushPromises(); + await harness.service.onModuleDestroy(); + }); + + it('does not reconnect a recovery that finishes after module shutdown', async () => { + const harness = createHarness(); + let finishRecovery: () => void = () => undefined; + (harness.client.end as jest.Mock).mockImplementation( + (force, _opts, callback) => { + if (force) finishRecovery = callback; + else callback?.(); + return harness.client; + }, + ); + harness.service.onModuleInit(); + + harness.client.emit('error', new Error('transient protocol failure')); + expect(harness.client.end).toHaveBeenNthCalledWith( + 1, + true, + {}, + expect.any(Function), + ); + await harness.service.onModuleDestroy(); + finishRecovery(); + + expect(harness.client.reconnect).not.toHaveBeenCalled(); + }); + + it('uses a persistent MQTT 5 session and advances published revision only after PUBACK', async () => { + const harness = createHarness(); + harness.service.onModuleInit(); + harness.client.emit('connect'); + await flushPromises(); + + expect(harness.clientOptions()).toMatchObject({ + clean: false, + clientId: 'kt-template-online-api-network-nas-main', + protocolVersion: 5, + }); + expect(harness.client.publish).toHaveBeenCalledWith( + 'kt/network/v1/agents/nas-main/desired', + expect.any(Buffer), + { qos: 1, retain: true }, + expect.any(Function), + ); + expect(harness.state.publishedRevision).toBe('0'); + expect(harness.transactionCalls()).toBe(1); + + harness.publishCallback()(); + await flushPromises(); + expect(harness.state.publishedRevision).toBe('7'); + expect(harness.transactionCalls()).toBe(2); + await harness.service.onModuleDestroy(); + }); + + it('defers failed publications to the bounded retry timer instead of spinning', async () => { + const harness = createHarness(); + harness.service.onModuleInit(); + + harness.service.requestDesiredPublish(); + await flushPromises(); + expect(harness.client.publish).toHaveBeenCalledTimes(1); + + harness.publishCallback()(new Error('broker unavailable')); + await flushPromises(); + await flushPromises(); + expect(harness.client.publish).toHaveBeenCalledTimes(1); + expect(harness.state.publishedRevision).toBe('0'); + await harness.service.onModuleDestroy(); + }); + + it('protects newer desired state from stale reported data and refreshes same-revision leases', async () => { + const harness = createHarness(); + const topic = 'kt/network/v1/agents/nas-main/reported'; + + await harness.service.consumeMessage(topic, reported(harness, 7)); + expect(harness.mapping.currentPublicPort).toBe(45000); + + harness.mapping.desiredRevision = '8'; + harness.mapping.syncStatus = 'pending'; + harness.state.desiredRevision = '8'; + await harness.service.consumeMessage( + topic, + reported(harness, 7, { + currentEndpoint: { + observedAt: '2026-07-22T01:03:04.000Z', + publicIpv4: '8.8.4.4', + publicPort: 49999, + validUntil: '2026-07-22T01:05:04.000Z', + }, + }), + ); + expect(harness.mapping.currentPublicPort).toBe(45000); + expect(harness.mapping.syncStatus).toBe('pending'); + + await harness.service.consumeMessage( + topic, + reported(harness, 8, { + currentEndpoint: { + observedAt: '2026-07-22T01:03:04.000Z', + publicIpv4: '8.8.8.8', + publicPort: 45000, + validUntil: '2026-07-22T01:06:04.000Z', + }, + }), + ); + expect(harness.mapping.currentValidUntil?.toISOString()).toBe( + '2026-07-22T01:06:04.000Z', + ); + }); + + it('does not let an out-of-order same-revision withdrawal erase a newer lease', async () => { + const harness = createHarness(); + const topic = 'kt/network/v1/agents/nas-main/reported'; + await harness.service.consumeMessage(topic, reported(harness, 7)); + + const staleWithdrawal = JSON.parse(reported(harness, 7).toString('utf8')); + delete staleWithdrawal.mappings[0].currentEndpoint; + staleWithdrawal.reportedAt = '2026-07-22T01:02:03.000Z'; + await harness.service.consumeMessage( + topic, + Buffer.from(JSON.stringify(staleWithdrawal)), + ); + + expect(harness.mapping.currentPublicPort).toBe(45000); + }); + + it('ignores lower applied revisions and rejects a wrong digest for the current revision', async () => { + const harness = createHarness(); + const topic = 'kt/network/v1/agents/nas-main/reported'; + await harness.service.consumeMessage(topic, reported(harness, 7)); + const originalPort = harness.mapping.currentPublicPort; + + await harness.service.consumeMessage( + topic, + reported(harness, 6, { + currentEndpoint: undefined, + errorCode: 'old_failure', + errorMessage: 'stale', + syncStatus: 'failed', + }), + ); + expect(harness.mapping.currentPublicPort).toBe(originalPort); + expect(harness.mapping.lastErrorCode).toBeNull(); + + const wrongDigest = JSON.parse(reported(harness, 7).toString('utf8')); + wrongDigest.desiredDigest = 'f'.repeat(64); + await expect( + harness.service.consumeMessage( + topic, + Buffer.from(JSON.stringify(wrongDigest)), + ), + ).rejects.toThrow('digest'); + }); + + it('rejects extra mapping IDs in a current full reported snapshot', async () => { + const harness = createHarness(); + const topic = 'kt/network/v1/agents/nas-main/reported'; + const payload = JSON.parse(reported(harness, 7).toString('utf8')); + payload.mappings.push({ + desiredState: 'absent', + id: '999', + keeperDesiredEnabled: false, + keeperStatus: 'disabled', + revision: 7, + routePresent: false, + routerPresent: false, + syncStatus: 'synced', + }); + + await expect( + harness.service.consumeMessage( + topic, + Buffer.from(JSON.stringify(payload)), + ), + ).rejects.toThrow('Unknown mapping'); + }); + + it('rejects Agent Keeper intent that differs from the persisted desired state', async () => { + const harness = createHarness(); + const topic = 'kt/network/v1/agents/nas-main/reported'; + harness.mapping.keeperDesiredEnabled = false; + const payload = JSON.parse(reported(harness, 7).toString('utf8')); + payload.mappings[0].keeperDesiredEnabled = true; + + await expect( + harness.service.consumeMessage( + topic, + Buffer.from(JSON.stringify(payload)), + ), + ).rejects.toThrow('Keeper intent'); + expect(harness.mapping.currentPublicIpv4).toBeNull(); + }); + + it.each([ + ['unknown', 0, ''], + ['failed', 8, 'e'.repeat(64)], + ['confirmed', 7, 'e'.repeat(64)], + ])( + 'keeps a deletion tombstone when global helper state is not current (%s)', + async (helperStatus, helperAppliedRevision, helperDigest) => { + const harness = createHarness(); + const topic = 'kt/network/v1/agents/nas-main/reported'; + harness.mapping.desiredPresence = 'absent'; + harness.mapping.desiredRevision = '8'; + harness.mapping.keeperDesiredEnabled = false; + harness.mapping.syncStatus = 'deleting'; + harness.state.desiredRevision = '8'; + const payload = JSON.parse( + reported(harness, 8, { + currentEndpoint: undefined, + desiredState: 'absent', + keeperDesiredEnabled: false, + keeperStatus: 'disabled', + routePresent: false, + routerPresent: false, + }).toString('utf8'), + ); + payload.helperAppliedRevision = helperAppliedRevision; + payload.helperDigest = helperDigest; + payload.helperStatus = helperStatus; + + await harness.service.consumeMessage( + topic, + Buffer.from(JSON.stringify(payload)), + ); + + expect(harness.mapping.isDeleted).toBe(false); + expect(harness.mapping.activeKey).toBe('udp:9000'); + expect(harness.state.desiredRevision).toBe('8'); + }, + ); + + it('finalizes deletion only with confirmed router, route, Keeper, helper, and endpoint absence', async () => { + const harness = createHarness(); + const topic = 'kt/network/v1/agents/nas-main/reported'; + harness.mapping.desiredPresence = 'absent'; + harness.mapping.desiredRevision = '8'; + harness.mapping.keeperDesiredEnabled = false; + harness.mapping.syncStatus = 'deleting'; + harness.state.desiredRevision = '8'; + + await harness.service.consumeMessage( + topic, + reported(harness, 8, { + currentEndpoint: undefined, + desiredState: 'absent', + keeperDesiredEnabled: false, + keeperStatus: 'disabled', + routePresent: false, + routerPresent: true, + syncStatus: 'deleting', + }), + ); + expect(harness.mapping.isDeleted).toBe(false); + expect(harness.mapping.activeKey).toBe('udp:9000'); + + await harness.service.consumeMessage( + topic, + reported(harness, 8, { + currentEndpoint: undefined, + desiredState: 'absent', + keeperDesiredEnabled: false, + keeperStatus: 'disabled', + routePresent: false, + routerPresent: false, + }), + ); + expect(harness.mapping.isDeleted).toBe(true); + expect(harness.mapping.activeKey).toBeNull(); + expect(harness.state.desiredRevision).toBe('9'); + + await expect( + harness.service.consumeMessage( + topic, + reported(harness, 8, { + currentEndpoint: undefined, + desiredState: 'absent', + keeperDesiredEnabled: false, + keeperStatus: 'disabled', + routePresent: false, + routerPresent: false, + }), + ), + ).resolves.toBeUndefined(); + }); + + it('appends endpoint events idempotently by event ID', async () => { + const harness = createHarness(); + const topic = 'kt/network/v1/agents/nas-main/events'; + const payload = Buffer.from( + JSON.stringify({ + agentId: 'nas-main', + endpoint: { + observedAt: '2026-07-22T01:02:04.000Z', + publicIpv4: '8.8.8.8', + publicPort: 45000, + validUntil: '2026-07-22T01:04:04.000Z', + }, + eventId: 'event-1', + mappingId: '100', + occurredAt: '2026-07-22T01:02:05.000Z', + revision: 7, + schemaVersion: 1, + type: 'published', + }), + ); + + await harness.service.consumeMessage(topic, payload); + await harness.service.consumeMessage(topic, payload); + expect(harness.histories).toHaveLength(1); + }); + + it('accepts a same-instance LWT without regressing heartbeat and ignores an old-instance LWT', async () => { + const harness = createHarness(); + const topic = 'kt/network/v1/agents/nas-main/status'; + harness.state.online = true; + harness.state.startedAt = new KtDateTime('2026-07-22T01:00:00.000Z'); + harness.state.lastHeartbeatAt = new KtDateTime('2026-07-22T01:10:00.000Z'); + const sameInstanceWill = Buffer.from( + JSON.stringify({ + agentId: 'nas-main', + observedAt: '2026-07-22T01:00:00.000Z', + online: false, + schemaVersion: 1, + startedAt: '2026-07-22T01:00:00.000Z', + version: '0.1.0', + }), + ); + + await harness.service.consumeMessage(topic, sameInstanceWill); + expect(harness.state.online).toBe(false); + expect(harness.state.lastHeartbeatAt?.toISOString()).toBe( + '2026-07-22T01:10:00.000Z', + ); + + harness.state.online = true; + harness.state.startedAt = new KtDateTime('2026-07-22T02:00:00.000Z'); + harness.state.lastHeartbeatAt = new KtDateTime('2026-07-22T02:10:00.000Z'); + await harness.service.consumeMessage(topic, sameInstanceWill); + expect(harness.state.online).toBe(true); + expect(harness.state.startedAt?.toISOString()).toBe( + '2026-07-22T02:00:00.000Z', + ); + }); +}); diff --git a/test/admin/network-management/network-management-deployment.spec.ts b/test/admin/network-management/network-management-deployment.spec.ts new file mode 100644 index 0000000..22ca2be --- /dev/null +++ b/test/admin/network-management/network-management-deployment.spec.ts @@ -0,0 +1,43 @@ +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +const REPO_ROOT = resolve(__dirname, '../../..'); + +/** + * Reads one repository deployment artifact as UTF-8 text. + * @param relativePath - Path relative to the API repository root. + * @returns Deployment artifact contents. + */ +function readDeploymentFile(relativePath: string): string { + return readFileSync(resolve(REPO_ROOT, relativePath), 'utf8'); +} + +describe('Network management production deployment contract', () => { + it('blocks Jenkins deployment when any Network Agent runtime key is absent', () => { + const jenkinsfile = readDeploymentFile('Jenkinsfile'); + const requiredKeys = [ + 'NETWORK_AGENT_ID', + 'NETWORK_AGENT_TARGET_IPV4', + 'NETWORK_AGENT_MQTT_URL', + 'NETWORK_AGENT_MQTT_CLIENT_ID', + 'NETWORK_AGENT_MQTT_USERNAME', + 'NETWORK_AGENT_MQTT_PASSWORD', + 'NETWORK_AGENT_MQTT_RETRY_MS', + ]; + + for (const key of requiredKeys) { + expect(jenkinsfile).toMatch( + new RegExp(`requiredRuntimeEnvKeys\\(\\)[\\s\\S]*?['\"]${key}['\"]`), + ); + } + }); + + it('injects the private runtime Secret through the existing K8s env contract', () => { + const manifest = readDeploymentFile('k8s/prod/api.yaml'); + + expect(manifest).toMatch( + /envFrom:[\s\S]*?secretRef:[\s\S]*?name:\s*kt-template-online-api-env/, + ); + expect(manifest).not.toContain('NETWORK_AGENT_MQTT_PASSWORD:'); + }); +}); diff --git a/test/admin/network-management/network-management.controller.spec.ts b/test/admin/network-management/network-management.controller.spec.ts new file mode 100644 index 0000000..d235908 --- /dev/null +++ b/test/admin/network-management/network-management.controller.spec.ts @@ -0,0 +1,163 @@ +import type { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import { AdminSuperGuard } from '../../../src/modules/admin/identity/auth/admin-super.guard'; +import { JwtAuthGuard } from '../../../src/modules/admin/identity/auth/jwt-auth.guard'; +import { NetworkManagementController } from '../../../src/modules/admin/platform-config/network-management/network-management.controller'; +import { NetworkManagementService } from '../../../src/modules/admin/platform-config/network-management/network-management.service'; + +describe('NetworkManagementController', () => { + let app: INestApplication; + let apiUrl: string; + const service = { + agentStatus: jest.fn(), + create: jest.fn(), + disableKeeper: jest.fn(), + enableKeeper: jest.fn(), + endpointHistory: jest.fn(), + list: jest.fn(), + probe: jest.fn(), + remove: jest.fn(), + retry: jest.fn(), + update: jest.fn(), + }; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [NetworkManagementController], + providers: [ + AdminSuperGuard, + { provide: NetworkManagementService, useValue: service }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ + canActivate: (context) => { + context.switchToHttp().getRequest().adminUser = { + roles: [{ isDeleted: false, roleCode: 'super', status: 1 }], + }; + return true; + }, + }) + .compile(); + + app = moduleRef.createNestApplication(); + await app.listen(0, '127.0.0.1'); + apiUrl = await app.getUrl(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + service.list.mockResolvedValue({ items: [], total: 0 }); + service.create.mockResolvedValue({ id: '100', syncStatus: 'pending' }); + service.agentStatus.mockResolvedValue({ + agentId: 'nas-main', + online: false, + }); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('exposes the persisted CRUD surface and rejects non-whitelisted fields', async () => { + await request(apiUrl) + .get('/system/network/port-forward/list?pageNo=1&pageSize=20') + .expect(200) + .expect('Cache-Control', 'no-store'); + await request(apiUrl) + .post('/system/network/port-forward') + .send({ + externalPort: 9000, + internalPort: 9000, + name: 'Game Server', + protocol: 'udp', + routerPassword: 'must-not-be-accepted', + }) + .expect(400); + await request(apiUrl) + .post('/system/network/port-forward') + .send({ + externalPort: 9000, + internalPort: 9000, + name: ' ', + protocol: 'udp', + }) + .expect(400); + expect(service.create).not.toHaveBeenCalled(); + }); + + it('accepts legal desired state while returning pending immediately', async () => { + const response = await request(apiUrl) + .post('/system/network/port-forward') + .send({ + externalPort: 9000, + internalPort: 9000, + name: 'Game Server', + protocol: 'udp', + }) + .expect(201) + .expect('Cache-Control', 'no-store'); + + expect(response.body.data).toMatchObject({ + id: '100', + syncStatus: 'pending', + }); + expect(service.create).toHaveBeenCalledWith( + expect.objectContaining({ protocol: 'udp' }), + ); + }); + + it('exposes asynchronous actions, history, and agent status routes', async () => { + service.retry.mockResolvedValue({ id: '100' }); + service.enableKeeper.mockResolvedValue({ id: '100' }); + service.disableKeeper.mockResolvedValue({ id: '100' }); + service.probe.mockResolvedValue({ id: '100' }); + service.endpointHistory.mockResolvedValue({ + items: [ + { + eventId: 'event-1', + id: '300', + occurredAt: '2026-07-22T01:02:05.000Z', + portForwardId: '100', + withdrawalReason: 'keeper_disabled', + }, + ], + total: 1, + }); + service.agentStatus.mockResolvedValue({ + agentId: 'nas-main', + lastErrorCode: 'router_conflict', + lastErrorMessage: 'router conflict', + online: false, + }); + + await request(apiUrl) + .post('/system/network/port-forward/100/retry') + .expect(200); + await request(apiUrl) + .post('/system/network/port-forward/100/keeper/enable') + .expect(200); + await request(apiUrl) + .post('/system/network/port-forward/100/keeper/disable') + .expect(200); + await request(apiUrl) + .post('/system/network/port-forward/100/probe') + .expect(200); + const historyResponse = await request(apiUrl) + .get('/system/network/port-forward/100/endpoint-history') + .expect(200); + expect(historyResponse.body.data.items[0]).toMatchObject({ + portForwardId: '100', + withdrawalReason: 'keeper_disabled', + }); + const statusResponse = await request(apiUrl) + .get('/system/network/agent/status') + .expect(200) + .expect('Cache-Control', 'no-store'); + expect(statusResponse.body.data).toMatchObject({ + lastErrorCode: 'router_conflict', + lastErrorMessage: 'router conflict', + }); + }); +}); diff --git a/test/admin/network-management/network-management.entity.spec.ts b/test/admin/network-management/network-management.entity.spec.ts new file mode 100644 index 0000000..93c6d66 --- /dev/null +++ b/test/admin/network-management/network-management.entity.spec.ts @@ -0,0 +1,45 @@ +import { MODULE_METADATA } from '@nestjs/common/constants'; +import { getMetadataArgsStorage } from 'typeorm'; +import { + ADMIN_PLATFORM_CONFIG_PROVIDERS, + AdminPlatformConfigModule, +} from '../../../src/modules/admin/platform-config/admin-platform-config.module'; +import { NetworkAgentMqttService } from '../../../src/modules/admin/platform-config/network-management/network-agent-mqtt.service'; +import { NetworkAgentState } from '../../../src/modules/admin/platform-config/network-management/network-agent-state.entity'; +import { NetworkEndpointHistory } from '../../../src/modules/admin/platform-config/network-management/network-endpoint-history.entity'; +import { NetworkPortForward } from '../../../src/modules/admin/platform-config/network-management/network-management.entity'; +import { NetworkManagementService } from '../../../src/modules/admin/platform-config/network-management/network-management.service'; + +describe('network management persistence module', () => { + it('registers the three exact database entity tables', () => { + const tables = getMetadataArgsStorage().tables.filter((table) => + [NetworkPortForward, NetworkAgentState, NetworkEndpointHistory].includes( + table.target as never, + ), + ); + expect(tables.map((table) => table.name).sort()).toEqual([ + 'network_agent_state', + 'network_endpoint_history', + 'network_port_forward', + ]); + }); + + it('registers the persisted service and dedicated MQTT bridge without router-specific clients', () => { + const providers = Reflect.getMetadata( + MODULE_METADATA.PROVIDERS, + AdminPlatformConfigModule, + ); + expect(ADMIN_PLATFORM_CONFIG_PROVIDERS).toEqual( + expect.arrayContaining([ + NetworkManagementService, + NetworkAgentMqttService, + ]), + ); + expect(providers).toEqual( + expect.arrayContaining([ + NetworkManagementService, + NetworkAgentMqttService, + ]), + ); + }); +}); diff --git a/test/admin/network-management/network-management.service.spec.ts b/test/admin/network-management/network-management.service.spec.ts new file mode 100644 index 0000000..ae605d7 --- /dev/null +++ b/test/admin/network-management/network-management.service.spec.ts @@ -0,0 +1,393 @@ +import { HttpException } from '@nestjs/common'; +import type { ConfigService } from '@nestjs/config'; +import type { DataSource, EntityManager, Repository } from 'typeorm'; +import { KtDateTime } from '../../../src/common'; +import type { NetworkAgentMqttService } from '../../../src/modules/admin/platform-config/network-management/network-agent-mqtt.service'; +import { NetworkAgentState } from '../../../src/modules/admin/platform-config/network-management/network-agent-state.entity'; +import { NetworkEndpointHistory } from '../../../src/modules/admin/platform-config/network-management/network-endpoint-history.entity'; +import { NetworkPortForward } from '../../../src/modules/admin/platform-config/network-management/network-management.entity'; +import { NetworkManagementService } from '../../../src/modules/admin/platform-config/network-management/network-management.service'; + +type Harness = { + bootstrapOrder: string[]; + bootstrapExecute: jest.Mock; + histories: NetworkEndpointHistory[]; + mappings: NetworkPortForward[]; + mqtt: jest.Mocked>; + service: NetworkManagementService; + state: NetworkAgentState; +}; + +/** + * Creates an in-memory TypeORM-shaped transaction harness for desired-state tests. + * @param initialMappings - Existing active mappings copied into the fake repository. + * @returns Mutable persistence state and real application service. + */ +function createHarness(initialMappings: NetworkPortForward[] = []): Harness { + const mappings = initialMappings; + const histories: NetworkEndpointHistory[] = []; + const bootstrapOrder: string[] = []; + const bootstrapExecute = jest.fn().mockImplementation(async () => { + bootstrapOrder.push('insert-ignore'); + return { identifiers: [] }; + }); + const state = Object.assign(new NetworkAgentState(), { + agentId: 'nas-main', + appliedRevision: '0', + desiredIssuedAt: new KtDateTime('2026-07-22T00:00:00.000Z'), + desiredRevision: initialMappings.length ? '3' : '0', + online: false, + publishedRevision: '0', + targetIpv4: '192.168.31.224', + }); + const mappingRepository = { + count: async () => mappings.filter((mapping) => !mapping.isDeleted).length, + create: (input) => + Object.assign(new NetworkPortForward(), { id: '100' }, input), + createQueryBuilder: () => createListBuilder(mappings), + findOne: async ({ where }) => + mappings.find((mapping) => + Object.entries(where).every(([key, value]) => mapping[key] === value), + ) || null, + save: async (mapping) => { + const index = mappings.findIndex((item) => item.id === mapping.id); + if (index >= 0) mappings[index] = mapping; + else mappings.push(mapping); + return mapping; + }, + } as unknown as Repository; + const stateRepository = { + create: (input) => Object.assign(new NetworkAgentState(), input), + createQueryBuilder: () => { + const builder = { + execute: bootstrapExecute, + insert: () => builder, + into: () => builder, + orIgnore: () => builder, + values: () => builder, + }; + return builder; + }, + findOne: async () => { + bootstrapOrder.push('pessimistic-lock'); + return state; + }, + save: async (value) => Object.assign(state, value), + } as unknown as Repository; + const historyRepository = { + findAndCount: jest.fn(async () => [histories, histories.length]), + } as unknown as Repository; + const manager = { + getRepository: (entity) => { + if (entity === NetworkPortForward) return mappingRepository; + if (entity === NetworkAgentState) return stateRepository; + if (entity === NetworkEndpointHistory) return historyRepository; + throw new Error('unexpected repository'); + }, + } as unknown as EntityManager; + const dataSource = { + transaction: async (work) => await work(manager), + } as unknown as DataSource; + const configService = { + get: (key) => + key === 'NETWORK_AGENT_TARGET_IPV4' + ? '192.168.31.224' + : key === 'NETWORK_AGENT_ID' + ? 'nas-main' + : undefined, + } as ConfigService; + const mqtt = { + requestDesiredPublish: jest.fn(), + } as jest.Mocked>; + const service = new NetworkManagementService( + mappingRepository, + historyRepository, + stateRepository, + dataSource, + configService, + mqtt as unknown as NetworkAgentMqttService, + ); + return { + bootstrapExecute, + bootstrapOrder, + histories, + mappings, + mqtt, + service, + state, + }; +} + +/** + * Creates a complete persisted mapping fixture. + * @param patch - Desired field overrides. + * @returns Active mapping entity suitable for mutation tests. + */ +function createMapping( + patch: Partial = {}, +): NetworkPortForward { + return Object.assign(new NetworkPortForward(), { + activeKey: 'udp:9000', + currentObservedAt: null, + currentPublicIpv4: null, + currentPublicPort: null, + currentValidUntil: null, + desiredIssuedAt: new KtDateTime('2026-07-22T00:00:00.000Z'), + desiredPresence: 'present', + desiredRevision: '3', + externalPort: 9000, + id: '100', + internalPort: 9000, + isDeleted: false, + keeperDesiredEnabled: false, + keeperStatus: 'disabled', + name: 'rule', + protocol: 'udp', + reportedRevision: '0', + syncStatus: 'synced', + targetIpv4: '192.168.31.224', + ...patch, + }); +} + +/** + * Creates the fluent query builder subset used by the service list method. + * @param mappings - Current in-memory rows. + * @returns Chainable fake returning all rows. + */ +function createListBuilder(mappings: NetworkPortForward[]) { + const builder = { + andWhere: () => builder, + getManyAndCount: async () => [mappings, mappings.length], + orderBy: () => builder, + skip: () => builder, + take: () => builder, + where: () => builder, + }; + return builder; +} + +/** Extracts the Nest status from a rejected desired-state operation. */ +function errorStatus(error: unknown): number { + return error instanceof HttpException ? error.getStatus() : 0; +} + +describe('NetworkManagementService', () => { + it('creates one desired mapping and advances the locked global revision once', async () => { + const harness = createHarness(); + + await expect( + harness.service.create({ + externalPort: 9000, + internalPort: 9000, + name: ' Game Server ', + protocol: 'udp', + }), + ).resolves.toMatchObject({ + desiredRevision: '1', + id: '100', + name: 'Game Server', + syncStatus: 'pending', + targetIpv4: '192.168.31.224', + }); + expect(harness.state.desiredRevision).toBe('1'); + expect(harness.mappings[0]).toMatchObject({ + activeKey: 'udp:9000', + desiredRevision: '1', + }); + expect(harness.mappings[0].desiredIssuedAt.toISOString()).toBe( + harness.state.desiredIssuedAt.toISOString(), + ); + expect(harness.mqtt.requestDesiredPublish).toHaveBeenCalledTimes(1); + expect(harness.bootstrapExecute).toHaveBeenCalledTimes(1); + }); + + it('uses insert-ignore bootstrap before the singleton pessimistic lock', async () => { + const harness = createHarness(); + + await harness.service.create({ + externalPort: 9000, + internalPort: 9000, + name: 'bootstrap-safe', + protocol: 'udp', + }); + + expect(harness.bootstrapExecute).toHaveBeenCalledTimes(1); + expect(harness.bootstrapOrder).toEqual([ + 'insert-ignore', + 'pessimistic-lock', + ]); + expect(harness.state.desiredRevision).toBe('1'); + }); + + it('rejects duplicate active keys without advancing revision', async () => { + const harness = createHarness([createMapping()]); + + await harness.service + .create({ + externalPort: 9000, + internalPort: 9999, + name: 'duplicate', + protocol: 'udp', + }) + .catch((error) => expect(errorStatus(error)).toBe(409)); + + expect(harness.state.desiredRevision).toBe('3'); + expect(harness.mqtt.requestDesiredPublish).not.toHaveBeenCalled(); + }); + + it('rejects names that exceed the Agent UTF-8 byte boundary', async () => { + const harness = createHarness(); + + await harness.service + .create({ + externalPort: 9000, + internalPort: 9000, + name: '网'.repeat(43), + protocol: 'udp', + }) + .catch((error) => expect(errorStatus(error)).toBe(400)); + + expect(harness.state.desiredRevision).toBe('0'); + expect(harness.mqtt.requestDesiredPublish).not.toHaveBeenCalled(); + }); + + it('rejects a sixty-fifth mapping before publishing an invalid snapshot', async () => { + const existing = Array.from({ length: 64 }, (_, index) => + createMapping({ + activeKey: `udp:${9000 + index}`, + externalPort: 9000 + index, + id: `${100 + index}`, + internalPort: 9000 + index, + }), + ); + const harness = createHarness(existing); + + await harness.service + .create({ + externalPort: 9100, + internalPort: 9100, + name: 'too-many', + protocol: 'udp', + }) + .catch((error) => expect(errorStatus(error)).toBe(409)); + + expect(harness.state.desiredRevision).toBe('3'); + expect(harness.mqtt.requestDesiredPublish).not.toHaveBeenCalled(); + }); + + it('keeps the active key through a deletion tombstone', async () => { + const mapping = createMapping({ + currentPublicIpv4: '203.0.113.10', + currentPublicPort: 45000, + currentValidUntil: new KtDateTime('2099-07-22T00:00:00.000Z'), + keeperDesiredEnabled: true, + }); + const harness = createHarness([mapping]); + + await expect(harness.service.remove('100')).resolves.toMatchObject({ + currentPublicEndpoint: null, + desiredPresence: 'absent', + desiredRevision: '4', + syncStatus: 'deleting', + }); + expect(mapping.activeKey).toBe('udp:9000'); + expect(mapping.isDeleted).toBe(false); + expect(mapping.currentPublicIpv4).toBeNull(); + }); + + it('clears an existing remark when update explicitly supplies an empty string', async () => { + const mapping = createMapping({ remark: 'managed' }); + const harness = createHarness([mapping]); + + await expect( + harness.service.update('100', { remark: '' }), + ).resolves.toMatchObject({ + remark: null, + }); + expect(mapping.remark).toBeNull(); + }); + + it('rejects Keeper actions for TCP without changing desired revision', async () => { + const mapping = createMapping({ + activeKey: 'tcp:9000', + protocol: 'tcp', + }); + const harness = createHarness([mapping]); + + await harness.service + .enableKeeper('100') + .catch((error) => expect(errorStatus(error)).toBe(400)); + + expect(harness.state.desiredRevision).toBe('3'); + expect(harness.mqtt.requestDesiredPublish).not.toHaveBeenCalled(); + }); + + it('hides an expired current endpoint while retaining last observation', async () => { + const mapping = createMapping({ + currentObservedAt: new KtDateTime('2026-07-22T00:00:00.000Z'), + currentPublicIpv4: '203.0.113.10', + currentPublicPort: 45000, + currentValidUntil: new KtDateTime('2026-07-22T00:01:00.000Z'), + lastObservedAt: new KtDateTime('2026-07-22T00:00:00.000Z'), + lastObservedIpv4: '203.0.113.10', + lastObservedPort: 45000, + }); + const harness = createHarness([mapping]); + + await expect(harness.service.list()).resolves.toMatchObject({ + items: [ + { + currentPublicEndpoint: null, + currentPublicIpv4: null, + lastObservedIpv4: '203.0.113.10', + lastObservedPort: 45000, + }, + ], + total: 1, + }); + }); + + it('serializes endpoint history and unified Agent error fields for Admin', async () => { + const harness = createHarness([createMapping()]); + harness.histories.push( + Object.assign(new NetworkEndpointHistory(), { + createTime: new KtDateTime('2026-07-22T01:02:06.000Z'), + eventId: 'event-1', + eventType: 'withdrawn', + firstObservedAt: new KtDateTime('2026-07-22T01:02:04.000Z'), + id: '300', + lastObservedAt: new KtDateTime('2026-07-22T01:02:04.000Z'), + mappingId: '100', + occurredAt: new KtDateTime('2026-07-22T01:02:05.000Z'), + publicIpv4: '8.8.8.8', + publicPort: 45000, + reason: 'keeper_disabled', + }), + ); + harness.state.lastMqttErrorCode = 'mqtt_fallback'; + harness.state.lastMqttErrorMessage = 'mqtt fallback'; + harness.state.lastReconcileErrorCode = 'router_conflict'; + harness.state.lastReconcileErrorMessage = 'router conflict'; + + await expect(harness.service.endpointHistory('100')).resolves.toMatchObject( + { + items: [ + { + eventId: 'event-1', + id: '300', + portForwardId: '100', + withdrawalReason: 'keeper_disabled', + }, + ], + total: 1, + }, + ); + await expect(harness.service.agentStatus()).resolves.toMatchObject({ + lastErrorCode: 'router_conflict', + lastErrorMessage: 'router conflict', + lastMqttErrorCode: 'mqtt_fallback', + lastReconcileErrorCode: 'router_conflict', + }); + }); +}); diff --git a/test/admin/network-management/network-management.types.spec.ts b/test/admin/network-management/network-management.types.spec.ts new file mode 100644 index 0000000..a07dd75 --- /dev/null +++ b/test/admin/network-management/network-management.types.spec.ts @@ -0,0 +1,184 @@ +import { + buildDesiredSnapshot, + desiredSnapshotDigest, + desiredSnapshotBytes, + parseEndpointEvent, + parseReportedSnapshot, + parseStatusSnapshot, +} from '../../../src/modules/admin/platform-config/network-management/network-management.types'; +import { NetworkAgentState } from '../../../src/modules/admin/platform-config/network-management/network-agent-state.entity'; +import { NetworkPortForward } from '../../../src/modules/admin/platform-config/network-management/network-management.entity'; + +/** + * Creates a stable singleton state fixture used by desired snapshot tests. + * @returns Agent state at desired revision seven. + */ +function createAgentState(): NetworkAgentState { + return Object.assign(new NetworkAgentState(), { + agentId: 'nas-main', + desiredIssuedAt: new Date('2026-07-22T01:02:03.000Z'), + desiredRevision: '7', + targetIpv4: '192.168.31.224', + }); +} + +/** + * Creates a persisted desired mapping fixture. + * @param id - Stable Snowflake-like identifier. + * @param externalPort - WAN-side port used to verify deterministic sorting. + * @returns Desired UDP mapping entity. + */ +function createMapping(id: string, externalPort: number): NetworkPortForward { + return Object.assign(new NetworkPortForward(), { + desiredPresence: 'present', + desiredRevision: '7', + externalPort, + id, + internalPort: externalPort, + keeperDesiredEnabled: true, + name: `rule-${externalPort}`, + probeRequestId: 'probe-1', + protocol: 'udp', + targetIpv4: '192.168.31.224', + }); +} + +describe('network management MQTT contracts', () => { + it('builds byte-stable, sorted, secret-free desired snapshots', () => { + const state = createAgentState(); + const later = createMapping('200', 9001); + const earlier = createMapping('100', 9000); + + const first = buildDesiredSnapshot(state, [later, earlier]); + const second = buildDesiredSnapshot(state, [earlier, later]); + + expect(first.mappings.map((mapping) => mapping.id)).toEqual(['100', '200']); + expect(first).toEqual(second); + expect(desiredSnapshotBytes(first)).toEqual(desiredSnapshotBytes(second)); + expect(JSON.stringify(first)).not.toMatch(/password|secret|token/i); + expect(first.mappings[0]).toMatchObject({ state: 'present' }); + expect(first.mappings[0]).not.toHaveProperty('desiredPresence'); + expect(first.mappings[0]).not.toHaveProperty('remark'); + expect(desiredSnapshotDigest(first)).toMatch(/^[0-9a-f]{64}$/); + expect(first).toMatchObject({ + agentId: 'nas-main', + issuedAt: '2026-07-22T01:02:03.000Z', + revision: 7, + schemaVersion: 1, + targetIpv4: '192.168.31.224', + }); + }); + + it('matches the Go canonical desired digest fixture', () => { + const mapping = createMapping('1', 9000); + mapping.name = 'game'; + + expect( + desiredSnapshotDigest( + buildDesiredSnapshot(createAgentState(), [mapping]), + ), + ).toBe('8f019e3947622cdb42beff79752cd158ace472798fd6c552f8f566e0698ac475'); + }); + + it('uses Go lexical ID order and HTML escaping in a multi-mapping digest', () => { + const idTwo = createMapping('2', 9000); + idTwo.name = 'game'; + const idTen = createMapping('10', 9001); + idTen.name = 'web<&>'; + idTen.protocol = 'tcp'; + idTen.keeperDesiredEnabled = false; + idTen.probeRequestId = null; + + const snapshot = buildDesiredSnapshot(createAgentState(), [idTen, idTwo]); + expect(snapshot.mappings.map((mapping) => mapping.id)).toEqual(['2', '10']); + expect(desiredSnapshotDigest(snapshot)).toBe( + 'e8893dead692a35b7813c8e5009e12f9e0a1adaea7123862c46a923b5a7b1141', + ); + }); + + it('strictly parses reported, status, and endpoint event payloads', () => { + expect( + parseReportedSnapshot({ + agentId: 'nas-main', + appliedRevision: 7, + desiredDigest: 'd'.repeat(64), + helperAppliedRevision: 7, + helperDigest: 'e'.repeat(64), + helperStatus: 'confirmed', + mappings: [ + { + currentEndpoint: { + observedAt: '2026-07-22T01:02:04.000Z', + publicIpv4: '8.8.8.8', + publicPort: 45000, + validUntil: '2026-07-22T01:04:04.000Z', + }, + desiredState: 'present', + id: '100', + keeperDesiredEnabled: true, + keeperStatus: 'active', + lastObservedEndpoint: { + observedAt: '2026-07-22T01:02:04.000Z', + publicIpv4: '8.8.8.8', + publicPort: 45000, + validUntil: '2026-07-22T01:04:04.000Z', + }, + revision: 7, + routePresent: true, + routerPresent: true, + syncStatus: 'synced', + }, + ], + reportedAt: '2026-07-22T01:02:04.000Z', + schemaVersion: 1, + }), + ).toMatchObject({ appliedRevision: 7 }); + expect( + parseStatusSnapshot({ + agentId: 'nas-main', + observedAt: '2026-07-22T01:02:04Z', + online: true, + schemaVersion: 1, + version: '0.1.0', + }), + ).toMatchObject({ online: true }); + expect( + parseEndpointEvent({ + agentId: 'nas-main', + eventId: 'event-1', + endpoint: { + observedAt: '2026-07-22T01:02:04.000Z', + publicIpv4: '8.8.8.8', + publicPort: 45000, + validUntil: '2026-07-22T01:04:04.000Z', + }, + mappingId: '100', + occurredAt: '2026-07-22T01:02:04.000Z', + revision: 7, + schemaVersion: 1, + type: 'published', + }), + ).toMatchObject({ eventId: 'event-1' }); + }); + + it.each([ + ['wrong schema', { schemaVersion: 2 }], + ['unknown field', { extra: true }], + ['unsafe revision', { appliedRevision: Number.MAX_SAFE_INTEGER + 1 }], + ])('rejects malformed reported snapshots: %s', (_, patch) => { + expect(() => + parseReportedSnapshot({ + agentId: 'nas-main', + appliedRevision: 7, + desiredDigest: 'd'.repeat(64), + helperAppliedRevision: 0, + helperDigest: '', + helperStatus: 'unknown', + mappings: [], + reportedAt: '2026-07-22T01:02:04.000Z', + schemaVersion: 1, + ...patch, + }), + ).toThrow(); + }); +}); diff --git a/test/modules/admin/admin-contract.spec.ts b/test/modules/admin/admin-contract.spec.ts index 1d6351a..e860f15 100644 --- a/test/modules/admin/admin-contract.spec.ts +++ b/test/modules/admin/admin-contract.spec.ts @@ -79,6 +79,16 @@ describe('Admin module route contract', () => { 'DELETE /system/notice/:id', 'POST /system/notice/toggle', 'POST /system/notice/top', + 'GET /system/network/port-forward/list', + 'POST /system/network/port-forward', + 'PUT /system/network/port-forward/:id', + 'DELETE /system/network/port-forward/:id', + 'POST /system/network/port-forward/:id/retry', + 'POST /system/network/port-forward/:id/keeper/enable', + 'POST /system/network/port-forward/:id/keeper/disable', + 'POST /system/network/port-forward/:id/probe', + 'GET /system/network/port-forward/:id/endpoint-history', + 'GET /system/network/agent/status', ]), ); }); diff --git a/test/runtime/runtime-config.service.spec.ts b/test/runtime/runtime-config.service.spec.ts index 875ab56..dcfe1f5 100644 --- a/test/runtime/runtime-config.service.spec.ts +++ b/test/runtime/runtime-config.service.spec.ts @@ -233,5 +233,44 @@ describe('RuntimeConfigService', () => { message: 'DB_PASSWORD is not configured', }), ); + expect(checks).toContainEqual( + expect.objectContaining({ + key: 'NETWORK_AGENT_MQTT_URL', + level: 'required', + present: false, + message: 'NETWORK_AGENT_MQTT_URL is not configured', + }), + ); + }); + + it('requires the complete Network Agent production connection contract', () => { + const service = createService({ + NETWORK_AGENT_ID: 'nas-main', + NETWORK_AGENT_TARGET_IPV4: '192.168.31.224', + NETWORK_AGENT_MQTT_URL: 'mqtt://192.168.31.224:1883', + NETWORK_AGENT_MQTT_CLIENT_ID: 'kt-template-online-api-network-nas-main', + NETWORK_AGENT_MQTT_USERNAME: 'network-api', + NETWORK_AGENT_MQTT_PASSWORD: 'network-api-password', + NETWORK_AGENT_MQTT_RETRY_MS: '5000', + }); + + const networkChecks = service + .getConfigChecks() + .filter((check) => check.key.startsWith('NETWORK_AGENT_')); + + expect(networkChecks).toHaveLength(7); + expect(networkChecks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: 'NETWORK_AGENT_MQTT_PASSWORD', + level: 'required', + present: true, + maskedValue: 'ne***rd', + }), + ]), + ); + expect(JSON.stringify(networkChecks)).not.toContain( + 'network-api-password', + ); }); });