feat: 增加通用网络端口转发管理

This commit is contained in:
sunlei 2026-07-23 00:44:29 +08:00
parent 22d99d1a85
commit 567208f418
30 changed files with 4640 additions and 59 deletions

View File

@ -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

26
API.md
View File

@ -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-v1desired 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` |

7
Jenkinsfile vendored
View File

@ -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',
]
}

View File

@ -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/MACNapCat/OneBot 配置 hashKT `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 staleAPI 在源 Docker 容器在线但 WebUI 明确 QQ 离线时会同容器调用 `RestartNapCat` 重启 NapCat worker重建 QQCore login service 后再推进 quick/password/qrcode不做 Docker 重建、补 env 或设备身份迁移,且同一个更新登录 session 只消费一次 worker restart 预算v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc/<pid>/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 <uin>` 快速登录参数避免重启后退回无账号扫码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。

View File

@ -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",

View File

@ -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:

View File

@ -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`);

View File

@ -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;

View File

@ -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,

View File

@ -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,

View File

@ -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'

View File

@ -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

View File

@ -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,

View File

@ -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<void> | 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<string>('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<string>('NETWORK_AGENT_MQTT_CLIENT_ID') ||
DEFAULT_CLIENT_ID,
customHandleAcks: (topic, payload, _packet, callback) => {
void this.acknowledgeIncoming(topic, payload, callback);
},
password: this.configService.get<string>('NETWORK_AGENT_MQTT_PASSWORD'),
protocolVersion: 5,
properties: { sessionExpiryInterval: 7 * 24 * 60 * 60 },
reconnectPeriod: this.retryMs(),
resubscribe: true,
username: this.configService.get<string>('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<void> {
this.shuttingDown = true;
if (this.retryTimer) clearInterval(this.retryTimer);
this.retryTimer = null;
const client = this.client;
if (!client) return;
await new Promise<void>((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<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
const client = this.client;
if (!client?.connected) throw new Error('Network MQTT disconnected');
await new Promise<void>((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<void> {
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<void> {
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<string>();
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<void> {
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<void> {
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<string>('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<string>('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;
}
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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');
}
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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<NetworkPortForward>,
@InjectRepository(NetworkEndpointHistory)
private readonly historyRepository: Repository<NetworkEndpointHistory>,
@InjectRepository(NetworkAgentState)
private readonly stateRepository: Repository<NetworkAgentState>,
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<void>,
) {
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<NetworkAgentState> {
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<string>('NETWORK_AGENT_ID') || DEFAULT_AGENT_ID
);
}
/** Returns and validates the fixed NAS target IPv4. */
private targetIpv4(): string {
const target =
this.configService.get<string>('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;
}
}

View File

@ -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, '\\u003c')
.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<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
invalid(label);
}
const record = value as Record<string, unknown>;
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<T extends string>(
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;
}

View File

@ -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<string | readonly string[]> = [

View File

@ -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<NetworkAgentState>;
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<NetworkPortForward>;
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<NetworkEndpointHistory>;
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<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
}
/** Mirrors MQTT.js by emitting callback errors through the client error event. */
function createProtocolAck(
client: MqttClient & EventEmitter,
): jest.Mock<void, [Error | number, number?]> {
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<string, unknown> = {},
) {
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<void>((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',
);
});
});

View File

@ -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:');
});
});

View File

@ -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',
});
});
});

View File

@ -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,
]),
);
});
});

View File

@ -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<Pick<NetworkAgentMqttService, 'requestDesiredPublish'>>;
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<NetworkPortForward>;
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<NetworkAgentState>;
const historyRepository = {
findAndCount: jest.fn(async () => [histories, histories.length]),
} as unknown as Repository<NetworkEndpointHistory>;
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<Pick<NetworkAgentMqttService, 'requestDesiredPublish'>>;
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> = {},
): 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',
});
});
});

View File

@ -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();
});
});

View File

@ -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',
]),
);
});

View File

@ -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',
);
});
});