feat: 增加通用网络端口转发管理
This commit is contained in:
parent
22d99d1a85
commit
567208f418
@ -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
26
API.md
@ -88,6 +88,31 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
|
||||
|
||||
当前版本只提供观测和只读自检。重启 Pod、触发 Jenkins 部署、执行迁移、重建 NapCat 容器、启停插件、立即执行插件任务、修改 Caddy/OpenClash/WireGuard/Tencent Cloud 等高风险能力只会以禁用动作展示,后端不提供通用写动作入口。
|
||||
|
||||
## System 网络端口转发管理
|
||||
|
||||
| 方法 | 路径 | 认证 | 说明 |
|
||||
| -------- | -------------------------------------------------------------- | ------- | ----------------------------------------- |
|
||||
| `GET` | `/system/network/port-forward/list` | `super` | 分页查询期望、同步、Keeper 与端点租约状态 |
|
||||
| `POST` | `/system/network/port-forward` | `super` | 新增 TCP/UDP 期望记录 |
|
||||
| `PUT` | `/system/network/port-forward/:id` | `super` | 修改名称、协议和端口期望 |
|
||||
| `DELETE` | `/system/network/port-forward/:id` | `super` | 写入 absent tombstone,等待 Agent 确认 |
|
||||
| `POST` | `/system/network/port-forward/:id/retry` | `super` | 提升 revision 并重试协调 |
|
||||
| `POST` | `/system/network/port-forward/:id/keeper/enable` | `super` | 启用同源端口 UDP Keeper 并立即探测 |
|
||||
| `POST` | `/system/network/port-forward/:id/keeper/disable` | `super` | 停用 UDP Keeper 并撤下当前端点 |
|
||||
| `POST` | `/system/network/port-forward/:id/probe` | `super` | 为已启用 Keeper 生成新 probeRequestId |
|
||||
| `GET` | `/system/network/port-forward/:id/endpoint-history` | `super` | 查询端点状态变化历史 |
|
||||
| `GET` | `/system/network/agent/status` | `super` | 查询 Agent 在线与 revision 收敛状态 |
|
||||
|
||||
新增和修改请求只接受名称、备注、`tcp|udp`、外部端口和内部端口;目标 NAS IPv4 固定来自 `NETWORK_AGENT_TARGET_IPV4`,请求体中的未知字段会返回 400。Snowflake ID 与 revision 在 HTTP JSON 中保留为字符串。所有动态响应设置 `Cache-Control: no-store`。
|
||||
|
||||
端点历史行使用 `portForwardId` 关联端口转发,撤下原因返回为 `withdrawalReason`。Agent 状态同时返回统一的 `lastErrorCode/lastErrorMessage`(reconciliation 优先于 MQTT)和细分错误字段,便于 Admin 展示与诊断。
|
||||
|
||||
API 数据库是唯一事实源。每次合法期望变更在同一事务中锁定 `network_agent_state`、全局 revision 只增加一次并保存稳定 `desiredIssuedAt`;事务提交后再发布 `kt/network/v1/agents/{agentId}/desired` 的 QoS 1 retained 完整快照。PUBACK 只推进 `publishedRevision`,Agent 的完整 `reported` 才推进 `appliedRevision` 和逐条实际状态。MQTT 或 Agent 离线不回滚已接受的期望状态。
|
||||
|
||||
Wire contract 采用 `kt-network-agent/internal/contract` schema-v1:desired mapping 使用 `state=present|absent`;reported 使用 `appliedRevision`、`desiredDigest`、helper 状态和逐条 router/route/Keeper 证据;endpoint event 使用唯一 `eventId`。API 不发布数据库备注,也不在 MQTT、HTTP 或数据库中接收/保存路由器密码和 token。事件按 `eventId` 幂等追加;删除只有在 Agent 明确回报 absent、synced、router/route 均不存在、Keeper 期望关闭且实际 disabled、current endpoint 为空,并且 helper 已确认且 `helperAppliedRevision === appliedRevision` 后才完成,随后生成新 revision 移除 tombstone。
|
||||
|
||||
TCP 记录支持 API CRUD,但不提供 STUN/Keeper;当前已验证的 Agent 切片尚未启用 TCP 路由器写入,会明确回报 `tcp_router_write_gated`,不能把 pending/failed TCP 记录描述为已生效转发。UDP 只有 `externalPort === internalPort` 时可启用 Keeper。当前端点只有在 `currentValidUntil` 未过期时才返回为可用值;租约过期不会删除最近观测或 `network_endpoint_history`。API 不直接访问小米路由器、不修改 NAS 路由;真实路由器、raw UDP 与回程规则只由固定 NAS Agent/helper 处理。
|
||||
|
||||
## 环境变量分组
|
||||
|
||||
| 分组 | 关键变量 |
|
||||
@ -101,6 +126,7 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
|
||||
| NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` |
|
||||
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
|
||||
| Env Dashboard | `ENV_DASHBOARD_CACHE_TTL_MS`、`ENV_DASHBOARD_SIGNAL_TIMEOUT_MS`、`ENV_DASHBOARD_EVENT_BUS`、`ENV_DASHBOARD_MQTT_*`、`ENV_DASHBOARD_SSE_*`、`ENV_DASHBOARD_JENKINS_*`、`ENV_DASHBOARD_K8S_*`、`ENV_DASHBOARD_TENCENT_*`、`ENV_DASHBOARD_CADDY_*`、`ENV_DASHBOARD_R4SE_*` |
|
||||
| Network | `NETWORK_AGENT_ID`、`NETWORK_AGENT_TARGET_IPV4`、`NETWORK_AGENT_MQTT_URL`、`NETWORK_AGENT_MQTT_CLIENT_ID`、`NETWORK_AGENT_MQTT_USERNAME`、`NETWORK_AGENT_MQTT_PASSWORD`、`NETWORK_AGENT_MQTT_RETRY_MS` |
|
||||
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
|
||||
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_DEFAULT_WORLD` |
|
||||
| FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
|
||||
|
||||
7
Jenkinsfile
vendored
7
Jenkinsfile
vendored
@ -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',
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
|
||||
| 模块 | 说明 |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志、环境总览面板 |
|
||||
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志、环境总览面板和网络端口映射管理 |
|
||||
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
|
||||
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
|
||||
| `qqbot` | QQBot 账号、NapCat 扫码登录、运行态 Profile、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 |
|
||||
@ -65,6 +65,7 @@ ci/ Jenkins Agent/Docker 辅助文件
|
||||
| Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` |
|
||||
| QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_*`、`QQBOT_SEND_*`、`QQBOT_PLUGIN_QUEUE_REDIS_*`、`QQBOT_PLUGIN_TASK_QUEUE_REDIS_*`、`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` |
|
||||
| Environment Dashboard | `ENV_DASHBOARD_CACHE_TTL_MS`、`ENV_DASHBOARD_SIGNAL_TIMEOUT_MS`、`ENV_DASHBOARD_EVENT_BUS`、`ENV_DASHBOARD_MQTT_*`、`ENV_DASHBOARD_SSE_*`、`ENV_DASHBOARD_JENKINS_*`、`ENV_DASHBOARD_K8S_*`、`ENV_DASHBOARD_TENCENT_*`、`ENV_DASHBOARD_CADDY_*`、`ENV_DASHBOARD_R4SE_*` |
|
||||
| Network Management | `NETWORK_AGENT_ID`、`NETWORK_AGENT_TARGET_IPV4`、`NETWORK_AGENT_MQTT_URL`、`NETWORK_AGENT_MQTT_CLIENT_ID`、`NETWORK_AGENT_MQTT_USERNAME`、`NETWORK_AGENT_MQTT_PASSWORD`、`NETWORK_AGENT_MQTT_RETRY_MS` |
|
||||
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
|
||||
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_MARKET_CACHE_TTL_MS` |
|
||||
| FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
|
||||
@ -79,6 +80,8 @@ QQBot 插件定时任务由 manifest 的 `tasks` 声明,平台持久化到 `qq
|
||||
|
||||
Admin 环境总览面板使用 `ENV_DASHBOARD_*` 只读配置聚合 local-dev、NAS 线上、腾讯云和 r4se 状态。`ENV_DASHBOARD_ADMIN_LOCAL_URL` / `ENV_DASHBOARD_ADMIN_PUBLIC_URL` 只用于展示 Admin 本机与线上入口证据。HTTP 快照提供当前拓扑,后端 local/MQTT 事件总线通过 SSE 推送增量事件给 Admin;前端不直连 MQTT,也不轮询刷新。Jenkins、K8s、Tencent Cloud、Caddy、WireGuard、Mihomo/OpenClash 未配置时会显示 `unwired` 证据,不能渲染成健康假象;第一版不暴露重启、部署、迁移、容器重建、插件启停或代理切换等写操作。
|
||||
|
||||
System 网络管理以 MySQL 中的 TCP/UDP 端口转发期望状态为唯一事实源。`super` 通过统一 CRUD 和 UDP Keeper 动作修改期望状态;API 在事务内单调提升 revision,提交后使用固定 `kt/network/v1/agents/{agentId}` MQTT topic、QoS 1 retained 完整快照通知 NAS `kt-network-agent`,自身不登录路由器、不接收路由器密码,也不执行 raw socket。Agent 失联或 MQTT 暂不可用时合法请求仍保存为 pending,恢复后按 revision 自动收敛;消费端发生瞬时数据库错误或 SUBACK 失败时主动重连并依赖 broker 重投,非法负载则确认后丢弃,避免 poison message 阻塞。TCP 目前仅保存 CRUD 期望,真实路由器写入仍受设备协议证据门禁并回报 `tcp_router_write_gated`;只有外部端口等于内部端口的 UDP 记录允许启停 Keeper 和立即探测。当前公网端点受 `currentValidUntil` 租约约束,过期后列表隐藏当前值但保留最近观测与历史。生产发布同时把完整 `NETWORK_AGENT_*` 连接配置作为 Jenkins 私有 env 和 `/health/runtime` 必需项,任一项缺失时拒绝发布或报告运行态阻断,避免页面可见但 MQTT 控制链路未接线。
|
||||
|
||||
NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/历史登录事件兼容表/风险模式表,真实物理设备风格 hostname/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v20 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后标记 native login service stale;API 在源 Docker 容器在线但 WebUI 明确 QQ 离线时会同容器调用 `RestartNapCat` 重启 NapCat worker,重建 QQCore login service 后再推进 quick/password/qrcode,不做 Docker 重建、补 env 或设备身份迁移,且同一个更新登录 session 只消费一次 worker restart 预算;v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc/<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。
|
||||
|
||||
@ -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",
|
||||
|
||||
157
pnpm-lock.yaml
157
pnpm-lock.yaml
@ -10,31 +10,31 @@ importers:
|
||||
dependencies:
|
||||
'@kwitsukasa/knife4j-swagger-vue3':
|
||||
specifier: 0.1.2
|
||||
version: 0.1.2(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||
version: 0.1.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||
'@nestjs-modules/ioredis':
|
||||
specifier: ^2.2.1
|
||||
version: 2.2.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))))(ioredis@5.11.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))
|
||||
version: 2.2.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))))(ioredis@5.11.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))
|
||||
'@nestjs/bullmq':
|
||||
specifier: 11.0.4
|
||||
version: 11.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(bullmq@5.78.1)
|
||||
version: 11.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(bullmq@5.78.1)
|
||||
'@nestjs/common':
|
||||
specifier: ^11.1.24
|
||||
version: 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
version: 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/config':
|
||||
specifier: ^4.0.4
|
||||
version: 4.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
|
||||
version: 4.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
|
||||
'@nestjs/core':
|
||||
specifier: ^11.1.24
|
||||
version: 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/platform-express':
|
||||
specifier: ^11.1.24
|
||||
version: 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||
version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||
'@nestjs/swagger':
|
||||
specifier: ^11.4.4
|
||||
version: 11.4.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
||||
version: 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||
'@nestjs/typeorm':
|
||||
specifier: ^11.0.1
|
||||
version: 11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))
|
||||
version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))
|
||||
axios:
|
||||
specifier: ^1.17.0
|
||||
version: 1.17.0
|
||||
@ -47,6 +47,12 @@ importers:
|
||||
chartjs-adapter-moment:
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1(chart.js@4.5.1)(moment@2.30.1)
|
||||
class-transformer:
|
||||
specifier: ^0.5.1
|
||||
version: 0.5.1
|
||||
class-validator:
|
||||
specifier: ^0.14.2
|
||||
version: 0.14.4
|
||||
cron-parser:
|
||||
specifier: 4.9.0
|
||||
version: 4.9.0
|
||||
@ -76,10 +82,10 @@ importers:
|
||||
version: 3.22.4(@types/node@22.19.19)
|
||||
nestjs-minio-client:
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||
version: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||
nestjs-pino:
|
||||
specifier: ^4.6.1
|
||||
version: 4.6.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2)
|
||||
version: 4.6.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2)
|
||||
pino:
|
||||
specifier: ^10.3.1
|
||||
version: 10.3.1
|
||||
@ -158,7 +164,7 @@ importers:
|
||||
version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3)
|
||||
'@nestjs/testing':
|
||||
specifier: ^11.1.24
|
||||
version: 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-express@11.1.24)
|
||||
version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-express@11.1.24)
|
||||
'@types/express':
|
||||
specifier: ^5.0.6
|
||||
version: 5.0.6
|
||||
@ -1370,6 +1376,9 @@ packages:
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
'@types/validator@13.15.10':
|
||||
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
@ -1901,6 +1910,12 @@ packages:
|
||||
cjs-module-lexer@1.4.3:
|
||||
resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==}
|
||||
|
||||
class-transformer@0.5.1:
|
||||
resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==}
|
||||
|
||||
class-validator@0.14.4:
|
||||
resolution: {integrity: sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==}
|
||||
|
||||
cli-boxes@2.2.1:
|
||||
resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==}
|
||||
engines: {node: '>=6'}
|
||||
@ -3111,6 +3126,9 @@ packages:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
libphonenumber-js@1.13.9:
|
||||
resolution: {integrity: sha512-VNS5vWMM7r0P66BYv+TQJATxExEgLxN+34hfHDVhDkUsGAE4cRg0shCNSLTXNKm7nIUscC7AfB51TjxEeF7msQ==}
|
||||
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
@ -4623,6 +4641,10 @@ packages:
|
||||
resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==}
|
||||
engines: {node: '>=10.12.0'}
|
||||
|
||||
validator@13.15.35:
|
||||
resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
vary@1.1.2:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@ -5698,9 +5720,9 @@ snapshots:
|
||||
|
||||
'@kurkle/color@0.3.4': {}
|
||||
|
||||
'@kwitsukasa/knife4j-swagger-vue3@0.1.2(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))':
|
||||
'@kwitsukasa/knife4j-swagger-vue3@0.1.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
|
||||
'@lukeed/csprng@1.1.0': {}
|
||||
|
||||
@ -5739,13 +5761,13 @@ snapshots:
|
||||
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4':
|
||||
optional: true
|
||||
|
||||
'@nestjs-modules/ioredis@2.2.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))))(ioredis@5.11.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))':
|
||||
'@nestjs-modules/ioredis@2.2.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))))(ioredis@5.11.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
ioredis: 5.11.1
|
||||
optionalDependencies:
|
||||
'@nestjs/terminus': 11.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))))(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))
|
||||
'@nestjs/terminus': 11.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))))(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))
|
||||
transitivePeerDependencies:
|
||||
- '@grpc/grpc-js'
|
||||
- '@grpc/proto-loader'
|
||||
@ -5763,17 +5785,17 @@ snapshots:
|
||||
- sequelize
|
||||
- typeorm
|
||||
|
||||
'@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)':
|
||||
'@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@nestjs/bullmq@11.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(bullmq@5.78.1)':
|
||||
'@nestjs/bullmq@11.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(bullmq@5.78.1)':
|
||||
dependencies:
|
||||
'@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
bullmq: 5.78.1
|
||||
tslib: 2.8.1
|
||||
|
||||
@ -5813,7 +5835,7 @@ snapshots:
|
||||
- uglify-js
|
||||
- webpack-cli
|
||||
|
||||
'@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||
'@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
file-type: 21.3.4
|
||||
iterare: 1.2.1
|
||||
@ -5822,20 +5844,23 @@ snapshots:
|
||||
rxjs: 7.8.2
|
||||
tslib: 2.8.1
|
||||
uid: 2.0.2
|
||||
optionalDependencies:
|
||||
class-transformer: 0.5.1
|
||||
class-validator: 0.14.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@nestjs/config@4.0.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)':
|
||||
'@nestjs/config@4.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
dotenv: 17.4.1
|
||||
dotenv-expand: 12.0.3
|
||||
lodash: 4.18.1
|
||||
rxjs: 7.8.2
|
||||
|
||||
'@nestjs/core@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||
'@nestjs/core@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nuxt/opencollective': 0.4.1
|
||||
fast-safe-stringify: 2.1.1
|
||||
iterare: 1.2.1
|
||||
@ -5845,17 +5870,20 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
uid: 2.0.2
|
||||
optionalDependencies:
|
||||
'@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||
'@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||
|
||||
'@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)':
|
||||
'@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
reflect-metadata: 0.2.2
|
||||
optionalDependencies:
|
||||
class-transformer: 0.5.1
|
||||
class-validator: 0.14.4
|
||||
|
||||
'@nestjs/platform-express@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)':
|
||||
'@nestjs/platform-express@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
cors: 2.8.6
|
||||
express: 5.2.1
|
||||
multer: 2.1.1
|
||||
@ -5877,43 +5905,46 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- chokidar
|
||||
|
||||
'@nestjs/swagger@11.4.4(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)':
|
||||
'@nestjs/swagger@11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)':
|
||||
dependencies:
|
||||
'@microsoft/tsdoc': 0.16.0
|
||||
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||
js-yaml: 4.1.1
|
||||
lodash: 4.18.1
|
||||
path-to-regexp: 8.4.2
|
||||
reflect-metadata: 0.2.2
|
||||
swagger-ui-dist: 5.32.6
|
||||
optionalDependencies:
|
||||
class-transformer: 0.5.1
|
||||
class-validator: 0.14.4
|
||||
|
||||
'@nestjs/terminus@11.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))))(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))':
|
||||
'@nestjs/terminus@11.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))))(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
boxen: 5.1.2
|
||||
check-disk-space: 3.4.0
|
||||
reflect-metadata: 0.2.2
|
||||
rxjs: 7.8.2
|
||||
optionalDependencies:
|
||||
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))
|
||||
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))
|
||||
typeorm: 0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))
|
||||
optional: true
|
||||
|
||||
'@nestjs/testing@11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-express@11.1.24)':
|
||||
'@nestjs/testing@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-express@11.1.24)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||
'@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||
|
||||
'@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))':
|
||||
'@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
reflect-metadata: 0.2.2
|
||||
rxjs: 7.8.2
|
||||
typeorm: 0.3.30(ioredis@5.11.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))
|
||||
@ -6173,6 +6204,8 @@ snapshots:
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@types/validator@13.15.10': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 22.19.19
|
||||
@ -6779,6 +6812,14 @@ snapshots:
|
||||
|
||||
cjs-module-lexer@1.4.3: {}
|
||||
|
||||
class-transformer@0.5.1: {}
|
||||
|
||||
class-validator@0.14.4:
|
||||
dependencies:
|
||||
'@types/validator': 13.15.10
|
||||
libphonenumber-js: 1.13.9
|
||||
validator: 13.15.35
|
||||
|
||||
cli-boxes@2.2.1:
|
||||
optional: true
|
||||
|
||||
@ -8276,6 +8317,8 @@ snapshots:
|
||||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
libphonenumber-js@1.13.9: {}
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
load-bmfont@1.4.2:
|
||||
@ -8840,17 +8883,17 @@ snapshots:
|
||||
|
||||
neo-async@2.6.2: {}
|
||||
|
||||
nestjs-minio-client@2.2.0(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24):
|
||||
nestjs-minio-client@2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24):
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
minio: 7.1.3
|
||||
reflect-metadata: 0.1.14
|
||||
rxjs: 7.8.2
|
||||
|
||||
nestjs-pino@4.6.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2):
|
||||
nestjs-pino@4.6.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2):
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
pino: 10.3.1
|
||||
pino-http: 11.0.0
|
||||
rxjs: 7.8.2
|
||||
@ -9977,6 +10020,8 @@ snapshots:
|
||||
'@types/istanbul-lib-coverage': 2.0.6
|
||||
convert-source-map: 2.0.0
|
||||
|
||||
validator@13.15.35: {}
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vfile-location@5.0.3:
|
||||
|
||||
82
sql/network-management-init.sql
Normal file
82
sql/network-management-init.sql
Normal 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`);
|
||||
65
sql/network-management-menu.sql
Normal file
65
sql/network-management-menu.sql
Normal 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;
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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[]> = [
|
||||
|
||||
701
test/admin/network-management/network-agent-mqtt.service.spec.ts
Normal file
701
test/admin/network-management/network-agent-mqtt.service.spec.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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:');
|
||||
});
|
||||
});
|
||||
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
393
test/admin/network-management/network-management.service.spec.ts
Normal file
393
test/admin/network-management/network-management.service.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
184
test/admin/network-management/network-management.types.spec.ts
Normal file
184
test/admin/network-management/network-management.types.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user